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.
@@ -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
@@ -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
- const onErr = () => {
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
- if (!cfg.clientId || cfg.clientId === '1234567890123456789') {
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 (!/^\d{17,21}$/.test(String(cfg.clientId))) {
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) => isOurHookCommand(h.command));
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
- for (const line of readFileSync(LOG_PATH, 'utf8').split('\n').slice(-80)) {
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 projectPretty = cwdIsLeaky
227
- ? (config?.appName || 'Claude Code')
228
- : (humanProject(state.cwd) || 'Claude Code');
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' ? humanProject(s) : humanProject(s.cwd || s.project || '')))
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: config?.appName || 'Claude Code',
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
- return /claude-rpc/i.test(cmd) || /hook\.js/i.test(cmd);
72
+ if (/claude-rpc/i.test(cmd)) return true;
73
+ // Dev-mode hooks point at THIS install's hook.js by absolute path. Match that
74
+ // exact path — NOT any `hook.js`, which could be a third-party tool's hook we
75
+ // must never rewrite or delete.
76
+ return cmd.includes(HOOK_SCRIPT.replace(/\\/g, '/'));
77
+ }
78
+
79
+ // A hook entry we own. Tagged entries are recognized regardless of command
80
+ // shape or clone-dir name; command-matching is the legacy fallback for entries
81
+ // written before tagging.
82
+ export function isOurHook(h) {
83
+ return !!h && (h._claudeRpc === true || isOurHookCommand(h.command));
73
84
  }
74
85
 
75
86
  export function installHooks(exePath) {
@@ -81,24 +92,29 @@ export function installHooks(exePath) {
81
92
  // npm → `claude-rpc hook <event>` (bin shim resolves through PATH;
82
93
  // survives `npm update` and nvm version switches)
83
94
  // dev → `node "<src/hook.js>" <event>` (cloned-source iteration)
95
+ // Hook commands must resolve under Claude Code's hook shell — `/bin/sh` with a
96
+ // minimal PATH that, under nvm, has neither `claude-rpc` nor `node` on it (no
97
+ // system node). So use the ABSOLUTE node (process.execPath) + absolute hook.js
98
+ // for both npm and dev installs; only the packaged exe is self-contained. This
99
+ // survives `npm update` (HOOK_SCRIPT is stable) and an nvm version switch (nvm
100
+ // keeps prior versions on disk); re-run setup only if that node is removed.
101
+ const node = process.execPath.replace(/\\/g, '/');
84
102
  const cmdFor = IS_PACKAGED
85
103
  ? (event) => `"${exePath}" hook ${event}`
86
- : IS_NPM_INSTALL
87
- ? (event) => `claude-rpc hook ${event}`
88
- : (event) => `node "${HOOK_SCRIPT.replace(/\\/g, '/')}" ${event}`;
104
+ : (event) => `"${node}" "${HOOK_SCRIPT.replace(/\\/g, '/')}" ${event}`;
89
105
 
90
106
  for (const event of EVENTS) {
91
107
  const bucket = settings.hooks[event] = settings.hooks[event] || [];
92
108
  const wanted = cmdFor(event);
93
109
  const existingEntry = bucket.find((b) =>
94
- Array.isArray(b.hooks) && b.hooks.some((h) => isOurHookCommand(h.command))
110
+ Array.isArray(b.hooks) && b.hooks.some((h) => isOurHook(h))
95
111
  );
96
112
  if (existingEntry) {
97
113
  existingEntry.hooks = existingEntry.hooks.map((h) =>
98
- isOurHookCommand(h.command) ? { ...h, command: wanted } : h
114
+ isOurHook(h) ? { ...h, command: wanted, _claudeRpc: true } : h
99
115
  );
100
116
  } else {
101
- bucket.push({ matcher: '', hooks: [{ type: 'command', command: wanted }] });
117
+ bucket.push({ matcher: '', hooks: [{ type: 'command', command: wanted, _claudeRpc: true }] });
102
118
  }
103
119
  }
104
120
  if (JSON.stringify(settings.hooks) === before) {
@@ -117,7 +133,7 @@ export function uninstallHooks() {
117
133
  const bucket = settings.hooks[event];
118
134
  if (!Array.isArray(bucket)) continue;
119
135
  settings.hooks[event] = bucket
120
- .map((entry) => ({ ...entry, hooks: (entry.hooks || []).filter((h) => !isOurHookCommand(h.command)) }))
136
+ .map((entry) => ({ ...entry, hooks: (entry.hooks || []).filter((h) => !isOurHook(h)) }))
121
137
  .filter((entry) => (entry.hooks || []).length > 0);
122
138
  if (settings.hooks[event].length === 0) delete settings.hooks[event];
123
139
  }
@@ -421,7 +437,7 @@ export function migrateConfig({ silent = false } = {}) {
421
437
  // anyone who added a custom frame is left entirely alone.
422
438
  const frameId = (f) => (Array.isArray(f?.requires) && f.requires.length)
423
439
  ? 'r:' + [...f.requires].map(String).sort().join('|')
424
- : 't:' + (f?.details ?? '') + '' + (f?.state ?? '');
440
+ : 't:' + (f?.details ?? '') + '\x00' + (f?.state ?? '');
425
441
  const dflBy = DEFAULT_CONFIG.presence?.byStatus || {};
426
442
  const usrBy = cfg.presence.byStatus || {};
427
443
  let framesAdded = 0;
@@ -453,18 +469,12 @@ export function migrateConfig({ silent = false } = {}) {
453
469
  // command and report success, leaving the user to discover the breakage
454
470
  // the next time they open Claude Code. Returns { ok, detail }.
455
471
  function verifyHookPipe(exePath) {
456
- const cmd = IS_PACKAGED ? exePath
457
- : IS_NPM_INSTALL ? 'claude-rpc'
458
- : process.execPath;
459
- const args = IS_PACKAGED || IS_NPM_INSTALL
460
- ? ['hook', 'SessionStart']
461
- : [HOOK_SCRIPT, 'SessionStart'];
462
- // Windows + npm-install: the global bin is `claude-rpc.cmd` (a batch shim),
463
- // and Node's spawn doesn't apply PATHEXT — calling `claude-rpc` raw fails
464
- // with ENOENT. shell:true makes cmd.exe do the resolution, mirroring how
465
- // Claude Code actually invokes the hook string at runtime. Args are static
466
- // and trusted; no injection surface.
467
- const useShell = IS_NPM_INSTALL && process.platform === 'win32';
472
+ // Spawn exactly what installHooks wired: the packaged exe, or the absolute
473
+ // node + hook.js. Both are absolute, so there's no PATH/PATHEXT/shell
474
+ // resolution — it works under the same minimal hook shell Claude Code uses
475
+ // (the reason the old `claude-rpc` / bare-`node` forms failed under nvm).
476
+ const cmd = IS_PACKAGED ? exePath : process.execPath;
477
+ const args = IS_PACKAGED ? ['hook', 'SessionStart'] : [HOOK_SCRIPT, 'SessionStart'];
468
478
  let result;
469
479
  try {
470
480
  result = spawnSync(cmd, args, {
@@ -472,7 +482,6 @@ function verifyHookPipe(exePath) {
472
482
  encoding: 'utf8',
473
483
  timeout: 3000,
474
484
  windowsHide: true,
475
- shell: useShell,
476
485
  });
477
486
  } catch (e) {
478
487
  return { ok: false, detail: `spawn failed: ${e.message}` };
@@ -481,8 +490,14 @@ function verifyHookPipe(exePath) {
481
490
  if (result.status !== 0) {
482
491
  return { ok: false, detail: `hook exit ${result.status}: ${(result.stderr || '').trim().slice(0, 120)}` };
483
492
  }
484
- if (!result.stdout.includes('"continue"')) {
485
- return { ok: false, detail: `unexpected hook output: ${result.stdout.trim().slice(0, 120)}` };
493
+ // Parse the ack and assert the actual contract (a JSON object with
494
+ // continue:true) rather than substring-matching the bytes "continue" anywhere
495
+ // in stdout.
496
+ let ack;
497
+ try { ack = JSON.parse((result.stdout || '').trim()); }
498
+ catch { return { ok: false, detail: `non-JSON hook output: ${(result.stdout || '').trim().slice(0, 120)}` }; }
499
+ if (ack?.continue !== true) {
500
+ return { ok: false, detail: `hook ack missing continue:true: ${(result.stdout || '').trim().slice(0, 120)}` };
486
501
  }
487
502
  return { ok: true, detail: 'SessionStart round-trip succeeded' };
488
503
  }
@@ -29,7 +29,11 @@ export function isValidHandle(input) {
29
29
  // trim, bound the length. Returns null for empty input.
30
30
  export function cleanDisplayName(input, max = 40) {
31
31
  if (!input || typeof input !== 'string') return null;
32
- const name = input.replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, max);
32
+ // Strip control chars (C0/DEL/C1), zero-width, and bidi overrides so a name
33
+ // can't visually corrupt or spoof the shared board. Denylist by code point -
34
+ // CJK, emoji and accents still pass. Mirrored in the worker's cleanName.
35
+ const ok = (c) => !(c < 32 || (c >= 0x7f && c <= 0x9f) || (c >= 0x200b && c <= 0x200f) || (c >= 0x202a && c <= 0x202e) || (c >= 0x2066 && c <= 0x2069) || c === 0xfeff);
36
+ const name = [...input].filter((ch) => ok(ch.codePointAt(0))).join('').trim().slice(0, max);
33
37
  return name || null;
34
38
  }
35
39
 
package/src/mcp.js CHANGED
@@ -114,6 +114,9 @@ export function callTool(name, getAgg = readAggregate) {
114
114
  */
115
115
  export function runMcpServer({ input = process.stdin, output = process.stdout } = {}) {
116
116
  let buf = '';
117
+ const MAX_BUF = 4 * 1024 * 1024; // hard cap on a single un-terminated line
118
+ const DEFAULT_PROTOCOL = '2024-11-05';
119
+ const SUPPORTED_PROTOCOLS = new Set([DEFAULT_PROTOCOL, '2025-03-26', '2025-06-18']);
117
120
  const send = (msg) => output.write(JSON.stringify(msg) + '\n');
118
121
  const reply = (id, result) => send({ jsonrpc: '2.0', id, result });
119
122
  const replyErr = (id, code, message) => send({ jsonrpc: '2.0', id, error: { code, message } });
@@ -121,8 +124,12 @@ export function runMcpServer({ input = process.stdin, output = process.stdout }
121
124
  function handle(msg) {
122
125
  const { id, method, params } = msg;
123
126
  if (method === 'initialize') {
127
+ // Echo the client's protocol version when we recognize it; otherwise fall
128
+ // back to our baseline (our surface — initialize/tools/*/ping — is stable
129
+ // across these revisions).
130
+ const requested = params?.protocolVersion;
124
131
  return reply(id, {
125
- protocolVersion: '2024-11-05',
132
+ protocolVersion: SUPPORTED_PROTOCOLS.has(requested) ? requested : DEFAULT_PROTOCOL,
126
133
  capabilities: { tools: {} },
127
134
  serverInfo: { name: 'claude-rpc', version: VERSION },
128
135
  });
@@ -159,7 +166,14 @@ export function runMcpServer({ input = process.stdin, output = process.stdout }
159
166
  try { msg = JSON.parse(line); } catch { continue; }
160
167
  try { handle(msg); } catch (e) { process.stderr.write(`mcp handler error: ${e.message}\n`); }
161
168
  }
169
+ // A single line larger than the cap (no newline yet) would grow buf without
170
+ // bound — drop the partial and resync on the next newline.
171
+ if (buf.length > MAX_BUF) {
172
+ process.stderr.write(`mcp: dropping oversized line (>${MAX_BUF} bytes)\n`);
173
+ buf = '';
174
+ }
162
175
  });
163
176
  input.on('end', () => process.exit(0));
177
+ input.on('error', () => process.exit(0));
164
178
  process.stderr.write(`claude-rpc MCP server v${VERSION} ready (stdio)\n`);
165
179
  }
package/src/notify.js CHANGED
@@ -29,7 +29,7 @@ export function sanitizeLabel(s) {
29
29
  * @param {string} [body] - Notification body text.
30
30
  * @returns {boolean} true if a notifier was spawned, false on failure.
31
31
  */
32
- export function desktopNotify(title, body = '') {
32
+ export function desktopNotify(title, body = '', { spawn: spawnFn = spawn } = {}) {
33
33
  try {
34
34
  const p = platform();
35
35
  // A missing notifier binary (e.g. no `notify-send`) makes spawn emit an
@@ -43,15 +43,15 @@ export function desktopNotify(title, body = '') {
43
43
  };
44
44
  if (p === 'darwin') {
45
45
  const script = `display notification ${JSON.stringify(body)} with title ${JSON.stringify(title)}`;
46
- swallow(spawn('osascript', ['-e', script], { stdio: 'ignore', detached: true }));
46
+ swallow(spawnFn('osascript', ['-e', script], { stdio: 'ignore', detached: true }));
47
47
  } else if (p === 'win32') {
48
48
  const script = `Add-Type -AssemblyName System.Windows.Forms;`
49
49
  + `$n=New-Object System.Windows.Forms.NotifyIcon;`
50
50
  + `$n.Icon=[System.Drawing.SystemIcons]::Information;$n.Visible=$true;`
51
51
  + `$n.ShowBalloonTip(5000, ${JSON.stringify(title)}, ${JSON.stringify(body)}, 'Info')`;
52
- swallow(spawn('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], { stdio: 'ignore', detached: true, windowsHide: true }));
52
+ swallow(spawnFn('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], { stdio: 'ignore', detached: true, windowsHide: true }));
53
53
  } else {
54
- swallow(spawn('notify-send', ['-a', 'Claude Code', title, body], { stdio: 'ignore', detached: true }));
54
+ swallow(spawnFn('notify-send', ['-a', 'Claude Code', title, body], { stdio: 'ignore', detached: true }));
55
55
  }
56
56
  return true;
57
57
  } catch {