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 +65 -2
- package/package.json +1 -1
- package/src/daemon.js +30 -2
- package/src/default-config.js +11 -0
- package/src/gist.js +4 -2
- package/src/install.js +5 -3
- package/src/presence.js +28 -2
- package/src/privacy.js +6 -1
- package/src/server/index.js +2 -1
- package/src/version.js +1 -1
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.
|
|
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
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
|
|
411
|
-
//
|
|
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);
|
package/src/default-config.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/src/server/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|