claude-rpc 0.3.11 → 0.6.0

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/src/daemon.js CHANGED
@@ -1,32 +1,71 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync, writeFileSync, existsSync, unlinkSync, watch, appendFileSync, mkdirSync } from 'node:fs';
2
+ import { writeFileSync, existsSync, unlinkSync, watch, appendFileSync, mkdirSync, statSync, renameSync } from 'node:fs';
3
3
  import { Client } from '@xhayper/discord-rpc';
4
4
  import { readState } from './state.js';
5
5
  import { buildVars, fillTemplate, framePasses, applyIdle } from './format.js';
6
6
  import { scan, readAggregate, findLiveSessions, readSessionTokens } from './scanner.js';
7
7
  import { detectGithubUrl } from './git.js';
8
+ import { applyPrivacy } from './privacy.js';
9
+ import { loadConfig } from './config.js';
8
10
  import { CONFIG_PATH, STATE_PATH, PID_PATH, LOG_PATH, STATE_DIR, AGGREGATE_PATH } from './paths.js';
9
11
 
10
12
  if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true });
11
13
 
14
+ // Daemon log capped at 5MB. Same policy events.jsonl uses (see hook.js).
15
+ // On rotation we move the existing log aside as `daemon.log.1` so the
16
+ // last rotation's content is still available for `claude-rpc tail`.
17
+ // One file's worth of history is enough — older logs have never been
18
+ // useful in practice, and the daemon runs for weeks.
19
+ const LOG_ROTATE_BYTES = 5 * 1024 * 1024;
20
+
21
+ function maybeRotateLog() {
22
+ try {
23
+ const st = statSync(LOG_PATH);
24
+ if (st.size <= LOG_ROTATE_BYTES) return;
25
+ renameSync(LOG_PATH, LOG_PATH + '.1');
26
+ } catch {
27
+ // No log file yet, or rename failed (another daemon is rotating
28
+ // simultaneously). Either case is safe to ignore — we'll just keep
29
+ // appending and try rotation again on the next write.
30
+ }
31
+ }
32
+
12
33
  function log(...args) {
13
34
  const line = `[${new Date().toISOString()}] ${args.map((a) => typeof a === 'string' ? a : JSON.stringify(a)).join(' ')}\n`;
14
- try { appendFileSync(LOG_PATH, line); } catch {}
35
+ maybeRotateLog();
36
+ try {
37
+ appendFileSync(LOG_PATH, line);
38
+ } catch {
39
+ // Disk full, permission denied, or LOG_PATH became invalid mid-run.
40
+ // The daemon must keep running regardless — Discord presence is more
41
+ // important than file logging.
42
+ }
15
43
  process.stdout.write(line);
16
44
  }
17
45
 
18
- function loadConfig() {
19
- try { return JSON.parse(readFileSync(CONFIG_PATH, 'utf8')); }
20
- catch (e) { log('Failed to read config.json:', e.message); process.exit(1); }
46
+ // Wrap loadConfig so a parse/IO failure logs once and the daemon keeps
47
+ // running on baked-in defaults. The Electron settings GUI saves the file
48
+ // atomically but mid-edit hand-edits used to brick the daemon this is
49
+ // the "no, just keep going with what we shipped" failsafe.
50
+ function loadConfigWithLog() {
51
+ return loadConfig({ onError: (msg) => log(msg) });
21
52
  }
22
53
 
23
- let config = loadConfig();
54
+ let config = loadConfigWithLog();
24
55
  let aggregate = readAggregate() || null;
25
56
  let liveSessions = [];
26
57
  let client = null;
27
58
  let connected = false;
28
59
  let lastPayloadHash = '';
29
60
  let reconnectTimer = null;
61
+ // Exponential backoff for Discord reconnect: 5s → 10s → 20s → … → 300s cap.
62
+ // Reset to RECONNECT_BASE_MS on a successful connect so the next outage
63
+ // also starts gentle. Jitter (±30%) keeps multiple daemons (e.g. a user
64
+ // running both packaged and dev simultaneously) from synchronizing
65
+ // reconnect storms against Discord's IPC socket.
66
+ const RECONNECT_BASE_MS = 5_000;
67
+ const RECONNECT_CAP_MS = 300_000;
68
+ let reconnectDelayMs = RECONNECT_BASE_MS;
30
69
  let rotationIndex = 0;
31
70
  let lastRotationAt = 0;
32
71
  // Stabilizes Discord's elapsed timer: applyIdle can synthesize a sessionStart
@@ -99,6 +138,12 @@ function buildActivity(opts = {}) {
99
138
  }
100
139
  }
101
140
 
141
+ // Apply privacy AFTER token resolution but BEFORE buildVars — so the
142
+ // template inputs ({project}, {currentFile}, etc.) already reflect the
143
+ // visibility decision. Sets state._privacy so we can short-circuit to
144
+ // clearActivity when visibility=hidden.
145
+ state = applyPrivacy(state, config);
146
+
102
147
  const vars = buildVars(state, config, opts.aggregate || aggregate);
103
148
  const p = config.presence || {};
104
149
 
@@ -127,9 +172,15 @@ function buildActivity(opts = {}) {
127
172
  if (frame.details) activity.details = fillTemplate(frame.details, vars).slice(0, 128);
128
173
  if (frame.state) activity.state = fillTemplate(frame.state, vars).slice(0, 128);
129
174
 
130
- // Image precedence: statusAssets[status] modelAssets[modelMatch] presence.largeImageKey.
131
- // statusAssets lets the user swap the big image based on what Claude is doing
132
- // (working/thinking/idle/stale/notification).
175
+ // ── Large-image precedence (single source of truth) ────────────────
176
+ // 1. statusAssets[status] "working" gif when working, etc.
177
+ // 2. modelAssets[opus|sonnet|haiku|default]
178
+ // per-model art (Opus/Sonnet/Haiku),
179
+ // only consulted when statusAssets
180
+ // doesn't match AND state isn't stale.
181
+ // 3. presence.largeImageKey global fallback.
182
+ // smallImageKey separately resolves to the `{statusIcon}` template var
183
+ // (set via config.statusIcons) and is dropped entirely when empty.
133
184
  let largeKeyTpl = p.largeImageKey;
134
185
  if (config.statusAssets && config.statusAssets[state.status]) {
135
186
  largeKeyTpl = config.statusAssets[state.status];
@@ -172,9 +223,12 @@ function buildActivity(opts = {}) {
172
223
  if (typeof config.activityType === 'number') activity.type = config.activityType;
173
224
 
174
225
  // Buttons: static configured set, optionally augmented with a per-project
175
- // GitHub button when the current cwd has a github origin.
226
+ // GitHub button when the current cwd has a github origin. Privacy mode
227
+ // suppresses the GitHub button entirely (else clicking it leaks the
228
+ // project name we're trying to hide).
229
+ const isPrivacyConstrained = state._privacy && state._privacy.visibility !== 'public';
176
230
  const buttons = Array.isArray(p.buttons) ? p.buttons.slice() : [];
177
- const gh = state.status !== 'stale' ? detectGithubUrl(state.cwd) : null;
231
+ const gh = (!isPrivacyConstrained && state.status !== 'stale') ? detectGithubUrl(state.cwd) : null;
178
232
  if (gh && !buttons.some((b) => /github\.com/i.test(b.url || ''))) {
179
233
  buttons.unshift({ label: 'View on GitHub →', url: gh });
180
234
  }
@@ -196,9 +250,14 @@ async function pushPresence() {
196
250
  let resolved = readState();
197
251
  resolved.liveSessions = liveSessions;
198
252
  resolved = applyIdle(resolved, config);
253
+ // Privacy can convert any state into a "hidden" verdict — give it the
254
+ // same treatment as hideWhenStale: a single clearActivity, deduped via
255
+ // lastPayloadHash so we don't spam the IPC.
256
+ resolved = applyPrivacy(resolved, config);
199
257
 
200
258
  const hideWhenStale = config.hideWhenStale !== false;
201
- if (resolved.status === 'stale' && hideWhenStale) {
259
+ const privacyHidden = resolved._privacy?.visibility === 'hidden';
260
+ if ((resolved.status === 'stale' && hideWhenStale) || privacyHidden) {
202
261
  const stamp = 'cleared';
203
262
  if (lastPayloadHash === stamp) return;
204
263
  lastPayloadHash = stamp;
@@ -206,7 +265,8 @@ async function pushPresence() {
206
265
  // elapsed timer rather than counting from a previous session.
207
266
  effectiveSessionStart = null;
208
267
  await client.user.clearActivity();
209
- log('Presence cleared (stale — Claude Code not running)');
268
+ const reason = privacyHidden ? 'privacy=hidden in this project' : 'stale — Claude Code not running';
269
+ log(`Presence cleared (${reason})`);
210
270
  return;
211
271
  }
212
272
 
@@ -227,26 +287,34 @@ async function connect() {
227
287
 
228
288
  client.on('ready', () => {
229
289
  connected = true;
290
+ // Reset backoff so the next outage also starts at RECONNECT_BASE_MS.
291
+ reconnectDelayMs = RECONNECT_BASE_MS;
230
292
  log('Discord RPC connected as', client.user?.username);
231
293
  lastPayloadHash = '';
232
294
  pushPresence();
233
295
  });
234
296
  client.on('disconnected', () => {
235
297
  connected = false;
236
- log('Discord disconnected — retrying in 10s');
237
- scheduleReconnect();
298
+ scheduleReconnect('Discord disconnected');
238
299
  });
239
300
  try { await client.login(); }
240
301
  catch (e) {
241
- log('Discord login failed:', e.message, '— retrying in 10s. Is Discord desktop running?');
242
- scheduleReconnect();
302
+ scheduleReconnect(`Discord login failed: ${e.message}`);
243
303
  }
244
304
  }
245
305
 
246
- function scheduleReconnect() {
306
+ function scheduleReconnect(reason = 'reconnect') {
247
307
  connected = false;
248
308
  if (reconnectTimer) return;
249
- reconnectTimer = setTimeout(() => { reconnectTimer = null; connect(); }, 10000);
309
+ // ±30% jitter on the current step. Cheap protection against
310
+ // synchronized reconnect storms from sibling daemons.
311
+ const jitter = 0.7 + Math.random() * 0.6;
312
+ const wait = Math.round(reconnectDelayMs * jitter);
313
+ log(`${reason} — retry in ${Math.round(wait / 1000)}s. Is Discord desktop running?`);
314
+ reconnectTimer = setTimeout(() => { reconnectTimer = null; connect(); }, wait);
315
+ // Step the base for the *next* failure. Cap at 5min so a long Discord
316
+ // outage doesn't push us into multi-hour silences.
317
+ reconnectDelayMs = Math.min(RECONNECT_CAP_MS, reconnectDelayMs * 2);
250
318
  }
251
319
 
252
320
  function watchFiles() {
@@ -259,8 +327,9 @@ function watchFiles() {
259
327
  }
260
328
  watch(CONFIG_PATH, () => {
261
329
  log('Config changed — reloading');
262
- try { config = loadConfig(); lastPayloadHash = ''; pushPresence(); }
263
- catch (e) { log('Reload failed:', e.message); }
330
+ config = loadConfigWithLog();
331
+ lastPayloadHash = '';
332
+ pushPresence();
264
333
  });
265
334
  if (existsSync(AGGREGATE_PATH)) {
266
335
  let aggTimer = null;
@@ -273,6 +342,44 @@ function watchFiles() {
273
342
  }, 250);
274
343
  });
275
344
  }
345
+
346
+ // Mtime-poll fallback. fs.watch on Windows occasionally drops events
347
+ // when the writer uses an atomic-rename pattern (which `state.js` does
348
+ // and the scanner does for aggregate.json). A 30s poll comparing
349
+ // last-seen mtime catches anything the watcher missed without making
350
+ // the watcher itself the bottleneck. No-op on Linux/macOS most of the
351
+ // time, but cheap enough to leave on everywhere.
352
+ let lastStateMtime = 0, lastAggMtime = 0;
353
+ setInterval(() => {
354
+ try {
355
+ if (existsSync(STATE_PATH)) {
356
+ const m = statSync(STATE_PATH).mtimeMs;
357
+ if (m > lastStateMtime) {
358
+ if (lastStateMtime !== 0) {
359
+ // The first observation is just the starting value; only
360
+ // log + push when we actually missed a watcher event.
361
+ log('state.json mtime advanced without a watcher event (poll fallback)');
362
+ pushPresence();
363
+ }
364
+ lastStateMtime = m;
365
+ }
366
+ }
367
+ if (existsSync(AGGREGATE_PATH)) {
368
+ const m = statSync(AGGREGATE_PATH).mtimeMs;
369
+ if (m > lastAggMtime) {
370
+ if (lastAggMtime !== 0) {
371
+ aggregate = readAggregate() || aggregate;
372
+ lastPayloadHash = '';
373
+ pushPresence();
374
+ }
375
+ lastAggMtime = m;
376
+ }
377
+ }
378
+ } catch {
379
+ // Stat fail mid-rotate of the watched file. The next tick will
380
+ // pick up the new mtime. Silent on purpose.
381
+ }
382
+ }, 30_000);
276
383
  }
277
384
 
278
385
  async function runBackgroundScan({ force = false } = {}) {
@@ -290,8 +397,11 @@ async function runBackgroundScan({ force = false } = {}) {
290
397
 
291
398
  function shutdown() {
292
399
  log('Shutting down…');
293
- try { client?.destroy(); } catch {}
294
- try { if (existsSync(PID_PATH)) unlinkSync(PID_PATH); } catch {}
400
+ // Both calls below are best-effort cleanup on the way out the door.
401
+ // If the IPC client is already half-dead or the PID file was removed
402
+ // by something else, we don't care — we're exiting anyway.
403
+ try { client?.destroy(); } catch { /* IPC already gone */ }
404
+ try { if (existsSync(PID_PATH)) unlinkSync(PID_PATH); } catch { /* race vs another shutdown */ }
295
405
  process.exit(0);
296
406
  }
297
407
 
package/src/doctor.js ADDED
@@ -0,0 +1,376 @@
1
+ // `claude-rpc doctor` — one-shot diagnostic.
2
+ //
3
+ // Checks every common failure path users have hit in support and prints a
4
+ // colored pass/fail/warn checklist with one-line fix hints. Self-contained:
5
+ // no Discord IPC connection (just a brief probe), no side effects, safe to
6
+ // run repeatedly. Exit code 0 when everything passes, 1 when any check fails.
7
+
8
+ import { existsSync, readFileSync, statSync, readdirSync } from 'node:fs';
9
+ import { join, basename } from 'node:path';
10
+ import {
11
+ IS_PACKAGED, IS_NPM_INSTALL, IS_INSTALLED,
12
+ CONFIG_PATH, CANONICAL_EXE, USER_CONFIG_DIR,
13
+ STATE_PATH, PID_PATH, LOG_PATH,
14
+ AGGREGATE_PATH, SCAN_CACHE_PATH,
15
+ CLAUDE_HOME, CLAUDE_PROJECTS, CLAUDE_SETTINGS,
16
+ } from './paths.js';
17
+ import { findLiveSessions } from './scanner.js';
18
+ import { resolveVisibility, listPrivateCwds } from './privacy.js';
19
+ import { c, check as uiCheck } from './ui.js';
20
+
21
+ const counters = { pass: 0, fail: 0, warn: 0 };
22
+
23
+ // Thin wrapper around the shared ui.check so we can keep counters local
24
+ // to this module without exporting a stateful version from ui.js.
25
+ function check(label, status, detail = '', hint = '') {
26
+ if (status === 'pass') counters.pass++;
27
+ else if (status === 'fail') counters.fail++;
28
+ else if (status === 'warn') counters.warn++;
29
+ uiCheck(label, status, detail, hint);
30
+ }
31
+
32
+ function section(title) {
33
+ console.log(`\n${c.bold}${title}${c.reset}`);
34
+ }
35
+
36
+ // ── individual checks ────────────────────────────────────────────────────
37
+
38
+ function checkNodeVersion() {
39
+ const major = Number((process.versions.node || '').split('.')[0] || 0);
40
+ if (major >= 18) {
41
+ check('Node.js version', 'pass', `${process.versions.node}`);
42
+ } else {
43
+ check('Node.js version', 'fail', `${process.versions.node} (need ≥18)`,
44
+ 'install a newer Node from https://nodejs.org');
45
+ }
46
+ }
47
+
48
+ function checkMode() {
49
+ let mode, detail;
50
+ if (IS_PACKAGED) {
51
+ mode = 'pass';
52
+ detail = `packaged exe at ${process.execPath}`;
53
+ } else if (IS_NPM_INSTALL) {
54
+ mode = 'pass';
55
+ detail = 'npm install (global or local node_modules)';
56
+ } else {
57
+ mode = 'pass';
58
+ detail = 'dev source (cloned repo, no node_modules wrapper)';
59
+ }
60
+ check('execution mode', mode, detail);
61
+ }
62
+
63
+ function checkConfig() {
64
+ if (!existsSync(CONFIG_PATH)) {
65
+ check('config.json present', 'fail', CONFIG_PATH,
66
+ 'run `claude-rpc setup` to seed a default config');
67
+ return null;
68
+ }
69
+ let cfg;
70
+ try {
71
+ cfg = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
72
+ } catch (e) {
73
+ check('config.json present', 'fail', `parse error: ${e.message}`,
74
+ `open ${CONFIG_PATH} and fix the JSON syntax (or delete it and re-run setup)`);
75
+ return null;
76
+ }
77
+ check('config.json present', 'pass', CONFIG_PATH);
78
+
79
+ if (!cfg.clientId || cfg.clientId === '1234567890123456789') {
80
+ check('discord clientId set', 'fail', cfg.clientId || '(empty)',
81
+ `paste your discord application ID into ${CONFIG_PATH}`);
82
+ } else if (!/^\d{17,21}$/.test(String(cfg.clientId))) {
83
+ check('discord clientId set', 'warn', `${cfg.clientId} doesn't look like a snowflake`,
84
+ 'discord application IDs are 17–21 digits');
85
+ } else {
86
+ check('discord clientId set', 'pass', String(cfg.clientId));
87
+ }
88
+
89
+ const hasByStatus = !!cfg.presence?.byStatus;
90
+ const hasRotation = Array.isArray(cfg.presence?.rotation) && cfg.presence.rotation.length > 0;
91
+ if (hasByStatus) {
92
+ check('presence schema', 'pass', 'byStatus block present (v0.3.6+ shape)');
93
+ } else if (hasRotation) {
94
+ check('presence schema', 'warn', 'legacy rotation only — no byStatus block',
95
+ 'run `claude-rpc setup` again to migrate config into the byStatus shape');
96
+ } else {
97
+ check('presence schema', 'warn', 'no presence templates configured',
98
+ 'either rotation or byStatus is needed for the card to render');
99
+ }
100
+ return cfg;
101
+ }
102
+
103
+ function checkClaudeHome() {
104
+ if (!existsSync(CLAUDE_HOME)) {
105
+ check('~/.claude exists', 'fail', CLAUDE_HOME,
106
+ 'install claude code first — https://claude.com/claude-code');
107
+ return false;
108
+ }
109
+ check('~/.claude exists', 'pass', CLAUDE_HOME);
110
+ return true;
111
+ }
112
+
113
+ function checkClaudeProjects() {
114
+ if (!existsSync(CLAUDE_PROJECTS)) {
115
+ check('~/.claude/projects exists', 'warn', 'no transcripts on disk yet',
116
+ 'open claude code and prompt once — the directory is created lazily');
117
+ return 0;
118
+ }
119
+ let count = 0;
120
+ try {
121
+ for (const proj of readdirSync(CLAUDE_PROJECTS)) {
122
+ for (const f of readdirSync(join(CLAUDE_PROJECTS, proj))) {
123
+ if (f.endsWith('.jsonl')) count++;
124
+ }
125
+ }
126
+ } catch { /* unreadable subdir — just report whatever we counted so far */ }
127
+ check('claude transcripts visible', count > 0 ? 'pass' : 'warn',
128
+ `${count} .jsonl ${count === 1 ? 'file' : 'files'}`,
129
+ count === 0 ? 'open claude code and send a prompt — transcripts appear immediately' : '');
130
+ return count;
131
+ }
132
+
133
+ const HOOK_EVENTS = [
134
+ 'SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse',
135
+ 'Stop', 'SubagentStop', 'Notification', 'SessionEnd',
136
+ ];
137
+
138
+ function isOurHookCommand(cmd) {
139
+ if (!cmd) return false;
140
+ return /claude-rpc/i.test(cmd) || /hook\.js/i.test(cmd);
141
+ }
142
+
143
+ function checkHooks() {
144
+ if (!existsSync(CLAUDE_SETTINGS)) {
145
+ check('hooks registered', 'fail', `${CLAUDE_SETTINGS} missing`,
146
+ 'run `claude-rpc setup` to register hooks');
147
+ return;
148
+ }
149
+ let settings;
150
+ try {
151
+ settings = JSON.parse(readFileSync(CLAUDE_SETTINGS, 'utf8'));
152
+ } catch (e) {
153
+ check('hooks registered', 'fail', `parse error: ${e.message}`,
154
+ `open ${CLAUDE_SETTINGS} and fix the JSON syntax`);
155
+ return;
156
+ }
157
+ const missing = [];
158
+ const stale = [];
159
+ for (const event of HOOK_EVENTS) {
160
+ const bucket = settings.hooks?.[event];
161
+ if (!Array.isArray(bucket) || bucket.length === 0) { missing.push(event); continue; }
162
+ const ours = bucket.flatMap((e) => e.hooks || []).find((h) => isOurHookCommand(h.command));
163
+ if (!ours) { missing.push(event); continue; }
164
+ if (IS_PACKAGED && !ours.command.includes(CANONICAL_EXE)) stale.push({ event, cmd: ours.command });
165
+ if (IS_NPM_INSTALL && !/\bclaude-rpc\b\s+hook\b/.test(ours.command)) stale.push({ event, cmd: ours.command });
166
+ }
167
+ if (missing.length === 0 && stale.length === 0) {
168
+ check(`hooks registered (${HOOK_EVENTS.length}/${HOOK_EVENTS.length})`, 'pass',
169
+ 'all events wired against the current binary');
170
+ } else if (missing.length === HOOK_EVENTS.length) {
171
+ check('hooks registered', 'fail', 'no claude-rpc hooks found',
172
+ 'run `claude-rpc setup` to register hooks');
173
+ } else if (missing.length > 0) {
174
+ check('hooks registered', 'warn', `missing: ${missing.join(', ')}`,
175
+ 'run `claude-rpc setup` to add the missing events');
176
+ } else if (stale.length > 0) {
177
+ check('hooks registered', 'warn',
178
+ `${stale.length} pointing at an old binary path`,
179
+ 'run `claude-rpc setup` to refresh hook commands against the current binary');
180
+ }
181
+ }
182
+
183
+ function checkCanonicalExe() {
184
+ if (!IS_PACKAGED) {
185
+ check('canonical exe', 'pass', `not applicable (${IS_NPM_INSTALL ? 'npm' : 'dev'} mode)`);
186
+ return;
187
+ }
188
+ if (existsSync(CANONICAL_EXE)) {
189
+ let size = '';
190
+ try { size = `${(statSync(CANONICAL_EXE).size / 1024 / 1024).toFixed(1)} MB`; } catch { /* stat failed, size stays blank */ }
191
+ check('canonical exe installed', 'pass', `${CANONICAL_EXE} (${size})`);
192
+ } else {
193
+ check('canonical exe installed', 'fail', `missing: ${CANONICAL_EXE}`,
194
+ 'run `claude-rpc setup` to copy this binary to the canonical location');
195
+ }
196
+ }
197
+
198
+ function isAlive(pid) {
199
+ try { process.kill(pid, 0); return true; } catch { return false; }
200
+ }
201
+
202
+ function checkDaemon() {
203
+ if (!existsSync(PID_PATH)) {
204
+ check('daemon running', 'warn', 'no pid file',
205
+ 'run `claude-rpc start` to launch the daemon');
206
+ return false;
207
+ }
208
+ const pid = Number(readFileSync(PID_PATH, 'utf8'));
209
+ if (!pid || !isAlive(pid)) {
210
+ check('daemon running', 'fail', `stale pid file (${pid})`,
211
+ 'run `claude-rpc start` — old daemon died without cleaning up');
212
+ return false;
213
+ }
214
+ check('daemon running', 'pass', `pid ${pid}`);
215
+ return true;
216
+ }
217
+
218
+ function checkDaemonLog() {
219
+ if (!existsSync(LOG_PATH)) {
220
+ check('daemon log', 'warn', 'no log file yet',
221
+ 'daemon will create this on first start');
222
+ return;
223
+ }
224
+ let st;
225
+ try { st = statSync(LOG_PATH); } catch {
226
+ check('daemon log', 'warn', 'unreadable');
227
+ return;
228
+ }
229
+ const ageMin = (Date.now() - st.mtimeMs) / 60_000;
230
+ const sizeKB = (st.size / 1024).toFixed(1);
231
+ // Look for "Discord RPC connected" in the tail to confirm Discord IPC.
232
+ let connected = false;
233
+ try {
234
+ const tail = readFileSync(LOG_PATH, 'utf8').split('\n').slice(-50).join('\n');
235
+ connected = /Discord RPC connected/i.test(tail);
236
+ } catch { /* log unreadable — connected stays false, warn check renders */ }
237
+ if (connected) {
238
+ check('discord IPC connection', 'pass',
239
+ `${sizeKB} KB log · last write ${ageMin.toFixed(1)} min ago`);
240
+ } else {
241
+ check('discord IPC connection', 'warn',
242
+ `log shows no recent "connected" line`,
243
+ 'is the discord desktop client running? rpc only works via desktop, not browser');
244
+ }
245
+ }
246
+
247
+ function checkState() {
248
+ if (!existsSync(STATE_PATH)) {
249
+ check('state.json', 'warn', 'not present', 'created by the first hook event');
250
+ return;
251
+ }
252
+ let state;
253
+ try { state = JSON.parse(readFileSync(STATE_PATH, 'utf8')); }
254
+ catch (e) {
255
+ check('state.json', 'fail', `parse error: ${e.message}`,
256
+ `delete ${STATE_PATH} and let the next hook event recreate it`);
257
+ return;
258
+ }
259
+ const ageMin = state.lastActivity
260
+ ? (Date.now() - state.lastActivity) / 60_000
261
+ : Infinity;
262
+ const ageLabel = ageMin === Infinity ? 'never' : `${ageMin.toFixed(1)} min ago`;
263
+ check('state.json fresh', 'pass', `status=${state.status} · last activity ${ageLabel}`);
264
+ }
265
+
266
+ function checkAggregate() {
267
+ if (!existsSync(AGGREGATE_PATH)) {
268
+ check('aggregate built', 'warn', 'never scanned',
269
+ 'run `claude-rpc scan` to build lifetime stats from your transcripts');
270
+ return;
271
+ }
272
+ try {
273
+ const agg = JSON.parse(readFileSync(AGGREGATE_PATH, 'utf8'));
274
+ const hours = ((agg.activeMs || 0) / 3_600_000).toFixed(1);
275
+ const ageMin = (Date.now() - statSync(AGGREGATE_PATH).mtimeMs) / 60_000;
276
+ check('aggregate built', 'pass',
277
+ `${agg.sessions || 0} sessions · ${hours}h · refreshed ${ageMin.toFixed(0)} min ago`);
278
+ } catch (e) {
279
+ check('aggregate built', 'fail', `parse error: ${e.message}`,
280
+ 'run `claude-rpc rescan` to rebuild the aggregate from scratch');
281
+ }
282
+ }
283
+
284
+ function checkPrivacy(cfg) {
285
+ try {
286
+ const cwd = process.cwd();
287
+ const { visibility, projectName, reason } = resolveVisibility(cwd, cfg || {});
288
+ const listed = listPrivateCwds();
289
+ const detail = projectName
290
+ ? `${visibility} · alias=${projectName} · ${reason}`
291
+ : `${visibility} · ${reason}`;
292
+ const status = visibility === 'hidden' ? 'warn'
293
+ : visibility === 'name-only' ? 'warn'
294
+ : 'pass';
295
+ check('current directory visibility', status, detail);
296
+ if (listed.length) {
297
+ check('private-list entries', 'pass', `${listed.length} ${listed.length === 1 ? 'path' : 'paths'} marked private`);
298
+ } else {
299
+ check('private-list entries', 'pass', 'none');
300
+ }
301
+ } catch (e) {
302
+ check('privacy check', 'warn', `lookup failed: ${e.message}`);
303
+ }
304
+ }
305
+
306
+ function checkLiveSessions() {
307
+ try {
308
+ const live = findLiveSessions({ thresholdMs: 90_000 });
309
+ if (live.length === 0) {
310
+ check('live sessions', 'pass',
311
+ 'none — claude code isn\'t actively writing a transcript right now');
312
+ } else {
313
+ const names = live.slice(0, 3).map((s) => `${s.project}(${s.ageSec}s)`).join(', ');
314
+ check('live sessions', 'pass', `${live.length} active: ${names}`);
315
+ }
316
+ } catch (e) {
317
+ check('live sessions', 'warn', `lookup failed: ${e.message}`);
318
+ }
319
+ }
320
+
321
+ function checkDataDir() {
322
+ if (!existsSync(USER_CONFIG_DIR)) {
323
+ check('user config dir', 'warn', `${USER_CONFIG_DIR} missing`,
324
+ 'run `claude-rpc setup` — this is created automatically');
325
+ return;
326
+ }
327
+ check('user config dir', 'pass', USER_CONFIG_DIR);
328
+ }
329
+
330
+ // ── public entry point ──────────────────────────────────────────────────
331
+
332
+ export function runDoctor() {
333
+ console.log(`${c.bold}${c.cyan}claude-rpc doctor${c.reset} ${c.dim}— diagnostic checklist${c.reset}`);
334
+
335
+ section('Runtime');
336
+ checkNodeVersion();
337
+ checkMode();
338
+ checkCanonicalExe();
339
+
340
+ section('Config');
341
+ checkDataDir();
342
+ const cfg = checkConfig();
343
+
344
+ section('Claude Code');
345
+ if (checkClaudeHome()) {
346
+ checkClaudeProjects();
347
+ checkHooks();
348
+ }
349
+
350
+ section('Daemon');
351
+ checkDaemon();
352
+ checkDaemonLog();
353
+ checkState();
354
+
355
+ section('Data');
356
+ checkAggregate();
357
+ checkLiveSessions();
358
+
359
+ section('Privacy');
360
+ checkPrivacy(cfg);
361
+
362
+ // Summary
363
+ const { pass, fail, warn } = counters;
364
+ console.log('');
365
+ console.log(` ${c.bold}Summary:${c.reset} ${c.green}${pass} pass${c.reset}` +
366
+ (warn ? ` ${c.yellow}${warn} warn${c.reset}` : '') +
367
+ (fail ? ` ${c.red}${fail} fail${c.reset}` : ''));
368
+ if (fail === 0 && warn === 0) {
369
+ console.log(` ${c.dim}everything looks good. if presence still isn't showing, restart discord.${c.reset}`);
370
+ } else if (fail === 0) {
371
+ console.log(` ${c.dim}no failures — warnings are usually fixed by running \`claude-rpc setup\`.${c.reset}`);
372
+ }
373
+ console.log('');
374
+
375
+ return fail === 0 ? 0 : 1;
376
+ }
package/src/git.js CHANGED
@@ -48,14 +48,14 @@ function readGitInfo(cwd) {
48
48
  if (leaf) out.repo = leaf;
49
49
  }
50
50
  }
51
- } catch {}
51
+ } catch { /* missing/unreadable .git/config — out.repo stays at cwd basename */ }
52
52
 
53
53
  // HEAD → branch (or empty when detached).
54
54
  try {
55
55
  const head = readFileSync(join(gitDir, 'HEAD'), 'utf8').trim();
56
56
  const ref = head.match(/^ref:\s+refs\/heads\/(.+)$/);
57
57
  if (ref) out.branch = ref[1].trim();
58
- } catch {}
58
+ } catch { /* missing/unreadable HEAD — leave branch blank, template will hide */ }
59
59
 
60
60
  return out;
61
61
  }
package/src/hook.js CHANGED
@@ -16,7 +16,7 @@ function appendEvent(entry) {
16
16
  }
17
17
  }
18
18
  appendFileSync(EVENTS_LOG_PATH, JSON.stringify(entry) + '\n');
19
- } catch {}
19
+ } catch { /* best-effort log: hooks must never fail because of an unwritable events.jsonl */ }
20
20
  }
21
21
 
22
22
  function readStdin() {