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/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/presence.js CHANGED
@@ -55,6 +55,34 @@ export function selectFrame(rawFrames, vars, status, cursor, intervalMs, framePa
55
55
  return frames[cursor.index % frames.length] || {};
56
56
  }
57
57
 
58
+ // Pick which session's card to show when several Claude sessions run at once.
59
+ // Each session writes its own state-<id>.json (lastActivity stamped on every
60
+ // hook), so `states` is one entry per session. Behavior: STICK to the currently
61
+ // shown session while it's still active (lastActivity within idleMs), so the
62
+ // card doesn't thrash between sessions while you're working in one; only once
63
+ // the shown session goes idle do we switch to the most-recently-active session.
64
+ // Returns { state, sessionId, liveCount } — liveCount feeds the "N sessions"
65
+ // party field so it stays consistent with what's displayed.
66
+ export function pickActiveSession(states, displayedId, now, idleMs) {
67
+ const list = (states || []).filter((s) => s && s.sessionId);
68
+ if (!list.length) return { state: null, sessionId: null, liveCount: 0 };
69
+ // A just-ended session (SessionEnd → claudeClosed) stamps a recent
70
+ // lastActivity but is gone — never count it live or stick to it.
71
+ const isLive = (s) => !s.claudeClosed && now - (s.lastActivity || 0) <= idleMs;
72
+ const liveCount = list.filter(isLive).length;
73
+ const byRecent = [...list].sort((a, b) => (b.lastActivity || 0) - (a.lastActivity || 0));
74
+ // Stickiness: keep the shown session while it's still active.
75
+ const current = list.find((s) => s.sessionId === displayedId);
76
+ if (current && isLive(current)) {
77
+ return { state: current, sessionId: current.sessionId, liveCount };
78
+ }
79
+ // Otherwise show the most-recently-active live session — or, if none are live,
80
+ // the most-recent overall so the card follows the last session into idle/stale
81
+ // rather than blanking.
82
+ const chosen = byRecent.find(isLive) || byRecent[0];
83
+ return { state: chosen, sessionId: chosen.sessionId, liveCount };
84
+ }
85
+
58
86
  // Should the daemon auto-add a "View on GitHub →" button for this cwd? The
59
87
  // button URL is read from .git/config (no `gh` needed), but private-repo
60
88
  // detection DOES need the gh CLI — so on a machine without gh a private repo
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