claude-rpc 0.20.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-rpc",
3
- "version": "0.20.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
@@ -12,22 +12,57 @@ if (process.platform === 'win32' && process.stdout.isTTY) {
12
12
  }
13
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
18
  import { spawnDaemonDetached } from './ensure-daemon.js';
30
- import * as lb from './leaderboard.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';
@@ -1972,6 +2007,12 @@ process.on('unhandledRejection', (e) => {
1972
2007
  // ESM (dev) and CommonJS (esbuild → SEA bundle) — CJS doesn't allow
1973
2008
  // top-level await.
1974
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();
1975
2016
  switch (cmd) {
1976
2017
  case '--version':
1977
2018
  case '-V':
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.20.1';
14
+ const BAKED = '0.20.2';
15
15
 
16
16
  function readPkgVersion() {
17
17
  try {