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 +40 -29
- package/package.json +1 -1
- package/src/cli.js +53 -12
- package/src/scanner.js +20 -2
- package/src/version.js +1 -1
package/README.md
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
<div align="center">
|
|
2
2
|
|
|
3
|
-
<img src="
|
|
4
|
-
<img src="https://cdn.qualit.ly/clawd-working-typing.gif" width="120" alt="thinking" />
|
|
5
|
-
<img src="https://cdn.qualit.ly/clawd-notification.gif" width="120" alt="notification" />
|
|
6
|
-
<img src="https://cdn.qualit.ly/clawd-sleeping.gif" width="120" alt="idle" />
|
|
3
|
+
<img src="docs/header.svg" width="820" alt="claude-rpc — Discord Rich Presence for Claude Code: live model, project, tokens, and lifetime stats, driven by the hooks Claude Code already fires. Install with: npx claude-rpc setup" />
|
|
7
4
|
|
|
8
|
-
|
|
5
|
+
<br/><br/>
|
|
9
6
|
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
<img src="https://cdn.qualit.ly/clawd-working-building.gif" width="104" alt="working" />
|
|
8
|
+
<img src="https://cdn.qualit.ly/clawd-working-typing.gif" width="104" alt="thinking" />
|
|
9
|
+
<img src="https://cdn.qualit.ly/clawd-notification.gif" width="104" alt="waiting on you" />
|
|
10
|
+
<img src="https://cdn.qualit.ly/clawd-sleeping.gif" width="104" alt="idle" />
|
|
12
11
|
|
|
13
|
-
|
|
12
|
+
<sub>the card's live states — <b>working</b> · <b>thinking</b> · <b>waiting</b> · <b>idle</b></sub>
|
|
13
|
+
|
|
14
|
+
<br/>
|
|
15
|
+
|
|
16
|
+
**Discord Rich Presence for [Claude Code](https://claude.com/claude-code)** — your live model, project, tokens, and lifetime stats, driven by the hooks Claude Code already fires.
|
|
17
|
+
|
|
18
|
+
**[claude-rpc.vercel.app →](https://claude-rpc.vercel.app)** — see it in one page.
|
|
14
19
|
|
|
15
20
|
[](#community-totals) [](#community-totals)
|
|
16
21
|
|
|
@@ -144,7 +149,11 @@ claude-rpc card --range year --out year-on-claude.svg
|
|
|
144
149
|
```
|
|
145
150
|
|
|
146
151
|
<div align="center">
|
|
147
|
-
<img src="site/examples/
|
|
152
|
+
<img src="site/examples/week-on-claude.svg" width="270" alt="Week-on-claude summary card" />
|
|
153
|
+
<img src="site/examples/month-on-claude.svg" width="270" alt="Month-on-claude summary card" />
|
|
154
|
+
<img src="site/examples/year-on-claude.svg" width="270" alt="Year-on-claude summary card — hours, prompts, tokens, lines, cost, daily activity strip" />
|
|
155
|
+
<br/>
|
|
156
|
+
<sub><code>card --range week · month · year</code> (also <code>all</code>) — live at <code>/api/card.svg</code> while the daemon's up</sub>
|
|
148
157
|
</div>
|
|
149
158
|
|
|
150
159
|
`badge --gist` writes the SVG to your own GitHub gist (creates one on first run, updates it after — id remembered in `config.json`). The URL printed back is README-ready and updates every time you re-run the command. Uses `gh` if available, else `GH_TOKEN` with `gist` scope.
|
|
@@ -173,21 +182,9 @@ For a complete account of the sensitive things claude-rpc does — startup persi
|
|
|
173
182
|
|
|
174
183
|
## three pieces, glued by json files
|
|
175
184
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
│ lifecycle event (stdin JSON) │ IPC frame
|
|
180
|
-
▼ │
|
|
181
|
-
┌──────────┐ state.json ┌──────────┐ │
|
|
182
|
-
│ hook.js │ ───────────────▶ │ daemon.js│ ────────────────┘
|
|
183
|
-
└──────────┘ └──────────┘
|
|
184
|
-
▲
|
|
185
|
-
│ aggregate.json
|
|
186
|
-
│
|
|
187
|
-
┌────────────┐
|
|
188
|
-
│ scanner.js │ ◀── ~/.claude/projects/*.jsonl
|
|
189
|
-
└────────────┘
|
|
190
|
-
```
|
|
185
|
+
<div align="center">
|
|
186
|
+
<img src="docs/architecture.svg" width="760" alt="Architecture: Claude Code fires a lifecycle event (stdin JSON) into hook.js, which writes state.json; daemon.js reads it and pushes an IPC frame to the Discord desktop client. scanner.js reads the ~/.claude/projects transcripts and feeds aggregate.json into daemon.js." />
|
|
187
|
+
</div>
|
|
191
188
|
|
|
192
189
|
No database, no message bus, no background polling when Claude Code isn't running. State on disk you can `cat` and `jq`. **Zero runtime dependencies** — even the Discord Rich Presence IPC client is hand-rolled (`src/discord-ipc.js`).
|
|
193
190
|
|
|
@@ -263,6 +260,13 @@ The full default config is in [`src/default-config.js`](src/default-config.js)
|
|
|
263
260
|
|
|
264
261
|
## commands
|
|
265
262
|
|
|
263
|
+
`claude-rpc --help` lists them all — and after `setup` you rarely need any.
|
|
264
|
+
|
|
265
|
+
<details>
|
|
266
|
+
<summary><b>full command reference</b></summary>
|
|
267
|
+
|
|
268
|
+
<br/>
|
|
269
|
+
|
|
266
270
|
| Command | What it does |
|
|
267
271
|
| ---------------- | ------------ |
|
|
268
272
|
| `setup` | Install Claude Code hooks (test-fires one synthetic SessionStart to prove the pipe works) |
|
|
@@ -295,19 +299,28 @@ The full default config is in [`src/default-config.js`](src/default-config.js)
|
|
|
295
299
|
|
|
296
300
|
Exit codes: `0` ok · `1` user error · `2` system error · `3` wrong state. `--version` and `--help` work as expected.
|
|
297
301
|
|
|
302
|
+
</details>
|
|
303
|
+
|
|
298
304
|
## troubleshooting
|
|
299
305
|
|
|
300
|
-
**First step is always `claude-rpc doctor
|
|
306
|
+
**First step is always `claude-rpc doctor`** — it checks Node, hook registration, daemon liveness, Discord IPC, aggregate freshness, and privacy resolution, with a one-line fix hint per failure.
|
|
307
|
+
|
|
308
|
+
<details>
|
|
309
|
+
<summary><b>common issues</b></summary>
|
|
310
|
+
|
|
311
|
+
<br/>
|
|
301
312
|
|
|
302
313
|
- **Discord doesn't show anything.** Discord *desktop* must be running. The browser client doesn't expose the local IPC bridge. `claude-rpc tail` shows what the daemon is actually doing.
|
|
303
314
|
- **Hooks don't fire.** `claude-rpc setup` re-registers them and now test-fires a synthetic `SessionStart` end-to-end, so a broken hook command surfaces immediately. Restart Claude Code afterwards so it re-reads its hook config.
|
|
304
315
|
- **Config error.** Bad JSON in `config.json` no longer crashes anything — the daemon logs one line and falls back to baked defaults. `claude-rpc tail` shows the parse error verbatim.
|
|
305
316
|
- **Old binary path baked into hooks.** Common after manual exe replacement. `claude-rpc setup` rewrites hook entries to point at the canonical install location.
|
|
306
317
|
|
|
318
|
+
</details>
|
|
319
|
+
|
|
307
320
|
## development
|
|
308
321
|
|
|
309
322
|
```sh
|
|
310
|
-
npm test #
|
|
323
|
+
npm test # 430+ tests, ~2s
|
|
311
324
|
npm run lint # eslint over src + test
|
|
312
325
|
npm run start # run daemon in foreground
|
|
313
326
|
npm run serve # web dashboard against your real data
|
|
@@ -315,9 +328,7 @@ npm run dashboard # Electron settings GUI (dev mode)
|
|
|
315
328
|
npm run build:exe # SEA single-file binary for the current OS
|
|
316
329
|
```
|
|
317
330
|
|
|
318
|
-
Tests are `node --test
|
|
319
|
-
|
|
320
|
-
Where the project is headed (and what it will deliberately never do) lives in [`ROADMAP.md`](ROADMAP.md).
|
|
331
|
+
Tests are `node --test`, zero deps; CI ([release.yml](.github/workflows/release.yml)) runs the suite (+ the Worker's) across Node 18/20/22 and gates the matrix build and npm publish. Where the project's headed (and what it'll deliberately never do): [`ROADMAP.md`](ROADMAP.md).
|
|
321
332
|
|
|
322
333
|
## license
|
|
323
334
|
|
package/package.json
CHANGED
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
|
-
|
|
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.
|
|
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 || {})) {
|