claude-rpc 0.17.2 → 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.17.2",
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
@@ -2,8 +2,8 @@
2
2
  import { writeFileSync, readFileSync, existsSync, unlinkSync, watch, appendFileSync, mkdirSync, statSync, renameSync } from 'node:fs';
3
3
  import { basename, dirname } from 'node:path';
4
4
  import { Client } from './discord-ipc.js';
5
- import { readState, sweepStaleStateTmp } from './state.js';
6
- import { makeRotationCursor, pickFrames, selectFrame, resolveLargeImageKey, shouldShowGithubButton } from './presence.js';
5
+ import { readState, sweepStaleStateTmp, listSessionStates, sweepStaleSessionStates } from './state.js';
6
+ import { makeRotationCursor, pickFrames, selectFrame, resolveLargeImageKey, shouldShowGithubButton, pickActiveSession } from './presence.js';
7
7
  import { buildVars, fillTemplate, framePasses, applyIdle, applyShipped, applyTrigger } from './format.js';
8
8
  import { scan, readAggregate, findLiveSessions, readSessionTokens } from './scanner.js';
9
9
  import { detectGithubUrl } from './git.js';
@@ -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.
@@ -96,6 +97,9 @@ let reconnectDelayMs = RECONNECT_BASE_MS;
96
97
  // 12-frame rotation into a single-frame working state and back, producing a
97
98
  // jarring "blank tick" until modulo aligns.
98
99
  const rotationCursor = makeRotationCursor();
100
+ // Which concurrent session's card we're currently showing. pickActiveSession
101
+ // keeps it sticky — see resolvePresence.
102
+ let displayedSessionId = null;
99
103
  // Stabilizes Discord's elapsed timer: applyIdle can synthesize a sessionStart
100
104
  // from a moving transcript mtime, and missing-hook scenarios leave it null —
101
105
  // either case would make startTimestamp jump on every rotation.
@@ -121,8 +125,10 @@ try {
121
125
  } catch { /* unreadable PID file — fall through and claim it */ }
122
126
  writeFileSync(PID_PATH, String(process.pid));
123
127
 
124
- // Reclaim any per-pid state tmp files orphaned by a hard-killed writer.
128
+ // Reclaim any per-pid state tmp files orphaned by a hard-killed writer, plus
129
+ // per-session state files from sessions that ended long ago.
125
130
  sweepStaleStateTmp();
131
+ sweepStaleSessionStates();
126
132
 
127
133
  // pickFrames / selectFrame / resolveLargeImageKey now live in presence.js (pure
128
134
  // + unit-tested). The rotation cursor (rotationCursor) is owned here and passed
@@ -135,7 +141,29 @@ sweepStaleStateTmp();
135
141
  // previously this chain ran twice and only buildActivity applied the trigger
136
142
  // overlay, so the two could diverge.
137
143
  function resolvePresence(opts = {}) {
138
- let state = opts.state || readState();
144
+ let state;
145
+ if (opts.state) {
146
+ state = opts.state; // standalone caller (preview/api) passes an explicit state
147
+ } else {
148
+ // Multi-session: each session writes its own state-<id>.json. Pick which one
149
+ // to show, sticking with the current session while it's active so the card
150
+ // doesn't thrash between projects (which also stabilizes the elapsed timer
151
+ // and the GitHub button — both follow the displayed session). Fall back to
152
+ // the legacy global state.json when there are no per-session files.
153
+ const sessions = listSessionStates();
154
+ if (sessions.length) {
155
+ const idleMs = Math.max(30_000, (config.idleThresholdSec || 60) * 1000);
156
+ const picked = pickActiveSession(sessions, displayedSessionId, Date.now(), idleMs);
157
+ displayedSessionId = picked.sessionId;
158
+ state = picked.state || readState();
159
+ // Party count from the SAME per-session list we selected from, so the
160
+ // "N sessions" field stays consistent with the card instead of wobbling
161
+ // on transcript-mtime timing.
162
+ state._liveCount = picked.liveCount;
163
+ } else {
164
+ state = readState();
165
+ }
166
+ }
139
167
  // Attach live sessions BEFORE applyIdle so the stale/idle decision can
140
168
  // see ongoing transcript activity, not just this daemon's hook state.
141
169
  state.liveSessions = opts.liveSessions || liveSessions;
@@ -257,7 +285,9 @@ function buildActivity(opts = {}) {
257
285
  // Concurrent sessions render natively via Discord's party field — the card
258
286
  // shows "(2 of 2)" with no template work. Only attached when more than one
259
287
  // live session exists (a party of one is noise). Opt out: showPartySize:false.
260
- const liveCount = (state.liveSessions || []).length;
288
+ // Prefer the per-session count (consistent with the displayed session); fall
289
+ // back to the transcript-derived count for the legacy single-state path.
290
+ const liveCount = state._liveCount != null ? state._liveCount : (state.liveSessions || []).length;
261
291
  if (config.showPartySize !== false && liveCount > 1) {
262
292
  activity.partyId = 'claude-rpc';
263
293
  activity.partySize = liveCount;
@@ -376,6 +406,10 @@ function isConnectionError(e) {
376
406
 
377
407
  async function connect() {
378
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;
379
413
  client = new Client({ clientId: config.clientId, transport: { type: 'ipc' } });
380
414
 
381
415
  client.on('ready', () => {
@@ -393,6 +427,8 @@ async function connect() {
393
427
  try { await client.login(); }
394
428
  catch (e) {
395
429
  scheduleReconnect(`Discord login failed: ${e.message}`);
430
+ } finally {
431
+ connecting = false;
396
432
  }
397
433
  }
398
434
 
@@ -580,7 +616,7 @@ const HEALTH_CHECK_MS = 30_000;
580
616
  setInterval(() => {
581
617
  try {
582
618
  // Half-open: flag says connected but there's no usable user handle.
583
- if (connected && !client?.user) {
619
+ if (connected && !client?.user && !connecting) {
584
620
  log('Watchdog: connected but no user handle — forcing reconnect');
585
621
  connected = false;
586
622
  try { client?.destroy(); } catch { /* already gone */ }
@@ -589,7 +625,7 @@ setInterval(() => {
589
625
  }
590
626
  // Down with nothing scheduled to bring us back. scheduleReconnect is a
591
627
  // no-op when a timer is already pending, so this can't stack retries.
592
- if (!connected && !reconnectTimer) {
628
+ if (!connected && !reconnectTimer && !connecting) {
593
629
  log('Watchdog: disconnected with no reconnect pending — forcing reconnect');
594
630
  scheduleReconnect('watchdog: no retry pending');
595
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/hook.js CHANGED
@@ -125,9 +125,17 @@ function parseInput() {
125
125
  // directly (avoids spawning a child process per hook).
126
126
  export function processHookEvent(event, input = {}) {
127
127
  const now = Date.now();
128
+ // Each session writes its OWN state file (state-<sessionId>.json) so concurrent
129
+ // sessions stop clobbering one shared state.json — that thrash made the card
130
+ // jump between projects, the timer reset, and counters over-count. Subagent
131
+ // hooks carry the parent's session_id, so their activity rolls up correctly.
132
+ // No id (legacy payload) → the global state.json, preserving old behavior.
133
+ const sid = input.session_id || input.sessionId || null;
134
+ const update = (fn) => updateState(fn, sid);
135
+ const reset = (seed) => resetState(seed, sid);
128
136
 
129
137
  function setActivity(patch) {
130
- updateState((s) => {
138
+ update((s) => {
131
139
  Object.assign(s, patch);
132
140
  s.lastActivity = now;
133
141
  // Any hook firing means Claude Code is alive — clear the closed flag
@@ -140,7 +148,7 @@ export function processHookEvent(event, input = {}) {
140
148
 
141
149
  switch (event) {
142
150
  case 'SessionStart': {
143
- resetState({
151
+ reset({
144
152
  cwd: input.cwd || process.cwd(),
145
153
  model: input.model?.id || input.model || 'claude',
146
154
  status: 'idle',
@@ -148,7 +156,7 @@ export function processHookEvent(event, input = {}) {
148
156
  break;
149
157
  }
150
158
  case 'UserPromptSubmit': {
151
- updateState((s) => {
159
+ update((s) => {
152
160
  s.messages += 1;
153
161
  s.lastUserPrompt = now;
154
162
  s.lastActivity = now;
@@ -164,7 +172,7 @@ export function processHookEvent(event, input = {}) {
164
172
  const toolName = input.tool_name || input.toolName || 'tool';
165
173
  const toolInput = input.tool_input || input.toolInput || {};
166
174
  const file = toolInput.file_path || toolInput.path || toolInput.notebook_path || null;
167
- updateState((s) => {
175
+ update((s) => {
168
176
  s.tools += 1;
169
177
  s.toolBreakdown[toolName] = (s.toolBreakdown[toolName] || 0) + 1;
170
178
  s.currentTool = toolName;
@@ -211,7 +219,7 @@ export function processHookEvent(event, input = {}) {
211
219
  }
212
220
  }
213
221
  }
214
- updateState((s) => {
222
+ update((s) => {
215
223
  s.currentTool = null;
216
224
  s.toolStartedAt = null;
217
225
  s.lastActivity = now;
@@ -236,7 +244,7 @@ export function processHookEvent(event, input = {}) {
236
244
  break;
237
245
  }
238
246
  case 'Notification': {
239
- updateState((s) => {
247
+ update((s) => {
240
248
  s.status = 'notification';
241
249
  s.lastNotification = now;
242
250
  s.lastActivity = now;
@@ -254,7 +262,7 @@ export function processHookEvent(event, input = {}) {
254
262
  // rewriting earlier context, not advancing a turn. Surface it as its
255
263
  // own state so the card stops reading "Thinking…" for the 10-60s
256
264
  // compactions can take on big sessions.
257
- updateState((s) => {
265
+ update((s) => {
258
266
  s.status = 'compacting';
259
267
  s.compactStartedAt = now;
260
268
  s.compactTrigger = input.trigger || input.matcher || null;
@@ -276,7 +284,7 @@ export function processHookEvent(event, input = {}) {
276
284
  // staleSessionMin timeout. applyIdle short-circuits to stale when it
277
285
  // sees claudeClosed=true. Any subsequent hook from another live
278
286
  // session will flip the flag back to false.
279
- updateState((s) => {
287
+ update((s) => {
280
288
  s.status = 'stale';
281
289
  s.claudeClosed = true;
282
290
  s.currentTool = null;
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)}%`;