claude-rpc 0.18.0 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-rpc",
3
- "version": "0.18.0",
3
+ "version": "0.19.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/badge.js CHANGED
@@ -3,6 +3,7 @@
3
3
 
4
4
  import { dayKey } from './scanner.js';
5
5
  import { fmtCost } from './pricing.js';
6
+ import { fmtNum, fmtHours as fmtHoursLabel } from './fmt.js';
6
7
 
7
8
  const COLORS = {
8
9
  hours: { left: '#555', right: '#4c1' }, // green
@@ -58,22 +59,6 @@ function rangeLabel(range) {
58
59
 
59
60
  export { rangeToDays, rangeLabel, pickWindow };
60
61
 
61
- function fmtHoursLabel(ms) {
62
- if (!ms) return '0h';
63
- const h = ms / 3_600_000;
64
- if (h < 1) return `${Math.round(h * 60)}m`;
65
- if (h < 10) return `${h.toFixed(1)}h`;
66
- return `${Math.round(h)}h`;
67
- }
68
-
69
- function fmtNum(n) {
70
- if (!n) return '0';
71
- if (n < 1000) return String(n);
72
- if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`;
73
- if (n < 1_000_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
74
- return `${(n / 1_000_000_000).toFixed(2)}B`;
75
- }
76
-
77
62
  // Compute label/value pair for the requested metric.
78
63
  function valueFor(aggregate, metric, range) {
79
64
  const a = aggregate || {};
package/src/card.js CHANGED
@@ -15,6 +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
19
 
19
20
  const W = 880;
20
21
  const H = 540;
@@ -45,22 +46,6 @@ function escapeXml(s) {
45
46
  .replace(/"/g, '&quot;');
46
47
  }
47
48
 
48
- function fmtNum(n) {
49
- if (!n) return '0';
50
- if (n < 1000) return String(Math.round(n));
51
- if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`;
52
- if (n < 1_000_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
53
- return `${(n / 1_000_000_000).toFixed(2)}B`;
54
- }
55
-
56
- function fmtHours(ms) {
57
- if (!ms || ms < 0) return '0h';
58
- const h = ms / 3_600_000;
59
- if (h < 1) return `${Math.round(h * 60)}m`;
60
- if (h < 10) return `${h.toFixed(1)}h`;
61
- return `${Math.round(h)}h`;
62
- }
63
-
64
49
  function rangeTitle(range) {
65
50
  const rl = rangeLabel(range);
66
51
  if (rl === 'all-time') return 'on claude';
@@ -103,7 +88,9 @@ function topWeekday(aggregate) {
103
88
  const wd = aggregate?.byWeekday || {};
104
89
  let best = null;
105
90
  for (const [k, v] of Object.entries(wd)) {
106
- if (!best || (v.activeMs || 0) > (best.ms || 0)) best = { day: Number(k), ms: v.activeMs || 0 };
91
+ const day = Number(k);
92
+ if (!Number.isInteger(day) || day < 0 || day > 6) continue; // out-of-range key → skip, no blank name + stray "(3h)"
93
+ if (!best || (v.activeMs || 0) > (best.ms || 0)) best = { day, ms: v.activeMs || 0 };
107
94
  }
108
95
  return best;
109
96
  }
@@ -133,6 +120,7 @@ function peakHourLabel(aggregate) {
133
120
  const ph = aggregate?.peakHour;
134
121
  if (!ph || ph.hour == null) return null;
135
122
  const h = Number(ph.hour);
123
+ if (!Number.isInteger(h) || h < 0 || h > 23) return null; // corrupt aggregate → no label, not "99:00"/"NaN:00"
136
124
  return `${String(h).padStart(2, '0')}:00`;
137
125
  }
138
126
 
package/src/cli.js CHANGED
@@ -11,9 +11,10 @@ if (process.platform === 'win32' && process.stdout.isTTY) {
11
11
  try { spawnSync('chcp.com', ['65001'], { stdio: 'ignore', windowsHide: true }); } catch { /* chcp absent (Wine, custom shell) — accept whatever code page is set */ }
12
12
  }
13
13
  import { DAEMON_SCRIPT, PID_PATH, STATE_PATH, LOG_PATH, AGGREGATE_PATH, CONFIG_PATH, IS_PACKAGED, IS_NPX, EXE_PATH, CANONICAL_EXE } from './paths.js';
14
- import { readState } from './state.js';
14
+ import { readActiveState } from './state.js';
15
15
  import { buildVars, fillTemplate, humanProject, humanTool, applyIdle, framePasses, fmtNum } from './format.js';
16
16
  import { scan, readAggregate, findLiveSessions, dayKey, weekKey } from './scanner.js';
17
+ import { weekGrid } from './week.js';
17
18
  import { runHookCli } from './hook.js';
18
19
  import { install as runInstall, uninstall as runUninstall, isInstalled, migrateConfig, installHooks, ensureCanonicalExe, installMcp, uninstallMcp, setupOutro } from './install.js';
19
20
  import { startTui } from './tui.js';
@@ -62,7 +63,7 @@ function readJson(path, fallback) {
62
63
  // (`profile set`, `community on`, `link`, …). mkdirSync first.
63
64
  function writeUserConfig(userCfg) {
64
65
  mkdirSync(dirname(CONFIG_PATH), { recursive: true });
65
- writeUserConfig(userCfg);
66
+ writeFileSync(CONFIG_PATH, JSON.stringify(userCfg, null, 2) + '\n');
66
67
  }
67
68
 
68
69
  // Validate a flag's value taken with `argv[++i]`. A value that's missing or
@@ -346,7 +347,7 @@ function todayBoxLines(vars, aggregate) {
346
347
  }
347
348
 
348
349
  function showStatus() {
349
- const state = readState();
350
+ const state = readActiveState();
350
351
  const aggregate = readAggregate();
351
352
  const config = loadConfig();
352
353
  const live = findLiveSessions({ thresholdMs: 90_000 });
@@ -528,7 +529,7 @@ function showStatus() {
528
529
  }
529
530
 
530
531
  function showToday() {
531
- const state = readState();
532
+ const state = readActiveState();
532
533
  const aggregate = readAggregate();
533
534
  const config = loadConfig();
534
535
  state.liveSessions = findLiveSessions({ thresholdMs: 90_000 });
@@ -561,7 +562,7 @@ function showToday() {
561
562
  }
562
563
 
563
564
  function showWeek() {
564
- const state = readState();
565
+ const state = readActiveState();
565
566
  const aggregate = readAggregate();
566
567
  const config = loadConfig();
567
568
  state.liveSessions = findLiveSessions({ thresholdMs: 90_000 });
@@ -593,22 +594,7 @@ function showWeek() {
593
594
 
594
595
  // Current ISO week (Mon → Sun). Future days show "—".
595
596
  if (aggregate?.byDay) {
596
- const now = new Date();
597
- now.setHours(0, 0, 0, 0);
598
- const monday = new Date(now);
599
- monday.setDate(monday.getDate() - ((monday.getDay() + 6) % 7));
600
- const days = [];
601
- for (let i = 0; i < 7; i++) {
602
- const d = new Date(monday);
603
- d.setDate(d.getDate() + i);
604
- const k = dayKey(d.getTime());
605
- const dayName = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][d.getDay()];
606
- const ms = aggregate.byDay[k]?.activeMs || 0;
607
- const isFuture = d > now;
608
- const isToday = k === dayKey(now.getTime());
609
- days.push({ label: `${dayName} ${k.slice(5)}`, ms, isFuture, isToday });
610
- }
611
- const maxMs = Math.max(...days.map((d) => d.ms)) || 1;
597
+ const { days, maxMs } = weekGrid(aggregate.byDay);
612
598
  const lines = days.map(({ label, ms, isFuture, isToday }) => {
613
599
  if (isFuture) return `${c.dim}${label.padEnd(12)} ${'·'.repeat(22)} —${c.reset}`;
614
600
  const h = ms / 3_600_000;
@@ -681,7 +667,7 @@ function statusColor(status) {
681
667
  }
682
668
 
683
669
  function showPreview() {
684
- let state = readState();
670
+ let state = readActiveState();
685
671
  const aggregate = readAggregate();
686
672
  const config = loadConfig();
687
673
  const live = findLiveSessions({ thresholdMs: 90_000 });
@@ -744,7 +730,7 @@ function showPreview() {
744
730
  // dashboard having to inline-eval ESM source. Output shape matches the
745
731
  // previous helper exactly: { vars: [sorted keys], live: <full vars object> }.
746
732
  function dumpVars() {
747
- let state = readState();
733
+ let state = readActiveState();
748
734
  const config = loadConfig();
749
735
  state.liveSessions = findLiveSessions({ thresholdMs: 90_000 });
750
736
  state = applyIdle(state, config);
@@ -971,7 +957,7 @@ async function doGithubStat(argv) {
971
957
  // Build the live template-variable table the way the daemon does — current
972
958
  // state + idle/stale resolution + aggregate. Shared by statusline/session-card.
973
959
  function liveVars() {
974
- const state = readState();
960
+ const state = readActiveState();
975
961
  state.liveSessions = findLiveSessions({ thresholdMs: 90_000 });
976
962
  const config = loadConfig();
977
963
  const resolved = applyIdle(state, config);
@@ -1837,7 +1823,7 @@ function overview() {
1837
1823
  }
1838
1824
 
1839
1825
  // Status line: daemon up/down + project + model + status verb.
1840
- const state = readState();
1826
+ const state = readActiveState();
1841
1827
  const aggregate = readAggregate();
1842
1828
  state.liveSessions = findLiveSessions({ thresholdMs: 90_000 });
1843
1829
  state.usage = readUsageCache();
package/src/daemon.js CHANGED
@@ -78,6 +78,7 @@ let aggregate = readAggregate() || null;
78
78
  let liveSessions = [];
79
79
  let client = null;
80
80
  let connected = false;
81
+ let connecting = false; // login() in flight — see the watchdog note in connect()
81
82
  let lastPayloadHash = '';
82
83
  // Last status we acted on for outbound side-effects (webhook / desktop notify).
83
84
  // Tracked separately from the render hash so we fire once per transition.
@@ -405,6 +406,10 @@ function isConnectionError(e) {
405
406
 
406
407
  async function connect() {
407
408
  if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
409
+ // While login() is in flight, connected===false and reconnectTimer===null
410
+ // both hold, so the watchdog's "down, nothing pending" branch would spawn a
411
+ // second connect() that overwrites `client` and orphans this socket. Block it.
412
+ connecting = true;
408
413
  client = new Client({ clientId: config.clientId, transport: { type: 'ipc' } });
409
414
 
410
415
  client.on('ready', () => {
@@ -422,6 +427,8 @@ async function connect() {
422
427
  try { await client.login(); }
423
428
  catch (e) {
424
429
  scheduleReconnect(`Discord login failed: ${e.message}`);
430
+ } finally {
431
+ connecting = false;
425
432
  }
426
433
  }
427
434
 
@@ -609,7 +616,7 @@ const HEALTH_CHECK_MS = 30_000;
609
616
  setInterval(() => {
610
617
  try {
611
618
  // Half-open: flag says connected but there's no usable user handle.
612
- if (connected && !client?.user) {
619
+ if (connected && !client?.user && !connecting) {
613
620
  log('Watchdog: connected but no user handle — forcing reconnect');
614
621
  connected = false;
615
622
  try { client?.destroy(); } catch { /* already gone */ }
@@ -618,7 +625,7 @@ setInterval(() => {
618
625
  }
619
626
  // Down with nothing scheduled to bring us back. scheduleReconnect is a
620
627
  // no-op when a timer is already pending, so this can't stack retries.
621
- if (!connected && !reconnectTimer) {
628
+ if (!connected && !reconnectTimer && !connecting) {
622
629
  log('Watchdog: disconnected with no reconnect pending — forcing reconnect');
623
630
  scheduleReconnect('watchdog: no retry pending');
624
631
  }
@@ -32,6 +32,9 @@ export const OP_PING = 3;
32
32
  export const OP_PONG = 4;
33
33
 
34
34
  const CONNECT_TIMEOUT_MS = 10_000; // matches @xhayper's connect() timeout
35
+ // Per-candidate connect timeout: a socket file whose peer accepts then stalls
36
+ // must not wedge discovery of the real Discord socket behind it.
37
+ const SOCKET_CONNECT_TIMEOUT_MS = 1500;
35
38
 
36
39
  // Same resolution order @xhayper used: XDG_RUNTIME_DIR → TMPDIR → TMP → TEMP → /tmp.
37
40
  function getTempDir() {
@@ -171,14 +174,18 @@ export class Client extends EventEmitter {
171
174
  for (const p of paths) {
172
175
  const socket = await new Promise((resolve) => {
173
176
  const s = net.createConnection(p);
174
- const onErr = () => {
177
+ let timer = null;
178
+ const cleanup = () => {
179
+ clearTimeout(timer);
175
180
  s.removeListener('connect', onOk);
176
- resolve(null);
177
- };
178
- const onOk = () => {
179
181
  s.removeListener('error', onErr);
180
- resolve(s);
181
182
  };
183
+ const onErr = () => { cleanup(); resolve(null); };
184
+ const onOk = () => { cleanup(); resolve(s); };
185
+ // A peer that accepts then stalls would otherwise never settle this
186
+ // candidate and block the rest each discovery cycle (and leak the fd).
187
+ timer = setTimeout(() => { cleanup(); s.destroy(); resolve(null); }, SOCKET_CONNECT_TIMEOUT_MS);
188
+ if (typeof timer === 'object' && 'unref' in timer) timer.unref();
182
189
  s.once('connect', onOk);
183
190
  s.once('error', onErr);
184
191
  });
package/src/doctor.js CHANGED
@@ -18,6 +18,7 @@ import { findLiveSessions } from './scanner.js';
18
18
  import { detectGithubUrl } from './git.js';
19
19
  import { resolveVisibility, listPrivateCwds, detectGithubPrivate } from './privacy.js';
20
20
  import { readClaudeCredentials, readUsageCache } from './usage.js';
21
+ import { EVENTS as HOOK_EVENTS, isOurHook } from './install.js';
21
22
  import { c, check as uiCheck } from './ui.js';
22
23
 
23
24
  const counters = { pass: 0, fail: 0, warn: 0 };
@@ -82,6 +83,28 @@ function checkMode() {
82
83
  check('execution mode', mode, detail);
83
84
  }
84
85
 
86
+ // Pure classifiers — extracted so doctor's decision logic is unit-testable
87
+ // without spawning the full filesystem diagnostic. The check* wrappers below
88
+ // pair these with the actual reads + check() output.
89
+ const PLACEHOLDER_CLIENT_ID = '1234567890123456789';
90
+
91
+ // '' / the seed placeholder → 'unset'; not a 17–21 digit snowflake → 'malformed'.
92
+ export function classifyClientId(clientId) {
93
+ if (!clientId || clientId === PLACEHOLDER_CLIENT_ID) return 'unset';
94
+ if (!/^\d{17,21}$/.test(String(clientId))) return 'malformed';
95
+ return 'ok';
96
+ }
97
+
98
+ // Most-recent-wins IPC state inferred from the daemon log tail: 'up'/'down'/'unknown'.
99
+ export function ipcStateFromLog(logText) {
100
+ let ipc = 'unknown';
101
+ for (const line of String(logText || '').split('\n').slice(-80)) {
102
+ if (/Discord RPC connected|Presence updated|Presence cleared/i.test(line)) ipc = 'up';
103
+ else if (/retry in \d+s|login failed|Discord disconnected/i.test(line)) ipc = 'down';
104
+ }
105
+ return ipc;
106
+ }
107
+
85
108
  function checkConfig() {
86
109
  if (!existsSync(CONFIG_PATH)) {
87
110
  check('config.json present', 'fail', CONFIG_PATH,
@@ -98,10 +121,11 @@ function checkConfig() {
98
121
  }
99
122
  check('config.json present', 'pass', CONFIG_PATH);
100
123
 
101
- if (!cfg.clientId || cfg.clientId === '1234567890123456789') {
124
+ const clientIdClass = classifyClientId(cfg.clientId);
125
+ if (clientIdClass === 'unset') {
102
126
  check('discord clientId set', 'fail', cfg.clientId || '(empty)',
103
127
  `paste your discord application ID into ${CONFIG_PATH}`);
104
- } else if (!/^\d{17,21}$/.test(String(cfg.clientId))) {
128
+ } else if (clientIdClass === 'malformed') {
105
129
  check('discord clientId set', 'warn', `${cfg.clientId} doesn't look like a snowflake`,
106
130
  'discord application IDs are 17–21 digits');
107
131
  } else {
@@ -152,16 +176,6 @@ function checkClaudeProjects() {
152
176
  return count;
153
177
  }
154
178
 
155
- const HOOK_EVENTS = [
156
- 'SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse',
157
- 'Stop', 'SubagentStop', 'Notification', 'SessionEnd',
158
- ];
159
-
160
- function isOurHookCommand(cmd) {
161
- if (!cmd) return false;
162
- return /claude-rpc/i.test(cmd) || /hook\.js/i.test(cmd);
163
- }
164
-
165
179
  function checkHooks() {
166
180
  if (!existsSync(CLAUDE_SETTINGS)) {
167
181
  check('hooks registered', 'fail', `${CLAUDE_SETTINGS} missing`,
@@ -181,7 +195,7 @@ function checkHooks() {
181
195
  for (const event of HOOK_EVENTS) {
182
196
  const bucket = settings.hooks?.[event];
183
197
  if (!Array.isArray(bucket) || bucket.length === 0) { missing.push(event); continue; }
184
- const ours = bucket.flatMap((e) => e.hooks || []).find((h) => isOurHookCommand(h.command));
198
+ const ours = bucket.flatMap((e) => e.hooks || []).find((h) => isOurHook(h));
185
199
  if (!ours) { missing.push(event); continue; }
186
200
  if (IS_PACKAGED && !ours.command.includes(CANONICAL_EXE)) stale.push({ event, cmd: ours.command });
187
201
  if (IS_NPM_INSTALL && !/\bclaude-rpc\b\s+hook\b/.test(ours.command)) stale.push({ event, cmd: ours.command });
@@ -259,12 +273,8 @@ function checkDaemonLog() {
259
273
  // Ns" / "login failed" / "disconnected" line means it dropped. Whichever
260
274
  // happened most recently wins.
261
275
  let ipc = 'unknown';
262
- try {
263
- for (const line of readFileSync(LOG_PATH, 'utf8').split('\n').slice(-80)) {
264
- if (/Discord RPC connected|Presence updated|Presence cleared/i.test(line)) ipc = 'up';
265
- else if (/retry in \d+s|login failed|Discord disconnected/i.test(line)) ipc = 'down';
266
- }
267
- } catch { /* log unreadable — ipc stays 'unknown', warn renders */ }
276
+ try { ipc = ipcStateFromLog(readFileSync(LOG_PATH, 'utf8')); }
277
+ catch { /* log unreadable — ipc stays 'unknown', warn renders */ }
268
278
  if (ipc === 'up') {
269
279
  check('discord IPC connection', 'pass',
270
280
  `connected · ${sizeKB} KB log · last write ${ageMin.toFixed(1)} min ago`);
package/src/fmt.js ADDED
@@ -0,0 +1,33 @@
1
+ // Canonical number/duration formatters shared across the card, badge, profile,
2
+ // insights and the live presence engine (format.js re-exports these). Single
3
+ // source of truth so tier-boundary rounding behaves identically everywhere —
4
+ // the copies had drifted (sub-1000 rounding, a missing Billion tier).
5
+ //
6
+ // NOTE: worker/src/badge.js keeps a byte-for-byte copy of fmtNum — the worker
7
+ // deploys dependency-free and can't import across the package boundary. Keep
8
+ // the two in sync; test/format.test.js pins fmtNum's tier behavior.
9
+
10
+ // Compact integer: 999 → "999", 1_500 → "1.5k", 1_500_000 → "1.50M", up to "B".
11
+ // The unit is chosen from the ROUNDED value, so 999_999 → "1.00M" (not the old
12
+ // "1000.0k") and 999_999_999 → "1.00B".
13
+ export function fmtNum(n) {
14
+ if (!n) return '0';
15
+ const neg = n < 0 ? '-' : '';
16
+ const v = Math.abs(n);
17
+ if (v < 1000) return neg + Math.round(v);
18
+ for (const [suf, div, prec] of [['k', 1e3, 1], ['M', 1e6, 2], ['B', 1e9, 2]]) {
19
+ const s = (v / div).toFixed(prec);
20
+ if (Number(s) < 1000) return neg + s + suf;
21
+ }
22
+ return neg + (v / 1e9).toFixed(2) + 'B'; // ≥ ~1e12 stays in the B tier
23
+ }
24
+
25
+ // Duration from ms: <60m → "42m", <10h → "2.5h", else "12h". Rounds to whole
26
+ // minutes first so 59.7m → "1.0h" (not the old "60m").
27
+ export function fmtHours(ms) {
28
+ if (!ms || ms < 0) return '0h';
29
+ const mins = Math.round(ms / 60_000);
30
+ if (mins < 60) return `${mins}m`;
31
+ const hours = mins / 60;
32
+ return hours < 10 ? `${hours.toFixed(1)}h` : `${Math.round(hours)}h`;
33
+ }
package/src/format.js CHANGED
@@ -4,6 +4,7 @@ import { fmtCost } from './pricing.js';
4
4
  import { languageOf } from './languages.js';
5
5
  import { detectGitBranch, detectGitRepo } from './git.js';
6
6
  import { fmtResetTime, fmtResetDay } from './usage.js';
7
+ import { fmtNum, fmtHours } from './fmt.js';
7
8
 
8
9
  const WEEKDAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
9
10
 
@@ -33,14 +34,6 @@ function fmtHourLocal(ms) {
33
34
  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
34
35
  }
35
36
 
36
- function fmtNum(n) {
37
- if (!n) return '0';
38
- if (n < 1000) return `${n}`;
39
- if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`;
40
- if (n < 1_000_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
41
- return `${(n / 1_000_000_000).toFixed(2)}B`;
42
- }
43
-
44
37
  function fmtDuration(ms) {
45
38
  if (!ms || ms < 0) return '0s';
46
39
  const s = Math.floor(ms / 1000);
@@ -69,14 +62,6 @@ function fmtToolElapsed(ms) {
69
62
  return `${h}h ${m}m`;
70
63
  }
71
64
 
72
- function fmtHours(ms) {
73
- if (!ms || ms < 0) return '0h';
74
- const hours = ms / 3_600_000;
75
- if (hours < 1) return `${Math.round(hours * 60)}m`;
76
- if (hours < 10) return `${hours.toFixed(1)}h`;
77
- return `${Math.round(hours)}h`;
78
- }
79
-
80
65
  function plural(n, sing, plur) {
81
66
  const word = n === 1 ? sing : (plur || `${sing}s`);
82
67
  return `${fmtNum(n)} ${word}`;
@@ -223,9 +208,11 @@ export function buildVars(state, config, aggregate) {
223
208
  // leak to anyone viewing the card. Fall back to the configured app name.
224
209
  const cwdIsLeaky = looksLikeUsernameLeak(state.cwd);
225
210
  const safeCwd = cwdIsLeaky ? '' : (state.cwd || '');
226
- const projectPretty = cwdIsLeaky
227
- ? (config?.appName || 'Claude Code')
228
- : (humanProject(state.cwd) || 'Claude Code');
211
+ const appName = config?.appName || 'Claude Code';
212
+ // Project label with the home-dir/username-leak guard applied — shared by the
213
+ // single-session label and the concurrent list so neither renders "lucas".
214
+ const safeProject = (cwd) => looksLikeUsernameLeak(cwd) ? appName : (humanProject(cwd) || appName);
215
+ const projectPretty = safeProject(state.cwd);
229
216
  const currentToolPretty = humanTool(state.currentTool);
230
217
  const modelPretty = humanModel(state.model);
231
218
 
@@ -244,7 +231,7 @@ export function buildVars(state, config, aggregate) {
244
231
  const concurrentOther = Math.max(0, concurrent - 1);
245
232
  const concurrentListPretty = liveSessions
246
233
  .slice(0, 3)
247
- .map((s) => (typeof s === 'string' ? humanProject(s) : humanProject(s.cwd || s.project || '')))
234
+ .map((s) => safeProject(typeof s === 'string' ? s : (s.cwd || s.project || '')))
248
235
  .filter(Boolean)
249
236
  .join(', ') || '—';
250
237
 
@@ -517,7 +504,7 @@ export function buildVars(state, config, aggregate) {
517
504
  gitRepo,
518
505
 
519
506
  // ── App identity (v0.3.6) ───────────────────────────────────
520
- appName: config?.appName || 'Claude Code',
507
+ appName,
521
508
 
522
509
  // Literal single space — handy for blanking a line without `requires`.
523
510
  empty: ' ',
package/src/insights.js CHANGED
@@ -4,24 +4,10 @@
4
4
 
5
5
  import { dayKey, weekKey } from './scanner.js';
6
6
  import { fmtCost } from './pricing.js';
7
+ import { fmtNum, fmtHours } from './fmt.js';
7
8
 
8
9
  const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
9
10
 
10
- function fmtHours(ms) {
11
- if (!ms || ms < 0) return '0h';
12
- const hours = ms / 3_600_000;
13
- if (hours < 1) return `${Math.round(hours * 60)}m`;
14
- if (hours < 10) return `${hours.toFixed(1)}h`;
15
- return `${Math.round(hours)}h`;
16
- }
17
-
18
- function fmtNum(n) {
19
- if (!n) return '0';
20
- if (n < 1000) return `${n}`;
21
- if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`;
22
- return `${(n / 1_000_000).toFixed(2)}M`;
23
- }
24
-
25
11
  function pct(n) {
26
12
  const sign = n >= 0 ? '+' : '−';
27
13
  return `${sign}${Math.round(Math.abs(n) * 100)}%`;
package/src/install.js CHANGED
@@ -52,7 +52,7 @@ function dirtyStep(sym, label, detail = '', log = console.log) {
52
52
  }
53
53
  function noop(fact) { noopFacts.push(fact); }
54
54
 
55
- const EVENTS = [
55
+ export const EVENTS = [
56
56
  'SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse',
57
57
  'Stop', 'SubagentStop', 'Notification', 'SessionEnd', 'PreCompact',
58
58
  ];
@@ -69,7 +69,18 @@ function writeJson(p, d) {
69
69
 
70
70
  function isOurHookCommand(cmd) {
71
71
  if (!cmd) return false;
72
- return /claude-rpc/i.test(cmd) || /hook\.js/i.test(cmd);
72
+ if (/claude-rpc/i.test(cmd)) return true;
73
+ // Dev-mode hooks point at THIS install's hook.js by absolute path. Match that
74
+ // exact path — NOT any `hook.js`, which could be a third-party tool's hook we
75
+ // must never rewrite or delete.
76
+ return cmd.includes(HOOK_SCRIPT.replace(/\\/g, '/'));
77
+ }
78
+
79
+ // A hook entry we own. Tagged entries are recognized regardless of command
80
+ // shape or clone-dir name; command-matching is the legacy fallback for entries
81
+ // written before tagging.
82
+ export function isOurHook(h) {
83
+ return !!h && (h._claudeRpc === true || isOurHookCommand(h.command));
73
84
  }
74
85
 
75
86
  export function installHooks(exePath) {
@@ -81,24 +92,29 @@ export function installHooks(exePath) {
81
92
  // npm → `claude-rpc hook <event>` (bin shim resolves through PATH;
82
93
  // survives `npm update` and nvm version switches)
83
94
  // dev → `node "<src/hook.js>" <event>` (cloned-source iteration)
95
+ // Hook commands must resolve under Claude Code's hook shell — `/bin/sh` with a
96
+ // minimal PATH that, under nvm, has neither `claude-rpc` nor `node` on it (no
97
+ // system node). So use the ABSOLUTE node (process.execPath) + absolute hook.js
98
+ // for both npm and dev installs; only the packaged exe is self-contained. This
99
+ // survives `npm update` (HOOK_SCRIPT is stable) and an nvm version switch (nvm
100
+ // keeps prior versions on disk); re-run setup only if that node is removed.
101
+ const node = process.execPath.replace(/\\/g, '/');
84
102
  const cmdFor = IS_PACKAGED
85
103
  ? (event) => `"${exePath}" hook ${event}`
86
- : IS_NPM_INSTALL
87
- ? (event) => `claude-rpc hook ${event}`
88
- : (event) => `node "${HOOK_SCRIPT.replace(/\\/g, '/')}" ${event}`;
104
+ : (event) => `"${node}" "${HOOK_SCRIPT.replace(/\\/g, '/')}" ${event}`;
89
105
 
90
106
  for (const event of EVENTS) {
91
107
  const bucket = settings.hooks[event] = settings.hooks[event] || [];
92
108
  const wanted = cmdFor(event);
93
109
  const existingEntry = bucket.find((b) =>
94
- Array.isArray(b.hooks) && b.hooks.some((h) => isOurHookCommand(h.command))
110
+ Array.isArray(b.hooks) && b.hooks.some((h) => isOurHook(h))
95
111
  );
96
112
  if (existingEntry) {
97
113
  existingEntry.hooks = existingEntry.hooks.map((h) =>
98
- isOurHookCommand(h.command) ? { ...h, command: wanted } : h
114
+ isOurHook(h) ? { ...h, command: wanted, _claudeRpc: true } : h
99
115
  );
100
116
  } else {
101
- bucket.push({ matcher: '', hooks: [{ type: 'command', command: wanted }] });
117
+ bucket.push({ matcher: '', hooks: [{ type: 'command', command: wanted, _claudeRpc: true }] });
102
118
  }
103
119
  }
104
120
  if (JSON.stringify(settings.hooks) === before) {
@@ -117,7 +133,7 @@ export function uninstallHooks() {
117
133
  const bucket = settings.hooks[event];
118
134
  if (!Array.isArray(bucket)) continue;
119
135
  settings.hooks[event] = bucket
120
- .map((entry) => ({ ...entry, hooks: (entry.hooks || []).filter((h) => !isOurHookCommand(h.command)) }))
136
+ .map((entry) => ({ ...entry, hooks: (entry.hooks || []).filter((h) => !isOurHook(h)) }))
121
137
  .filter((entry) => (entry.hooks || []).length > 0);
122
138
  if (settings.hooks[event].length === 0) delete settings.hooks[event];
123
139
  }
@@ -421,7 +437,7 @@ export function migrateConfig({ silent = false } = {}) {
421
437
  // anyone who added a custom frame is left entirely alone.
422
438
  const frameId = (f) => (Array.isArray(f?.requires) && f.requires.length)
423
439
  ? 'r:' + [...f.requires].map(String).sort().join('|')
424
- : 't:' + (f?.details ?? '') + '' + (f?.state ?? '');
440
+ : 't:' + (f?.details ?? '') + '\x00' + (f?.state ?? '');
425
441
  const dflBy = DEFAULT_CONFIG.presence?.byStatus || {};
426
442
  const usrBy = cfg.presence.byStatus || {};
427
443
  let framesAdded = 0;
@@ -453,18 +469,12 @@ export function migrateConfig({ silent = false } = {}) {
453
469
  // command and report success, leaving the user to discover the breakage
454
470
  // the next time they open Claude Code. Returns { ok, detail }.
455
471
  function verifyHookPipe(exePath) {
456
- const cmd = IS_PACKAGED ? exePath
457
- : IS_NPM_INSTALL ? 'claude-rpc'
458
- : process.execPath;
459
- const args = IS_PACKAGED || IS_NPM_INSTALL
460
- ? ['hook', 'SessionStart']
461
- : [HOOK_SCRIPT, 'SessionStart'];
462
- // Windows + npm-install: the global bin is `claude-rpc.cmd` (a batch shim),
463
- // and Node's spawn doesn't apply PATHEXT — calling `claude-rpc` raw fails
464
- // with ENOENT. shell:true makes cmd.exe do the resolution, mirroring how
465
- // Claude Code actually invokes the hook string at runtime. Args are static
466
- // and trusted; no injection surface.
467
- const useShell = IS_NPM_INSTALL && process.platform === 'win32';
472
+ // Spawn exactly what installHooks wired: the packaged exe, or the absolute
473
+ // node + hook.js. Both are absolute, so there's no PATH/PATHEXT/shell
474
+ // resolution — it works under the same minimal hook shell Claude Code uses
475
+ // (the reason the old `claude-rpc` / bare-`node` forms failed under nvm).
476
+ const cmd = IS_PACKAGED ? exePath : process.execPath;
477
+ const args = IS_PACKAGED ? ['hook', 'SessionStart'] : [HOOK_SCRIPT, 'SessionStart'];
468
478
  let result;
469
479
  try {
470
480
  result = spawnSync(cmd, args, {
@@ -472,7 +482,6 @@ function verifyHookPipe(exePath) {
472
482
  encoding: 'utf8',
473
483
  timeout: 3000,
474
484
  windowsHide: true,
475
- shell: useShell,
476
485
  });
477
486
  } catch (e) {
478
487
  return { ok: false, detail: `spawn failed: ${e.message}` };
@@ -481,8 +490,14 @@ function verifyHookPipe(exePath) {
481
490
  if (result.status !== 0) {
482
491
  return { ok: false, detail: `hook exit ${result.status}: ${(result.stderr || '').trim().slice(0, 120)}` };
483
492
  }
484
- if (!result.stdout.includes('"continue"')) {
485
- return { ok: false, detail: `unexpected hook output: ${result.stdout.trim().slice(0, 120)}` };
493
+ // Parse the ack and assert the actual contract (a JSON object with
494
+ // continue:true) rather than substring-matching the bytes "continue" anywhere
495
+ // in stdout.
496
+ let ack;
497
+ try { ack = JSON.parse((result.stdout || '').trim()); }
498
+ catch { return { ok: false, detail: `non-JSON hook output: ${(result.stdout || '').trim().slice(0, 120)}` }; }
499
+ if (ack?.continue !== true) {
500
+ return { ok: false, detail: `hook ack missing continue:true: ${(result.stdout || '').trim().slice(0, 120)}` };
486
501
  }
487
502
  return { ok: true, detail: 'SessionStart round-trip succeeded' };
488
503
  }
@@ -29,7 +29,11 @@ export function isValidHandle(input) {
29
29
  // trim, bound the length. Returns null for empty input.
30
30
  export function cleanDisplayName(input, max = 40) {
31
31
  if (!input || typeof input !== 'string') return null;
32
- const name = input.replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, max);
32
+ // Strip control chars (C0/DEL/C1), zero-width, and bidi overrides so a name
33
+ // can't visually corrupt or spoof the shared board. Denylist by code point -
34
+ // CJK, emoji and accents still pass. Mirrored in the worker's cleanName.
35
+ const ok = (c) => !(c < 32 || (c >= 0x7f && c <= 0x9f) || (c >= 0x200b && c <= 0x200f) || (c >= 0x202a && c <= 0x202e) || (c >= 0x2066 && c <= 0x2069) || c === 0xfeff);
36
+ const name = [...input].filter((ch) => ok(ch.codePointAt(0))).join('').trim().slice(0, max);
33
37
  return name || null;
34
38
  }
35
39
 
package/src/mcp.js CHANGED
@@ -114,6 +114,9 @@ export function callTool(name, getAgg = readAggregate) {
114
114
  */
115
115
  export function runMcpServer({ input = process.stdin, output = process.stdout } = {}) {
116
116
  let buf = '';
117
+ const MAX_BUF = 4 * 1024 * 1024; // hard cap on a single un-terminated line
118
+ const DEFAULT_PROTOCOL = '2024-11-05';
119
+ const SUPPORTED_PROTOCOLS = new Set([DEFAULT_PROTOCOL, '2025-03-26', '2025-06-18']);
117
120
  const send = (msg) => output.write(JSON.stringify(msg) + '\n');
118
121
  const reply = (id, result) => send({ jsonrpc: '2.0', id, result });
119
122
  const replyErr = (id, code, message) => send({ jsonrpc: '2.0', id, error: { code, message } });
@@ -121,8 +124,12 @@ export function runMcpServer({ input = process.stdin, output = process.stdout }
121
124
  function handle(msg) {
122
125
  const { id, method, params } = msg;
123
126
  if (method === 'initialize') {
127
+ // Echo the client's protocol version when we recognize it; otherwise fall
128
+ // back to our baseline (our surface — initialize/tools/*/ping — is stable
129
+ // across these revisions).
130
+ const requested = params?.protocolVersion;
124
131
  return reply(id, {
125
- protocolVersion: '2024-11-05',
132
+ protocolVersion: SUPPORTED_PROTOCOLS.has(requested) ? requested : DEFAULT_PROTOCOL,
126
133
  capabilities: { tools: {} },
127
134
  serverInfo: { name: 'claude-rpc', version: VERSION },
128
135
  });
@@ -159,7 +166,14 @@ export function runMcpServer({ input = process.stdin, output = process.stdout }
159
166
  try { msg = JSON.parse(line); } catch { continue; }
160
167
  try { handle(msg); } catch (e) { process.stderr.write(`mcp handler error: ${e.message}\n`); }
161
168
  }
169
+ // A single line larger than the cap (no newline yet) would grow buf without
170
+ // bound — drop the partial and resync on the next newline.
171
+ if (buf.length > MAX_BUF) {
172
+ process.stderr.write(`mcp: dropping oversized line (>${MAX_BUF} bytes)\n`);
173
+ buf = '';
174
+ }
162
175
  });
163
176
  input.on('end', () => process.exit(0));
177
+ input.on('error', () => process.exit(0));
164
178
  process.stderr.write(`claude-rpc MCP server v${VERSION} ready (stdio)\n`);
165
179
  }
package/src/notify.js CHANGED
@@ -29,7 +29,7 @@ export function sanitizeLabel(s) {
29
29
  * @param {string} [body] - Notification body text.
30
30
  * @returns {boolean} true if a notifier was spawned, false on failure.
31
31
  */
32
- export function desktopNotify(title, body = '') {
32
+ export function desktopNotify(title, body = '', { spawn: spawnFn = spawn } = {}) {
33
33
  try {
34
34
  const p = platform();
35
35
  // A missing notifier binary (e.g. no `notify-send`) makes spawn emit an
@@ -43,15 +43,15 @@ export function desktopNotify(title, body = '') {
43
43
  };
44
44
  if (p === 'darwin') {
45
45
  const script = `display notification ${JSON.stringify(body)} with title ${JSON.stringify(title)}`;
46
- swallow(spawn('osascript', ['-e', script], { stdio: 'ignore', detached: true }));
46
+ swallow(spawnFn('osascript', ['-e', script], { stdio: 'ignore', detached: true }));
47
47
  } else if (p === 'win32') {
48
48
  const script = `Add-Type -AssemblyName System.Windows.Forms;`
49
49
  + `$n=New-Object System.Windows.Forms.NotifyIcon;`
50
50
  + `$n.Icon=[System.Drawing.SystemIcons]::Information;$n.Visible=$true;`
51
51
  + `$n.ShowBalloonTip(5000, ${JSON.stringify(title)}, ${JSON.stringify(body)}, 'Info')`;
52
- swallow(spawn('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], { stdio: 'ignore', detached: true, windowsHide: true }));
52
+ swallow(spawnFn('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], { stdio: 'ignore', detached: true, windowsHide: true }));
53
53
  } else {
54
- swallow(spawn('notify-send', ['-a', 'Claude Code', title, body], { stdio: 'ignore', detached: true }));
54
+ swallow(spawnFn('notify-send', ['-a', 'Claude Code', title, body], { stdio: 'ignore', detached: true }));
55
55
  }
56
56
  return true;
57
57
  } catch {
package/src/privacy.js CHANGED
@@ -21,7 +21,7 @@
21
21
  // by these flags. Privacy is a one-way valve from local state → Discord.
22
22
 
23
23
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
24
- import { execFileSync } from 'node:child_process';
24
+ import { execFile } from 'node:child_process';
25
25
  import { join, basename, dirname, resolve as resolvePath } from 'node:path';
26
26
  import { DATA_DIR } from './paths.js';
27
27
 
@@ -33,6 +33,7 @@ const TTL_MS = 5 * 60 * 1000;
33
33
 
34
34
  const projectFileCache = new Map(); // cwd → { ts, value | null }
35
35
  const ghPrivateCache = new Map(); // cwd → { ts, value: bool | null }
36
+ const ghProbeInFlight = new Set(); // cwds with an async `gh` probe running
36
37
 
37
38
  // ── Per-project .claude-rpc.json ────────────────────────────────────────
38
39
 
@@ -182,18 +183,33 @@ export function detectGithubPrivate(cwd) {
182
183
  if (!cwd) return null;
183
184
  const cached = ghPrivateCache.get(cwd);
184
185
  if (cached && Date.now() - cached.ts < TTL_MS) return cached.value;
185
- let value = null;
186
- try {
187
- const out = execFileSync(
188
- 'gh',
189
- ['repo', 'view', '--json', 'isPrivate', '-q', '.isPrivate'],
190
- { cwd, stdio: ['ignore', 'pipe', 'ignore'], timeout: 1500 }
191
- ).toString().trim();
192
- if (out === 'true') value = true;
193
- else if (out === 'false') value = false;
194
- } catch { /* gh missing, not auth'd, not a repo, timeout — unknown */ }
195
- ghPrivateCache.set(cwd, { ts: Date.now(), value });
196
- return value;
186
+ // Cache miss: the single-threaded daemon render path must never block on a
187
+ // subprocess (a 1.5s gh call stalls ALL presence + file-watch updates). Fire
188
+ // a non-blocking probe and serve the last known value (null = unknown) this
189
+ // tick; the fresh verdict lands in the cache for the next one.
190
+ probeGithubPrivate(cwd);
191
+ return cached ? cached.value : null;
192
+ }
193
+
194
+ function probeGithubPrivate(cwd) {
195
+ if (ghProbeInFlight.has(cwd)) return; // one in-flight probe per cwd
196
+ ghProbeInFlight.add(cwd);
197
+ execFile(
198
+ 'gh',
199
+ ['repo', 'view', '--json', 'isPrivate', '-q', '.isPrivate'],
200
+ { cwd, timeout: 1500 },
201
+ (err, stdout) => {
202
+ ghProbeInFlight.delete(cwd);
203
+ let value = null;
204
+ if (!err) {
205
+ const out = String(stdout).trim();
206
+ if (out === 'true') value = true;
207
+ else if (out === 'false') value = false;
208
+ }
209
+ // gh missing / not auth'd / not a repo / timeout → value stays null (unknown).
210
+ ghPrivateCache.set(cwd, { ts: Date.now(), value });
211
+ }
212
+ );
197
213
  }
198
214
 
199
215
  // ── Resolution + application ────────────────────────────────────────────
package/src/profile.js CHANGED
@@ -7,6 +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
11
 
11
12
  const W = 520;
12
13
  const H = 240;
@@ -32,22 +33,6 @@ function escapeXml(s) {
32
33
  .replace(/"/g, '&quot;');
33
34
  }
34
35
 
35
- function fmtNum(n) {
36
- if (!n) return '0';
37
- if (n < 1000) return String(Math.round(n));
38
- if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`;
39
- if (n < 1_000_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
40
- return `${(n / 1_000_000_000).toFixed(2)}B`;
41
- }
42
-
43
- function fmtHours(ms) {
44
- if (!ms || ms < 0) return '0h';
45
- const h = ms / 3_600_000;
46
- if (h < 1) return `${Math.round(h * 60)}m`;
47
- if (h < 10) return `${h.toFixed(1)}h`;
48
- return `${Math.round(h)}h`;
49
- }
50
-
51
36
  function topLanguage(aggregate) {
52
37
  const langs = aggregate?.languages || {};
53
38
  let best = null;
package/src/scanner.js CHANGED
@@ -632,25 +632,37 @@ export function readSessionTokens(path) {
632
632
 
633
633
  // Detect live sessions by transcript mtime. Returns array of { path, project, cwd, mtime, ageSec }.
634
634
  // A session is "live" if its .jsonl was modified within thresholdMs.
635
- export function findLiveSessions({ projectsDir = CLAUDE_PROJECTS, thresholdMs = 90_000 } = {}) {
636
- if (!existsSync(projectsDir)) return [];
635
+ // Roots default to the same set scan() uses the canonical ~/.claude/projects
636
+ // plus any discovered alt locations — so live presence, concurrent-session
637
+ // detection, the web API, doctor and the TUI don't go silent on XDG-strict /
638
+ // AppData / Library-relocated installs. Legacy single-root `projectsDir` and
639
+ // explicit multi-root `projectsDirs` are both still accepted.
640
+ export function findLiveSessions({ projectsDir, projectsDirs, thresholdMs = 90_000 } = {}) {
641
+ const dirs = projectsDirs && projectsDirs.length ? projectsDirs
642
+ : projectsDir ? [projectsDir]
643
+ : [CLAUDE_PROJECTS, ...discoverAltProjectDirs()];
637
644
  const now = Date.now();
638
645
  const live = [];
639
- for (const proj of readdirSync(projectsDir)) {
640
- const projPath = join(projectsDir, proj);
641
- let entries;
642
- try { entries = readdirSync(projPath, { withFileTypes: true }); } catch { continue; }
643
- for (const e of entries) {
644
- // Only top-level transcripts count as sessions, not subagent files.
645
- if (!e.isFile() || !e.name.endsWith('.jsonl')) continue;
646
- const full = join(projPath, e.name);
647
- let st;
648
- try { st = statSync(full); } catch { continue; }
649
- const age = now - st.mtimeMs;
650
- if (age <= thresholdMs) {
651
- const cwd = readTranscriptCwd(full, st.mtimeMs);
652
- const project = cleanProjectName(cwd ? basename(cwd) : proj);
653
- live.push({ path: full, project, cwd: cwd || '', mtime: st.mtimeMs, ageSec: Math.round(age / 1000) });
646
+ for (const root of dirs) {
647
+ if (!existsSync(root)) continue;
648
+ let projects;
649
+ try { projects = readdirSync(root); } catch { continue; }
650
+ for (const proj of projects) {
651
+ const projPath = join(root, proj);
652
+ let entries;
653
+ try { entries = readdirSync(projPath, { withFileTypes: true }); } catch { continue; }
654
+ for (const e of entries) {
655
+ // Only top-level transcripts count as sessions, not subagent files.
656
+ if (!e.isFile() || !e.name.endsWith('.jsonl')) continue;
657
+ const full = join(projPath, e.name);
658
+ let st;
659
+ try { st = statSync(full); } catch { continue; }
660
+ const age = now - st.mtimeMs;
661
+ if (age <= thresholdMs) {
662
+ const cwd = readTranscriptCwd(full, st.mtimeMs);
663
+ const project = cleanProjectName(cwd ? basename(cwd) : proj);
664
+ live.push({ path: full, project, cwd: cwd || '', mtime: st.mtimeMs, ageSec: Math.round(age / 1000) });
665
+ }
654
666
  }
655
667
  }
656
668
  }
package/src/server/api.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // from the routing layer.
4
4
 
5
5
  import { basename } from 'node:path';
6
- import { readState } from '../state.js';
6
+ import { readActiveState } from '../state.js';
7
7
  import { buildVars, fillTemplate, applyIdle, framePasses, humanModel } from '../format.js';
8
8
  import { readAggregate, findLiveSessions, dayKey } from '../scanner.js';
9
9
  import { loadConfig as loadSharedConfig } from '../config.js';
@@ -111,7 +111,7 @@ export function windowedAggregate(agg, range) {
111
111
  export function snapshot() {
112
112
  const config = loadConfig();
113
113
  const live = findLiveSessions({ thresholdMs: 90_000 });
114
- let state = readState();
114
+ let state = readActiveState();
115
115
  state.liveSessions = live;
116
116
  state = applyIdle(state, config);
117
117
  const aggregate = readAggregate() || {};
@@ -196,7 +196,7 @@ export function wrappedData() {
196
196
  topLanguage: langs[0] ? { name: langs[0][0], edits: langs[0][1].edits || 0 } : null,
197
197
  languages: langs.slice(0, 5).map(([name, v]) => ({ name, edits: v.edits || 0 })),
198
198
  hotspot: top ? { name: basename(String(top.path || '')), count: top.count, daysSinceLastEdit: top.daysSinceLastEdit } : null,
199
- peakHour: agg.peakHour && agg.peakHour.hour != null ? agg.peakHour.hour : null,
199
+ peakHour: (Number.isInteger(agg.peakHour?.hour) && agg.peakHour.hour >= 0 && agg.peakHour.hour <= 23) ? agg.peakHour.hour : null,
200
200
  peakWeekday: peakWd ? { name: WRAPPED_WEEKDAYS[peakWd.day], hours: peakWd.ms / 3_600_000 } : null,
201
201
  modelSplit: (agg.modelSplit || []).slice(0, 4).map((m) => ({ model: humanModel(m.model) || m.model, costPct: m.costPct || 0, tokenPct: m.tokenPct || 0 })),
202
202
  linesAdded: agg.linesAdded || 0,
@@ -23,6 +23,7 @@
23
23
  let range = '90d';
24
24
  let liveData = null;
25
25
  let aggData = null;
26
+ let heatmapByDay = null; // dedicated fixed-90d source, independent of the range pill
26
27
  let allFrames = [];
27
28
  let currentLiveIdx = 0;
28
29
  let rotationTimer = null;
@@ -162,25 +163,30 @@
162
163
  function renderHeatmap(byDay) {
163
164
  const grid = $('heatmap-grid');
164
165
  grid.innerHTML = '';
166
+ byDay = byDay || {};
165
167
  const today = new Date(); today.setHours(0, 0, 0, 0);
166
- let start = new Date(today); start.setDate(start.getDate() - 90);
168
+ const start = new Date(today); start.setDate(start.getDate() - 90);
167
169
  while (start.getDay() !== 0) start.setDate(start.getDate() - 1);
168
- let max = 0;
169
- for (let k in byDay) max = Math.max(max, byDay[k].activeMs || 0);
170
+ // The day keys actually drawn — a fixed window ending today, independent of
171
+ // the range pill (so 7d/30d don't blank the grid).
172
+ const keys = [];
170
173
  const cur = new Date(start);
171
- while (cur <= today) {
172
- const k = dayKey(cur.getTime());
174
+ while (cur <= today) { keys.push(dayKey(cur.getTime())); cur.setDate(cur.getDate() + 1); }
175
+ // Normalize against the max of ONLY the drawn cells, so an off-window peak
176
+ // (from a wider range selection) can't dim the visible grid.
177
+ let max = 0;
178
+ for (const k of keys) max = Math.max(max, (byDay[k] || {}).activeMs || 0);
179
+ for (const k of keys) {
173
180
  const ms = (byDay[k] || {}).activeMs || 0;
174
181
  const cell = document.createElement('div');
175
182
  cell.className = 'cell';
176
- if (ms > 0) {
183
+ if (ms > 0 && max > 0) {
177
184
  const lvl = Math.min(1, ms / max);
178
185
  cell.style.background = 'rgba(74, 222, 128, ' + (0.18 + lvl * 0.72).toFixed(2) + ')';
179
186
  }
180
187
  cell.title = k + ' · ' + fmtH(ms);
181
188
  cell.addEventListener('click', () => openDay(k));
182
189
  grid.appendChild(cell);
183
- cur.setDate(cur.getDate() + 1);
184
190
  }
185
191
  }
186
192
 
@@ -434,6 +440,16 @@
434
440
  } catch (e) { console.error(e); }
435
441
  }
436
442
 
443
+ // The heatmap always shows the same fixed ~90-day window, so it has its own
444
+ // fetch decoupled from the range pill.
445
+ async function fetchHeatmap() {
446
+ try {
447
+ const r = await fetch('/api/aggregate?range=90d', { cache: 'no-store' });
448
+ heatmapByDay = (await r.json()).byDay || {};
449
+ renderHeatmap(heatmapByDay);
450
+ } catch (e) { console.error(e); }
451
+ }
452
+
437
453
  async function fetchInsights() {
438
454
  try {
439
455
  const r = await fetch('/api/insights');
@@ -454,7 +470,7 @@
454
470
  if (!aggData) return;
455
471
  const days = range === '7d' ? 7 : range === '30d' ? 30 : range === '1y' ? 365 : range === 'all' ? 365 : 90;
456
472
  renderChart(aggData.byDay || {}, days);
457
- renderHeatmap(aggData.byDay || {});
473
+ renderHeatmap(heatmapByDay || aggData.byDay || {});
458
474
  renderChurn(aggData.byDay || {}, Math.min(days, 90));
459
475
  renderCost(aggData);
460
476
  renderLanguages(aggData.languages);
@@ -573,7 +589,9 @@
573
589
  // ── SSE ────────────────────────────────────────────────
574
590
  function startSse() {
575
591
  try {
592
+ $('conn-state').textContent = 'connecting';
576
593
  const ev = new EventSource('/events');
594
+ ev.onopen = () => { $('conn-state').textContent = 'live'; };
577
595
  ev.onmessage = async (e) => {
578
596
  try {
579
597
  const d = JSON.parse(e.data);
@@ -582,10 +600,14 @@
582
600
  await refreshState();
583
601
  await fetchAggregate();
584
602
  await fetchInsights();
603
+ await fetchHeatmap();
585
604
  }
586
605
  } catch { /* malformed SSE frame — wait for the next one */ }
587
606
  };
588
- ev.onerror = () => { $('conn-state').textContent = 'reconnecting'; setTimeout(() => { $('conn-state').textContent = 'live'; }, 4000); };
607
+ // CLOSED = EventSource gave up (no auto-retry); otherwise a native
608
+ // reconnect is already in flight. No blind timer flipping back to "live"
609
+ // while the daemon is actually down.
610
+ ev.onerror = () => { $('conn-state').textContent = ev.readyState === EventSource.CLOSED ? 'offline' : 'reconnecting'; };
589
611
  } catch { /* EventSource constructor failed (very old browser) — dashboard falls back to one-shot fetches */ }
590
612
  }
591
613
 
@@ -650,6 +672,7 @@
650
672
  await refreshState();
651
673
  await fetchAggregate();
652
674
  await fetchInsights();
675
+ await fetchHeatmap();
653
676
  startSse();
654
677
  // Restore deep link.
655
678
  if (location.hash.startsWith('#projects/')) openProject(decodeURIComponent(location.hash.slice(10)));
@@ -15,7 +15,7 @@ import { createServer } from 'node:http';
15
15
  import { exec } from 'node:child_process';
16
16
  import { ROUTES, JSON_HEADERS } from './routes.js';
17
17
  import { projectDrilldown, dayDetail } from './api.js';
18
- import { sseClients, watchSources } from './sse.js';
18
+ import { watchSources, addClient, removeClient } from './sse.js';
19
19
  import { buildHtml, buildWrappedHtml } from './page.js';
20
20
 
21
21
  // Pre-compose the HTML once at startup — the only dynamic bit is the port
@@ -60,8 +60,8 @@ function dispatch(req, res) {
60
60
  'connection': 'keep-alive',
61
61
  });
62
62
  res.write(': hello\n\n');
63
- sseClients.add(res);
64
- req.on('close', () => sseClients.delete(res));
63
+ addClient(res);
64
+ req.on('close', () => removeClient(res));
65
65
  return;
66
66
  }
67
67
 
package/src/server/sse.js CHANGED
@@ -16,6 +16,33 @@ export function broadcast(payload) {
16
16
  }
17
17
  }
18
18
 
19
+ // Heartbeat: bytes only flow on file-change broadcasts, so an idle connection
20
+ // can sit half-open indefinitely and a dead socket isn't reaped until the next
21
+ // real write throws. A periodic comment frame keeps connections warm and lets
22
+ // the write/try-catch evict peers that have gone away. Started on the first
23
+ // client, cleared when the last one leaves.
24
+ const HEARTBEAT_MS = 20_000;
25
+ let heartbeat = null;
26
+
27
+ export function addClient(res) {
28
+ sseClients.add(res);
29
+ if (heartbeat) return;
30
+ heartbeat = setInterval(() => {
31
+ for (const r of sseClients) {
32
+ try { r.write(': ping\n\n'); } catch { sseClients.delete(r); }
33
+ }
34
+ }, HEARTBEAT_MS);
35
+ heartbeat.unref?.(); // a lone heartbeat shouldn't keep the process alive
36
+ }
37
+
38
+ export function removeClient(res) {
39
+ sseClients.delete(res);
40
+ if (sseClients.size === 0 && heartbeat) {
41
+ clearInterval(heartbeat);
42
+ heartbeat = null;
43
+ }
44
+ }
45
+
19
46
  // Watch a file that is updated via atomic rename (write-tmp + renameSync).
20
47
  // Watching the file path directly binds to the inode at watch-time — on
21
48
  // Linux that inode is replaced by the first rename, so the watcher fires
package/src/state.js CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  } from 'node:fs';
14
14
  import { basename, join } from 'node:path';
15
15
  import { STATE_PATH, STATE_DIR } from './paths.js';
16
+ import { pickActiveSession } from './presence.js';
16
17
 
17
18
  const DEFAULT_STATE = {
18
19
  sessionStart: null,
@@ -170,6 +171,17 @@ export function readState(sessionId) {
170
171
  }
171
172
  }
172
173
 
174
+ // The session a one-shot reader (status / serve / tui) should display. The
175
+ // daemon resolves per-session state with cross-tick stickiness; one-shot readers
176
+ // have no cursor, so they pass displayedId=null and get the most-recently-active
177
+ // session — exactly what the daemon settles on. Falls back to the legacy global
178
+ // state.json when no per-session files exist (e.g. a hook payload without an id).
179
+ export function readActiveState({ now = Date.now(), idleMs = 60_000 } = {}) {
180
+ const sessions = listSessionStates();
181
+ if (!sessions.length) return readState();
182
+ return pickActiveSession(sessions, null, now, idleMs).state || readState();
183
+ }
184
+
173
185
  export function writeState(next, sessionId) {
174
186
  ensureDir();
175
187
  const path = statePathFor(sessionId);
package/src/tui.js CHANGED
@@ -6,8 +6,9 @@
6
6
 
7
7
  import process from 'node:process';
8
8
  import { readFileSync, existsSync } from 'node:fs';
9
- import { readState } from './state.js';
10
- import { readAggregate, findLiveSessions, dayKey, weekKey } from './scanner.js';
9
+ import { readActiveState } from './state.js';
10
+ import { readAggregate, findLiveSessions, weekKey } from './scanner.js';
11
+ import { weekGrid } from './week.js';
11
12
  import { buildVars, applyIdle, humanProject } from './format.js';
12
13
  import { loadConfig } from './config.js';
13
14
  import { PID_PATH } from './paths.js';
@@ -51,7 +52,7 @@ let exiting = false;
51
52
 
52
53
  // ── Data ────────────────────────────────────────────────────────────────────
53
54
  function loadSnapshot() {
54
- let state = readState();
55
+ let state = readActiveState();
55
56
  state.liveSessions = findLiveSessions({ thresholdMs: 90_000 });
56
57
  const config = loadConfig();
57
58
  state = applyIdle(state, config);
@@ -186,22 +187,7 @@ function tabWeek(_, data) {
186
187
  if (agg.byDay) {
187
188
  out.push('');
188
189
  out.push(`${C.dim}daily breakdown${C.reset}`);
189
- const now = new Date();
190
- now.setHours(0, 0, 0, 0);
191
- const monday = new Date(now);
192
- monday.setDate(monday.getDate() - ((monday.getDay() + 6) % 7));
193
- const days = [];
194
- for (let i = 0; i < 7; i++) {
195
- const d = new Date(monday);
196
- d.setDate(d.getDate() + i);
197
- const k = dayKey(d.getTime());
198
- const dayName = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][d.getDay()];
199
- const ms = agg.byDay[k]?.activeMs || 0;
200
- const isFuture = d > now;
201
- const isToday = k === dayKey(now.getTime());
202
- days.push({ label: `${dayName} ${k.slice(5)}`, ms, isFuture, isToday });
203
- }
204
- const maxMs = Math.max(...days.map((d) => d.ms)) || 1;
190
+ const { days, maxMs } = weekGrid(agg.byDay);
205
191
  for (const { label, ms, isFuture, isToday } of days) {
206
192
  if (isFuture) {
207
193
  out.push(`${C.dim}${label.padEnd(11)}${' '.repeat(18)} —${C.reset}`);
package/src/usage.js CHANGED
@@ -75,7 +75,7 @@ export function normalizeUsage(json) {
75
75
 
76
76
  export async function fetchUsage({ fetchImpl = globalThis.fetch, creds = readClaudeCredentials(), now = Date.now() } = {}) {
77
77
  if (!creds?.accessToken) return { ok: false, reason: 'no-credentials' };
78
- if (creds.expiresAt && now > creds.expiresAt) return { ok: false, reason: 'token-expired' };
78
+ if (creds.expiresAt && now > creds.expiresAt - 30_000) return { ok: false, reason: 'token-expired' }; // 30s skew margin — skip a request we know will 401
79
79
  let res;
80
80
  try {
81
81
  res = await fetchImpl(USAGE_ENDPOINT, {
@@ -112,7 +112,11 @@ export function writeUsageCache(usage, path = USAGE_CACHE_PATH) {
112
112
  export function readUsageCache({ path = USAGE_CACHE_PATH, maxAgeMs = USAGE_STALE_MS, now = Date.now() } = {}) {
113
113
  try {
114
114
  const u = JSON.parse(readFileSync(path, 'utf8'));
115
- if (!u?.fetchedAt || now - u.fetchedAt > maxAgeMs) return null;
115
+ if (!u?.fetchedAt) return null;
116
+ const age = now - u.fetchedAt;
117
+ // Stale, or future-dated (corrupt write / clock skew) — a future fetchedAt
118
+ // would otherwise read as fresh forever.
119
+ if (age > maxAgeMs || age < -60_000) return null;
116
120
  return u;
117
121
  } catch { return null; }
118
122
  }
package/src/version.js CHANGED
@@ -11,7 +11,7 @@ import { readFileSync } from 'node:fs';
11
11
  import { join } from 'node:path';
12
12
  import { ROOT } from './paths.js';
13
13
 
14
- const BAKED = '0.18.0';
14
+ const BAKED = '0.19.1';
15
15
 
16
16
  function readPkgVersion() {
17
17
  try {
package/src/week.js ADDED
@@ -0,0 +1,33 @@
1
+ // Shared Monday-anchored current-week grid. cli.js (showWeek) and tui.js
2
+ // (tabWeek) render it with different ANSI widths but compute the same seven
3
+ // days, future/today flags, and peak — extracted here so the date math can't
4
+ // drift between the two and is unit-testable against a fixed clock.
5
+
6
+ import { dayKey } from './scanner.js';
7
+
8
+ const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
9
+
10
+ // Returns { days: [{ label, ms, isFuture, isToday }], maxMs } for the ISO week
11
+ // (Mon -> Sun) containing `now`. `label` is e.g. "Wed 06-17"; future days have
12
+ // ms 0 and isFuture true. maxMs is floored at 1 so callers divide safely.
13
+ export function weekGrid(byDay = {}, now = new Date()) {
14
+ const today = new Date(now);
15
+ today.setHours(0, 0, 0, 0);
16
+ const monday = new Date(today);
17
+ monday.setDate(monday.getDate() - ((monday.getDay() + 6) % 7));
18
+ const todayKey = dayKey(today.getTime());
19
+ const days = [];
20
+ for (let i = 0; i < 7; i++) {
21
+ const d = new Date(monday);
22
+ d.setDate(d.getDate() + i);
23
+ const k = dayKey(d.getTime());
24
+ days.push({
25
+ label: `${DAY_NAMES[d.getDay()]} ${k.slice(5)}`,
26
+ ms: byDay?.[k]?.activeMs || 0,
27
+ isFuture: d > today,
28
+ isToday: k === todayKey,
29
+ });
30
+ }
31
+ const maxMs = Math.max(...days.map((x) => x.ms)) || 1;
32
+ return { days, maxMs };
33
+ }