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.
- package/LICENSE +21 -0
- package/README.md +292 -0
- package/bin/cli.js +169 -0
- package/bin/export.js +64 -0
- package/hooks/notify-hook.js +109 -0
- package/hooks/permission-hook.js +177 -0
- package/hooks/stop-hook.js +83 -0
- package/package.json +36 -0
- package/public/app.js +1024 -0
- package/public/assets/ClaudeCourchevel.png +0 -0
- package/public/assets/ClaudeCourchevelWork.png +0 -0
- package/public/assets/ClaudeGarage.png +0 -0
- package/public/assets/ClaudeGarageWork.png +0 -0
- package/public/assets/ClaudeOffice.png +0 -0
- package/public/assets/ClaudeOfficeWork.png +0 -0
- package/public/assets/ClaudeParis.png +0 -0
- package/public/assets/ClaudeParisWork.png +0 -0
- package/public/favicon.svg +5 -0
- package/public/index.html +216 -0
- package/public/manifest.webmanifest +11 -0
- package/public/style.css +577 -0
- package/src/approvals.js +77 -0
- package/src/config.js +83 -0
- package/src/daemon.js +148 -0
- package/src/engine.js +573 -0
- package/src/hooksetup.js +60 -0
- package/src/notify.js +56 -0
- package/src/ntfy.js +82 -0
- package/src/phonepage.js +81 -0
- package/src/search.js +45 -0
- package/src/server.js +322 -0
- package/src/snapshots.js +33 -0
- package/src/transcript.js +206 -0
|
@@ -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
|
+
}
|