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 +1 -1
- package/src/badge.js +1 -16
- package/src/card.js +5 -17
- package/src/cli.js +11 -25
- package/src/daemon.js +43 -7
- package/src/discord-ipc.js +12 -5
- package/src/doctor.js +29 -19
- package/src/fmt.js +33 -0
- package/src/format.js +8 -21
- package/src/hook.js +16 -8
- package/src/insights.js +1 -15
- package/src/install.js +40 -25
- package/src/leaderboard.js +5 -1
- package/src/mcp.js +15 -1
- package/src/notify.js +4 -4
- package/src/presence.js +28 -0
- package/src/privacy.js +29 -13
- package/src/profile.js +1 -16
- package/src/scanner.js +29 -17
- package/src/server/api.js +3 -3
- package/src/server/assets/dashboard.client.js +32 -9
- package/src/server/index.js +3 -3
- package/src/server/sse.js +27 -0
- package/src/state.js +95 -27
- package/src/tui.js +5 -19
- package/src/usage.js +6 -2
- package/src/version.js +1 -1
- package/src/week.js +33 -0
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
|
-
|
|
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
|
-
:
|
|
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) =>
|
|
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
|
-
|
|
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) => !
|
|
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 ?? '') + '
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
|
|
485
|
-
|
|
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
|
}
|
package/src/leaderboard.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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(
|
|
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(
|
|
52
|
+
swallow(spawnFn('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], { stdio: 'ignore', detached: true, windowsHide: true }));
|
|
53
53
|
} else {
|
|
54
|
-
swallow(
|
|
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 {
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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, '"');
|
|
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
|
-
|
|
636
|
-
|
|
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
|
|
640
|
-
|
|
641
|
-
let
|
|
642
|
-
try {
|
|
643
|
-
for (const
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
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 {
|
|
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 =
|
|
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
|
|
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
|
-
|
|
168
|
+
const start = new Date(today); start.setDate(start.getDate() - 90);
|
|
167
169
|
while (start.getDay() !== 0) start.setDate(start.getDate() - 1);
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)));
|
package/src/server/index.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
64
|
-
req.on('close', () =>
|
|
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
|