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 +40 -29
- package/package.json +1 -1
- package/src/cli.js +64 -33
- package/src/format.js +3 -1
- package/src/scanner.js +35 -13
- 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/package.json
CHANGED
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,
|
|
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
|
-
|
|
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 =
|
|
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 (
|
|
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 =
|
|
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 (!
|
|
179
|
+
if (!daemonAlive()) { startDaemon(); return; }
|
|
155
180
|
if (Date.now() >= deadline) {
|
|
156
|
-
const pid =
|
|
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 =
|
|
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 =
|
|
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 (!
|
|
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 ${
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
const
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
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 || {})) {
|