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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-rpc",
3
- "version": "0.7.4",
3
+ "version": "0.8.0",
4
4
  "description": "Discord Rich Presence for Claude Code — live model, project, tokens, and lifetime stats driven by Claude Code's hook system.",
5
5
  "type": "module",
6
6
  "license": "MIT",
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
@@ -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://claude.com/claude-code" },
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
- // Fast-path stale: if there are NO transcripts being written anywhere
734
- // on disk, Claude Code isn't running. SessionEnd may not have fired
735
- // (force-quit, OS sleep, crash). Going stale here clears Discord
736
- // within ~90-120s of close instead of waiting the full staleMs (5min)
737
- // keeps the user's cwd off the card when they're away from their
738
- // machine. The 5min legacy fallback below still catches the case
739
- // where transcript mtime is fresh but the hook channel is silent.
740
- if (liveSessions.length === 0) return staleWipe(state);
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 Claude is closed, not paused.
748
- // Skip idle, go straight to stale. Same privacy reasoning as the
749
- // idle-state fast-path above.
750
- if (liveSessions.length === 0) return staleWipe(state);
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/version.js CHANGED
@@ -11,7 +11,7 @@ import { readFileSync } from 'node:fs';
11
11
  import { join } from 'node:path';
12
12
  import { ROOT } from './paths.js';
13
13
 
14
- const BAKED = '0.7.4';
14
+ const BAKED = '0.8.0';
15
15
 
16
16
  function readPkgVersion() {
17
17
  try {