claude-rpc 0.5.0 → 0.6.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/src/cli.js CHANGED
@@ -8,7 +8,7 @@ import process from 'node:process';
8
8
  // code page on Win10 is 437/850, which displays many of our chars as `?`.
9
9
  // Hook events (no TTY) skip this — they don't print anything user-visible.
10
10
  if (process.platform === 'win32' && process.stdout.isTTY) {
11
- try { spawnSync('chcp.com', ['65001'], { stdio: 'ignore', windowsHide: true }); } catch {}
11
+ try { spawnSync('chcp.com', ['65001'], { stdio: 'ignore', windowsHide: true }); } catch { /* chcp absent (Wine, custom shell) — accept whatever code page is set */ }
12
12
  }
13
13
  import { DAEMON_SCRIPT, PID_PATH, STATE_PATH, LOG_PATH, AGGREGATE_PATH, CONFIG_PATH, IS_PACKAGED, EXE_PATH, CANONICAL_EXE } from './paths.js';
14
14
  import { readState } from './state.js';
@@ -21,6 +21,9 @@ import { generateInsights } from './insights.js';
21
21
  import { badgeSvg } from './badge.js';
22
22
  import { fmtCost } from './pricing.js';
23
23
  import { addPrivateCwd, removePrivateCwd, listPrivateCwds, resolveVisibility } from './privacy.js';
24
+ import { loadConfig, hasUserConfig } from './config.js';
25
+ import { VERSION } from './version.js';
26
+ import { fail, EX_USER_ERROR, EX_BAD_STATE } from './ui.js';
24
27
  import { basename } from 'node:path';
25
28
 
26
29
  const cmd = process.argv[2];
@@ -246,7 +249,7 @@ function bar(val, max, width = 22) {
246
249
  function showStatus() {
247
250
  const state = readState();
248
251
  const aggregate = readAggregate();
249
- const config = readJson(CONFIG_PATH, {});
252
+ const config = loadConfig();
250
253
  const live = findLiveSessions({ thresholdMs: 90_000 });
251
254
  state.liveSessions = live;
252
255
  const vars = buildVars(state, config, aggregate);
@@ -430,7 +433,7 @@ function showStatus() {
430
433
  function showToday() {
431
434
  const state = readState();
432
435
  const aggregate = readAggregate();
433
- const config = readJson(CONFIG_PATH, {});
436
+ const config = loadConfig();
434
437
  state.liveSessions = findLiveSessions({ thresholdMs: 90_000 });
435
438
  const vars = buildVars(state, config, aggregate);
436
439
 
@@ -458,7 +461,7 @@ function showToday() {
458
461
  function showWeek() {
459
462
  const state = readState();
460
463
  const aggregate = readAggregate();
461
- const config = readJson(CONFIG_PATH, {});
464
+ const config = loadConfig();
462
465
  state.liveSessions = findLiveSessions({ thresholdMs: 90_000 });
463
466
  const vars = buildVars(state, config, aggregate);
464
467
 
@@ -518,7 +521,7 @@ function statusColor(status) {
518
521
  function showPreview() {
519
522
  let state = readState();
520
523
  const aggregate = readAggregate();
521
- const config = readJson(CONFIG_PATH, {});
524
+ const config = loadConfig();
522
525
  const live = findLiveSessions({ thresholdMs: 90_000 });
523
526
  state.liveSessions = live;
524
527
  state = applyIdle(state, config);
@@ -558,7 +561,7 @@ function showPreview() {
558
561
  // previous helper exactly: { vars: [sorted keys], live: <full vars object> }.
559
562
  function dumpVars() {
560
563
  let state = readState();
561
- const config = readJson(CONFIG_PATH, {});
564
+ const config = loadConfig();
562
565
  state.liveSessions = findLiveSessions({ thresholdMs: 90_000 });
563
566
  state = applyIdle(state, config);
564
567
  const live = buildVars(state, config, readAggregate() || {});
@@ -597,14 +600,12 @@ function doScan(force = false) {
597
600
  function doBackfill(argv) {
598
601
  const path = argv[0];
599
602
  if (!path) {
600
- console.log(`${c.red}✗${c.reset} usage: claude-rpc backfill <path>`);
601
- console.log(`${c.dim} scans the given directory recursively for .jsonl transcripts and${c.reset}`);
602
- console.log(`${c.dim} merges them into your aggregate.${c.reset}`);
603
- process.exit(1);
603
+ fail('usage: claude-rpc backfill <path>',
604
+ { hint: 'point at any folder containing .jsonl transcripts (e.g. a backup of ~/.claude/projects)' });
604
605
  }
605
606
  if (!existsSync(path)) {
606
- console.log(`${c.red}✗${c.reset} path doesn't exist: ${path}`);
607
- process.exit(1);
607
+ fail(`path doesn't exist: ${path}`,
608
+ { hint: 'check the spelling, or run `claude-rpc doctor` to see where transcripts live' });
608
609
  }
609
610
  console.log(`${c.dim}Backfilling from${c.reset} ${c.cyan}${path}${c.reset}…`);
610
611
  const t0 = Date.now();
@@ -655,8 +656,7 @@ function doBadge(argv) {
655
656
  const opts = parseBadgeArgs(argv);
656
657
  const aggregate = readAggregate();
657
658
  if (!aggregate) {
658
- console.error(`${c.yellow}No aggregate yet. Run ${c.cyan}claude-rpc scan${c.reset} first.`);
659
- process.exit(1);
659
+ fail('no aggregate yet nothing to render', { hint: 'run `claude-rpc scan` first', code: EX_BAD_STATE });
660
660
  }
661
661
  const svg = badgeSvg({ aggregate, metric: opts.metric, range: opts.range, label: opts.label });
662
662
  if (opts.out) {
@@ -684,8 +684,7 @@ async function doCard(argv) {
684
684
  const opts = parseCardArgs(argv);
685
685
  const aggregate = readAggregate();
686
686
  if (!aggregate) {
687
- console.error(`${c.yellow}No aggregate yet. Run ${c.cyan}claude-rpc scan${c.reset} first.`);
688
- process.exit(1);
687
+ fail('no aggregate yet nothing to render', { hint: 'run `claude-rpc scan` first', code: EX_BAD_STATE });
689
688
  }
690
689
  const { renderCard } = await import('./card.js');
691
690
  const svg = renderCard(aggregate, { range: opts.range });
@@ -707,10 +706,6 @@ async function doCard(argv) {
707
706
  // Per-project overrides live in <project>/.claude-rpc.json and take priority
708
707
  // over the runtime list. See src/privacy.js for the full resolution chain.
709
708
 
710
- function loadConfigSafe() {
711
- try { return JSON.parse(readFileSync(CONFIG_PATH, 'utf8')); } catch { return {}; }
712
- }
713
-
714
709
  function doPrivate() {
715
710
  const cwd = process.cwd();
716
711
  const list = addPrivateCwd(cwd);
@@ -731,7 +726,7 @@ function doPublic() {
731
726
 
732
727
  function doPrivacy() {
733
728
  const cwd = process.cwd();
734
- const cfg = loadConfigSafe();
729
+ const cfg = loadConfig();
735
730
  const { visibility, projectName, reason } = resolveVisibility(cwd, cfg);
736
731
  const color = visibility === 'hidden' ? c.red : visibility === 'name-only' ? c.yellow : c.green;
737
732
  console.log('');
@@ -771,14 +766,85 @@ function tailLog() {
771
766
  // file rotated
772
767
  lastSize = buf.length;
773
768
  }
774
- } catch {}
769
+ } catch { /* read race vs rotation — next watchFile tick recovers */ }
775
770
  });
776
771
  }
777
772
 
773
+ // One-screen "where am I?" view. Goal: a user typing `claude-rpc` with no
774
+ // args sees in <24 lines what's happening and the four most useful next
775
+ // commands. Full command list lives behind --help.
776
+ function overview() {
777
+ const setUp = hasUserConfig();
778
+ const cfg = loadConfig();
779
+ const pid = daemonPid();
780
+
781
+ console.log('');
782
+ console.log(` ${c.bold}${c.magenta}◆ claude-rpc${c.reset} ${c.dim}v${VERSION} — Discord Rich Presence for Claude Code${c.reset}`);
783
+ console.log('');
784
+
785
+ if (!setUp) {
786
+ console.log(` ${c.yellow}○${c.reset} not configured yet`);
787
+ console.log('');
788
+ console.log(` Run ${c.cyan}claude-rpc setup${c.reset} to get started.`);
789
+ console.log('');
790
+ console.log(` ${c.dim}--help for the full command list${c.reset}`);
791
+ console.log('');
792
+ return;
793
+ }
794
+
795
+ // Status line: daemon up/down + project + model + status verb.
796
+ const state = readState();
797
+ const aggregate = readAggregate();
798
+ state.liveSessions = findLiveSessions({ thresholdMs: 90_000 });
799
+ const vars = buildVars(state, cfg, aggregate);
800
+ const dot = pid
801
+ ? `${c.green}●${c.reset} running ${c.dim}pid ${pid}${c.reset}`
802
+ : `${c.yellow}○${c.reset} ${c.dim}daemon not running${c.reset}`;
803
+ if (pid) {
804
+ console.log(` ${dot} ${c.gray}·${c.reset} ${c.bold}${vars.modelPretty}${c.reset} in ${c.bold}${vars.project}${c.reset} ${c.gray}·${c.reset} ${statusColor(vars.status)}${vars.statusVerbose}${c.reset}`);
805
+ } else {
806
+ console.log(` ${dot}`);
807
+ }
808
+
809
+ if (aggregate) {
810
+ console.log('');
811
+ console.log(` ${c.dim}today ${c.reset}${c.green}${vars.todayHours.padEnd(6)}${c.reset}${c.dim}active · ${vars.todayPromptsLabel} · ${vars.todayCostFmt}${c.reset}`);
812
+ console.log(` ${c.dim}streak ${c.reset}${c.magenta}${String(vars.streak).padEnd(6)}${c.reset}${c.dim}${vars.streak === 1 ? 'day' : 'days'} · longest ${vars.longestStreak} · ${vars.allHours} all-time${c.reset}`);
813
+ } else {
814
+ console.log('');
815
+ console.log(` ${c.dim}no stats yet — run ${c.reset}${c.cyan}claude-rpc scan${c.reset}${c.dim} to build aggregates${c.reset}`);
816
+ }
817
+
818
+ // Four most useful next commands. Doctor leads — it's the answer to
819
+ // "something looks wrong" without the user having to read anything.
820
+ console.log('');
821
+ const next = pid
822
+ ? [['status', 'full dashboard with heatmap'],
823
+ ['doctor', 'diagnose problems'],
824
+ ['serve', 'open the web dashboard'],
825
+ ['stop', 'stop the daemon']]
826
+ : (cfg.clientId && cfg.clientId !== '1234567890123456789')
827
+ ? [['start', 'launch the daemon'],
828
+ ['doctor', 'diagnose problems'],
829
+ ['status', 'show current stats'],
830
+ ['setup', 'register Claude Code hooks']]
831
+ : [['setup', 'install hooks and seed config'],
832
+ ['doctor', 'diagnose problems'],
833
+ ['start', 'launch the daemon'],
834
+ ['status', 'show current stats']];
835
+ for (const [name, desc] of next) {
836
+ console.log(` ${c.cyan}${name.padEnd(8)}${c.reset} ${c.dim}→${c.reset} ${desc}`);
837
+ }
838
+ console.log('');
839
+ console.log(` ${c.dim}--help for the full command list · --version${c.reset}`);
840
+ console.log('');
841
+ }
842
+
778
843
  function help() {
779
844
  const cmds = [
780
845
  ['setup', 'Install Claude Code hooks (~/.claude/settings.json)'],
781
846
  ['uninstall', 'Remove Claude Code hooks'],
847
+ ['upgrade-config', 'Re-run idempotent migrations on an existing config.json'],
782
848
  ['start', 'Start the Discord RPC daemon (detached)'],
783
849
  ['stop', 'Stop the daemon'],
784
850
  ['restart', 'Stop then start the daemon'],
@@ -815,6 +881,8 @@ function help() {
815
881
  console.log('');
816
882
  console.log(` ${c.dim}Tip: ${c.reset}edit ${c.cyan}config.json${c.reset} to customize rotation frames. Run ${c.cyan}claude-rpc preview${c.reset} to see the result without Discord.`);
817
883
  console.log('');
884
+ console.log(` ${c.dim}Exit codes:${c.reset} ${c.dim}0 ok · 1 user error · 2 system error · 3 wrong state${c.reset}`);
885
+ console.log('');
818
886
  }
819
887
 
820
888
  // Packaged exe: `claude-rpc.exe` with no args → first-run install + start.
@@ -827,9 +895,16 @@ const packagedDefault = IS_PACKAGED && !cmd;
827
895
  // top-level await.
828
896
  (async () => {
829
897
  switch (cmd) {
898
+ case '--version':
899
+ case '-V':
900
+ case '-v': console.log(`claude-rpc ${VERSION}`); break;
901
+ case '--help':
902
+ case '-h':
903
+ case 'help': help(); break;
830
904
  case 'setup': await runInstall({ exePath: EXE_PATH || process.execPath, withStartup: false }); break;
831
905
  case 'install': await runInstall({ exePath: EXE_PATH || process.execPath }); break;
832
906
  case 'uninstall': await runUninstall(); break;
907
+ case 'upgrade-config': migrateConfig(); break;
833
908
  case 'start': startDaemon(); break;
834
909
  case 'stop': stopDaemon(); break;
835
910
  case 'restart': restartDaemon(); break;
@@ -895,7 +970,7 @@ const packagedDefault = IS_PACKAGED && !cmd;
895
970
  console.warn(`refresh skipped: ${e.message}`);
896
971
  }
897
972
  const wasRunning = stopDaemon({ quiet: true });
898
- try { if (existsSync(STATE_PATH)) unlinkSync(STATE_PATH); } catch {}
973
+ try { if (existsSync(STATE_PATH)) unlinkSync(STATE_PATH); } catch { /* state.json locked or already gone — next hook will recreate it */ }
899
974
  if (wasRunning) {
900
975
  // Brief wait for the OS to release the pid file before we spawn.
901
976
  setTimeout(() => startDaemon(), 700);
@@ -903,8 +978,14 @@ const packagedDefault = IS_PACKAGED && !cmd;
903
978
  startDaemon();
904
979
  }
905
980
  }
981
+ } else if (!cmd) {
982
+ // True no-args invocation in dev/npm mode → one-screen overview.
983
+ // The packagedDefault branch above handles the SEA exe's "double-
984
+ // click with no args" install-and-start flow.
985
+ overview();
906
986
  } else {
907
- help();
987
+ fail(`unknown command: ${cmd}`,
988
+ { hint: 'run `claude-rpc --help` for the full list', code: EX_USER_ERROR });
908
989
  }
909
990
  }
910
991
  }
package/src/config.js ADDED
@@ -0,0 +1,89 @@
1
+ // Config loader. One place that knows how to:
2
+ //
3
+ // 1. Read the user's config.json from disk.
4
+ // 2. Deep-merge it over DEFAULT_CONFIG so the user file only needs to
5
+ // hold *overrides* (a fresh user file is two lines: clientId and
6
+ // maybe appName).
7
+ // 3. Survive bad JSON / missing files / wrong types without crashing.
8
+ // Bad input logs a one-line warning and falls back to defaults.
9
+ // Critical for the daemon — an Electron-GUI mid-edit save used to
10
+ // hard-exit it via daemon.js's `process.exit(1)`. No more.
11
+ //
12
+ // Merge rule: plain objects deep-merge, arrays REPLACE. Arrays-as-deep-
13
+ // merge is rarely what anyone wants (a user rotation array becomes a
14
+ // spliced franken-array of theirs + defaults). Replacing matches what
15
+ // you'd expect from "I set this, it's mine."
16
+ //
17
+ // All callers (daemon, server/api, tui, cli) should go through
18
+ // `loadConfig()` rather than reading CONFIG_PATH directly.
19
+
20
+ import { readFileSync, existsSync } from 'node:fs';
21
+ import { CONFIG_PATH } from './paths.js';
22
+ import { DEFAULT_CONFIG } from './default-config.js';
23
+
24
+ // "Has the user run setup?" — a separate signal from `loadConfig` because
25
+ // loadConfig now always returns merged defaults (the daemon needs them
26
+ // even if the file isn't there yet). Callers that want to distinguish
27
+ // "never been set up" from "everything default" check this instead.
28
+ export function hasUserConfig(path = CONFIG_PATH) {
29
+ return existsSync(path);
30
+ }
31
+
32
+ function isPlainObject(v) {
33
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
34
+ }
35
+
36
+ // Deep-merge `over` onto `base`. Plain objects merge recursively; arrays
37
+ // and primitives from `over` replace whatever was in `base`. Returns a
38
+ // fresh object — neither input is mutated. Used for layering user config
39
+ // over DEFAULT_CONFIG and (later) per-project overrides over user config.
40
+ export function mergeConfig(base, over) {
41
+ if (over === undefined || over === null) return structuredClone(base);
42
+ if (!isPlainObject(base) || !isPlainObject(over)) return structuredClone(over);
43
+ const out = {};
44
+ const keys = new Set([...Object.keys(base), ...Object.keys(over)]);
45
+ for (const k of keys) {
46
+ const a = base[k];
47
+ const b = over[k];
48
+ if (b === undefined) {
49
+ out[k] = structuredClone(a);
50
+ } else if (isPlainObject(a) && isPlainObject(b)) {
51
+ out[k] = mergeConfig(a, b);
52
+ } else {
53
+ out[k] = structuredClone(b);
54
+ }
55
+ }
56
+ return out;
57
+ }
58
+
59
+ // Read + merge. `onError(message)` is called when the user config can't
60
+ // be parsed — caller decides whether to log to stdout, daemon.log, or
61
+ // nothing. Returning the merged-defaults object is the contract: never
62
+ // throw, never exit. Worst case we render with shipped defaults.
63
+ //
64
+ // path defaults to CONFIG_PATH so all daemon-like callers can drop the
65
+ // `readFileSync(CONFIG_PATH, ...)` boilerplate.
66
+ export function loadConfig({ path = CONFIG_PATH, onError } = {}) {
67
+ if (!existsSync(path)) {
68
+ return mergeConfig(DEFAULT_CONFIG, {});
69
+ }
70
+ let raw;
71
+ try {
72
+ raw = readFileSync(path, 'utf8');
73
+ } catch (e) {
74
+ if (onError) onError(`config read failed at ${path}: ${e.message} — falling back to defaults`);
75
+ return mergeConfig(DEFAULT_CONFIG, {});
76
+ }
77
+ let parsed;
78
+ try {
79
+ parsed = JSON.parse(raw);
80
+ } catch (e) {
81
+ if (onError) onError(`config parse failed at ${path}: ${e.message} — falling back to defaults`);
82
+ return mergeConfig(DEFAULT_CONFIG, {});
83
+ }
84
+ if (!isPlainObject(parsed)) {
85
+ if (onError) onError(`config at ${path} is not an object — falling back to defaults`);
86
+ return mergeConfig(DEFAULT_CONFIG, {});
87
+ }
88
+ return mergeConfig(DEFAULT_CONFIG, parsed);
89
+ }
package/src/daemon.js CHANGED
@@ -1,33 +1,71 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync, writeFileSync, existsSync, unlinkSync, watch, appendFileSync, mkdirSync } from 'node:fs';
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
8
  import { applyPrivacy } from './privacy.js';
9
+ import { loadConfig } from './config.js';
9
10
  import { CONFIG_PATH, STATE_PATH, PID_PATH, LOG_PATH, STATE_DIR, AGGREGATE_PATH } from './paths.js';
10
11
 
11
12
  if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true });
12
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
+
13
33
  function log(...args) {
14
34
  const line = `[${new Date().toISOString()}] ${args.map((a) => typeof a === 'string' ? a : JSON.stringify(a)).join(' ')}\n`;
15
- try { appendFileSync(LOG_PATH, line); } catch {}
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
+ }
16
43
  process.stdout.write(line);
17
44
  }
18
45
 
19
- function loadConfig() {
20
- try { return JSON.parse(readFileSync(CONFIG_PATH, 'utf8')); }
21
- catch (e) { log('Failed to read config.json:', e.message); process.exit(1); }
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) });
22
52
  }
23
53
 
24
- let config = loadConfig();
54
+ let config = loadConfigWithLog();
25
55
  let aggregate = readAggregate() || null;
26
56
  let liveSessions = [];
27
57
  let client = null;
28
58
  let connected = false;
29
59
  let lastPayloadHash = '';
30
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;
31
69
  let rotationIndex = 0;
32
70
  let lastRotationAt = 0;
33
71
  // Stabilizes Discord's elapsed timer: applyIdle can synthesize a sessionStart
@@ -134,9 +172,15 @@ function buildActivity(opts = {}) {
134
172
  if (frame.details) activity.details = fillTemplate(frame.details, vars).slice(0, 128);
135
173
  if (frame.state) activity.state = fillTemplate(frame.state, vars).slice(0, 128);
136
174
 
137
- // Image precedence: statusAssets[status] modelAssets[modelMatch] presence.largeImageKey.
138
- // statusAssets lets the user swap the big image based on what Claude is doing
139
- // (working/thinking/idle/stale/notification).
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.
140
184
  let largeKeyTpl = p.largeImageKey;
141
185
  if (config.statusAssets && config.statusAssets[state.status]) {
142
186
  largeKeyTpl = config.statusAssets[state.status];
@@ -243,26 +287,34 @@ async function connect() {
243
287
 
244
288
  client.on('ready', () => {
245
289
  connected = true;
290
+ // Reset backoff so the next outage also starts at RECONNECT_BASE_MS.
291
+ reconnectDelayMs = RECONNECT_BASE_MS;
246
292
  log('Discord RPC connected as', client.user?.username);
247
293
  lastPayloadHash = '';
248
294
  pushPresence();
249
295
  });
250
296
  client.on('disconnected', () => {
251
297
  connected = false;
252
- log('Discord disconnected — retrying in 10s');
253
- scheduleReconnect();
298
+ scheduleReconnect('Discord disconnected');
254
299
  });
255
300
  try { await client.login(); }
256
301
  catch (e) {
257
- log('Discord login failed:', e.message, '— retrying in 10s. Is Discord desktop running?');
258
- scheduleReconnect();
302
+ scheduleReconnect(`Discord login failed: ${e.message}`);
259
303
  }
260
304
  }
261
305
 
262
- function scheduleReconnect() {
306
+ function scheduleReconnect(reason = 'reconnect') {
263
307
  connected = false;
264
308
  if (reconnectTimer) return;
265
- reconnectTimer = setTimeout(() => { reconnectTimer = null; connect(); }, 10000);
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);
266
318
  }
267
319
 
268
320
  function watchFiles() {
@@ -275,8 +327,9 @@ function watchFiles() {
275
327
  }
276
328
  watch(CONFIG_PATH, () => {
277
329
  log('Config changed — reloading');
278
- try { config = loadConfig(); lastPayloadHash = ''; pushPresence(); }
279
- catch (e) { log('Reload failed:', e.message); }
330
+ config = loadConfigWithLog();
331
+ lastPayloadHash = '';
332
+ pushPresence();
280
333
  });
281
334
  if (existsSync(AGGREGATE_PATH)) {
282
335
  let aggTimer = null;
@@ -289,6 +342,44 @@ function watchFiles() {
289
342
  }, 250);
290
343
  });
291
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);
292
383
  }
293
384
 
294
385
  async function runBackgroundScan({ force = false } = {}) {
@@ -306,8 +397,11 @@ async function runBackgroundScan({ force = false } = {}) {
306
397
 
307
398
  function shutdown() {
308
399
  log('Shutting down…');
309
- try { client?.destroy(); } catch {}
310
- try { if (existsSync(PID_PATH)) unlinkSync(PID_PATH); } catch {}
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 */ }
311
405
  process.exit(0);
312
406
  }
313
407
 
package/src/doctor.js CHANGED
@@ -16,37 +16,17 @@ import {
16
16
  } from './paths.js';
17
17
  import { findLiveSessions } from './scanner.js';
18
18
  import { resolveVisibility, listPrivateCwds } from './privacy.js';
19
-
20
- const TTY = process.stdout.isTTY && !process.env.NO_COLOR;
21
- const c = {
22
- reset: TTY ? '\x1b[0m' : '',
23
- dim: TTY ? '\x1b[2m' : '',
24
- bold: TTY ? '\x1b[1m' : '',
25
- red: TTY ? '\x1b[31m' : '',
26
- green: TTY ? '\x1b[32m' : '',
27
- yellow: TTY ? '\x1b[33m' : '',
28
- cyan: TTY ? '\x1b[36m' : '',
29
- gray: TTY ? '\x1b[90m' : '',
30
- };
31
-
32
- const SYM_OK = TTY ? `${c.green}✓${c.reset}` : '[ok] ';
33
- const SYM_FAIL = TTY ? `${c.red}✗${c.reset}` : '[fail]';
34
- const SYM_WARN = TTY ? `${c.yellow}!${c.reset}` : '[warn]';
35
- const SYM_INFO = TTY ? `${c.cyan}·${c.reset}` : '[info]';
19
+ import { c, check as uiCheck } from './ui.js';
36
20
 
37
21
  const counters = { pass: 0, fail: 0, warn: 0 };
38
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.
39
25
  function check(label, status, detail = '', hint = '') {
40
- let sym;
41
- if (status === 'pass') { sym = SYM_OK; counters.pass++; }
42
- else if (status === 'fail') { sym = SYM_FAIL; counters.fail++; }
43
- else if (status === 'warn') { sym = SYM_WARN; counters.warn++; }
44
- else { sym = SYM_INFO; }
45
- const tail = detail ? ` ${c.dim}${detail}${c.reset}` : '';
46
- console.log(` ${sym} ${label}${tail}`);
47
- if (hint && status !== 'pass') {
48
- console.log(` ${c.gray}↳ ${hint}${c.reset}`);
49
- }
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);
50
30
  }
51
31
 
52
32
  function section(title) {
@@ -143,7 +123,7 @@ function checkClaudeProjects() {
143
123
  if (f.endsWith('.jsonl')) count++;
144
124
  }
145
125
  }
146
- } catch {}
126
+ } catch { /* unreadable subdir — just report whatever we counted so far */ }
147
127
  check('claude transcripts visible', count > 0 ? 'pass' : 'warn',
148
128
  `${count} .jsonl ${count === 1 ? 'file' : 'files'}`,
149
129
  count === 0 ? 'open claude code and send a prompt — transcripts appear immediately' : '');
@@ -207,7 +187,7 @@ function checkCanonicalExe() {
207
187
  }
208
188
  if (existsSync(CANONICAL_EXE)) {
209
189
  let size = '';
210
- try { size = `${(statSync(CANONICAL_EXE).size / 1024 / 1024).toFixed(1)} MB`; } catch {}
190
+ try { size = `${(statSync(CANONICAL_EXE).size / 1024 / 1024).toFixed(1)} MB`; } catch { /* stat failed, size stays blank */ }
211
191
  check('canonical exe installed', 'pass', `${CANONICAL_EXE} (${size})`);
212
192
  } else {
213
193
  check('canonical exe installed', 'fail', `missing: ${CANONICAL_EXE}`,
@@ -253,7 +233,7 @@ function checkDaemonLog() {
253
233
  try {
254
234
  const tail = readFileSync(LOG_PATH, 'utf8').split('\n').slice(-50).join('\n');
255
235
  connected = /Discord RPC connected/i.test(tail);
256
- } catch {}
236
+ } catch { /* log unreadable — connected stays false, warn check renders */ }
257
237
  if (connected) {
258
238
  check('discord IPC connection', 'pass',
259
239
  `${sizeKB} KB log · last write ${ageMin.toFixed(1)} min ago`);
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
@@ -16,7 +16,7 @@ function appendEvent(entry) {
16
16
  }
17
17
  }
18
18
  appendFileSync(EVENTS_LOG_PATH, JSON.stringify(entry) + '\n');
19
- } catch {}
19
+ } catch { /* best-effort log: hooks must never fail because of an unwritable events.jsonl */ }
20
20
  }
21
21
 
22
22
  function readStdin() {