claude-rpc 0.13.7 → 0.14.0

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
@@ -7,7 +7,7 @@
7
7
 
8
8
  # claude-rpc
9
9
 
10
- **Discord Rich Presence for [Claude Code](https://claude.com/claude-code).**
10
+ **Discord Rich Presence (RPC) for [Claude Code](https://claude.com/claude-code).**
11
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.
12
12
 
13
13
  **→ [claude-rpc.vercel.app](https://claude-rpc.vercel.app)** — what it looks like, in one page.
@@ -38,9 +38,11 @@ A small Node daemon that takes the lifecycle events Claude Code already fires an
38
38
  **macOS / Linux / any Node 18+** — one command:
39
39
 
40
40
  ```sh
41
- npx claude-rpc setup
41
+ npx claude-rpc@latest setup
42
42
  ```
43
43
 
44
+ (The `@latest` matters — bare `npx claude-rpc` will happily reuse a stale cached copy.)
45
+
44
46
  That installs `claude-rpc` globally, wires the hooks into Claude Code, and starts the daemon — no separate `start` step. Open Claude Code in any project and the card appears within a second. Something looks wrong? `claude-rpc doctor` (or `claude-rpc doctor --fix` to auto-repair).
45
47
 
46
48
  **Prefer a one-liner that figures it out for you?**
@@ -312,6 +314,8 @@ npm run build:exe # SEA single-file binary for the current OS
312
314
 
313
315
  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.
314
316
 
317
+ Where the project is headed (and what it will deliberately never do) lives in [`ROADMAP.md`](ROADMAP.md).
318
+
315
319
  ## license
316
320
 
317
321
  [MIT](LICENSE) © Archer Simmons
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-rpc",
3
- "version": "0.13.7",
3
+ "version": "0.14.0",
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
@@ -22,10 +22,11 @@ import { maybeNudge } from './nudge.js';
22
22
  import { badgeSvg } from './badge.js';
23
23
  import { fmtCost } from './pricing.js';
24
24
  import { addPrivateCwd, removePrivateCwd, listPrivateCwds, resolveVisibility } from './privacy.js';
25
+ import { parseDuration, setPause, clearPause, pauseUntil } from './pause.js';
25
26
  import { loadConfig, hasUserConfig } from './config.js';
26
27
  import * as lb from './leaderboard.js';
27
28
  import { VERSION } from './version.js';
28
- import { fail, EX_USER_ERROR, EX_BAD_STATE, EX_SYS_ERROR } from './ui.js';
29
+ import { fail, tailLines, EX_USER_ERROR, EX_BAD_STATE, EX_SYS_ERROR } from './ui.js';
29
30
  import { randomUUID } from 'node:crypto';
30
31
  import { createInterface } from 'node:readline';
31
32
  import { basename } from 'node:path';
@@ -110,7 +111,7 @@ function box(title, lines, minWidth = 64) {
110
111
  const termWidth = process.stdout.columns || 100;
111
112
  const maxAllowed = Math.max(40, termWidth - 2);
112
113
  const width = Math.min(maxAllowed, Math.max(minWidth, longest + 4, title.length + 8));
113
- const top = `${c.gray}┌─ ${c.reset}${c.bold}${title}${c.reset} ${c.gray}${'─'.repeat(Math.max(0, width - 4 - title.length))}┐${c.reset}`;
114
+ const top = `${c.gray}┌─ ${c.reset}${c.bold}${title}${c.reset} ${c.gray}${'─'.repeat(Math.max(0, width - 5 - title.length))}┐${c.reset}`;
114
115
  const bottom = `${c.gray}└${'─'.repeat(width - 2)}┘${c.reset}`;
115
116
  console.log(top);
116
117
  for (const raw of lines) {
@@ -932,6 +933,78 @@ function doPrivacy() {
932
933
  console.log('');
933
934
  }
934
935
 
936
+ // ── Pause / resume ───────────────────────────────────────────────────────
937
+ //
938
+ // `claude-rpc pause [30m|2h|1h30m|90]` → snooze the Discord card globally
939
+ // (default 1h). `claude-rpc resume` (or `pause off`) lifts it early; expiry
940
+ // lifts it automatically. Privacy controls are per-cwd — this is the
941
+ // "I'm screen-sharing" switch that hides everything regardless of project.
942
+
943
+ function fmtClock(ts) {
944
+ const d = new Date(ts);
945
+ return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
946
+ }
947
+
948
+ function doPause(argv) {
949
+ const arg = (argv[0] || '').toLowerCase();
950
+ if (arg === 'off' || arg === 'resume') return doResume();
951
+ if (arg === 'status') {
952
+ const until = pauseUntil();
953
+ if (until) console.log(`${c.yellow}●${c.reset} paused until ${c.cyan}${fmtClock(until)}${c.reset}`);
954
+ else console.log(`${c.green}○${c.reset} not paused`);
955
+ return;
956
+ }
957
+ const ms = parseDuration(argv[0]);
958
+ if (ms === null) {
959
+ fail(`could not parse duration: ${argv[0]}`,
960
+ { hint: 'use 30m, 2h, 1h30m, or a bare number of minutes (default: 1h)', code: EX_USER_ERROR });
961
+ }
962
+ const until = setPause(ms);
963
+ console.log(`${c.green}✓${c.reset} presence paused until ${c.cyan}${fmtClock(until)}${c.reset}`);
964
+ console.log(`${c.dim} the daemon clears the card within a few seconds. Resume early with ${c.reset}${c.cyan}claude-rpc resume${c.reset}`);
965
+ }
966
+
967
+ function doResume() {
968
+ const was = pauseUntil();
969
+ clearPause();
970
+ if (was) {
971
+ console.log(`${c.green}✓${c.reset} presence resumed ${c.dim}(was paused until ${fmtClock(was)})${c.reset}`);
972
+ } else {
973
+ console.log(`${c.dim}presence wasn't paused.${c.reset}`);
974
+ }
975
+ }
976
+
977
+ // ── Export ───────────────────────────────────────────────────────────────
978
+ //
979
+ // `claude-rpc export [--csv] [--out <file>]` — the full aggregate as JSON, or
980
+ // the per-day breakdown as CSV (same shape the dashboard's /api/export routes
981
+ // serve, without needing the server up). Writes to stdout unless --out, so it
982
+ // pipes cleanly into jq / a spreadsheet import.
983
+
984
+ async function doExport(argv) {
985
+ const csv = argv.includes('--csv');
986
+ let out = '';
987
+ const i = argv.findIndex((a) => a === '--out' || a === '-o');
988
+ if (i !== -1) out = argv[i + 1] || '';
989
+ const aggregate = readAggregate();
990
+ if (!aggregate) {
991
+ fail('no aggregate yet — nothing to export', { hint: 'run `claude-rpc scan` first', code: EX_BAD_STATE });
992
+ }
993
+ let payload;
994
+ if (csv) {
995
+ const { aggregateToCsv } = await import('./server/api.js');
996
+ payload = aggregateToCsv(aggregate);
997
+ } else {
998
+ payload = JSON.stringify(aggregate, null, 2) + '\n';
999
+ }
1000
+ if (out) {
1001
+ writeFileSync(out, payload);
1002
+ console.log(`${c.green}✓${c.reset} Wrote ${c.cyan}${out}${c.reset} (${payload.length} bytes, ${csv ? 'CSV' : 'JSON'})`);
1003
+ } else {
1004
+ process.stdout.write(payload);
1005
+ }
1006
+ }
1007
+
935
1008
  // ── Community totals ─────────────────────────────────────────────────────
936
1009
  //
937
1010
  // `claude-rpc community` → show current state + endpoint
@@ -1060,16 +1133,46 @@ function readFlag(argv, name) {
1060
1133
 
1061
1134
  function profileStatus() {
1062
1135
  const p = (loadConfig().profile) || {};
1136
+ const handleOk = lb.isValidHandle(p.handle);
1137
+ const boardUrl = handleOk ? `https://claude-rpc.vercel.app/u/${encodeURIComponent(p.handle)}` : '';
1138
+
1063
1139
  console.log('');
1064
- console.log(` ${c.bold}claude-rpc profile${c.reset} ${c.dim}— public leaderboard identity${c.reset}`);
1140
+ console.log(` ${c.bold}${c.magenta}◆ profile${c.reset} ${c.dim}— public leaderboard identity${c.reset}`);
1065
1141
  console.log('');
1066
- console.log(` state: ${p.enabled ? `${c.green}on${c.reset}` : `${c.dim}off${c.reset}`}`);
1067
- console.log(` handle: ${p.handle ? c.cyan + p.handle + c.reset : c.dim + '(unset)' + c.reset}`);
1068
- console.log(` name: ${p.displayName ? p.displayName : c.dim + '(unset)' + c.reset}`);
1069
- console.log(` github: ${p.githubUser ? `${p.githubUser} ${c.dim}(verify to earn ✓)${c.reset}` : c.dim + '(unset — unverified)' + c.reset}`);
1142
+
1143
+ const githubLine = p.githubUser
1144
+ ? `${p.githubUser}${p.verified ? ` ${c.green}✓ verified${c.reset}` : ` ${c.dim}(unverified)${c.reset}`}`
1145
+ : `${c.dim}—${c.reset}`;
1146
+ box('profile', [
1147
+ pair('state', p.enabled ? `${c.green}● publishing${c.reset}` : `${c.dim}○ off${c.reset}`, ''),
1148
+ pair('handle', handleOk ? `${c.cyan}${p.handle}${c.reset}` : `${c.dim}(unset)${c.reset}`, ''),
1149
+ pair('name', p.displayName || `${c.dim}—${c.reset}`, ''),
1150
+ pair('github', githubLine, ''),
1151
+ ...(p.enabled && handleOk ? [pair('board', boardUrl, c.cyan)] : []),
1152
+ ]);
1070
1153
  console.log('');
1071
- if (!p.handle) console.log(` ${c.dim}set one with:${c.reset} claude-rpc profile set --handle <name> [--name "..."] [--github <user>]`);
1072
- else if (!p.enabled) console.log(` ${c.dim}publish to the board with:${c.reset} claude-rpc profile on`);
1154
+
1155
+ // Setup checklist same shape every time, so the user always sees where
1156
+ // they are and the exact next command. This is the screen the daemon's
1157
+ // breadcrumbs point back to.
1158
+ const steps = [
1159
+ { done: handleOk, label: 'set a handle', cmd: 'claude-rpc profile set --handle <name>', note: p.handle },
1160
+ { done: !!p.enabled, label: 'enable publishing', cmd: 'claude-rpc profile on', note: 'daemon republishes automatically' },
1161
+ { done: !!p.verified, label: 'verify on GitHub', cmd: 'claude-rpc profile verify', note: p.githubUser ? `@${p.githubUser}` : '' },
1162
+ ];
1163
+ if (steps.every((s) => s.done)) {
1164
+ console.log(` ${c.green}✓${c.reset} all set — you're live at ${c.cyan}${boardUrl}${c.reset}`);
1165
+ } else {
1166
+ const nextIdx = steps.findIndex((s) => !s.done);
1167
+ box('next steps', steps.map((s, i) => {
1168
+ const mark = s.done ? `${c.green}✓${c.reset}` : (i === nextIdx ? `${c.yellow}○${c.reset}` : `${c.dim}○${c.reset}`);
1169
+ const label = s.done ? `${c.dim}${s.label}${c.reset}` : `${c.bold}${s.label}${c.reset}`;
1170
+ const tail = s.done
1171
+ ? `${c.dim}${s.note || 'done'}${c.reset}`
1172
+ : `${c.cyan}${s.cmd}${c.reset}${i === nextIdx ? ` ${c.dim}← next${c.reset}` : ''}`;
1173
+ return `${mark} ${i + 1}. ${label}${' '.repeat(Math.max(1, 20 - s.label.length))}${tail}`;
1174
+ }));
1175
+ }
1073
1176
  console.log('');
1074
1177
  }
1075
1178
 
@@ -1094,6 +1197,9 @@ function profileSet(argv) {
1094
1197
  if (!u) return fail(`invalid GitHub username: ${rawGh}`, { code: EX_USER_ERROR });
1095
1198
  next.githubUser = u;
1096
1199
  }
1200
+ // The ✓ belongs to the account that was verified — switching accounts
1201
+ // means re-verifying.
1202
+ if (next.githubUser !== (userCfg.profile || {}).githubUser) delete next.verified;
1097
1203
  }
1098
1204
 
1099
1205
  userCfg.profile = next;
@@ -1127,7 +1233,7 @@ function profileEnable(on) {
1127
1233
  console.log(`${c.green}✓${c.reset} leaderboard publishing ${on ? 'enabled' : 'disabled'}`);
1128
1234
  if (on) {
1129
1235
  console.log(` ${c.dim}publish now with ${c.reset}${c.cyan}claude-rpc profile publish${c.reset}${c.dim} (or wait for the next daemon flush).${c.reset}`);
1130
- console.log(` ${c.dim}earn the ✓ with ${c.reset}${c.cyan}claude-rpc profile verify${c.reset}${c.dim}.${c.reset}`);
1236
+ profileStatus();
1131
1237
  }
1132
1238
  }
1133
1239
 
@@ -1158,10 +1264,12 @@ async function profileVerify() {
1158
1264
  const cfg = loadConfig();
1159
1265
  const profile = cfg.profile || {};
1160
1266
  const community = cfg.community || {};
1267
+ // No --github required up front: the worker treats it as a hint only and
1268
+ // takes the authoritative identity from whoever owns the proof gist —
1269
+ // and publishing that gist already requires gh auth, so the account is
1270
+ // known by the time it matters.
1161
1271
  if (!profile.githubUser) {
1162
- return fail('set a GitHub username first', {
1163
- hint: 'claude-rpc profile set --github <user>', code: EX_BAD_STATE,
1164
- });
1272
+ console.log(`${c.dim}no --github set your verified identity will be the account that owns the proof gist.${c.reset}`);
1165
1273
  }
1166
1274
  if (!community.instanceId) {
1167
1275
  return fail('enable the profile first', { hint: 'claude-rpc profile on', code: EX_BAD_STATE });
@@ -1186,7 +1294,7 @@ async function profileVerify() {
1186
1294
  await flushProfile(cfg);
1187
1295
  }
1188
1296
  console.log(`${c.dim}requesting a verification token…${c.reset}`);
1189
- const start = await post('/verify/start', { instanceId: community.instanceId, githubUser: profile.githubUser });
1297
+ const start = await post('/verify/start', { instanceId: community.instanceId, githubUser: profile.githubUser || null });
1190
1298
  if (!start.json?.token) return fail(`verify/start failed: ${start.json?.error || start.status}`, { code: EX_SYS_ERROR });
1191
1299
  const token = start.json.token;
1192
1300
 
@@ -1206,13 +1314,11 @@ async function profileVerify() {
1206
1314
  const check = await post('/verify/check', { instanceId: community.instanceId, gistId: gist.id });
1207
1315
  if (check.json?.verified) {
1208
1316
  const who = check.json.githubUser || gist.owner || profile.githubUser;
1209
- // Persist the authoritative owner so the local profile + future publishes
1210
- // match what got verified.
1211
- if (who && who !== profile.githubUser) {
1212
- const userCfg = readJson(CONFIG_PATH, {});
1213
- userCfg.profile = { ...(userCfg.profile || {}), githubUser: who };
1214
- writeFileSync(CONFIG_PATH, JSON.stringify(userCfg, null, 2));
1215
- }
1317
+ // Persist the authoritative owner + a local verified marker so the
1318
+ // profile checklist and future publishes match what got verified.
1319
+ const userCfg = readJson(CONFIG_PATH, {});
1320
+ userCfg.profile = { ...(userCfg.profile || {}), ...(who ? { githubUser: who } : {}), verified: true };
1321
+ writeFileSync(CONFIG_PATH, JSON.stringify(userCfg, null, 2));
1216
1322
  console.log(`${c.green}✓${c.reset} verified as @${who} — you'll show the ✓ on the board.`);
1217
1323
  if (who && profile.githubUser && who.toLowerCase() !== profile.githubUser.toLowerCase()) {
1218
1324
  console.log(` ${c.dim}(your gist is owned by @${who}, so the profile now uses that account.)${c.reset}`);
@@ -1260,8 +1366,7 @@ function tailLog() {
1260
1366
  return;
1261
1367
  }
1262
1368
  // Print the last ~30 lines, then follow.
1263
- const raw = readFileSync(LOG_PATH, 'utf8').split('\n');
1264
- const tail = raw.slice(-31, -1);
1369
+ const tail = tailLines(readFileSync(LOG_PATH, 'utf8'));
1265
1370
  for (const line of tail) process.stdout.write(line + '\n');
1266
1371
  let lastSize = readFileSync(LOG_PATH).length;
1267
1372
  console.log(`${c.dim}-- tailing ${LOG_PATH} (Ctrl-C to stop) --${c.reset}`);
@@ -1375,6 +1480,9 @@ function help() {
1375
1480
  ['mcp install', 'Wire the stats MCP server into Claude Code (one command)'],
1376
1481
  ['mcp', 'Run the MCP server (stdio) — exposes your stats to Claude'],
1377
1482
  ['wrapped', 'Open your animated year-in-review (Claude Wrapped)'],
1483
+ ['pause', 'Snooze the Discord card globally (pause [30m|2h], default 1h)'],
1484
+ ['resume', 'Lift a pause early'],
1485
+ ['export', 'Dump the aggregate as JSON, or daily rows as CSV (--csv --out)'],
1378
1486
  ['private', 'Mark the current directory as private (hide from Discord)'],
1379
1487
  ['public', 'Un-mark the current directory'],
1380
1488
  ['privacy', 'Show resolved visibility for the current directory'],
@@ -1483,6 +1591,10 @@ const packagedDefault = IS_PACKAGED && !cmd;
1483
1591
  break;
1484
1592
  }
1485
1593
  case 'wrapped': process.env.CLAUDE_RPC_OPEN_PATH = '/wrapped'; await import('./server/index.js'); break;
1594
+ case 'pause': doPause(process.argv.slice(3)); break;
1595
+ case 'resume':
1596
+ case 'unpause': doResume(); break;
1597
+ case 'export': await doExport(process.argv.slice(3)); break;
1486
1598
  case 'private': doPrivate(); break;
1487
1599
  case 'public': doPublic(); break;
1488
1600
  case 'privacy': doPrivacy(); break;
@@ -1579,8 +1691,11 @@ const packagedDefault = IS_PACKAGED && !cmd;
1579
1691
  // click with no args" install-and-start flow.
1580
1692
  overview();
1581
1693
  } else {
1582
- fail(`unknown command: ${cmd}`,
1583
- { hint: 'run `claude-rpc --help` for the full list', code: EX_USER_ERROR });
1694
+ // Version in the error line is deliberate: the #1 cause of "unknown
1695
+ // command" in the wild is a stale global install resolving instead of
1696
+ // the version the user read the docs for. Make the skew visible.
1697
+ fail(`unknown command: ${cmd} (claude-rpc v${VERSION})`,
1698
+ { hint: 'run `claude-rpc --help` for the full list — if this command should exist, update first: npm install -g claude-rpc@latest', code: EX_USER_ERROR });
1584
1699
  }
1585
1700
  }
1586
1701
  }
package/src/daemon.js CHANGED
@@ -1,17 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  import { writeFileSync, existsSync, unlinkSync, watch, appendFileSync, mkdirSync, statSync, renameSync } from 'node:fs';
3
- import { dirname } from 'node:path';
3
+ import { basename, dirname } from 'node:path';
4
4
  import { Client } from './discord-ipc.js';
5
5
  import { readState } from './state.js';
6
6
  import { buildVars, fillTemplate, framePasses, applyIdle, applyShipped, applyTrigger } from './format.js';
7
7
  import { scan, readAggregate, findLiveSessions, readSessionTokens } from './scanner.js';
8
8
  import { detectGithubUrl } from './git.js';
9
9
  import { applyPrivacy } from './privacy.js';
10
+ import { pauseUntil } from './pause.js';
10
11
  import { loadConfig } from './config.js';
11
12
  import { migrateConfig } from './install.js';
12
13
  import { desktopNotify, postWebhook, shouldWebhook, shouldNotify } from './notify.js';
13
14
  import { humanProject } from './format.js';
14
- import { CONFIG_PATH, STATE_PATH, PID_PATH, LOG_PATH, STATE_DIR, AGGREGATE_PATH } from './paths.js';
15
+ import { CONFIG_PATH, STATE_PATH, PID_PATH, LOG_PATH, STATE_DIR, AGGREGATE_PATH, PAUSE_PATH } from './paths.js';
15
16
 
16
17
  if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true });
17
18
 
@@ -232,7 +233,8 @@ function buildActivity(opts = {}) {
232
233
  } else if (config.modelAssets && state.model && state.status !== 'stale') {
233
234
  const m = String(state.model).toLowerCase();
234
235
  let pick = null;
235
- if (m.includes('opus')) pick = config.modelAssets.opus;
236
+ if (m.includes('fable')) pick = config.modelAssets.fable;
237
+ else if (m.includes('opus')) pick = config.modelAssets.opus;
236
238
  else if (m.includes('sonnet')) pick = config.modelAssets.sonnet;
237
239
  else if (m.includes('haiku')) pick = config.modelAssets.haiku;
238
240
  if (!pick) pick = config.modelAssets.default;
@@ -283,6 +285,16 @@ function buildActivity(opts = {}) {
283
285
  url: fillTemplate(b.url, vars),
284
286
  }));
285
287
  }
288
+
289
+ // Concurrent sessions render natively via Discord's party field — the card
290
+ // shows "(2 of 2)" with no template work. Only attached when more than one
291
+ // live session exists (a party of one is noise). Opt out: showPartySize:false.
292
+ const liveCount = (state.liveSessions || []).length;
293
+ if (config.showPartySize !== false && liveCount > 1) {
294
+ activity.partyId = 'claude-rpc';
295
+ activity.partySize = liveCount;
296
+ activity.partyMax = liveCount;
297
+ }
286
298
  return activity;
287
299
  }
288
300
 
@@ -327,7 +339,11 @@ async function pushPresence() {
327
339
 
328
340
  const hideWhenStale = config.hideWhenStale !== false;
329
341
  const privacyHidden = resolved._privacy?.visibility === 'hidden';
330
- if ((resolved.status === 'stale' && hideWhenStale) || privacyHidden) {
342
+ // Global snooze (`claude-rpc pause`) clears the card while the deadline
343
+ // is in the future. Re-checked every tick, so expiry resumes presence
344
+ // automatically (the 'cleared' stamp differs from the next frame's hash).
345
+ const pausedUntil = pauseUntil();
346
+ if ((resolved.status === 'stale' && hideWhenStale) || privacyHidden || pausedUntil) {
331
347
  const stamp = 'cleared';
332
348
  if (lastPayloadHash === stamp) return;
333
349
  lastPayloadHash = stamp;
@@ -335,7 +351,9 @@ async function pushPresence() {
335
351
  // elapsed timer rather than counting from a previous session.
336
352
  effectiveSessionStart = null;
337
353
  await client.user.clearActivity();
338
- const reason = privacyHidden ? 'privacy=hidden in this project' : 'stale — Claude Code not running';
354
+ const reason = pausedUntil
355
+ ? `paused until ${new Date(pausedUntil).toLocaleTimeString()}`
356
+ : privacyHidden ? 'privacy=hidden in this project' : 'stale — Claude Code not running';
339
357
  log(`Presence cleared (${reason})`);
340
358
  return;
341
359
  }
@@ -414,61 +432,45 @@ function scheduleReconnect(reason = 'reconnect') {
414
432
  }
415
433
 
416
434
  function watchFiles() {
417
- let stateTimer = null;
418
- if (existsSync(STATE_PATH)) {
419
- watch(STATE_PATH, () => {
420
- clearTimeout(stateTimer);
421
- stateTimer = setTimeout(pushPresence, 250);
422
- });
423
- }
424
- // config.json may not exist yet on a fresh install where the daemon starts
425
- // before `setup` seeds it (or if the user deletes it). watch() on a missing
426
- // path throws ENOENT synchronously, which would crash startup — exactly the
427
- // failure loadConfig was hardened against. Guard it, and if the file is
428
- // absent, poll for its creation and attach the watcher once it lands.
429
- const watchConfig = () => {
430
- watch(CONFIG_PATH, () => {
431
- log('Config changed reloading');
432
- config = loadConfigWithLog();
433
- lastPayloadHash = '';
434
- pushPresence();
435
- });
436
- };
437
- if (existsSync(CONFIG_PATH)) {
438
- watchConfig();
439
- } else {
440
- let configTimer = null;
441
- const dir = dirname(CONFIG_PATH);
442
- if (existsSync(dir)) {
443
- const dirWatcher = watch(dir, () => {
444
- if (!existsSync(CONFIG_PATH)) return;
445
- clearTimeout(configTimer);
446
- configTimer = setTimeout(() => {
447
- try {
448
- dirWatcher.close();
449
- } catch {
450
- /* already closed */
451
- }
452
- log('Config appeared — reloading');
453
- config = loadConfigWithLog();
454
- lastPayloadHash = '';
455
- watchConfig();
456
- pushPresence();
457
- }, 250);
435
+ // Watch DIRECTORIES, not files. Every watched file here is written via
436
+ // tmp+rename (state.js, the scanner, the settings GUI), and inotify tracks
437
+ // the inode — a watcher attached to the file path goes silent after the
438
+ // first rename. A directory watcher survives renames AND works when the
439
+ // file doesn't exist yet (fresh install where the daemon starts before the
440
+ // first hook / before `setup` seeds config.json). Events are filtered by
441
+ // filename where the platform reports one; the rare null-filename event
442
+ // just costs one debounced no-op push (the payload hash dedupes it).
443
+ const watchDirFor = (targetPath, label, onChange, extraNames = []) => {
444
+ const dir = dirname(targetPath);
445
+ const names = new Set([basename(targetPath), ...extraNames]);
446
+ if (!existsSync(dir)) return;
447
+ let timer = null;
448
+ try {
449
+ watch(dir, (event, filename) => {
450
+ if (filename && !names.has(filename)) return;
451
+ clearTimeout(timer);
452
+ timer = setTimeout(onChange, 250);
458
453
  });
454
+ } catch (e) {
455
+ log(`watch failed for ${label} (poll fallback still covers it):`, e.message);
459
456
  }
460
- }
461
- if (existsSync(AGGREGATE_PATH)) {
462
- let aggTimer = null;
463
- watch(AGGREGATE_PATH, () => {
464
- clearTimeout(aggTimer);
465
- aggTimer = setTimeout(() => {
466
- aggregate = readAggregate() || aggregate;
467
- lastPayloadHash = '';
468
- pushPresence();
469
- }, 250);
470
- });
471
- }
457
+ };
458
+
459
+ // state.json and pause.json share STATE_DIR — one watcher serves both, so
460
+ // a `claude-rpc pause` clears the card on the next debounce, not the next
461
+ // 4s tick. The filename filter keeps daemon.log appends from triggering it.
462
+ watchDirFor(STATE_PATH, 'state', pushPresence, [basename(PAUSE_PATH)]);
463
+ watchDirFor(CONFIG_PATH, 'config', () => {
464
+ log('Config changed — reloading');
465
+ config = loadConfigWithLog();
466
+ lastPayloadHash = '';
467
+ pushPresence();
468
+ });
469
+ watchDirFor(AGGREGATE_PATH, 'aggregate', () => {
470
+ aggregate = readAggregate() || aggregate;
471
+ lastPayloadHash = '';
472
+ pushPresence();
473
+ });
472
474
 
473
475
  // Mtime-poll fallback. fs.watch on Windows occasionally drops events
474
476
  // when the writer uses an atomic-rename pattern (which `state.js` does
@@ -133,6 +133,16 @@ export function formatActivity(activity = {}, pid) {
133
133
  if (activity.smallImageText) a.assets.small_text = activity.smallImageText;
134
134
  }
135
135
 
136
+ // Party — renders natively as "(2 of 4)" on the card. Same field names
137
+ // @xhayper used (partyId / partySize / partyMax); size requires both ends.
138
+ if (activity.partyId || (activity.partySize != null && activity.partyMax != null)) {
139
+ a.party = {};
140
+ if (activity.partyId) a.party.id = activity.partyId;
141
+ if (activity.partySize != null && activity.partyMax != null) {
142
+ a.party.size = [activity.partySize, activity.partyMax];
143
+ }
144
+ }
145
+
136
146
  if (activity.buttons?.length) a.buttons = activity.buttons;
137
147
 
138
148
  return { pid: pid ?? process?.pid ?? 0, activity: a };
package/src/format.js CHANGED
@@ -81,11 +81,17 @@ function plural(n, sing, plur) {
81
81
  return `${fmtNum(n)} ${word}`;
82
82
  }
83
83
 
84
- // claude-opus-4-7 → Opus 4.7 · claude-sonnet-4-6-20250514 → Sonnet 4.6
84
+ // claude-opus-4-7 → Opus 4.7 · claude-sonnet-4-6-20250514 → Sonnet 4.6 ·
85
+ // claude-fable-5 → Fable 5 (no minor version). Version digits are capped at
86
+ // two so a trailing date stamp ("…-5-20260301") can't be read as a minor.
85
87
  function humanModel(id) {
86
88
  if (!id || typeof id !== 'string') return 'Claude';
87
- const m = id.match(/(opus|sonnet|haiku)[^\d]*(\d+)[-.](\d+)/i);
88
- if (m) return `${m[1][0].toUpperCase()}${m[1].slice(1).toLowerCase()} ${m[2]}.${m[3]}`;
89
+ const m = id.match(/(opus|sonnet|haiku|fable)[^\d]*(\d{1,2})(?!\d)(?:[-.](\d{1,2})(?!\d))?/i);
90
+ if (m) {
91
+ const name = `${m[1][0].toUpperCase()}${m[1].slice(1).toLowerCase()}`;
92
+ return m[3] ? `${name} ${m[2]}.${m[3]}` : `${name} ${m[2]}`;
93
+ }
94
+ if (/fable/i.test(id)) return 'Fable';
89
95
  if (/opus/i.test(id)) return 'Opus';
90
96
  if (/sonnet/i.test(id)) return 'Sonnet';
91
97
  if (/haiku/i.test(id)) return 'Haiku';
@@ -841,13 +847,11 @@ export function applyIdle(state, cfg = {}) {
841
847
  // Local state is fresh.
842
848
  if (state.status === 'idle') {
843
849
  // No transcripts being written anywhere on disk — Claude Code may have
844
- // closed without a SessionEnd hook (force-quit, OS sleep, crash). With
845
- // idleWhenOpen (default) we keep showing 'idle': the session hasn't been
846
- // authoritatively closed and the staleMs dormancy backstop above will
847
- // still clear it if Claude is truly gone. Set idleWhenOpen:false to go
848
- // straight to stale here clears Discord ~90-120s after the last write,
849
- // at the cost of dropping the card whenever you pause for a couple
850
- // minutes with the session still open.
850
+ // closed without a SessionEnd hook (force-quit, OS sleep, crash). By
851
+ // default (idleWhenOpen=false) go straight to stale so a closed terminal
852
+ // clears the card within ~90-120s of the last write. Opt in with
853
+ // idleWhenOpen:true to keep showing 'idle' through short pauses; the
854
+ // staleMs dormancy backstop above still clears it if Claude is truly gone.
851
855
  if (liveSessions.length === 0 && !idleWhenOpen) return staleWipe(state);
852
856
  return state;
853
857
  }
@@ -855,10 +859,10 @@ export function applyIdle(state, cfg = {}) {
855
859
  // Hook channel is quiet, but a live transcript was modified recently?
856
860
  // Keep "working" instead of dropping to "idle".
857
861
  if (liveAgeMs <= idleMs) return state;
858
- // Hooks quiet AND no live transcripts. With idleWhenOpen (default) the
862
+ // Hooks quiet AND no live transcripts. By default (idleWhenOpen=false)
863
+ // treat Claude as gone and go stale now. With idleWhenOpen:true the
859
864
  // session is treated as open-but-paused and drops to idle; the staleMs
860
- // backstop above clears it if Claude is actually gone. Set
861
- // idleWhenOpen:false to go straight to stale here (old behavior).
865
+ // backstop above clears it later if Claude actually exited.
862
866
  if (liveSessions.length === 0 && !idleWhenOpen) return staleWipe(state);
863
867
  // Going idle — wipe "current activity" indicators so rotation frames
864
868
  // gated on filesEdited / currentFile / currentTool stop showing stale
package/src/git.js CHANGED
@@ -7,8 +7,8 @@
7
7
  // The detached-HEAD case (HEAD contains a raw SHA, not `ref: refs/heads/...`)
8
8
  // returns an empty branch — template `requires` will hide the branch frame.
9
9
 
10
- import { readFileSync, existsSync } from 'node:fs';
11
- import { basename, join } from 'node:path';
10
+ import { readFileSync, statSync } from 'node:fs';
11
+ import { basename, join, isAbsolute } from 'node:path';
12
12
 
13
13
  const TTL_MS = 5 * 60 * 1000;
14
14
  const cache = new Map(); // cwd → { ts, github, branch, repo }
@@ -17,6 +17,35 @@ function fresh(entry) {
17
17
  return entry && (Date.now() - entry.ts) < TTL_MS;
18
18
  }
19
19
 
20
+ // Resolve a cwd's `.git` to the directory that actually holds HEAD etc.
21
+ // Usually `.git` IS that directory, but in a linked worktree or a submodule
22
+ // it's a one-line FILE — "gitdir: <path>" — pointing at the real location
23
+ // (e.g. <main>/.git/worktrees/<name>). Treating the file as a directory made
24
+ // every read below fail silently, so worktrees lost branch/repo/GitHub-button
25
+ // detection entirely.
26
+ function resolveGitDir(cwd) {
27
+ const dotGit = join(cwd, '.git');
28
+ let st;
29
+ try { st = statSync(dotGit); } catch { return null; }
30
+ if (st.isDirectory()) return dotGit;
31
+ try {
32
+ const m = readFileSync(dotGit, 'utf8').match(/^gitdir:\s*(.+?)\s*$/m);
33
+ if (!m) return null;
34
+ return isAbsolute(m[1]) ? m[1] : join(cwd, m[1]);
35
+ } catch { return null; }
36
+ }
37
+
38
+ // The COMMON git dir — where `config` lives. For a linked worktree the
39
+ // per-worktree gitdir holds a `commondir` file with a (usually relative)
40
+ // path back to the main `.git`; everywhere else the gitdir is its own
41
+ // common dir (main repos, submodules).
42
+ function commonGitDir(gitDir) {
43
+ try {
44
+ const rel = readFileSync(join(gitDir, 'commondir'), 'utf8').trim();
45
+ return isAbsolute(rel) ? rel : join(gitDir, rel);
46
+ } catch { return gitDir; }
47
+ }
48
+
20
49
  function readGitInfo(cwd) {
21
50
  const out = { github: null, branch: '', repo: '' };
22
51
  if (!cwd) return out;
@@ -25,12 +54,12 @@ function readGitInfo(cwd) {
25
54
  // find a github origin.
26
55
  out.repo = basename(cwd) || '';
27
56
 
28
- const gitDir = join(cwd, '.git');
29
- if (!existsSync(gitDir)) return out;
57
+ const gitDir = resolveGitDir(cwd);
58
+ if (!gitDir) return out;
30
59
 
31
60
  // origin URL → github URL + repo name.
32
61
  try {
33
- const cfg = readFileSync(join(gitDir, 'config'), 'utf8');
62
+ const cfg = readFileSync(join(commonGitDir(gitDir), 'config'), 'utf8');
34
63
  const m = cfg.match(/\[remote\s+"origin"\][^[]*?url\s*=\s*([^\r\n]+)/i);
35
64
  if (m) {
36
65
  const raw = m[1].trim();
@@ -84,8 +113,11 @@ export function detectGitRepo(cwd) { return lookup(cwd).repo; }
84
113
  // just made a new one.
85
114
  export function detectLastCommitSubject(cwd, max = 80) {
86
115
  if (!cwd) return '';
87
- const gitDir = join(cwd, '.git');
88
- if (!existsSync(gitDir)) return '';
116
+ // Follow a worktree/submodule `.git` file to the real gitdir —
117
+ // COMMIT_EDITMSG and logs/HEAD are per-worktree, so the resolved
118
+ // dir (not the common dir) is the right place for both.
119
+ const gitDir = resolveGitDir(cwd);
120
+ if (!gitDir) return '';
89
121
 
90
122
  // COMMIT_EDITMSG: the editor buffer from the most recent `git commit`.
91
123
  // First non-blank, non-comment line is the subject.
package/src/hook.js CHANGED
@@ -56,8 +56,17 @@ function shipKindForSegment(seg) {
56
56
  // leading command is git/gh — so a quoted mention ("git push later" inside an
57
57
  // echo or a commit message) no longer false-fires. Tolerates env prefixes,
58
58
  // sudo/time, chained commands, and git global flags.
59
+ //
60
+ // Quoted spans are blanked BEFORE splitting: separators inside quotes
61
+ // (`echo "run git push && rejoice"`) used to create a fake segment whose
62
+ // leading command was git. The real command's own quoted args (`git commit
63
+ // -m "msg"`) classify the same with or without the message text, so blanking
64
+ // is lossless for detection. An unbalanced quote leaves the string untouched.
59
65
  export function classifyShip(cmd) {
60
- const segments = String(cmd || '').split(/[;&|\n]+/);
66
+ const blanked = String(cmd || '')
67
+ .replace(/'[^']*'/g, ' ')
68
+ .replace(/"(?:\\.|[^"\\])*"/g, ' ');
69
+ const segments = blanked.split(/[;&|\n]+/);
61
70
  const found = new Set();
62
71
  for (const seg of segments) {
63
72
  const k = shipKindForSegment(seg);
package/src/install.js CHANGED
@@ -425,7 +425,31 @@ function promoteNpxToGlobal() {
425
425
  return !r.error && r.status === 0;
426
426
  }
427
427
 
428
+ // Best-effort registry check. npx serves stale cached copies without
429
+ // warning, and promoteNpxToGlobal pins @VERSION — so a stale npx cache
430
+ // would otherwise propagate itself into the global install silently, and
431
+ // the user's next `claude-rpc profile …` hits "unknown command" with no
432
+ // clue why. Warn loudly up front; never block setup on it (offline is fine).
433
+ function warnIfStale() {
434
+ try {
435
+ const r = spawnSync('npm', ['view', 'claude-rpc', 'version'], {
436
+ encoding: 'utf8', timeout: 4000,
437
+ shell: process.platform === 'win32', // npm is npm.cmd on Windows
438
+ });
439
+ const latest = (r.stdout || '').trim();
440
+ if (!latest || latest === VERSION) return;
441
+ const num = (v) => v.split('.').map((n) => parseInt(n, 10) || 0);
442
+ const [l, v] = [num(latest), num(VERSION)];
443
+ const newer = l[0] !== v[0] ? l[0] > v[0] : l[1] !== v[1] ? l[1] > v[1] : l[2] > v[2];
444
+ if (newer) {
445
+ console.warn(` ! you're running v${VERSION} but v${latest} is published — npx may have served a stale cache.`);
446
+ console.warn(` for the newest version, stop here and re-run: npx claude-rpc@latest setup`);
447
+ }
448
+ } catch { /* offline or npm missing — a version check must never block setup */ }
449
+ }
450
+
428
451
  export async function install({ exePath, withStartup = true } = {}) {
452
+ warnIfStale();
429
453
  if (IS_NPX) {
430
454
  if (!promoteNpxToGlobal()) {
431
455
  console.error('\n ✗ Global install failed. Run this once, then you\'re set:');
package/src/paths.js CHANGED
@@ -70,6 +70,9 @@ export const STATE_DIR = join(tmpdir(), 'claude-rpc');
70
70
  export const STATE_PATH = join(STATE_DIR, 'state.json');
71
71
  export const PID_PATH = join(STATE_DIR, 'daemon.pid');
72
72
  export const LOG_PATH = join(STATE_DIR, 'daemon.log');
73
+ // Presence snooze marker (`claude-rpc pause`). Lives in the tmp state dir on
74
+ // purpose — a reboot clearing a forgotten pause is the right failure mode.
75
+ export const PAUSE_PATH = join(STATE_DIR, 'pause.json');
73
76
  export const DATA_DIR = join(homedir(), '.claude-rpc');
74
77
  export const AGGREGATE_PATH = join(DATA_DIR, 'aggregate.json');
75
78
  export const SCAN_CACHE_PATH = join(DATA_DIR, 'scan-cache.json');
package/src/pause.js ADDED
@@ -0,0 +1,56 @@
1
+ // Global presence snooze — `claude-rpc pause [30m|2h]` / `claude-rpc resume`.
2
+ //
3
+ // Privacy controls are per-cwd; this is the orthogonal "I'm screen-sharing
4
+ // for an hour" switch. The CLI writes a tiny { until } marker and the daemon
5
+ // checks it on every push tick, clearing the Discord card while the deadline
6
+ // is in the future. Expiry is passive: once `until` passes, the next tick
7
+ // resumes presence — no timer, no daemon restart, nothing to clean up.
8
+
9
+ import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync } from 'node:fs';
10
+ import { PAUSE_PATH, STATE_DIR } from './paths.js';
11
+
12
+ const DEFAULT_PAUSE_MS = 60 * 60 * 1000; // bare `pause` = 1 hour
13
+
14
+ // Parse a human duration: "30m", "2h", "1h30m", or a bare number (minutes).
15
+ // Returns milliseconds, or null when the input doesn't parse / is <= 0.
16
+ export function parseDuration(raw) {
17
+ if (raw == null || raw === '') return DEFAULT_PAUSE_MS;
18
+ const s = String(raw).trim().toLowerCase();
19
+ if (/^\d+$/.test(s)) {
20
+ const min = Number(s);
21
+ return min > 0 ? min * 60_000 : null;
22
+ }
23
+ const m = s.match(/^(?:(\d+)h)?(?:(\d+)m)?$/);
24
+ if (!m || (!m[1] && !m[2])) return null;
25
+ const ms = (Number(m[1] || 0) * 60 + Number(m[2] || 0)) * 60_000;
26
+ return ms > 0 ? ms : null;
27
+ }
28
+
29
+ export function setPause(ms, { path = PAUSE_PATH, now = Date.now() } = {}) {
30
+ const until = now + ms;
31
+ if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true });
32
+ writeFileSync(path, JSON.stringify({ until }));
33
+ return until;
34
+ }
35
+
36
+ export function clearPause({ path = PAUSE_PATH } = {}) {
37
+ try {
38
+ if (existsSync(path)) {
39
+ unlinkSync(path);
40
+ return true;
41
+ }
42
+ } catch { /* already gone, or unwritable tmp — either way not paused anymore */ }
43
+ return false;
44
+ }
45
+
46
+ // Epoch ms the pause runs until, or 0 when not paused (missing file,
47
+ // unreadable JSON, or a deadline already in the past).
48
+ export function pauseUntil({ path = PAUSE_PATH, now = Date.now() } = {}) {
49
+ try {
50
+ const raw = JSON.parse(readFileSync(path, 'utf8'));
51
+ const until = Number(raw?.until);
52
+ return Number.isFinite(until) && until > now ? until : 0;
53
+ } catch {
54
+ return 0;
55
+ }
56
+ }
package/src/pricing.js CHANGED
@@ -6,11 +6,16 @@
6
6
  // To customize: edit this file and run `claude-rpc rescan`.
7
7
 
8
8
  const PRICING = {
9
- // Opus 4.x family
9
+ // Fable family — Anthropic's frontier tier above Opus.
10
+ 'fable-5': { input: 10.00, output: 50.00, cacheRead: 1.00, cacheWrite: 12.50 },
11
+
12
+ // Opus 4.x family. 4.0/4.1 launched at $15/$75; the list price dropped to
13
+ // $5/$25 with Opus 4.5 and has held through 4.8.
10
14
  'opus-4': { input: 15.00, output: 75.00, cacheRead: 1.50, cacheWrite: 18.75 },
11
- 'opus-4-5': { input: 15.00, output: 75.00, cacheRead: 1.50, cacheWrite: 18.75 },
12
- 'opus-4-6': { input: 15.00, output: 75.00, cacheRead: 1.50, cacheWrite: 18.75 },
13
- 'opus-4-7': { input: 15.00, output: 75.00, cacheRead: 1.50, cacheWrite: 18.75 },
15
+ 'opus-4-5': { input: 5.00, output: 25.00, cacheRead: 0.50, cacheWrite: 6.25 },
16
+ 'opus-4-6': { input: 5.00, output: 25.00, cacheRead: 0.50, cacheWrite: 6.25 },
17
+ 'opus-4-7': { input: 5.00, output: 25.00, cacheRead: 0.50, cacheWrite: 6.25 },
18
+ 'opus-4-8': { input: 5.00, output: 25.00, cacheRead: 0.50, cacheWrite: 6.25 },
14
19
 
15
20
  // Sonnet 4.x family
16
21
  'sonnet-4': { input: 3.00, output: 15.00, cacheRead: 0.30, cacheWrite: 3.75 },
@@ -21,15 +26,17 @@ const PRICING = {
21
26
  'haiku-4': { input: 1.00, output: 5.00, cacheRead: 0.10, cacheWrite: 1.25 },
22
27
  'haiku-4-5': { input: 1.00, output: 5.00, cacheRead: 0.10, cacheWrite: 1.25 },
23
28
 
24
- // Generic fallbacks by tier.
25
- 'opus': { input: 15.00, output: 75.00, cacheRead: 1.50, cacheWrite: 18.75 },
29
+ // Generic fallbacks by tier. Opus uses the current-generation rates —
30
+ // unknown/future opus ids are far more likely to be 4.5+ than 4.1-era.
31
+ 'fable': { input: 10.00, output: 50.00, cacheRead: 1.00, cacheWrite: 12.50 },
32
+ 'opus': { input: 5.00, output: 25.00, cacheRead: 0.50, cacheWrite: 6.25 },
26
33
  'sonnet': { input: 3.00, output: 15.00, cacheRead: 0.30, cacheWrite: 3.75 },
27
34
  'haiku': { input: 1.00, output: 5.00, cacheRead: 0.10, cacheWrite: 1.25 },
28
35
  };
29
36
 
30
37
  const DEFAULT = PRICING.sonnet;
31
38
 
32
- const TIERS = new Set(['opus', 'sonnet', 'haiku']);
39
+ const TIERS = new Set(['opus', 'sonnet', 'haiku', 'fable']);
33
40
 
34
41
  // Map a model id like "claude-opus-4-7-20251101" to a pricing key.
35
42
  //
package/src/scanner.js CHANGED
@@ -208,11 +208,8 @@ function readHead(path, maxBytes = 65536) {
208
208
  }
209
209
  }
210
210
 
211
- // Parse a single transcript JSONL into a per-file summary.
212
- export function parseTranscript(filePath) {
213
- const raw = readFileSync(filePath, 'utf8');
214
- const lines = iterLines(raw);
215
- const summary = {
211
+ function blankTranscriptSummary() {
212
+ return {
216
213
  sessionId: null,
217
214
  project: null,
218
215
  cwd: null,
@@ -244,18 +241,31 @@ export function parseTranscript(filePath) {
244
241
  modelsUsed: {}, // raw model id → assistant turns
245
242
  byModel: {}, // pricing key → { turns, tokens, cost } (model split)
246
243
  };
247
- const fileSet = new Set();
248
- // Claude Code splits one assistant message (a single message.id) across
249
- // several JSONL lines one per content block (thinking / text / tool_use)
250
- // and repeats the SAME `usage` object on every line. Token/cost/turn
251
- // counting must happen once per message.id, or a 3-block turn is counted 3×
252
- // (this was the source of wildly inflated token + cost totals). Content
253
- // blocks themselves are distinct per line, so those stay counted per line.
254
- const usageCountedIds = new Set();
244
+ }
245
+
246
+ // Sliding window for assistant message-id dedup (see parseChunkInto). Blocks
247
+ // of one message land on ADJACENT lines, so a small window catches every real
248
+ // split while staying cheap to persist in the scan cache.
249
+ const RECENT_IDS_MAX = 200;
250
+
251
+ // Parse complete JSONL lines into `summary`, mutating it in place. `pstate`
252
+ // carries the cross-chunk bookkeeping that incremental (append-only) parsing
253
+ // needs to behave exactly like a full parse:
254
+ // recentIds — recently counted assistant message.ids. Claude Code splits
255
+ // one assistant message (a single message.id) across several
256
+ // JSONL lines — one per content block — repeating the SAME
257
+ // `usage` object on every line. Token/cost/turn counting must
258
+ // happen once per message.id, or a 3-block turn counts 3×.
259
+ // Content blocks themselves are distinct per line, so those
260
+ // stay counted per line.
261
+ // lastRec — the previous chunk's final timestamped record, so the
262
+ // active-time gap across a chunk boundary still accrues.
263
+ function parseChunkInto(text, summary, pstate) {
264
+ const fileSet = new Set(summary.files || []);
255
265
  // Records in their original order, retaining timestamps for per-day bucketing.
256
266
  const records = [];
257
267
 
258
- for (const line of lines) {
268
+ for (const line of iterLines(text)) {
259
269
  if (!line) continue;
260
270
  const r = safeJson(line);
261
271
  if (!r) continue;
@@ -277,10 +287,13 @@ export function parseTranscript(filePath) {
277
287
  const turnModel = r.message?.model || summary.model;
278
288
  const u = r.message?.usage;
279
289
  // Count usage/cost/turn only the first time we see this message.id (see
280
- // usageCountedIds note above). No id (rare/legacy) → count it.
290
+ // the pstate.recentIds note above). No id (rare/legacy) → count it.
281
291
  const msgId = r.message?.id;
282
- const firstSeen = !msgId || !usageCountedIds.has(msgId);
283
- if (msgId) usageCountedIds.add(msgId);
292
+ const firstSeen = !msgId || !pstate.recentIds.includes(msgId);
293
+ if (msgId && firstSeen) {
294
+ pstate.recentIds.push(msgId);
295
+ if (pstate.recentIds.length > RECENT_IDS_MAX) pstate.recentIds.shift();
296
+ }
284
297
  // Per-model split bucket, keyed by pricing key so cost/tokens/turns align.
285
298
  const mkey = turnModel ? pricingKeyFor(turnModel) : null;
286
299
  const mb = mkey ? (summary.byModel[mkey] ||= { turns: 0, tokens: 0, cost: 0 }) : null;
@@ -373,24 +386,84 @@ export function parseTranscript(filePath) {
373
386
  summary.files = Array.from(fileSet);
374
387
  if (records.length) {
375
388
  records.sort((a, b) => a.ts - b.ts);
376
- summary.firstTs = records[0].ts;
377
- summary.lastTs = records[records.length - 1].ts;
378
- let active = 0;
379
- for (let i = 1; i < records.length; i++) {
380
- const prev = records[i - 1];
381
- const gap = records[i].ts - prev.ts;
382
- if (gap > 0 && gap < ACTIVE_GAP_CAP_MS) {
383
- active += gap;
384
- // Charge the gap's active time to the day/week/hour of the earlier record.
385
- if (prev.day) (summary.byDay[prev.day] ||= blankDay()).activeMs += gap;
386
- if (prev.week) (summary.byWeek[prev.week] ||= blankDay()).activeMs += gap;
387
- if (prev.hour !== null && prev.hour !== undefined) {
388
- (summary.byHour[prev.hour] ||= blankDay()).activeMs += gap;
389
+ if (!summary.firstTs || records[0].ts < summary.firstTs) summary.firstTs = records[0].ts;
390
+ const chunkLast = records[records.length - 1].ts;
391
+ if (!summary.lastTs || chunkLast > summary.lastTs) summary.lastTs = chunkLast;
392
+ // Charge each gap's active time to the day/week/hour of the earlier
393
+ // record. The first record's "earlier" is the previous chunk's last.
394
+ let prev = pstate.lastRec;
395
+ for (const rec of records) {
396
+ if (prev) {
397
+ const gap = rec.ts - prev.ts;
398
+ if (gap > 0 && gap < ACTIVE_GAP_CAP_MS) {
399
+ summary.activeMs += gap;
400
+ if (prev.day) (summary.byDay[prev.day] ||= blankDay()).activeMs += gap;
401
+ if (prev.week) (summary.byWeek[prev.week] ||= blankDay()).activeMs += gap;
402
+ if (prev.hour !== null && prev.hour !== undefined) {
403
+ (summary.byHour[prev.hour] ||= blankDay()).activeMs += gap;
404
+ }
389
405
  }
390
406
  }
407
+ prev = rec;
408
+ }
409
+ pstate.lastRec = prev;
410
+ }
411
+ }
412
+
413
+ // Parse a single transcript JSONL into a per-file summary.
414
+ //
415
+ // With a prior cache entry (`prev`), parses only the bytes appended since the
416
+ // last scan — transcripts are append-only, and an active session's multi-MB
417
+ // file otherwise gets fully re-read every rescan tick. The entry carries
418
+ // `_offset` (bytes consumed through the last complete line) and `_parse`
419
+ // (the cross-chunk bookkeeping for parseChunkInto); anything that breaks the
420
+ // append assumption (file shrank, entry predates these fields, `_offset`
421
+ // null) falls back to a from-scratch parse.
422
+ export function parseTranscript(filePath, prev = null) {
423
+ const st = statSync(filePath);
424
+ const canAppend = !!(prev && prev._parse && typeof prev._offset === 'number'
425
+ && st.size >= prev._offset);
426
+ const summary = canAppend ? structuredClone(prev) : blankTranscriptSummary();
427
+ const pstate = canAppend
428
+ ? { recentIds: (prev._parse.recentIds || []).slice(), lastRec: prev._parse.lastRec || null }
429
+ : { recentIds: [], lastRec: null };
430
+ const startOffset = canAppend ? prev._offset : 0;
431
+
432
+ let text = '';
433
+ const len = st.size - startOffset;
434
+ if (len > 0) {
435
+ let fd;
436
+ try {
437
+ fd = openSync(filePath, 'r');
438
+ const buf = Buffer.allocUnsafe(len);
439
+ const n = readSync(fd, buf, 0, len, startOffset);
440
+ text = buf.toString('utf8', 0, n);
441
+ } finally {
442
+ if (fd !== undefined) {
443
+ try { closeSync(fd); } catch { /* already closed */ }
444
+ }
391
445
  }
392
- summary.activeMs = active;
393
446
  }
447
+
448
+ // Consume through the last newline; \n is single-byte ASCII so the boundary
449
+ // is exact even with multi-byte content in the lines.
450
+ const lastNl = text.lastIndexOf('\n');
451
+ const complete = lastNl === -1 ? '' : text.slice(0, lastNl + 1);
452
+ const remainder = lastNl === -1 ? text : text.slice(lastNl + 1);
453
+ parseChunkInto(complete, summary, pstate);
454
+ let offset = startOffset + Buffer.byteLength(complete, 'utf8');
455
+ if (remainder.trim()) {
456
+ if (safeJson(remainder) !== null) {
457
+ // A complete final line that just isn't newline-terminated (fully
458
+ // written file). Count it, but mark the entry non-appendable — if more
459
+ // bytes ever land we can't tell whether they extend this line.
460
+ parseChunkInto(remainder, summary, pstate);
461
+ offset = null;
462
+ }
463
+ // else: a partial line mid-write — leave it for the next (append) read.
464
+ }
465
+ summary._offset = offset;
466
+ summary._parse = { recentIds: pstate.recentIds, lastRec: pstate.lastRec };
394
467
  return summary;
395
468
  }
396
469
 
@@ -426,12 +499,25 @@ function isSubagentPath(p) {
426
499
  return /[\\/]subagents[\\/]/.test(p);
427
500
  }
428
501
 
502
+ // The daemon runs for weeks; these per-transcript caches would otherwise grow
503
+ // one entry per file ever observed. LRU with a generous cap — the hot set is
504
+ // the handful of live sessions, so an eviction just costs one re-read.
505
+ const CACHE_MAX_ENTRIES = 512;
506
+ function lruTouch(map, key, value) {
507
+ if (map.has(key)) map.delete(key);
508
+ map.set(key, value);
509
+ if (map.size > CACHE_MAX_ENTRIES) map.delete(map.keys().next().value);
510
+ }
511
+
429
512
  // Pull the real cwd from the head of a transcript so live sessions can show
430
513
  // "my-app" instead of the slugified directory name.
431
514
  const cwdCache = new Map(); // path → { mtime, cwd }
432
515
  function readTranscriptCwd(path, mtimeMs) {
433
516
  const cached = cwdCache.get(path);
434
- if (cached && cached.mtime === mtimeMs) return cached.cwd;
517
+ if (cached && cached.mtime === mtimeMs) {
518
+ lruTouch(cwdCache, path, cached);
519
+ return cached.cwd;
520
+ }
435
521
  let cwd = null;
436
522
  try {
437
523
  let seen = 0;
@@ -442,7 +528,7 @@ function readTranscriptCwd(path, mtimeMs) {
442
528
  if (r?.cwd) { cwd = r.cwd; break; }
443
529
  }
444
530
  } catch { /* transcript head unreadable — cwd stays null, project name falls back to slug */ }
445
- cwdCache.set(path, { mtime: mtimeMs, cwd });
531
+ lruTouch(cwdCache, path, { mtime: mtimeMs, cwd });
446
532
  return cwd;
447
533
  }
448
534
 
@@ -480,7 +566,10 @@ export function readSessionTokens(path) {
480
566
  let st;
481
567
  try { st = statSync(path); } catch { return null; }
482
568
  const cached = sessionTokenCache.get(path);
483
- if (cached && cached.mtime === st.mtimeMs) return cached.tokens;
569
+ if (cached && cached.mtime === st.mtimeMs) {
570
+ lruTouch(sessionTokenCache, path, cached);
571
+ return cached.tokens;
572
+ }
484
573
 
485
574
  // Transcripts are append-only JSONL. If the file only grew since the last
486
575
  // read, parse just the appended tail from the cached byte offset instead of
@@ -519,7 +608,7 @@ export function readSessionTokens(path) {
519
608
  }
520
609
  }
521
610
 
522
- sessionTokenCache.set(path, { mtime: st.mtimeMs, size: st.size, offset: newOffset, tokens });
611
+ lruTouch(sessionTokenCache, path, { mtime: st.mtimeMs, size: st.size, offset: newOffset, tokens });
523
612
  return tokens;
524
613
  }
525
614
 
@@ -941,7 +1030,9 @@ export function scan({ projectsDir, projectsDirs, onProgress, force = false, ext
941
1030
  continue;
942
1031
  }
943
1032
  try {
944
- const summary = parseTranscript(fp);
1033
+ // Hand the prior entry to parseTranscript so an active session's
1034
+ // growing transcript parses only its appended tail (force = from scratch).
1035
+ const summary = parseTranscript(fp, force ? null : cache.files[fp]);
945
1036
  summary._sig = sig;
946
1037
  summary.isSubagent = isSubagentPath(fp);
947
1038
  cache.files[fp] = summary;
@@ -965,7 +1056,21 @@ export function scan({ projectsDir, projectsDirs, onProgress, force = false, ext
965
1056
  removed += 1;
966
1057
  }
967
1058
  }
968
- writeCache(cache);
1059
+ const changed = scanned > 0 || removed > 0;
1060
+ if (changed) {
1061
+ writeCache(cache);
1062
+ } else {
1063
+ // Nothing parsed, nothing removed — the cache on disk is byte-identical,
1064
+ // so skip rewriting it (it can be tens of MB) AND skip the aggregate
1065
+ // recompute, UNLESS the local day has rolled since the aggregate was
1066
+ // generated: streak / daysSinceFirst / hotspot-aging are derived from
1067
+ // "today" and go stale at midnight even with no new data.
1068
+ const existing = readAggregate();
1069
+ if (existing && existing._v === CACHE_VERSION
1070
+ && dayKey(existing.generatedAt || 0) === dayKey(Date.now())) {
1071
+ return { aggregate: existing, scanned, skipped, removed, total: transcripts.length, dirs };
1072
+ }
1073
+ }
969
1074
  const aggregate = aggregateFrom(cache);
970
1075
  writeAggregate(aggregate);
971
1076
  return { aggregate, scanned, skipped, removed, total: transcripts.length, dirs };
@@ -30,6 +30,10 @@ function parseUrl(rawUrl) {
30
30
  return { path: url.pathname, query: Object.fromEntries(url.searchParams) };
31
31
  }
32
32
 
33
+ function safeDecode(s) {
34
+ try { return decodeURIComponent(s); } catch { return null; }
35
+ }
36
+
33
37
  // Loopback-only Host allowlist. Binding to 127.0.0.1 blocks the LAN, but not
34
38
  // DNS rebinding: a malicious page can point its own hostname at 127.0.0.1 and
35
39
  // become "same-origin" with this server, then read /api/export.json. Rejecting
@@ -64,7 +68,8 @@ const server = createServer((req, res) => {
64
68
  // Project drilldown. Path-prefix dispatch (the project name is in the
65
69
  // URL itself, not in a query string).
66
70
  if (req.method === 'GET' && path.startsWith('/api/project/')) {
67
- const name = decodeURIComponent(path.slice('/api/project/'.length));
71
+ const name = safeDecode(path.slice('/api/project/'.length));
72
+ if (name === null) { res.writeHead(400, JSON_HEADERS).end(JSON.stringify({ error: 'bad request' })); return; }
68
73
  const result = projectDrilldown(name);
69
74
  res.writeHead(result ? 200 : 404, JSON_HEADERS);
70
75
  res.end(JSON.stringify(result || { error: 'not found' }));
@@ -73,7 +78,8 @@ const server = createServer((req, res) => {
73
78
 
74
79
  // Day detail. Same pattern — day key in the URL path.
75
80
  if (req.method === 'GET' && path.startsWith('/api/day/')) {
76
- const day = decodeURIComponent(path.slice('/api/day/'.length));
81
+ const day = safeDecode(path.slice('/api/day/'.length));
82
+ if (day === null) { res.writeHead(400, JSON_HEADERS).end(JSON.stringify({ error: 'bad request' })); return; }
77
83
  const result = dayDetail(day);
78
84
  res.writeHead(result ? 200 : 404, JSON_HEADERS);
79
85
  res.end(JSON.stringify(result || { error: 'not found' }));
package/src/server/sse.js CHANGED
@@ -4,6 +4,7 @@
4
4
  // shared client set.
5
5
 
6
6
  import { watch } from 'node:fs';
7
+ import { dirname, basename } from 'node:path';
7
8
  import { STATE_PATH, AGGREGATE_PATH } from '../paths.js';
8
9
 
9
10
  export const sseClients = new Set();
@@ -15,18 +16,31 @@ export function broadcast(payload) {
15
16
  }
16
17
  }
17
18
 
18
- export function watchSources() {
19
- let stTimer = null, agTimer = null;
19
+ // Watch a file that is updated via atomic rename (write-tmp + renameSync).
20
+ // Watching the file path directly binds to the inode at watch-time — on
21
+ // Linux that inode is replaced by the first rename, so the watcher fires
22
+ // once then goes permanently silent. Watching the parent directory avoids
23
+ // this: directory inodes are stable across entry renames.
24
+ export function watchFile(filePath, callback) {
25
+ const dir = dirname(filePath);
26
+ const name = basename(filePath);
20
27
  try {
21
- watch(STATE_PATH, () => {
22
- clearTimeout(stTimer);
23
- stTimer = setTimeout(() => broadcast({ type: 'state' }), 200);
28
+ return watch(dir, (_, filename) => {
29
+ if (!filename || filename === name) callback();
24
30
  });
25
- } catch { /* state file not on disk yet — dashboard still works, just no SSE state events until daemon writes one */ }
26
- try {
27
- watch(AGGREGATE_PATH, () => {
28
- clearTimeout(agTimer);
29
- agTimer = setTimeout(() => broadcast({ type: 'aggregate' }), 200);
30
- });
31
- } catch { /* aggregate not on disk yet — dashboard renders empty stats until first scan completes */ }
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ export function watchSources() {
37
+ let stTimer = null, agTimer = null;
38
+ watchFile(STATE_PATH, () => {
39
+ clearTimeout(stTimer);
40
+ stTimer = setTimeout(() => broadcast({ type: 'state' }), 200);
41
+ });
42
+ watchFile(AGGREGATE_PATH, () => {
43
+ clearTimeout(agTimer);
44
+ agTimer = setTimeout(() => broadcast({ type: 'aggregate' }), 200);
45
+ });
32
46
  }
package/src/ui.js CHANGED
@@ -72,6 +72,16 @@ export function fail(label, { hint = 'run `claude-rpc doctor` for a full diagnos
72
72
  process.exit(code);
73
73
  }
74
74
 
75
+ // Return the last n lines of a log file's raw text, trimming the trailing
76
+ // empty element that split('\n') produces when the file ends with a newline.
77
+ // When the file lacks a trailing newline the last element is the last real
78
+ // line — the old raw.slice(-31,-1) pattern silently dropped it.
79
+ export function tailLines(raw, n = 30) {
80
+ const lines = raw.split('\n');
81
+ if (lines.at(-1) === '') lines.pop();
82
+ return lines.slice(-n);
83
+ }
84
+
75
85
  // Compatibility with doctor.js's existing API. Same `check(label, status,
76
86
  // detail, hint)` signature; doctor.js can switch its private copy out for
77
87
  // this without behavior change.
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.13.7';
14
+ const BAKED = '0.14.0';
15
15
 
16
16
  function readPkgVersion() {
17
17
  try {