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