claude-rpc 0.18.0 → 0.19.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/badge.js +1 -16
- package/src/card.js +5 -17
- package/src/cli.js +11 -25
- package/src/daemon.js +9 -2
- 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/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/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 +12 -0
- 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/package.json
CHANGED
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, '"');
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
1826
|
+
const state = readActiveState();
|
|
1841
1827
|
const aggregate = readAggregate();
|
|
1842
1828
|
state.liveSessions = findLiveSessions({ thresholdMs: 90_000 });
|
|
1843
1829
|
state.usage = readUsageCache();
|
package/src/daemon.js
CHANGED
|
@@ -78,6 +78,7 @@ let aggregate = readAggregate() || null;
|
|
|
78
78
|
let liveSessions = [];
|
|
79
79
|
let client = null;
|
|
80
80
|
let connected = false;
|
|
81
|
+
let connecting = false; // login() in flight — see the watchdog note in connect()
|
|
81
82
|
let lastPayloadHash = '';
|
|
82
83
|
// Last status we acted on for outbound side-effects (webhook / desktop notify).
|
|
83
84
|
// Tracked separately from the render hash so we fire once per transition.
|
|
@@ -405,6 +406,10 @@ function isConnectionError(e) {
|
|
|
405
406
|
|
|
406
407
|
async function connect() {
|
|
407
408
|
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
|
409
|
+
// While login() is in flight, connected===false and reconnectTimer===null
|
|
410
|
+
// both hold, so the watchdog's "down, nothing pending" branch would spawn a
|
|
411
|
+
// second connect() that overwrites `client` and orphans this socket. Block it.
|
|
412
|
+
connecting = true;
|
|
408
413
|
client = new Client({ clientId: config.clientId, transport: { type: 'ipc' } });
|
|
409
414
|
|
|
410
415
|
client.on('ready', () => {
|
|
@@ -422,6 +427,8 @@ async function connect() {
|
|
|
422
427
|
try { await client.login(); }
|
|
423
428
|
catch (e) {
|
|
424
429
|
scheduleReconnect(`Discord login failed: ${e.message}`);
|
|
430
|
+
} finally {
|
|
431
|
+
connecting = false;
|
|
425
432
|
}
|
|
426
433
|
}
|
|
427
434
|
|
|
@@ -609,7 +616,7 @@ const HEALTH_CHECK_MS = 30_000;
|
|
|
609
616
|
setInterval(() => {
|
|
610
617
|
try {
|
|
611
618
|
// Half-open: flag says connected but there's no usable user handle.
|
|
612
|
-
if (connected && !client?.user) {
|
|
619
|
+
if (connected && !client?.user && !connecting) {
|
|
613
620
|
log('Watchdog: connected but no user handle — forcing reconnect');
|
|
614
621
|
connected = false;
|
|
615
622
|
try { client?.destroy(); } catch { /* already gone */ }
|
|
@@ -618,7 +625,7 @@ setInterval(() => {
|
|
|
618
625
|
}
|
|
619
626
|
// Down with nothing scheduled to bring us back. scheduleReconnect is a
|
|
620
627
|
// no-op when a timer is already pending, so this can't stack retries.
|
|
621
|
-
if (!connected && !reconnectTimer) {
|
|
628
|
+
if (!connected && !reconnectTimer && !connecting) {
|
|
622
629
|
log('Watchdog: disconnected with no reconnect pending — forcing reconnect');
|
|
623
630
|
scheduleReconnect('watchdog: no retry pending');
|
|
624
631
|
}
|
package/src/discord-ipc.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 (
|
|
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) =>
|
|
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
|
-
|
|
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
|
|
227
|
-
|
|
228
|
-
|
|
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' ?
|
|
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
|
|
507
|
+
appName,
|
|
521
508
|
|
|
522
509
|
// Literal single space — handy for blanking a line without `requires`.
|
|
523
510
|
empty: ' ',
|
package/src/insights.js
CHANGED
|
@@ -4,24 +4,10 @@
|
|
|
4
4
|
|
|
5
5
|
import { dayKey, weekKey } from './scanner.js';
|
|
6
6
|
import { fmtCost } from './pricing.js';
|
|
7
|
+
import { fmtNum, fmtHours } from './fmt.js';
|
|
7
8
|
|
|
8
9
|
const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
9
10
|
|
|
10
|
-
function fmtHours(ms) {
|
|
11
|
-
if (!ms || ms < 0) return '0h';
|
|
12
|
-
const hours = ms / 3_600_000;
|
|
13
|
-
if (hours < 1) return `${Math.round(hours * 60)}m`;
|
|
14
|
-
if (hours < 10) return `${hours.toFixed(1)}h`;
|
|
15
|
-
return `${Math.round(hours)}h`;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function fmtNum(n) {
|
|
19
|
-
if (!n) return '0';
|
|
20
|
-
if (n < 1000) return `${n}`;
|
|
21
|
-
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`;
|
|
22
|
-
return `${(n / 1_000_000).toFixed(2)}M`;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
11
|
function pct(n) {
|
|
26
12
|
const sign = n >= 0 ? '+' : '−';
|
|
27
13
|
return `${sign}${Math.round(Math.abs(n) * 100)}%`;
|
package/src/install.js
CHANGED
|
@@ -52,7 +52,7 @@ function dirtyStep(sym, label, detail = '', log = console.log) {
|
|
|
52
52
|
}
|
|
53
53
|
function noop(fact) { noopFacts.push(fact); }
|
|
54
54
|
|
|
55
|
-
const EVENTS = [
|
|
55
|
+
export const EVENTS = [
|
|
56
56
|
'SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse',
|
|
57
57
|
'Stop', 'SubagentStop', 'Notification', 'SessionEnd', 'PreCompact',
|
|
58
58
|
];
|
|
@@ -69,7 +69,18 @@ function writeJson(p, d) {
|
|
|
69
69
|
|
|
70
70
|
function isOurHookCommand(cmd) {
|
|
71
71
|
if (!cmd) return false;
|
|
72
|
-
|
|
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/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
|
package/src/state.js
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from 'node:fs';
|
|
14
14
|
import { basename, join } from 'node:path';
|
|
15
15
|
import { STATE_PATH, STATE_DIR } from './paths.js';
|
|
16
|
+
import { pickActiveSession } from './presence.js';
|
|
16
17
|
|
|
17
18
|
const DEFAULT_STATE = {
|
|
18
19
|
sessionStart: null,
|
|
@@ -170,6 +171,17 @@ export function readState(sessionId) {
|
|
|
170
171
|
}
|
|
171
172
|
}
|
|
172
173
|
|
|
174
|
+
// The session a one-shot reader (status / serve / tui) should display. The
|
|
175
|
+
// daemon resolves per-session state with cross-tick stickiness; one-shot readers
|
|
176
|
+
// have no cursor, so they pass displayedId=null and get the most-recently-active
|
|
177
|
+
// session — exactly what the daemon settles on. Falls back to the legacy global
|
|
178
|
+
// state.json when no per-session files exist (e.g. a hook payload without an id).
|
|
179
|
+
export function readActiveState({ now = Date.now(), idleMs = 60_000 } = {}) {
|
|
180
|
+
const sessions = listSessionStates();
|
|
181
|
+
if (!sessions.length) return readState();
|
|
182
|
+
return pickActiveSession(sessions, null, now, idleMs).state || readState();
|
|
183
|
+
}
|
|
184
|
+
|
|
173
185
|
export function writeState(next, sessionId) {
|
|
174
186
|
ensureDir();
|
|
175
187
|
const path = statePathFor(sessionId);
|
package/src/tui.js
CHANGED
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
|
|
7
7
|
import process from 'node:process';
|
|
8
8
|
import { readFileSync, existsSync } from 'node:fs';
|
|
9
|
-
import {
|
|
10
|
-
import { readAggregate, findLiveSessions,
|
|
9
|
+
import { readActiveState } from './state.js';
|
|
10
|
+
import { readAggregate, findLiveSessions, weekKey } from './scanner.js';
|
|
11
|
+
import { weekGrid } from './week.js';
|
|
11
12
|
import { buildVars, applyIdle, humanProject } from './format.js';
|
|
12
13
|
import { loadConfig } from './config.js';
|
|
13
14
|
import { PID_PATH } from './paths.js';
|
|
@@ -51,7 +52,7 @@ let exiting = false;
|
|
|
51
52
|
|
|
52
53
|
// ── Data ────────────────────────────────────────────────────────────────────
|
|
53
54
|
function loadSnapshot() {
|
|
54
|
-
let state =
|
|
55
|
+
let state = readActiveState();
|
|
55
56
|
state.liveSessions = findLiveSessions({ thresholdMs: 90_000 });
|
|
56
57
|
const config = loadConfig();
|
|
57
58
|
state = applyIdle(state, config);
|
|
@@ -186,22 +187,7 @@ function tabWeek(_, data) {
|
|
|
186
187
|
if (agg.byDay) {
|
|
187
188
|
out.push('');
|
|
188
189
|
out.push(`${C.dim}daily breakdown${C.reset}`);
|
|
189
|
-
const
|
|
190
|
-
now.setHours(0, 0, 0, 0);
|
|
191
|
-
const monday = new Date(now);
|
|
192
|
-
monday.setDate(monday.getDate() - ((monday.getDay() + 6) % 7));
|
|
193
|
-
const days = [];
|
|
194
|
-
for (let i = 0; i < 7; i++) {
|
|
195
|
-
const d = new Date(monday);
|
|
196
|
-
d.setDate(d.getDate() + i);
|
|
197
|
-
const k = dayKey(d.getTime());
|
|
198
|
-
const dayName = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][d.getDay()];
|
|
199
|
-
const ms = agg.byDay[k]?.activeMs || 0;
|
|
200
|
-
const isFuture = d > now;
|
|
201
|
-
const isToday = k === dayKey(now.getTime());
|
|
202
|
-
days.push({ label: `${dayName} ${k.slice(5)}`, ms, isFuture, isToday });
|
|
203
|
-
}
|
|
204
|
-
const maxMs = Math.max(...days.map((d) => d.ms)) || 1;
|
|
190
|
+
const { days, maxMs } = weekGrid(agg.byDay);
|
|
205
191
|
for (const { label, ms, isFuture, isToday } of days) {
|
|
206
192
|
if (isFuture) {
|
|
207
193
|
out.push(`${C.dim}${label.padEnd(11)}${' '.repeat(18)} —${C.reset}`);
|
package/src/usage.js
CHANGED
|
@@ -75,7 +75,7 @@ export function normalizeUsage(json) {
|
|
|
75
75
|
|
|
76
76
|
export async function fetchUsage({ fetchImpl = globalThis.fetch, creds = readClaudeCredentials(), now = Date.now() } = {}) {
|
|
77
77
|
if (!creds?.accessToken) return { ok: false, reason: 'no-credentials' };
|
|
78
|
-
if (creds.expiresAt && now > creds.expiresAt) return { ok: false, reason: 'token-expired' };
|
|
78
|
+
if (creds.expiresAt && now > creds.expiresAt - 30_000) return { ok: false, reason: 'token-expired' }; // 30s skew margin — skip a request we know will 401
|
|
79
79
|
let res;
|
|
80
80
|
try {
|
|
81
81
|
res = await fetchImpl(USAGE_ENDPOINT, {
|
|
@@ -112,7 +112,11 @@ export function writeUsageCache(usage, path = USAGE_CACHE_PATH) {
|
|
|
112
112
|
export function readUsageCache({ path = USAGE_CACHE_PATH, maxAgeMs = USAGE_STALE_MS, now = Date.now() } = {}) {
|
|
113
113
|
try {
|
|
114
114
|
const u = JSON.parse(readFileSync(path, 'utf8'));
|
|
115
|
-
if (!u?.fetchedAt
|
|
115
|
+
if (!u?.fetchedAt) return null;
|
|
116
|
+
const age = now - u.fetchedAt;
|
|
117
|
+
// Stale, or future-dated (corrupt write / clock skew) — a future fetchedAt
|
|
118
|
+
// would otherwise read as fresh forever.
|
|
119
|
+
if (age > maxAgeMs || age < -60_000) return null;
|
|
116
120
|
return u;
|
|
117
121
|
} catch { return null; }
|
|
118
122
|
}
|
package/src/version.js
CHANGED
package/src/week.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Shared Monday-anchored current-week grid. cli.js (showWeek) and tui.js
|
|
2
|
+
// (tabWeek) render it with different ANSI widths but compute the same seven
|
|
3
|
+
// days, future/today flags, and peak — extracted here so the date math can't
|
|
4
|
+
// drift between the two and is unit-testable against a fixed clock.
|
|
5
|
+
|
|
6
|
+
import { dayKey } from './scanner.js';
|
|
7
|
+
|
|
8
|
+
const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
9
|
+
|
|
10
|
+
// Returns { days: [{ label, ms, isFuture, isToday }], maxMs } for the ISO week
|
|
11
|
+
// (Mon -> Sun) containing `now`. `label` is e.g. "Wed 06-17"; future days have
|
|
12
|
+
// ms 0 and isFuture true. maxMs is floored at 1 so callers divide safely.
|
|
13
|
+
export function weekGrid(byDay = {}, now = new Date()) {
|
|
14
|
+
const today = new Date(now);
|
|
15
|
+
today.setHours(0, 0, 0, 0);
|
|
16
|
+
const monday = new Date(today);
|
|
17
|
+
monday.setDate(monday.getDate() - ((monday.getDay() + 6) % 7));
|
|
18
|
+
const todayKey = dayKey(today.getTime());
|
|
19
|
+
const days = [];
|
|
20
|
+
for (let i = 0; i < 7; i++) {
|
|
21
|
+
const d = new Date(monday);
|
|
22
|
+
d.setDate(d.getDate() + i);
|
|
23
|
+
const k = dayKey(d.getTime());
|
|
24
|
+
days.push({
|
|
25
|
+
label: `${DAY_NAMES[d.getDay()]} ${k.slice(5)}`,
|
|
26
|
+
ms: byDay?.[k]?.activeMs || 0,
|
|
27
|
+
isFuture: d > today,
|
|
28
|
+
isToday: k === todayKey,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
const maxMs = Math.max(...days.map((x) => x.ms)) || 1;
|
|
32
|
+
return { days, maxMs };
|
|
33
|
+
}
|