claude-rpc 0.7.4 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/daemon.js +69 -0
- package/src/default-config.js +7 -1
- package/src/format.js +20 -12
- package/src/install.js +23 -5
- package/src/version.js +1 -1
package/package.json
CHANGED
package/src/daemon.js
CHANGED
|
@@ -7,6 +7,7 @@ import { scan, readAggregate, findLiveSessions, readSessionTokens } from './scan
|
|
|
7
7
|
import { detectGithubUrl } from './git.js';
|
|
8
8
|
import { applyPrivacy } from './privacy.js';
|
|
9
9
|
import { loadConfig } from './config.js';
|
|
10
|
+
import { migrateConfig } from './install.js';
|
|
10
11
|
import { CONFIG_PATH, STATE_PATH, PID_PATH, LOG_PATH, STATE_DIR, AGGREGATE_PATH } from './paths.js';
|
|
11
12
|
|
|
12
13
|
if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true });
|
|
@@ -51,6 +52,20 @@ function loadConfigWithLog() {
|
|
|
51
52
|
return loadConfig({ onError: (msg) => log(msg) });
|
|
52
53
|
}
|
|
53
54
|
|
|
55
|
+
// Bring an existing config.json up to date with the current defaults before
|
|
56
|
+
// we load it. This is how upgrades reach users who just `npm update` + restart
|
|
57
|
+
// the daemon without re-running `claude-rpc setup` — e.g. the v0.8.1 button-URL
|
|
58
|
+
// move. Idempotent: only writes when something actually changes, so steady-state
|
|
59
|
+
// restarts are a no-op and can't loop the config watcher. Best-effort — a
|
|
60
|
+
// migration failure must never stop the daemon from starting.
|
|
61
|
+
try {
|
|
62
|
+
if (migrateConfig({ silent: true })) {
|
|
63
|
+
log('config.json migrated to current defaults on startup');
|
|
64
|
+
}
|
|
65
|
+
} catch (e) {
|
|
66
|
+
log('startup config migration failed (continuing):', e?.message || String(e));
|
|
67
|
+
}
|
|
68
|
+
|
|
54
69
|
let config = loadConfigWithLog();
|
|
55
70
|
let aggregate = readAggregate() || null;
|
|
56
71
|
let liveSessions = [];
|
|
@@ -282,9 +297,33 @@ async function pushPresence() {
|
|
|
282
297
|
log('Presence updated:', activity.details || '-', '|', activity.state || '-');
|
|
283
298
|
} catch (e) {
|
|
284
299
|
log('setActivity failed:', e.message, '|', e.stack?.split('\n').slice(0, 3).join(' | '));
|
|
300
|
+
// A failed setActivity usually means the IPC pipe died WITHOUT the client
|
|
301
|
+
// emitting a 'disconnected' event (Discord restart, socket reset, OS
|
|
302
|
+
// sleep). Left alone, `connected` stays true and the daemon goes silently
|
|
303
|
+
// dark forever. Tear the client down and force a backoff reconnect so we
|
|
304
|
+
// self-heal. Guarded to connection-shaped errors so a one-off API hiccup
|
|
305
|
+
// doesn't needlessly bounce a healthy socket.
|
|
306
|
+
if (isConnectionError(e)) {
|
|
307
|
+
log('setActivity error looks connection-level — forcing reconnect');
|
|
308
|
+
connected = false;
|
|
309
|
+
lastPayloadHash = '';
|
|
310
|
+
try { client?.destroy(); } catch { /* already gone */ }
|
|
311
|
+
scheduleReconnect('setActivity failed');
|
|
312
|
+
}
|
|
285
313
|
}
|
|
286
314
|
}
|
|
287
315
|
|
|
316
|
+
// Heuristic: does this error indicate the IPC transport itself is dead
|
|
317
|
+
// (vs. a transient/application-level failure)? Matches the common broken-pipe
|
|
318
|
+
// / closed-socket shapes from @xhayper/discord-rpc and the underlying net
|
|
319
|
+
// socket so we only force a reconnect when the connection is actually gone.
|
|
320
|
+
function isConnectionError(e) {
|
|
321
|
+
const code = (e && e.code) || '';
|
|
322
|
+
if (['EPIPE', 'ECONNRESET', 'ENOENT', 'ECONNREFUSED', 'ERR_STREAM_WRITE_AFTER_END'].includes(code)) return true;
|
|
323
|
+
const m = String((e && e.message) || '').toLowerCase();
|
|
324
|
+
return /closed|reset|broken pipe|not connected|disconnect|write after end|socket|econnreset|epipe|connection/.test(m);
|
|
325
|
+
}
|
|
326
|
+
|
|
288
327
|
async function connect() {
|
|
289
328
|
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
|
290
329
|
client = new Client({ clientId: config.clientId, transport: { type: 'ipc' } });
|
|
@@ -454,6 +493,36 @@ function refreshLiveSessions() {
|
|
|
454
493
|
refreshLiveSessions();
|
|
455
494
|
setInterval(refreshLiveSessions, 30_000);
|
|
456
495
|
|
|
496
|
+
// ── Connection watchdog (auto-heal) ──────────────────────────────────────────
|
|
497
|
+
// The reconnect path is event-driven (the 'disconnected' handler + login
|
|
498
|
+
// failures + setActivity errors). But a connection can rot in ways that emit
|
|
499
|
+
// no event at all: a half-open client where `connected` is still true but the
|
|
500
|
+
// user handle is gone, or a state where we're down with no retry in flight.
|
|
501
|
+
// This periodic check guarantees the daemon always converges back to a live
|
|
502
|
+
// connection instead of silently staying dark — the single most common
|
|
503
|
+
// "it just stopped showing up" failure users hit.
|
|
504
|
+
const HEALTH_CHECK_MS = 30_000;
|
|
505
|
+
setInterval(() => {
|
|
506
|
+
try {
|
|
507
|
+
// Half-open: flag says connected but there's no usable user handle.
|
|
508
|
+
if (connected && !client?.user) {
|
|
509
|
+
log('Watchdog: connected but no user handle — forcing reconnect');
|
|
510
|
+
connected = false;
|
|
511
|
+
try { client?.destroy(); } catch { /* already gone */ }
|
|
512
|
+
scheduleReconnect('watchdog: half-open');
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
// Down with nothing scheduled to bring us back. scheduleReconnect is a
|
|
516
|
+
// no-op when a timer is already pending, so this can't stack retries.
|
|
517
|
+
if (!connected && !reconnectTimer) {
|
|
518
|
+
log('Watchdog: disconnected with no reconnect pending — forcing reconnect');
|
|
519
|
+
scheduleReconnect('watchdog: no retry pending');
|
|
520
|
+
}
|
|
521
|
+
} catch (e) {
|
|
522
|
+
log('Watchdog tick failed:', e.message);
|
|
523
|
+
}
|
|
524
|
+
}, HEALTH_CHECK_MS);
|
|
525
|
+
|
|
457
526
|
// Community-totals flush. Disabled by default; turns on via
|
|
458
527
|
// `claude-rpc community on`. Best-effort — flushCommunity swallows every
|
|
459
528
|
// failure mode, so a flaky endpoint or no network just means the deltas
|
package/src/default-config.js
CHANGED
|
@@ -21,6 +21,12 @@ export const DEFAULT_CONFIG = {
|
|
|
21
21
|
// when Claude isn't open, the Discord presence should disappear quickly.
|
|
22
22
|
// The SessionEnd hook short-circuits this — see hook.js + format.applyIdle.
|
|
23
23
|
staleSessionMin: 5,
|
|
24
|
+
// When the Claude Code session is still open but its transcript has gone
|
|
25
|
+
// quiet (you paused, stepped away briefly), show 'idle' rather than
|
|
26
|
+
// clearing the card. Only an authoritative SessionEnd or the full
|
|
27
|
+
// staleSessionMin dormancy window drops to stale. Set false to restore the
|
|
28
|
+
// old behavior (clear ~90-120s after the last transcript write).
|
|
29
|
+
idleWhenOpen: true,
|
|
24
30
|
// When true, the daemon CLEARS Discord activity entirely once the state
|
|
25
31
|
// goes stale — your profile shows nothing instead of an "Away" frame.
|
|
26
32
|
hideWhenStale: true,
|
|
@@ -119,7 +125,7 @@ export const DEFAULT_CONFIG = {
|
|
|
119
125
|
},
|
|
120
126
|
|
|
121
127
|
buttons: [
|
|
122
|
-
{ label: "Claude Code", url: "https://
|
|
128
|
+
{ label: "Claude Code", url: "https://github.com/rar-file/claude-rpc" },
|
|
123
129
|
],
|
|
124
130
|
},
|
|
125
131
|
statusIcons: {
|
package/src/format.js
CHANGED
|
@@ -682,6 +682,12 @@ export function applyIdle(state, cfg = {}) {
|
|
|
682
682
|
const idleMs = (cfg.idleThresholdSec || 60) * 1000;
|
|
683
683
|
const staleMs = Math.max(60_000, (cfg.staleSessionMin || 5) * 60 * 1000);
|
|
684
684
|
const notificationMs = (cfg.notificationWindowSec || 8) * 1000;
|
|
685
|
+
// When the Claude Code session is still open (no authoritative SessionEnd)
|
|
686
|
+
// but its transcript has gone quiet, prefer 'idle' over clearing the card.
|
|
687
|
+
// Only an explicit close (claudeClosed) or the staleMs dormancy backstop
|
|
688
|
+
// drops us to stale. Default on; set idleWhenOpen:false to restore the old
|
|
689
|
+
// aggressive ~90-120s clear when no transcript is being written.
|
|
690
|
+
const idleWhenOpen = cfg.idleWhenOpen !== false;
|
|
685
691
|
|
|
686
692
|
// Authoritative close signal from the SessionEnd hook — trust it instead
|
|
687
693
|
// of waiting on staleSessionMin. Any other hook clears the flag, so a
|
|
@@ -730,24 +736,26 @@ export function applyIdle(state, cfg = {}) {
|
|
|
730
736
|
|
|
731
737
|
// Local state is fresh.
|
|
732
738
|
if (state.status === 'idle') {
|
|
733
|
-
//
|
|
734
|
-
//
|
|
735
|
-
// (
|
|
736
|
-
//
|
|
737
|
-
//
|
|
738
|
-
//
|
|
739
|
-
//
|
|
740
|
-
|
|
739
|
+
// No transcripts being written anywhere on disk — Claude Code may have
|
|
740
|
+
// closed without a SessionEnd hook (force-quit, OS sleep, crash). With
|
|
741
|
+
// idleWhenOpen (default) we keep showing 'idle': the session hasn't been
|
|
742
|
+
// authoritatively closed and the staleMs dormancy backstop above will
|
|
743
|
+
// still clear it if Claude is truly gone. Set idleWhenOpen:false to go
|
|
744
|
+
// straight to stale here — clears Discord ~90-120s after the last write,
|
|
745
|
+
// at the cost of dropping the card whenever you pause for a couple
|
|
746
|
+
// minutes with the session still open.
|
|
747
|
+
if (liveSessions.length === 0 && !idleWhenOpen) return staleWipe(state);
|
|
741
748
|
return state;
|
|
742
749
|
}
|
|
743
750
|
if (ageMs > idleMs) {
|
|
744
751
|
// Hook channel is quiet, but a live transcript was modified recently?
|
|
745
752
|
// Keep "working" instead of dropping to "idle".
|
|
746
753
|
if (liveAgeMs <= idleMs) return state;
|
|
747
|
-
// Hooks quiet AND no live transcripts
|
|
748
|
-
//
|
|
749
|
-
//
|
|
750
|
-
|
|
754
|
+
// Hooks quiet AND no live transcripts. With idleWhenOpen (default) the
|
|
755
|
+
// session is treated as open-but-paused and drops to idle; the staleMs
|
|
756
|
+
// backstop above clears it if Claude is actually gone. Set
|
|
757
|
+
// idleWhenOpen:false to go straight to stale here (old behavior).
|
|
758
|
+
if (liveSessions.length === 0 && !idleWhenOpen) return staleWipe(state);
|
|
751
759
|
// Going idle — wipe "current activity" indicators so rotation frames
|
|
752
760
|
// gated on filesEdited / currentFile / currentTool stop showing stale
|
|
753
761
|
// active-session data. Keep the session counters (messages/tools/tokens)
|
package/src/install.js
CHANGED
|
@@ -235,12 +235,12 @@ export function seedConfig() {
|
|
|
235
235
|
// without clobbering the user's customizations. Anything the user already
|
|
236
236
|
// has — including a pre-existing byStatus, custom rotation array, custom
|
|
237
237
|
// appName etc. — is left untouched.
|
|
238
|
-
export function migrateConfig() {
|
|
238
|
+
export function migrateConfig({ silent = false } = {}) {
|
|
239
239
|
if (!existsSync(CONFIG_PATH)) return false;
|
|
240
240
|
let cfg;
|
|
241
241
|
try { cfg = JSON.parse(readFileSync(CONFIG_PATH, 'utf8')); }
|
|
242
242
|
catch (e) {
|
|
243
|
-
console.warn(` ! could not read config for migration: ${e.message}`);
|
|
243
|
+
if (!silent) console.warn(` ! could not read config for migration: ${e.message}`);
|
|
244
244
|
return false;
|
|
245
245
|
}
|
|
246
246
|
if (!cfg || typeof cfg !== 'object') return false;
|
|
@@ -300,13 +300,31 @@ export function migrateConfig() {
|
|
|
300
300
|
added.push('community (preserved-off)');
|
|
301
301
|
}
|
|
302
302
|
|
|
303
|
+
// v0.8.1: the default presence button moved from the Claude Code website
|
|
304
|
+
// to the project repo. Existing configs carry their own `buttons` array,
|
|
305
|
+
// which fully REPLACES the default (arrays don't deep-merge) — so the new
|
|
306
|
+
// default never reaches upgraders just by bumping the package. Rewrite
|
|
307
|
+
// ONLY a button still pointing at the verbatim old default URL; anything a
|
|
308
|
+
// user has customized (label or url) is left untouched.
|
|
309
|
+
const OLD_BTN_URL = 'https://claude.com/claude-code';
|
|
310
|
+
const NEW_BTN_URL = DEFAULT_CONFIG.presence?.buttons?.[0]?.url;
|
|
311
|
+
if (NEW_BTN_URL && Array.isArray(cfg.presence?.buttons)) {
|
|
312
|
+
let changed = false;
|
|
313
|
+
for (const b of cfg.presence.buttons) {
|
|
314
|
+
if (b && b.url === OLD_BTN_URL) { b.url = NEW_BTN_URL; changed = true; }
|
|
315
|
+
}
|
|
316
|
+
if (changed) added.push('presence.buttons[].url → repo');
|
|
317
|
+
}
|
|
318
|
+
|
|
303
319
|
if (added.length === 0) {
|
|
304
|
-
console.log(` config up to date → ${CONFIG_PATH}`);
|
|
320
|
+
if (!silent) console.log(` config up to date → ${CONFIG_PATH}`);
|
|
305
321
|
return false;
|
|
306
322
|
}
|
|
307
323
|
writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2));
|
|
308
|
-
|
|
309
|
-
|
|
324
|
+
if (!silent) {
|
|
325
|
+
console.log(` config migrated → ${CONFIG_PATH}`);
|
|
326
|
+
console.log(` added: ${added.join(', ')}`);
|
|
327
|
+
}
|
|
310
328
|
return true;
|
|
311
329
|
}
|
|
312
330
|
|