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
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
|
+

|
|
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
|
+
|  |  |
|
|
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
|
+
})();
|