claude-rpc 1.0.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/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/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
|
|