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 CHANGED
@@ -1,16 +1,21 @@
1
1
  <div align="center">
2
2
 
3
- <img src="https://cdn.qualit.ly/clawd-working-building.gif" width="120" alt="working" />
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
- # claude-rpc
5
+ <br/><br/>
9
6
 
10
- **Discord Rich Presence (RPC) for [Claude Code](https://claude.com/claude-code).**
11
- Your live model, project, current tool, tokens, and lifetime stats — in your Discord profile. Driven by the hooks Claude Code already fires. Zero polling between sessions.
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
- **→ [claude-rpc.vercel.app](https://claude-rpc.vercel.app)**what it looks like, in one page.
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 · sessions](https://claude-rpc-totals.claude-rpc.workers.dev/sessions.svg)](#community-totals) &nbsp; [![community · tokens](https://claude-rpc-totals.claude-rpc.workers.dev/tokens.svg)](#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/year-on-claude.svg" width="560" alt="Year-on-claude card — hours, prompts, tokens, lines, cost, daily activity strip" />
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
- Claude Code Discord desktop
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`.** It checks Node version, hook registration, daemon liveness, Discord IPC connection, aggregate freshness, and privacy resolution with a one-line fix hint per failure.
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 # 270+ tests, ~2s
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` with zero deps. The CI pipeline ([release.yml](.github/workflows/release.yml)) runs the suite (plus the Cloudflare Worker's own tests) across Node 18/20/22 and gates the matrix build and the npm publish behind it. Every pure/logic module in `src/*.js` is unit-tested, including the MCP transport and the SVG renderers; the long-running daemon, the TUI, and the CLI dispatcher are covered by integration and manual smoke testing rather than unit tests.
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, current user, no admin | Yes — `claude-rpc uninstall` / `removeStartupEntry` |
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** persistence registration (`install()` warns
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-rpc",
3
- "version": "0.19.1",
3
+ "version": "0.20.2",
4
4
  "description": "Discord Rich Presence for Claude Code — live model, project, tokens, and lifetime stats driven by Claude Code's hook system.",
5
5
  "type": "module",
6
6
  "license": "MIT",
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, CANONICAL_EXE } from './paths.js';
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 * as lb from './leaderboard.js';
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
- // In packaged mode the "daemon script" is the exe itself with a subcommand;
100
- // in dev mode it's the src/daemon.js path passed to node. Prefer the
101
- // canonical exe when it exists so we don't keep the user's Downloads copy
102
- // locked open — the canonical install is the long-lived path.
103
- const exe = (IS_PACKAGED && existsSync(CANONICAL_EXE)) ? CANONICAL_EXE : process.execPath;
104
- const args = IS_PACKAGED ? ['daemon'] : [DAEMON_SCRIPT];
105
- const child = spawn(exe, args, { detached: true, stdio: 'ignore', windowsHide: true });
106
- child.unref();
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 { writeFileSync, readFileSync, existsSync, unlinkSync, watch, appendFileSync, mkdirSync, statSync, renameSync } from 'node:fs';
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
- let lastPayloadHash = '';
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. The CLI checks before spawning, but off-path launches
109
- // (a login-startup entry racing a manual start; a packaged exe beside a dev
110
- // run) could start a second daemon that fights over setActivity every ~4s and
111
- // double-counts the additive community total. If a live daemon already owns the
112
- // PID file, step aside; a stale PID (owner gone) means take over.
113
- try {
114
- if (existsSync(PID_PATH)) {
115
- const existing = parseInt(readFileSync(PID_PATH, 'utf8'), 10);
116
- if (existing && existing !== process.pid) {
117
- let alive = false;
118
- try { process.kill(existing, 0); alive = true; } catch { /* stale PID take over */ }
119
- if (alive) {
120
- log(`Another daemon (pid ${existing}) is already running — exiting.`);
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 runningexiting.`);
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
- async function pushPresence() {
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 'cleared' stamp differs from the next frame's hash).
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
- log(`Presence cleared (${reason})`);
389
+ transmit('clear', null, `Presence cleared (${reason})`);
367
390
  return;
368
391
  }
369
392
 
370
393
  const activity = buildActivity({ resolved });
371
- const hash = JSON.stringify(activity);
372
- if (hash === lastPayloadHash) return;
373
- lastPayloadHash = hash;
374
- await client.user.setActivity(activity);
375
- log('Presence updated:', activity.details || '-', '|', activity.state || '-');
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 setActivity usually means the IPC pipe died WITHOUT the client
379
- // emitting a 'disconnected' event (Discord restart, socket reset, OS
380
- // sleep). Left alone, `connected` stays true and the daemon goes silently
381
- // dark forever. Tear the client down and force a backoff reconnect so we
382
- // self-heal. Guarded to connection-shaped errors so a one-off API hiccup
383
- // doesn't needlessly bounce a healthy socket.
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
- lastPayloadHash = '';
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
- lastPayloadHash = '';
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
- lastPayloadHash = '';
545
+ resetTransmit();
463
546
  pushPresence();
464
547
  } },
465
548
  { path: AGGREGATE_PATH, label: 'aggregate', onChange: () => {
466
549
  aggregate = readAggregate() || aggregate;
467
- lastPayloadHash = '';
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
- lastPayloadHash = '';
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
- lastPayloadHash = '';
680
+ resetTransmit();
598
681
  pushPresence();
599
682
  }
600
683
  } catch (e) {
@@ -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._offset);
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 || {})) {
package/src/version.js CHANGED
@@ -11,7 +11,7 @@ import { readFileSync } from 'node:fs';
11
11
  import { join } from 'node:path';
12
12
  import { ROOT } from './paths.js';
13
13
 
14
- const BAKED = '0.19.1';
14
+ const BAKED = '0.20.2';
15
15
 
16
16
  function readPkgVersion() {
17
17
  try {