pulse-for-claude-code 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,177 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /*
5
+ * Claude Code "PreToolUse" hook for Pulse: approve tools from the dashboard.
6
+ *
7
+ * Safety first. This hook can pause a tool while it waits for your click, so it
8
+ * is built to NEVER hang Claude:
9
+ * - if Pulse is not running (stale heartbeat) it returns immediately and the
10
+ * normal terminal prompt happens, exactly as without this hook
11
+ * - read only tools are auto allowed so they never wait
12
+ * - standing rules (allow all / per tool) answer instantly
13
+ * - a hard timeout falls back to the normal prompt
14
+ * - any error falls back to the normal prompt
15
+ *
16
+ * Wire it to PreToolUse in ~/.claude/settings.json (see README). Opt in.
17
+ */
18
+
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+ const os = require('os');
22
+ const https = require('https');
23
+ const { spawn } = require('child_process');
24
+
25
+ const DIR = path.join(os.homedir(), '.claude-pulse');
26
+ const PENDING = path.join(DIR, 'pending');
27
+ const DECISIONS = path.join(DIR, 'decisions');
28
+ const ALIVE = path.join(DIR, 'alive');
29
+ const RULES = path.join(DIR, 'rules.json');
30
+ const TOKEN = path.join(DIR, 'token');
31
+ const CONFIG = path.join(os.homedir(), '.claude-pulse.json');
32
+
33
+ const SAFE = ['Read', 'Grep', 'Glob', 'LS', 'NotebookRead', 'TodoWrite', 'WebFetch', 'WebSearch'];
34
+ // How long to wait for your click before falling back to the normal terminal
35
+ // prompt. Short by default so Claude never feels stuck; override with
36
+ // "approvalTimeoutMs" in ~/.claude-pulse.json.
37
+ function timeoutMs() {
38
+ var v = (readJson(CONFIG, {}) || {}).approvalTimeoutMs;
39
+ v = parseInt(v, 10);
40
+ if (!v || v < 5000) return 60 * 1000;
41
+ return Math.min(v, 10 * 60 * 1000);
42
+ }
43
+ const POLL_MS = 300;
44
+ const HEARTBEAT_MAX = 10 * 1000;
45
+
46
+ function passthrough() { process.exit(0); } // no output = normal permission flow
47
+ function decide(decision, reason) {
48
+ process.stdout.write(JSON.stringify({
49
+ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: decision, permissionDecisionReason: reason || 'Pulse' },
50
+ }));
51
+ process.exit(0);
52
+ }
53
+ function sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); }
54
+ function readJson(p, fb) { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch (e) { return fb; } }
55
+ function aliveFresh() { try { return Date.now() - (parseInt(fs.readFileSync(ALIVE, 'utf8'), 10) || 0) < HEARTBEAT_MAX; } catch (e) { return false; } }
56
+ function readStdin() {
57
+ return new Promise(function (r) {
58
+ var d = ''; if (process.stdin.isTTY) return r('');
59
+ process.stdin.setEncoding('utf8');
60
+ process.stdin.on('data', function (c) { d += c; });
61
+ process.stdin.on('end', function () { r(d); });
62
+ setTimeout(function () { r(d); }, 800);
63
+ });
64
+ }
65
+ function summarize(tool, input) {
66
+ if (!input || typeof input !== 'object') return tool;
67
+ var h = input.command || input.file_path || input.path || input.pattern || input.url || input.description || '';
68
+ return String(h).replace(/\s+/g, ' ').trim().slice(0, 200);
69
+ }
70
+ function lanIp() {
71
+ try {
72
+ var ifs = os.networkInterfaces();
73
+ for (var k in ifs) for (var i = 0; i < ifs[k].length; i++) {
74
+ var a = ifs[k][i];
75
+ if (a.family === 'IPv4' && !a.internal) return a.address;
76
+ }
77
+ } catch (e) {}
78
+ return '';
79
+ }
80
+ function pushNtfy(input) {
81
+ var topic = '';
82
+ try { topic = (readJson(CONFIG, {}) || {}).ntfyTopic || ''; } catch (e) {}
83
+ if (!topic) return Promise.resolve();
84
+ var tool = input._tool, summary = input._summary, id = input._id, project = input._project;
85
+ var rt = 'https://ntfy.sh/' + encodeURIComponent(topic + '-reply');
86
+ return new Promise(function (resolve) {
87
+ // the buttons post the answer back through ntfy; Pulse is subscribed to the
88
+ // reply topic, so no LAN, IP or open port is needed. Works anywhere.
89
+ var headers = {
90
+ 'Title': ('Allow ' + tool + (project ? ' in ' + project : '')).replace(/[^\x20-\x7E]/g, ''),
91
+ 'Tags': 'lock', 'Priority': 'high',
92
+ 'Actions': [
93
+ 'http, Allow, ' + rt + ', method=POST, body=allow|once|' + id + ', clear=true',
94
+ 'http, Allow all, ' + rt + ', method=POST, body=allow|all|' + id + ', clear=true',
95
+ 'http, Deny, ' + rt + ', method=POST, body=deny|once|' + id + ', clear=true',
96
+ ].join('; '),
97
+ };
98
+ var data = Buffer.from(String(summary || tool), 'utf8');
99
+ headers['Content-Length'] = data.length;
100
+ var req = https.request({ method: 'POST', hostname: 'ntfy.sh', path: '/' + encodeURIComponent(topic), headers: headers },
101
+ function (res) { res.on('data', function () {}); res.on('end', resolve); });
102
+ req.on('error', resolve); req.write(data); req.end();
103
+ setTimeout(resolve, 2500);
104
+ });
105
+ }
106
+
107
+ function shellQuote(s) { return '"' + String(s).replace(/["\\]/g, '\\$&') + '"'; }
108
+ function desktopNotify(title, body, sound) {
109
+ try {
110
+ if (process.platform === 'darwin') {
111
+ var script = 'display notification ' + shellQuote(body) + ' with title ' + shellQuote(title);
112
+ spawn('osascript', ['-e', script], { stdio: 'ignore', detached: true }).unref();
113
+ // play the sound directly so it is heard even if notification sounds are off
114
+ if (sound) spawn('afplay', ['/System/Library/Sounds/' + sound + '.aiff'], { stdio: 'ignore', detached: true }).unref();
115
+ } else if (process.platform === 'linux') {
116
+ spawn('notify-send', [title, body], { stdio: 'ignore', detached: true }).unref();
117
+ }
118
+ } catch (e) {}
119
+ }
120
+
121
+ (async function () {
122
+ try {
123
+ var raw = await readStdin();
124
+ var input = {}; try { input = JSON.parse(raw); } catch (e) {}
125
+ var tool = input.tool_name || input.toolName || 'Tool';
126
+
127
+ // read only tools never wait
128
+ if (SAFE.indexOf(tool) !== -1) return passthrough();
129
+
130
+ var rules = readJson(RULES, { enabled: false, allowAll: false, allowTools: [], denyTools: [], paused: false });
131
+ // pause works independently of remote approvals: stop further actions on tap
132
+ if (rules.paused) return decide('deny', 'Paused from Pulse - resume on your phone or the dashboard to continue');
133
+ if (!rules.enabled) return passthrough(); // remote approvals are opt-in; off by default
134
+ if (rules.denyTools && rules.denyTools.indexOf(tool) !== -1) return decide('deny', 'Denied by Pulse rule');
135
+ if (rules.allowAll || (rules.allowTools && rules.allowTools.indexOf(tool) !== -1)) return decide('allow', 'Allowed by Pulse rule');
136
+
137
+ // if Pulse is not up, do nothing special: normal terminal prompt
138
+ if (!aliveFresh()) return passthrough();
139
+
140
+ var id = Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
141
+ var cwd = input.cwd || '';
142
+ var project = cwd ? path.basename(cwd) : '';
143
+ var summary = summarize(tool, input.tool_input || input.toolInput);
144
+ try { fs.mkdirSync(PENDING, { recursive: true }); } catch (e) {}
145
+ try {
146
+ fs.writeFileSync(path.join(PENDING, id + '.json'), JSON.stringify({
147
+ id: id, time: Date.now(), sessionId: input.session_id || input.sessionId || null,
148
+ cwd: cwd, project: project, tool: tool, summary: summary,
149
+ }));
150
+ } catch (e) { return passthrough(); }
151
+
152
+ desktopNotify('Claude needs approval' + (project ? ' · ' + project : ''), tool + (summary ? ': ' + summary.slice(0, 120) : ''), 'Funk');
153
+ pushNtfy({ _tool: tool, _summary: summary, _id: id, _project: project });
154
+
155
+ var start = Date.now(), decision = null, deadline = timeoutMs();
156
+ while (Date.now() - start < deadline) {
157
+ decision = readJson(path.join(DECISIONS, id + '.json'), null);
158
+ if (decision) break;
159
+ // honor a standing rule set WHILE we wait: tapping "Allow all" (or a per
160
+ // tool rule) on ONE card must release every other waiting request too, not
161
+ // only the one you clicked. without this, parallel tool calls hang here
162
+ // until they time out, which looked like "Allow all sometimes does nothing".
163
+ var live = readJson(RULES, { allowAll: false, allowTools: [], denyTools: [] });
164
+ if (live.denyTools && live.denyTools.indexOf(tool) !== -1) { decision = { decision: 'deny' }; break; }
165
+ if (live.allowAll || (live.allowTools && live.allowTools.indexOf(tool) !== -1)) { decision = { decision: 'allow' }; break; }
166
+ await sleep(POLL_MS);
167
+ }
168
+ try { fs.unlinkSync(path.join(PENDING, id + '.json')); } catch (e) {}
169
+ try { fs.unlinkSync(path.join(DECISIONS, id + '.json')); } catch (e) {}
170
+
171
+ if (!decision) return passthrough(); // timed out, normal prompt
172
+ if (decision.decision === 'deny') return decide('deny', 'Denied in Pulse');
173
+ return decide('allow', 'Approved in Pulse');
174
+ } catch (e) {
175
+ return passthrough(); // never block on a bug
176
+ }
177
+ })();
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /*
5
+ * Claude Code "Stop" hook for Pulse.
6
+ *
7
+ * Fires when Claude finishes a turn (it is now your turn). Sends a phone push
8
+ * via ntfy.sh so you know to come back, debounced so a rapid back-and-forth
9
+ * does not spam you. Requires "ntfyTopic" in ~/.claude-pulse.json.
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const os = require('os');
15
+ const https = require('https');
16
+ const { spawn } = require('child_process');
17
+
18
+ const RUNTIME = path.join(os.homedir(), '.claude-pulse');
19
+ const LAST = path.join(RUNTIME, 'last-stop-push');
20
+ const COOLDOWN = 30 * 1000;
21
+
22
+ function readStdin() {
23
+ return new Promise(function (r) {
24
+ var d = '';
25
+ if (process.stdin.isTTY) return r('');
26
+ process.stdin.setEncoding('utf8');
27
+ process.stdin.on('data', function (c) { d += c; });
28
+ process.stdin.on('end', function () { r(d); });
29
+ setTimeout(function () { r(d); }, 500);
30
+ });
31
+ }
32
+ function topic() {
33
+ try { return JSON.parse(fs.readFileSync(path.join(os.homedir(), '.claude-pulse.json'), 'utf8')).ntfyTopic || ''; }
34
+ catch (e) { return ''; }
35
+ }
36
+ function push(t, title, msg, tags) {
37
+ if (!t) return Promise.resolve();
38
+ return new Promise(function (res) {
39
+ var data = Buffer.from(msg || '', 'utf8');
40
+ var req = https.request({
41
+ method: 'POST', hostname: 'ntfy.sh', path: '/' + encodeURIComponent(t),
42
+ headers: {
43
+ 'Content-Type': 'text/plain; charset=utf-8',
44
+ 'Content-Length': data.length,
45
+ 'Title': String(title || 'Claude Code').replace(/[^\x20-\x7E]/g, ''),
46
+ 'Tags': tags || 'white_check_mark',
47
+ 'Priority': 'default',
48
+ },
49
+ }, function (r) { r.on('data', function () {}); r.on('end', res); });
50
+ req.on('error', res);
51
+ req.write(data); req.end();
52
+ setTimeout(res, 2500);
53
+ });
54
+ }
55
+
56
+ function shellQuote(s) { return '"' + String(s).replace(/["\\]/g, '\\$&') + '"'; }
57
+ function desktopNotify(title, body, sound) {
58
+ try {
59
+ if (process.platform === 'darwin') {
60
+ var script = 'display notification ' + shellQuote(body) + ' with title ' + shellQuote(title);
61
+ spawn('osascript', ['-e', script], { stdio: 'ignore', detached: true }).unref();
62
+ if (sound) spawn('afplay', ['/System/Library/Sounds/' + sound + '.aiff'], { stdio: 'ignore', detached: true }).unref();
63
+ } else if (process.platform === 'linux') {
64
+ spawn('notify-send', [title, body], { stdio: 'ignore', detached: true }).unref();
65
+ }
66
+ } catch (e) {}
67
+ }
68
+
69
+ (async function () {
70
+ const raw = await readStdin();
71
+ let input = {}; try { input = JSON.parse(raw); } catch (e) {}
72
+
73
+ // debounce so a rapid back-and-forth does not spam you
74
+ try { const last = parseInt(fs.readFileSync(LAST, 'utf8'), 10) || 0; if (Date.now() - last < COOLDOWN) return process.exit(0); } catch (e) {}
75
+ try { fs.mkdirSync(RUNTIME, { recursive: true }); fs.writeFileSync(LAST, String(Date.now())); } catch (e) {}
76
+
77
+ const project = input.cwd ? path.basename(input.cwd) : '';
78
+ // desktop banner always; phone push only if an ntfy topic is set
79
+ desktopNotify('Claude finished' + (project ? ' · ' + project : ''), 'Your turn', 'Glass');
80
+ const t = topic();
81
+ if (t) await push(t, 'Claude finished' + (project ? ' (' + project + ')' : ''), 'Your turn' + (project ? ' in ' + project : ''), 'white_check_mark');
82
+ process.exit(0);
83
+ })();
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "pulse-for-claude-code",
3
+ "version": "0.1.0",
4
+ "description": "A local, zero-dependency dashboard for Claude Code: live token usage, context fill, lost-session recovery, and approving tool calls from your phone.",
5
+ "bin": {
6
+ "claude-pulse": "bin/cli.js",
7
+ "pulse-for-claude-code": "bin/cli.js",
8
+ "claude-pulse-export": "bin/export.js"
9
+ },
10
+ "main": "src/server.js",
11
+ "scripts": {
12
+ "start": "node bin/cli.js"
13
+ },
14
+ "keywords": [
15
+ "claude",
16
+ "claude-code",
17
+ "dashboard",
18
+ "monitor",
19
+ "tokens",
20
+ "usage",
21
+ "cli"
22
+ ],
23
+ "author": "Nikita Vdoudikoff",
24
+ "license": "MIT",
25
+ "engines": {
26
+ "node": ">=18"
27
+ },
28
+ "files": [
29
+ "bin",
30
+ "src",
31
+ "public",
32
+ "hooks",
33
+ "README.md",
34
+ "LICENSE"
35
+ ]
36
+ }