Skip to content
Development
Hook

Hooks

What 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
claude-pulse
2473 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.

Stats
247
Stars
14
Forks
Maintained
Maintenance
JavaScript
Language
MIT
License
Last commit
Created

Repo: nikitadoudikov/claude-pulse

@nikitadoudikov
Ships withclaude-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.

Stars on GitHub

Momentum

stars in total
247
Star history of nikitadoudikov/claude-pulse on GitHub, a chart by star-history.com

More plugins that ship hooks.

superpowers

superpowers

A development methodology for coding agents that makes the agent brainstorm before building, follow plans, run parallel work and finish branches in a disciplined way. You get fewer skipped steps and more consistent results from your coding assistant.

15 skills1 hook318k
@obra@obra
Install Plugin
ponytail

ponytail

A Claude Code plugin with skills for auditing a whole repo, reviewing a change, listing deferred shortcut comments, and showing measured savings and a quick command reference.

6 skills10 hooks191k
@dietrichgebert@dietrichgebert
Install Plugin
agent-skills

agent-skills

Walks a software change from a written spec through design, testing, code review, CI/CD, and release, applying the matching engineering skill at each stage. Useful for developers using Claude Code on full development workflows.

25 skills+3117k
@addyosmani@addyosmani
Install Plugin
planning-with-files

planning-with-files

Persistent file-based planning for AI coding agents on long-running, multi-step tasks. It keeps task_plan.md, findings.md and progress.md on disk, so work can resume after /clear or context compaction.

6 skills+228k
@othmanadi@othmanadi
Install Plugin
gsd-core

gsd-core

A spec-driven development workflow for Claude Code that guides a project through planning, phases, milestone audits and fixes. Includes skills to generate tests from UAT criteria and to audit completed work.

72 skills+312k
@open-gsd@open-gsd
Install Plugin
superpowers-zh

superpowers-zh

Chinese-language skill pack for Claude Code that adds workflow guidance for brainstorming, TDD, debugging, and code review, plus Chinese conventions for commits, documentation, and domestic Git platforms like Gitee and Coding.net.

21 skills1 hook8.5k
@jnmetacode@jnmetacode
Install Plugin
socraticode

socraticode

Gives Claude Code semantic search, dependency graph and architecture context over a codebase, so an AI assistant can explore and understand a project's code. Includes skills for indexing a project and managing its file watching.

2 skills+33.4k
@giancarloerra@giancarloerra
Install Plugin
cocoindex-code

cocoindex-code

A lightweight code search engine that uses AST-based semantic search with local embeddings and incremental indexing. It helps Claude Code users find code faster and more efficiently.

1 skill1 MCP+13k
@cocoindex-io@cocoindex-io
Install Plugin

Questions about the hooks in claude-pulse.

Which coding agents can use the hooks in claude-pulse?
The hooks in claude-pulse install in Claude Code and Cursor: each runs Claude Code hooks from .claude/settings.json, so copy the hooks there.