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/README.md +96 -136
- package/config.example.json +2 -65
- package/package.json +3 -2
- package/src/badge.js +38 -10
- package/src/card.js +345 -0
- package/src/cli.js +239 -12
- package/src/config.js +89 -0
- package/src/daemon.js +133 -23
- package/src/doctor.js +376 -0
- package/src/git.js +2 -2
- package/src/hook.js +1 -1
- package/src/install.js +51 -5
- package/src/pricing.js +29 -6
- package/src/privacy.js +231 -0
- package/src/scanner.js +62 -7
- package/src/server/api.js +175 -0
- package/src/server/index.js +98 -0
- package/src/{server.js → server/page.js} +58 -327
- package/src/server/routes.js +63 -0
- package/src/server/sse.js +32 -0
- package/src/tui.js +6 -7
- package/src/ui.js +89 -0
- package/src/version.js +26 -0
package/src/daemon.js
CHANGED
|
@@ -1,32 +1,71 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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 =
|
|
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
|
-
//
|
|
131
|
-
// statusAssets
|
|
132
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
237
|
-
scheduleReconnect();
|
|
298
|
+
scheduleReconnect('Discord disconnected');
|
|
238
299
|
});
|
|
239
300
|
try { await client.login(); }
|
|
240
301
|
catch (e) {
|
|
241
|
-
|
|
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
|
-
|
|
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
|
-
|
|
263
|
-
|
|
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
|
-
|
|
294
|
-
|
|
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