claude-rpc 0.18.0 → 0.20.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/SECURITY.md CHANGED
@@ -16,10 +16,10 @@ fetch-and-execute anywhere in `src/`.
16
16
 
17
17
  | Behavior | Where | Scope | Reversible? |
18
18
  | --- | --- | --- | --- |
19
- | Startup persistence | `src/install.js` → `addStartupEntry` | `HKCU` Run key, current user, no admin | Yes — `claude-rpc uninstall` / `removeStartupEntry` |
19
+ | Startup persistence | `src/install.js` → `addStartupEntry` (Windows Run key); `src/hook.js` → `ensureDaemonRunning` (SessionStart self-heal, all platforms) | `HKCU` Run key (no admin) + a detached daemon (re)launched when you start a Claude Code session | Yes — `claude-rpc uninstall` / `removeStartupEntry`; disable self-heal with `autostart:false` |
20
20
  | Hook injection | `src/install.js` → `installHooks` | Only into Claude Code's own `settings.json`, only our own commands | Yes — `uninstallHooks` removes exactly what it added |
21
21
  | Outbound network | `src/community.js`, `src/gist.js`, `src/usage.js`, `src/notify.js`, `default-config.js` | Anonymous counters + (opt-in) profile/gist/webhook + own read-only OAuth-usage poll + GIF assets | Telemetry: `community off`. Profile: `profile off`. Gist/webhook: opt-in only. Usage: `usage.enabled:false`. |
22
- | Local subprocess | `reg.exe`, `wscript`, `git`, `gh`, `npm`, `claude`, `security`, notifiers | Static or escaped args, no shell interpolation of untrusted input | n/a |
22
+ | Local subprocess | `reg.exe`, `wscript`, the daemon itself (`node`/packaged exe — via the Run key and the SessionStart self-heal), `git`, `gh`, `npm`, `claude`, `security`, notifiers | Static or escaped args, no shell interpolation of untrusted input | n/a |
23
23
 
24
24
  No credential access beyond the read-only Claude Code OAuth-token read for usage
25
25
  polling (§3d), no filesystem scanning outside `~/.claude-rpc` and Claude Code
@@ -47,8 +47,16 @@ HKCU\Software\Microsoft\Windows\CurrentVersion\Run
47
47
  not at wherever you happened to run the installer from — see
48
48
  `ensureCanonicalExe`.
49
49
 
50
- Non-Windows platforms get **no** persistence registration (`install()` warns
51
- and skips it).
50
+ Non-Windows platforms get **no** Run-key/login registration (`install()` warns
51
+ and skips it). Instead — on every platform, Windows included — the
52
+ `SessionStart` hook **self-heals** the daemon: if none is running when you start
53
+ a Claude Code session, the hook spawns one (detached, windowless), so presence
54
+ is restored after a reboot, crash, or OS sleep exactly when you next use Claude.
55
+ It is the same long-lived daemon launched the same way (`src/ensure-daemon.js` →
56
+ `spawnDaemonDetached`), a no-op when a daemon is already running, and
57
+ cooldown-guarded against spawn storms; the daemon's atomic single-instance claim
58
+ reaps any duplicate. Disable it with `autostart:false` in config (then manage
59
+ the daemon yourself via `claude-rpc start` / `stop`).
52
60
 
53
61
  ## 2. Hook injection into Claude Code
54
62
 
@@ -71,6 +79,11 @@ events: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`,
71
79
  counters, and `git push`/`git commit` detection (for the "just shipped"
72
80
  card). It writes only to local state/log files. It does not read file
73
81
  *contents*, prompts, or responses beyond the usage counters Claude provides.
82
+ - **`SessionStart` additionally self-heals the daemon:** if none is running it
83
+ spawns the (detached) daemon so presence is assured cross-platform — see §1
84
+ and `ensureDaemonRunning`. Best-effort, gated by `autostart` (default on). It
85
+ is the **only** hook event that launches a process; every other event just
86
+ writes state and acks.
74
87
  - **Scope:** the installer only ever touches entries whose command matches
75
88
  `isOurHookCommand` (contains `claude-rpc` or `hook.js`). It will not modify,
76
89
  reorder, or delete anyone else's hooks.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-rpc",
3
- "version": "0.18.0",
3
+ "version": "0.20.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/badge.js CHANGED
@@ -3,6 +3,7 @@
3
3
 
4
4
  import { dayKey } from './scanner.js';
5
5
  import { fmtCost } from './pricing.js';
6
+ import { fmtNum, fmtHours as fmtHoursLabel } from './fmt.js';
6
7
 
7
8
  const COLORS = {
8
9
  hours: { left: '#555', right: '#4c1' }, // green
@@ -58,22 +59,6 @@ function rangeLabel(range) {
58
59
 
59
60
  export { rangeToDays, rangeLabel, pickWindow };
60
61
 
61
- function fmtHoursLabel(ms) {
62
- if (!ms) return '0h';
63
- const h = ms / 3_600_000;
64
- if (h < 1) return `${Math.round(h * 60)}m`;
65
- if (h < 10) return `${h.toFixed(1)}h`;
66
- return `${Math.round(h)}h`;
67
- }
68
-
69
- function fmtNum(n) {
70
- if (!n) return '0';
71
- if (n < 1000) return String(n);
72
- if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`;
73
- if (n < 1_000_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
74
- return `${(n / 1_000_000_000).toFixed(2)}B`;
75
- }
76
-
77
62
  // Compute label/value pair for the requested metric.
78
63
  function valueFor(aggregate, metric, range) {
79
64
  const a = aggregate || {};
package/src/card.js CHANGED
@@ -15,6 +15,7 @@ import { dayKey } from './scanner.js';
15
15
  import { fmtCost } from './pricing.js';
16
16
  import { rangeToDays, rangeLabel, pickWindow } from './badge.js';
17
17
  import { VERSION } from './version.js';
18
+ import { fmtNum, fmtHours } from './fmt.js';
18
19
 
19
20
  const W = 880;
20
21
  const H = 540;
@@ -45,22 +46,6 @@ function escapeXml(s) {
45
46
  .replace(/"/g, '&quot;');
46
47
  }
47
48
 
48
- function fmtNum(n) {
49
- if (!n) return '0';
50
- if (n < 1000) return String(Math.round(n));
51
- if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`;
52
- if (n < 1_000_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
53
- return `${(n / 1_000_000_000).toFixed(2)}B`;
54
- }
55
-
56
- function fmtHours(ms) {
57
- if (!ms || ms < 0) return '0h';
58
- const h = ms / 3_600_000;
59
- if (h < 1) return `${Math.round(h * 60)}m`;
60
- if (h < 10) return `${h.toFixed(1)}h`;
61
- return `${Math.round(h)}h`;
62
- }
63
-
64
49
  function rangeTitle(range) {
65
50
  const rl = rangeLabel(range);
66
51
  if (rl === 'all-time') return 'on claude';
@@ -103,7 +88,9 @@ function topWeekday(aggregate) {
103
88
  const wd = aggregate?.byWeekday || {};
104
89
  let best = null;
105
90
  for (const [k, v] of Object.entries(wd)) {
106
- if (!best || (v.activeMs || 0) > (best.ms || 0)) best = { day: Number(k), ms: v.activeMs || 0 };
91
+ const day = Number(k);
92
+ if (!Number.isInteger(day) || day < 0 || day > 6) continue; // out-of-range key → skip, no blank name + stray "(3h)"
93
+ if (!best || (v.activeMs || 0) > (best.ms || 0)) best = { day, ms: v.activeMs || 0 };
107
94
  }
108
95
  return best;
109
96
  }
@@ -133,6 +120,7 @@ function peakHourLabel(aggregate) {
133
120
  const ph = aggregate?.peakHour;
134
121
  if (!ph || ph.hour == null) return null;
135
122
  const h = Number(ph.hour);
123
+ if (!Number.isInteger(h) || h < 0 || h > 23) return null; // corrupt aggregate → no label, not "99:00"/"NaN:00"
136
124
  return `${String(h).padStart(2, '0')}:00`;
137
125
  }
138
126
 
package/src/cli.js CHANGED
@@ -10,10 +10,11 @@ import process from 'node:process';
10
10
  if (process.platform === 'win32' && process.stdout.isTTY) {
11
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
- import { DAEMON_SCRIPT, PID_PATH, STATE_PATH, LOG_PATH, AGGREGATE_PATH, CONFIG_PATH, IS_PACKAGED, IS_NPX, EXE_PATH, CANONICAL_EXE } from './paths.js';
14
- import { readState } from './state.js';
13
+ import { DAEMON_SCRIPT, PID_PATH, STATE_PATH, LOG_PATH, AGGREGATE_PATH, CONFIG_PATH, IS_PACKAGED, IS_NPX, EXE_PATH } from './paths.js';
14
+ import { readActiveState } from './state.js';
15
15
  import { buildVars, fillTemplate, humanProject, humanTool, applyIdle, framePasses, fmtNum } from './format.js';
16
16
  import { scan, readAggregate, findLiveSessions, dayKey, weekKey } from './scanner.js';
17
+ import { weekGrid } from './week.js';
17
18
  import { runHookCli } from './hook.js';
18
19
  import { install as runInstall, uninstall as runUninstall, isInstalled, migrateConfig, installHooks, ensureCanonicalExe, installMcp, uninstallMcp, setupOutro } from './install.js';
19
20
  import { startTui } from './tui.js';
@@ -25,6 +26,7 @@ import { addPrivateCwd, removePrivateCwd, listPrivateCwds, resolveVisibility } f
25
26
  import { parseDuration, setPause, clearPause, pauseUntil } from './pause.js';
26
27
  import { readUsageCache, fetchUsage, writeUsageCache, fmtResetTime, fmtResetDay } from './usage.js';
27
28
  import { loadConfig, hasUserConfig } from './config.js';
29
+ import { spawnDaemonDetached } from './ensure-daemon.js';
28
30
  import * as lb from './leaderboard.js';
29
31
  import { VERSION } from './version.js';
30
32
  import { fail, tailLines, heat, sparkline, fmtDelta, topPercentile, EX_USER_ERROR, EX_BAD_STATE, EX_SYS_ERROR } from './ui.js';
@@ -62,7 +64,7 @@ function readJson(path, fallback) {
62
64
  // (`profile set`, `community on`, `link`, …). mkdirSync first.
63
65
  function writeUserConfig(userCfg) {
64
66
  mkdirSync(dirname(CONFIG_PATH), { recursive: true });
65
- writeUserConfig(userCfg);
67
+ writeFileSync(CONFIG_PATH, JSON.stringify(userCfg, null, 2) + '\n');
66
68
  }
67
69
 
68
70
  // Validate a flag's value taken with `argv[++i]`. A value that's missing or
@@ -95,18 +97,39 @@ function startDaemon({ quiet = false } = {}) {
95
97
  if (!quiet) console.log(` ${c.yellow}!${c.reset} ${'daemon running'.padEnd(16)}${c.dim}already up (pid ${pid}) · bounce it with ${c.reset}${c.cyan}claude-rpc restart${c.reset}`);
96
98
  return false;
97
99
  }
98
- // In packaged mode the "daemon script" is the exe itself with a subcommand;
99
- // in dev mode it's the src/daemon.js path passed to node. Prefer the
100
- // canonical exe when it exists so we don't keep the user's Downloads copy
101
- // locked open — the canonical install is the long-lived path.
102
- const exe = (IS_PACKAGED && existsSync(CANONICAL_EXE)) ? CANONICAL_EXE : process.execPath;
103
- const args = IS_PACKAGED ? ['daemon'] : [DAEMON_SCRIPT];
104
- const child = spawn(exe, args, { detached: true, stdio: 'ignore', windowsHide: true });
105
- child.unref();
100
+ // Shared spawn recipe (canonical-exe preference, detached, windowless, error
101
+ // listener) lives in ensure-daemon.spawnDaemonDetached so the CLI and the
102
+ // self-healing hook launch the daemon identically.
103
+ const child = spawnDaemonDetached();
104
+ if (!child || !child.pid) {
105
+ if (!quiet) console.log(` ${c.red}✗${c.reset} ${'daemon start'.padEnd(16)}${c.dim}spawn failed check ${shortPath(LOG_PATH)} or run ${c.reset}${c.cyan}claude-rpc doctor${c.reset}`);
106
+ return false;
107
+ }
106
108
  if (!quiet) console.log(` ${c.green}✓${c.reset} ${'daemon launched'.padEnd(16)}${c.dim}pid ${c.reset}${c.cyan}${child.pid}${c.reset}${c.dim} · log ${shortPath(LOG_PATH)}${c.reset}`);
107
109
  return true;
108
110
  }
109
111
 
112
+ // Confirm a freshly-spawned daemon actually came up, instead of trusting the
113
+ // detached spawn (it can die a millisecond later — single-instance loss, a bad
114
+ // path, a startup throw). Polls the pid file for liveness, since the daemon
115
+ // claims it early in startup. Interactive callers (`start`) await this so the
116
+ // user gets honest "it's up" / "it didn't come up" feedback rather than a
117
+ // success message for a daemon that's already gone.
118
+ async function confirmDaemonUp({ timeoutMs = 3000, intervalMs = 100 } = {}) {
119
+ const deadline = Date.now() + timeoutMs;
120
+ for (;;) {
121
+ await new Promise((r) => setTimeout(r, intervalMs));
122
+ if (daemonPid()) {
123
+ console.log(` ${c.green}✓${c.reset} ${'daemon confirmed'.padEnd(16)}${c.dim}up — connecting to Discord${c.reset}`);
124
+ return true;
125
+ }
126
+ if (Date.now() >= deadline) {
127
+ console.log(` ${c.yellow}!${c.reset} ${'daemon check'.padEnd(16)}${c.dim}not up yet — see ${shortPath(LOG_PATH)} or run ${c.reset}${c.cyan}claude-rpc doctor${c.reset}`);
128
+ return false;
129
+ }
130
+ }
131
+ }
132
+
110
133
  function stopDaemon({ quiet = false } = {}) {
111
134
  const pid = daemonPid();
112
135
  if (!pid) { if (!quiet) console.log(` ${c.cyan}·${c.reset} daemon not running`); return false; }
@@ -346,7 +369,7 @@ function todayBoxLines(vars, aggregate) {
346
369
  }
347
370
 
348
371
  function showStatus() {
349
- const state = readState();
372
+ const state = readActiveState();
350
373
  const aggregate = readAggregate();
351
374
  const config = loadConfig();
352
375
  const live = findLiveSessions({ thresholdMs: 90_000 });
@@ -528,7 +551,7 @@ function showStatus() {
528
551
  }
529
552
 
530
553
  function showToday() {
531
- const state = readState();
554
+ const state = readActiveState();
532
555
  const aggregate = readAggregate();
533
556
  const config = loadConfig();
534
557
  state.liveSessions = findLiveSessions({ thresholdMs: 90_000 });
@@ -561,7 +584,7 @@ function showToday() {
561
584
  }
562
585
 
563
586
  function showWeek() {
564
- const state = readState();
587
+ const state = readActiveState();
565
588
  const aggregate = readAggregate();
566
589
  const config = loadConfig();
567
590
  state.liveSessions = findLiveSessions({ thresholdMs: 90_000 });
@@ -593,22 +616,7 @@ function showWeek() {
593
616
 
594
617
  // Current ISO week (Mon → Sun). Future days show "—".
595
618
  if (aggregate?.byDay) {
596
- const now = new Date();
597
- now.setHours(0, 0, 0, 0);
598
- const monday = new Date(now);
599
- monday.setDate(monday.getDate() - ((monday.getDay() + 6) % 7));
600
- const days = [];
601
- for (let i = 0; i < 7; i++) {
602
- const d = new Date(monday);
603
- d.setDate(d.getDate() + i);
604
- const k = dayKey(d.getTime());
605
- const dayName = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][d.getDay()];
606
- const ms = aggregate.byDay[k]?.activeMs || 0;
607
- const isFuture = d > now;
608
- const isToday = k === dayKey(now.getTime());
609
- days.push({ label: `${dayName} ${k.slice(5)}`, ms, isFuture, isToday });
610
- }
611
- const maxMs = Math.max(...days.map((d) => d.ms)) || 1;
619
+ const { days, maxMs } = weekGrid(aggregate.byDay);
612
620
  const lines = days.map(({ label, ms, isFuture, isToday }) => {
613
621
  if (isFuture) return `${c.dim}${label.padEnd(12)} ${'·'.repeat(22)} —${c.reset}`;
614
622
  const h = ms / 3_600_000;
@@ -681,7 +689,7 @@ function statusColor(status) {
681
689
  }
682
690
 
683
691
  function showPreview() {
684
- let state = readState();
692
+ let state = readActiveState();
685
693
  const aggregate = readAggregate();
686
694
  const config = loadConfig();
687
695
  const live = findLiveSessions({ thresholdMs: 90_000 });
@@ -744,7 +752,7 @@ function showPreview() {
744
752
  // dashboard having to inline-eval ESM source. Output shape matches the
745
753
  // previous helper exactly: { vars: [sorted keys], live: <full vars object> }.
746
754
  function dumpVars() {
747
- let state = readState();
755
+ let state = readActiveState();
748
756
  const config = loadConfig();
749
757
  state.liveSessions = findLiveSessions({ thresholdMs: 90_000 });
750
758
  state = applyIdle(state, config);
@@ -971,7 +979,7 @@ async function doGithubStat(argv) {
971
979
  // Build the live template-variable table the way the daemon does — current
972
980
  // state + idle/stale resolution + aggregate. Shared by statusline/session-card.
973
981
  function liveVars() {
974
- const state = readState();
982
+ const state = readActiveState();
975
983
  state.liveSessions = findLiveSessions({ thresholdMs: 90_000 });
976
984
  const config = loadConfig();
977
985
  const resolved = applyIdle(state, config);
@@ -1837,7 +1845,7 @@ function overview() {
1837
1845
  }
1838
1846
 
1839
1847
  // Status line: daemon up/down + project + model + status verb.
1840
- const state = readState();
1848
+ const state = readActiveState();
1841
1849
  const aggregate = readAggregate();
1842
1850
  state.liveSessions = findLiveSessions({ thresholdMs: 90_000 });
1843
1851
  state.usage = readUsageCache();
@@ -2012,6 +2020,7 @@ process.on('unhandledRejection', (e) => {
2012
2020
  const child = spawn(process.execPath, [script || DAEMON_SCRIPT], {
2013
2021
  detached: true, stdio: 'ignore', windowsHide: true,
2014
2022
  });
2023
+ child.on('error', () => {}); // never let a spawn ENOENT bubble as an unhandled crash
2015
2024
  child.unref();
2016
2025
  console.log(` ${c.green}✓${c.reset} ${'daemon launched'.padEnd(16)}${c.dim}pid ${c.reset}${c.cyan}${child.pid}${c.reset}${c.dim} · log ${shortPath(LOG_PATH)}${c.reset}`);
2017
2026
  } else {
@@ -2031,7 +2040,7 @@ process.on('unhandledRejection', (e) => {
2031
2040
  case 'upgrade-config':
2032
2041
  if (!migrateConfig()) console.log(` ${c.green}✓${c.reset} config already current — nothing to migrate`);
2033
2042
  break;
2034
- case 'start': startDaemon(); break;
2043
+ case 'start': if (startDaemon()) await confirmDaemonUp(); break;
2035
2044
  case 'stop': stopDaemon(); break;
2036
2045
  case 'restart': restartDaemon(); break;
2037
2046
  case 'status': {
package/src/daemon.js CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
- import { writeFileSync, readFileSync, existsSync, unlinkSync, watch, appendFileSync, mkdirSync, statSync, renameSync } from 'node:fs';
2
+ import { existsSync, unlinkSync, watch, appendFileSync, mkdirSync, statSync, renameSync } from 'node:fs';
3
3
  import { basename, dirname } from 'node:path';
4
4
  import { Client } from './discord-ipc.js';
5
5
  import { readState, sweepStaleStateTmp, listSessionStates, sweepStaleSessionStates } from './state.js';
6
- import { makeRotationCursor, pickFrames, selectFrame, resolveLargeImageKey, shouldShowGithubButton, pickActiveSession } from './presence.js';
6
+ import { makeRotationCursor, pickFrames, selectFrame, resolveLargeImageKey, shouldShowGithubButton, pickActiveSession, throttleDecision } from './presence.js';
7
7
  import { buildVars, fillTemplate, framePasses, applyIdle, applyShipped, applyTrigger } from './format.js';
8
8
  import { scan, readAggregate, findLiveSessions, readSessionTokens } from './scanner.js';
9
9
  import { detectGithubUrl } from './git.js';
@@ -16,6 +16,7 @@ import { humanProject } from './format.js';
16
16
  import { CONFIG_PATH, STATE_PATH, PID_PATH, LOG_PATH, STATE_DIR, AGGREGATE_PATH, PAUSE_PATH } from './paths.js';
17
17
  import { readUsageCache, pollUsage } from './usage.js';
18
18
  import { pollDecision, pollIntervalMs } from './watch-poll.js';
19
+ import { claimSingleInstance } from './ensure-daemon.js';
19
20
 
20
21
  if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true });
21
22
 
@@ -51,6 +52,19 @@ function log(...args) {
51
52
  process.stdout.write(line);
52
53
  }
53
54
 
55
+ // Last-resort resilience. A presence daemon is best-effort and long-lived — it
56
+ // must never die from an unexpected error in a non-critical path (a scan edge
57
+ // case, a transient IPC oddity, a stray rejection). Log and keep running:
58
+ // staying up is the entire point, and the periodic ticks/watchdog re-converge
59
+ // from almost anything. Installed before any other startup work so even a boot
60
+ // error is captured rather than killing us silently.
61
+ process.on('uncaughtException', (e) => {
62
+ try { log('uncaughtException (continuing):', e?.stack || e?.message || String(e)); } catch { /* logging itself failed — nothing more we can do */ }
63
+ });
64
+ process.on('unhandledRejection', (e) => {
65
+ try { log('unhandledRejection (continuing):', e?.stack || e?.message || String(e)); } catch { /* see above */ }
66
+ });
67
+
54
68
  // Wrap loadConfig so a parse/IO failure logs once and the daemon keeps
55
69
  // running on baked-in defaults. The Electron settings GUI saves the file
56
70
  // atomically but mid-edit hand-edits used to brick the daemon — this is
@@ -78,7 +92,22 @@ let aggregate = readAggregate() || null;
78
92
  let liveSessions = [];
79
93
  let client = null;
80
94
  let connected = false;
81
- let lastPayloadHash = '';
95
+ let connecting = false; // login() in flight — see the watchdog note in connect()
96
+ // ── Outbound presence throttle ──────────────────────────────────────────────
97
+ // Discord rate-limits SET_ACTIVITY hard (~5 writes per 20s); a burst past it
98
+ // makes the client blank the presence until the writes stop — the actual
99
+ // mechanism behind "the card sometimes looks buggy", since Claude fires hooks
100
+ // in flurries. We never write faster than activityGapMs(): the first change
101
+ // after a quiet gap goes out at once, and changes inside the gap coalesce to
102
+ // the LATEST and flush once when it expires. See presence.throttleDecision.
103
+ // `lastSentHash` is what we believe is on the wire — advanced ONLY after a
104
+ // confirmed write, so a dropped/rate-limited write self-heals on the next push
105
+ // instead of being recorded as success and stranded (the old optimistic-hash
106
+ // bug, which stuck the card on a stale frame).
107
+ let lastSentHash = '';
108
+ let lastSentAt = 0; // ms of the last write ATTEMPT (success or fail → back off either way)
109
+ let pendingSend = null; // { kind, activity, hash, logMsg } coalesced during the gap; latest wins
110
+ let flushTimer = null;
82
111
  // Last status we acted on for outbound side-effects (webhook / desktop notify).
83
112
  // Tracked separately from the render hash so we fire once per transition.
84
113
  let lastNotifiedStatus = null;
@@ -104,25 +133,19 @@ let displayedSessionId = null;
104
133
  // either case would make startTimestamp jump on every rotation.
105
134
  let effectiveSessionStart = null;
106
135
 
107
- // Single-instance guard. The CLI checks before spawning, but off-path launches
108
- // (a login-startup entry racing a manual start; a packaged exe beside a dev
109
- // run) could start a second daemon that fights over setActivity every ~4s and
110
- // double-counts the additive community total. If a live daemon already owns the
111
- // PID file, step aside; a stale PID (owner gone) means take over.
112
- try {
113
- if (existsSync(PID_PATH)) {
114
- const existing = parseInt(readFileSync(PID_PATH, 'utf8'), 10);
115
- if (existing && existing !== process.pid) {
116
- let alive = false;
117
- try { process.kill(existing, 0); alive = true; } catch { /* stale PID take over */ }
118
- if (alive) {
119
- log(`Another daemon (pid ${existing}) is already running — exiting.`);
120
- process.exit(0);
121
- }
122
- }
123
- }
124
- } catch { /* unreadable PID file — fall through and claim it */ }
125
- writeFileSync(PID_PATH, String(process.pid));
136
+ // Single-instance guard an ATOMIC claim (see ensure-daemon.claimSingleInstance).
137
+ // Many launchers can fire at once now that the SessionStart hook self-heals the
138
+ // daemon: a manual `start`, a Windows Run entry, and several concurrent sessions'
139
+ // hooks could all spawn a daemon in the same instant. A second daemon would
140
+ // fight over setActivity every ~4s and double-count the additive community
141
+ // total. The old read-then-write guard had a window where two starters both saw
142
+ // "no live owner" and both wrote their pid; the exclusive-create claim closes it
143
+ // so exactly one survives. A live owner → we exit; a stale pid → we reclaim it.
144
+ const ownerPid = claimSingleInstance();
145
+ if (ownerPid) {
146
+ log(`Another daemon (pid ${ownerPid}) is already runningexiting.`);
147
+ process.exit(0);
148
+ }
126
149
 
127
150
  // Reclaim any per-pid state tmp files orphaned by a hard-killed writer, plus
128
151
  // per-session state files from sessions that ended long ago.
@@ -329,7 +352,12 @@ function fireStatusSideEffects(resolved, suppressed = false) {
329
352
  }
330
353
  }
331
354
 
332
- async function pushPresence() {
355
+ // Compute the desired presence for the current state and hand it to the
356
+ // throttle. Called from many triggers (hook-driven state changes, the periodic
357
+ // tick, config/scan/session refreshes); transmit() collapses that into at most
358
+ // one Discord write per activityGapMs(), so callers never have to think about
359
+ // the rate limit. Best-effort throughout — presence must never crash the daemon.
360
+ function pushPresence() {
333
361
  if (!connected || !client?.user) return;
334
362
  try {
335
363
  // Resolve state ONCE — this same object decides clear-vs-push, drives the
@@ -341,7 +369,7 @@ async function pushPresence() {
341
369
  const privacyHidden = resolved._privacy?.visibility === 'hidden';
342
370
  // Global snooze (`claude-rpc pause`) — clears the card while the deadline
343
371
  // is in the future. Re-checked every tick, so expiry resumes presence
344
- // automatically (the 'cleared' stamp differs from the next frame's hash).
372
+ // automatically (the next frame's hash differs from the cleared sentinel).
345
373
  const pausedUntil = pauseUntil();
346
374
  const suppressed = privacyHidden || !!pausedUntil || (resolved.status === 'stale' && hideWhenStale);
347
375
 
@@ -352,44 +380,100 @@ async function pushPresence() {
352
380
  fireStatusSideEffects(resolved, suppressed);
353
381
 
354
382
  if (suppressed) {
355
- const stamp = 'cleared';
356
- if (lastPayloadHash === stamp) return;
357
- lastPayloadHash = stamp;
358
383
  // Wipe effectiveSessionStart so the next active push gets a fresh
359
384
  // elapsed timer rather than counting from a previous session.
360
385
  effectiveSessionStart = null;
361
- await client.user.clearActivity();
362
386
  const reason = pausedUntil
363
387
  ? `paused until ${new Date(pausedUntil).toLocaleTimeString()}`
364
388
  : privacyHidden ? 'privacy=hidden in this project' : 'stale — Claude Code not running';
365
- log(`Presence cleared (${reason})`);
389
+ transmit('clear', null, `Presence cleared (${reason})`);
366
390
  return;
367
391
  }
368
392
 
369
393
  const activity = buildActivity({ resolved });
370
- const hash = JSON.stringify(activity);
371
- if (hash === lastPayloadHash) return;
372
- lastPayloadHash = hash;
373
- await client.user.setActivity(activity);
374
- log('Presence updated:', activity.details || '-', '|', activity.state || '-');
394
+ transmit('set', activity, `Presence updated: ${activity.details || '-'} | ${activity.state || '-'}`);
395
+ } catch (e) {
396
+ log('pushPresence failed:', e.message);
397
+ }
398
+ }
399
+
400
+ // Minimum gap between Discord writes, read live so a config reload takes effect.
401
+ // Floored at 2s as a sanity minimum; the 4s default keeps us under Discord's
402
+ // ~5-per-20s SET_ACTIVITY limit (see default-config.minActivityGapMs).
403
+ function activityGapMs() {
404
+ return Math.max(2000, config.minActivityGapMs || 4000);
405
+ }
406
+
407
+ // Sentinel for a clear — can never collide with a real activity hash, since
408
+ // JSON.stringify of an activity object always begins with '{'.
409
+ const CLEAR_HASH = 'cleared';
410
+
411
+ // Route a desired payload (kind:'set'|'clear') through the rate-limit throttle.
412
+ // 'send' writes now; 'defer' stashes the LATEST payload and arms a single flush
413
+ // timer; 'skip' means the wire already shows this exact frame.
414
+ function transmit(kind, activity, logMsg) {
415
+ const hash = kind === 'clear' ? CLEAR_HASH : JSON.stringify(activity);
416
+ const d = throttleDecision({
417
+ hash, lastSentHash, lastSentAt,
418
+ now: Date.now(), gapMs: activityGapMs(), flushPending: !!flushTimer,
419
+ });
420
+ if (d.action === 'skip') { pendingSend = null; return; }
421
+ if (d.action === 'send') { doSend({ kind, activity, hash, logMsg }); return; }
422
+ // defer — remember the LATEST payload and flush it once when the gap expires.
423
+ pendingSend = { kind, activity, hash, logMsg };
424
+ if (!flushTimer) {
425
+ flushTimer = setTimeout(flushPendingSend, d.waitMs);
426
+ if (flushTimer.unref) flushTimer.unref();
427
+ }
428
+ }
429
+
430
+ function flushPendingSend() {
431
+ flushTimer = null;
432
+ const p = pendingSend;
433
+ pendingSend = null;
434
+ if (!p || !connected || !client?.user) return;
435
+ if (p.hash === lastSentHash) return; // state reverted to what's already shown
436
+ doSend(p);
437
+ }
438
+
439
+ // The single point that actually writes to Discord. lastSentAt advances on every
440
+ // ATTEMPT (so a failure backs off a full gap before retrying); lastSentHash
441
+ // advances ONLY on success — a dropped/rate-limited write is therefore retried
442
+ // by the next push rather than being recorded as success and stranded.
443
+ async function doSend({ kind, activity, hash, logMsg }) {
444
+ lastSentAt = Date.now();
445
+ try {
446
+ if (kind === 'clear') await client.user.clearActivity();
447
+ else await client.user.setActivity(activity);
448
+ lastSentHash = hash;
449
+ log(logMsg);
375
450
  } catch (e) {
376
451
  log('setActivity failed:', e.message, '|', e.stack?.split('\n').slice(0, 3).join(' | '));
377
- // A failed setActivity usually means the IPC pipe died WITHOUT the client
378
- // emitting a 'disconnected' event (Discord restart, socket reset, OS
379
- // sleep). Left alone, `connected` stays true and the daemon goes silently
380
- // dark forever. Tear the client down and force a backoff reconnect so we
381
- // self-heal. Guarded to connection-shaped errors so a one-off API hiccup
382
- // doesn't needlessly bounce a healthy socket.
452
+ // A failed write usually means the IPC pipe died WITHOUT a 'disconnected'
453
+ // event (Discord restart, socket reset, OS sleep). Left alone, `connected`
454
+ // stays true and the daemon goes silently dark forever. Tear down and force
455
+ // a backoff reconnect so we self-heal guarded to connection-shaped errors
456
+ // so a one-off app error (e.g. a rate-limit rejection) just waits for the
457
+ // next push's retry instead of needlessly bouncing a healthy socket.
383
458
  if (isConnectionError(e)) {
384
459
  log('setActivity error looks connection-level — forcing reconnect');
385
460
  connected = false;
386
- lastPayloadHash = '';
461
+ resetTransmit();
387
462
  try { client?.destroy(); } catch { /* already gone */ }
388
463
  scheduleReconnect('setActivity failed');
389
464
  }
390
465
  }
391
466
  }
392
467
 
468
+ // Force the next pushPresence to write even if the activity is byte-identical,
469
+ // and drop any queued flush. Called when an input changed (config/aggregate/
470
+ // scan/sessions) or the connection bounced — the new frame must go out fresh.
471
+ function resetTransmit() {
472
+ lastSentHash = '';
473
+ pendingSend = null;
474
+ if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
475
+ }
476
+
393
477
  // Heuristic: does this error indicate the IPC transport itself is dead
394
478
  // (vs. a transient/application-level failure)? Matches the common broken-pipe
395
479
  // / closed-socket shapes from our IPC client (src/discord-ipc.js) and the net
@@ -405,6 +489,10 @@ function isConnectionError(e) {
405
489
 
406
490
  async function connect() {
407
491
  if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
492
+ // While login() is in flight, connected===false and reconnectTimer===null
493
+ // both hold, so the watchdog's "down, nothing pending" branch would spawn a
494
+ // second connect() that overwrites `client` and orphans this socket. Block it.
495
+ connecting = true;
408
496
  client = new Client({ clientId: config.clientId, transport: { type: 'ipc' } });
409
497
 
410
498
  client.on('ready', () => {
@@ -412,7 +500,7 @@ async function connect() {
412
500
  // Reset backoff so the next outage also starts at RECONNECT_BASE_MS.
413
501
  reconnectDelayMs = RECONNECT_BASE_MS;
414
502
  log('Discord RPC connected as', client.user?.username);
415
- lastPayloadHash = '';
503
+ resetTransmit();
416
504
  pushPresence();
417
505
  });
418
506
  client.on('disconnected', () => {
@@ -422,6 +510,8 @@ async function connect() {
422
510
  try { await client.login(); }
423
511
  catch (e) {
424
512
  scheduleReconnect(`Discord login failed: ${e.message}`);
513
+ } finally {
514
+ connecting = false;
425
515
  }
426
516
  }
427
517
 
@@ -452,12 +542,12 @@ function watchFiles() {
452
542
  { path: CONFIG_PATH, label: 'config', onChange: () => {
453
543
  log('Config changed — reloading');
454
544
  config = loadConfigWithLog();
455
- lastPayloadHash = '';
545
+ resetTransmit();
456
546
  pushPresence();
457
547
  } },
458
548
  { path: AGGREGATE_PATH, label: 'aggregate', onChange: () => {
459
549
  aggregate = readAggregate() || aggregate;
460
- lastPayloadHash = '';
550
+ resetTransmit();
461
551
  pushPresence();
462
552
  } },
463
553
  ];
@@ -535,7 +625,7 @@ async function runBackgroundScan({ force = false } = {}) {
535
625
  const { aggregate: agg, scanned, skipped, removed, total } = scan({ force });
536
626
  aggregate = agg;
537
627
  log(`Scan complete: ${scanned} parsed / ${skipped} cached / ${removed} removed / ${total} total in ${Date.now() - t0}ms — allHours=${(agg.activeMs / 3_600_000).toFixed(1)}, sessions=${agg.sessions}, tokens=${(agg.inputTokens + agg.outputTokens)}`);
538
- lastPayloadHash = '';
628
+ resetTransmit();
539
629
  pushPresence();
540
630
  } catch (e) {
541
631
  log('Scan failed:', e.message);
@@ -587,7 +677,7 @@ function refreshLiveSessions() {
587
677
  liveSessions = next;
588
678
  if (next.length !== prevCount) {
589
679
  log('Concurrent sessions:', next.length, next.map((s) => `${s.project}(${s.ageSec}s)`).join(', '));
590
- lastPayloadHash = '';
680
+ resetTransmit();
591
681
  pushPresence();
592
682
  }
593
683
  } catch (e) {
@@ -609,7 +699,7 @@ const HEALTH_CHECK_MS = 30_000;
609
699
  setInterval(() => {
610
700
  try {
611
701
  // Half-open: flag says connected but there's no usable user handle.
612
- if (connected && !client?.user) {
702
+ if (connected && !client?.user && !connecting) {
613
703
  log('Watchdog: connected but no user handle — forcing reconnect');
614
704
  connected = false;
615
705
  try { client?.destroy(); } catch { /* already gone */ }
@@ -618,7 +708,7 @@ setInterval(() => {
618
708
  }
619
709
  // Down with nothing scheduled to bring us back. scheduleReconnect is a
620
710
  // no-op when a timer is already pending, so this can't stack retries.
621
- if (!connected && !reconnectTimer) {
711
+ if (!connected && !reconnectTimer && !connecting) {
622
712
  log('Watchdog: disconnected with no reconnect pending — forcing reconnect');
623
713
  scheduleReconnect('watchdog: no retry pending');
624
714
  }