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 +1 -1
- package/src/calendar.js +2 -1
- package/src/card.js +2 -2
- package/src/cli.js +72 -14
- package/src/daemon.js +46 -15
- package/src/fmt.js +8 -0
- package/src/format.js +4 -0
- package/src/hook.js +26 -2
- package/src/nudge.js +25 -14
- package/src/profile.js +2 -2
- package/src/scanner.js +30 -4
- package/src/session-card.js +2 -1
- package/src/version.js +1 -1
package/package.json
CHANGED
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
|
|
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
|
|
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
|
-
|
|
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
|
|
306
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1292
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
30
|
-
// the single most impressive one
|
|
31
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
105
|
+
function writeShownKeys(shown, path = NUDGE_STATE) {
|
|
97
106
|
try {
|
|
98
107
|
mkdirSync(dirname(path), { recursive: true });
|
|
99
|
-
|
|
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
|
|
117
|
+
const shown = readShownKeys(path);
|
|
118
|
+
const n = pickShareNudge(agg, shown);
|
|
108
119
|
if (!n) return null;
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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();
|
package/src/session-card.js
CHANGED
|
@@ -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
|
|
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"/>
|