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 +1 -1
- package/src/cli.js +35 -14
- package/src/daemon.js +78 -67
- package/src/version.js +1 -1
- package/src/watch-poll.js +37 -0
package/package.json
CHANGED
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
|
-
|
|
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
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
?
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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
|
-
//
|
|
440
|
-
//
|
|
441
|
-
// the
|
|
442
|
-
//
|
|
443
|
-
//
|
|
444
|
-
//
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
|
|
502
|
+
const hits = filename ? (byName.has(filename) ? [byName.get(filename)] : []) : group;
|
|
503
|
+
if (!hits.length) return;
|
|
455
504
|
clearTimeout(timer);
|
|
456
|
-
timer = setTimeout(
|
|
505
|
+
timer = setTimeout(() => hits.forEach((t) => fire(t, false)), 250);
|
|
457
506
|
});
|
|
458
507
|
} catch (e) {
|
|
459
|
-
log(`watch failed for ${
|
|
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.
|
|
480
|
-
//
|
|
481
|
-
//
|
|
482
|
-
//
|
|
483
|
-
// 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
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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
|
-
},
|
|
526
|
+
}, pollIntervalMs());
|
|
516
527
|
}
|
|
517
528
|
|
|
518
529
|
async function runBackgroundScan({ force = false } = {}) {
|
package/src/version.js
CHANGED
|
@@ -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
|
+
}
|