claude-rpc 0.7.4 → 0.8.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/package.json +1 -1
- package/src/daemon.js +54 -0
- package/src/default-config.js +7 -1
- package/src/format.js +20 -12
- package/src/version.js +1 -1
package/package.json
CHANGED
package/src/daemon.js
CHANGED
|
@@ -282,9 +282,33 @@ async function pushPresence() {
|
|
|
282
282
|
log('Presence updated:', activity.details || '-', '|', activity.state || '-');
|
|
283
283
|
} catch (e) {
|
|
284
284
|
log('setActivity failed:', e.message, '|', e.stack?.split('\n').slice(0, 3).join(' | '));
|
|
285
|
+
// A failed setActivity usually means the IPC pipe died WITHOUT the client
|
|
286
|
+
// emitting a 'disconnected' event (Discord restart, socket reset, OS
|
|
287
|
+
// sleep). Left alone, `connected` stays true and the daemon goes silently
|
|
288
|
+
// dark forever. Tear the client down and force a backoff reconnect so we
|
|
289
|
+
// self-heal. Guarded to connection-shaped errors so a one-off API hiccup
|
|
290
|
+
// doesn't needlessly bounce a healthy socket.
|
|
291
|
+
if (isConnectionError(e)) {
|
|
292
|
+
log('setActivity error looks connection-level — forcing reconnect');
|
|
293
|
+
connected = false;
|
|
294
|
+
lastPayloadHash = '';
|
|
295
|
+
try { client?.destroy(); } catch { /* already gone */ }
|
|
296
|
+
scheduleReconnect('setActivity failed');
|
|
297
|
+
}
|
|
285
298
|
}
|
|
286
299
|
}
|
|
287
300
|
|
|
301
|
+
// Heuristic: does this error indicate the IPC transport itself is dead
|
|
302
|
+
// (vs. a transient/application-level failure)? Matches the common broken-pipe
|
|
303
|
+
// / closed-socket shapes from @xhayper/discord-rpc and the underlying net
|
|
304
|
+
// socket so we only force a reconnect when the connection is actually gone.
|
|
305
|
+
function isConnectionError(e) {
|
|
306
|
+
const code = (e && e.code) || '';
|
|
307
|
+
if (['EPIPE', 'ECONNRESET', 'ENOENT', 'ECONNREFUSED', 'ERR_STREAM_WRITE_AFTER_END'].includes(code)) return true;
|
|
308
|
+
const m = String((e && e.message) || '').toLowerCase();
|
|
309
|
+
return /closed|reset|broken pipe|not connected|disconnect|write after end|socket|econnreset|epipe|connection/.test(m);
|
|
310
|
+
}
|
|
311
|
+
|
|
288
312
|
async function connect() {
|
|
289
313
|
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
|
290
314
|
client = new Client({ clientId: config.clientId, transport: { type: 'ipc' } });
|
|
@@ -454,6 +478,36 @@ function refreshLiveSessions() {
|
|
|
454
478
|
refreshLiveSessions();
|
|
455
479
|
setInterval(refreshLiveSessions, 30_000);
|
|
456
480
|
|
|
481
|
+
// ── Connection watchdog (auto-heal) ──────────────────────────────────────────
|
|
482
|
+
// The reconnect path is event-driven (the 'disconnected' handler + login
|
|
483
|
+
// failures + setActivity errors). But a connection can rot in ways that emit
|
|
484
|
+
// no event at all: a half-open client where `connected` is still true but the
|
|
485
|
+
// user handle is gone, or a state where we're down with no retry in flight.
|
|
486
|
+
// This periodic check guarantees the daemon always converges back to a live
|
|
487
|
+
// connection instead of silently staying dark — the single most common
|
|
488
|
+
// "it just stopped showing up" failure users hit.
|
|
489
|
+
const HEALTH_CHECK_MS = 30_000;
|
|
490
|
+
setInterval(() => {
|
|
491
|
+
try {
|
|
492
|
+
// Half-open: flag says connected but there's no usable user handle.
|
|
493
|
+
if (connected && !client?.user) {
|
|
494
|
+
log('Watchdog: connected but no user handle — forcing reconnect');
|
|
495
|
+
connected = false;
|
|
496
|
+
try { client?.destroy(); } catch { /* already gone */ }
|
|
497
|
+
scheduleReconnect('watchdog: half-open');
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
// Down with nothing scheduled to bring us back. scheduleReconnect is a
|
|
501
|
+
// no-op when a timer is already pending, so this can't stack retries.
|
|
502
|
+
if (!connected && !reconnectTimer) {
|
|
503
|
+
log('Watchdog: disconnected with no reconnect pending — forcing reconnect');
|
|
504
|
+
scheduleReconnect('watchdog: no retry pending');
|
|
505
|
+
}
|
|
506
|
+
} catch (e) {
|
|
507
|
+
log('Watchdog tick failed:', e.message);
|
|
508
|
+
}
|
|
509
|
+
}, HEALTH_CHECK_MS);
|
|
510
|
+
|
|
457
511
|
// Community-totals flush. Disabled by default; turns on via
|
|
458
512
|
// `claude-rpc community on`. Best-effort — flushCommunity swallows every
|
|
459
513
|
// 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)
|