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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nikita Vdoudikoff
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,292 @@
1
+ # Pulse for Claude Code
2
+
3
+ **A local dashboard for [Claude Code](https://claude.com/claude-code) that shows what Claude is doing, what it is spending, and lets you approve its tool calls from your phone.** Zero dependencies, nothing leaves your machine.
4
+
5
+ ![Overview](docs/overview.png)
6
+
7
+ Claude Code already writes every session to disk. Pulse reads those files (read only) and turns them into a live dashboard: token spend by hour, day and week, the context fill of your active session, an ambient view of Claude at work, full-text search across everything you have ever run, and a notification with `Allow` / `Allow all` / `Deny` buttons when Claude needs you, on your desktop or your phone. No account, no telemetry, no network calls.
8
+
9
+ ## Why you might want it
10
+
11
+ - **Approve from your phone.** A push with working `Allow` / `Allow all` / `Deny` buttons. No Wi-Fi setup, no IP, no open port: it works from anywhere, even on cellular.
12
+ - **Never lose a session.** One command recovers your last session as a readable transcript, and Pulse auto-snapshots active ones, so a crash or a frozen laptop never costs you context.
13
+ - **See the spend.** Live tokens and API-equivalent cost by hour, day, week, model and project, against budgets you set, with a phone alert when you cross one.
14
+ - **Ambient office.** A full-screen view of a little mascot working, resting, or waiting on you, with a rough ETA. Quietly addictive on a second monitor.
15
+ - **Search everything.** Full-text search across every session on disk, one click to the transcript.
16
+ - **Local and private.** Reads `~/.claude` read only, serves on `127.0.0.1`, zero dependencies, no telemetry.
17
+
18
+ | Ambient office view | Approve from the dashboard or your phone |
19
+ | --- | --- |
20
+ | ![Office](docs/office.png) | ![Approve](docs/approve.png) |
21
+
22
+ ## Quick start
23
+
24
+ Requires Node 18+. Run it with no install:
25
+
26
+ ```bash
27
+ npx pulse-for-claude-code
28
+ ```
29
+
30
+ Or clone it:
31
+
32
+ ```bash
33
+ git clone https://github.com/nikitadoudikov/claude-pulse.git
34
+ cd claude-pulse
35
+ node bin/cli.js
36
+ ```
37
+
38
+ Either way it opens `http://127.0.0.1:4317`. To get desktop and phone
39
+ notifications and to approve tool calls, wire the hooks (one command, safe to
40
+ re-run):
41
+
42
+ ```bash
43
+ claude-pulse install-hooks # adds the hooks to ~/.claude/settings.json
44
+ ```
45
+
46
+ Then restart Claude Code, and you are set. Other options:
47
+
48
+ ```
49
+ claude-pulse --port 4317 # change the port
50
+ claude-pulse --no-open # do not open the browser
51
+ ```
52
+
53
+ ## Keep it running
54
+
55
+ Run in the foreground and Pulse dies when you close that terminal. To keep it
56
+ alive independently, run it in the background:
57
+
58
+ ```bash
59
+ claude-pulse start # run detached, survives closing the terminal
60
+ claude-pulse status # is it running?
61
+ claude-pulse stop # stop it
62
+ claude-pulse restart # stop and start again
63
+ ```
64
+
65
+ If your terminal crashes, `claude-pulse start` brings it back in one command,
66
+ and a background instance is not affected by the crash in the first place.
67
+
68
+ On macOS you can hand Pulse to the system so it starts at login and respawns
69
+ itself if it ever dies:
70
+
71
+ ```bash
72
+ claude-pulse install-service # start at login, auto-restart
73
+ claude-pulse uninstall-service # remove it
74
+ ```
75
+
76
+ ## Recover a lost session
77
+
78
+ Terminal crashed, laptop froze, hit a session limit? Nothing is lost: Claude
79
+ Code writes every session to disk as it happens. One command brings the last one
80
+ back, prints a recap and saves a readable transcript:
81
+
82
+ ```bash
83
+ claude-pulse recover # the most recent session
84
+ claude-pulse recover 2 # the one before that
85
+ claude-pulse recover <id> # a specific session
86
+ ```
87
+
88
+ It saves a light markdown file under `~/.claude-pulse/exports/` (a 15 MB log
89
+ becomes a ~180 KB file) and prints a link to read the full transcript in the
90
+ browser or on your phone. You can also open any session in the dashboard and use
91
+ **open transcript** / **download .md**.
92
+
93
+ While Pulse runs it also **auto-snapshots** every recently active session to
94
+ `~/.claude-pulse/exports/snapshots/` (one file per session, rewritten only when
95
+ it changes). So the latest state is always on disk even if you never run
96
+ `recover`. Set `snapshotMinutes` to `0` in `~/.claude-pulse.json` to turn it off.
97
+
98
+ To back up everything at once, `claude-pulse export-all` writes every session
99
+ into a single small gzipped markdown file, or use **download all history** on the
100
+ Sessions screen.
101
+
102
+ ## Search every session
103
+
104
+ Lost where you did something? The **Sessions** screen has a search box that
105
+ scans every session on disk for a word or phrase and jumps you straight to the
106
+ transcript. It works from your phone too.
107
+
108
+ ## On your phone
109
+
110
+ The simplest phone control is the ntfy notification itself: it carries working
111
+ `Allow` / `Allow all` / `Deny` buttons (see above), no network setup at all.
112
+
113
+ For a richer view, open `http://<your-machine>:4317/phone` on the same Wi-Fi
114
+ (needs `bindLan: true`) to see what Claude is doing right now plus a **Pause /
115
+ Resume** button. Pausing stops Claude from running further tools until you
116
+ resume. Both need the `PreToolUse` hook wired.
117
+
118
+ ## How it works
119
+
120
+ ```
121
+ ┌──────────────┐ writes .jsonl ┌──────────────────────┐ SSE ┌──────────────┐
122
+ │ Claude Code │ ─────────────────▶ │ Pulse (read only) │ ───────▶ │ dashboard │
123
+ │ (terminal) │ │ 127.0.0.1:4317 │ │ + phone │
124
+ └──────┬───────┘ └──────────────────────┘ └──────────────┘
125
+
126
+ │ hooks: Notification · Stop · PreToolUse
127
+
128
+ ┌─────────────────────────────┐
129
+ │ ~/.claude-pulse/ │ pending approvals · decisions · events
130
+ └─────────────────────────────┘
131
+ ```
132
+
133
+ Claude Code logs every session as JSONL under `~/.claude/projects/`. Each assistant
134
+ message carries a `usage` block (input, output and cache tokens) with a timestamp.
135
+ Pulse reads those files (read only), caches each file by modification time so
136
+ unchanged sessions are never re-parsed, and aggregates the numbers. The browser
137
+ gets live updates over Server-Sent Events. Three small hooks let Claude Code tell
138
+ Pulse when it needs you, when a turn ends, and when it wants to run a tool.
139
+
140
+ ## Notifications when Claude needs you
141
+
142
+ Claude Code can run a hook when it needs your attention. Point its `Notification`
143
+ event at the bundled script and Pulse will show a banner and fire a desktop
144
+ notification, even if the tab is in the background.
145
+
146
+ The easy way is one command:
147
+
148
+ ```bash
149
+ claude-pulse install-hooks # wires the hooks into ~/.claude/settings.json (safe to re-run)
150
+ claude-pulse uninstall-hooks # removes them
151
+ ```
152
+
153
+ It backs up your settings once, merges next to any hooks you already have, and
154
+ never adds a duplicate. Restart Claude Code afterwards. To do it by hand instead,
155
+ add this to `~/.claude/settings.json` (use the absolute path to your clone):
156
+
157
+ ```json
158
+ {
159
+ "hooks": {
160
+ "Notification": [
161
+ {
162
+ "matcher": "",
163
+ "hooks": [
164
+ { "type": "command", "command": "node /absolute/path/to/claude-pulse/hooks/notify-hook.js" }
165
+ ]
166
+ }
167
+ ],
168
+ "Stop": [
169
+ {
170
+ "matcher": "",
171
+ "hooks": [
172
+ { "type": "command", "command": "node /absolute/path/to/claude-pulse/hooks/stop-hook.js" }
173
+ ]
174
+ }
175
+ ],
176
+ "PreToolUse": [
177
+ {
178
+ "matcher": "",
179
+ "hooks": [
180
+ { "type": "command", "command": "node /absolute/path/to/claude-pulse/hooks/permission-hook.js" }
181
+ ]
182
+ }
183
+ ]
184
+ }
185
+ }
186
+ ```
187
+
188
+ Keep `claude-pulse` running and you are set.
189
+
190
+ ## Approve tools from the dashboard (and your phone)
191
+
192
+ With the `PreToolUse` hook wired, when Claude wants to run something that needs
193
+ permission, an approval card appears in Pulse with `Allow`, `Allow all` and
194
+ `Deny`. `Allow all` stops asking for the rest of the run.
195
+
196
+ This is built to never hang Claude. Read only tools pass straight through, and if
197
+ Pulse is not running, has not heard from you within the approval timeout (60s by
198
+ default, set `approvalTimeoutMs`), or hits any error, it falls back to the normal
199
+ terminal prompt. Nothing breaks if you ignore it. The phone push carries `Allow`,
200
+ `Allow all` and `Deny` buttons.
201
+
202
+ To approve from your phone, you only need an `ntfyTopic` (below) and the ntfy
203
+ app. The push notification carries `Allow`, `Allow all` and `Deny` buttons, and
204
+ tapping one sends the answer back through ntfy to a private reply topic that
205
+ Pulse listens on. No same Wi-Fi, no IP, no open port: it works from anywhere,
206
+ even on cellular. Pulse only acts on a reply while it is actually waiting for
207
+ that request, so a stale notification can do nothing.
208
+
209
+ ```
210
+ Claude wants to run a tool
211
+
212
+
213
+ PreToolUse hook ──▶ Pulse ──push──▶ phone notification
214
+ │ │
215
+ │ tap "Allow"
216
+ │ │
217
+ │ answer returns over ntfy
218
+ │ │
219
+ ▼ ▼
220
+ hook is still waiting ◀── decision ◀── Pulse (subscribed to the reply topic)
221
+
222
+
223
+ hook returns "allow" ──▶ Claude runs the tool
224
+ ```
225
+
226
+ ## Phone push (optional)
227
+
228
+ To get a push on your phone when Claude needs you or finishes, pick a hard to
229
+ guess topic name, install the free [ntfy](https://ntfy.sh) app and subscribe to
230
+ that topic, then set it in `~/.claude-pulse.json`:
231
+
232
+ ```json
233
+ { "ntfyTopic": "claude-pulse-9f3a7c" }
234
+ ```
235
+
236
+ With the hooks above wired, the `Notification` hook pushes when Claude is waiting
237
+ for you, and the `Stop` hook pushes when a turn finishes (debounced to 30s so a
238
+ back and forth does not spam you). Anyone who knows the topic can read it, so use
239
+ a random name.
240
+
241
+ If you set `budgets` (below), Pulse also pushes when a rolling window crosses 80%
242
+ then 100% of its budget, so you find out from your pocket, not by checking.
243
+
244
+ ## Configuration
245
+
246
+ Copy `config.example.json` to `~/.claude-pulse.json` and edit. Every field is optional.
247
+
248
+ ```json
249
+ {
250
+ "plan": "max20",
251
+ "contextLimit": 200000,
252
+ "idleMinutes": 10,
253
+ "approvalTimeoutMs": 60000,
254
+ "budgets": { "fiveHour": 140, "day": 360, "week": 1100 }
255
+ }
256
+ ```
257
+
258
+ ### About limits
259
+
260
+ Anthropic does not publish exact subscription limits, and they are usage based
261
+ rather than a fixed token count. Pulse cannot read your real plan ceiling, so the
262
+ budgets above are rough API-equivalent estimates you adjust to match what you
263
+ observe. The `pro`, `max5` and `max20` presets are starting points, not official
264
+ numbers. Token cost is estimated from public API list prices purely as a usage
265
+ proxy; subscription users do not pay per token.
266
+
267
+ ## Security and privacy
268
+
269
+ Pulse is local-first and opt-in. Out of the box it binds to `127.0.0.1` only,
270
+ makes no outbound calls, has zero dependencies (no supply chain), and reads
271
+ `~/.claude` read only. Nothing leaves your machine and there is no analytics. Two
272
+ optional features change that, and both are off until you turn them on:
273
+
274
+ - **Phone push (`ntfyTopic`)** routes through the public
275
+ [ntfy.sh](https://ntfy.sh) relay. Approval prompts (with a short command
276
+ summary) and your taps pass through a topic you name, so anyone who learns the
277
+ topic can read those prompts and answer them. Use a long random topic, and
278
+ self-host ntfy or use ntfy access tokens if you want stronger guarantees. Pulse
279
+ only acts on a reply while it is genuinely waiting for that exact request, so a
280
+ stale or guessed message cannot approve anything by itself.
281
+ - **LAN access (`bindLan`)** binds the server to your whole network so a phone on
282
+ the same Wi-Fi can open the live `/phone` page. While it is on, other devices
283
+ on that network can also read the dashboard and your transcripts, so only
284
+ enable it on a network you trust. You do not need it for phone approvals (those
285
+ go through ntfy), so most people should leave it off.
286
+
287
+ Runtime state, the device token and your config live in your home directory under
288
+ `~/.claude-pulse/`, and are never committed or sent anywhere.
289
+
290
+ ## License
291
+
292
+ MIT
package/bin/cli.js ADDED
@@ -0,0 +1,169 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const os = require('os');
5
+ const { spawn } = require('child_process');
6
+ const { start } = require('../src/server');
7
+
8
+ const COMMANDS = new Set(['run', 'start', 'stop', 'restart', 'status', 'recover', 'export-all',
9
+ 'install-hooks', 'uninstall-hooks', 'install-service', 'uninstall-service']);
10
+
11
+ function lanIp() {
12
+ try {
13
+ const ifs = os.networkInterfaces();
14
+ for (const k in ifs) for (const a of ifs[k]) if (a.family === 'IPv4' && !a.internal) return a.address;
15
+ } catch (e) {}
16
+ return '';
17
+ }
18
+
19
+ function parseArgs(argv) {
20
+ const out = { port: 4317, open: true };
21
+ for (let i = 0; i < argv.length; i++) {
22
+ const a = argv[i];
23
+ if (a === '--port' || a === '-p') out.port = parseInt(argv[++i], 10) || out.port;
24
+ else if (a === '--no-open') out.open = false;
25
+ else if (a === '--help' || a === '-h') out.help = true;
26
+ else if (a === '--version' || a === '-v') out.version = true;
27
+ }
28
+ return out;
29
+ }
30
+
31
+ function openBrowser(url) {
32
+ const platform = process.platform;
33
+ const cmd = platform === 'darwin' ? 'open' : platform === 'win32' ? 'start' : 'xdg-open';
34
+ try {
35
+ const child = spawn(cmd, [url], { stdio: 'ignore', detached: true, shell: platform === 'win32' });
36
+ child.unref();
37
+ } catch (e) {}
38
+ }
39
+
40
+ function printHelp() {
41
+ console.log(`claude-pulse - local dashboard for Claude Code
42
+
43
+ Usage: claude-pulse [command] [options]
44
+
45
+ Commands:
46
+ (none) / run run in the foreground (ctrl+c to stop)
47
+ start run in the background, survives closing the terminal
48
+ stop stop the background instance
49
+ restart restart the background instance
50
+ status show whether Pulse is running
51
+ recover [n|id] show + save the last session (lost it after a crash? run this)
52
+ export-all save every session as one small gzipped markdown file
53
+ install-hooks wire the Pulse hooks into ~/.claude/settings.json
54
+ uninstall-hooks remove the Pulse hooks from ~/.claude/settings.json
55
+ install-service macOS: start at login and auto-restart if it dies
56
+ uninstall-service macOS: remove the login service
57
+
58
+ Options:
59
+ -p, --port <n> port to listen on (default 4317)
60
+ --no-open do not open the browser automatically
61
+ -h, --help show this help
62
+ -v, --version show version
63
+ `);
64
+ }
65
+
66
+ function recover(rest) {
67
+ const t = require('../src/transcript');
68
+ const daemon = require('../src/daemon');
69
+ const positional = rest.find((a) => !a.startsWith('-'));
70
+ const sessions = t.listSessions();
71
+ if (!sessions.length) { console.log('no sessions found under ~/.claude/projects'); return; }
72
+
73
+ let s;
74
+ if (positional && /^\d+$/.test(positional)) s = sessions[parseInt(positional, 10) - 1];
75
+ else if (positional) s = sessions.find((x) => x.sid.startsWith(positional));
76
+ else s = sessions[0];
77
+ if (!s) { console.log(`no session for "${positional}". recent ones:`); sessions.slice(0, 6).forEach((x, i) => console.log(` ${i + 1}. ${x.sid.slice(0, 8)} ${new Date(x.mtimeMs).toISOString().slice(0, 16).replace('T', ' ')}`)); return; }
78
+
79
+ console.log('');
80
+ console.log(t.recapText(s.file, 8));
81
+ const r = t.saveExport(s, {});
82
+ const running = daemon.running();
83
+ const port = running ? running.port : 4317;
84
+ console.log('');
85
+ console.log(`full transcript saved: ${r.path}`);
86
+ console.log(`read it in the browser or on your phone: http://127.0.0.1:${port}/transcript?sid=${s.sid}`);
87
+ if (sessions.length > 1) console.log(`a different one? claude-pulse recover 2 (or an id)`);
88
+ }
89
+
90
+ function installHooks() {
91
+ const r = require('../src/hooksetup').installHooks();
92
+ if (r.added) console.log(`wired ${r.added} hook(s) into ~/.claude/settings.json`);
93
+ if (r.already) console.log(`${r.already} hook(s) were already set`);
94
+ console.log('restart Claude Code (or open a new session) for the hooks to take effect');
95
+ }
96
+
97
+ function uninstallHooks() {
98
+ const r = require('../src/hooksetup').uninstallHooks();
99
+ console.log(r.removed ? `removed Pulse hooks from ${r.removed} event(s)` : 'no Pulse hooks were set');
100
+ }
101
+
102
+ function exportAll(rest) {
103
+ const t = require('../src/transcript');
104
+ const zlib = require('zlib');
105
+ const fs = require('fs'), os = require('os'), path = require('path');
106
+ const md = t.combinedMarkdown({ full: rest.indexOf('--full') !== -1 });
107
+ const dir = path.join(os.homedir(), '.claude-pulse', 'exports');
108
+ try { fs.mkdirSync(dir, { recursive: true }); } catch (e) {}
109
+ const dest = path.join(dir, 'history-' + new Date().toISOString().slice(0, 10) + '.md.gz');
110
+ fs.writeFileSync(dest, zlib.gzipSync(md));
111
+ console.log('exported every session to one file:');
112
+ console.log(` ${dest}`);
113
+ console.log(` ${(Buffer.byteLength(md) / 1048576).toFixed(1)} MB markdown -> ${(fs.statSync(dest).size / 1024).toFixed(0)} KB gzipped`);
114
+ }
115
+
116
+ async function runForeground(args) {
117
+ try {
118
+ const { port } = await start({ port: args.port });
119
+ const url = `http://127.0.0.1:${port}`;
120
+ console.log(`\n Pulse for Claude Code`);
121
+ console.log(` running at ${url}`);
122
+ console.log(` reading ~/.claude/projects (read only)`);
123
+ const cfg = require('../src/config').loadConfig();
124
+ if (cfg.bindLan) {
125
+ const ip = lanIp();
126
+ if (ip) console.log(` on your network: http://${ip}:${port}`);
127
+ }
128
+ console.log(cfg.ntfyTopic
129
+ ? ` phone push: ntfy topic "${cfg.ntfyTopic}"`
130
+ : ` phone push: set "ntfyTopic" in ~/.claude-pulse.json`);
131
+ console.log(`\n press ctrl+c to stop\n`);
132
+ if (args.open) openBrowser(url);
133
+ } catch (e) {
134
+ if (e && e.code === 'EADDRINUSE') {
135
+ console.error(`\n port ${args.port} is busy. is Pulse already running? try: claude-pulse status`);
136
+ console.error(` or pick another port: claude-pulse --port ${args.port + 1}\n`);
137
+ } else {
138
+ console.error(' failed to start:', e && e.message);
139
+ }
140
+ process.exit(1);
141
+ }
142
+ }
143
+
144
+ async function main() {
145
+ const argv = process.argv.slice(2);
146
+ const cmd = argv[0] && !argv[0].startsWith('-') && COMMANDS.has(argv[0]) ? argv[0] : null;
147
+ const args = parseArgs(cmd ? argv.slice(1) : argv);
148
+
149
+ if (args.help) { printHelp(); return; }
150
+ if (args.version) { console.log(require('../package.json').version); return; }
151
+
152
+ if (cmd && cmd !== 'run') {
153
+ const daemon = require('../src/daemon');
154
+ if (cmd === 'start') return daemon.start({ port: args.port });
155
+ if (cmd === 'stop') return daemon.stop();
156
+ if (cmd === 'restart') return daemon.restart({ port: args.port });
157
+ if (cmd === 'status') return daemon.status();
158
+ if (cmd === 'recover') return recover(argv.slice(1));
159
+ if (cmd === 'export-all') return exportAll(argv.slice(1));
160
+ if (cmd === 'install-hooks') return installHooks();
161
+ if (cmd === 'uninstall-hooks') return uninstallHooks();
162
+ if (cmd === 'install-service') return daemon.installService({ port: args.port });
163
+ if (cmd === 'uninstall-service') return daemon.uninstallService();
164
+ }
165
+
166
+ await runForeground(args);
167
+ }
168
+
169
+ main();
package/bin/export.js ADDED
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // Save a Claude Code session to a light markdown file. Works with no server
5
+ // running. See src/transcript.js for the rendering.
6
+
7
+ const fs = require('fs');
8
+ const t = require('../src/transcript');
9
+
10
+ function parseArgs(argv) {
11
+ const out = { full: false, gz: false, list: false, sid: null };
12
+ for (const a of argv) {
13
+ if (a === '--full') out.full = true;
14
+ else if (a === '--gz') out.gz = true;
15
+ else if (a === '--list' || a === '-l') out.list = true;
16
+ else if (a === '--help' || a === '-h') out.help = true;
17
+ else if (!a.startsWith('-')) out.sid = a;
18
+ }
19
+ return out;
20
+ }
21
+
22
+ function main() {
23
+ const args = parseArgs(process.argv.slice(2));
24
+
25
+ if (args.help) {
26
+ console.log(`claude-pulse-export - save a Claude Code session to a light markdown file
27
+
28
+ Usage:
29
+ claude-pulse-export [session] [options]
30
+
31
+ session session id (or prefix), or "latest" (default)
32
+
33
+ Options:
34
+ -l, --list list recent sessions and exit
35
+ --full include full tool inputs (bigger file)
36
+ --gz also write a gzipped copy
37
+ -h, --help show this help
38
+
39
+ Works with no server running. Reads ~/.claude/projects (read only),
40
+ writes to ~/.claude-pulse/exports/.`);
41
+ return;
42
+ }
43
+
44
+ if (args.list) {
45
+ const all = t.listSessions().slice(0, 25);
46
+ if (!all.length) { console.log('no sessions found under ~/.claude/projects'); return; }
47
+ for (const s of all) {
48
+ const mb = (s.size / 1048576).toFixed(1);
49
+ console.log(`${s.sid} ${new Date(s.mtimeMs).toISOString().slice(0, 16).replace('T', ' ')} ${mb} MB`);
50
+ }
51
+ return;
52
+ }
53
+
54
+ const s = t.findSession(args.sid);
55
+ if (!s) { console.error(`no session matching "${args.sid || 'latest'}". try --list`); process.exit(1); }
56
+
57
+ const r = t.saveExport(s, args);
58
+ console.log(`exported ${s.sid}`);
59
+ console.log(` raw log: ${(s.size / 1048576).toFixed(1)} MB -> export: ${(Buffer.byteLength(r.md) / 1024).toFixed(0)} KB`);
60
+ console.log(` ${r.path}`);
61
+ if (r.gz) console.log(` ${r.gz} (${(fs.statSync(r.gz).size / 1024).toFixed(0)} KB)`);
62
+ }
63
+
64
+ main();
@@ -0,0 +1,109 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /*
5
+ * Claude Code "Notification" hook for Pulse.
6
+ *
7
+ * Claude Code runs this and pipes a JSON object on stdin whenever it needs
8
+ * your attention (a permission / Allow prompt, or it has been idle waiting for
9
+ * input). This script does two things:
10
+ * 1. appends the event to ~/.claude-pulse/events.jsonl (the dashboard reads it)
11
+ * 2. fires a native desktop notification so you notice even if the tab is hidden
12
+ *
13
+ * Wire it up in ~/.claude/settings.json (see README), then keep `claude-pulse`
14
+ * running. The script is intentionally tiny and never blocks Claude.
15
+ */
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const os = require('os');
20
+ const https = require('https');
21
+ const { spawn } = require('child_process');
22
+
23
+ const RUNTIME_DIR = path.join(os.homedir(), '.claude-pulse');
24
+ const EVENTS_FILE = path.join(RUNTIME_DIR, 'events.jsonl');
25
+ const MAX_LINES = 200; // keep the events file small
26
+
27
+ function readStdin() {
28
+ return new Promise((resolve) => {
29
+ let data = '';
30
+ if (process.stdin.isTTY) return resolve('');
31
+ process.stdin.setEncoding('utf8');
32
+ process.stdin.on('data', (c) => { data += c; });
33
+ process.stdin.on('end', () => resolve(data));
34
+ setTimeout(() => resolve(data), 500); // never hang
35
+ });
36
+ }
37
+
38
+ function classify(message) {
39
+ const m = String(message || '').toLowerCase();
40
+ if (m.includes('permission') || m.includes('approve') || m.includes('allow')) return 'permission';
41
+ return 'notification';
42
+ }
43
+
44
+ function appendEvent(ev) {
45
+ try { fs.mkdirSync(RUNTIME_DIR, { recursive: true }); } catch (e) {}
46
+ let lines = [];
47
+ try { lines = fs.readFileSync(EVENTS_FILE, 'utf8').split('\n').filter(Boolean); } catch (e) {}
48
+ lines.push(JSON.stringify(ev));
49
+ if (lines.length > MAX_LINES) lines = lines.slice(lines.length - MAX_LINES);
50
+ try { fs.writeFileSync(EVENTS_FILE, lines.join('\n') + '\n'); } catch (e) {}
51
+ }
52
+
53
+ function desktopNotify(title, body) {
54
+ try {
55
+ if (process.platform === 'darwin') {
56
+ const script = 'display notification ' + q(body) + ' with title ' + q(title) + ' sound name "Ping"';
57
+ spawn('osascript', ['-e', script], { stdio: 'ignore', detached: true }).unref();
58
+ } else if (process.platform === 'linux') {
59
+ spawn('notify-send', [title, body], { stdio: 'ignore', detached: true }).unref();
60
+ }
61
+ } catch (e) {}
62
+ }
63
+ function q(s) { return '"' + String(s).replace(/["\\]/g, '\\$&') + '"'; }
64
+
65
+ function readNtfyTopic() {
66
+ try { return JSON.parse(fs.readFileSync(path.join(os.homedir(), '.claude-pulse.json'), 'utf8')).ntfyTopic || ''; }
67
+ catch (e) { return ''; }
68
+ }
69
+ function pushNtfy(topic, title, message, tags) {
70
+ if (!topic) return Promise.resolve();
71
+ return new Promise(function (resolve) {
72
+ var data = Buffer.from(message || '', 'utf8');
73
+ var req = https.request({
74
+ method: 'POST', hostname: 'ntfy.sh', path: '/' + encodeURIComponent(topic),
75
+ headers: {
76
+ 'Content-Type': 'text/plain; charset=utf-8',
77
+ 'Content-Length': data.length,
78
+ 'Title': String(title || 'Claude Code').replace(/[^\x20-\x7E]/g, ''),
79
+ 'Tags': tags || 'warning',
80
+ 'Priority': 'high',
81
+ },
82
+ }, function (res) { res.on('data', function () {}); res.on('end', resolve); });
83
+ req.on('error', resolve);
84
+ req.write(data); req.end();
85
+ setTimeout(resolve, 2500);
86
+ });
87
+ }
88
+
89
+ (async function main() {
90
+ const raw = await readStdin();
91
+ let input = {};
92
+ try { input = JSON.parse(raw); } catch (e) {}
93
+
94
+ const message = input.message || input.notification || 'Claude needs your attention';
95
+ const ev = {
96
+ time: Date.now(),
97
+ type: classify(message),
98
+ sessionId: input.session_id || input.sessionId || null,
99
+ cwd: input.cwd || null,
100
+ message: message,
101
+ };
102
+
103
+ appendEvent(ev);
104
+ const project = ev.cwd ? path.basename(ev.cwd) : '';
105
+ desktopNotify('Claude Code' + (project ? ' · ' + project : ''), message);
106
+ await pushNtfy(readNtfyTopic(), 'Claude needs you' + (project ? ' (' + project + ')' : ''), message, 'warning');
107
+
108
+ process.exit(0);
109
+ })();