claude-rpc 0.7.3 → 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/README.md CHANGED
@@ -43,6 +43,8 @@ claude-rpc start
43
43
 
44
44
  That's the whole pitch. Open Claude Code in any project — the daemon picks it up within a second. Something looks wrong? `claude-rpc doctor`.
45
45
 
46
+ > `setup` registers a Windows startup entry and wires hooks into Claude Code's `settings.json`, and the daemon reports anonymous totals by default. All of it is reversible (`claude-rpc uninstall`, `community off`) and fully documented in [`SECURITY.md`](SECURITY.md) — read it first if you want to know exactly what runs.
47
+
46
48
  The Discord *desktop* app must be running. The browser client doesn't expose the local IPC bridge that Rich Presence uses.
47
49
 
48
50
  <details>
@@ -142,6 +144,8 @@ claude-rpc community report # one-shot manual flush (testing)
142
144
 
143
145
  Each report sends only: a `sessionsDelta`, a `tokensDelta`, the claude-rpc version, OS family (`linux`/`darwin`/`win32`), and the anonymous UUID v4. No prompts, paths, models, repos, costs, usernames, or hostnames — the Worker's [`validateReport`](worker/src/index.js) is the schema of record. The full Worker source is in this repo so the privacy claim is auditable.
144
146
 
147
+ For a complete account of the sensitive things claude-rpc does — startup persistence, hook injection, every outbound request, and the exact telemetry payload — see [`SECURITY.md`](SECURITY.md). It's also the reference for supply-chain scanner findings (Socket.dev et al.): the flagged persistence and hook-injection behaviors are inherent to the tool and documented there.
148
+
145
149
  ## three pieces, glued by json files
146
150
 
147
151
  ```
package/SECURITY.md ADDED
@@ -0,0 +1,169 @@
1
+ # Security & behavior disclosure
2
+
3
+ claude-rpc is a Discord Rich Presence daemon for Claude Code. To do its job it
4
+ has to (a) register itself to start on login and (b) wire commands into Claude
5
+ Code's hook system. Both are legitimate, documented behaviors — but they are
6
+ also patterns that automated supply-chain scanners (e.g. Socket.dev) flag,
7
+ because malware persists and injects the same way. This document is the
8
+ audit trail: every sensitive thing the package does, where in the source it
9
+ lives, why it's there, its blast radius, and how to reverse or disable it.
10
+
11
+ Everything below is verifiable against the published source — there is no
12
+ minified bundle, no obfuscation, no `eval`/`new Function`, and no remote code
13
+ fetch-and-execute anywhere in `src/`.
14
+
15
+ ## TL;DR for reviewers
16
+
17
+ | Behavior | Where | Scope | Reversible? |
18
+ | --- | --- | --- | --- |
19
+ | Startup persistence | `src/install.js` → `addStartupEntry` | `HKCU` Run key, current user, no admin | Yes — `claude-rpc uninstall` / `removeStartupEntry` |
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
+ | Outbound network | `src/community.js`, `src/gist.js`, `default-config.js` asset URLs | Anonymous counters + (opt-in) gist publish + GIF assets | Telemetry: `community off`. Gist: only on explicit `badge --gist`. |
22
+ | Local subprocess | `reg.exe`, `git`, `gh` | Static args, no shell interpolation of untrusted input | n/a |
23
+
24
+ No credential access, no filesystem scanning outside `~/.claude-rpc` and Claude
25
+ Code transcripts, no keylogging, no clipboard access, no AV/EDR evasion.
26
+
27
+ ## 1. Startup persistence (Windows Run key)
28
+
29
+ **Source:** `src/install.js`, `addStartupEntry` / `removeStartupEntry`.
30
+
31
+ On Windows, `setup` writes one value:
32
+
33
+ ```
34
+ HKCU\Software\Microsoft\Windows\CurrentVersion\Run
35
+ ClaudeRPC = "<path-to-exe>" daemon
36
+ ```
37
+
38
+ - **Why:** the Discord presence is driven by a long-lived daemon. If it doesn't
39
+ restart on login, your presence silently stops working after every reboot.
40
+ - **Scope:** `HKCU` (current user) only. No admin elevation, no `HKLM`, no
41
+ service install, no scheduled task.
42
+ - **Reverse it:** `claude-rpc uninstall` deletes the value. You can also delete
43
+ it by hand in `regedit` or with
44
+ `reg delete "HKCU\...\Run" /v ClaudeRPC /f`.
45
+ - The value points at the canonical install path (`%LOCALAPPDATA%`-class dir),
46
+ not at wherever you happened to run the installer from — see
47
+ `ensureCanonicalExe`.
48
+
49
+ Non-Windows platforms get **no** persistence registration (`install()` warns
50
+ and skips it).
51
+
52
+ ## 2. Hook injection into Claude Code
53
+
54
+ **Source:** `src/install.js`, `installHooks` / `uninstallHooks`.
55
+
56
+ `setup` adds command hooks to Claude Code's `settings.json` for eight lifecycle
57
+ events: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`,
58
+ `Stop`, `SubagentStop`, `Notification`, `SessionEnd`. Each entry looks like:
59
+
60
+ ```jsonc
61
+ { "matcher": "", "hooks": [{ "type": "command", "command": "\"<exe>\" hook PostToolUse" }] }
62
+ ```
63
+
64
+ - **Why:** this *is* the integration. Claude Code's hook system is the
65
+ supported, documented way for a tool to observe session lifecycle. The hook
66
+ reads the event JSON on stdin, updates `~/.claude-rpc/state.json`, and prints
67
+ `{"continue":true}`. It never blocks, rewrites, or vetoes a tool call — see
68
+ `src/hook.js`, `processHookEvent`.
69
+ - **What the hook reads:** tool name, file path of the active tool, token usage
70
+ counters, and `git push`/`git commit` detection (for the "just shipped"
71
+ card). It writes only to local state/log files. It does not read file
72
+ *contents*, prompts, or responses beyond the usage counters Claude provides.
73
+ - **Scope:** the installer only ever touches entries whose command matches
74
+ `isOurHookCommand` (contains `claude-rpc` or `hook.js`). It will not modify,
75
+ reorder, or delete anyone else's hooks.
76
+ - **Reverse it:** `claude-rpc uninstall` (or `uninstallHooks`) strips exactly
77
+ the entries it added and leaves the rest of `settings.json` intact.
78
+
79
+ ## 3. Outbound network
80
+
81
+ There are three distinct network behaviors. Two are optional; one is cosmetic.
82
+
83
+ ### 3a. Community totals (telemetry) — ON by default for fresh installs
84
+
85
+ **Source:** `src/community.js`; endpoint in `src/default-config.js`
86
+ (`community.endpoint`); receiving end is the full Worker source in
87
+ [`worker/src/index.js`](worker/src/index.js).
88
+
89
+ A fresh install mints an anonymous UUID v4 and the daemon POSTs to
90
+ `https://claude-rpc-totals.claude-rpc.workers.dev/report` every 30 minutes.
91
+ The **complete** payload (see `buildPayload`, enforced by the Worker's
92
+ `validateReport`) is:
93
+
94
+ ```json
95
+ {
96
+ "instanceId": "<random UUID v4>",
97
+ "sessionsDelta": 3,
98
+ "tokensDelta": 142000,
99
+ "version": "0.7.3",
100
+ "osFamily": "win32",
101
+ "ts": 1716500000000
102
+ }
103
+ ```
104
+
105
+ What is **not** sent, ever: prompts, responses, file paths, file contents,
106
+ project/repo names, models, cost figures, usernames, hostnames, IP (beyond what
107
+ any HTTP request inherently exposes to Cloudflare's edge), or absolute counter
108
+ values — only forward deltas since the last accepted report.
109
+
110
+ - **Opt out any time:** `claude-rpc community off`.
111
+ - **Upgraders are protected:** anyone upgrading from a pre-v0.7 config is
112
+ written `community.enabled: false`; re-enabling requires the explicit consent
113
+ flow `claude-rpc community on`, which prints the payload schema first.
114
+ - **Why on by default:** the live badges in the README aggregate these counters.
115
+ The trade-off is disclosed here, in the README, and at install time.
116
+ - **Auditable:** the Worker persists only two running integers and a 30-day
117
+ `seen:<instanceId>` dedup marker. The source is in this repo.
118
+
119
+ ### 3b. Gist badge publishing — only on explicit command
120
+
121
+ **Source:** `src/gist.js`. Runs **only** when you run `claude-rpc badge --gist`.
122
+ Publishes a badge SVG to *your own* GitHub gist via the `gh` CLI or a
123
+ `GH_TOKEN` you supply with `gist` scope. Hits `api.github.com` /
124
+ `gist.github.com`. Never runs unattended, never on install, never from the
125
+ daemon.
126
+
127
+ ### 3c. Presence GIF assets — Discord-side only
128
+
129
+ `default-config.js` references `https://cdn.qualit.ly/clawd-*.gif`. These URLs
130
+ are handed to Discord as image keys; **Discord's** client fetches them to render
131
+ the card. The daemon itself doesn't download them. Swap them for your own URLs
132
+ in `config.json` if you prefer.
133
+
134
+ ## 4. Local subprocesses
135
+
136
+ All `child_process` use is static-argument and visible:
137
+
138
+ - `reg.exe add/delete` — the Run key above (`src/install.js`).
139
+ - `git` — read last commit subject / branch for the "just shipped" card
140
+ (`src/git.js`).
141
+ - `gh repo view --json isPrivate` — auto-hide GitHub-private repos from the card
142
+ (`src/privacy.js`); 1.5s timeout, silent skip if `gh` is absent.
143
+ - `gh gist` — only under 3b above.
144
+
145
+ No subprocess interpolates untrusted/remote input into a shell. The one
146
+ `shell: true` call (`verifyHookPipe`) uses only static, trusted args and is
147
+ documented inline as such.
148
+
149
+ ## 5. What it stores locally
150
+
151
+ Under `~/.claude-rpc/` (a.k.a. `%APPDATA%\claude-rpc\` on Windows):
152
+ `config.json`, `state.json`, `aggregate.json`, `events.jsonl` (rotated at 5 MB),
153
+ `private-list.json`, `community-cursor.json`. The scanner also reads Claude Code
154
+ transcript files to build aggregates. None of this leaves your machine except
155
+ the minimal telemetry in 3a.
156
+
157
+ ## 6. Privacy controls
158
+
159
+ Independent of telemetry, the Discord card has a privacy valve (`src/privacy.js`):
160
+ per-project `.claude-rpc.json`, a runtime private-list, config glob patterns, and
161
+ `gh`-based auto-hide of private repos. Levels: `public` / `name-only` / `hidden`.
162
+ Local dashboards and aggregates are never redacted — privacy is a one-way valve
163
+ from local state to Discord only.
164
+
165
+ ## Reporting a vulnerability
166
+
167
+ Open an issue at https://github.com/rar-file/claude-rpc/issues, or for anything
168
+ sensitive email c.archer.simmons@gmail.com. Please include version
169
+ (`claude-rpc --version`), OS, and repro steps.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-rpc",
3
- "version": "0.7.3",
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",
@@ -57,6 +57,7 @@
57
57
  "src",
58
58
  "config.example.json",
59
59
  "LICENSE",
60
- "README.md"
60
+ "README.md",
61
+ "SECURITY.md"
61
62
  ]
62
63
  }
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/doctor.js CHANGED
@@ -228,19 +228,30 @@ function checkDaemonLog() {
228
228
  }
229
229
  const ageMin = (Date.now() - st.mtimeMs) / 60_000;
230
230
  const sizeKB = (st.size / 1024).toFixed(1);
231
- // Look for "Discord RPC connected" in the tail to confirm Discord IPC.
232
- let connected = false;
231
+ // Infer live IPC state from the log. The daemon connects once and stays
232
+ // connected without re-logging, so grepping only for "connected" produces
233
+ // a false warning on a long-lived daemon. Instead: "Discord RPC connected",
234
+ // "Presence updated", and "Presence cleared" all imply a live connection —
235
+ // the latter two only log *after* the daemon's `connected` guard passes and
236
+ // a setActivity/clearActivity succeeds (see daemon.js). A later "retry in
237
+ // Ns" / "login failed" / "disconnected" line means it dropped. Whichever
238
+ // happened most recently wins.
239
+ let ipc = 'unknown';
233
240
  try {
234
- const tail = readFileSync(LOG_PATH, 'utf8').split('\n').slice(-50).join('\n');
235
- connected = /Discord RPC connected/i.test(tail);
236
- } catch { /* log unreadable connected stays false, warn check renders */ }
237
- if (connected) {
241
+ for (const line of readFileSync(LOG_PATH, 'utf8').split('\n').slice(-80)) {
242
+ if (/Discord RPC connected|Presence updated|Presence cleared/i.test(line)) ipc = 'up';
243
+ else if (/retry in \d+s|login failed|Discord disconnected/i.test(line)) ipc = 'down';
244
+ }
245
+ } catch { /* log unreadable — ipc stays 'unknown', warn renders */ }
246
+ if (ipc === 'up') {
238
247
  check('discord IPC connection', 'pass',
239
- `${sizeKB} KB log · last write ${ageMin.toFixed(1)} min ago`);
240
- } else {
241
- check('discord IPC connection', 'warn',
242
- `log shows no recent "connected" line`,
248
+ `connected · ${sizeKB} KB log · last write ${ageMin.toFixed(1)} min ago`);
249
+ } else if (ipc === 'down') {
250
+ check('discord IPC connection', 'warn', 'daemon is reconnecting to Discord',
243
251
  'is the discord desktop client running? rpc only works via desktop, not browser');
252
+ } else {
253
+ check('discord IPC connection', 'warn', 'no connection activity in the log yet',
254
+ 'start the daemon with discord desktop running');
244
255
  }
245
256
  }
246
257
 
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.3';
14
+ const BAKED = '0.8.0';
15
15
 
16
16
  function readPkgVersion() {
17
17
  try {