claude-rpc 1.1.0 → 1.1.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-rpc",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
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/calendar.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // `claude-rpc calendar --out cal.svg [--gist]`.
4
4
 
5
5
  import { dayKey } from './scanner.js';
6
+ import { localDateStamp } from './fmt.js';
6
7
  import { VERSION } from './version.js';
7
8
 
8
9
  const PALETTE = {
@@ -80,7 +81,7 @@ export function renderCalendar(aggregate, { weeks = 53, generatedAt = new Date()
80
81
  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" role="img" aria-label="Claude Code activity calendar">
81
82
  <rect width="${W}" height="${H}" fill="${PALETTE.paper}"/>
82
83
  <text x="${padX}" y="28" font-family="Space Grotesk, Inter, system-ui, sans-serif" font-size="20" font-weight="800" fill="${PALETTE.ink}">a year on Claude Code</text>
83
- <text x="${padX}" y="44" font-family="JetBrains Mono, ui-monospace, monospace" font-size="11" fill="${PALETTE.inkMute}">${escapeXml(activeDays)} active days · ${escapeXml(totalHours)}h · as of ${generatedAt.toISOString().slice(0, 10)}</text>
84
+ <text x="${padX}" y="44" font-family="JetBrains Mono, ui-monospace, monospace" font-size="11" fill="${PALETTE.inkMute}">${escapeXml(activeDays)} active days · ${escapeXml(totalHours)}h · as of ${localDateStamp(generatedAt)}</text>
84
85
  ${monthLabels}
85
86
  ${cells}
86
87
  <text x="${W - 170}" y="${H - 10}" font-family="JetBrains Mono, ui-monospace, monospace" font-size="9" fill="${PALETTE.inkFaint}">less</text>
package/src/card.js CHANGED
@@ -15,7 +15,7 @@ import { dayKey } from './scanner.js';
15
15
  import { fmtCost } from './pricing.js';
16
16
  import { rangeToDays, rangeLabel, pickWindow } from './badge.js';
17
17
  import { VERSION } from './version.js';
18
- import { fmtNum, fmtHours } from './fmt.js';
18
+ import { fmtNum, fmtHours, localDateStamp } from './fmt.js';
19
19
 
20
20
  const W = 880;
21
21
  const H = 540;
@@ -237,7 +237,7 @@ export function renderCard(aggregate, { range = 'year', generatedAt = new Date()
237
237
  const linesNet = r.linesAdded - r.linesRemoved;
238
238
  const allTimeHours = ((aggregate?.activeMs || 0) / 3_600_000).toFixed(1);
239
239
 
240
- const subtitle = `${escapeXml(r.daysActive)} active days / ${escapeXml(rangeLabel(range))} ending ${escapeXml(generatedAt.toISOString().slice(0, 10))}`;
240
+ const subtitle = `${escapeXml(r.daysActive)} active days / ${escapeXml(rangeLabel(range))} ending ${escapeXml(localDateStamp(generatedAt))}`;
241
241
 
242
242
  // Layout grid
243
243
  // Title block 80 → W-80
package/src/cli.js CHANGED
@@ -116,6 +116,19 @@ function takeValue(v, flag) {
116
116
  return v;
117
117
  }
118
118
 
119
+ // Expand `--flag=value` into two tokens so every takeValue-based parser
120
+ // accepts the escape hatch takeValue's error message promises. Split on the
121
+ // FIRST `=` only — values may contain their own (`--template={a}={b}`).
122
+ function splitEqArgs(argv) {
123
+ const out = [];
124
+ for (const a of argv) {
125
+ const eq = typeof a === 'string' && a.startsWith('--') ? a.indexOf('=') : -1;
126
+ if (eq > 2) out.push(a.slice(0, eq), a.slice(eq + 1));
127
+ else out.push(a);
128
+ }
129
+ return out;
130
+ }
131
+
119
132
  function startDaemon({ quiet = false } = {}) {
120
133
  const pid = daemonAlive();
121
134
  if (pid) {
@@ -241,8 +254,9 @@ function renderHourHistogram(byHour, opts = {}) {
241
254
  const ch = heightChars[idx];
242
255
  colored.push(h === opts.peakHour ? `${c.bold}${heat(1)}${ch}${c.reset}` : `${heat(ms / max)}${ch}${c.reset}`);
243
256
  }
244
- // Hour labels under every 3rd hour.
245
- const labels = '00 03 06 09 12 15 18 21 ';
257
+ // Hour labels under every 3rd hour — 3-char pitch to match the 1-char-per-
258
+ // hour bars above (a 4-char pitch drifted the labels a column per group).
259
+ const labels = '00 03 06 09 12 15 18 21';
246
260
  return [
247
261
  ` ${colored.join('')}`,
248
262
  ` ${c.dim}${labels}${c.reset}`,
@@ -302,8 +316,26 @@ function renderHeatmap(byDay, days = 91) {
302
316
  lastMonth = d.getMonth();
303
317
  }
304
318
  }
305
- // Build header (3-letter months stretched across columns).
306
- const header = labelRow.map((s) => (s + ' ').slice(0, 2)).join(' ');
319
+ // Build header on the SAME 2-chars-per-column pitch as the body rows
320
+ // (joining with a space drifted month labels a column per week, ~6 columns
321
+ // off by the right edge — and 2-char truncation made Mar/May both "Ma").
322
+ // Full 3-letter names are stamped into a char grid; months are always ≥4
323
+ // columns apart so an overhanging name can't collide with the next label.
324
+ const headerChars = new Array(cols * 2).fill(' ');
325
+ const stamps = [];
326
+ labelRow.forEach((s, col) => {
327
+ const name = String(s).trim(); // labelRow is prefilled with ' ' blanks
328
+ if (name) stamps.push({ name, col });
329
+ });
330
+ for (let i = 0; i < stamps.length; i++) {
331
+ const { name, col } = stamps[i];
332
+ const next = stamps[i + 1];
333
+ // Window-edge crowding: a month with a single week in view sits right
334
+ // next to the following month's label — yield to the newer month.
335
+ if (next && next.col * 2 < col * 2 + name.length + 1) continue;
336
+ for (let k = 0; k < name.length && col * 2 + k < headerChars.length; k++) headerChars[col * 2 + k] = name[k];
337
+ }
338
+ const header = headerChars.join('').replace(/\s+$/, '');
307
339
 
308
340
  const dayLabels = [' ', 'M', ' ', 'W', ' ', 'F', ' '];
309
341
  const lines = [` ${c.dim}${header}${c.reset}`];
@@ -887,6 +919,7 @@ async function doRecap(argv) {
887
919
  }
888
920
 
889
921
  function parseBadgeArgs(argv) {
922
+ argv = splitEqArgs(argv);
890
923
  const out = { metric: 'hours', range: '7d', out: '', gist: false };
891
924
  for (let i = 0; i < argv.length; i++) {
892
925
  const a = argv[i];
@@ -921,11 +954,18 @@ async function doBadge(argv) {
921
954
  // Publish the rendered badge to a GitHub gist and emit the README-ready
922
955
  // markdown snippet. First successful publish records id+owner in
923
956
  // config.json so subsequent runs UPDATE that gist (raw URL stays stable).
924
- async function publishBadgeToGist(svg, opts) {
957
+ async function publishBadgeToGist(svg, opts, kind = 'badge') {
925
958
  const { publishGistFile, gistMarkdown } = await import('./gist.js');
926
959
  const cfg = loadConfig();
927
960
  const stored = cfg.gist || {};
928
- const filename = stored.filename || 'claude.svg';
961
+ // One shared gist, but a filename PER ARTIFACT KIND — badge, calendar, and
962
+ // github-stat used to all write `claude.svg`, so publishing one silently
963
+ // replaced whichever the user had embedded. The legacy single `filename`
964
+ // stays with the badge (the original artifact); other kinds get their own
965
+ // stable file the first time they publish.
966
+ const files = stored.files || {};
967
+ const filename = files[kind]
968
+ || (kind === 'badge' ? (stored.filename || 'claude.svg') : `claude-${kind}.svg`);
929
969
  try {
930
970
  const result = await publishGistFile({
931
971
  svg,
@@ -943,8 +983,11 @@ async function publishBadgeToGist(svg, opts) {
943
983
  ...(userCfg.gist || {}),
944
984
  id: result.id,
945
985
  owner: result.owner,
946
- filename,
986
+ files: { ...((userCfg.gist || {}).files || {}), [kind]: filename },
947
987
  };
988
+ // Keep the legacy field pointing at the badge so an older CLI version
989
+ // reading `gist.filename` still updates the right file.
990
+ if (kind === 'badge') userCfg.gist.filename = filename;
948
991
  if (stored.public !== undefined) userCfg.gist.public = stored.public;
949
992
  writeUserConfig(userCfg);
950
993
  const wasUpdate = !!stored.id;
@@ -968,6 +1011,7 @@ async function publishBadgeToGist(svg, opts) {
968
1011
  // for a range (year / month / week / all-time). Output is SVG only;
969
1012
  // screenshot or convert to PNG offline if needed.
970
1013
  function parseCardArgs(argv) {
1014
+ argv = splitEqArgs(argv);
971
1015
  const out = { range: 'year', out: '' };
972
1016
  for (let i = 0; i < argv.length; i++) {
973
1017
  const a = argv[i];
@@ -998,6 +1042,7 @@ async function doCard(argv) {
998
1042
  // for a profile README. `--gist` publishes it to a gist (raw URL stays stable
999
1043
  // across re-runs) so the README image auto-refreshes when you re-run it.
1000
1044
  function parseGithubStatArgs(argv) {
1045
+ argv = splitEqArgs(argv);
1001
1046
  const out = { out: '', gist: false, handle: '' };
1002
1047
  for (let i = 0; i < argv.length; i++) {
1003
1048
  const a = argv[i];
@@ -1017,7 +1062,7 @@ async function doGithubStat(argv) {
1017
1062
  const { renderProfileCard } = await import('./profile.js');
1018
1063
  const svg = renderProfileCard(aggregate, { handle: opts.handle });
1019
1064
  if (opts.gist) {
1020
- await publishBadgeToGist(svg, { metric: 'profile', range: 'all-time' });
1065
+ await publishBadgeToGist(svg, { metric: 'profile', range: 'all-time' }, 'github-stat');
1021
1066
  return;
1022
1067
  }
1023
1068
  if (opts.out) {
@@ -1043,6 +1088,7 @@ function liveVars() {
1043
1088
  // statusline — a compact one-line status for tmux / starship / shell prompts
1044
1089
  // and Claude Code's own statusline. `--template "..."` overrides the format.
1045
1090
  function doStatusline(argv) {
1091
+ argv = splitEqArgs(argv);
1046
1092
  let tpl = '{statusVerbose} · {project} · {modelPretty}{tokensLabelPad}';
1047
1093
  for (let i = 0; i < argv.length; i++) {
1048
1094
  if (argv[i] === '--template' || argv[i] === '-t') tpl = takeValue(argv[++i], '--template');
@@ -1054,6 +1100,7 @@ function doStatusline(argv) {
1054
1100
 
1055
1101
  // Activity calendar — GitHub-contributions-style year heatmap SVG.
1056
1102
  async function doCalendar(argv) {
1103
+ argv = splitEqArgs(argv);
1057
1104
  const opts = { out: '', gist: false };
1058
1105
  for (let i = 0; i < argv.length; i++) {
1059
1106
  if (argv[i] === '--out' || argv[i] === '-o') opts.out = takeValue(argv[++i], '--out');
@@ -1063,7 +1110,7 @@ async function doCalendar(argv) {
1063
1110
  if (!aggregate) fail('no aggregate yet — nothing to render', { hint: 'run `claude-rpc scan` first', code: EX_BAD_STATE });
1064
1111
  const { renderCalendar } = await import('./calendar.js');
1065
1112
  const svg = renderCalendar(aggregate, {});
1066
- if (opts.gist) return publishBadgeToGist(svg, { metric: 'calendar', range: 'year' });
1113
+ if (opts.gist) return publishBadgeToGist(svg, { metric: 'calendar', range: 'year' }, 'calendar');
1067
1114
  if (opts.out) {
1068
1115
  writeFileSync(opts.out, svg);
1069
1116
  console.log(` ${c.green}✓${c.reset} wrote ${c.cyan}${opts.out}${c.reset} ${c.dim}(${svg.length} bytes)${c.reset}`);
@@ -1072,6 +1119,7 @@ async function doCalendar(argv) {
1072
1119
 
1073
1120
  // Per-session recap card — current/most-recent session as a shareable SVG.
1074
1121
  async function doSessionCard(argv) {
1122
+ argv = splitEqArgs(argv);
1075
1123
  const opts = { out: '' };
1076
1124
  for (let i = 0; i < argv.length; i++) {
1077
1125
  if (argv[i] === '--out' || argv[i] === '-o') opts.out = takeValue(argv[++i], '--out');
@@ -1286,10 +1334,12 @@ function doResume() {
1286
1334
  // pipes cleanly into jq / a spreadsheet import.
1287
1335
 
1288
1336
  async function doExport(argv) {
1337
+ argv = splitEqArgs(argv);
1289
1338
  const csv = argv.includes('--csv');
1290
1339
  let out = '';
1291
- const i = argv.findIndex((a) => a === '--out' || a === '-o');
1292
- if (i !== -1) out = argv[i + 1] || '';
1340
+ for (let i = 0; i < argv.length; i++) {
1341
+ if (argv[i] === '--out' || argv[i] === '-o') out = takeValue(argv[++i], '--out');
1342
+ }
1293
1343
  const aggregate = readAggregate();
1294
1344
  if (!aggregate) {
1295
1345
  fail('no aggregate yet — nothing to export', { hint: 'run `claude-rpc scan` first', code: EX_BAD_STATE });
@@ -1629,7 +1679,13 @@ async function communityReport() {
1629
1679
  } else if (result.ok) {
1630
1680
  console.log(` ${c.cyan}·${c.reset} ${c.dim}${result.reason}${c.reset}`);
1631
1681
  } else {
1632
- console.log(` ${c.yellow}!${c.reset} flush did not complete ${c.dim}(${result.reason}${result.error ? ': ' + result.error : ''})${c.reset}`);
1682
+ // Scripts rely on the exit code (`claude-rpc community report || alert`),
1683
+ // so a failed flush must exit non-zero, not just print a warning.
1684
+ console.log('');
1685
+ return fail('flush did not complete', {
1686
+ hint: `${result.reason}${result.error ? ': ' + result.error : ''}`,
1687
+ code: EX_SYS_ERROR,
1688
+ });
1633
1689
  }
1634
1690
  console.log('');
1635
1691
  }
@@ -1745,8 +1801,10 @@ function profileSet(argv) {
1745
1801
  next.githubUser = u;
1746
1802
  }
1747
1803
  // The ✓ belongs to the account that was verified — switching accounts
1748
- // means re-verifying.
1749
- if (next.githubUser !== (userCfg.profile || {}).githubUser) delete next.verified;
1804
+ // means re-verifying. GitHub usernames are case-insensitive, so a
1805
+ // case-only respelling of the same account keeps the badge.
1806
+ const prevGh = String((userCfg.profile || {}).githubUser || '').toLowerCase();
1807
+ if (String(next.githubUser || '').toLowerCase() !== prevGh) delete next.verified;
1750
1808
  }
1751
1809
 
1752
1810
  userCfg.profile = next;
package/src/daemon.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync, unlinkSync, watch, appendFileSync, mkdirSync, statSync, renameSync } from 'node:fs';
3
- import { basename, dirname } from 'node:path';
2
+ import { existsSync, unlinkSync, watch, appendFileSync, mkdirSync, statSync, renameSync, readdirSync } from 'node:fs';
3
+ import { basename, dirname, join } from 'node:path';
4
4
  import { Client } from './discord-ipc.js';
5
5
  import { readState, sweepStaleStateTmp, listSessionStates, sweepStaleSessionStates } from './state.js';
6
6
  import { makeRotationCursor, pickFrames, selectFrame, resolveLargeImageKey, shouldShowGithubButton, pickActiveSession, throttleDecision } from './presence.js';
@@ -291,10 +291,13 @@ function buildActivity(opts = {}) {
291
291
 
292
292
  if (state.status === 'stale') {
293
293
  effectiveSessionStart = null;
294
- } else if (state.sessionStart) {
294
+ } else if (state.sessionStart && !state.borrowed) {
295
295
  effectiveSessionStart = state.sessionStart;
296
296
  } else if (!effectiveSessionStart) {
297
- effectiveSessionStart = state.lastActivity || Date.now();
297
+ // Borrowed states carry a moving transcript mtime as sessionStart — seed
298
+ // the timer from the first observation and hold it, or the elapsed clock
299
+ // restarts every refreshLiveSessions tick.
300
+ effectiveSessionStart = state.sessionStart || state.lastActivity || Date.now();
298
301
  }
299
302
  if (config.showElapsed && effectiveSessionStart && state.status !== 'stale') {
300
303
  // Discord IPC expects millisecond timestamps (not seconds).
@@ -574,7 +577,15 @@ function watchFiles() {
574
577
  // every writer here (state.js, pause.js, the scanner, the settings GUI)
575
578
  // commits via tmp+rename.
576
579
  const targets = [
577
- { path: STATE_PATH, label: 'state', onChange: pushPresence },
580
+ // Hooks write per-session state-<sid>.json (state.json only for legacy
581
+ // payloads without a session_id), so the state target matches the whole
582
+ // family — filtering on the literal 'state.json' made the instant watcher
583
+ // path dead for every real session, leaving only the poll tick.
584
+ {
585
+ path: STATE_PATH, label: 'state', onChange: pushPresence,
586
+ matches: (f) => /^state(-.+)?\.json$/.test(f),
587
+ scanDir: STATE_DIR,
588
+ },
578
589
  { path: PAUSE_PATH, label: 'pause', onChange: pushPresence },
579
590
  { path: CONFIG_PATH, label: 'config', onChange: () => {
580
591
  log('Config changed — reloading');
@@ -595,17 +606,38 @@ function watchFiles() {
595
606
  // poll only logs a "fallback caught it" line for events the watcher truly
596
607
  // missed, not for everything it already handled.
597
608
  const lastMtime = new Map();
598
- const recordMtime = (path) => {
599
- try { if (existsSync(path)) lastMtime.set(path, statSync(path).mtimeMs); }
600
- catch { /* mid-rename; a later observation records it */ }
609
+ // Current mtime for a target: single file by default, or the max across a
610
+ // directory's matching files for family targets (state-<sid>.json). Max is
611
+ // monotone under writes, so pollDecision's `cur > prev` still works; a
612
+ // deleted session file lowers it, which correctly doesn't fire.
613
+ const statTarget = (t) => {
614
+ if (t.scanDir) {
615
+ let max;
616
+ try {
617
+ for (const name of readdirSync(t.scanDir)) {
618
+ if (!t.matches(name)) continue;
619
+ try {
620
+ const m = statSync(join(t.scanDir, name)).mtimeMs;
621
+ if (max === undefined || m > max) max = m;
622
+ } catch { /* file vanished mid-scan */ }
623
+ }
624
+ } catch { /* dir missing (pre-first-hook) */ }
625
+ return max;
626
+ }
627
+ try { return existsSync(t.path) ? statSync(t.path).mtimeMs : undefined; }
628
+ catch { return undefined; /* mid-rename; a later observation records it */ }
629
+ };
630
+ const recordMtime = (t) => {
631
+ const m = statTarget(t);
632
+ if (m !== undefined) lastMtime.set(t.path, m);
601
633
  };
602
634
  // Seed baselines so the first poll tick doesn't fire for files that merely
603
635
  // already existed when the daemon started.
604
- targets.forEach((t) => recordMtime(t.path));
636
+ targets.forEach((t) => recordMtime(t));
605
637
 
606
638
  const fire = (t, viaPoll) => {
607
639
  if (viaPoll) log(`${t.label} changed — poll fallback caught an event fs.watch missed`);
608
- recordMtime(t.path); // record before onChange so a re-entrant tick can't double-fire
640
+ recordMtime(t); // record before onChange so a re-entrant tick can't double-fire
609
641
  t.onChange();
610
642
  };
611
643
 
@@ -625,11 +657,12 @@ function watchFiles() {
625
657
  }
626
658
  for (const [dir, group] of groups) {
627
659
  if (!existsSync(dir)) continue;
628
- const byName = new Map(group.map((t) => [basename(t.path), t]));
629
660
  let timer = null;
630
661
  try {
631
662
  watch(dir, (event, filename) => {
632
- const hits = filename ? (byName.has(filename) ? [byName.get(filename)] : []) : group;
663
+ const hits = filename
664
+ ? group.filter((t) => (t.matches ? t.matches(String(filename)) : basename(t.path) === filename))
665
+ : group;
633
666
  if (!hits.length) return;
634
667
  clearTimeout(timer);
635
668
  timer = setTimeout(() => hits.forEach((t) => fire(t, false)), 250);
@@ -646,9 +679,7 @@ function watchFiles() {
646
679
  // until the next unrelated state change.
647
680
  setInterval(() => {
648
681
  for (const t of targets) {
649
- let cur;
650
- try { cur = existsSync(t.path) ? statSync(t.path).mtimeMs : undefined; }
651
- catch { continue; /* mid-rename; next tick picks it up */ }
682
+ const cur = statTarget(t);
652
683
  const decision = pollDecision(lastMtime.get(t.path), cur);
653
684
  if (decision === 'seed') lastMtime.set(t.path, cur);
654
685
  else if (decision === 'fire') fire(t, true);
package/src/fmt.js CHANGED
@@ -31,3 +31,11 @@ export function fmtHours(ms) {
31
31
  const hours = mins / 60;
32
32
  return hours < 10 ? `${hours.toFixed(1)}h` : `${Math.round(hours)}h`;
33
33
  }
34
+
35
+ // Local calendar date "YYYY-MM-DD" — the same day the scanner's byDay buckets
36
+ // key on. Card/calendar/profile stamps used toISOString(), which is UTC and
37
+ // labels the data with the wrong day for any non-UTC user near midnight.
38
+ export function localDateStamp(d = new Date()) {
39
+ const p = (n) => String(n).padStart(2, '0');
40
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
41
+ }
package/src/format.js CHANGED
@@ -805,6 +805,10 @@ function borrowLiveSession(state, liveSessions, now) {
805
805
  return {
806
806
  ...state,
807
807
  status: 'working',
808
+ // sessionStart here is a moving transcript mtime, not a real anchor — the
809
+ // flag lets the daemon pin its elapsed timer to the first observation
810
+ // instead of restarting it every 30s refresh.
811
+ borrowed: true,
808
812
  cwd: recent.cwd || state.cwd || '',
809
813
  sessionStart: recent.mtime || now,
810
814
  lastActivity: recent.mtime || now,
package/src/hook.js CHANGED
@@ -67,6 +67,23 @@ export function processHookEvent(event, input = {}) {
67
67
 
68
68
  switch (event) {
69
69
  case 'SessionStart': {
70
+ // Post-compaction continuation, NOT a new session: Claude Code re-fires
71
+ // SessionStart with source:'compact' after every compaction. Resetting
72
+ // here wiped the elapsed timer and message/tool/file counters mid-turn.
73
+ // Just clear the compacting marker and carry on; the turn that triggered
74
+ // the compaction resumes immediately, hence 'working'.
75
+ if (input.source === 'compact') {
76
+ update((s) => {
77
+ delete s.compactStartedAt;
78
+ delete s.compactTrigger;
79
+ s.status = 'working';
80
+ s.lastActivity = now;
81
+ s.claudeClosed = false;
82
+ if (!s.sessionStart) s.sessionStart = now;
83
+ return s;
84
+ });
85
+ break;
86
+ }
70
87
  reset({
71
88
  cwd: input.cwd || process.cwd(),
72
89
  model: input.model?.id || input.model || 'claude',
@@ -132,7 +149,7 @@ export function processHookEvent(event, input = {}) {
132
149
  case 'PostToolUse': {
133
150
  const toolName = input.tool_name || input.toolName || '';
134
151
  const toolInput = input.tool_input || input.toolInput || {};
135
- const file = toolInput.file_path || toolInput.path || null;
152
+ const file = toolInput.file_path || toolInput.path || toolInput.notebook_path || null;
136
153
  // Just-shipped detection: any Bash command that contains `git push`
137
154
  // or `git commit`. Capture cwd + branch + last commit subject NOW
138
155
  // — by the time the daemon renders the next frame this info may be
@@ -227,8 +244,15 @@ export function processHookEvent(event, input = {}) {
227
244
  });
228
245
  break;
229
246
  }
247
+ case 'SubagentStop': {
248
+ // A subagent finishing is not the SESSION going idle — the parent turn
249
+ // is still running (often with sibling subagents in flight). Flipping to
250
+ // idle here showed "Standing by" mid-generation. Just record liveness;
251
+ // the parent's own Stop/PreToolUse hooks drive status.
252
+ setActivity({});
253
+ break;
254
+ }
230
255
  case 'Stop':
231
- case 'SubagentStop':
232
256
  default: {
233
257
  setActivity({ status: 'idle', currentTool: null, currentFile: null });
234
258
  }
package/src/nudge.js CHANGED
@@ -26,9 +26,11 @@ function reached(value, list) {
26
26
  const fmt = (n) => n >= 1000 ? n.toLocaleString('en-US') : String(n);
27
27
 
28
28
  // Returns { key, weight, message } for the biggest milestone the aggregate has
29
- // crossed, or null. `weight` ranks across milestone types so we only ever show
30
- // the single most impressive one.
31
- export function pickShareNudge(agg) {
29
+ // crossed that hasn't been shown yet, or null. `weight` ranks across milestone
30
+ // types so we show the single most impressive UNSEEN one — without the `shown`
31
+ // filter, a standing streak record (weight 1000+) re-won every pick, matched
32
+ // the dedup, and permanently silenced every other milestone behind it.
33
+ export function pickShareNudge(agg, shown = new Set()) {
32
34
  if (!agg || typeof agg !== 'object') return null;
33
35
  const out = [];
34
36
 
@@ -58,9 +60,10 @@ export function pickShareNudge(agg) {
58
60
  message: `${fmt(h)}+ hours on Claude Code. Your year-in-review is ready — \`claude-rpc serve\` then open /wrapped and hit Share.`,
59
61
  });
60
62
 
61
- if (!out.length) return null;
62
- out.sort((a, b) => b.weight - a.weight);
63
- return out[0];
63
+ const fresh = out.filter((n) => !shown.has(n.key));
64
+ if (!fresh.length) return null;
65
+ fresh.sort((a, b) => b.weight - a.weight);
66
+ return fresh[0];
64
67
  }
65
68
 
66
69
  // A quiet, local celebration line for `claude-rpc today`. Complements the
@@ -88,15 +91,22 @@ export function pickTodayMilestone(agg, todayTokens = 0) {
88
91
  return null;
89
92
  }
90
93
 
91
- function readLastKey(path = NUDGE_STATE) {
92
- try { return JSON.parse(readFileSync(path, 'utf8')).key || null; }
93
- catch { return null; }
94
+ // Set of every nudge key already shown. Older state files stored a single
95
+ // `key`; fold it in so upgrading doesn't re-show the last nudge.
96
+ function readShownKeys(path = NUDGE_STATE) {
97
+ try {
98
+ const raw = JSON.parse(readFileSync(path, 'utf8'));
99
+ const keys = Array.isArray(raw.shown) ? raw.shown : [];
100
+ if (raw.key) keys.push(raw.key);
101
+ return new Set(keys.filter((k) => typeof k === 'string'));
102
+ } catch { return new Set(); }
94
103
  }
95
104
 
96
- function writeLastKey(key, path = NUDGE_STATE) {
105
+ function writeShownKeys(shown, path = NUDGE_STATE) {
97
106
  try {
98
107
  mkdirSync(dirname(path), { recursive: true });
99
- writeFileSync(path, JSON.stringify({ key, ts: Date.now() }));
108
+ // Cap the history — milestone lists are finite, 100 is plenty.
109
+ writeFileSync(path, JSON.stringify({ shown: [...shown].slice(-100), ts: Date.now() }));
100
110
  } catch { /* best-effort */ }
101
111
  }
102
112
 
@@ -104,9 +114,10 @@ function writeLastKey(key, path = NUDGE_STATE) {
104
114
  // dedup. Returns a string to print, or null. Marks the nudge as shown.
105
115
  export function maybeNudge(agg, config = {}, { path = NUDGE_STATE } = {}) {
106
116
  if (config?.nudges?.enabled === false) return null;
107
- const n = pickShareNudge(agg);
117
+ const shown = readShownKeys(path);
118
+ const n = pickShareNudge(agg, shown);
108
119
  if (!n) return null;
109
- if (n.key === readLastKey(path)) return null; // already shown this one
110
- writeLastKey(n.key, path);
120
+ shown.add(n.key);
121
+ writeShownKeys(shown, path);
111
122
  return n.message;
112
123
  }
package/src/profile.js CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { fmtCost } from './pricing.js';
9
9
  import { VERSION } from './version.js';
10
- import { fmtNum, fmtHours } from './fmt.js';
10
+ import { fmtNum, fmtHours, localDateStamp } from './fmt.js';
11
11
 
12
12
  const W = 520;
13
13
  const H = 240;
@@ -94,7 +94,7 @@ export function renderProfileCard(aggregate, { handle = '', generatedAt = new Da
94
94
  const t = lifetime(aggregate);
95
95
  const lang = topLanguage(aggregate);
96
96
  const who = handle ? `@${String(handle).replace(/^@/, '')}` : 'on Claude Code';
97
- const sub = `${who} · Day ${t.daysSinceFirst} · as of ${generatedAt.toISOString().slice(0, 10)}`;
97
+ const sub = `${who} · Day ${t.daysSinceFirst} · as of ${localDateStamp(generatedAt)}`;
98
98
  const netStr = `${t.linesNet >= 0 ? '+' : '−'}${fmtNum(Math.abs(t.linesNet))}`;
99
99
 
100
100
  // 3 columns × 2 rows of stats.
package/src/scanner.js CHANGED
@@ -1,4 +1,4 @@
1
- import { readdirSync, readFileSync, statSync, existsSync, writeFileSync, mkdirSync, renameSync, openSync, readSync, closeSync } from 'node:fs';
1
+ import { readdirSync, readFileSync, statSync, existsSync, writeFileSync, mkdirSync, renameSync, openSync, readSync, closeSync, realpathSync } from 'node:fs';
2
2
  import { join, dirname, basename } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { CLAUDE_PROJECTS, SCAN_CACHE_PATH, AGGREGATE_PATH, DATA_DIR, EVENTS_LOG_PATH } from './paths.js';
@@ -9,7 +9,9 @@ import { classifyShip } from './ships.js';
9
9
  // Bumping this forces a full re-parse on next scan. Increment whenever the
10
10
  // per-transcript summary schema changes in a way old caches can't satisfy.
11
11
  // v5: per-day ships/shipKinds + per-day project attribution (recap).
12
- const CACHE_VERSION = 5;
12
+ // v6: day/week/hour buckets carry firstTs/lastTs (they were declared but
13
+ // never assigned, leaving {startTimeLabel} permanently blank).
14
+ const CACHE_VERSION = 6;
13
15
 
14
16
  // Cap counted gap between consecutive timestamps. Anything larger is treated
15
17
  // as the user walking away — we count only what's plausibly active time.
@@ -295,6 +297,15 @@ function parseChunkInto(text, summary, pstate) {
295
297
  const weekBucket = week ? (summary.byWeek[week] ||= blankDay()) : null;
296
298
  const hourBucket = hour !== null ? (summary.byHour[hour] ||= blankDay()) : null;
297
299
  const allBuckets = [dayBucket, weekBucket, hourBucket].filter(Boolean);
300
+ // First/last activity within each bucket — blankDay declares these and
301
+ // mergeDay merges them, but nothing assigned them, so "started 09:14"-style
302
+ // labels never rendered.
303
+ if (ts) {
304
+ for (const bucket of allBuckets) {
305
+ if (!bucket.firstTs || ts < bucket.firstTs) bucket.firstTs = ts;
306
+ if (!bucket.lastTs || ts > bucket.lastTs) bucket.lastTs = ts;
307
+ }
308
+ }
298
309
 
299
310
  if (r.type === 'assistant') {
300
311
  const turnModel = r.message?.model || summary.model;
@@ -711,7 +722,10 @@ function readCache() {
711
722
  function writeCache(cache) {
712
723
  ensureDataDir();
713
724
  cache._v = CACHE_VERSION;
714
- const tmp = SCAN_CACHE_PATH + '.tmp';
725
+ // Pid-suffixed like state.js: the daemon's background rescan and a user-run
726
+ // `claude-rpc scan` can overlap, and a shared tmp name lets one writer's
727
+ // rename land a half-written file (or ENOENT the other's rename).
728
+ const tmp = `${SCAN_CACHE_PATH}.${process.pid}.tmp`;
715
729
  writeFileSync(tmp, JSON.stringify(cache));
716
730
  renameSync(tmp, SCAN_CACHE_PATH);
717
731
  }
@@ -740,7 +754,7 @@ function readNotificationsByDay() {
740
754
 
741
755
  function writeAggregate(agg) {
742
756
  ensureDataDir();
743
- const tmp = AGGREGATE_PATH + '.tmp';
757
+ const tmp = `${AGGREGATE_PATH}.${process.pid}.tmp`;
744
758
  writeFileSync(tmp, JSON.stringify(agg, null, 2));
745
759
  renameSync(tmp, AGGREGATE_PATH);
746
760
  }
@@ -1104,6 +1118,18 @@ export function scan({ projectsDir, projectsDirs, onProgress, force = false, ext
1104
1118
  dirs.push(CLAUDE_PROJECTS);
1105
1119
  dirs.push(...discoverAltProjectDirs());
1106
1120
  }
1121
+ // Dedupe by REAL path, not path string: a symlinked alt location (e.g.
1122
+ // ~/.config/claude → ~/.claude) otherwise walks the same transcripts twice
1123
+ // under two absolute paths and exactly doubles every lifetime stat.
1124
+ {
1125
+ const seen = new Set();
1126
+ for (let i = 0; i < dirs.length; i++) {
1127
+ let key = dirs[i];
1128
+ try { key = realpathSync(key); } catch { /* missing dir — keep literal */ }
1129
+ if (seen.has(key)) { dirs.splice(i, 1); i--; continue; }
1130
+ seen.add(key);
1131
+ }
1132
+ }
1107
1133
  for (const d of extraDirs) if (!dirs.includes(d)) dirs.push(d);
1108
1134
 
1109
1135
  const cache = readCache();
@@ -4,6 +4,7 @@
4
4
  // `claude-rpc session-card --out s.svg`.
5
5
 
6
6
  import { VERSION } from './version.js';
7
+ import { localDateStamp } from './fmt.js';
7
8
 
8
9
  const W = 520;
9
10
  const H = 230;
@@ -47,7 +48,7 @@ export function renderSessionCard(vars = {}, { generatedAt = new Date() } = {})
47
48
  <rect x="0.75" y="0.75" width="${W - 7}" height="${H - 9}" fill="url(#dg)"/>
48
49
 
49
50
  <text x="40" y="50" font-family="Space Grotesk, Inter, system-ui, sans-serif" font-size="28" font-weight="800" letter-spacing="-1" fill="${PALETTE.ink}">${escapeXml(v.project || 'this session')}</text>
50
- <text x="40" y="72" font-family="JetBrains Mono, ui-monospace, monospace" font-size="12" fill="${PALETTE.inkMute}">${escapeXml(`${v.modelPretty || 'Claude'} · ${v.duration || '0s'} · ${generatedAt.toISOString().slice(0, 10)}`)}</text>
51
+ <text x="40" y="72" font-family="JetBrains Mono, ui-monospace, monospace" font-size="12" fill="${PALETTE.inkMute}">${escapeXml(`${v.modelPretty || 'Claude'} · ${v.duration || '0s'} · ${localDateStamp(generatedAt)}`)}</text>
51
52
  ${v.modelPretty ? tape(W - 150, 28, String(v.modelPretty)) : ''}
52
53
 
53
54
  <line x1="40" y1="92" x2="${W - 40}" y2="92" stroke="${PALETTE.ink}" stroke-width="1" opacity="0.18"/>
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 = '1.1.0';
14
+ const BAKED = '1.1.1';
15
15
 
16
16
  function readPkgVersion() {
17
17
  try {