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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-rpc",
3
- "version": "1.0.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 = '1.0.1';
14
+ const BAKED = '1.0.2';
15
15
 
16
16
  function readPkgVersion() {
17
17
  try {