claude-rpc 0.19.1 → 0.20.2
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/README.md +40 -29
- package/SECURITY.md +17 -4
- package/package.json +1 -1
- package/src/cli.js +86 -22
- package/src/daemon.js +129 -46
- package/src/default-config.js +14 -0
- package/src/ensure-daemon.js +127 -0
- package/src/hook.js +16 -0
- package/src/presence.js +24 -0
- package/src/scanner.js +20 -2
- package/src/version.js +1 -1
package/README.md
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
<div align="center">
|
|
2
2
|
|
|
3
|
-
<img src="
|
|
4
|
-
<img src="https://cdn.qualit.ly/clawd-working-typing.gif" width="120" alt="thinking" />
|
|
5
|
-
<img src="https://cdn.qualit.ly/clawd-notification.gif" width="120" alt="notification" />
|
|
6
|
-
<img src="https://cdn.qualit.ly/clawd-sleeping.gif" width="120" alt="idle" />
|
|
3
|
+
<img src="docs/header.svg" width="820" alt="claude-rpc — Discord Rich Presence for Claude Code: live model, project, tokens, and lifetime stats, driven by the hooks Claude Code already fires. Install with: npx claude-rpc setup" />
|
|
7
4
|
|
|
8
|
-
|
|
5
|
+
<br/><br/>
|
|
9
6
|
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
<img src="https://cdn.qualit.ly/clawd-working-building.gif" width="104" alt="working" />
|
|
8
|
+
<img src="https://cdn.qualit.ly/clawd-working-typing.gif" width="104" alt="thinking" />
|
|
9
|
+
<img src="https://cdn.qualit.ly/clawd-notification.gif" width="104" alt="waiting on you" />
|
|
10
|
+
<img src="https://cdn.qualit.ly/clawd-sleeping.gif" width="104" alt="idle" />
|
|
12
11
|
|
|
13
|
-
|
|
12
|
+
<sub>the card's live states — <b>working</b> · <b>thinking</b> · <b>waiting</b> · <b>idle</b></sub>
|
|
13
|
+
|
|
14
|
+
<br/>
|
|
15
|
+
|
|
16
|
+
**Discord Rich Presence for [Claude Code](https://claude.com/claude-code)** — your live model, project, tokens, and lifetime stats, driven by the hooks Claude Code already fires.
|
|
17
|
+
|
|
18
|
+
**[claude-rpc.vercel.app →](https://claude-rpc.vercel.app)** — see it in one page.
|
|
14
19
|
|
|
15
20
|
[](#community-totals) [](#community-totals)
|
|
16
21
|
|
|
@@ -144,7 +149,11 @@ claude-rpc card --range year --out year-on-claude.svg
|
|
|
144
149
|
```
|
|
145
150
|
|
|
146
151
|
<div align="center">
|
|
147
|
-
<img src="site/examples/
|
|
152
|
+
<img src="site/examples/week-on-claude.svg" width="270" alt="Week-on-claude summary card" />
|
|
153
|
+
<img src="site/examples/month-on-claude.svg" width="270" alt="Month-on-claude summary card" />
|
|
154
|
+
<img src="site/examples/year-on-claude.svg" width="270" alt="Year-on-claude summary card — hours, prompts, tokens, lines, cost, daily activity strip" />
|
|
155
|
+
<br/>
|
|
156
|
+
<sub><code>card --range week · month · year</code> (also <code>all</code>) — live at <code>/api/card.svg</code> while the daemon's up</sub>
|
|
148
157
|
</div>
|
|
149
158
|
|
|
150
159
|
`badge --gist` writes the SVG to your own GitHub gist (creates one on first run, updates it after — id remembered in `config.json`). The URL printed back is README-ready and updates every time you re-run the command. Uses `gh` if available, else `GH_TOKEN` with `gist` scope.
|
|
@@ -173,21 +182,9 @@ For a complete account of the sensitive things claude-rpc does — startup persi
|
|
|
173
182
|
|
|
174
183
|
## three pieces, glued by json files
|
|
175
184
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
│ lifecycle event (stdin JSON) │ IPC frame
|
|
180
|
-
▼ │
|
|
181
|
-
┌──────────┐ state.json ┌──────────┐ │
|
|
182
|
-
│ hook.js │ ───────────────▶ │ daemon.js│ ────────────────┘
|
|
183
|
-
└──────────┘ └──────────┘
|
|
184
|
-
▲
|
|
185
|
-
│ aggregate.json
|
|
186
|
-
│
|
|
187
|
-
┌────────────┐
|
|
188
|
-
│ scanner.js │ ◀── ~/.claude/projects/*.jsonl
|
|
189
|
-
└────────────┘
|
|
190
|
-
```
|
|
185
|
+
<div align="center">
|
|
186
|
+
<img src="docs/architecture.svg" width="760" alt="Architecture: Claude Code fires a lifecycle event (stdin JSON) into hook.js, which writes state.json; daemon.js reads it and pushes an IPC frame to the Discord desktop client. scanner.js reads the ~/.claude/projects transcripts and feeds aggregate.json into daemon.js." />
|
|
187
|
+
</div>
|
|
191
188
|
|
|
192
189
|
No database, no message bus, no background polling when Claude Code isn't running. State on disk you can `cat` and `jq`. **Zero runtime dependencies** — even the Discord Rich Presence IPC client is hand-rolled (`src/discord-ipc.js`).
|
|
193
190
|
|
|
@@ -263,6 +260,13 @@ The full default config is in [`src/default-config.js`](src/default-config.js)
|
|
|
263
260
|
|
|
264
261
|
## commands
|
|
265
262
|
|
|
263
|
+
`claude-rpc --help` lists them all — and after `setup` you rarely need any.
|
|
264
|
+
|
|
265
|
+
<details>
|
|
266
|
+
<summary><b>full command reference</b></summary>
|
|
267
|
+
|
|
268
|
+
<br/>
|
|
269
|
+
|
|
266
270
|
| Command | What it does |
|
|
267
271
|
| ---------------- | ------------ |
|
|
268
272
|
| `setup` | Install Claude Code hooks (test-fires one synthetic SessionStart to prove the pipe works) |
|
|
@@ -295,19 +299,28 @@ The full default config is in [`src/default-config.js`](src/default-config.js)
|
|
|
295
299
|
|
|
296
300
|
Exit codes: `0` ok · `1` user error · `2` system error · `3` wrong state. `--version` and `--help` work as expected.
|
|
297
301
|
|
|
302
|
+
</details>
|
|
303
|
+
|
|
298
304
|
## troubleshooting
|
|
299
305
|
|
|
300
|
-
**First step is always `claude-rpc doctor
|
|
306
|
+
**First step is always `claude-rpc doctor`** — it checks Node, hook registration, daemon liveness, Discord IPC, aggregate freshness, and privacy resolution, with a one-line fix hint per failure.
|
|
307
|
+
|
|
308
|
+
<details>
|
|
309
|
+
<summary><b>common issues</b></summary>
|
|
310
|
+
|
|
311
|
+
<br/>
|
|
301
312
|
|
|
302
313
|
- **Discord doesn't show anything.** Discord *desktop* must be running. The browser client doesn't expose the local IPC bridge. `claude-rpc tail` shows what the daemon is actually doing.
|
|
303
314
|
- **Hooks don't fire.** `claude-rpc setup` re-registers them and now test-fires a synthetic `SessionStart` end-to-end, so a broken hook command surfaces immediately. Restart Claude Code afterwards so it re-reads its hook config.
|
|
304
315
|
- **Config error.** Bad JSON in `config.json` no longer crashes anything — the daemon logs one line and falls back to baked defaults. `claude-rpc tail` shows the parse error verbatim.
|
|
305
316
|
- **Old binary path baked into hooks.** Common after manual exe replacement. `claude-rpc setup` rewrites hook entries to point at the canonical install location.
|
|
306
317
|
|
|
318
|
+
</details>
|
|
319
|
+
|
|
307
320
|
## development
|
|
308
321
|
|
|
309
322
|
```sh
|
|
310
|
-
npm test #
|
|
323
|
+
npm test # 430+ tests, ~2s
|
|
311
324
|
npm run lint # eslint over src + test
|
|
312
325
|
npm run start # run daemon in foreground
|
|
313
326
|
npm run serve # web dashboard against your real data
|
|
@@ -315,9 +328,7 @@ npm run dashboard # Electron settings GUI (dev mode)
|
|
|
315
328
|
npm run build:exe # SEA single-file binary for the current OS
|
|
316
329
|
```
|
|
317
330
|
|
|
318
|
-
Tests are `node --test
|
|
319
|
-
|
|
320
|
-
Where the project is headed (and what it will deliberately never do) lives in [`ROADMAP.md`](ROADMAP.md).
|
|
331
|
+
Tests are `node --test`, zero deps; CI ([release.yml](.github/workflows/release.yml)) runs the suite (+ the Worker's) across Node 18/20/22 and gates the matrix build and npm publish. Where the project's headed (and what it'll deliberately never do): [`ROADMAP.md`](ROADMAP.md).
|
|
321
332
|
|
|
322
333
|
## license
|
|
323
334
|
|
package/SECURITY.md
CHANGED
|
@@ -16,10 +16,10 @@ fetch-and-execute anywhere in `src/`.
|
|
|
16
16
|
|
|
17
17
|
| Behavior | Where | Scope | Reversible? |
|
|
18
18
|
| --- | --- | --- | --- |
|
|
19
|
-
| Startup persistence | `src/install.js` → `addStartupEntry` | `HKCU` Run key
|
|
19
|
+
| Startup persistence | `src/install.js` → `addStartupEntry` (Windows Run key); `src/hook.js` → `ensureDaemonRunning` (SessionStart self-heal, all platforms) | `HKCU` Run key (no admin) + a detached daemon (re)launched when you start a Claude Code session | Yes — `claude-rpc uninstall` / `removeStartupEntry`; disable self-heal with `autostart:false` |
|
|
20
20
|
| Hook injection | `src/install.js` → `installHooks` | Only into Claude Code's own `settings.json`, only our own commands | Yes — `uninstallHooks` removes exactly what it added |
|
|
21
21
|
| Outbound network | `src/community.js`, `src/gist.js`, `src/usage.js`, `src/notify.js`, `default-config.js` | Anonymous counters + (opt-in) profile/gist/webhook + own read-only OAuth-usage poll + GIF assets | Telemetry: `community off`. Profile: `profile off`. Gist/webhook: opt-in only. Usage: `usage.enabled:false`. |
|
|
22
|
-
| Local subprocess | `reg.exe`, `wscript`, `git`, `gh`, `npm`, `claude`, `security`, notifiers | Static or escaped args, no shell interpolation of untrusted input | n/a |
|
|
22
|
+
| Local subprocess | `reg.exe`, `wscript`, the daemon itself (`node`/packaged exe — via the Run key and the SessionStart self-heal), `git`, `gh`, `npm`, `claude`, `security`, notifiers | Static or escaped args, no shell interpolation of untrusted input | n/a |
|
|
23
23
|
|
|
24
24
|
No credential access beyond the read-only Claude Code OAuth-token read for usage
|
|
25
25
|
polling (§3d), no filesystem scanning outside `~/.claude-rpc` and Claude Code
|
|
@@ -47,8 +47,16 @@ HKCU\Software\Microsoft\Windows\CurrentVersion\Run
|
|
|
47
47
|
not at wherever you happened to run the installer from — see
|
|
48
48
|
`ensureCanonicalExe`.
|
|
49
49
|
|
|
50
|
-
Non-Windows platforms get **no**
|
|
51
|
-
and skips it).
|
|
50
|
+
Non-Windows platforms get **no** Run-key/login registration (`install()` warns
|
|
51
|
+
and skips it). Instead — on every platform, Windows included — the
|
|
52
|
+
`SessionStart` hook **self-heals** the daemon: if none is running when you start
|
|
53
|
+
a Claude Code session, the hook spawns one (detached, windowless), so presence
|
|
54
|
+
is restored after a reboot, crash, or OS sleep exactly when you next use Claude.
|
|
55
|
+
It is the same long-lived daemon launched the same way (`src/ensure-daemon.js` →
|
|
56
|
+
`spawnDaemonDetached`), a no-op when a daemon is already running, and
|
|
57
|
+
cooldown-guarded against spawn storms; the daemon's atomic single-instance claim
|
|
58
|
+
reaps any duplicate. Disable it with `autostart:false` in config (then manage
|
|
59
|
+
the daemon yourself via `claude-rpc start` / `stop`).
|
|
52
60
|
|
|
53
61
|
## 2. Hook injection into Claude Code
|
|
54
62
|
|
|
@@ -71,6 +79,11 @@ events: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`,
|
|
|
71
79
|
counters, and `git push`/`git commit` detection (for the "just shipped"
|
|
72
80
|
card). It writes only to local state/log files. It does not read file
|
|
73
81
|
*contents*, prompts, or responses beyond the usage counters Claude provides.
|
|
82
|
+
- **`SessionStart` additionally self-heals the daemon:** if none is running it
|
|
83
|
+
spawns the (detached) daemon so presence is assured cross-platform — see §1
|
|
84
|
+
and `ensureDaemonRunning`. Best-effort, gated by `autostart` (default on). It
|
|
85
|
+
is the **only** hook event that launches a process; every other event just
|
|
86
|
+
writes state and acks.
|
|
74
87
|
- **Scope:** the installer only ever touches entries whose command matches
|
|
75
88
|
`isOurHookCommand` (contains `claude-rpc` or `hook.js`). It will not modify,
|
|
76
89
|
reorder, or delete anyone else's hooks.
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -10,23 +10,59 @@ import process from 'node:process';
|
|
|
10
10
|
if (process.platform === 'win32' && process.stdout.isTTY) {
|
|
11
11
|
try { spawnSync('chcp.com', ['65001'], { stdio: 'ignore', windowsHide: true }); } catch { /* chcp absent (Wine, custom shell) — accept whatever code page is set */ }
|
|
12
12
|
}
|
|
13
|
-
import { DAEMON_SCRIPT, PID_PATH, STATE_PATH, LOG_PATH, AGGREGATE_PATH, CONFIG_PATH, IS_PACKAGED, IS_NPX, EXE_PATH
|
|
13
|
+
import { DAEMON_SCRIPT, PID_PATH, STATE_PATH, LOG_PATH, AGGREGATE_PATH, CONFIG_PATH, IS_PACKAGED, IS_NPX, EXE_PATH } from './paths.js';
|
|
14
14
|
import { readActiveState } from './state.js';
|
|
15
|
-
import { buildVars, fillTemplate, humanProject, humanTool, applyIdle, framePasses, fmtNum } from './format.js';
|
|
16
|
-
import { scan, readAggregate, findLiveSessions, dayKey, weekKey } from './scanner.js';
|
|
17
|
-
import { weekGrid } from './week.js';
|
|
18
15
|
import { runHookCli } from './hook.js';
|
|
19
|
-
import { install as runInstall, uninstall as runUninstall, isInstalled, migrateConfig, installHooks, ensureCanonicalExe, installMcp, uninstallMcp, setupOutro } from './install.js';
|
|
20
|
-
import { startTui } from './tui.js';
|
|
21
|
-
import { generateInsights } from './insights.js';
|
|
22
|
-
import { maybeNudge, pickTodayMilestone } from './nudge.js';
|
|
23
|
-
import { badgeSvg } from './badge.js';
|
|
24
|
-
import { fmtCost } from './pricing.js';
|
|
25
|
-
import { addPrivateCwd, removePrivateCwd, listPrivateCwds, resolveVisibility } from './privacy.js';
|
|
26
16
|
import { parseDuration, setPause, clearPause, pauseUntil } from './pause.js';
|
|
27
|
-
import { readUsageCache, fetchUsage, writeUsageCache, fmtResetTime, fmtResetDay } from './usage.js';
|
|
28
17
|
import { loadConfig, hasUserConfig } from './config.js';
|
|
29
|
-
import
|
|
18
|
+
import { spawnDaemonDetached } from './ensure-daemon.js';
|
|
19
|
+
|
|
20
|
+
// ── Lazy-loaded heavy module graph ───────────────────────────────────────────
|
|
21
|
+
// format→scanner→pricing→languages→git→usage (plus install/tui/insights/nudge/
|
|
22
|
+
// badge/privacy/leaderboard) is ~60ms of module-load that the fast-exit commands
|
|
23
|
+
// (--version, --help, start, stop, restart) never touch. We declare the bindings
|
|
24
|
+
// here and fill them via loadStats() only for commands that actually fan out into
|
|
25
|
+
// the stats/format/install code, so daemon-control and version stay near-instant.
|
|
26
|
+
// (The repo already lazy-imports cold deps like doctor.js/card.js/mcp.js inside
|
|
27
|
+
// their handlers — this just extends that to the graph the main switch needs.)
|
|
28
|
+
let buildVars, fillTemplate, humanProject, humanTool, applyIdle, framePasses, fmtNum;
|
|
29
|
+
let scan, readAggregate, findLiveSessions, dayKey, weekKey;
|
|
30
|
+
let weekGrid;
|
|
31
|
+
let runInstall, runUninstall, isInstalled, migrateConfig, installHooks, ensureCanonicalExe, installMcp, uninstallMcp, setupOutro;
|
|
32
|
+
let startTui;
|
|
33
|
+
let generateInsights;
|
|
34
|
+
let maybeNudge, pickTodayMilestone;
|
|
35
|
+
let badgeSvg;
|
|
36
|
+
let fmtCost;
|
|
37
|
+
let addPrivateCwd, removePrivateCwd, listPrivateCwds, resolveVisibility;
|
|
38
|
+
let readUsageCache, fetchUsage, writeUsageCache, fmtResetTime, fmtResetDay;
|
|
39
|
+
let lb;
|
|
40
|
+
|
|
41
|
+
let statsLoaded = false;
|
|
42
|
+
async function loadStats() {
|
|
43
|
+
if (statsLoaded) return;
|
|
44
|
+
// Load the graph concurrently — these are all leaf-ish modules with no
|
|
45
|
+
// ordering dependency, so Promise.all beats a sequential import chain.
|
|
46
|
+
const [fmt, scn, wk, inst, tui, ins, ndg, bdg, prc, priv, usg, leaderboard] = await Promise.all([
|
|
47
|
+
import('./format.js'), import('./scanner.js'), import('./week.js'),
|
|
48
|
+
import('./install.js'), import('./tui.js'), import('./insights.js'),
|
|
49
|
+
import('./nudge.js'), import('./badge.js'), import('./pricing.js'),
|
|
50
|
+
import('./privacy.js'), import('./usage.js'), import('./leaderboard.js'),
|
|
51
|
+
]);
|
|
52
|
+
({ buildVars, fillTemplate, humanProject, humanTool, applyIdle, framePasses, fmtNum } = fmt);
|
|
53
|
+
({ scan, readAggregate, findLiveSessions, dayKey, weekKey } = scn);
|
|
54
|
+
({ weekGrid } = wk);
|
|
55
|
+
({ install: runInstall, uninstall: runUninstall, isInstalled, migrateConfig, installHooks, ensureCanonicalExe, installMcp, uninstallMcp, setupOutro } = inst);
|
|
56
|
+
({ startTui } = tui);
|
|
57
|
+
({ generateInsights } = ins);
|
|
58
|
+
({ maybeNudge, pickTodayMilestone } = ndg);
|
|
59
|
+
({ badgeSvg } = bdg);
|
|
60
|
+
({ fmtCost } = prc);
|
|
61
|
+
({ addPrivateCwd, removePrivateCwd, listPrivateCwds, resolveVisibility } = priv);
|
|
62
|
+
({ readUsageCache, fetchUsage, writeUsageCache, fmtResetTime, fmtResetDay } = usg);
|
|
63
|
+
lb = leaderboard;
|
|
64
|
+
statsLoaded = true;
|
|
65
|
+
}
|
|
30
66
|
import { VERSION } from './version.js';
|
|
31
67
|
import { fail, tailLines, heat, sparkline, fmtDelta, topPercentile, EX_USER_ERROR, EX_BAD_STATE, EX_SYS_ERROR } from './ui.js';
|
|
32
68
|
import { randomUUID } from 'node:crypto';
|
|
@@ -96,18 +132,39 @@ function startDaemon({ quiet = false } = {}) {
|
|
|
96
132
|
if (!quiet) console.log(` ${c.yellow}!${c.reset} ${'daemon running'.padEnd(16)}${c.dim}already up (pid ${pid}) · bounce it with ${c.reset}${c.cyan}claude-rpc restart${c.reset}`);
|
|
97
133
|
return false;
|
|
98
134
|
}
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
135
|
+
// Shared spawn recipe (canonical-exe preference, detached, windowless, error
|
|
136
|
+
// listener) lives in ensure-daemon.spawnDaemonDetached so the CLI and the
|
|
137
|
+
// self-healing hook launch the daemon identically.
|
|
138
|
+
const child = spawnDaemonDetached();
|
|
139
|
+
if (!child || !child.pid) {
|
|
140
|
+
if (!quiet) console.log(` ${c.red}✗${c.reset} ${'daemon start'.padEnd(16)}${c.dim}spawn failed — check ${shortPath(LOG_PATH)} or run ${c.reset}${c.cyan}claude-rpc doctor${c.reset}`);
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
107
143
|
if (!quiet) console.log(` ${c.green}✓${c.reset} ${'daemon launched'.padEnd(16)}${c.dim}pid ${c.reset}${c.cyan}${child.pid}${c.reset}${c.dim} · log ${shortPath(LOG_PATH)}${c.reset}`);
|
|
108
144
|
return true;
|
|
109
145
|
}
|
|
110
146
|
|
|
147
|
+
// Confirm a freshly-spawned daemon actually came up, instead of trusting the
|
|
148
|
+
// detached spawn (it can die a millisecond later — single-instance loss, a bad
|
|
149
|
+
// path, a startup throw). Polls the pid file for liveness, since the daemon
|
|
150
|
+
// claims it early in startup. Interactive callers (`start`) await this so the
|
|
151
|
+
// user gets honest "it's up" / "it didn't come up" feedback rather than a
|
|
152
|
+
// success message for a daemon that's already gone.
|
|
153
|
+
async function confirmDaemonUp({ timeoutMs = 3000, intervalMs = 100 } = {}) {
|
|
154
|
+
const deadline = Date.now() + timeoutMs;
|
|
155
|
+
for (;;) {
|
|
156
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
157
|
+
if (daemonPid()) {
|
|
158
|
+
console.log(` ${c.green}✓${c.reset} ${'daemon confirmed'.padEnd(16)}${c.dim}up — connecting to Discord${c.reset}`);
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
if (Date.now() >= deadline) {
|
|
162
|
+
console.log(` ${c.yellow}!${c.reset} ${'daemon check'.padEnd(16)}${c.dim}not up yet — see ${shortPath(LOG_PATH)} or run ${c.reset}${c.cyan}claude-rpc doctor${c.reset}`);
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
111
168
|
function stopDaemon({ quiet = false } = {}) {
|
|
112
169
|
const pid = daemonPid();
|
|
113
170
|
if (!pid) { if (!quiet) console.log(` ${c.cyan}·${c.reset} daemon not running`); return false; }
|
|
@@ -1950,6 +2007,12 @@ process.on('unhandledRejection', (e) => {
|
|
|
1950
2007
|
// ESM (dev) and CommonJS (esbuild → SEA bundle) — CJS doesn't allow
|
|
1951
2008
|
// top-level await.
|
|
1952
2009
|
(async () => {
|
|
2010
|
+
// Fast-exit commands touch none of the lazy stats/format/install graph, so
|
|
2011
|
+
// skip the module load and keep them instant. Everything else loads it once
|
|
2012
|
+
// up front, which makes every binding available to the handlers below exactly
|
|
2013
|
+
// as before. (daemon/serve/wrapped/hook only dynamic-import their own module.)
|
|
2014
|
+
const FAST_PATHS = new Set(['--version', '-V', '-v', '--help', '-h', 'help', 'start', 'stop', 'restart', 'hook', 'daemon', 'serve', 'wrapped']);
|
|
2015
|
+
if (!FAST_PATHS.has(cmd)) await loadStats();
|
|
1953
2016
|
switch (cmd) {
|
|
1954
2017
|
case '--version':
|
|
1955
2018
|
case '-V':
|
|
@@ -1998,6 +2061,7 @@ process.on('unhandledRejection', (e) => {
|
|
|
1998
2061
|
const child = spawn(process.execPath, [script || DAEMON_SCRIPT], {
|
|
1999
2062
|
detached: true, stdio: 'ignore', windowsHide: true,
|
|
2000
2063
|
});
|
|
2064
|
+
child.on('error', () => {}); // never let a spawn ENOENT bubble as an unhandled crash
|
|
2001
2065
|
child.unref();
|
|
2002
2066
|
console.log(` ${c.green}✓${c.reset} ${'daemon launched'.padEnd(16)}${c.dim}pid ${c.reset}${c.cyan}${child.pid}${c.reset}${c.dim} · log ${shortPath(LOG_PATH)}${c.reset}`);
|
|
2003
2067
|
} else {
|
|
@@ -2017,7 +2081,7 @@ process.on('unhandledRejection', (e) => {
|
|
|
2017
2081
|
case 'upgrade-config':
|
|
2018
2082
|
if (!migrateConfig()) console.log(` ${c.green}✓${c.reset} config already current — nothing to migrate`);
|
|
2019
2083
|
break;
|
|
2020
|
-
case 'start': startDaemon(); break;
|
|
2084
|
+
case 'start': if (startDaemon()) await confirmDaemonUp(); break;
|
|
2021
2085
|
case 'stop': stopDaemon(); break;
|
|
2022
2086
|
case 'restart': restartDaemon(); break;
|
|
2023
2087
|
case 'status': {
|
package/src/daemon.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { existsSync, unlinkSync, watch, appendFileSync, mkdirSync, statSync, renameSync } from 'node:fs';
|
|
3
3
|
import { basename, dirname } from 'node:path';
|
|
4
4
|
import { Client } from './discord-ipc.js';
|
|
5
5
|
import { readState, sweepStaleStateTmp, listSessionStates, sweepStaleSessionStates } from './state.js';
|
|
6
|
-
import { makeRotationCursor, pickFrames, selectFrame, resolveLargeImageKey, shouldShowGithubButton, pickActiveSession } from './presence.js';
|
|
6
|
+
import { makeRotationCursor, pickFrames, selectFrame, resolveLargeImageKey, shouldShowGithubButton, pickActiveSession, throttleDecision } from './presence.js';
|
|
7
7
|
import { buildVars, fillTemplate, framePasses, applyIdle, applyShipped, applyTrigger } from './format.js';
|
|
8
8
|
import { scan, readAggregate, findLiveSessions, readSessionTokens } from './scanner.js';
|
|
9
9
|
import { detectGithubUrl } from './git.js';
|
|
@@ -16,6 +16,7 @@ import { humanProject } from './format.js';
|
|
|
16
16
|
import { CONFIG_PATH, STATE_PATH, PID_PATH, LOG_PATH, STATE_DIR, AGGREGATE_PATH, PAUSE_PATH } from './paths.js';
|
|
17
17
|
import { readUsageCache, pollUsage } from './usage.js';
|
|
18
18
|
import { pollDecision, pollIntervalMs } from './watch-poll.js';
|
|
19
|
+
import { claimSingleInstance } from './ensure-daemon.js';
|
|
19
20
|
|
|
20
21
|
if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true });
|
|
21
22
|
|
|
@@ -51,6 +52,19 @@ function log(...args) {
|
|
|
51
52
|
process.stdout.write(line);
|
|
52
53
|
}
|
|
53
54
|
|
|
55
|
+
// Last-resort resilience. A presence daemon is best-effort and long-lived — it
|
|
56
|
+
// must never die from an unexpected error in a non-critical path (a scan edge
|
|
57
|
+
// case, a transient IPC oddity, a stray rejection). Log and keep running:
|
|
58
|
+
// staying up is the entire point, and the periodic ticks/watchdog re-converge
|
|
59
|
+
// from almost anything. Installed before any other startup work so even a boot
|
|
60
|
+
// error is captured rather than killing us silently.
|
|
61
|
+
process.on('uncaughtException', (e) => {
|
|
62
|
+
try { log('uncaughtException (continuing):', e?.stack || e?.message || String(e)); } catch { /* logging itself failed — nothing more we can do */ }
|
|
63
|
+
});
|
|
64
|
+
process.on('unhandledRejection', (e) => {
|
|
65
|
+
try { log('unhandledRejection (continuing):', e?.stack || e?.message || String(e)); } catch { /* see above */ }
|
|
66
|
+
});
|
|
67
|
+
|
|
54
68
|
// Wrap loadConfig so a parse/IO failure logs once and the daemon keeps
|
|
55
69
|
// running on baked-in defaults. The Electron settings GUI saves the file
|
|
56
70
|
// atomically but mid-edit hand-edits used to brick the daemon — this is
|
|
@@ -79,7 +93,21 @@ let liveSessions = [];
|
|
|
79
93
|
let client = null;
|
|
80
94
|
let connected = false;
|
|
81
95
|
let connecting = false; // login() in flight — see the watchdog note in connect()
|
|
82
|
-
|
|
96
|
+
// ── Outbound presence throttle ──────────────────────────────────────────────
|
|
97
|
+
// Discord rate-limits SET_ACTIVITY hard (~5 writes per 20s); a burst past it
|
|
98
|
+
// makes the client blank the presence until the writes stop — the actual
|
|
99
|
+
// mechanism behind "the card sometimes looks buggy", since Claude fires hooks
|
|
100
|
+
// in flurries. We never write faster than activityGapMs(): the first change
|
|
101
|
+
// after a quiet gap goes out at once, and changes inside the gap coalesce to
|
|
102
|
+
// the LATEST and flush once when it expires. See presence.throttleDecision.
|
|
103
|
+
// `lastSentHash` is what we believe is on the wire — advanced ONLY after a
|
|
104
|
+
// confirmed write, so a dropped/rate-limited write self-heals on the next push
|
|
105
|
+
// instead of being recorded as success and stranded (the old optimistic-hash
|
|
106
|
+
// bug, which stuck the card on a stale frame).
|
|
107
|
+
let lastSentHash = '';
|
|
108
|
+
let lastSentAt = 0; // ms of the last write ATTEMPT (success or fail → back off either way)
|
|
109
|
+
let pendingSend = null; // { kind, activity, hash, logMsg } coalesced during the gap; latest wins
|
|
110
|
+
let flushTimer = null;
|
|
83
111
|
// Last status we acted on for outbound side-effects (webhook / desktop notify).
|
|
84
112
|
// Tracked separately from the render hash so we fire once per transition.
|
|
85
113
|
let lastNotifiedStatus = null;
|
|
@@ -105,25 +133,19 @@ let displayedSessionId = null;
|
|
|
105
133
|
// either case would make startTimestamp jump on every rotation.
|
|
106
134
|
let effectiveSessionStart = null;
|
|
107
135
|
|
|
108
|
-
// Single-instance guard
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
process.exit(0);
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
} catch { /* unreadable PID file — fall through and claim it */ }
|
|
126
|
-
writeFileSync(PID_PATH, String(process.pid));
|
|
136
|
+
// Single-instance guard — an ATOMIC claim (see ensure-daemon.claimSingleInstance).
|
|
137
|
+
// Many launchers can fire at once now that the SessionStart hook self-heals the
|
|
138
|
+
// daemon: a manual `start`, a Windows Run entry, and several concurrent sessions'
|
|
139
|
+
// hooks could all spawn a daemon in the same instant. A second daemon would
|
|
140
|
+
// fight over setActivity every ~4s and double-count the additive community
|
|
141
|
+
// total. The old read-then-write guard had a window where two starters both saw
|
|
142
|
+
// "no live owner" and both wrote their pid; the exclusive-create claim closes it
|
|
143
|
+
// so exactly one survives. A live owner → we exit; a stale pid → we reclaim it.
|
|
144
|
+
const ownerPid = claimSingleInstance();
|
|
145
|
+
if (ownerPid) {
|
|
146
|
+
log(`Another daemon (pid ${ownerPid}) is already running — exiting.`);
|
|
147
|
+
process.exit(0);
|
|
148
|
+
}
|
|
127
149
|
|
|
128
150
|
// Reclaim any per-pid state tmp files orphaned by a hard-killed writer, plus
|
|
129
151
|
// per-session state files from sessions that ended long ago.
|
|
@@ -330,7 +352,12 @@ function fireStatusSideEffects(resolved, suppressed = false) {
|
|
|
330
352
|
}
|
|
331
353
|
}
|
|
332
354
|
|
|
333
|
-
|
|
355
|
+
// Compute the desired presence for the current state and hand it to the
|
|
356
|
+
// throttle. Called from many triggers (hook-driven state changes, the periodic
|
|
357
|
+
// tick, config/scan/session refreshes); transmit() collapses that into at most
|
|
358
|
+
// one Discord write per activityGapMs(), so callers never have to think about
|
|
359
|
+
// the rate limit. Best-effort throughout — presence must never crash the daemon.
|
|
360
|
+
function pushPresence() {
|
|
334
361
|
if (!connected || !client?.user) return;
|
|
335
362
|
try {
|
|
336
363
|
// Resolve state ONCE — this same object decides clear-vs-push, drives the
|
|
@@ -342,7 +369,7 @@ async function pushPresence() {
|
|
|
342
369
|
const privacyHidden = resolved._privacy?.visibility === 'hidden';
|
|
343
370
|
// Global snooze (`claude-rpc pause`) — clears the card while the deadline
|
|
344
371
|
// is in the future. Re-checked every tick, so expiry resumes presence
|
|
345
|
-
// automatically (the '
|
|
372
|
+
// automatically (the next frame's hash differs from the cleared sentinel).
|
|
346
373
|
const pausedUntil = pauseUntil();
|
|
347
374
|
const suppressed = privacyHidden || !!pausedUntil || (resolved.status === 'stale' && hideWhenStale);
|
|
348
375
|
|
|
@@ -353,44 +380,100 @@ async function pushPresence() {
|
|
|
353
380
|
fireStatusSideEffects(resolved, suppressed);
|
|
354
381
|
|
|
355
382
|
if (suppressed) {
|
|
356
|
-
const stamp = 'cleared';
|
|
357
|
-
if (lastPayloadHash === stamp) return;
|
|
358
|
-
lastPayloadHash = stamp;
|
|
359
383
|
// Wipe effectiveSessionStart so the next active push gets a fresh
|
|
360
384
|
// elapsed timer rather than counting from a previous session.
|
|
361
385
|
effectiveSessionStart = null;
|
|
362
|
-
await client.user.clearActivity();
|
|
363
386
|
const reason = pausedUntil
|
|
364
387
|
? `paused until ${new Date(pausedUntil).toLocaleTimeString()}`
|
|
365
388
|
: privacyHidden ? 'privacy=hidden in this project' : 'stale — Claude Code not running';
|
|
366
|
-
|
|
389
|
+
transmit('clear', null, `Presence cleared (${reason})`);
|
|
367
390
|
return;
|
|
368
391
|
}
|
|
369
392
|
|
|
370
393
|
const activity = buildActivity({ resolved });
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
394
|
+
transmit('set', activity, `Presence updated: ${activity.details || '-'} | ${activity.state || '-'}`);
|
|
395
|
+
} catch (e) {
|
|
396
|
+
log('pushPresence failed:', e.message);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Minimum gap between Discord writes, read live so a config reload takes effect.
|
|
401
|
+
// Floored at 2s as a sanity minimum; the 4s default keeps us under Discord's
|
|
402
|
+
// ~5-per-20s SET_ACTIVITY limit (see default-config.minActivityGapMs).
|
|
403
|
+
function activityGapMs() {
|
|
404
|
+
return Math.max(2000, config.minActivityGapMs || 4000);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Sentinel for a clear — can never collide with a real activity hash, since
|
|
408
|
+
// JSON.stringify of an activity object always begins with '{'.
|
|
409
|
+
const CLEAR_HASH = 'cleared';
|
|
410
|
+
|
|
411
|
+
// Route a desired payload (kind:'set'|'clear') through the rate-limit throttle.
|
|
412
|
+
// 'send' writes now; 'defer' stashes the LATEST payload and arms a single flush
|
|
413
|
+
// timer; 'skip' means the wire already shows this exact frame.
|
|
414
|
+
function transmit(kind, activity, logMsg) {
|
|
415
|
+
const hash = kind === 'clear' ? CLEAR_HASH : JSON.stringify(activity);
|
|
416
|
+
const d = throttleDecision({
|
|
417
|
+
hash, lastSentHash, lastSentAt,
|
|
418
|
+
now: Date.now(), gapMs: activityGapMs(), flushPending: !!flushTimer,
|
|
419
|
+
});
|
|
420
|
+
if (d.action === 'skip') { pendingSend = null; return; }
|
|
421
|
+
if (d.action === 'send') { doSend({ kind, activity, hash, logMsg }); return; }
|
|
422
|
+
// defer — remember the LATEST payload and flush it once when the gap expires.
|
|
423
|
+
pendingSend = { kind, activity, hash, logMsg };
|
|
424
|
+
if (!flushTimer) {
|
|
425
|
+
flushTimer = setTimeout(flushPendingSend, d.waitMs);
|
|
426
|
+
if (flushTimer.unref) flushTimer.unref();
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function flushPendingSend() {
|
|
431
|
+
flushTimer = null;
|
|
432
|
+
const p = pendingSend;
|
|
433
|
+
pendingSend = null;
|
|
434
|
+
if (!p || !connected || !client?.user) return;
|
|
435
|
+
if (p.hash === lastSentHash) return; // state reverted to what's already shown
|
|
436
|
+
doSend(p);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// The single point that actually writes to Discord. lastSentAt advances on every
|
|
440
|
+
// ATTEMPT (so a failure backs off a full gap before retrying); lastSentHash
|
|
441
|
+
// advances ONLY on success — a dropped/rate-limited write is therefore retried
|
|
442
|
+
// by the next push rather than being recorded as success and stranded.
|
|
443
|
+
async function doSend({ kind, activity, hash, logMsg }) {
|
|
444
|
+
lastSentAt = Date.now();
|
|
445
|
+
try {
|
|
446
|
+
if (kind === 'clear') await client.user.clearActivity();
|
|
447
|
+
else await client.user.setActivity(activity);
|
|
448
|
+
lastSentHash = hash;
|
|
449
|
+
log(logMsg);
|
|
376
450
|
} catch (e) {
|
|
377
451
|
log('setActivity failed:', e.message, '|', e.stack?.split('\n').slice(0, 3).join(' | '));
|
|
378
|
-
// A failed
|
|
379
|
-
//
|
|
380
|
-
//
|
|
381
|
-
//
|
|
382
|
-
//
|
|
383
|
-
//
|
|
452
|
+
// A failed write usually means the IPC pipe died WITHOUT a 'disconnected'
|
|
453
|
+
// event (Discord restart, socket reset, OS sleep). Left alone, `connected`
|
|
454
|
+
// stays true and the daemon goes silently dark forever. Tear down and force
|
|
455
|
+
// a backoff reconnect so we self-heal — guarded to connection-shaped errors
|
|
456
|
+
// so a one-off app error (e.g. a rate-limit rejection) just waits for the
|
|
457
|
+
// next push's retry instead of needlessly bouncing a healthy socket.
|
|
384
458
|
if (isConnectionError(e)) {
|
|
385
459
|
log('setActivity error looks connection-level — forcing reconnect');
|
|
386
460
|
connected = false;
|
|
387
|
-
|
|
461
|
+
resetTransmit();
|
|
388
462
|
try { client?.destroy(); } catch { /* already gone */ }
|
|
389
463
|
scheduleReconnect('setActivity failed');
|
|
390
464
|
}
|
|
391
465
|
}
|
|
392
466
|
}
|
|
393
467
|
|
|
468
|
+
// Force the next pushPresence to write even if the activity is byte-identical,
|
|
469
|
+
// and drop any queued flush. Called when an input changed (config/aggregate/
|
|
470
|
+
// scan/sessions) or the connection bounced — the new frame must go out fresh.
|
|
471
|
+
function resetTransmit() {
|
|
472
|
+
lastSentHash = '';
|
|
473
|
+
pendingSend = null;
|
|
474
|
+
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
|
|
475
|
+
}
|
|
476
|
+
|
|
394
477
|
// Heuristic: does this error indicate the IPC transport itself is dead
|
|
395
478
|
// (vs. a transient/application-level failure)? Matches the common broken-pipe
|
|
396
479
|
// / closed-socket shapes from our IPC client (src/discord-ipc.js) and the net
|
|
@@ -417,7 +500,7 @@ async function connect() {
|
|
|
417
500
|
// Reset backoff so the next outage also starts at RECONNECT_BASE_MS.
|
|
418
501
|
reconnectDelayMs = RECONNECT_BASE_MS;
|
|
419
502
|
log('Discord RPC connected as', client.user?.username);
|
|
420
|
-
|
|
503
|
+
resetTransmit();
|
|
421
504
|
pushPresence();
|
|
422
505
|
});
|
|
423
506
|
client.on('disconnected', () => {
|
|
@@ -459,12 +542,12 @@ function watchFiles() {
|
|
|
459
542
|
{ path: CONFIG_PATH, label: 'config', onChange: () => {
|
|
460
543
|
log('Config changed — reloading');
|
|
461
544
|
config = loadConfigWithLog();
|
|
462
|
-
|
|
545
|
+
resetTransmit();
|
|
463
546
|
pushPresence();
|
|
464
547
|
} },
|
|
465
548
|
{ path: AGGREGATE_PATH, label: 'aggregate', onChange: () => {
|
|
466
549
|
aggregate = readAggregate() || aggregate;
|
|
467
|
-
|
|
550
|
+
resetTransmit();
|
|
468
551
|
pushPresence();
|
|
469
552
|
} },
|
|
470
553
|
];
|
|
@@ -542,7 +625,7 @@ async function runBackgroundScan({ force = false } = {}) {
|
|
|
542
625
|
const { aggregate: agg, scanned, skipped, removed, total } = scan({ force });
|
|
543
626
|
aggregate = agg;
|
|
544
627
|
log(`Scan complete: ${scanned} parsed / ${skipped} cached / ${removed} removed / ${total} total in ${Date.now() - t0}ms — allHours=${(agg.activeMs / 3_600_000).toFixed(1)}, sessions=${agg.sessions}, tokens=${(agg.inputTokens + agg.outputTokens)}`);
|
|
545
|
-
|
|
628
|
+
resetTransmit();
|
|
546
629
|
pushPresence();
|
|
547
630
|
} catch (e) {
|
|
548
631
|
log('Scan failed:', e.message);
|
|
@@ -594,7 +677,7 @@ function refreshLiveSessions() {
|
|
|
594
677
|
liveSessions = next;
|
|
595
678
|
if (next.length !== prevCount) {
|
|
596
679
|
log('Concurrent sessions:', next.length, next.map((s) => `${s.project}(${s.ageSec}s)`).join(', '));
|
|
597
|
-
|
|
680
|
+
resetTransmit();
|
|
598
681
|
pushPresence();
|
|
599
682
|
}
|
|
600
683
|
} catch (e) {
|
package/src/default-config.js
CHANGED
|
@@ -14,6 +14,20 @@ export const DEFAULT_CONFIG = {
|
|
|
14
14
|
appName: "Claude Code",
|
|
15
15
|
updateIntervalMs: 4000,
|
|
16
16
|
rotationIntervalMs: 12000,
|
|
17
|
+
// Self-heal: the SessionStart hook (re)starts the daemon whenever it isn't
|
|
18
|
+
// already running, so presence is assured whenever you use Claude Code —
|
|
19
|
+
// surviving reboots, crashes, OS sleep, and platforms with no login-autostart
|
|
20
|
+
// entry (macOS/Linux). Set false to disable that and manage the daemon
|
|
21
|
+
// yourself with `claude-rpc start` / `stop`.
|
|
22
|
+
autostart: true,
|
|
23
|
+
// Minimum gap (ms) between Discord SET_ACTIVITY writes. Discord hard-limits
|
|
24
|
+
// activity updates (~5 per 20s); blowing past it makes the client EMPTY the
|
|
25
|
+
// presence and stop updating until the writes stop. Claude Code fires hooks
|
|
26
|
+
// in bursts, so the daemon coalesces rapid changes and never writes faster
|
|
27
|
+
// than this — the first change after a quiet gap goes out at once, the rest
|
|
28
|
+
// collapse to the latest and flush when the gap expires. 4s stays safely
|
|
29
|
+
// under the limit; lower values risk Discord throttling the card.
|
|
30
|
+
minActivityGapMs: 4000,
|
|
17
31
|
rescanIntervalSec: 300,
|
|
18
32
|
idleThresholdSec: 60,
|
|
19
33
|
// Time (minutes) of no hook activity AND no live transcripts on disk before
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Daemon-lifecycle primitives shared by the CLI (`start`), the daemon itself
|
|
2
|
+
// (single-instance guard), and the hook (self-heal on SessionStart). Kept in a
|
|
3
|
+
// tiny dependency-light module so the hook — which runs on every Claude Code
|
|
4
|
+
// event and must stay fast and crash-proof — can import the spawn recipe
|
|
5
|
+
// without pulling in cli.js's UI stack.
|
|
6
|
+
//
|
|
7
|
+
// The "ultra assured startup" story lives here: the daemon comes up whenever a
|
|
8
|
+
// Claude Code session starts (ensureDaemonRunning, called from the hook), and a
|
|
9
|
+
// race-proof claim (claimSingleInstance, called by the daemon) guarantees that
|
|
10
|
+
// no matter how many launchers fire at once — a manual `start`, a Windows Run
|
|
11
|
+
// entry, several SessionStart hooks from concurrent sessions — exactly one
|
|
12
|
+
// daemon survives.
|
|
13
|
+
|
|
14
|
+
import { spawn } from 'node:child_process';
|
|
15
|
+
import {
|
|
16
|
+
openSync, closeSync, writeFileSync, readFileSync, unlinkSync, existsSync, statSync, mkdirSync,
|
|
17
|
+
} from 'node:fs';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { PID_PATH, STATE_DIR, DAEMON_SCRIPT, CANONICAL_EXE, IS_PACKAGED } from './paths.js';
|
|
20
|
+
|
|
21
|
+
// mtime of this marker = last time any launcher attempted a spawn. A short
|
|
22
|
+
// cooldown stops concurrent SessionStart hooks (several sessions opening at
|
|
23
|
+
// once) and any startup crash-loop from spawning a swarm of throwaway daemons —
|
|
24
|
+
// the single-instance claim would reap them, but not spawning them is cheaper.
|
|
25
|
+
const SPAWN_MARKER = join(STATE_DIR, 'daemon-spawn.ts');
|
|
26
|
+
const SPAWN_COOLDOWN_MS = 15_000;
|
|
27
|
+
|
|
28
|
+
// True if `pid` names a live process. process.kill(pid,0) sends no signal — it
|
|
29
|
+
// just probes: ESRCH → gone, EPERM → exists but owned by someone else (still
|
|
30
|
+
// alive). 0/NaN is never alive.
|
|
31
|
+
export function isAlive(pid) {
|
|
32
|
+
if (!pid || !Number.isFinite(pid)) return false;
|
|
33
|
+
try { process.kill(pid, 0); return true; }
|
|
34
|
+
catch (e) { return e.code === 'EPERM'; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function readPid(pidPath = PID_PATH) {
|
|
38
|
+
try { return Number(readFileSync(pidPath, 'utf8')) || 0; }
|
|
39
|
+
catch { return 0; }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The pid of the live daemon, or 0 if none is running.
|
|
43
|
+
export function daemonAlive(pidPath = PID_PATH) {
|
|
44
|
+
const pid = readPid(pidPath);
|
|
45
|
+
return pid && isAlive(pid) ? pid : 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The single source of truth for HOW to launch the daemon, shared by every
|
|
49
|
+
// launcher so the CLI and the hook spawn it identically:
|
|
50
|
+
// packaged → "<canonical exe>" daemon (prefer the canonical install over a
|
|
51
|
+
// transient Downloads copy so we don't keep that file locked open)
|
|
52
|
+
// dev/npm → "<node>" "<abs daemon.js>" (PATH-independent — process.execPath
|
|
53
|
+
// is the very node already running us)
|
|
54
|
+
// Always detached + windowless + stdio-ignored so it outlives the launcher and
|
|
55
|
+
// never pops a console. Returns the child (caller reads .pid) or null on a
|
|
56
|
+
// synchronous spawn failure.
|
|
57
|
+
export function spawnDaemonDetached() {
|
|
58
|
+
const exe = (IS_PACKAGED && existsSync(CANONICAL_EXE)) ? CANONICAL_EXE : process.execPath;
|
|
59
|
+
const args = IS_PACKAGED ? ['daemon'] : [DAEMON_SCRIPT];
|
|
60
|
+
try {
|
|
61
|
+
const child = spawn(exe, args, { detached: true, stdio: 'ignore', windowsHide: true });
|
|
62
|
+
// An async spawn failure (ENOENT on the exe) emits 'error'; with no listener
|
|
63
|
+
// Node rethrows it as an unhandled exception that can take down the launcher.
|
|
64
|
+
// Swallow — the caller verifies liveness via the pid file, not the child.
|
|
65
|
+
child.on('error', () => {});
|
|
66
|
+
child.unref();
|
|
67
|
+
return child;
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Pure decision for the hook's self-heal: should THIS SessionStart spawn a
|
|
74
|
+
// daemon? Separated from the side effects so it's unit-testable.
|
|
75
|
+
export function shouldSpawnDaemon({ autostart, daemonPid, lastAttemptMs, now, cooldownMs }) {
|
|
76
|
+
if (autostart === false) return false; // explicit opt-out
|
|
77
|
+
if (daemonPid) return false; // already running
|
|
78
|
+
if (lastAttemptMs && now - lastAttemptMs < cooldownMs) return false; // within the cooldown
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Self-heal entry point, called by the SessionStart hook. Best-effort and fast:
|
|
83
|
+
// if no daemon is running (reboot, crash, OS sleep, closed terminal — or simply
|
|
84
|
+
// a platform with no login-autostart entry), bring one up. Stamps the cooldown
|
|
85
|
+
// marker BEFORE spawning so sibling SessionStart hooks racing this one skip
|
|
86
|
+
// instead of each launching a throwaway. Returns the spawned child, or null.
|
|
87
|
+
export function ensureDaemonRunning({ autostart = true, now = Date.now(), cooldownMs = SPAWN_COOLDOWN_MS } = {}) {
|
|
88
|
+
let lastAttemptMs = 0;
|
|
89
|
+
try { if (existsSync(SPAWN_MARKER)) lastAttemptMs = statSync(SPAWN_MARKER).mtimeMs; } catch { /* unreadable — treat as never */ }
|
|
90
|
+
if (!shouldSpawnDaemon({ autostart, daemonPid: daemonAlive(), lastAttemptMs, now, cooldownMs })) return null;
|
|
91
|
+
try { mkdirSync(STATE_DIR, { recursive: true }); } catch { /* exists / unwritable */ }
|
|
92
|
+
try { writeFileSync(SPAWN_MARKER, String(now)); } catch { /* best-effort cooldown */ }
|
|
93
|
+
return spawnDaemonDetached();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Atomic single-instance claim, called once by the daemon at startup. Returns
|
|
97
|
+
// the pid of an already-running daemon (caller should exit), or null when WE
|
|
98
|
+
// now own the pid file. The exclusive-create ('wx') makes the pid file itself
|
|
99
|
+
// the mutex: only one of N simultaneously-starting daemons can create it; the
|
|
100
|
+
// losers read the winner's live pid and step aside. A stale file (dead owner,
|
|
101
|
+
// empty, or our own recycled pid) is reclaimed and the create retried — so a
|
|
102
|
+
// crashed daemon never blocks its successor.
|
|
103
|
+
export function claimSingleInstance({ pidPath = PID_PATH, pid = process.pid, alive = isAlive } = {}) {
|
|
104
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
105
|
+
let fd;
|
|
106
|
+
try {
|
|
107
|
+
fd = openSync(pidPath, 'wx'); // atomic: throws EEXIST if the file already exists
|
|
108
|
+
} catch (e) {
|
|
109
|
+
if (e.code !== 'EEXIST') {
|
|
110
|
+
// Unexpected (perms, missing dir). Best-effort write and take ownership
|
|
111
|
+
// rather than refusing to start.
|
|
112
|
+
try { writeFileSync(pidPath, String(pid)); } catch { /* unwritable — run lockless */ }
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
let owner = 0;
|
|
116
|
+
try { owner = Number(readFileSync(pidPath, 'utf8')) || 0; } catch { /* mid-write — reclaim */ }
|
|
117
|
+
if (owner && owner !== pid && alive(owner)) return owner; // a live daemon already owns it
|
|
118
|
+
try { unlinkSync(pidPath); } catch { /* a racer reclaimed it first */ }
|
|
119
|
+
continue; // retry the exclusive create
|
|
120
|
+
}
|
|
121
|
+
try { writeFileSync(fd, String(pid)); } finally { try { closeSync(fd); } catch { /* already closed */ } }
|
|
122
|
+
return null; // we created the file → we are THE daemon
|
|
123
|
+
}
|
|
124
|
+
// Repeatedly lost the reclaim race (extreme contention) — proceed best-effort.
|
|
125
|
+
try { writeFileSync(pidPath, String(pid)); } catch { /* unwritable */ }
|
|
126
|
+
return null;
|
|
127
|
+
}
|
package/src/hook.js
CHANGED
|
@@ -4,6 +4,8 @@ import { dirname } from 'node:path';
|
|
|
4
4
|
import { updateState, resetState, pushUnique, shortFile } from './state.js';
|
|
5
5
|
import { detectLastCommitSubject, detectGitBranch } from './git.js';
|
|
6
6
|
import { EVENTS_LOG_PATH } from './paths.js';
|
|
7
|
+
import { loadConfig } from './config.js';
|
|
8
|
+
import { ensureDaemonRunning } from './ensure-daemon.js';
|
|
7
9
|
|
|
8
10
|
// Precedence when a command ships more than one way (`git commit && git push`
|
|
9
11
|
// → push). Highest first.
|
|
@@ -153,6 +155,20 @@ export function processHookEvent(event, input = {}) {
|
|
|
153
155
|
model: input.model?.id || input.model || 'claude',
|
|
154
156
|
status: 'idle',
|
|
155
157
|
});
|
|
158
|
+
// Self-heal the daemon. A reboot, crash, OS sleep, or closed terminal can
|
|
159
|
+
// leave nothing running, so the card silently never appears — and on
|
|
160
|
+
// macOS/Linux there's no login-autostart entry at all (only Windows wires
|
|
161
|
+
// a Run key). Every Claude Code session begins with this hook, so make it
|
|
162
|
+
// (best-effort) guarantee the daemon is up: presence is then assured
|
|
163
|
+
// exactly when you're using Claude, on every platform. ensureDaemonRunning
|
|
164
|
+
// is a no-op when a daemon is already alive and is cooldown-guarded against
|
|
165
|
+
// spawn storms; the daemon's atomic claim reaps any duplicate. Opt out
|
|
166
|
+
// with `autostart:false` in config.
|
|
167
|
+
try {
|
|
168
|
+
let autostart = true;
|
|
169
|
+
try { autostart = loadConfig().autostart !== false; } catch { /* unreadable config — default on */ }
|
|
170
|
+
ensureDaemonRunning({ autostart });
|
|
171
|
+
} catch { /* presence is best-effort — never break the user's turn */ }
|
|
156
172
|
break;
|
|
157
173
|
}
|
|
158
174
|
case 'UserPromptSubmit': {
|
package/src/presence.js
CHANGED
|
@@ -96,6 +96,30 @@ export function shouldShowGithubButton(p, state) {
|
|
|
96
96
|
return true;
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
// Decide how to transmit a candidate presence payload without tripping Discord's
|
|
100
|
+
// SET_ACTIVITY rate limit. Discord hard-limits activity writes (~5 per 20s) and
|
|
101
|
+
// punishes a burst — calling it ~10× in 5s makes the client EMPTY the presence
|
|
102
|
+
// and stop updating until the writes stop (discord-api-docs#668). Claude Code
|
|
103
|
+
// fires PreToolUse/PostToolUse hooks back-to-back through a flurry of quick
|
|
104
|
+
// Reads/Edits, so the daemon would otherwise write many times a second. The
|
|
105
|
+
// Game SDK claims to coalesce-and-queue for you; raw IPC (what we speak) gives
|
|
106
|
+
// no such cushion, so we throttle ourselves.
|
|
107
|
+
//
|
|
108
|
+
// This is the leading+trailing decision — pure so it's unit-testable; the daemon
|
|
109
|
+
// owns the wall clock, the last-sent bookkeeping, and the flush timer:
|
|
110
|
+
// - 'skip' : byte-identical to what's already on the wire — do nothing
|
|
111
|
+
// - 'send' : the gap has elapsed and nothing is queued — write immediately
|
|
112
|
+
// (snappy idle→thinking→working transitions)
|
|
113
|
+
// - 'defer' : inside the gap, or a flush is already queued — coalesce to the
|
|
114
|
+
// LATEST payload and let the caller flush it in `waitMs`, so the
|
|
115
|
+
// final state of every burst still lands, once, under the limit
|
|
116
|
+
export function throttleDecision({ hash, lastSentHash, lastSentAt, now, gapMs, flushPending }) {
|
|
117
|
+
if (hash === lastSentHash) return { action: 'skip', waitMs: 0 };
|
|
118
|
+
const waitMs = Math.max(0, (lastSentAt || 0) + gapMs - now);
|
|
119
|
+
if (waitMs === 0 && !flushPending) return { action: 'send', waitMs: 0 };
|
|
120
|
+
return { action: 'defer', waitMs };
|
|
121
|
+
}
|
|
122
|
+
|
|
99
123
|
// Large-image key precedence (returns the TEMPLATE string; the caller fills it):
|
|
100
124
|
// 1. statusAssets[status] per-status art ("working" gif, etc.)
|
|
101
125
|
// 2. modelAssets[fable|opus|sonnet|haiku|default] per-model art, never stale
|
package/src/scanner.js
CHANGED
|
@@ -439,8 +439,15 @@ function parseChunkInto(text, summary, pstate) {
|
|
|
439
439
|
// null) falls back to a from-scratch parse.
|
|
440
440
|
export function parseTranscript(filePath, prev = null) {
|
|
441
441
|
const st = statSync(filePath);
|
|
442
|
+
// Append only if this is the SAME file that grew: the current size must be
|
|
443
|
+
// >= the size at the last parse (mirrors readSessionTokens). The old check,
|
|
444
|
+
// `st.size >= prev._offset`, was too weak — a rewrite to a size between the
|
|
445
|
+
// consumed offset and the prior file size would wrongly append onto stale
|
|
446
|
+
// counts (the leading bytes are now different content), silently corrupting
|
|
447
|
+
// lifetime stats. A cache entry predating _size has no size to compare, so it
|
|
448
|
+
// falls back to a full re-parse (which then stamps _size going forward).
|
|
442
449
|
const canAppend = !!(prev && prev._parse && typeof prev._offset === 'number'
|
|
443
|
-
&& st.size >= prev.
|
|
450
|
+
&& typeof prev._size === 'number' && st.size >= prev._size);
|
|
444
451
|
const summary = canAppend ? structuredClone(prev) : blankTranscriptSummary();
|
|
445
452
|
const pstate = canAppend
|
|
446
453
|
? { recentIds: (prev._parse.recentIds || []).slice(), lastRec: prev._parse.lastRec || null }
|
|
@@ -481,6 +488,7 @@ export function parseTranscript(filePath, prev = null) {
|
|
|
481
488
|
// else: a partial line mid-write — leave it for the next (append) read.
|
|
482
489
|
}
|
|
483
490
|
summary._offset = offset;
|
|
491
|
+
summary._size = st.size; // file size at this parse — guards the append fast-path above
|
|
484
492
|
summary._parse = { recentIds: pstate.recentIds, lastRec: pstate.lastRec };
|
|
485
493
|
return summary;
|
|
486
494
|
}
|
|
@@ -721,10 +729,11 @@ export function readAggregate() {
|
|
|
721
729
|
|
|
722
730
|
export { dayKey, weekKey, hourKey };
|
|
723
731
|
|
|
724
|
-
function aggregateFrom(cache) {
|
|
732
|
+
export function aggregateFrom(cache) {
|
|
725
733
|
const agg = {
|
|
726
734
|
sessions: 0,
|
|
727
735
|
subagentRuns: 0,
|
|
736
|
+
subagentActiveMs: 0,
|
|
728
737
|
inputTokens: 0,
|
|
729
738
|
outputTokens: 0,
|
|
730
739
|
cacheReadTokens: 0,
|
|
@@ -814,6 +823,15 @@ function aggregateFrom(cache) {
|
|
|
814
823
|
}
|
|
815
824
|
if (isSub) {
|
|
816
825
|
agg.subagentRuns += 1;
|
|
826
|
+
// Subagent active time is tracked separately and deliberately NOT folded
|
|
827
|
+
// into agg.activeMs. A subagent's wall-time overlaps its parent session
|
|
828
|
+
// (and parallel subagents overlap each other), and gaps >=5min are already
|
|
829
|
+
// excluded from the parent's activeMs (see ACTIVE_GAP_CAP_MS) — so summing
|
|
830
|
+
// the scalar would double-count short subagents. agg.activeMs stays an
|
|
831
|
+
// interactive-session measure; subagentActiveMs exposes delegated work as
|
|
832
|
+
// its own honest number. A single unified figure would need interval-union
|
|
833
|
+
// across parent+subagents, not scalar sums.
|
|
834
|
+
agg.subagentActiveMs += summary.activeMs || 0;
|
|
817
835
|
// Subagents still contribute tokens/tools/lines/cost to per-day/week/hour buckets.
|
|
818
836
|
const mergeSubBuckets = (srcMap, destMap) => {
|
|
819
837
|
for (const [k, src] of Object.entries(srcMap || {})) {
|