claude-rpc 0.16.0 → 0.16.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": "0.16.0",
3
+ "version": "0.16.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/cli.js CHANGED
@@ -656,7 +656,25 @@ function showPreview() {
656
656
  state.usage = readUsageCache();
657
657
  const vars = buildVars(state, config, aggregate);
658
658
  const p = config.presence || {};
659
- const frames = (Array.isArray(p.rotation) ? p.rotation : [{ details: p.details, state: p.state }]);
659
+ // Preview the frames the daemon ACTUALLY uses: byStatus (base frame + its
660
+ // rotation, per status) when present — pickFrames in the daemon prefers it
661
+ // — falling back to the legacy top-level rotation for old configs.
662
+ const sections = [];
663
+ if (p.byStatus && typeof p.byStatus === 'object' && Object.keys(p.byStatus).length) {
664
+ for (const [status, sb] of Object.entries(p.byStatus)) {
665
+ if (!sb || typeof sb !== 'object') continue;
666
+ const frames = [
667
+ { details: sb.details, state: sb.state },
668
+ ...(Array.isArray(sb.rotation) ? sb.rotation : []),
669
+ ];
670
+ sections.push({ title: status, frames });
671
+ }
672
+ } else {
673
+ sections.push({
674
+ title: null,
675
+ frames: Array.isArray(p.rotation) ? p.rotation : [{ details: p.details, state: p.state }],
676
+ });
677
+ }
660
678
 
661
679
  console.log('');
662
680
  console.log(` ${c.bold}${c.magenta}◆ Presence preview${c.reset} ${c.dim}— how Discord renders each rotation frame${c.reset}`);
@@ -670,19 +688,22 @@ function showPreview() {
670
688
  console.log(` ${c.dim}small image:${c.reset} ${smallHidden ? c.dim + '(hidden)' + c.reset : c.cyan + smallKey + c.reset} ${c.dim}· tooltip:${c.reset} ${smallText}`);
671
689
  console.log('');
672
690
 
673
- frames.forEach((frame, i) => {
674
- const passes = framePasses(frame, vars);
675
- const reqs = frame.requires ? (Array.isArray(frame.requires) ? frame.requires : [frame.requires]) : [];
676
- const tag = passes
677
- ? `${c.green}● live${c.reset}`
678
- : `${c.dim}○ skipped (requires ${reqs.join(', ')})${c.reset}`;
679
- const details = fillTemplate(frame.details || '', vars);
680
- const stateLine = fillTemplate(frame.state || '', vars);
681
- console.log(` ${c.bold}${String(i + 1).padStart(2)}.${c.reset} ${tag}`);
682
- console.log(` ${passes ? c.cyan : c.dim}${details || ''}${c.reset}`);
683
- console.log(` ${passes ? '' : c.dim}${stateLine || '—'}${c.reset}`);
684
- console.log('');
685
- });
691
+ for (const { title, frames } of sections) {
692
+ if (title) console.log(` ${c.bold}${title}${c.reset}${state.status === title ? ` ${c.green}← current status${c.reset}` : ''}`);
693
+ frames.forEach((frame, i) => {
694
+ const passes = framePasses(frame, vars);
695
+ const reqs = frame.requires ? (Array.isArray(frame.requires) ? frame.requires : [frame.requires]) : [];
696
+ const tag = passes
697
+ ? `${c.green}● live${c.reset}`
698
+ : `${c.dim}○ skipped (requires ${reqs.join(', ')})${c.reset}`;
699
+ const details = fillTemplate(frame.details || '', vars);
700
+ const stateLine = fillTemplate(frame.state || '', vars);
701
+ console.log(` ${c.bold}${String(i + 1).padStart(2)}.${c.reset} ${tag}`);
702
+ console.log(` ${passes ? c.cyan : c.dim}${details || ''}${c.reset}`);
703
+ console.log(` ${passes ? '' : c.dim}${stateLine || '—'}${c.reset}`);
704
+ console.log('');
705
+ });
706
+ }
686
707
  }
687
708
 
688
709
  // Emit the autocomplete payload the dashboard needs as JSON, without the
package/src/daemon.js CHANGED
@@ -14,6 +14,7 @@ import { desktopNotify, postWebhook, shouldWebhook, shouldNotify } from './notif
14
14
  import { humanProject } from './format.js';
15
15
  import { CONFIG_PATH, STATE_PATH, PID_PATH, LOG_PATH, STATE_DIR, AGGREGATE_PATH, PAUSE_PATH } from './paths.js';
16
16
  import { readUsageCache, pollUsage } from './usage.js';
17
+ import { pollDecision, pollIntervalMs } from './watch-poll.js';
17
18
 
18
19
  if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true });
19
20
 
@@ -436,83 +437,93 @@ function scheduleReconnect(reason = 'reconnect') {
436
437
  }
437
438
 
438
439
  function watchFiles() {
439
- // Watch DIRECTORIES, not files. Every watched file here is written via
440
- // tmp+rename (state.js, the scanner, the settings GUI), and inotify tracks
441
- // the inode a watcher attached to the file path goes silent after the
442
- // first rename. A directory watcher survives renames AND works when the
443
- // file doesn't exist yet (fresh install where the daemon starts before the
444
- // first hook / before `setup` seeds config.json). Events are filtered by
445
- // filename where the platform reports one; the rare null-filename event
446
- // just costs one debounced no-op push (the payload hash dedupes it).
447
- const watchDirFor = (targetPath, label, onChange, extraNames = []) => {
448
- const dir = dirname(targetPath);
449
- const names = new Set([basename(targetPath), ...extraNames]);
450
- if (!existsSync(dir)) return;
440
+ // Single source of truth for every on-disk change the daemon reacts to.
441
+ // Each target is covered two ways at once: a directory watcher (instant)
442
+ // and the mtime-poll fallback below (never misses). See watch-poll.js for
443
+ // why both exist fs.watch drops atomic-rename events on Windows, and
444
+ // every writer here (state.js, pause.js, the scanner, the settings GUI)
445
+ // commits via tmp+rename.
446
+ const targets = [
447
+ { path: STATE_PATH, label: 'state', onChange: pushPresence },
448
+ { path: PAUSE_PATH, label: 'pause', onChange: pushPresence },
449
+ { path: CONFIG_PATH, label: 'config', onChange: () => {
450
+ log('Config changed reloading');
451
+ config = loadConfigWithLog();
452
+ lastPayloadHash = '';
453
+ pushPresence();
454
+ } },
455
+ { path: AGGREGATE_PATH, label: 'aggregate', onChange: () => {
456
+ aggregate = readAggregate() || aggregate;
457
+ lastPayloadHash = '';
458
+ pushPresence();
459
+ } },
460
+ ];
461
+
462
+ // Last mtime we've reacted to, per target. Updated by BOTH the watcher and
463
+ // the poll, so a change one path already handled resolves to a no-op for
464
+ // the other (pollDecision → 'idle') instead of a duplicate push — and the
465
+ // poll only logs a "fallback caught it" line for events the watcher truly
466
+ // missed, not for everything it already handled.
467
+ const lastMtime = new Map();
468
+ const recordMtime = (path) => {
469
+ try { if (existsSync(path)) lastMtime.set(path, statSync(path).mtimeMs); }
470
+ catch { /* mid-rename; a later observation records it */ }
471
+ };
472
+ // Seed baselines so the first poll tick doesn't fire for files that merely
473
+ // already existed when the daemon started.
474
+ targets.forEach((t) => recordMtime(t.path));
475
+
476
+ const fire = (t, viaPoll) => {
477
+ if (viaPoll) log(`${t.label} changed — poll fallback caught an event fs.watch missed`);
478
+ recordMtime(t.path); // record before onChange so a re-entrant tick can't double-fire
479
+ t.onChange();
480
+ };
481
+
482
+ // Watch DIRECTORIES, not files: every writer uses tmp+rename and inotify
483
+ // tracks the inode, so a file-path watcher goes silent after the first
484
+ // rename. A dir watcher survives renames and works before the file exists
485
+ // (fresh install — daemon up before the first hook seeds state/config).
486
+ // Group by directory so STATE_DIR (state.json + pause.json) takes one
487
+ // watcher, not two. Events are filtered by filename where the platform
488
+ // reports one; a null filename fans out to the whole group (one debounced
489
+ // push per target, deduped by the payload hash).
490
+ const groups = new Map();
491
+ for (const t of targets) {
492
+ const dir = dirname(t.path);
493
+ if (!groups.has(dir)) groups.set(dir, []);
494
+ groups.get(dir).push(t);
495
+ }
496
+ for (const [dir, group] of groups) {
497
+ if (!existsSync(dir)) continue;
498
+ const byName = new Map(group.map((t) => [basename(t.path), t]));
451
499
  let timer = null;
452
500
  try {
453
501
  watch(dir, (event, filename) => {
454
- if (filename && !names.has(filename)) return;
502
+ const hits = filename ? (byName.has(filename) ? [byName.get(filename)] : []) : group;
503
+ if (!hits.length) return;
455
504
  clearTimeout(timer);
456
- timer = setTimeout(onChange, 250);
505
+ timer = setTimeout(() => hits.forEach((t) => fire(t, false)), 250);
457
506
  });
458
507
  } catch (e) {
459
- log(`watch failed for ${label} (poll fallback still covers it):`, e.message);
508
+ log(`watch failed for ${dir} (poll fallback still covers it):`, e.message);
460
509
  }
461
- };
462
-
463
- // state.json and pause.json share STATE_DIR — one watcher serves both, so
464
- // a `claude-rpc pause` clears the card on the next debounce, not the next
465
- // 4s tick. The filename filter keeps daemon.log appends from triggering it.
466
- watchDirFor(STATE_PATH, 'state', pushPresence, [basename(PAUSE_PATH)]);
467
- watchDirFor(CONFIG_PATH, 'config', () => {
468
- log('Config changed — reloading');
469
- config = loadConfigWithLog();
470
- lastPayloadHash = '';
471
- pushPresence();
472
- });
473
- watchDirFor(AGGREGATE_PATH, 'aggregate', () => {
474
- aggregate = readAggregate() || aggregate;
475
- lastPayloadHash = '';
476
- pushPresence();
477
- });
510
+ }
478
511
 
479
- // Mtime-poll fallback. fs.watch on Windows occasionally drops events
480
- // when the writer uses an atomic-rename pattern (which `state.js` does
481
- // and the scanner does for aggregate.json). A 30s poll comparing
482
- // last-seen mtime catches anything the watcher missed without making
483
- // the watcher itself the bottleneck. No-op on Linux/macOS most of the
484
- // time, but cheap enough to leave on everywhere.
485
- let lastStateMtime = 0, lastAggMtime = 0;
512
+ // Mtime-poll fallback. Runs fast on Windows (where it's effectively the
513
+ // primary path fs.watch drops atomic-rename events there) and lazily on
514
+ // macOS/Linux. Now covers config.json and pause.json too: a dropped
515
+ // pause/config event previously had no backstop at all and could hang
516
+ // until the next unrelated state change.
486
517
  setInterval(() => {
487
- try {
488
- if (existsSync(STATE_PATH)) {
489
- const m = statSync(STATE_PATH).mtimeMs;
490
- if (m > lastStateMtime) {
491
- if (lastStateMtime !== 0) {
492
- // The first observation is just the starting value; only
493
- // log + push when we actually missed a watcher event.
494
- log('state.json mtime advanced without a watcher event (poll fallback)');
495
- pushPresence();
496
- }
497
- lastStateMtime = m;
498
- }
499
- }
500
- if (existsSync(AGGREGATE_PATH)) {
501
- const m = statSync(AGGREGATE_PATH).mtimeMs;
502
- if (m > lastAggMtime) {
503
- if (lastAggMtime !== 0) {
504
- aggregate = readAggregate() || aggregate;
505
- lastPayloadHash = '';
506
- pushPresence();
507
- }
508
- lastAggMtime = m;
509
- }
510
- }
511
- } catch {
512
- // Stat fail mid-rotate of the watched file. The next tick will
513
- // pick up the new mtime. Silent on purpose.
518
+ for (const t of targets) {
519
+ let cur;
520
+ try { cur = existsSync(t.path) ? statSync(t.path).mtimeMs : undefined; }
521
+ catch { continue; /* mid-rename; next tick picks it up */ }
522
+ const decision = pollDecision(lastMtime.get(t.path), cur);
523
+ if (decision === 'seed') lastMtime.set(t.path, cur);
524
+ else if (decision === 'fire') fire(t, true);
514
525
  }
515
- }, 30_000);
526
+ }, pollIntervalMs());
516
527
  }
517
528
 
518
529
  async function runBackgroundScan({ force = false } = {}) {
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.16.0';
14
+ const BAKED = '0.16.2';
15
15
 
16
16
  function readPkgVersion() {
17
17
  try {
@@ -0,0 +1,37 @@
1
+ // Pure helpers for the daemon's file-watch + mtime-poll fallback. Kept out of
2
+ // daemon.js so they're unit-testable without booting the daemon (daemon.js
3
+ // runs side effects — mkdir, logging, the IPC connect — at import time).
4
+ //
5
+ // The daemon reacts to on-disk changes (state.json, pause.json, config.json,
6
+ // aggregate.json) two ways at once: a directory watcher (instant) and this
7
+ // mtime poll (never misses). fs.watch is reliable on macOS/Linux via
8
+ // inotify/FSEvents, but on Windows it drops events when the writer commits via
9
+ // atomic rename — which state.js, pause.js, the scanner, and the settings GUI
10
+ // all do. So on Windows the poll is effectively the primary path and runs an
11
+ // order of magnitude faster; elsewhere it's a lazy backstop.
12
+
13
+ export const WATCH_POLL_MS = 30_000;
14
+ export const WATCH_POLL_WIN_MS = 3_000;
15
+
16
+ // How often the fallback poll runs. Fast on Windows (watcher unreliable),
17
+ // lazy on macOS/Linux (watcher reliable — this is just belt-and-suspenders).
18
+ export function pollIntervalMs(platform = process.platform) {
19
+ return platform === 'win32' ? WATCH_POLL_WIN_MS : WATCH_POLL_MS;
20
+ }
21
+
22
+ // Per-target poll decision, given the last mtime we reacted to (`prev`) and
23
+ // the file's current mtime (`cur`, undefined when absent / stat failed):
24
+ // 'seed' — first observation: record the baseline, don't react (the file
25
+ // merely already existed at startup).
26
+ // 'fire' — mtime advanced past what we last handled: the watcher missed an
27
+ // event, react now.
28
+ // 'idle' — no change, file gone, or mtime went backwards (file replaced
29
+ // with an older copy — atomic writers only ever move it forward).
30
+ // Both the watcher and the poll record into the same baseline, so a change
31
+ // one path already handled resolves to 'idle' for the other instead of a
32
+ // duplicate push.
33
+ export function pollDecision(prev, cur) {
34
+ if (cur === undefined) return 'idle';
35
+ if (prev === undefined) return 'seed';
36
+ return cur > prev ? 'fire' : 'idle';
37
+ }