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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-rpc",
3
- "version": "0.7.4",
3
+ "version": "0.8.1",
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
@@ -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
@@ -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/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
- console.log(` config migrated → ${CONFIG_PATH}`);
309
- console.log(` added: ${added.join(', ')}`);
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
 
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.1';
15
15
 
16
16
  function readPkgVersion() {
17
17
  try {