claude-rpc 0.18.0 → 0.20.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/SECURITY.md +17 -4
- package/package.json +1 -1
- package/src/badge.js +1 -16
- package/src/card.js +5 -17
- package/src/cli.js +44 -35
- package/src/daemon.js +138 -48
- package/src/default-config.js +14 -0
- package/src/discord-ipc.js +12 -5
- package/src/doctor.js +29 -19
- package/src/ensure-daemon.js +127 -0
- package/src/fmt.js +33 -0
- package/src/format.js +8 -21
- package/src/hook.js +16 -0
- 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 +24 -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 +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/src/default-config.js
CHANGED
|
@@ -14,6 +14,20 @@ export const DEFAULT_CONFIG = {
|
|
|
14
14
|
appName: "Claude Code",
|
|
15
15
|
updateIntervalMs: 4000,
|
|
16
16
|
rotationIntervalMs: 12000,
|
|
17
|
+
// Self-heal: the SessionStart hook (re)starts the daemon whenever it isn't
|
|
18
|
+
// already running, so presence is assured whenever you use Claude Code —
|
|
19
|
+
// surviving reboots, crashes, OS sleep, and platforms with no login-autostart
|
|
20
|
+
// entry (macOS/Linux). Set false to disable that and manage the daemon
|
|
21
|
+
// yourself with `claude-rpc start` / `stop`.
|
|
22
|
+
autostart: true,
|
|
23
|
+
// Minimum gap (ms) between Discord SET_ACTIVITY writes. Discord hard-limits
|
|
24
|
+
// activity updates (~5 per 20s); blowing past it makes the client EMPTY the
|
|
25
|
+
// presence and stop updating until the writes stop. Claude Code fires hooks
|
|
26
|
+
// in bursts, so the daemon coalesces rapid changes and never writes faster
|
|
27
|
+
// than this — the first change after a quiet gap goes out at once, the rest
|
|
28
|
+
// collapse to the latest and flush when the gap expires. 4s stays safely
|
|
29
|
+
// under the limit; lower values risk Discord throttling the card.
|
|
30
|
+
minActivityGapMs: 4000,
|
|
17
31
|
rescanIntervalSec: 300,
|
|
18
32
|
idleThresholdSec: 60,
|
|
19
33
|
// Time (minutes) of no hook activity AND no live transcripts on disk before
|
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`);
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Daemon-lifecycle primitives shared by the CLI (`start`), the daemon itself
|
|
2
|
+
// (single-instance guard), and the hook (self-heal on SessionStart). Kept in a
|
|
3
|
+
// tiny dependency-light module so the hook — which runs on every Claude Code
|
|
4
|
+
// event and must stay fast and crash-proof — can import the spawn recipe
|
|
5
|
+
// without pulling in cli.js's UI stack.
|
|
6
|
+
//
|
|
7
|
+
// The "ultra assured startup" story lives here: the daemon comes up whenever a
|
|
8
|
+
// Claude Code session starts (ensureDaemonRunning, called from the hook), and a
|
|
9
|
+
// race-proof claim (claimSingleInstance, called by the daemon) guarantees that
|
|
10
|
+
// no matter how many launchers fire at once — a manual `start`, a Windows Run
|
|
11
|
+
// entry, several SessionStart hooks from concurrent sessions — exactly one
|
|
12
|
+
// daemon survives.
|
|
13
|
+
|
|
14
|
+
import { spawn } from 'node:child_process';
|
|
15
|
+
import {
|
|
16
|
+
openSync, closeSync, writeFileSync, readFileSync, unlinkSync, existsSync, statSync, mkdirSync,
|
|
17
|
+
} from 'node:fs';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { PID_PATH, STATE_DIR, DAEMON_SCRIPT, CANONICAL_EXE, IS_PACKAGED } from './paths.js';
|
|
20
|
+
|
|
21
|
+
// mtime of this marker = last time any launcher attempted a spawn. A short
|
|
22
|
+
// cooldown stops concurrent SessionStart hooks (several sessions opening at
|
|
23
|
+
// once) and any startup crash-loop from spawning a swarm of throwaway daemons —
|
|
24
|
+
// the single-instance claim would reap them, but not spawning them is cheaper.
|
|
25
|
+
const SPAWN_MARKER = join(STATE_DIR, 'daemon-spawn.ts');
|
|
26
|
+
const SPAWN_COOLDOWN_MS = 15_000;
|
|
27
|
+
|
|
28
|
+
// True if `pid` names a live process. process.kill(pid,0) sends no signal — it
|
|
29
|
+
// just probes: ESRCH → gone, EPERM → exists but owned by someone else (still
|
|
30
|
+
// alive). 0/NaN is never alive.
|
|
31
|
+
export function isAlive(pid) {
|
|
32
|
+
if (!pid || !Number.isFinite(pid)) return false;
|
|
33
|
+
try { process.kill(pid, 0); return true; }
|
|
34
|
+
catch (e) { return e.code === 'EPERM'; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function readPid(pidPath = PID_PATH) {
|
|
38
|
+
try { return Number(readFileSync(pidPath, 'utf8')) || 0; }
|
|
39
|
+
catch { return 0; }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The pid of the live daemon, or 0 if none is running.
|
|
43
|
+
export function daemonAlive(pidPath = PID_PATH) {
|
|
44
|
+
const pid = readPid(pidPath);
|
|
45
|
+
return pid && isAlive(pid) ? pid : 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The single source of truth for HOW to launch the daemon, shared by every
|
|
49
|
+
// launcher so the CLI and the hook spawn it identically:
|
|
50
|
+
// packaged → "<canonical exe>" daemon (prefer the canonical install over a
|
|
51
|
+
// transient Downloads copy so we don't keep that file locked open)
|
|
52
|
+
// dev/npm → "<node>" "<abs daemon.js>" (PATH-independent — process.execPath
|
|
53
|
+
// is the very node already running us)
|
|
54
|
+
// Always detached + windowless + stdio-ignored so it outlives the launcher and
|
|
55
|
+
// never pops a console. Returns the child (caller reads .pid) or null on a
|
|
56
|
+
// synchronous spawn failure.
|
|
57
|
+
export function spawnDaemonDetached() {
|
|
58
|
+
const exe = (IS_PACKAGED && existsSync(CANONICAL_EXE)) ? CANONICAL_EXE : process.execPath;
|
|
59
|
+
const args = IS_PACKAGED ? ['daemon'] : [DAEMON_SCRIPT];
|
|
60
|
+
try {
|
|
61
|
+
const child = spawn(exe, args, { detached: true, stdio: 'ignore', windowsHide: true });
|
|
62
|
+
// An async spawn failure (ENOENT on the exe) emits 'error'; with no listener
|
|
63
|
+
// Node rethrows it as an unhandled exception that can take down the launcher.
|
|
64
|
+
// Swallow — the caller verifies liveness via the pid file, not the child.
|
|
65
|
+
child.on('error', () => {});
|
|
66
|
+
child.unref();
|
|
67
|
+
return child;
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Pure decision for the hook's self-heal: should THIS SessionStart spawn a
|
|
74
|
+
// daemon? Separated from the side effects so it's unit-testable.
|
|
75
|
+
export function shouldSpawnDaemon({ autostart, daemonPid, lastAttemptMs, now, cooldownMs }) {
|
|
76
|
+
if (autostart === false) return false; // explicit opt-out
|
|
77
|
+
if (daemonPid) return false; // already running
|
|
78
|
+
if (lastAttemptMs && now - lastAttemptMs < cooldownMs) return false; // within the cooldown
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Self-heal entry point, called by the SessionStart hook. Best-effort and fast:
|
|
83
|
+
// if no daemon is running (reboot, crash, OS sleep, closed terminal — or simply
|
|
84
|
+
// a platform with no login-autostart entry), bring one up. Stamps the cooldown
|
|
85
|
+
// marker BEFORE spawning so sibling SessionStart hooks racing this one skip
|
|
86
|
+
// instead of each launching a throwaway. Returns the spawned child, or null.
|
|
87
|
+
export function ensureDaemonRunning({ autostart = true, now = Date.now(), cooldownMs = SPAWN_COOLDOWN_MS } = {}) {
|
|
88
|
+
let lastAttemptMs = 0;
|
|
89
|
+
try { if (existsSync(SPAWN_MARKER)) lastAttemptMs = statSync(SPAWN_MARKER).mtimeMs; } catch { /* unreadable — treat as never */ }
|
|
90
|
+
if (!shouldSpawnDaemon({ autostart, daemonPid: daemonAlive(), lastAttemptMs, now, cooldownMs })) return null;
|
|
91
|
+
try { mkdirSync(STATE_DIR, { recursive: true }); } catch { /* exists / unwritable */ }
|
|
92
|
+
try { writeFileSync(SPAWN_MARKER, String(now)); } catch { /* best-effort cooldown */ }
|
|
93
|
+
return spawnDaemonDetached();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Atomic single-instance claim, called once by the daemon at startup. Returns
|
|
97
|
+
// the pid of an already-running daemon (caller should exit), or null when WE
|
|
98
|
+
// now own the pid file. The exclusive-create ('wx') makes the pid file itself
|
|
99
|
+
// the mutex: only one of N simultaneously-starting daemons can create it; the
|
|
100
|
+
// losers read the winner's live pid and step aside. A stale file (dead owner,
|
|
101
|
+
// empty, or our own recycled pid) is reclaimed and the create retried — so a
|
|
102
|
+
// crashed daemon never blocks its successor.
|
|
103
|
+
export function claimSingleInstance({ pidPath = PID_PATH, pid = process.pid, alive = isAlive } = {}) {
|
|
104
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
105
|
+
let fd;
|
|
106
|
+
try {
|
|
107
|
+
fd = openSync(pidPath, 'wx'); // atomic: throws EEXIST if the file already exists
|
|
108
|
+
} catch (e) {
|
|
109
|
+
if (e.code !== 'EEXIST') {
|
|
110
|
+
// Unexpected (perms, missing dir). Best-effort write and take ownership
|
|
111
|
+
// rather than refusing to start.
|
|
112
|
+
try { writeFileSync(pidPath, String(pid)); } catch { /* unwritable — run lockless */ }
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
let owner = 0;
|
|
116
|
+
try { owner = Number(readFileSync(pidPath, 'utf8')) || 0; } catch { /* mid-write — reclaim */ }
|
|
117
|
+
if (owner && owner !== pid && alive(owner)) return owner; // a live daemon already owns it
|
|
118
|
+
try { unlinkSync(pidPath); } catch { /* a racer reclaimed it first */ }
|
|
119
|
+
continue; // retry the exclusive create
|
|
120
|
+
}
|
|
121
|
+
try { writeFileSync(fd, String(pid)); } finally { try { closeSync(fd); } catch { /* already closed */ } }
|
|
122
|
+
return null; // we created the file → we are THE daemon
|
|
123
|
+
}
|
|
124
|
+
// Repeatedly lost the reclaim race (extreme contention) — proceed best-effort.
|
|
125
|
+
try { writeFileSync(pidPath, String(pid)); } catch { /* unwritable */ }
|
|
126
|
+
return null;
|
|
127
|
+
}
|
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
|
@@ -4,6 +4,8 @@ import { dirname } from 'node:path';
|
|
|
4
4
|
import { updateState, resetState, pushUnique, shortFile } from './state.js';
|
|
5
5
|
import { detectLastCommitSubject, detectGitBranch } from './git.js';
|
|
6
6
|
import { EVENTS_LOG_PATH } from './paths.js';
|
|
7
|
+
import { loadConfig } from './config.js';
|
|
8
|
+
import { ensureDaemonRunning } from './ensure-daemon.js';
|
|
7
9
|
|
|
8
10
|
// Precedence when a command ships more than one way (`git commit && git push`
|
|
9
11
|
// → push). Highest first.
|
|
@@ -153,6 +155,20 @@ export function processHookEvent(event, input = {}) {
|
|
|
153
155
|
model: input.model?.id || input.model || 'claude',
|
|
154
156
|
status: 'idle',
|
|
155
157
|
});
|
|
158
|
+
// Self-heal the daemon. A reboot, crash, OS sleep, or closed terminal can
|
|
159
|
+
// leave nothing running, so the card silently never appears — and on
|
|
160
|
+
// macOS/Linux there's no login-autostart entry at all (only Windows wires
|
|
161
|
+
// a Run key). Every Claude Code session begins with this hook, so make it
|
|
162
|
+
// (best-effort) guarantee the daemon is up: presence is then assured
|
|
163
|
+
// exactly when you're using Claude, on every platform. ensureDaemonRunning
|
|
164
|
+
// is a no-op when a daemon is already alive and is cooldown-guarded against
|
|
165
|
+
// spawn storms; the daemon's atomic claim reaps any duplicate. Opt out
|
|
166
|
+
// with `autostart:false` in config.
|
|
167
|
+
try {
|
|
168
|
+
let autostart = true;
|
|
169
|
+
try { autostart = loadConfig().autostart !== false; } catch { /* unreadable config — default on */ }
|
|
170
|
+
ensureDaemonRunning({ autostart });
|
|
171
|
+
} catch { /* presence is best-effort — never break the user's turn */ }
|
|
156
172
|
break;
|
|
157
173
|
}
|
|
158
174
|
case 'UserPromptSubmit': {
|
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 {
|