Read the script
#!/usr/bin/env node
'use strict';
/*
* Claude Code "PreToolUse" hook for Pulse: approve tools from the dashboard.
*
* Safety first. This hook can pause a tool while it waits for your click, so it
* is built to NEVER hang Claude:
* - if Pulse is not running (stale heartbeat) it returns immediately and the
* normal terminal prompt happens, exactly as without this hook
* - read only tools are auto allowed so they never wait
* - standing rules (allow all / per tool) answer instantly
* - a hard timeout falls back to the normal prompt
* - any error falls back to the normal prompt
*
* Wire it to PreToolUse in ~/.claude/settings.json (see README). Opt in.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const https = require('https');
const { spawn } = require('child_process');
const DIR = path.join(os.homedir(), '.claude-pulse');
const PENDING = path.join(DIR, 'pending');
const DECISIONS = path.join(DIR, 'decisions');
const ALIVE = path.join(DIR, 'alive');
const RULES = path.join(DIR, 'rules.json');
const TOKEN = path.join(DIR, 'token');
const CONFIG = path.join(os.homedir(), '.claude-pulse.json');
const SAFE = ['Read', 'Grep', 'Glob', 'LS', 'NotebookRead', 'TodoWrite', 'WebFetch', 'WebSearch'];
// How long to wait for your click before falling back to the normal terminal
// prompt. Short by default so Claude never feels stuck; override with
// "approvalTimeoutMs" in ~/.claude-pulse.json.
function timeoutMs() {
var v = (readJson(CONFIG, {}) || {}).approvalTimeoutMs;
v = parseInt(v, 10);
if (!v || v < 5000) return 60 * 1000;
return Math.min(v, 10 * 60 * 1000);
}
const POLL_MS = 300;
const HEARTBEAT_MAX = 10 * 1000;
function passthrough() { process.exit(0); } // no output = normal permission flow
function decide(decision, reason) {
process.stdout.write(JSON.stringify({
hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: decision, permissionDecisionReason: reason || 'Pulse' },
}));
process.exit(0);
}
function sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); }
function readJson(p, fb) { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch (e) { return fb; } }
function aliveFresh() { try { return Date.now() - (parseInt(fs.readFileSync(ALIVE, 'utf8'), 10) || 0) < HEARTBEAT_MAX; } catch (e) { return false; } }
function readStdin() {
return new Promise(function (r) {
var d = ''; if (process.stdin.isTTY) return r('');
process.stdin.setEncoding('utf8');
process.stdin.on('data', function (c) { d += c; });
process.stdin.on('end', function () { r(d); });
setTimeout(function () { r(d); }, 800);
});
}
function summarize(tool, input) {
if (!input || typeof input !== 'object') return tool;
var h = input.command || input.file_path || input.path || input.pattern || input.url || input.description || '';
return String(h).replace(/\s+/g, ' ').trim().slice(0, 200);
}
// structured detail for the dashboard card: the full command, the file being
// touched, the edit itself. Truncated hard so a pending file stays tiny.
function details(tool, input) {
if (!input || typeof input !== 'object') return null;
var d = {};
if (input.command != null) d.command = String(input.command).slice(0, 600);
if (input.description != null) d.description = String(input.description).slice(0, 200);
if (input.file_path != null) d.file = String(input.file_path);
else if (input.path != null) d.file = String(input.path);
if (input.url != null) d.url = String(input.url).slice(0, 300);
if (input.old_string != null) d.oldStr = String(input.old_string).slice(0, 400);
if (input.new_string != null) d.newStr = String(input.new_string).slice(0, 400);
if (input.content != null) d.preview = String(input.content).slice(0, 400);
else if (input.prompt != null) d.preview = String(input.prompt).slice(0, 400);
return Object.keys(d).length ? d : null;
}
function lanIp() {
try {
var ifs = os.networkInterfaces();
for (var k in ifs) for (var i = 0; i < ifs[k].length; i++) {
var a = ifs[k][i];
if (a.family === 'IPv4' && !a.internal) return a.address;
}
} catch (e) {}
return '';
}
// ntfy settings from ~/.claude-pulse.json: topic, server (self-hosted or
// ntfy.sh), access token for reserved topics, and the per-hook push toggle.
// A bad ntfyServer URL falls back to ntfy.sh so a typo cannot break the hook.
function ntfySettings() {
var cfg = readJson(CONFIG, {}) || {};
var u;
try { u = new URL(String(cfg.ntfyServer || 'https://ntfy.sh')); }
catch (e) { u = new URL('https://ntfy.sh'); }
return {
topic: cfg.ntfyTopic || '',
token: String(cfg.ntfyToken || ''),
origin: u.origin,
mod: u.protocol === 'http:' ? require('http') : https,
hostname: u.hostname,
port: u.port ? parseInt(u.port, 10) : (u.protocol === 'http:' ? 80 : 443),
pushApproval: cfg.ntfyPushApproval !== false,
};
}
function pushNtfy(input) {
var n = ntfySettings();
if (!n.topic || !n.pushApproval) return Promise.resolve();
var tool = input._tool, summary = input._summary, id = input._id, project = input._project;
var rt = n.origin + '/' + encodeURIComponent(n.topic + '-reply');
return new Promise(function (resolve) {
// the buttons post the answer back through ntfy; Pulse is subscribed to the
// reply topic, so no LAN, IP or open port is needed. Works anywhere.
// JSON Actions format: unambiguous quoting, and lets each button carry an
// Authorization header - the phone app does NOT attach credentials to
// "http" actions on its own, so a reserved reply topic needs the token
// embedded in the action definition.
var acts = [
{ label: 'Allow', body: 'allow|once|' + id },
{ label: 'Allow all', body: 'allow|all|' + id },
{ label: 'Deny', body: 'deny|once|' + id },
].map(function (a) {
var act = { action: 'http', label: a.label, url: rt, method: 'POST', body: a.body, clear: true }