claude-rpc 0.24.1 → 1.0.2

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
@@ -193,7 +193,7 @@ claude-rpc community on # explicit consent flow (upgraders / re-enable
193
193
  claude-rpc community report # one-shot manual flush (testing)
194
194
  ```
195
195
 
196
- 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.
196
+ 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. Every worker route — path, params, response — is documented in [`docs/WORKER-API.md`](docs/WORKER-API.md).
197
197
 
198
198
  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.
199
199
 
@@ -273,7 +273,7 @@ Frames have a `requires` field; the daemon skips a frame when any of its require
273
273
  }
274
274
  ```
275
275
 
276
- The full default config is in [`src/default-config.js`](src/default-config.js) — that's the canonical list of every key. ~140 template variables are available; `claude-rpc vars` is the source of truth.
276
+ The full default config is in [`src/default-config.js`](src/default-config.js) — that's the canonical list of every key. Over 200 template variables are available; `claude-rpc vars` is the authoritative list.
277
277
 
278
278
  ## claude code plugin
279
279
 
@@ -330,6 +330,36 @@ Exit codes: `0` ok · `1` user error · `2` system error · `3` wrong state. `--
330
330
 
331
331
  </details>
332
332
 
333
+ <details>
334
+ <summary><b>complete removal</b></summary>
335
+
336
+ <br/>
337
+
338
+ `claude-rpc uninstall` removes everything that *respawns or registers* the tool:
339
+ its hooks from `~/.claude/settings.json` (only its own — third-party hooks are
340
+ left untouched) and the login-autostart entry (`systemd --user` unit on Linux,
341
+ LaunchAgent on macOS, `HKCU\…\Run` value + `.vbs` shim on Windows). After it
342
+ runs, nothing brings the daemon back.
343
+
344
+ It deliberately **leaves your data in place**: `config.json` (under the per-OS
345
+ config dir), the stats in `~/.claude-rpc/`, and the transient files in the temp
346
+ dir (which clear on reboot anyway). Two things it does *not* touch, by design:
347
+
348
+ - a daemon already running this session keeps running until `claude-rpc stop` or
349
+ reboot (the autostart-managed one is stopped; a manually `start`ed one isn't);
350
+ - the MCP server, if you wired it — that's a separate `claude-rpc mcp uninstall`.
351
+
352
+ For a clean wipe on macOS/Linux:
353
+
354
+ ```sh
355
+ claude-rpc mcp uninstall # only if you ran `mcp install`
356
+ claude-rpc stop
357
+ claude-rpc uninstall
358
+ rm -rf ~/.claude-rpc # stats; plus the per-OS config dir if you want it gone
359
+ ```
360
+
361
+ </details>
362
+
333
363
  ## troubleshooting
334
364
 
335
365
  **First step is always `claude-rpc doctor`** — it checks Node, hook registration, daemon liveness, Discord IPC, aggregate freshness, and privacy resolution, with a one-line fix hint per failure.
@@ -346,6 +376,39 @@ Exit codes: `0` ok · `1` user error · `2` system error · `3` wrong state. `--
346
376
 
347
377
  </details>
348
378
 
379
+ ## platform support
380
+
381
+ **macOS and Linux are first-class.** The daemon reacts to on-disk changes with a
382
+ directory watcher (`fs.watch` over FSEvents/inotify — instant) *and* an mtime
383
+ poll as a lazy backstop, so an event is never missed. Login autostart is a
384
+ per-user [`launchd` LaunchAgent](src/install.js) on macOS and a
385
+ [`systemd --user` service](src/install.js) on Linux; both start the daemon at
386
+ login without `sudo` and don't fight a manual `claude-rpc stop`.
387
+
388
+ **Windows is supported** — grab the portable exe (no Node required), then
389
+ `claude-rpc setup`. One caveat documented honestly: `fs.watch` on Windows drops
390
+ events when a writer commits via atomic rename (which the state file, pause file,
391
+ scanner, and settings GUI all do), so on Windows the **mtime poll is effectively
392
+ the primary path** and runs an order of magnitude faster (every ~3s vs ~30s
393
+ elsewhere) to compensate. It's a reliable fallback, not yet the native-watch
394
+ confidence macOS/Linux get — closing that gap is on the [roadmap](ROADMAP.md).
395
+ Autostart is an `HKCU\…\Run` entry launched through a windowless `.vbs` shim (no
396
+ scheduled task, by design — see [`SECURITY.md`](SECURITY.md)).
397
+
398
+ Everything else — the scanner, dashboards, cards, the worker client — is
399
+ platform-neutral. CI runs the full suite on Node 18/20/22 and builds the macOS
400
+ and Windows binaries every release.
401
+
402
+ ## versioning
403
+
404
+ What's a stable contract and what's an internal detail you shouldn't build on:
405
+ [`VERSIONING.md`](VERSIONING.md). Short version — the worker HTTP API, the CLI
406
+ (commands, flags, exit codes), the `config.json` schema, the `claude-rpc vars`
407
+ template variables, and the local data formats are stable and semver-governed;
408
+ daemon internals, the scan cache, the worker's KV layout, and exact wording are
409
+ not. The worker's HTTP surface is documented in
410
+ [`docs/WORKER-API.md`](docs/WORKER-API.md).
411
+
349
412
  ## development
350
413
 
351
414
  ```sh
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-rpc",
3
- "version": "0.24.1",
3
+ "version": "1.0.2",
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
@@ -117,6 +117,15 @@ let lastSentHash = '';
117
117
  let lastSentAt = 0; // ms of the last write ATTEMPT (success or fail → back off either way)
118
118
  let pendingSend = null; // { kind, activity, hash, logMsg } coalesced during the gap; latest wins
119
119
  let flushTimer = null;
120
+ // Timestamps (ms) of recent write ATTEMPTS (set + clear both hit Discord), for
121
+ // the sliding-window rate cap below. The per-write gap alone leaves the daemon
122
+ // riding Discord's ~5-per-20s SET_ACTIVITY ceiling — many triggers fire and a
123
+ // 4s gap still permits 6 writes in some 20s windows, which makes Discord EMPTY
124
+ // the presence (card → app name + bare timer). This bounds the COUNT per window
125
+ // regardless of how many triggers fire. Deliberately NOT cleared by
126
+ // resetTransmit (a scan/session/config refresh forces a re-render but must not
127
+ // reset the rate budget, or those refreshes could burst us past the limit).
128
+ let recentSends = [];
120
129
  // Last status we acted on for outbound side-effects (webhook / desktop notify).
121
130
  // Tracked separately from the render hash so we fire once per transition.
122
131
  let lastNotifiedStatus = null;
@@ -407,12 +416,24 @@ function pushPresence() {
407
416
  }
408
417
 
409
418
  // Minimum gap between Discord writes, read live so a config reload takes effect.
410
- // Floored at 2s as a sanity minimum; the 4s default keeps us under Discord's
411
- // ~5-per-20s SET_ACTIVITY limit (see default-config.minActivityGapMs).
419
+ // Floored at 2s as a sanity minimum. The gap spaces consecutive writes; the
420
+ // sliding-window cap below is what actually bounds writes-per-window so we stay
421
+ // under Discord's ~5-per-20s SET_ACTIVITY limit (see default-config).
412
422
  function activityGapMs() {
413
423
  return Math.max(2000, config.minActivityGapMs || 4000);
414
424
  }
415
425
 
426
+ // Sliding-window rate cap parameters, read live like the gap. Defaults to 4
427
+ // writes per 20s — one under Discord's ~5-per-20s SET_ACTIVITY ceiling, so we
428
+ // stay clear of it even if Discord counts its window inclusively or our clock
429
+ // drifts. Floored so a bad config can't disable the protection entirely.
430
+ function activityWindowMs() {
431
+ return Math.max(5000, config.activityWindowMs || 20000);
432
+ }
433
+ function maxActivityWrites() {
434
+ return Math.max(2, config.maxActivityWrites || 4);
435
+ }
436
+
416
437
  // Sentinel for a clear — can never collide with a real activity hash, since
417
438
  // JSON.stringify of an activity object always begins with '{'.
418
439
  const CLEAR_HASH = 'cleared';
@@ -425,6 +446,7 @@ function transmit(kind, activity, logMsg) {
425
446
  const d = throttleDecision({
426
447
  hash, lastSentHash, lastSentAt,
427
448
  now: Date.now(), gapMs: activityGapMs(), flushPending: !!flushTimer,
449
+ recentSends, windowMs: activityWindowMs(), maxPerWindow: maxActivityWrites(),
428
450
  });
429
451
  if (d.action === 'skip') { pendingSend = null; return; }
430
452
  if (d.action === 'send') { doSend({ kind, activity, hash, logMsg }); return; }
@@ -451,6 +473,12 @@ function flushPendingSend() {
451
473
  // by the next push rather than being recorded as success and stranded.
452
474
  async function doSend({ kind, activity, hash, logMsg }) {
453
475
  lastSentAt = Date.now();
476
+ // Record the attempt and drop anything older than the window — this is the
477
+ // ledger throttleDecision reads to enforce the per-window cap. Counts every
478
+ // attempt (set AND clear) since both are SET_ACTIVITY writes to Discord.
479
+ recentSends.push(lastSentAt);
480
+ const cutoff = lastSentAt - activityWindowMs();
481
+ if (recentSends[0] <= cutoff) recentSends = recentSends.filter((t) => t > cutoff);
454
482
  try {
455
483
  if (kind === 'clear') await client.user.clearActivity();
456
484
  else await client.user.setActivity(activity);
@@ -28,6 +28,17 @@ export const DEFAULT_CONFIG = {
28
28
  // collapse to the latest and flush when the gap expires. 4s stays safely
29
29
  // under the limit; lower values risk Discord throttling the card.
30
30
  minActivityGapMs: 4000,
31
+ // Hard sliding-window cap on Discord writes, on top of minActivityGapMs. The
32
+ // gap alone only spaces *consecutive* writes — with a 4s gap a 20s window can
33
+ // still catch 6 writes (floor(20000/4000)+1) once several triggers (the
34
+ // rotation tick, a scan, a live-session change, a config reload) coincide,
35
+ // and that 6th write is what makes Discord EMPTY the presence (the card
36
+ // collapses to just the app name + elapsed timer, no details or art). This
37
+ // bounds the COUNT per window: no more than maxActivityWrites writes per
38
+ // activityWindowMs, whatever fires. Default 4-per-20s leaves a write of
39
+ // headroom under Discord's ~5-per-20s ceiling.
40
+ maxActivityWrites: 4,
41
+ activityWindowMs: 20000,
31
42
  rescanIntervalSec: 300,
32
43
  idleThresholdSec: 60,
33
44
  // Time (minutes) of no hook activity AND no live transcripts on disk before
package/src/gist.js CHANGED
@@ -27,9 +27,11 @@ function ghQuote(a) {
27
27
  return /[\s"]/.test(a) ? `"${String(a).replace(/"/g, '""')}"` : a;
28
28
  }
29
29
  function gh(args, opts = {}) {
30
+ // windowsHide so the gh.exe/cmd shim doesn't flash a console window on
31
+ // Windows (shell:true routes through cmd.exe; hide that too). No-op elsewhere.
30
32
  return WIN
31
- ? spawnSync('gh', args.map(ghQuote), { ...opts, shell: true })
32
- : spawnSync('gh', args, opts);
33
+ ? spawnSync('gh', args.map(ghQuote), { ...opts, shell: true, windowsHide: true })
34
+ : spawnSync('gh', args, { ...opts, windowsHide: true });
33
35
  }
34
36
 
35
37
  // Bare fetch has no total timeout; a stalled GitHub endpoint would hang the
package/src/install.js CHANGED
@@ -447,15 +447,15 @@ export function installMcp({ exePath, scope = 'user' } = {}) {
447
447
  const { command, args } = mcpServerCommand(exePath);
448
448
  const winShell = process.platform === 'win32';
449
449
  // Replace any stale entry first so re-running is idempotent (ignore failure).
450
- spawnSync('claude', ['mcp', 'remove', 'claude-rpc', '--scope', scope], { stdio: 'ignore', shell: winShell });
451
- const r = spawnSync('claude', ['mcp', 'add', 'claude-rpc', '--scope', scope, '--', command, ...args], { stdio: 'inherit', shell: winShell });
450
+ spawnSync('claude', ['mcp', 'remove', 'claude-rpc', '--scope', scope], { stdio: 'ignore', shell: winShell, windowsHide: true });
451
+ const r = spawnSync('claude', ['mcp', 'add', 'claude-rpc', '--scope', scope, '--', command, ...args], { stdio: 'inherit', shell: winShell, windowsHide: true });
452
452
  if (r.error && r.error.code === 'ENOENT') return { ok: false, reason: 'no-claude', command, args };
453
453
  if (r.status !== 0) return { ok: false, reason: 'add-failed', code: r.status, command, args };
454
454
  return { ok: true, command, args, scope };
455
455
  }
456
456
 
457
457
  export function uninstallMcp({ scope = 'user' } = {}) {
458
- const r = spawnSync('claude', ['mcp', 'remove', 'claude-rpc', '--scope', scope], { stdio: 'inherit', shell: process.platform === 'win32' });
458
+ const r = spawnSync('claude', ['mcp', 'remove', 'claude-rpc', '--scope', scope], { stdio: 'inherit', shell: process.platform === 'win32', windowsHide: true });
459
459
  if (r.error && r.error.code === 'ENOENT') return { ok: false, reason: 'no-claude' };
460
460
  return { ok: r.status === 0 };
461
461
  }
@@ -666,6 +666,7 @@ function promoteNpxToGlobal() {
666
666
  const r = spawnSync('npm', ['install', '-g', `claude-rpc@${VERSION}`], {
667
667
  encoding: 'utf8',
668
668
  shell: process.platform === 'win32', // npm is npm.cmd on Windows
669
+ windowsHide: true, // don't flash a console window
669
670
  });
670
671
  if (r.error || r.status !== 0) {
671
672
  // The piped npm chatter only matters when it failed.
@@ -687,6 +688,7 @@ function warnIfStale() {
687
688
  const r = spawnSync('npm', ['view', 'claude-rpc', 'version'], {
688
689
  encoding: 'utf8', timeout: 4000,
689
690
  shell: process.platform === 'win32', // npm is npm.cmd on Windows
691
+ windowsHide: true, // don't flash a console window
690
692
  });
691
693
  const latest = (r.stdout || '').trim();
692
694
  if (!latest || latest === VERSION) return;
package/src/presence.js CHANGED
@@ -113,9 +113,35 @@ export function shouldShowGithubButton(p, state) {
113
113
  // - 'defer' : inside the gap, or a flush is already queued — coalesce to the
114
114
  // LATEST payload and let the caller flush it in `waitMs`, so the
115
115
  // final state of every burst still lands, once, under the limit
116
- export function throttleDecision({ hash, lastSentHash, lastSentAt, now, gapMs, flushPending }) {
116
+ //
117
+ // The per-write `gapMs` only spaces *consecutive* writes; it does NOT bound
118
+ // writes-per-window. With gapMs == windowMs/maxPerWindow (the 4s default sits
119
+ // exactly on Discord's ~5-per-20s ceiling) a 20s window can still catch
120
+ // floor(windowMs/gapMs)+1 == 6 gap-spaced writes — one over the limit — because
121
+ // many independent triggers (the rotation tick re-rendering {toolElapsed}, a
122
+ // background scan, a live-session change, a config reload) each want to write.
123
+ // That extra write is exactly what makes Discord EMPTY the presence (the card
124
+ // collapses to just the app name + elapsed timer). So when `recentSends` (the
125
+ // timestamps of recent writes, set+clear) and `maxPerWindow` are supplied we
126
+ // add a hard sliding-window cap on top of the gap: defer until the oldest write
127
+ // in the window expires, guaranteeing no more than `maxPerWindow` writes in any
128
+ // `windowMs` no matter how many triggers fire. Omitting them keeps the old
129
+ // gap-only behavior (used by tests that predate the cap).
130
+ export function throttleDecision({ hash, lastSentHash, lastSentAt, now, gapMs, flushPending, recentSends, windowMs, maxPerWindow }) {
117
131
  if (hash === lastSentHash) return { action: 'skip', waitMs: 0 };
118
- const waitMs = Math.max(0, (lastSentAt || 0) + gapMs - now);
132
+ let waitMs = Math.max(0, (lastSentAt || 0) + gapMs - now);
133
+ if (maxPerWindow && windowMs && Array.isArray(recentSends)) {
134
+ // Strict window (age < windowMs) so a write exactly windowMs old has already
135
+ // fallen out — matches a "requests in the last 20s" limiter and keeps the
136
+ // boundary case from sneaking a write over the line.
137
+ const inWindow = recentSends.filter((t) => t > now - windowMs);
138
+ if (inWindow.length >= maxPerWindow) {
139
+ // The oldest write we must let expire before another may go out. Once it
140
+ // ages past windowMs the window drops below the cap and we can send.
141
+ const oldest = inWindow[inWindow.length - maxPerWindow];
142
+ waitMs = Math.max(waitMs, oldest + windowMs - now);
143
+ }
144
+ }
119
145
  if (waitMs === 0 && !flushPending) return { action: 'send', waitMs: 0 };
120
146
  return { action: 'defer', waitMs };
121
147
  }
package/src/privacy.js CHANGED
@@ -197,7 +197,12 @@ function probeGithubPrivate(cwd) {
197
197
  execFile(
198
198
  'gh',
199
199
  ['repo', 'view', '--json', 'isPrivate', '-q', '.isPrivate'],
200
- { cwd, timeout: 1500 },
200
+ // windowsHide: on Windows `gh` is a console app; without this the daemon —
201
+ // which runs with no console of its own — pops a visible terminal window for
202
+ // the life of the probe (~1s) every time this fires (a cwd cache-miss, i.e.
203
+ // entering a project, then once per 5-min TTL). That's the stray terminal
204
+ // flash users see "sometimes" while working. No-op off Windows.
205
+ { cwd, timeout: 1500, windowsHide: true },
201
206
  (err, stdout) => {
202
207
  ghProbeInFlight.delete(cwd);
203
208
  let value = null;
@@ -133,7 +133,8 @@ server.listen(PORT, '127.0.0.1', () => {
133
133
  const opener = process.platform === 'win32' ? `start "" "${url}"`
134
134
  : process.platform === 'darwin' ? `open "${url}"`
135
135
  : `xdg-open "${url}"`;
136
- exec(opener, () => {});
136
+ // windowsHide so the cmd.exe that runs `start` doesn't flash a console.
137
+ exec(opener, { windowsHide: true }, () => {});
137
138
  }
138
139
  });
139
140
 
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.24.1';
14
+ const BAKED = '1.0.2';
15
15
 
16
16
  function readPkgVersion() {
17
17
  try {