@phnx-labs/agents-cli 1.22.29 → 1.22.31

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.
Files changed (82) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/README.md +44 -5
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/accounts.d.ts +13 -0
  5. package/dist/commands/accounts.js +32 -0
  6. package/dist/commands/daemon.d.ts +18 -0
  7. package/dist/commands/daemon.js +581 -0
  8. package/dist/commands/exec.js +66 -20
  9. package/dist/commands/focus.d.ts +4 -1
  10. package/dist/commands/focus.js +19 -4
  11. package/dist/commands/routines.js +29 -11
  12. package/dist/commands/secrets.d.ts +37 -0
  13. package/dist/commands/secrets.js +86 -105
  14. package/dist/commands/sessions-bookmark.d.ts +20 -0
  15. package/dist/commands/{sessions-favorite.js → sessions-bookmark.js} +42 -42
  16. package/dist/commands/sessions-browser.d.ts +10 -8
  17. package/dist/commands/sessions-browser.js +61 -32
  18. package/dist/commands/sessions-picker.d.ts +33 -1
  19. package/dist/commands/sessions-picker.js +102 -27
  20. package/dist/commands/sessions-stats.js +1 -1
  21. package/dist/commands/sessions.d.ts +21 -8
  22. package/dist/commands/sessions.js +328 -74
  23. package/dist/commands/view.d.ts +11 -0
  24. package/dist/commands/view.js +56 -29
  25. package/dist/index.js +37 -2
  26. package/dist/lib/account-labels.d.ts +24 -0
  27. package/dist/lib/account-labels.js +72 -0
  28. package/dist/lib/agents.d.ts +32 -1
  29. package/dist/lib/agents.js +96 -31
  30. package/dist/lib/daemon-health.d.ts +24 -0
  31. package/dist/lib/daemon-health.js +84 -0
  32. package/dist/lib/daemon-ticks.d.ts +81 -0
  33. package/dist/lib/daemon-ticks.js +190 -0
  34. package/dist/lib/daemon.d.ts +68 -18
  35. package/dist/lib/daemon.js +303 -338
  36. package/dist/lib/device-config.d.ts +10 -0
  37. package/dist/lib/device-config.js +27 -0
  38. package/dist/lib/exec.d.ts +27 -0
  39. package/dist/lib/exec.js +49 -2
  40. package/dist/lib/hosts/dispatch.d.ts +4 -0
  41. package/dist/lib/hosts/dispatch.js +4 -0
  42. package/dist/lib/hosts/remote-cmd.js +1 -0
  43. package/dist/lib/hosts/run-target.d.ts +1 -0
  44. package/dist/lib/hosts/run-target.js +1 -0
  45. package/dist/lib/import.js +7 -6
  46. package/dist/lib/memory-cache.d.ts +19 -0
  47. package/dist/lib/memory-cache.js +31 -0
  48. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  49. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  50. package/dist/lib/migrate.d.ts +1 -1
  51. package/dist/lib/migrate.js +13 -2
  52. package/dist/lib/picker.d.ts +6 -3
  53. package/dist/lib/picker.js +7 -2
  54. package/dist/lib/routine-activation.d.ts +2 -0
  55. package/dist/lib/routine-activation.js +16 -0
  56. package/dist/lib/runner.d.ts +18 -0
  57. package/dist/lib/runner.js +52 -0
  58. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  59. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  60. package/dist/lib/secrets/agent.d.ts +19 -1
  61. package/dist/lib/secrets/agent.js +32 -6
  62. package/dist/lib/secrets/scope.d.ts +3 -3
  63. package/dist/lib/secrets/scope.js +3 -3
  64. package/dist/lib/secrets/session-store.d.ts +0 -4
  65. package/dist/lib/secrets/session-store.js +0 -5
  66. package/dist/lib/session/{favorites.d.ts → bookmarks.d.ts} +15 -15
  67. package/dist/lib/session/{favorites.js → bookmarks.js} +23 -23
  68. package/dist/lib/session/db.d.ts +15 -0
  69. package/dist/lib/session/db.js +90 -15
  70. package/dist/lib/session/discover.js +91 -39
  71. package/dist/lib/session/parse.d.ts +63 -0
  72. package/dist/lib/session/parse.js +165 -20
  73. package/dist/lib/session/session-cache.d.ts +9 -6
  74. package/dist/lib/session/session-cache.js +23 -6
  75. package/dist/lib/shims.js +12 -0
  76. package/dist/lib/startup/command-registry.d.ts +15 -1
  77. package/dist/lib/startup/command-registry.js +49 -0
  78. package/dist/lib/usage-refresh.js +3 -2
  79. package/dist/lib/usage.d.ts +12 -10
  80. package/dist/lib/usage.js +63 -144
  81. package/package.json +4 -1
  82. package/dist/commands/sessions-favorite.d.ts +0 -20
@@ -16,18 +16,19 @@ import { listJobs as listAllJobs } from './routines.js';
16
16
  import { syncAllProjectRoutines } from './routines-project.js';
17
17
  import { JobScheduler } from './scheduler.js';
18
18
  import { MonitorEngine } from './monitors/engine.js';
19
- import { executeJobDetached, monitorRunningJobs } from './runner.js';
19
+ import { executeJobDetached, monitorRunningJobs, listLiveRoutineChildren } from './runner.js';
20
20
  import { detectOverdueJobs, notifyOverdue } from './overdue.js';
21
21
  import { runCatchup } from './catchup.js';
22
22
  import { notifyRoutineStart, notifyRoutineFinish, notifyRoutineStartFailed } from './routine-notify.js';
23
23
  import { notifyOwnerRoutineFinish, notifyOwnerRoutineStartFailed } from './routine-notify-owner.js';
24
24
  import { BrowserService } from './browser/service.js';
25
- import { BrowserIPCServer } from './browser/ipc.js';
25
+ import { BrowserIPCServer, getSocketPath as getBrowserIpcSocketPath } from './browser/ipc.js';
26
+ import { secretsBrokerSocketPath, brokerPidAlive } from './secrets/agent.js';
26
27
  import { redactSecrets } from './redact.js';
27
28
  import { getAgentsBinPath, getCliLaunch, BUN_VIRTUAL_ROOT } from './cli-entry.js';
28
- import { getConfigValue, isSchedulerEnabled, assertSchedulerEnabled } from './device-config.js';
29
+ import { isSchedulerEnabled, assertSchedulerEnabled, isDaemonEnabled } from './device-config.js';
29
30
  import { reapTerminalRoutineProcesses } from './routine-process-cleanup.js';
30
- import { runWatchdogPass } from './watchdog/service.js';
31
+ import { recordSubsystemOk, recordSubsystemError, SUBSYSTEM_SECRETS_BROKER, SUBSYSTEM_BROWSER_IPC } from './daemon-health.js';
31
32
  const PID_FILE = 'daemon.pid';
32
33
  const LOCK_FILE = 'daemon.lock';
33
34
  const LOG_FILE = 'logs.jsonl';
@@ -37,7 +38,6 @@ const LOG_ROTATE_COUNT = 3;
37
38
  const PLIST_NAME = 'com.phnx-labs.agents-daemon';
38
39
  const SYSTEMD_UNIT = 'agents-daemon.service';
39
40
  const MONITOR_TICK_MS = 60_000;
40
- const WATCHDOG_TICK_MS = 3 * 60_000;
41
41
  /**
42
42
  * How often to re-scan for missed fires. Deliberately slower than the monitor
43
43
  * tick: detection walks a week of cron occurrences per routine
@@ -250,30 +250,42 @@ export function isDaemonRunning() {
250
250
  * writeDaemonPid() unconditionally, clobber a live daemon's recorded PID, and
251
251
  * run a second JobScheduler concurrently, so every cron routine fires twice.
252
252
  *
253
- * Returns true and records our PID when no other live daemon owns the pid file;
254
- * returns false when a live daemon already holds it (the caller must exit
255
- * without touching any further state). The read-decide-write is serialized
256
- * behind the same O_EXCL start lock startDaemon() uses, so two _run processes
257
- * can't both claim in the window between the liveness check and the write.
253
+ * LAST-WINS takeover (SING-11, RUSH-2352): when a live daemon already owns the
254
+ * pid file, this does NOT defer to it it evicts the incumbent and becomes the
255
+ * survivor, so a second install can never leave two daemons running. Returns true
256
+ * and records our PID once the incumbent is provably dead (its resources
257
+ * released). Returns false ONLY when another `__daemon-run` currently holds the
258
+ * O_EXCL start lock — i.e. a concurrent claimer is mid-takeover and will be the
259
+ * singleton — in which case the caller must exit without touching further state.
260
+ * The read-evict-write is serialized behind the same start lock startDaemon()
261
+ * uses, so two `_run` processes can't both claim in the window between the
262
+ * liveness check and the write.
258
263
  */
259
264
  export function claimDaemonInstance() {
260
265
  const release = acquireStartLock();
261
266
  // acquireStartLock() returns null only when another __daemon-run currently
262
267
  // holds the O_EXCL lock — a dead holder's lock is reclaimed and retried inside
263
268
  // acquireStartLock, so null means a *live* claimer is mid-claim. Bail rather
264
- // than run the read-decide-write unlocked: otherwise two first-start processes
269
+ // than run the read-evict-write unlocked: otherwise two first-start processes
265
270
  // could each see no pid file (before either writes one) and both claim,
266
- // running the concurrent JobScheduler this guard exists to prevent.
271
+ // running the concurrent JobScheduler this guard exists to prevent. The live
272
+ // claimer we bailed for becomes the singleton, so last-wins still holds.
267
273
  if (!release)
268
274
  return false;
269
275
  try {
270
276
  // resolveLiveDaemonPid() also consults a fresh heartbeat, so a live daemon
271
- // whose pid file was lost still blocks a second claim — otherwise a missing
272
- // pid file would let this instance start a concurrent JobScheduler and
273
- // double-fire every routine.
277
+ // whose pid file was lost is still found and evicted — otherwise a missing
278
+ // pid file would let both this instance AND the orphaned incumbent run a
279
+ // JobScheduler at once and double-fire every routine.
274
280
  const existing = resolveLiveDaemonPid();
275
281
  if (existing !== null && existing !== process.pid) {
276
- return false; // another live daemon already owns the instance
282
+ // Evict, and WAIT for the incumbent to be provably dead — its graceful
283
+ // handleShutdown releasing the browser IPC binding and the secrets broker
284
+ // socket — before we write our pid and (later, in runDaemon) bind our own.
285
+ // Binding before the release recreates the two-brokers-on-one-socket orphan
286
+ // documented at stopDaemon below, so the pid file is not written until the
287
+ // prior owner is gone.
288
+ evictIncumbentDaemon(existing);
277
289
  }
278
290
  writeDaemonPid(process.pid);
279
291
  return true;
@@ -283,56 +295,153 @@ export function claimDaemonInstance() {
283
295
  }
284
296
  }
285
297
  /**
286
- * Reap stray duplicate daemon processes a `__daemon-run` of THIS install that
287
- * isn't this process and isn't the pid-file owner. Mirrors the browser orphan
288
- * reaper (below): a predecessor that was SIGKILLed/OOM-ed without cleaning up,
289
- * or a duplicate that lost the pid-file write race, would otherwise keep a
290
- * second scheduler alive and double-fire jobs even after claimDaemonInstance()
291
- * hands the pid file to the survivor.
292
- *
293
- * Scoped to our own launch entry (process.argv[1]) so it only ever targets
294
- * daemons of the same installation a daemon from a different install / home
295
- * (e.g. a side-by-side dev build, or a test fixture) is a legitimately separate
296
- * instance and is left untouched. POSIX-only (uses `ps`); a no-op on Windows.
298
+ * SIGTERM a live incumbent daemon and block until it is provably dead, so its
299
+ * graceful handleShutdown has released the browser IPC binding and the secrets
300
+ * broker socket BEFORE the newcomer binds anything of its own (SING-11). Escalates
301
+ * to killTree after the grace window. Passes the POSITIVE pid so the kill reaches
302
+ * only the incumbent daemon never its detached routine children, which run in
303
+ * their own process groups and must survive takeover (SING-11a); the new daemon
304
+ * re-adopts them via monitorRunningJobs. Synchronous to match claimDaemonInstance's
305
+ * read-evict-write, which runs under the O_EXCL start lock; mirrors stopDaemon's
306
+ * grace-then-escalate shape and constants exactly, because the same
307
+ * proof-of-release requirement applies.
308
+ */
309
+ function evictIncumbentDaemon(pid) {
310
+ if (process.platform === 'win32') {
311
+ // No graceful termination signal on Windows — take the incumbent down and
312
+ // still wait for the kill to land before the caller binds anything (mirrors
313
+ // stopDaemon's win32 branch).
314
+ killTree(pid);
315
+ waitForExit(pid, STOP_KILL_GRACE_MS);
316
+ return;
317
+ }
318
+ try {
319
+ process.kill(pid, 'SIGTERM');
320
+ }
321
+ catch {
322
+ return; // already gone between resolveLiveDaemonPid() and here
323
+ }
324
+ if (waitForExit(pid, STOP_GRACE_MS))
325
+ return; // graceful release complete
326
+ killTree(pid); // positive pid: SIGKILL reaches the daemon, not its job children
327
+ waitForExit(pid, STOP_KILL_GRACE_MS);
328
+ }
329
+ /** Directory that registers every live daemon of THIS device (one per daemon dir). */
330
+ function getDaemonInstancesDir() {
331
+ return path.join(getDaemonDirRoot(), 'instances');
332
+ }
333
+ /**
334
+ * Record this daemon in the device's instance registry — a marker file named by
335
+ * pid under `<daemonDir>/instances/`. The registry, not a process scan, is how
336
+ * the reaper enumerates the device singleton: because the dir lives INSIDE the
337
+ * daemon dir (`AGENTS_DAEMON_DIR` ?? `<HOME>/.agents/.cache/helpers/daemon`), every
338
+ * daemon of one device — however it was launched — registers in the same place,
339
+ * while a genuinely separate install/home or a test fixture registers under its
340
+ * own daemon dir and is invisible here. This is what fixes the two-entry pile-up:
341
+ * the compiled `dist/bin/agents` binary and the `node <shim>` JS entry have
342
+ * different `process.argv[1]`, so the old launch-entry-scoped `ps` match never
343
+ * reaped across them and duplicates accumulated (78 observed on one box), every
344
+ * routine double-firing. Best-effort — the reaper self-heals a missing/stale
345
+ * marker, and reading another process's ENV to key on the daemon dir directly is
346
+ * not portable (hardened macOS hides it from `ps`), so identity rides the shared
347
+ * on-disk registry instead. No-op on Windows (POSIX-only reaper).
348
+ */
349
+ export function registerDaemonInstance(pid = process.pid) {
350
+ if (process.platform === 'win32')
351
+ return;
352
+ try {
353
+ const dir = getDaemonInstancesDir();
354
+ fs.mkdirSync(dir, { recursive: true });
355
+ // The command line is stored for diagnostics; the filename (pid) is identity.
356
+ fs.writeFileSync(path.join(dir, String(pid)), process.argv.slice(1).join(' '), 'utf-8');
357
+ }
358
+ catch { /* best effort — the reaper self-heals a missing marker */ }
359
+ }
360
+ /** Remove this daemon's registry marker on graceful shutdown. */
361
+ export function unregisterDaemonInstance(pid = process.pid) {
362
+ if (process.platform === 'win32')
363
+ return;
364
+ try {
365
+ fs.rmSync(path.join(getDaemonInstancesDir(), String(pid)), { force: true });
366
+ }
367
+ catch { /* ignore */ }
368
+ }
369
+ /**
370
+ * Reap stray duplicate daemons of THIS device — every registrant in the instance
371
+ * registry that is a live `agents __daemon-run` and is neither this process nor
372
+ * the current pid-file owner. A predecessor SIGKILLed/OOM-ed without cleanup, or a
373
+ * duplicate that lost the pid-file write race, would otherwise keep a second
374
+ * scheduler alive and double-fire jobs even after claimDaemonInstance() hands the
375
+ * pid file to the survivor. Also garbage-collects markers whose pid is dead or was
376
+ * reused by an unrelated process. No-op on Windows (POSIX-only).
297
377
  */
298
378
  export function reapStrayDaemons(keepPid = process.pid) {
299
379
  const details = [];
300
380
  let reaped = 0;
301
381
  if (process.platform === 'win32')
302
382
  return { reaped, details };
303
- const selfEntry = process.argv[1];
304
- if (!selfEntry)
305
- return { reaped, details };
306
- let out;
383
+ const dir = getDaemonInstancesDir();
384
+ let entries;
307
385
  try {
308
- out = execFileSync('ps', ['-eo', 'pid=,args='], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
386
+ entries = fs.readdirSync(dir);
309
387
  }
310
388
  catch {
311
- return { reaped, details }; // no `ps`best effort
389
+ return { reaped, details }; // no registry yet nothing to reap
312
390
  }
313
391
  const ownerPid = readDaemonPid();
314
- for (const line of out.split('\n')) {
315
- const m = line.trim().match(/^(\d+)\s+(.*)$/);
316
- if (!m)
317
- continue;
318
- const pid = parseInt(m[1], 10);
319
- const args = m[2];
320
- if (isNaN(pid) || pid === keepPid || pid === process.pid || pid === ownerPid)
392
+ const dropMarker = (name) => {
393
+ try {
394
+ fs.rmSync(path.join(dir, name), { force: true });
395
+ }
396
+ catch { /* ignore */ }
397
+ };
398
+ for (const name of entries) {
399
+ const pid = parseInt(name, 10);
400
+ if (isNaN(pid) || String(pid) !== name)
401
+ continue; // not a pid marker
402
+ if (pid === keepPid || pid === process.pid || pid === ownerPid)
321
403
  continue;
322
- // Same install (same launch entry) AND a `__daemon-run` command line.
323
- if (!args.includes(selfEntry))
404
+ // Dead registrant stale marker.
405
+ if (!isAlive(pid)) {
406
+ dropMarker(name);
324
407
  continue;
325
- if (!/\b__daemon-run\b/.test(args))
408
+ }
409
+ // Live pid, but guard against pid reuse: only a real `__daemon-run` is ours.
410
+ // Process ARGS are readable cross-platform (unlike ENV on hardened macOS).
411
+ if (!isDaemonRunProcess(pid)) {
412
+ dropMarker(name);
326
413
  continue;
414
+ }
327
415
  try {
328
416
  process.kill(pid, 'SIGTERM');
329
417
  reaped++;
330
418
  details.push(`reaped stray daemon pid ${pid}`);
331
419
  }
332
- catch { /* already gone */ }
420
+ catch { /* already gone between the alive check and the signal */ }
421
+ dropMarker(name);
333
422
  }
334
423
  return { reaped, details };
335
424
  }
425
+ /**
426
+ * Whether `pid` is a live `agents __daemon-run` process. Reads the process's
427
+ * command line (`ps -p <pid> -o command=`), which — unlike its environment — is
428
+ * visible cross-platform, including on hardened macOS. Guards the reaper against
429
+ * killing an unrelated process that reused a dead registrant's pid.
430
+ */
431
+ function isDaemonRunProcess(pid) {
432
+ if (process.platform === 'win32')
433
+ return false;
434
+ try {
435
+ const out = execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
436
+ encoding: 'utf-8',
437
+ stdio: ['ignore', 'pipe', 'ignore'],
438
+ });
439
+ return /\b__daemon-run\b/.test(out);
440
+ }
441
+ catch {
442
+ return false;
443
+ }
444
+ }
336
445
  function rotateLogsIfNeeded(logPath) {
337
446
  try {
338
447
  const stat = fs.statSync(logPath);
@@ -423,12 +532,13 @@ export function warnEphemeralDaemonRoot(resolveBin = getAgentsBinPath) {
423
532
  }
424
533
  }
425
534
  export async function runDaemon() {
426
- // Single-instance guard: a direct `agents __daemon-run` (manual, or a
427
- // service-manager restart racing a live predecessor) must not clobber a
428
- // running daemon's pid file and start a second scheduler.
535
+ // Single-instance guard (last-wins, SING-11): a direct `agents __daemon-run`
536
+ // (manual, or a service-manager restart racing a live predecessor) EVICTS the
537
+ // incumbent and becomes the survivor. claimDaemonInstance returns false only
538
+ // when a concurrent `__daemon-run` currently holds the start lock — that peer
539
+ // is mid-takeover and will be the singleton, so this instance stands down.
429
540
  if (!claimDaemonInstance()) {
430
- const owner = readDaemonPid();
431
- log('WARN', `Another daemon already owns the pid file (PID: ${owner}); this instance (PID ${process.pid}) is exiting`);
541
+ log('WARN', `Another daemon is mid-takeover (holds the start lock); this instance (PID ${process.pid}) is exiting`);
432
542
  // Exit cleanly (0) so a service manager treats it as an orderly no-op
433
543
  // rather than a failure to restart-flap on.
434
544
  process.exit(0);
@@ -443,8 +553,11 @@ export async function runDaemon() {
443
553
  // refreshes itself per-device; a routine whose account login has gone dead is
444
554
  // skipped up front by the auth-health preflight (runner.ts) with a re-login
445
555
  // hint, rather than papered over by an injected fallback token.
446
- // Reap any stray duplicate daemon of this install that slipped past the start
447
- // lock or was orphaned by a hard-crash — before it can double-fire jobs.
556
+ // Register this daemon in the device instance registry, then reap any stray
557
+ // duplicate that slipped past the start lock or was orphaned by a hard-crash —
558
+ // before it can double-fire jobs. Registration comes first so a racing peer's
559
+ // reaper can see this pid, and so this reaper never mistakes itself for a stray.
560
+ registerDaemonInstance();
448
561
  try {
449
562
  const strays = reapStrayDaemons();
450
563
  if (strays.reaped > 0) {
@@ -474,9 +587,12 @@ export async function runDaemon() {
474
587
  if (hostedBroker)
475
588
  log('INFO', 'Secrets broker hosted in daemon (socket-first)');
476
589
  }
590
+ recordSubsystemOk(SUBSYSTEM_SECRETS_BROKER);
477
591
  }
478
592
  catch (err) {
479
- log('WARN', `Secrets broker host skipped: ${err.message}`);
593
+ const message = err.message;
594
+ log('WARN', `Secrets broker host skipped: ${message}`);
595
+ recordSubsystemError(SUBSYSTEM_SECRETS_BROKER, message);
480
596
  }
481
597
  // scheduler.enabled=false in this machine's device doc means NO routines fire
482
598
  // here — the scheduler and its catchup recovery simply never start, while the
@@ -595,24 +711,13 @@ export async function runDaemon() {
595
711
  }
596
712
  if (schedulerEnabledAtBoot)
597
713
  bootScheduler();
598
- let watchdogInFlight = false;
599
- const runDaemonWatchdog = async () => {
600
- if (watchdogInFlight || getConfigValue('watchdog.enabled').value !== true)
601
- return;
602
- watchdogInFlight = true;
603
- try {
604
- const result = await runWatchdogPass({ nudge: true });
605
- log('INFO', `watchdog: ${result.counts.total} live, ${result.counts.stalled} stalled, ${result.counts.nudged} nudged`);
606
- }
607
- catch (err) {
608
- log('ERROR', `watchdog tick failed: ${err.message}`);
609
- }
610
- finally {
611
- watchdogInFlight = false;
612
- }
613
- };
614
- const watchdogInterval = setInterval(() => { void runDaemonWatchdog(); }, WATCHDOG_TICK_MS);
615
- const watchdogKickoff = setTimeout(() => { void runDaemonWatchdog(); }, 30_000);
714
+ // watchdog, device-probe, tmux-reconcile, launch-health, fleet-cache-warm,
715
+ // session-cache-warm, usage-refresh, and auto-dispatch used to be hardcoded
716
+ // setInterval ticks here (RUSH-2353). They are now shipped system routines
717
+ // (gh:phnx-labs/.agents-system routines/*.yml), invoked one-shot via
718
+ // `agents __daemon-tick <name>` (see daemon-ticks.ts) and fired by the
719
+ // JobScheduler above like any other routine — declared, listed, run-tracked,
720
+ // pausable, and device-pinnable, instead of a second unowned scheduling path.
616
721
  // Monitor engine: event-triggered watchers, beside the cron scheduler. Same
617
722
  // daemon, same dispatch seam — a monitor is a routine whose trigger is a
618
723
  // watched source instead of a clock. Reloads on SIGHUP alongside the scheduler.
@@ -715,9 +820,12 @@ export async function runDaemon() {
715
820
  try {
716
821
  await browserIPC.start();
717
822
  log('INFO', 'Browser IPC server started');
823
+ recordSubsystemOk(SUBSYSTEM_BROWSER_IPC);
718
824
  }
719
825
  catch (err) {
720
- log('ERROR', `Browser IPC failed to start: ${err.message}`);
826
+ const message = err.message;
827
+ log('ERROR', `Browser IPC failed to start: ${message}`);
828
+ recordSubsystemError(SUBSYSTEM_BROWSER_IPC, message);
721
829
  }
722
830
  writeHeartbeat();
723
831
  const monitorInterval = setInterval(() => {
@@ -760,253 +868,6 @@ export async function runDaemon() {
760
868
  };
761
869
  const healInterval = setInterval(() => { void runHealCheck(); }, 6 * 60 * 60_000);
762
870
  const healKickoff = setTimeout(() => { void runHealCheck(); }, 30_000);
763
- // Auto-dispatch: for any managed project that has opted in (autoDispatch:true +
764
- // maxAgents>0 in ~/.agents/factory/projects.json), pick up Linear tickets that
765
- // are delegated to an agent and still in Todo, and DISPATCH each through
766
- // agents-cli's own cloud-provider layer (resolveProvider().dispatch(), same as
767
- // `agents cloud run`) — then mark it Doing so it isn't re-picked. Capped at
768
- // maxAgents concurrent per project. No hidden Prix dependency: Rush is one
769
- // provider among rush/codex/factory, pinned per-project via `provider`. OFF
770
- // unless a project opts in; no opted-in project or no LINEAR_API_KEY is a clean
771
- // no-op. Overlap-guarded like the probes above. ~every 3 min.
772
- let autoDispatching = false;
773
- const runAutoDispatch = async () => {
774
- if (autoDispatching)
775
- return;
776
- autoDispatching = true;
777
- try {
778
- const { readAutoDispatchProjects, isEligible, autoDispatchTick } = await import('./auto-dispatch.js');
779
- const projects = readAutoDispatchProjects();
780
- if (!projects.some(isEligible))
781
- return; // opt-in: nothing enabled → skip
782
- const { createLinearGateway } = await import('./auto-dispatch-linear.js');
783
- const linear = createLinearGateway();
784
- if (!linear)
785
- return; // no LINEAR_API_KEY configured → skip
786
- const { createProviderDispatcher } = await import('./auto-dispatch-provider.js');
787
- const dispatcher = createProviderDispatcher();
788
- const dispatched = await autoDispatchTick({ projects, linear, dispatcher, log: (lvl, m) => log(lvl, m) });
789
- if (dispatched.length) {
790
- log('INFO', `auto-dispatch: started ${dispatched.length} delegated ticket(s): ${dispatched.map((d) => d.identifier).join(', ')}`);
791
- }
792
- }
793
- catch (err) {
794
- log('ERROR', `auto-dispatch failed: ${err.message}`);
795
- }
796
- finally {
797
- autoDispatching = false;
798
- }
799
- };
800
- const autoDispatchInterval = setInterval(() => { void runAutoDispatch(); }, 3 * 60_000);
801
- const autoDispatchKickoff = setTimeout(() => { void runAutoDispatch(); }, 45_000);
802
- // Device probe: refresh registered devices' reachability and detect newly
803
- // appeared tailnet nodes, dropping a sentinel per pending device so the
804
- // menu-bar helper can surface "NEW DEVICES → Register / Ignore". Refresh mode
805
- // never auto-registers a newcomer. Soft + overlap-guarded like session sync;
806
- // a machine without tailscale is a clean no-op. ~every 3 min.
807
- let probingDevices = false;
808
- const runDeviceProbe = async () => {
809
- if (probingDevices)
810
- return;
811
- probingDevices = true;
812
- try {
813
- const { runDeviceSync } = await import('./devices/sync.js');
814
- const { reconcilePendingSentinels } = await import('./devices/pending.js');
815
- const dev = await runDeviceSync({ soft: true, mode: 'refresh' });
816
- if (dev.ok) {
817
- reconcilePendingSentinels(dev.pending);
818
- if (dev.pending.length) {
819
- log('INFO', `devices: ${dev.pending.length} new pending (${dev.pending.map((p) => p.name).join(', ')})`);
820
- }
821
- }
822
- }
823
- catch (err) {
824
- log('ERROR', `device probe failed: ${err.message}`);
825
- }
826
- finally {
827
- probingDevices = false;
828
- }
829
- };
830
- const deviceProbeInterval = setInterval(() => { void runDeviceProbe(); }, 3 * 60_000);
831
- const deviceProbeKickoff = setTimeout(() => { void runDeviceProbe(); }, 15_000);
832
- // tmux hook reconcile: retrofit the guarded `pane-died` hook onto managed
833
- // `agents run` sessions a pre-fix binary left with the old unconditional hook
834
- // (which detached the whole client — kicking the user out of the view — when
835
- // they exited a split they'd opened). Non-destructive: set-hook only, never a
836
- // kill or detach. A per-session schema marker makes steady-state a no-op, so
837
- // this stays cheap at ~every 5 min, plus once ~20s after startup so a
838
- // just-upgraded daemon heals still-running sessions without waiting for them to
839
- // cycle or the shared server to be recycled.
840
- let reconcilingTmux = false;
841
- const runTmuxReconcile = async () => {
842
- if (reconcilingTmux)
843
- return;
844
- reconcilingTmux = true;
845
- try {
846
- const { isTmuxInstalled } = await import('./tmux/binary.js');
847
- if (!isTmuxInstalled())
848
- return;
849
- const { reconcileSessionHooks } = await import('./tmux/session.js');
850
- const r = await reconcileSessionHooks();
851
- if (r.reconciled > 0)
852
- log('INFO', `tmux: retrofitted pane-died hook on ${r.reconciled} session(s)`);
853
- }
854
- catch (err) {
855
- log('ERROR', `tmux reconcile failed: ${err.message}`);
856
- }
857
- finally {
858
- reconcilingTmux = false;
859
- }
860
- };
861
- const tmuxReconcileInterval = setInterval(() => { void runTmuxReconcile(); }, 5 * 60_000);
862
- const tmuxReconcileKickoff = setTimeout(() => { void runTmuxReconcile(); }, 20_000);
863
- // Launch-health self-heal: probe that each agent's DEFAULT version actually
864
- // LAUNCHES (not just that its files exist), and repair a gutted install — the
865
- // JS wrapper present but its native binary renamed/missing (a vendor
866
- // auto-update that never landed its replacement, or a partially-extracted
867
- // tarball) — BEFORE the user's next `agents run` dies with a raw ENOENT. This
868
- // is the proactive companion to the run-time heal (ensureAgentRunnable), which
869
- // only fires once a run is already starting. Cheap steady-state: one
870
- // `--version` probe per default version; a clean reinstall runs only on a real
871
- // launch failure. ~every 6h, plus once ~90s after startup (staggered off launch).
872
- let checkingLaunchHealth = false;
873
- const runLaunchHealthCheck = async () => {
874
- if (checkingLaunchHealth)
875
- return;
876
- checkingLaunchHealth = true;
877
- try {
878
- const { healBrokenDefaultLaunches } = await import('./versions.js');
879
- // Unattended pass: repair the current default in place, but NEVER repoint
880
- // the global default (allowDefaultSwitch: false). A background default
881
- // switch installs a fresh version home → a fresh empty Claude credential
882
- // scope, i.e. a silent logout uncorrelated with anything the user did.
883
- const { repaired, unhealed } = await healBrokenDefaultLaunches((m) => log('INFO', `launch-health: ${m}`), { allowDefaultSwitch: false });
884
- if (repaired.length)
885
- log('INFO', `launch-health: repaired ${repaired.join(', ')}`);
886
- if (unhealed.length) {
887
- log('WARN', `launch-health: ${unhealed.join(', ')} won't launch and will not be auto-switched — choose a version with \`agents use <agent> <version>\` or \`agents add <agent>@latest\``);
888
- }
889
- }
890
- catch (err) {
891
- log('ERROR', `launch-health check failed: ${err.message}`);
892
- }
893
- finally {
894
- checkingLaunchHealth = false;
895
- }
896
- };
897
- const launchHealthInterval = setInterval(() => { void runLaunchHealthCheck(); }, 6 * 60 * 60_000);
898
- const launchHealthKickoff = setTimeout(() => { void runLaunchHealthCheck(); }, 90_000);
899
- // Fleet cache warm: publish THIS host's row for the caches `agents fleet
900
- // status` / `agents devices list` read. PUBLISH-OWN / READ-UNION (RUSH-2061):
901
- // each daemon probes only ITSELF and never SSHes another box, so the fleet no
902
- // longer pays N² SSH resource probes every 3 minutes (N daemons × N devices) —
903
- // the source of the fan-out storm AND the orphaned-probe pile-up. Two cheap
904
- // self-only refreshes: (1) this host's auth-health verdicts (also feeds the
905
- // `doctor --json` Auth rollup other hosts read), and (2) its fleet-status row
906
- // (local resource probe + live-agent workload). Cross-host rows are unioned on
907
- // demand by the reader (`agents fleet status`), not pushed by every daemon.
908
- // Best-effort + overlap-guarded like the probes above; ~every 3 min, once ~60s
909
- // after startup.
910
- let warmingFleetCache = false;
911
- const runFleetCacheWarm = async () => {
912
- if (warmingFleetCache)
913
- return;
914
- warmingFleetCache = true;
915
- try {
916
- const { machineId } = await import('./machine-id.js');
917
- const self = machineId();
918
- const { probeLocalFleetAuth, writeFleetAuthRows } = await import('./auth-health.js');
919
- const { getCliVersion } = await import('./version.js');
920
- const authRows = await probeLocalFleetAuth({ cliVersion: getCliVersion() });
921
- writeFleetAuthRows(self, authRows);
922
- const { publishLocalFleetStatus } = await import('./fleet-status.js');
923
- const row = await publishLocalFleetStatus(self);
924
- log('INFO', `fleet cache warm: ${authRows.length} auth row(s), ${row.agents.running} running agent(s) on ${self}`);
925
- }
926
- catch (err) {
927
- log('ERROR', `fleet cache warm failed: ${err.message}`);
928
- }
929
- finally {
930
- warmingFleetCache = false;
931
- }
932
- };
933
- const fleetCacheInterval = setInterval(() => { void runFleetCacheWarm(); }, 3 * 60_000);
934
- const fleetCacheKickoff = setTimeout(() => { void runFleetCacheWarm(); }, 60_000);
935
- // Session-status cache warm (RUSH-2062): publish THIS host's local active
936
- // sessions so menubar / Factory / watchdog / CLI share one warm snapshot
937
- // instead of each re-running a full ~9s / ~170MB gather. Publish-own only
938
- // (no cross-host SSH — same N² lesson as RUSH-2061). Short interval keeps
939
- // live status fresh; forceRefresh still re-gathers. Additive sibling of
940
- // fleetCacheInterval — does not touch the reaper tick.
941
- let warmingSessionCache = false;
942
- // Import once so the interval/kickoff constants stay the single source of truth
943
- // (SESSION_CACHE_WARM_* in session-cache.ts). Dynamic import of the warm fn
944
- // itself stays inside the tick so a cold daemon boot does not pay the load.
945
- const sessionCacheWarmTiming = import('./session/session-cache.js').then((m) => ({
946
- intervalMs: m.SESSION_CACHE_WARM_INTERVAL_MS,
947
- kickoffMs: m.SESSION_CACHE_WARM_KICKOFF_MS,
948
- publish: m.publishLocalActiveSessions,
949
- }));
950
- const runSessionCacheWarm = async () => {
951
- if (warmingSessionCache)
952
- return;
953
- warmingSessionCache = true;
954
- try {
955
- const { publish } = await sessionCacheWarmTiming;
956
- const r = await publish();
957
- log('INFO', `session cache warm: ${r.sessions.length} local session(s)`);
958
- }
959
- catch (err) {
960
- log('ERROR', `session cache warm failed: ${err.message}`);
961
- }
962
- finally {
963
- warmingSessionCache = false;
964
- }
965
- };
966
- // Intervals resolved from the module constants (fallback matches the defaults
967
- // if the import is still pending — the first tick uses the live import).
968
- let sessionCacheInterval;
969
- let sessionCacheKickoff;
970
- void sessionCacheWarmTiming.then(({ intervalMs, kickoffMs }) => {
971
- sessionCacheInterval = setInterval(() => { void runSessionCacheWarm(); }, intervalMs);
972
- sessionCacheKickoff = setTimeout(() => { void runSessionCacheWarm(); }, kickoffMs);
973
- });
974
- // Usage refresh: keep the usage cache the `agents run` router reads
975
- // (RUSH-2061, readOnly hot path) fresh, WITHOUT the hot path ever fetching.
976
- // This host is the sole writer for its own local accounts. The tick wakes
977
- // every 60s (USAGE_REFRESH_TICK_MS) to consider due accounts; each account
978
- // is scheduled at a fixed 5-minute cadence (REFRESH_INTERVAL_MS), capped at
979
- // ~12 provider calls/account/hour, skipped under 429 backoff, and fetched
980
- // with fileOnly credentials so a background tick never pops macOS Touch ID.
981
- // Overlap-guarded: a slow pass cannot stack concurrent refresh loops.
982
- let refreshingUsage = false;
983
- const runUsageRefreshTick = async () => {
984
- if (refreshingUsage)
985
- return;
986
- refreshingUsage = true;
987
- try {
988
- const { runUsageRefresh, buildLocalUsageAccounts } = await import('./usage-refresh.js');
989
- const { writeClaudeUsageCache } = await import('./usage.js');
990
- const { usageRateLimitedUntil } = await import('./usage-backoff.js');
991
- const r = await runUsageRefresh({
992
- listAccounts: buildLocalUsageAccounts,
993
- writeUsageCache: writeClaudeUsageCache,
994
- backoffUntil: usageRateLimitedUntil,
995
- });
996
- // Always log a compact summary so "is refresh working?" is greppable even
997
- // when every account was not-due (proves the tick ran).
998
- log('INFO', `usage refresh: ${r.refreshed} refreshed, ${r.failed} failed, ${r.skippedNotDue} not-due, ${r.skippedBackoff} backed-off, ${r.skippedCap} capped`);
999
- }
1000
- catch (err) {
1001
- log('ERROR', `usage refresh failed: ${err.message}`);
1002
- }
1003
- finally {
1004
- refreshingUsage = false;
1005
- }
1006
- };
1007
- // 60s wake matches USAGE_REFRESH_TICK_MS in usage-refresh.ts (keep in sync).
1008
- const usageRefreshInterval = setInterval(() => { void runUsageRefreshTick(); }, 60_000);
1009
- const usageRefreshKickoff = setTimeout(() => { void runUsageRefreshTick(); }, 30_000);
1010
871
  // RUSH-1817: the startup host decision above is one-shot. If a standalone
1011
872
  // broker answered agentPing() at daemon start, the daemon declined to host —
1012
873
  // but should that standalone later die or crash-loop, nothing takes over and
@@ -1110,31 +971,14 @@ export async function runDaemon() {
1110
971
  monitorEngine.stop();
1111
972
  await browserIPC.stop();
1112
973
  clearInterval(monitorInterval);
1113
- clearInterval(watchdogInterval);
1114
- clearTimeout(watchdogKickoff);
1115
974
  clearInterval(healInterval);
1116
975
  clearTimeout(healKickoff);
1117
- clearInterval(autoDispatchInterval);
1118
- clearTimeout(autoDispatchKickoff);
1119
- clearInterval(deviceProbeInterval);
1120
- clearTimeout(deviceProbeKickoff);
1121
- clearInterval(tmuxReconcileInterval);
1122
- clearTimeout(tmuxReconcileKickoff);
1123
- clearInterval(launchHealthInterval);
1124
- clearTimeout(launchHealthKickoff);
1125
- clearInterval(fleetCacheInterval);
1126
- clearTimeout(fleetCacheKickoff);
1127
- if (sessionCacheInterval)
1128
- clearInterval(sessionCacheInterval);
1129
- if (sessionCacheKickoff)
1130
- clearTimeout(sessionCacheKickoff);
1131
- clearInterval(usageRefreshInterval);
1132
- clearTimeout(usageRefreshKickoff);
1133
976
  clearInterval(brokerSelfHealInterval);
1134
977
  clearInterval(keychainReapInterval);
1135
978
  hostedBroker?.close();
1136
979
  removeDaemonPid();
1137
980
  removeHeartbeat();
981
+ unregisterDaemonInstance();
1138
982
  process.exit(0);
1139
983
  };
1140
984
  process.on('SIGHUP', handleReload);
@@ -1292,6 +1136,12 @@ export function startDaemon(agentsBin) {
1292
1136
  * happened to bring it up. See issue #415.
1293
1137
  */
1294
1138
  export function ensureDaemonStarted() {
1139
+ // RUSH-2354: honor daemon.enabled — a background-adjacent caller (secrets
1140
+ // unlock, browser start, ...) must not resurrect a daemon the owner
1141
+ // explicitly turned off. `agents daemon start` is the deliberate override
1142
+ // and calls startDaemon() directly instead of going through this helper.
1143
+ if (!isDaemonEnabled())
1144
+ return null;
1295
1145
  try {
1296
1146
  return startDaemon();
1297
1147
  }
@@ -1499,9 +1349,59 @@ function waitForPid(timeoutMs) {
1499
1349
  }
1500
1350
  return readDaemonPid();
1501
1351
  }
1502
- /** Stop the daemon, unloading it from launchd/systemd if applicable. */
1352
+ /**
1353
+ * Live `__daemon-run` processes still registered in THIS state dir's instance
1354
+ * registry, excluding `exclude`. State-dir-scoped by construction: the registry
1355
+ * lives inside this daemon dir, so a daemon serving a DIFFERENT state dir (a test
1356
+ * fixture with its own HOME, a separate install/home) registers elsewhere and is
1357
+ * invisible here — it is never a stop/takeover target. POSIX-only (the registry
1358
+ * and its `ps` liveness probe are); `[]` on Windows.
1359
+ */
1360
+ function findSurvivingStateDirDaemons(exclude) {
1361
+ if (process.platform === 'win32')
1362
+ return [];
1363
+ const dir = getDaemonInstancesDir();
1364
+ let entries;
1365
+ try {
1366
+ entries = fs.readdirSync(dir);
1367
+ }
1368
+ catch {
1369
+ return [];
1370
+ }
1371
+ const found = [];
1372
+ for (const name of entries) {
1373
+ const pid = parseInt(name, 10);
1374
+ if (isNaN(pid) || String(pid) !== name)
1375
+ continue; // not a pid marker
1376
+ if (exclude.has(pid))
1377
+ continue;
1378
+ if (!isAlive(pid))
1379
+ continue; // dead marker — reaper self-heals it
1380
+ if (!isDaemonRunProcess(pid))
1381
+ continue; // pid reused by an unrelated process
1382
+ found.push(pid);
1383
+ }
1384
+ return found;
1385
+ }
1386
+ /**
1387
+ * Stop the daemon and ASSERT its postcondition (SING-12, RUSH-2355), unloading it
1388
+ * from launchd/systemd if applicable.
1389
+ *
1390
+ * The SIGTERM → grace → killTree sequence is unchanged; what it adds is
1391
+ * verification. After the daemon is gone it checks that the secrets broker socket
1392
+ * and browser IPC binding actually released — a stale socket present on disk but
1393
+ * with no live owner is the orphan that keeps clients holding unlocked bundles
1394
+ * hanging (`daemon.ts` broker-hosting; the two-brokers-on-one-socket bug) — and
1395
+ * that no `__daemon-run` for THIS state dir survives. A killTree escalation exits
1396
+ * without running the daemon's graceful handleShutdown, so those sockets can be
1397
+ * left stale; this reclaims each (the owner is provably dead) and reports it. It
1398
+ * never reports success on an unverified stop.
1399
+ */
1503
1400
  export function stopDaemon() {
1504
1401
  const platform = os.platform();
1402
+ const released = [];
1403
+ const surviving = [];
1404
+ let escalated = false;
1505
1405
  if (platform === 'darwin') {
1506
1406
  const plistPath = getLaunchdPlistPath();
1507
1407
  if (fs.existsSync(plistPath)) {
@@ -1541,6 +1441,7 @@ export function stopDaemon() {
1541
1441
  // its job/browser child tree in one shot (taskkill /T), so stop doesn't
1542
1442
  // report success while children keep running.
1543
1443
  killTree(pid);
1444
+ escalated = true;
1544
1445
  }
1545
1446
  else {
1546
1447
  try {
@@ -1558,12 +1459,76 @@ export function stopDaemon() {
1558
1459
  // after an install into a second prefix.
1559
1460
  if (!waitForExit(pid, STOP_GRACE_MS)) {
1560
1461
  killTree(pid);
1462
+ escalated = true;
1561
1463
  waitForExit(pid, STOP_KILL_GRACE_MS);
1562
1464
  }
1563
1465
  }
1564
1466
  }
1565
1467
  removeDaemonPid();
1566
- return true;
1468
+ // ── Assert the postcondition (SING-12) ────────────────────────────────────
1469
+ // No `__daemon-run` for this state dir may survive the stop.
1470
+ const survivors = findSurvivingStateDirDaemons(new Set([process.pid]));
1471
+ if (pid && process.platform === 'win32' && isAlive(pid) && !survivors.includes(pid)) {
1472
+ survivors.push(pid); // registry is POSIX-only; check the killed pid directly
1473
+ }
1474
+ if (survivors.length > 0) {
1475
+ for (const s of survivors)
1476
+ surviving.push(`__daemon-run pid ${s} still alive`);
1477
+ }
1478
+ else if (pid) {
1479
+ released.push('daemon process');
1480
+ }
1481
+ // Browser IPC binding: on POSIX the listening socket is a filesystem object.
1482
+ // A graceful handleShutdown unlinks it; if it survives, the daemon exited
1483
+ // ungracefully (killTree) and left a stale binding — the owner is provably
1484
+ // dead, so reclaim it and report.
1485
+ if (process.platform !== 'win32') {
1486
+ const browserSock = getBrowserIpcSocketPath();
1487
+ if (fs.existsSync(browserSock)) {
1488
+ try {
1489
+ fs.unlinkSync(browserSock);
1490
+ }
1491
+ catch { /* raced with a fresh start */ }
1492
+ if (fs.existsSync(browserSock))
1493
+ surviving.push('browser IPC socket not released');
1494
+ else
1495
+ released.push('browser IPC socket (reclaimed)');
1496
+ }
1497
+ else {
1498
+ released.push('browser IPC socket');
1499
+ }
1500
+ // Secrets broker socket: released when it is gone, or still owned by a
1501
+ // DIFFERENT live broker (a standalone service the daemon never hosted). A
1502
+ // socket present with NO live owner is the orphan — reclaim it.
1503
+ const brokerSock = secretsBrokerSocketPath();
1504
+ if (!fs.existsSync(brokerSock)) {
1505
+ released.push('secrets broker socket');
1506
+ }
1507
+ else if (brokerPidAlive()) {
1508
+ released.push('secrets broker socket (standalone owner)');
1509
+ }
1510
+ else {
1511
+ try {
1512
+ fs.unlinkSync(brokerSock);
1513
+ }
1514
+ catch { /* raced with a fresh bind */ }
1515
+ if (fs.existsSync(brokerSock))
1516
+ surviving.push('secrets broker socket not released');
1517
+ else
1518
+ released.push('secrets broker socket (reclaimed)');
1519
+ }
1520
+ }
1521
+ // In-flight detached routine children survive on purpose (SING-11a) — report,
1522
+ // never kill: severing a live agent mid-run is worse than a daemon restart.
1523
+ const detachedChildren = listLiveRoutineChildren();
1524
+ return {
1525
+ ok: surviving.length === 0,
1526
+ stoppedPid: pid,
1527
+ escalated,
1528
+ released,
1529
+ surviving,
1530
+ detachedChildren,
1531
+ };
1567
1532
  }
1568
1533
  /** Get current daemon status including running state, PID, and enabled job count. */
1569
1534
  export function getDaemonStatus() {