Skip to content
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.

From plugin
nikitadoudikov-claude-pulse
2443 hooks

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