Development
Hook
Hooks
What nikitadoudikov-claude-pulse runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.
Where it lives
- hooks/notify-hook.jsGitHub
Read the script
#!/usr/bin/env node 'use strict'; /* * Claude Code "Notification" hook for Pulse. * * Claude Code runs this and pipes a JSON object on stdin whenever it needs * your attention (a permission / Allow prompt, or it has been idle waiting for * input). This script does two things: * 1. appends the event to ~/.claude-pulse/events.jsonl (the dashboard reads it) * 2. fires a native desktop notification so you notice even if the tab is hidden * * Wire it up in ~/.claude/settings.json (see README), then keep `claude-pulse` * running. The script is intentionally tiny and never blocks Claude. */ const fs = require('fs'); const path = require('path'); const os = require('os'); const https = require('https'); const { spawn } = require('child_process'); const RUNTIME_DIR = path.join(os.homedir(), '.claude-pulse'); const EVENTS_FILE = path.join(RUNTIME_DIR, 'events.jsonl'); const MAX_LINES = 200; // keep the events file small function readStdin() { return new Promise((resolve) => { let data = ''; if (process.stdin.isTTY) return resolve(''); process.stdin.setEncoding('utf8'); process.stdin.on('data', (c) => { data += c; }); process.stdin.on('end', () => resolve(data)); setTimeout(() => resolve(data), 500); // never hang }); } function classify(message) { const m = String(message || '').toLowerCase(); if (m.includes('permission') || m.includes('approve') || m.includes('allow')) return 'permission'; return 'notification'; } function appendEvent(ev) { try { fs.mkdirSync(RUNTIME_DIR, { recursive: true }); } catch (e) {} let lines = []; try { lines = fs.readFileSync(EVENTS_FILE, 'utf8').split('\n').filter(Boolean); } catch (e) {} lines.push(JSON.stringify(ev)); if (lines.length > MAX_LINES) lines = lines.slice(lines.length - MAX_LINES); try { fs.writeFileSync(EVENTS_FILE, lines.join('\n') + '\n'); } catch (e) {} } function desktopNotify(title, body) { try { if (process.platform === 'darwin') { const script = 'display notification ' + q(body) + ' with title ' + q(title) + ' sound name "Ping"'; spawn('osascript', ['-e', script], { stdio: 'ignore', detached: true }).unref(); } else if (process.platform === 'linux') { spawn('notify-send', [title, body], { stdio: 'ignore', detached: true }).unref(); } } catch (e) {} } function q(s) { return '"' + String(s).replace(/["\\]/g, '\\$&') + '"'; } // 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 = {}; try { cfg = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.claude-pulse.json'), 'utf8')) || {}; } catch (e) {} 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 || ''), mod: u.protocol === 'http:' ? require('http') : https, hostname: u.hostname, port: u.port ? parseInt(u.port, 10) : (u.protocol === 'http:' ? 80 : 443), pushNotification: cfg.ntfyPushNotification !== false, }; } function pushNtfy(n, title, message, tags) { if (!n.topic || !n.pushNotification) return Promise.resolve(); return new Promise(function (resolve) { var data = Buffer.from(message || '', 'utf8'); var headers = { 'Content-Type': 'text/plain; charset=utf-8', 'Content-Length': data.length, 'Title': String(title || 'Claude Code').replace(/[^\x20-\x7E]/g, ''), 'Tags': tags || 'warning', 'Priority': 'high', }; if (n.token) headers.Authorization = 'Bearer ' + n.token; var req = n.mod.request({ method: 'POST', hostname: n.hostname, port: n.port, path: '/' + encodeURIComponent(n.topic), headers: headers, }, function (res) { res.on('data', function () {}); res.on('end', resolve); }); req.on('error', resolve); req.write(data); req.end(); setTimeout(resolve, 2500); }); } (async function main() { const raw = await readStdin(); let input = {}; try { input = JSON.parse(raw); } catch (e) {} const message = input.message || input.notification || 'Claude needs your attention'; const ev = { time: Date.now(), type: classify(message), sessionId: input.session_id || input.sessionId || null, cwd: input.cwd || null, message: message, }; appendEvent(ev); const project = ev.cwd ? path.basename(ev.cwd) : ''; var cfg0 = {}; try { cfg0 = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.claude-pulse.json'), 'utf8')) || {}; } catch (e) {} if (cfg0.desktopNotify !== false) desktopNotify('Claude Code' + (project ? ' · ' + project : ''), message); await pushNtfy(ntfySettings(), 'Claude needs you' + (project ? ' (' + project + ')' : ''), message, 'warning'); process.exit(0); })(); - hooks/permission-hook.jsGitHub
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 } - hooks/stop-hook.jsGitHub
Read the script
#!/usr/bin/env node 'use strict'; /* * Claude Code "Stop" hook for Pulse. * * Fires when Claude finishes a turn (it is now your turn). Sends a phone push * via ntfy.sh so you know to come back, debounced so a rapid back-and-forth * does not spam you. Requires "ntfyTopic" in ~/.claude-pulse.json. */ const fs = require('fs'); const path = require('path'); const os = require('os'); const https = require('https'); const { spawn } = require('child_process'); const RUNTIME = path.join(os.homedir(), '.claude-pulse'); const LAST = path.join(RUNTIME, 'last-stop-push'); const COOLDOWN = 30 * 1000; 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); }, 500); }); } // 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 = {}; try { cfg = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.claude-pulse.json'), 'utf8')) || {}; } catch (e) {} 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 || ''), mod: u.protocol === 'http:' ? require('http') : https, hostname: u.hostname, port: u.port ? parseInt(u.port, 10) : (u.protocol === 'http:' ? 80 : 443), pushStop: cfg.ntfyPushStop !== false, }; } function push(n, title, msg, tags) { if (!n.topic || !n.pushStop) return Promise.resolve(); return new Promise(function (res) { var data = Buffer.from(msg || '', 'utf8'); var headers = { 'Content-Type': 'text/plain; charset=utf-8', 'Content-Length': data.length, 'Title': String(title || 'Claude Code').replace(/[^\x20-\x7E]/g, ''), 'Tags': tags || 'white_check_mark', 'Priority': 'default', }; if (n.token) headers.Authorization = 'Bearer ' + n.token; var req = n.mod.request({ method: 'POST', hostname: n.hostname, port: n.port, path: '/' + encodeURIComponent(n.topic), headers: headers, }, function (r) { r.on('data', function () {}); r.on('end', res); }); req.on('error', res); req.write(data); req.end(); setTimeout(res, 2500); }); } function shellQuote(s) { return '"' + String(s).replace(/["\\]/g, '\\$&') + '"'; } function desktopNotify(title, body, sound) { try { if (process.platform === 'darwin') { var script = 'display notification ' + shellQuote(body) + ' with title ' + shellQuote(title); spawn('osascript', ['-e', script], { stdio: 'ignore', detached: true }).unref(); if (sound) { var custom = path.join(os.homedir(), '.claude-pulse', 'sounds', 'done.mp3'); var sfile = fs.existsSync(custom) ? custom : '/System/Library/Sounds/' + sound + '.aiff'; spawn('afplay', [sfile], { stdio: 'ignore', detached: true }).unref(); } } else if (process.platform === 'linux') { spawn('notify-send', [title, body], { stdio: 'ignore', detached: true }).unref(); } } catch (e) {} } (async function () { const raw = await readStdin(); let input = {}; try { input = JSON.parse(raw); } catch (e) {} // debounce so a rapid back-and-forth does not spam you try { const last = parseInt(fs.readFileSync(LAST, 'utf8'), 10) || 0; if (Date.now() - last < COOLDOWN) return process.exit(0); } catch (e) {} try { fs.mkdirSync(RUNTIME, { recursive: true }); fs.writeFileSync(LAST, String(Date.now())); } catch (e) {} const project = input.cwd ? path.basename(input.cwd) : ''; // desktop banner always; phone push only if an ntfy topic is set var cfg0 = {}; try { cfg0 = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.claude-pulse.json'), 'utf8')) || {}; } catch (e) {} if (cfg0.desktopNotify !== false) desktopNotify('Claude finished' + (project ? ' · ' + project : ''), 'Your turn', 'Glass'); await push(ntfySettings(), 'Claude finished' + (project ? ' (' + project + ')' : ''), 'Your turn' + (project ? ' in ' + project : ''), 'white_check_mark'); process.exit(0); })();
Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.
Ships withnikitadoudikov-claude-pulse
Claude Code, with a pulse. A local dashboard that watches every Claude Code (and Codex) session on your machine, gives it a literal heartbeat, and lets you approve its tool calls from your phone or a strip under your MacBook's notch. Zero dependencies.
Get the whole plugin
Stats
244
Stars
12
Forks
Active
Maintenance
JavaScript
Language
MIT
License
22d ago
Last commit
1mo ago
Created
Repo: nikitadoudikov/claude-pulse

