claude-rpc 1.0.3 → 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/README.md CHANGED
@@ -133,6 +133,7 @@ claude-rpc today (today's stats, focused)
133
133
  claude-rpc week (weekday breakdown)
134
134
  claude-rpc preview (every rotation frame rendered with real data)
135
135
  claude-rpc insights (3–5 auto-generated lines: trend, peak, hotspot)
136
+ claude-rpc recap --md (standup-ready: yesterday's projects, ships, churn)
136
137
  ```
137
138
 
138
139
  The web dashboard pushes updates via SSE; the TUI refreshes on a 3-second tick.
@@ -309,6 +310,7 @@ It's a thin bootstrapper — on the first session it just runs `npx claude-rpc@l
309
310
  | `scan` / `rescan`| Incremental / forced re-parse of `~/.claude/projects` |
310
311
  | `backfill <dir>` | Import transcripts from any folder (backup, other machine) |
311
312
  | `insights` | Print 3–5 auto-generated lines about your week |
313
+ | `recap` | Standup summary — yesterday's work, paste-ready (`today\|week\|date`, `--md`) |
312
314
  | `badge` | Shields-style SVG (`--metric` `--range` `--out` `--gist`) |
313
315
  | `card` | Poster-style SVG (`--range year\|month\|week\|all`) |
314
316
  | `github-stat` | Embeddable profile stat card (`--handle` `--out` `--gist`) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-rpc",
3
- "version": "1.0.3",
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}`];
@@ -858,7 +890,36 @@ function showInsights() {
858
890
  console.log('');
859
891
  }
860
892
 
893
+ // `claude-rpc recap [today|yesterday|week|YYYY-MM-DD] [--md|--json]` — the
894
+ // standup answer. Defaults to yesterday (with Monday-covers-Friday fallback,
895
+ // see recap.js). --md prints paste-ready markdown; --json the raw numbers.
896
+ async function doRecap(argv) {
897
+ const { buildRecap, renderRecapMarkdown, renderRecapLines, recapTitle } = await import('./recap.js');
898
+ let spec = 'yesterday', md = false, json = false;
899
+ for (const a of argv) {
900
+ if (a === '--md' || a === '--markdown') md = true;
901
+ else if (a === '--json') json = true;
902
+ else if (!a.startsWith('-')) spec = a;
903
+ else return fail(`unknown flag ${a}`, { hint: 'usage: claude-rpc recap [today|yesterday|week|YYYY-MM-DD] [--md|--json]', code: EX_USER_ERROR });
904
+ }
905
+ const aggregate = readAggregate();
906
+ if (!aggregate) return fail('no stats yet', { hint: 'run `claude-rpc scan` to build your history first', code: EX_BAD_STATE });
907
+ const r = buildRecap(aggregate, spec);
908
+ if (!r) return fail(`unknown range: ${spec}`, { hint: 'usage: claude-rpc recap [today|yesterday|week|YYYY-MM-DD] [--md|--json]', code: EX_USER_ERROR });
909
+ if (json) { console.log(JSON.stringify(r, null, 2)); return; }
910
+ if (md) { console.log(renderRecapMarkdown(r)); return; }
911
+ console.log('');
912
+ console.log(` ${c.bold}${c.magenta}◆ Recap${c.reset} ${c.dim}— ${recapTitle(r)}${c.reset}`);
913
+ console.log('');
914
+ box('recap', renderRecapLines(r, c));
915
+ console.log('');
916
+ if (process.stdout.isTTY && !r.empty) {
917
+ console.log(` ${c.dim}↗ standup-ready markdown: claude-rpc recap${spec === 'yesterday' ? '' : ` ${spec}`} --md${c.reset}\n`);
918
+ }
919
+ }
920
+
861
921
  function parseBadgeArgs(argv) {
922
+ argv = splitEqArgs(argv);
862
923
  const out = { metric: 'hours', range: '7d', out: '', gist: false };
863
924
  for (let i = 0; i < argv.length; i++) {
864
925
  const a = argv[i];
@@ -893,11 +954,18 @@ async function doBadge(argv) {
893
954
  // Publish the rendered badge to a GitHub gist and emit the README-ready
894
955
  // markdown snippet. First successful publish records id+owner in
895
956
  // config.json so subsequent runs UPDATE that gist (raw URL stays stable).
896
- async function publishBadgeToGist(svg, opts) {
957
+ async function publishBadgeToGist(svg, opts, kind = 'badge') {
897
958
  const { publishGistFile, gistMarkdown } = await import('./gist.js');
898
959
  const cfg = loadConfig();
899
960
  const stored = cfg.gist || {};
900
- 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`);
901
969
  try {
902
970
  const result = await publishGistFile({
903
971
  svg,
@@ -915,8 +983,11 @@ async function publishBadgeToGist(svg, opts) {
915
983
  ...(userCfg.gist || {}),
916
984
  id: result.id,
917
985
  owner: result.owner,
918
- filename,
986
+ files: { ...((userCfg.gist || {}).files || {}), [kind]: filename },
919
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;
920
991
  if (stored.public !== undefined) userCfg.gist.public = stored.public;
921
992
  writeUserConfig(userCfg);
922
993
  const wasUpdate = !!stored.id;
@@ -940,6 +1011,7 @@ async function publishBadgeToGist(svg, opts) {
940
1011
  // for a range (year / month / week / all-time). Output is SVG only;
941
1012
  // screenshot or convert to PNG offline if needed.
942
1013
  function parseCardArgs(argv) {
1014
+ argv = splitEqArgs(argv);
943
1015
  const out = { range: 'year', out: '' };
944
1016
  for (let i = 0; i < argv.length; i++) {
945
1017
  const a = argv[i];
@@ -970,6 +1042,7 @@ async function doCard(argv) {
970
1042
  // for a profile README. `--gist` publishes it to a gist (raw URL stays stable
971
1043
  // across re-runs) so the README image auto-refreshes when you re-run it.
972
1044
  function parseGithubStatArgs(argv) {
1045
+ argv = splitEqArgs(argv);
973
1046
  const out = { out: '', gist: false, handle: '' };
974
1047
  for (let i = 0; i < argv.length; i++) {
975
1048
  const a = argv[i];
@@ -989,7 +1062,7 @@ async function doGithubStat(argv) {
989
1062
  const { renderProfileCard } = await import('./profile.js');
990
1063
  const svg = renderProfileCard(aggregate, { handle: opts.handle });
991
1064
  if (opts.gist) {
992
- await publishBadgeToGist(svg, { metric: 'profile', range: 'all-time' });
1065
+ await publishBadgeToGist(svg, { metric: 'profile', range: 'all-time' }, 'github-stat');
993
1066
  return;
994
1067
  }
995
1068
  if (opts.out) {
@@ -1015,6 +1088,7 @@ function liveVars() {
1015
1088
  // statusline — a compact one-line status for tmux / starship / shell prompts
1016
1089
  // and Claude Code's own statusline. `--template "..."` overrides the format.
1017
1090
  function doStatusline(argv) {
1091
+ argv = splitEqArgs(argv);
1018
1092
  let tpl = '{statusVerbose} · {project} · {modelPretty}{tokensLabelPad}';
1019
1093
  for (let i = 0; i < argv.length; i++) {
1020
1094
  if (argv[i] === '--template' || argv[i] === '-t') tpl = takeValue(argv[++i], '--template');
@@ -1026,6 +1100,7 @@ function doStatusline(argv) {
1026
1100
 
1027
1101
  // Activity calendar — GitHub-contributions-style year heatmap SVG.
1028
1102
  async function doCalendar(argv) {
1103
+ argv = splitEqArgs(argv);
1029
1104
  const opts = { out: '', gist: false };
1030
1105
  for (let i = 0; i < argv.length; i++) {
1031
1106
  if (argv[i] === '--out' || argv[i] === '-o') opts.out = takeValue(argv[++i], '--out');
@@ -1035,7 +1110,7 @@ async function doCalendar(argv) {
1035
1110
  if (!aggregate) fail('no aggregate yet — nothing to render', { hint: 'run `claude-rpc scan` first', code: EX_BAD_STATE });
1036
1111
  const { renderCalendar } = await import('./calendar.js');
1037
1112
  const svg = renderCalendar(aggregate, {});
1038
- if (opts.gist) return publishBadgeToGist(svg, { metric: 'calendar', range: 'year' });
1113
+ if (opts.gist) return publishBadgeToGist(svg, { metric: 'calendar', range: 'year' }, 'calendar');
1039
1114
  if (opts.out) {
1040
1115
  writeFileSync(opts.out, svg);
1041
1116
  console.log(` ${c.green}✓${c.reset} wrote ${c.cyan}${opts.out}${c.reset} ${c.dim}(${svg.length} bytes)${c.reset}`);
@@ -1044,6 +1119,7 @@ async function doCalendar(argv) {
1044
1119
 
1045
1120
  // Per-session recap card — current/most-recent session as a shareable SVG.
1046
1121
  async function doSessionCard(argv) {
1122
+ argv = splitEqArgs(argv);
1047
1123
  const opts = { out: '' };
1048
1124
  for (let i = 0; i < argv.length; i++) {
1049
1125
  if (argv[i] === '--out' || argv[i] === '-o') opts.out = takeValue(argv[++i], '--out');
@@ -1258,10 +1334,12 @@ function doResume() {
1258
1334
  // pipes cleanly into jq / a spreadsheet import.
1259
1335
 
1260
1336
  async function doExport(argv) {
1337
+ argv = splitEqArgs(argv);
1261
1338
  const csv = argv.includes('--csv');
1262
1339
  let out = '';
1263
- const i = argv.findIndex((a) => a === '--out' || a === '-o');
1264
- 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
+ }
1265
1343
  const aggregate = readAggregate();
1266
1344
  if (!aggregate) {
1267
1345
  fail('no aggregate yet — nothing to export', { hint: 'run `claude-rpc scan` first', code: EX_BAD_STATE });
@@ -1601,7 +1679,13 @@ async function communityReport() {
1601
1679
  } else if (result.ok) {
1602
1680
  console.log(` ${c.cyan}·${c.reset} ${c.dim}${result.reason}${c.reset}`);
1603
1681
  } else {
1604
- 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
+ });
1605
1689
  }
1606
1690
  console.log('');
1607
1691
  }
@@ -1717,8 +1801,10 @@ function profileSet(argv) {
1717
1801
  next.githubUser = u;
1718
1802
  }
1719
1803
  // The ✓ belongs to the account that was verified — switching accounts
1720
- // means re-verifying.
1721
- 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;
1722
1808
  }
1723
1809
 
1724
1810
  userCfg.profile = next;
@@ -2005,6 +2091,7 @@ function help() {
2005
2091
  ['rescan', 'Force re-parse every transcript (ignores cache)'],
2006
2092
  ['backfill', 'Import transcripts from any folder (e.g. a backup)'],
2007
2093
  ['insights', 'Auto-generated insights from your history'],
2094
+ ['recap', 'Standup summary (yesterday by default; today|week|date, --md for markdown)'],
2008
2095
  ['badge', 'Render a Shields-style SVG (--metric --range --out --gist)'],
2009
2096
  ['card', 'Render a poster-style SVG summary (--range year|month|week|all)'],
2010
2097
  ['github-stat', 'Render an embeddable profile stat card (--handle --out --gist)'],
@@ -2161,6 +2248,7 @@ process.on('unhandledRejection', (e) => {
2161
2248
  case 'rescan': doScan(true); break;
2162
2249
  case 'backfill': doBackfill(process.argv.slice(3)); break;
2163
2250
  case 'insights': showInsights(); break;
2251
+ case 'recap': await doRecap(process.argv.slice(3)); break;
2164
2252
  case 'badge': await doBadge(process.argv.slice(3)); break;
2165
2253
  case 'card': await doCard(process.argv.slice(3)); break;
2166
2254
  case 'github-stat': await doGithubStat(process.argv.slice(3)); break;
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
@@ -6,93 +6,10 @@ import { detectLastCommitSubject, detectGitBranch } from './git.js';
6
6
  import { EVENTS_LOG_PATH } from './paths.js';
7
7
  import { loadConfig } from './config.js';
8
8
  import { ensureDaemonRunning } from './ensure-daemon.js';
9
-
10
- // Precedence when a command ships more than one way (`git commit && git push`
11
- // push). Highest first.
12
- const SHIP_PRECEDENCE = ['push', 'commit', 'pr', 'issue', 'tag'];
13
-
14
- // Tokenize one command segment the way a shell roughly would for our purposes:
15
- // strip leading env assignments (FOO=bar) and sudo/time wrappers, drop the path
16
- // from the leading binary (/usr/bin/git → git), lowercase it.
17
- function tokenizeSegment(seg) {
18
- const stripped = seg.replace(/^\s*(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)+/, '').trim();
19
- let toks = stripped.split(/\s+/).filter(Boolean);
20
- while (toks.length && (toks[0] === 'sudo' || toks[0] === 'time')) toks = toks.slice(1);
21
- if (toks.length) {
22
- const slash = toks[0].lastIndexOf('/');
23
- if (slash !== -1) toks[0] = toks[0].slice(slash + 1);
24
- toks[0] = toks[0].toLowerCase();
25
- }
26
- return toks;
27
- }
28
-
29
- // First real git subcommand, skipping global flags and their values
30
- // (`git -C /repo -c k=v push` → push).
31
- function gitSubcommand(args) {
32
- for (let i = 0; i < args.length; i++) {
33
- const a = args[i];
34
- if (a === '-C' || a === '-c') { i++; continue; } // flag that takes a value
35
- if (a.startsWith('-')) continue;
36
- return a.toLowerCase();
37
- }
38
- return null;
39
- }
40
-
41
- // First two gh positionals (noun, verb), skipping global flags so the canonical
42
- // targeted form `gh -R owner/repo pr create` still classifies. -R/--repo take a
43
- // value; other globals here don't precede a create, so skipping the rest is safe.
44
- function ghSubcommand(args) {
45
- const pos = [];
46
- for (let i = 0; i < args.length; i++) {
47
- const a = args[i];
48
- if (a === '-R' || a === '--repo') { i++; continue; } // flag that takes a value
49
- if (a.startsWith('-')) continue;
50
- pos.push(a.toLowerCase());
51
- if (pos.length === 2) break;
52
- }
53
- return { noun: pos[0], verb: pos[1] };
54
- }
55
-
56
- function shipKindForSegment(seg) {
57
- const toks = tokenizeSegment(seg);
58
- if (!toks.length) return null;
59
- if (toks[0] === 'git') {
60
- const sub = gitSubcommand(toks.slice(1));
61
- if (sub === 'push') return 'push';
62
- if (sub === 'commit') return 'commit';
63
- } else if (toks[0] === 'gh') {
64
- const { noun, verb } = ghSubcommand(toks.slice(1));
65
- if (noun === 'pr' && verb === 'create') return 'pr';
66
- if (noun === 'issue' && verb === 'create') return 'issue';
67
- if (noun === 'release' && verb === 'create') return 'tag';
68
- }
69
- return null;
70
- }
71
-
72
- // Return the "shipped" kind for a shell command, or null. Exported for tests.
73
- // Splits on shell separators and only classifies a segment whose *actual*
74
- // leading command is git/gh — so a quoted mention ("git push later" inside an
75
- // echo or a commit message) no longer false-fires. Tolerates env prefixes,
76
- // sudo/time, chained commands, and git global flags.
77
- //
78
- // Quoted spans are blanked BEFORE splitting: separators inside quotes
79
- // (`echo "run git push && rejoice"`) used to create a fake segment whose
80
- // leading command was git. The real command's own quoted args (`git commit
81
- // -m "msg"`) classify the same with or without the message text, so blanking
82
- // is lossless for detection. An unbalanced quote leaves the string untouched.
83
- export function classifyShip(cmd) {
84
- const blanked = String(cmd || '')
85
- .replace(/'[^']*'/g, ' ')
86
- .replace(/"(?:\\.|[^"\\])*"/g, ' ');
87
- const segments = blanked.split(/[;&|\n]+/);
88
- const found = new Set();
89
- for (const seg of segments) {
90
- const k = shipKindForSegment(seg);
91
- if (k) found.add(k);
92
- }
93
- for (const kind of SHIP_PRECEDENCE) if (found.has(kind)) return kind;
94
- return null;
95
- }
9
+ // Ship classification lives in ships.js (shared with the scanner); re-exported
10
+ // here so existing importers/tests keep working.
11
+ import { classifyShip } from './ships.js';
12
+ export { classifyShip };
96
13
 
97
14
  const EVENTS_LOG_ROTATE_BYTES = 5 * 1024 * 1024;
98
15
 
@@ -150,6 +67,23 @@ export function processHookEvent(event, input = {}) {
150
67
 
151
68
  switch (event) {
152
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
+ }
153
87
  reset({
154
88
  cwd: input.cwd || process.cwd(),
155
89
  model: input.model?.id || input.model || 'claude',
@@ -215,7 +149,7 @@ export function processHookEvent(event, input = {}) {
215
149
  case 'PostToolUse': {
216
150
  const toolName = input.tool_name || input.toolName || '';
217
151
  const toolInput = input.tool_input || input.toolInput || {};
218
- const file = toolInput.file_path || toolInput.path || null;
152
+ const file = toolInput.file_path || toolInput.path || toolInput.notebook_path || null;
219
153
  // Just-shipped detection: any Bash command that contains `git push`
220
154
  // or `git commit`. Capture cwd + branch + last commit subject NOW
221
155
  // — by the time the daemon renders the next frame this info may be
@@ -310,8 +244,15 @@ export function processHookEvent(event, input = {}) {
310
244
  });
311
245
  break;
312
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
+ }
313
255
  case 'Stop':
314
- case 'SubagentStop':
315
256
  default: {
316
257
  setActivity({ status: 'idle', currentTool: null, currentFile: null });
317
258
  }
package/src/mcp.js CHANGED
@@ -8,6 +8,7 @@
8
8
  // without standing up the transport.
9
9
 
10
10
  import { readAggregate, dayKey } from './scanner.js';
11
+ import { buildRecap, renderRecapMarkdown } from './recap.js';
11
12
  import { VERSION } from './version.js';
12
13
 
13
14
  function fmtH(ms) { const h = (ms || 0) / 3_600_000; return h < 1 ? `${Math.round(h * 60)}m` : `${h.toFixed(1)}h`; }
@@ -75,6 +76,25 @@ export const TOOLS = {
75
76
  return split.map((m) => `${m.model}: $${(m.cost || 0).toFixed(2)} (${Math.round((m.costPct || 0) * 100)}%) · ${fmtN(m.tokens)} tokens · ${m.turns} turns`).join('\n');
76
77
  },
77
78
  },
79
+ get_recap: {
80
+ description: 'Standup-ready recap of a day or week of Claude Code work: active time, sessions, projects touched, ships (commits/pushes/PRs), code churn, tokens/cost. Answers "what did I work on yesterday/this week?".',
81
+ inputSchema: {
82
+ type: 'object',
83
+ properties: {
84
+ range: {
85
+ type: 'string',
86
+ description: 'today | yesterday | week | YYYY-MM-DD (default yesterday; an inactive yesterday falls back to the most recent active day, so Monday covers Friday)',
87
+ },
88
+ },
89
+ additionalProperties: false,
90
+ },
91
+ handler(agg, args) {
92
+ const spec = args?.range || 'yesterday';
93
+ const r = buildRecap(agg, spec);
94
+ if (!r) return `Unknown range "${spec}" — use today, yesterday, week, or YYYY-MM-DD.`;
95
+ return renderRecapMarkdown(r);
96
+ },
97
+ },
78
98
  };
79
99
 
80
100
  // Build a tools/list payload from the registry.
@@ -82,7 +102,7 @@ export function toolList() {
82
102
  return Object.entries(TOOLS).map(([name, t]) => ({
83
103
  name,
84
104
  description: t.description,
85
- inputSchema: { type: 'object', properties: {}, additionalProperties: false },
105
+ inputSchema: t.inputSchema || { type: 'object', properties: {}, additionalProperties: false },
86
106
  }));
87
107
  }
88
108
 
@@ -90,10 +110,11 @@ export function toolList() {
90
110
  * Dispatch a tools/call by name. Reads a fresh aggregate per call (cheap).
91
111
  * @param {string} name - Tool name (a key of TOOLS).
92
112
  * @param {() => object} [getAgg] - Aggregate provider; defaults to readAggregate (injectable for tests).
113
+ * @param {object} [args] - Tool arguments (params.arguments from tools/call).
93
114
  * @returns {string} The tool's text result.
94
115
  * @throws {Error} If the tool name is unknown.
95
116
  */
96
- export function callTool(name, getAgg = readAggregate) {
117
+ export function callTool(name, getAgg = readAggregate, args = {}) {
97
118
  const t = TOOLS[name];
98
119
  if (!t) throw new Error(`unknown tool: ${name}`);
99
120
  const agg = getAgg();
@@ -101,7 +122,7 @@ export function callTool(name, getAgg = readAggregate) {
101
122
  // a `|| {}` here would render all-zeros, indistinguishable from a genuinely
102
123
  // idle day. Surface the same hint every other surface uses instead.
103
124
  if (agg == null) return 'No stats yet — run `claude-rpc scan` to build your history.';
104
- return t.handler(agg);
125
+ return t.handler(agg, args);
105
126
  }
106
127
 
107
128
  /**
@@ -144,7 +165,7 @@ export function runMcpServer({ input = process.stdin, output = process.stdout }
144
165
  if (!name) return replyErr(id, -32602, 'missing tool name');
145
166
  if (!TOOLS[name]) return replyErr(id, -32602, `unknown tool: ${name}`);
146
167
  try {
147
- const text = callTool(name);
168
+ const text = callTool(name, undefined, params?.arguments || {});
148
169
  return reply(id, { content: [{ type: 'text', text }], isError: false });
149
170
  } catch (e) {
150
171
  return reply(id, { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true });
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/recap.js ADDED
@@ -0,0 +1,205 @@
1
+ // Standup recap — "what did I do yesterday?" answered from aggregate.json.
2
+ // Pure functions of (aggregate, range spec, now): buildRecap assembles the
3
+ // numbers, renderRecapMarkdown/renderRecapLines format them for pasting into
4
+ // a standup message or printing in the terminal. Consumed by `claude-rpc
5
+ // recap` and the MCP `get_recap` tool.
6
+ //
7
+ // Range semantics:
8
+ // today — the current local day
9
+ // yesterday — the previous local day; if it had no activity (weekend,
10
+ // day off), falls back to the most recent active day before
11
+ // today and says so — Monday standups cover Friday.
12
+ // week — the last 7 local days, ending today
13
+ // YYYY-MM-DD — one explicit local day
14
+ import { dayKey } from './scanner.js';
15
+
16
+ const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
17
+ const SHIP_LABELS = [
18
+ ['commit', 'commit', 'commits'],
19
+ ['push', 'push', 'pushes'],
20
+ ['pr', 'PR', 'PRs'],
21
+ ['issue', 'issue', 'issues'],
22
+ ['tag', 'release', 'releases'],
23
+ ];
24
+
25
+ function fmtDur(ms) {
26
+ const m = Math.round((ms || 0) / 60_000);
27
+ if (m < 1) return '0m';
28
+ if (m < 60) return `${m}m`;
29
+ return `${(m / 60).toFixed(1)}h`;
30
+ }
31
+
32
+ function fmtN(n) {
33
+ if (!n) return '0';
34
+ if (n < 1000) return String(Math.round(n));
35
+ if (n < 1e6) return `${(n / 1e3).toFixed(1)}k`;
36
+ if (n < 1e9) return `${(n / 1e6).toFixed(2)}M`;
37
+ return `${(n / 1e9).toFixed(2)}B`;
38
+ }
39
+
40
+ function dayKeyAt(now, offsetDays) {
41
+ const d = new Date(now);
42
+ d.setHours(0, 0, 0, 0);
43
+ d.setDate(d.getDate() - offsetDays);
44
+ return dayKey(d.getTime());
45
+ }
46
+
47
+ // "Tue 2026-07-01" — manual weekday names so output is locale-stable.
48
+ function prettyDay(key) {
49
+ const [y, m, d] = String(key).split('-').map(Number);
50
+ if (!y || !m || !d) return String(key);
51
+ return `${WEEKDAYS[new Date(y, m - 1, d).getDay()]} ${key}`;
52
+ }
53
+
54
+ // A day "has activity" when real work landed — active time, prompts, tool
55
+ // calls, sessions, or tokens. Notification-only days (the daemon logging a
56
+ // permission ping) don't count.
57
+ function hasActivity(bucket) {
58
+ if (!bucket) return false;
59
+ return (bucket.activeMs || 0) > 0 || (bucket.userMessages || 0) > 0
60
+ || (bucket.toolCalls || 0) > 0 || (bucket.sessions || 0) > 0
61
+ || (bucket.inputTokens || 0) + (bucket.outputTokens || 0)
62
+ + (bucket.cacheReadTokens || 0) + (bucket.cacheWriteTokens || 0) > 0;
63
+ }
64
+
65
+ // Resolve a range spec to { label, days, note }. Exported for tests.
66
+ export function resolveRecapRange(spec, agg, now = Date.now()) {
67
+ const byDay = agg?.byDay || {};
68
+ const s = String(spec || 'yesterday').toLowerCase();
69
+ if (s === 'today') return { label: 'today', days: [dayKeyAt(now, 0)] };
70
+ if (s === 'week') {
71
+ const days = [];
72
+ for (let i = 6; i >= 0; i--) days.push(dayKeyAt(now, i));
73
+ return { label: 'last 7 days', days };
74
+ }
75
+ if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return { label: prettyDay(s), days: [s] };
76
+ if (s !== 'yesterday') return null; // unknown spec — caller reports usage
77
+ const yesterday = dayKeyAt(now, 1);
78
+ if (hasActivity(byDay[yesterday])) return { label: 'yesterday', days: [yesterday] };
79
+ // Walk back for the most recent active day before today (bounded so a
80
+ // fresh install doesn't loop through a year of nothing).
81
+ for (let i = 2; i <= 366; i++) {
82
+ const k = dayKeyAt(now, i);
83
+ if (hasActivity(byDay[k])) {
84
+ return {
85
+ label: 'yesterday',
86
+ days: [k],
87
+ note: `no activity yesterday — showing ${prettyDay(k)}, your most recent active day`,
88
+ };
89
+ }
90
+ }
91
+ return { label: 'yesterday', days: [yesterday] }; // genuinely nothing — renders as empty
92
+ }
93
+
94
+ /**
95
+ * Assemble a recap for a range of days.
96
+ * @param {object} agg - aggregate.json contents
97
+ * @param {string} [spec] - today|yesterday|week|YYYY-MM-DD (default yesterday)
98
+ * @param {number} [now] - injectable clock for tests
99
+ * @returns {object|null} recap data, or null for an unknown spec
100
+ */
101
+ export function buildRecap(agg, spec = 'yesterday', now = Date.now()) {
102
+ const range = resolveRecapRange(spec, agg, now);
103
+ if (!range) return null;
104
+ const byDay = agg?.byDay || {};
105
+ const r = {
106
+ label: range.label,
107
+ days: range.days,
108
+ from: range.days[0],
109
+ to: range.days[range.days.length - 1],
110
+ note: range.note || null,
111
+ activeMs: 0, sessions: 0, prompts: 0, toolCalls: 0,
112
+ tokens: 0, cost: 0, linesAdded: 0, linesRemoved: 0,
113
+ ships: 0, shipKinds: {}, projects: [],
114
+ };
115
+ const projects = {};
116
+ for (const k of range.days) {
117
+ const d = byDay[k];
118
+ if (!d) continue;
119
+ r.activeMs += d.activeMs || 0;
120
+ r.sessions += d.sessions || 0;
121
+ r.prompts += d.userMessages || 0;
122
+ r.toolCalls += d.toolCalls || 0;
123
+ r.tokens += (d.inputTokens || 0) + (d.outputTokens || 0) + (d.cacheReadTokens || 0) + (d.cacheWriteTokens || 0);
124
+ r.cost += d.cost || 0;
125
+ r.linesAdded += d.linesAdded || 0;
126
+ r.linesRemoved += d.linesRemoved || 0;
127
+ r.ships += d.ships || 0;
128
+ for (const [kind, n] of Object.entries(d.shipKinds || {})) {
129
+ r.shipKinds[kind] = (r.shipKinds[kind] || 0) + n;
130
+ }
131
+ for (const [name, p] of Object.entries(d.projects || {})) {
132
+ const t = projects[name] ||= { name, activeMs: 0, tokens: 0 };
133
+ t.activeMs += p.activeMs || 0;
134
+ t.tokens += p.tokens || 0;
135
+ }
136
+ }
137
+ r.projects = Object.values(projects)
138
+ .sort((a, b) => (b.activeMs - a.activeMs) || (b.tokens - a.tokens) || a.name.localeCompare(b.name));
139
+ r.empty = !(r.activeMs || r.prompts || r.toolCalls || r.sessions || r.tokens);
140
+ return r;
141
+ }
142
+
143
+ /** Human title for a recap: "Tue 2026-07-01 (yesterday)" / "last 7 days (…)". */
144
+ export function recapTitle(r) {
145
+ if (r.days.length > 1) return `${r.label} (${r.from} → ${r.to})`;
146
+ const day = prettyDay(r.from);
147
+ return r.label === 'today' || r.label === 'yesterday' ? `${day} (${r.label})` : day;
148
+ }
149
+
150
+ function shipPhrase(r) {
151
+ const parts = [];
152
+ for (const [kind, singular, plural] of SHIP_LABELS) {
153
+ const n = r.shipKinds[kind] || 0;
154
+ if (n) parts.push(`${n} ${n === 1 ? singular : plural}`);
155
+ }
156
+ // Counted ships whose kind map got lost (old partial data) still show up.
157
+ if (!parts.length && r.ships) parts.push(`${r.ships}×`);
158
+ return parts.join(' · ');
159
+ }
160
+
161
+ function projectPhrase(r, max = 6) {
162
+ const shown = r.projects.slice(0, max)
163
+ .map((p) => (p.activeMs >= 60_000 ? `${p.name} (${fmtDur(p.activeMs)})` : p.name));
164
+ const extra = r.projects.length - max;
165
+ if (extra > 0) shown.push(`+${extra} more`);
166
+ return shown.join(', ');
167
+ }
168
+
169
+ /** Markdown recap — standup-paste-ready. */
170
+ export function renderRecapMarkdown(r) {
171
+ const lines = [`**Claude Code recap — ${recapTitle(r)}**`, ''];
172
+ if (r.empty) {
173
+ lines.push(`_No Claude Code activity ${r.days.length > 1 ? 'in this range' : `on ${prettyDay(r.from)}`}._`);
174
+ return lines.join('\n');
175
+ }
176
+ lines.push(`- **Active:** ${fmtDur(r.activeMs)} across ${r.sessions} session${r.sessions === 1 ? '' : 's'} · ${r.prompts} prompt${r.prompts === 1 ? '' : 's'}`);
177
+ if (r.projects.length) lines.push(`- **Projects:** ${projectPhrase(r)}`);
178
+ if (r.ships) lines.push(`- **Shipped:** ${shipPhrase(r)}`);
179
+ if (r.linesAdded || r.linesRemoved) lines.push(`- **Code:** +${fmtN(r.linesAdded)} / −${fmtN(r.linesRemoved)} lines · ${fmtN(r.toolCalls)} tool calls`);
180
+ lines.push(`- **Tokens:** ${fmtN(r.tokens)} · est. $${r.cost.toFixed(2)}`);
181
+ if (r.note) lines.push('', `_(${r.note})_`);
182
+ return lines.join('\n');
183
+ }
184
+
185
+ /**
186
+ * Terminal recap — array of lines for the CLI's box renderer.
187
+ * @param {object} r - buildRecap result
188
+ * @param {object} [c] - optional ANSI palette ({bold, dim, cyan, green, reset, …}); omit for plain text
189
+ */
190
+ export function renderRecapLines(r, c = {}) {
191
+ const B = c.bold || '', D = c.dim || '', C = c.cyan || '', G = c.green || '', R = c.reset || '';
192
+ if (r.empty) {
193
+ const where = r.days.length > 1 ? 'in this range' : `on ${prettyDay(r.from)}`;
194
+ return [`${D}no Claude Code activity ${where}${R}`];
195
+ }
196
+ const lines = [
197
+ `${D}active${R} ${B}${fmtDur(r.activeMs)}${R} · ${r.sessions} session${r.sessions === 1 ? '' : 's'} · ${r.prompts} prompt${r.prompts === 1 ? '' : 's'}`,
198
+ ];
199
+ if (r.projects.length) lines.push(`${D}projects${R} ${C}${projectPhrase(r)}${R}`);
200
+ if (r.ships) lines.push(`${D}shipped${R} ${G}${shipPhrase(r)}${R}`);
201
+ if (r.linesAdded || r.linesRemoved) lines.push(`${D}code${R} +${fmtN(r.linesAdded)} / −${fmtN(r.linesRemoved)} lines · ${fmtN(r.toolCalls)} tool calls`);
202
+ lines.push(`${D}tokens${R} ${fmtN(r.tokens)} · est. $${r.cost.toFixed(2)}`);
203
+ if (r.note) lines.push(`${D}(${r.note})${R}`);
204
+ return lines;
205
+ }
package/src/scanner.js CHANGED
@@ -1,13 +1,17 @@
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';
5
5
  import { languageOf } from './languages.js';
6
6
  import { costFor, pricingKeyFor } from './pricing.js';
7
+ import { classifyShip } from './ships.js';
7
8
 
8
9
  // Bumping this forces a full re-parse on next scan. Increment whenever the
9
10
  // per-transcript summary schema changes in a way old caches can't satisfy.
10
- const CACHE_VERSION = 4;
11
+ // v5: per-day ships/shipKinds + per-day project attribution (recap).
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;
11
15
 
12
16
  // Cap counted gap between consecutive timestamps. Anything larger is treated
13
17
  // as the user walking away — we count only what's plausibly active time.
@@ -129,8 +133,13 @@ function blankDay() {
129
133
  linesRemoved: 0,
130
134
  cost: 0,
131
135
  notifications: 0,
136
+ ships: 0,
132
137
  firstTs: null,
133
138
  lastTs: null,
139
+ // Day buckets may also lazily carry:
140
+ // shipKinds — { push|commit|pr|issue|tag → count } (only when a ship lands)
141
+ // projects — { name → { activeMs, tokens } } (aggregate-level only,
142
+ // attributed at merge time where the file's project is known)
134
143
  };
135
144
  }
136
145
 
@@ -147,6 +156,10 @@ function mergeDay(target, src) {
147
156
  target.linesRemoved += src.linesRemoved || 0;
148
157
  target.cost += src.cost || 0;
149
158
  target.notifications += src.notifications || 0;
159
+ target.ships += src.ships || 0;
160
+ for (const [k, n] of Object.entries(src.shipKinds || {})) {
161
+ (target.shipKinds ||= {})[k] = (target.shipKinds[k] || 0) + n;
162
+ }
150
163
  if (src.firstTs && (!target.firstTs || src.firstTs < target.firstTs)) target.firstTs = src.firstTs;
151
164
  if (src.lastTs && (!target.lastTs || src.lastTs > target.lastTs)) target.lastTs = src.lastTs;
152
165
  }
@@ -236,6 +249,8 @@ function blankTranscriptSummary() {
236
249
  bashCommands: {}, // first token → count
237
250
  webDomains: {}, // hostname → count
238
251
  subagents: {}, // subagent_type → count
252
+ ships: 0, // shipped-command count (git commit/push, gh pr/issue/release create)
253
+ shipKinds: {}, // ship kind → count
239
254
  cost: 0, // estimated USD
240
255
  costByModel: {}, // pricing key → USD
241
256
  modelsUsed: {}, // raw model id → assistant turns
@@ -282,6 +297,15 @@ function parseChunkInto(text, summary, pstate) {
282
297
  const weekBucket = week ? (summary.byWeek[week] ||= blankDay()) : null;
283
298
  const hourBucket = hour !== null ? (summary.byHour[hour] ||= blankDay()) : null;
284
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
+ }
285
309
 
286
310
  if (r.type === 'assistant') {
287
311
  const turnModel = r.message?.model || summary.model;
@@ -385,6 +409,13 @@ function parseChunkInto(text, summary, pstate) {
385
409
  } else if (b.name === 'Bash') {
386
410
  const cmd = firstShellToken(input.command);
387
411
  if (cmd) summary.bashCommands[cmd] = (summary.bashCommands[cmd] || 0) + 1;
412
+ const shipKind = classifyShip(input.command);
413
+ if (shipKind) {
414
+ summary.ships = (summary.ships || 0) + 1;
415
+ summary.shipKinds[shipKind] = (summary.shipKinds[shipKind] || 0) + 1;
416
+ for (const bucket of allBuckets) bucket.ships = (bucket.ships || 0) + 1;
417
+ if (dayBucket) (dayBucket.shipKinds ||= {})[shipKind] = (dayBucket.shipKinds[shipKind] || 0) + 1;
418
+ }
388
419
  } else if (b.name === 'WebFetch' || b.name === 'WebSearch') {
389
420
  const host = b.name === 'WebFetch' ? domainOf(input.url) : '';
390
421
  if (host) summary.webDomains[host] = (summary.webDomains[host] || 0) + 1;
@@ -691,7 +722,10 @@ function readCache() {
691
722
  function writeCache(cache) {
692
723
  ensureDataDir();
693
724
  cache._v = CACHE_VERSION;
694
- 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`;
695
729
  writeFileSync(tmp, JSON.stringify(cache));
696
730
  renameSync(tmp, SCAN_CACHE_PATH);
697
731
  }
@@ -720,7 +754,7 @@ function readNotificationsByDay() {
720
754
 
721
755
  function writeAggregate(agg) {
722
756
  ensureDataDir();
723
- const tmp = AGGREGATE_PATH + '.tmp';
757
+ const tmp = `${AGGREGATE_PATH}.${process.pid}.tmp`;
724
758
  writeFileSync(tmp, JSON.stringify(agg, null, 2));
725
759
  renameSync(tmp, AGGREGATE_PATH);
726
760
  }
@@ -767,6 +801,8 @@ export function aggregateFrom(cache) {
767
801
  linesAdded: 0,
768
802
  linesRemoved: 0,
769
803
  linesNet: 0,
804
+ ships: 0,
805
+ shipKinds: {},
770
806
  bashCommands: {},
771
807
  webDomains: {},
772
808
  subagents: {},
@@ -800,6 +836,10 @@ export function aggregateFrom(cache) {
800
836
  // — they represent real work done by Claude.
801
837
  agg.linesAdded += summary.linesAdded || 0;
802
838
  agg.linesRemoved += summary.linesRemoved || 0;
839
+ agg.ships += summary.ships || 0;
840
+ for (const [k, n] of Object.entries(summary.shipKinds || {})) {
841
+ agg.shipKinds[k] = (agg.shipKinds[k] || 0) + n;
842
+ }
803
843
  agg.estimatedCost += summary.cost || 0;
804
844
  for (const [m, v] of Object.entries(summary.costByModel || {})) {
805
845
  agg.costByModel[m] = (agg.costByModel[m] || 0) + v;
@@ -848,6 +888,10 @@ export function aggregateFrom(cache) {
848
888
  target.linesAdded += src.linesAdded || 0;
849
889
  target.linesRemoved += src.linesRemoved || 0;
850
890
  target.cost += src.cost || 0;
891
+ target.ships += src.ships || 0;
892
+ for (const [kind, n] of Object.entries(src.shipKinds || {})) {
893
+ (target.shipKinds ||= {})[kind] = (target.shipKinds[kind] || 0) + n;
894
+ }
851
895
  }
852
896
  };
853
897
  mergeSubBuckets(summary.byDay, agg.byDay);
@@ -882,7 +926,17 @@ export function aggregateFrom(cache) {
882
926
  if (summary.lastTs) agg.lastTs = agg.lastTs ? Math.max(agg.lastTs, summary.lastTs) : summary.lastTs;
883
927
  // Full per-day/week/hour merge for top-level sessions.
884
928
  for (const [k, day] of Object.entries(summary.byDay || {})) {
885
- mergeDay(agg.byDay[k] ||= blankDay(), day);
929
+ const target = agg.byDay[k] ||= blankDay();
930
+ mergeDay(target, day);
931
+ // Per-day project attribution — only possible here, where the whole
932
+ // file's project is known. Presence in the map means "touched that
933
+ // day", even if activeMs rounded to 0.
934
+ if (summary.project) {
935
+ const pm = (target.projects ||= {});
936
+ const p = pm[summary.project] ||= { activeMs: 0, tokens: 0 };
937
+ p.activeMs += day.activeMs || 0;
938
+ p.tokens += (day.inputTokens || 0) + (day.outputTokens || 0) + (day.cacheReadTokens || 0) + (day.cacheWriteTokens || 0);
939
+ }
886
940
  }
887
941
  for (const [k, w] of Object.entries(summary.byWeek || {})) {
888
942
  mergeDay(agg.byWeek[k] ||= blankDay(), w);
@@ -1064,6 +1118,18 @@ export function scan({ projectsDir, projectsDirs, onProgress, force = false, ext
1064
1118
  dirs.push(CLAUDE_PROJECTS);
1065
1119
  dirs.push(...discoverAltProjectDirs());
1066
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
+ }
1067
1133
  for (const d of extraDirs) if (!dirs.includes(d)) dirs.push(d);
1068
1134
 
1069
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/ships.js ADDED
@@ -0,0 +1,91 @@
1
+ // "Ship" classification — deciding whether a shell command published work to
2
+ // the world (commit, push, PR, issue, release). Shared by the hook (live
3
+ // celebration frame) and the scanner (per-day ship counts in aggregate.json),
4
+ // so both surfaces agree on what counts as shipping.
5
+
6
+ // Precedence when a command ships more than one way (`git commit && git push`
7
+ // → push). Highest first.
8
+ const SHIP_PRECEDENCE = ['push', 'commit', 'pr', 'issue', 'tag'];
9
+
10
+ // Tokenize one command segment the way a shell roughly would for our purposes:
11
+ // strip leading env assignments (FOO=bar) and sudo/time wrappers, drop the path
12
+ // from the leading binary (/usr/bin/git → git), lowercase it.
13
+ function tokenizeSegment(seg) {
14
+ const stripped = seg.replace(/^\s*(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)+/, '').trim();
15
+ let toks = stripped.split(/\s+/).filter(Boolean);
16
+ while (toks.length && (toks[0] === 'sudo' || toks[0] === 'time')) toks = toks.slice(1);
17
+ if (toks.length) {
18
+ const slash = toks[0].lastIndexOf('/');
19
+ if (slash !== -1) toks[0] = toks[0].slice(slash + 1);
20
+ toks[0] = toks[0].toLowerCase();
21
+ }
22
+ return toks;
23
+ }
24
+
25
+ // First real git subcommand, skipping global flags and their values
26
+ // (`git -C /repo -c k=v push` → push).
27
+ function gitSubcommand(args) {
28
+ for (let i = 0; i < args.length; i++) {
29
+ const a = args[i];
30
+ if (a === '-C' || a === '-c') { i++; continue; } // flag that takes a value
31
+ if (a.startsWith('-')) continue;
32
+ return a.toLowerCase();
33
+ }
34
+ return null;
35
+ }
36
+
37
+ // First two gh positionals (noun, verb), skipping global flags so the canonical
38
+ // targeted form `gh -R owner/repo pr create` still classifies. -R/--repo take a
39
+ // value; other globals here don't precede a create, so skipping the rest is safe.
40
+ function ghSubcommand(args) {
41
+ const pos = [];
42
+ for (let i = 0; i < args.length; i++) {
43
+ const a = args[i];
44
+ if (a === '-R' || a === '--repo') { i++; continue; } // flag that takes a value
45
+ if (a.startsWith('-')) continue;
46
+ pos.push(a.toLowerCase());
47
+ if (pos.length === 2) break;
48
+ }
49
+ return { noun: pos[0], verb: pos[1] };
50
+ }
51
+
52
+ function shipKindForSegment(seg) {
53
+ const toks = tokenizeSegment(seg);
54
+ if (!toks.length) return null;
55
+ if (toks[0] === 'git') {
56
+ const sub = gitSubcommand(toks.slice(1));
57
+ if (sub === 'push') return 'push';
58
+ if (sub === 'commit') return 'commit';
59
+ } else if (toks[0] === 'gh') {
60
+ const { noun, verb } = ghSubcommand(toks.slice(1));
61
+ if (noun === 'pr' && verb === 'create') return 'pr';
62
+ if (noun === 'issue' && verb === 'create') return 'issue';
63
+ if (noun === 'release' && verb === 'create') return 'tag';
64
+ }
65
+ return null;
66
+ }
67
+
68
+ // Return the "shipped" kind for a shell command, or null. Exported for tests.
69
+ // Splits on shell separators and only classifies a segment whose *actual*
70
+ // leading command is git/gh — so a quoted mention ("git push later" inside an
71
+ // echo or a commit message) no longer false-fires. Tolerates env prefixes,
72
+ // sudo/time, chained commands, and git global flags.
73
+ //
74
+ // Quoted spans are blanked BEFORE splitting: separators inside quotes
75
+ // (`echo "run git push && rejoice"`) used to create a fake segment whose
76
+ // leading command was git. The real command's own quoted args (`git commit
77
+ // -m "msg"`) classify the same with or without the message text, so blanking
78
+ // is lossless for detection. An unbalanced quote leaves the string untouched.
79
+ export function classifyShip(cmd) {
80
+ const blanked = String(cmd || '')
81
+ .replace(/'[^']*'/g, ' ')
82
+ .replace(/"(?:\\.|[^"\\])*"/g, ' ');
83
+ const segments = blanked.split(/[;&|\n]+/);
84
+ const found = new Set();
85
+ for (const seg of segments) {
86
+ const k = shipKindForSegment(seg);
87
+ if (k) found.add(k);
88
+ }
89
+ for (const kind of SHIP_PRECEDENCE) if (found.has(kind)) return kind;
90
+ return null;
91
+ }
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.0.3';
14
+ const BAKED = '1.1.1';
15
15
 
16
16
  function readPkgVersion() {
17
17
  try {