claude-rpc 0.20.1 → 0.20.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-rpc",
3
- "version": "0.20.1",
3
+ "version": "0.20.3",
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,24 +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 } from './paths.js';
13
+ import { DAEMON_SCRIPT, 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 { spawnDaemonDetached } from './ensure-daemon.js';
30
- import * as lb from './leaderboard.js';
18
+ import { spawnDaemonDetached, daemonAlive } 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
+ }
31
66
  import { VERSION } from './version.js';
32
67
  import { fail, tailLines, heat, sparkline, fmtDelta, topPercentile, EX_USER_ERROR, EX_BAD_STATE, EX_SYS_ERROR } from './ui.js';
33
68
  import { randomUUID } from 'node:crypto';
@@ -81,18 +116,8 @@ function takeValue(v, flag) {
81
116
  return v;
82
117
  }
83
118
 
84
- function isAlive(pid) {
85
- try { process.kill(pid, 0); return true; } catch { return false; }
86
- }
87
-
88
- function daemonPid() {
89
- if (!existsSync(PID_PATH)) return null;
90
- const pid = Number(readFileSync(PID_PATH, 'utf8'));
91
- return pid && isAlive(pid) ? pid : null;
92
- }
93
-
94
119
  function startDaemon({ quiet = false } = {}) {
95
- const pid = daemonPid();
120
+ const pid = daemonAlive();
96
121
  if (pid) {
97
122
  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}`);
98
123
  return false;
@@ -119,7 +144,7 @@ async function confirmDaemonUp({ timeoutMs = 3000, intervalMs = 100 } = {}) {
119
144
  const deadline = Date.now() + timeoutMs;
120
145
  for (;;) {
121
146
  await new Promise((r) => setTimeout(r, intervalMs));
122
- if (daemonPid()) {
147
+ if (daemonAlive()) {
123
148
  console.log(` ${c.green}✓${c.reset} ${'daemon confirmed'.padEnd(16)}${c.dim}up — connecting to Discord${c.reset}`);
124
149
  return true;
125
150
  }
@@ -131,7 +156,7 @@ async function confirmDaemonUp({ timeoutMs = 3000, intervalMs = 100 } = {}) {
131
156
  }
132
157
 
133
158
  function stopDaemon({ quiet = false } = {}) {
134
- const pid = daemonPid();
159
+ const pid = daemonAlive();
135
160
  if (!pid) { if (!quiet) console.log(` ${c.cyan}·${c.reset} daemon not running`); return false; }
136
161
  try {
137
162
  process.kill(pid, 'SIGTERM');
@@ -151,9 +176,9 @@ function restartDaemon() {
151
176
  // one exited. Give up after ~3s, force-kill the wedged pid, and start anyway.
152
177
  const deadline = Date.now() + 3000;
153
178
  const tick = () => {
154
- if (!daemonPid()) { startDaemon(); return; }
179
+ if (!daemonAlive()) { startDaemon(); return; }
155
180
  if (Date.now() >= deadline) {
156
- const pid = daemonPid();
181
+ const pid = daemonAlive();
157
182
  if (pid) { try { process.kill(pid, 'SIGKILL'); } catch { /* already gone */ } }
158
183
  startDaemon();
159
184
  return;
@@ -376,7 +401,7 @@ function showStatus() {
376
401
  state.liveSessions = live;
377
402
  state.usage = readUsageCache();
378
403
  const vars = buildVars(state, config, aggregate);
379
- const pid = daemonPid();
404
+ const pid = daemonAlive();
380
405
 
381
406
  console.log('');
382
407
  console.log(` ${c.bold}${c.magenta}◆ Claude RPC${c.reset} ${c.dim}— Discord Rich Presence for Claude Code${c.reset}`);
@@ -1828,7 +1853,7 @@ function tailLog() {
1828
1853
  function overview() {
1829
1854
  const setUp = hasUserConfig();
1830
1855
  const cfg = loadConfig();
1831
- const pid = daemonPid();
1856
+ const pid = daemonAlive();
1832
1857
 
1833
1858
  console.log('');
1834
1859
  console.log(` ${c.bold}${c.magenta}◆ claude-rpc${c.reset} ${c.dim}v${VERSION} — Discord Rich Presence for Claude Code${c.reset}`);
@@ -1972,6 +1997,12 @@ process.on('unhandledRejection', (e) => {
1972
1997
  // ESM (dev) and CommonJS (esbuild → SEA bundle) — CJS doesn't allow
1973
1998
  // top-level await.
1974
1999
  (async () => {
2000
+ // Fast-exit commands touch none of the lazy stats/format/install graph, so
2001
+ // skip the module load and keep them instant. Everything else loads it once
2002
+ // up front, which makes every binding available to the handlers below exactly
2003
+ // as before. (daemon/serve/wrapped/hook only dynamic-import their own module.)
2004
+ const FAST_PATHS = new Set(['--version', '-V', '-v', '--help', '-h', 'help', 'start', 'stop', 'restart', 'hook', 'daemon', 'serve', 'wrapped']);
2005
+ if (!FAST_PATHS.has(cmd)) await loadStats();
1975
2006
  switch (cmd) {
1976
2007
  case '--version':
1977
2008
  case '-V':
@@ -2004,7 +2035,7 @@ process.on('unhandledRejection', (e) => {
2004
2035
  // console, the grandchild node allocates a fresh one, and Windows 11
2005
2036
  // pops it as a visible Windows Terminal window whose closure kills
2006
2037
  // the daemon.
2007
- if (!daemonPid()) {
2038
+ if (!daemonAlive()) {
2008
2039
  let script = null;
2009
2040
  try {
2010
2041
  const r = spawnSync('npm', ['root', '-g'], {
@@ -2024,7 +2055,7 @@ process.on('unhandledRejection', (e) => {
2024
2055
  child.unref();
2025
2056
  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}`);
2026
2057
  } else {
2027
- console.log(` ${c.cyan}·${c.reset} ${'daemon running'.padEnd(16)}${c.dim}pid ${daemonPid()}${c.reset}`);
2058
+ console.log(` ${c.cyan}·${c.reset} ${'daemon running'.padEnd(16)}${c.dim}pid ${daemonAlive()}${c.reset}`);
2028
2059
  }
2029
2060
  } else {
2030
2061
  startDaemon();
package/src/format.js CHANGED
@@ -870,7 +870,9 @@ export function applyIdle(state, cfg = {}) {
870
870
  if (state.status === 'notification') {
871
871
  const notifAge = now - (state.lastNotification || 0);
872
872
  if (notifAge <= notificationMs) return state;
873
- state = { ...state, status: 'idle' };
873
+ // Going idle after a notification — wipe the current-activity slots so idle
874
+ // frames don't render stale file/tool names (same as the idle path below).
875
+ state = { ...state, status: 'idle', currentTool: null, currentFile: null, filesOpened: [], filesEdited: [], filesRead: [] };
874
876
  }
875
877
 
876
878
  // Truly dormant: no live transcripts AND local state is old → 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
  }
@@ -691,18 +699,22 @@ function writeCache(cache) {
691
699
  // Per-day notification counts come from a hook-side append log, since
692
700
  // transcripts don't carry Notification events reliably.
693
701
  function readNotificationsByDay() {
694
- if (!existsSync(EVENTS_LOG_PATH)) return {};
695
702
  const out = {};
696
- try {
697
- const raw = readFileSync(EVENTS_LOG_PATH, 'utf8');
698
- for (const line of raw.split('\n')) {
699
- if (!line) continue;
700
- const e = safeJson(line);
701
- if (!e || e.type !== 'notification' || !e.ts) continue;
702
- const k = dayKey(e.ts);
703
- out[k] = (out[k] || 0) + 1;
704
- }
705
- } catch { /* events log unreadable/truncated return whatever we got, the aggregate will just under-count notifications */ }
703
+ // The hook rotates events.jsonl to `.1` at 5MB; read both (oldest first) so a
704
+ // rotation doesn't silently drop a day's notification counts.
705
+ for (const path of [EVENTS_LOG_PATH + '.1', EVENTS_LOG_PATH]) {
706
+ if (!existsSync(path)) continue;
707
+ try {
708
+ const raw = readFileSync(path, 'utf8');
709
+ for (const line of raw.split('\n')) {
710
+ if (!line) continue;
711
+ const e = safeJson(line);
712
+ if (!e || e.type !== 'notification' || !e.ts) continue;
713
+ const k = dayKey(e.ts);
714
+ out[k] = (out[k] || 0) + 1;
715
+ }
716
+ } catch { /* a rotation file unreadable/truncated — count what we can */ }
717
+ }
706
718
  return out;
707
719
  }
708
720
 
@@ -721,10 +733,11 @@ export function readAggregate() {
721
733
 
722
734
  export { dayKey, weekKey, hourKey };
723
735
 
724
- function aggregateFrom(cache) {
736
+ export function aggregateFrom(cache) {
725
737
  const agg = {
726
738
  sessions: 0,
727
739
  subagentRuns: 0,
740
+ subagentActiveMs: 0,
728
741
  inputTokens: 0,
729
742
  outputTokens: 0,
730
743
  cacheReadTokens: 0,
@@ -814,6 +827,15 @@ function aggregateFrom(cache) {
814
827
  }
815
828
  if (isSub) {
816
829
  agg.subagentRuns += 1;
830
+ // Subagent active time is tracked separately and deliberately NOT folded
831
+ // into agg.activeMs. A subagent's wall-time overlaps its parent session
832
+ // (and parallel subagents overlap each other), and gaps >=5min are already
833
+ // excluded from the parent's activeMs (see ACTIVE_GAP_CAP_MS) — so summing
834
+ // the scalar would double-count short subagents. agg.activeMs stays an
835
+ // interactive-session measure; subagentActiveMs exposes delegated work as
836
+ // its own honest number. A single unified figure would need interval-union
837
+ // across parent+subagents, not scalar sums.
838
+ agg.subagentActiveMs += summary.activeMs || 0;
817
839
  // Subagents still contribute tokens/tools/lines/cost to per-day/week/hour buckets.
818
840
  const mergeSubBuckets = (srcMap, destMap) => {
819
841
  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.20.1';
14
+ const BAKED = '0.20.3';
15
15
 
16
16
  function readPkgVersion() {
17
17
  try {