@phnx-labs/agents-cli 1.22.66 → 1.22.68

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 (109) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +7 -2
  3. package/dist/bootstrap.js +3 -3
  4. package/dist/commands/browser.js +46 -21
  5. package/dist/commands/daemon-test-harness.d.ts +1 -1
  6. package/dist/commands/daemon-test-harness.js +2 -2
  7. package/dist/commands/daemon.js +21 -22
  8. package/dist/commands/exec.js +40 -0
  9. package/dist/commands/monitors.js +2 -2
  10. package/dist/commands/repo.js +57 -19
  11. package/dist/commands/resume.d.ts +16 -0
  12. package/dist/commands/resume.js +41 -8
  13. package/dist/commands/routines.js +42 -21
  14. package/dist/commands/sessions-optimize.js +1 -1
  15. package/dist/commands/sessions.js +4 -11
  16. package/dist/commands/share.d.ts +5 -3
  17. package/dist/commands/share.js +33 -9
  18. package/dist/lib/accounting/account-pool-collect.d.ts +6 -4
  19. package/dist/lib/accounting/account-pool-collect.js +6 -4
  20. package/dist/lib/accounting/usage-ingest.js +4 -2
  21. package/dist/lib/accounting/usage-sync.d.ts +38 -93
  22. package/dist/lib/accounting/usage-sync.js +66 -210
  23. package/dist/lib/accounting/usage.d.ts +11 -3
  24. package/dist/lib/accounting/usage.js +23 -10
  25. package/dist/lib/auth-health.d.ts +8 -0
  26. package/dist/lib/auth-health.js +4 -4
  27. package/dist/lib/browser/caller-identity.d.ts +12 -0
  28. package/dist/lib/browser/caller-identity.js +19 -0
  29. package/dist/lib/browser/ipc.d.ts +37 -32
  30. package/dist/lib/browser/ipc.js +146 -94
  31. package/dist/lib/browser/task-index.d.ts +10 -2
  32. package/dist/lib/browser/task-index.js +22 -3
  33. package/dist/lib/daemon/account-state-daemon-service.d.ts +49 -9
  34. package/dist/lib/daemon/account-state-daemon-service.js +81 -18
  35. package/dist/lib/daemon/auth-sync-service.d.ts +4 -4
  36. package/dist/lib/daemon/auth-sync-service.js +17 -6
  37. package/dist/lib/daemon/catchup-service.d.ts +51 -0
  38. package/dist/lib/daemon/catchup-service.js +51 -0
  39. package/dist/lib/daemon/daemon.d.ts +12 -22
  40. package/dist/lib/daemon/daemon.js +463 -176
  41. package/dist/lib/daemon/runner.js +2 -0
  42. package/dist/lib/daemon/service.d.ts +22 -4
  43. package/dist/lib/daemon/service.js +2 -2
  44. package/dist/lib/daemon/supervisor.d.ts +55 -15
  45. package/dist/lib/daemon/supervisor.js +119 -29
  46. package/dist/lib/daemon/usage-sync-service.d.ts +4 -6
  47. package/dist/lib/daemon/usage-sync-service.js +22 -18
  48. package/dist/lib/daemon-health.js +36 -31
  49. package/dist/lib/daemon-services.d.ts +1 -1
  50. package/dist/lib/daemon-services.js +12 -2
  51. package/dist/lib/daemon-ticks.d.ts +9 -6
  52. package/dist/lib/daemon-ticks.js +14 -8
  53. package/dist/lib/devices/registry.js +2 -0
  54. package/dist/lib/exec.js +11 -0
  55. package/dist/lib/feed/watch.d.ts +3 -0
  56. package/dist/lib/feed/watch.js +13 -3
  57. package/dist/lib/fleet-shared-repo-sync.d.ts +36 -0
  58. package/dist/lib/fleet-shared-repo-sync.js +333 -0
  59. package/dist/lib/fleet-shared-state.d.ts +38 -0
  60. package/dist/lib/fleet-shared-state.js +105 -0
  61. package/dist/lib/hosts/remote-cmd.d.ts +2 -0
  62. package/dist/lib/hosts/remote-cmd.js +12 -3
  63. package/dist/lib/lock-compromise.d.ts +8 -0
  64. package/dist/lib/lock-compromise.js +12 -0
  65. package/dist/lib/monitors/engine.d.ts +2 -1
  66. package/dist/lib/monitors/engine.js +9 -2
  67. package/dist/lib/refresh-coordinator.js +2 -0
  68. package/dist/lib/run-trace-sync.d.ts +13 -0
  69. package/dist/lib/run-trace-sync.js +77 -0
  70. package/dist/lib/secrets/push.d.ts +10 -0
  71. package/dist/lib/secrets/push.js +86 -7
  72. package/dist/lib/secrets/remote.d.ts +18 -6
  73. package/dist/lib/secrets/remote.js +29 -4
  74. package/dist/lib/secrets/reserved-sync.d.ts +28 -27
  75. package/dist/lib/secrets/reserved-sync.js +119 -101
  76. package/dist/lib/session/active.d.ts +3 -1
  77. package/dist/lib/session/active.js +2 -0
  78. package/dist/lib/session/actor-sidecar.d.ts +12 -0
  79. package/dist/lib/session/actor-sidecar.js +2 -0
  80. package/dist/lib/session/db.d.ts +5 -2
  81. package/dist/lib/session/db.js +54 -22
  82. package/dist/lib/session/discover.d.ts +12 -3
  83. package/dist/lib/session/discover.js +106 -25
  84. package/dist/lib/session/prompt.d.ts +11 -0
  85. package/dist/lib/session/prompt.js +25 -0
  86. package/dist/lib/session/recovery.d.ts +30 -2
  87. package/dist/lib/session/recovery.js +56 -4
  88. package/dist/lib/session/remote/remote-list.d.ts +17 -1
  89. package/dist/lib/session/remote/remote-list.js +29 -4
  90. package/dist/lib/session/remote/watch.d.ts +25 -2
  91. package/dist/lib/session/remote/watch.js +185 -11
  92. package/dist/lib/session/session-cache.d.ts +2 -1
  93. package/dist/lib/session/session-cache.js +1 -0
  94. package/dist/lib/session/types.d.ts +2 -0
  95. package/dist/lib/share/publish.d.ts +55 -9
  96. package/dist/lib/share/publish.js +87 -9
  97. package/dist/lib/share/worker-template.js +100 -8
  98. package/dist/lib/ssh-exec.d.ts +2 -0
  99. package/dist/lib/ssh-exec.js +20 -4
  100. package/dist/lib/teams/registry.js +2 -0
  101. package/dist/lib/testdata/daemon-health-writer.d.ts +1 -0
  102. package/dist/lib/testdata/daemon-health-writer.js +8 -0
  103. package/dist/lib/traces/sync.d.ts +7 -0
  104. package/dist/lib/traces/sync.js +9 -0
  105. package/dist/lib/usage-refresh.d.ts +8 -2
  106. package/dist/lib/usage-refresh.js +3 -3
  107. package/package.json +1 -1
  108. package/dist/lib/account-state-service.d.ts +0 -21
  109. package/dist/lib/account-state-service.js +0 -60
@@ -34,7 +34,8 @@ import { ServiceSupervisor } from './supervisor.js';
34
34
  import { SessionIndexService } from './session-index-service.js';
35
35
  import { SecretsBrokerService } from './secrets-broker-service.js';
36
36
  import { MonitorEngineService } from './monitor-engine-service.js';
37
- import { AccountStateDaemonService } from './account-state-daemon-service.js';
37
+ import { AccountUsageService, AccountAuthService } from './account-state-daemon-service.js';
38
+ import { CatchupService } from './catchup-service.js';
38
39
  import { BrowserIPCService } from './browser-ipc-service.js';
39
40
  import { WatchdogService } from './watchdog-service.js';
40
41
  import { DeviceProbeService } from './device-probe-service.js';
@@ -50,6 +51,7 @@ import { TmuxReapService } from './tmux-reap-service.js';
50
51
  import { BrowserTaskReapService } from './browser-task-reap-service.js';
51
52
  import { emit, emitRoutineEnd } from '../feed/events.js';
52
53
  import { readDaemonServicesConfig, drainDaemonServiceRestartQueue } from '../daemon-services.js';
54
+ import { sleepSync } from '../fs-atomic.js';
53
55
  /**
54
56
  * The live `ServiceSupervisor` for the current `runDaemon()` invocation, or
55
57
  * `null` before boot / after shutdown. In-process only — a separate `agents
@@ -114,14 +116,9 @@ export function daemonSystemdUnitName() {
114
116
  const suffix = isolatedHomeSuffix();
115
117
  return suffix ? `agents-daemon-sandbox-${suffix}.service` : SYSTEMD_UNIT;
116
118
  }
117
- /**
118
- * How often to re-scan for missed fires. Deliberately slower than the monitor
119
- * tick: detection walks a week of cron occurrences per routine
120
- * (`previousExpectedFire`), and a fire that was already missed is not urgent to
121
- * the second — five minutes bounds the cost while still recovering from a
122
- * wedge or an OS suspend the process survived.
123
- */
124
- const CATCHUP_TICK_MS = 5 * 60_000;
119
+ // Catch-up cadence + the supervised CatchupService live in catchup-service.ts
120
+ // (PHNX-3608): the pass now runs under the ServiceSupervisor with a deadline +
121
+ // AbortSignal + circuit breaker instead of a bare setInterval.
125
122
  /**
126
123
  * Cadences for the in-process background ticks, named here beside the other
127
124
  * tick constants rather than left as inline literals at their `setInterval`
@@ -265,6 +262,25 @@ function acquireStartLock() {
265
262
  throw err;
266
263
  }
267
264
  }
265
+ /**
266
+ * Stop is a lifecycle mutation just like start/claim, so it must cross the same
267
+ * lock. A claim can legitimately hold the lock through the incumbent's 5s
268
+ * graceful window plus the 2s hard-kill backstop; wait beyond that complete
269
+ * takeover window before failing loud instead of running teardown unlocked.
270
+ */
271
+ const STOP_LOCK_WAIT_MS = 10_000;
272
+ const STOP_LOCK_POLL_MS = 50;
273
+ function acquireStopLock() {
274
+ const deadline = Date.now() + STOP_LOCK_WAIT_MS;
275
+ for (;;) {
276
+ const release = acquireStartLock();
277
+ if (release)
278
+ return release;
279
+ if (Date.now() >= deadline)
280
+ return null;
281
+ sleepSync(Math.min(STOP_LOCK_POLL_MS, deadline - Date.now()));
282
+ }
283
+ }
268
284
  /**
269
285
  * Absolute path to the daemon's structured log.
270
286
  *
@@ -306,6 +322,16 @@ export function removeDaemonPid() {
306
322
  fs.unlinkSync(pidPath);
307
323
  }
308
324
  }
325
+ /** Remove the pid registration only while it still names the owner we observed. */
326
+ function removeDaemonPidIfOwned(pid) {
327
+ if (readDaemonPid() !== pid)
328
+ return false;
329
+ try {
330
+ fs.unlinkSync(getPidPath());
331
+ }
332
+ catch { /* already removed */ }
333
+ return readDaemonPid() !== pid;
334
+ }
309
335
  function getHeartbeatPath() {
310
336
  return path.join(ensureDaemonDir(), HEARTBEAT_FILE);
311
337
  }
@@ -348,7 +374,7 @@ export function isDaemonWedged() {
348
374
  const pid = readDaemonPid();
349
375
  if (!pid)
350
376
  return false;
351
- if (!isAlive(pid))
377
+ if (!isLiveDaemon(pid))
352
378
  return false;
353
379
  const hb = readHeartbeat();
354
380
  if (!hb)
@@ -372,32 +398,66 @@ const STOP_KILL_GRACE_MS = 2000;
372
398
  * the pid file then reports "stopped" for a running scheduler, and (worse) lets
373
399
  * claimDaemonInstance() start a SECOND daemon that double-fires every routine.
374
400
  *
375
- * So: trust the pid file when its pid is alive; otherwise trust a FRESH
376
- * heartbeat whose pid is alive, and re-adopt the pid file so the desync heals.
377
- * Returns null only when neither points at a live process (clearing a stale pid
378
- * file on the way out).
401
+ * So: trust the pid file only when its pid is a live `__daemon-run`; otherwise
402
+ * trust a FRESH heartbeat whose pid passes the same identity check. Callers that
403
+ * already own daemon.lock may request repair, re-adopting the heartbeat pid or
404
+ * removing the exact stale pid they observed. Read-only liveness probes never
405
+ * mutate shared state outside that lock.
379
406
  */
380
- function resolveLiveDaemonPid() {
407
+ function resolveLiveDaemonPid(repair = false) {
381
408
  const pid = readDaemonPid();
382
- if (pid !== null && isAlive(pid))
409
+ const pidIdentity = pid !== null ? daemonProcessIdentity(pid) : 'dead';
410
+ if (pid !== null && pidIdentity === 'daemon')
383
411
  return pid;
384
412
  const hb = readHeartbeat();
385
- if (hb && isAlive(hb.pid) && isHeartbeatFresh(hb)) {
386
- if (pid !== hb.pid)
387
- writeDaemonPid(hb.pid); // heal the pid-file/heartbeat desync
413
+ if (hb && isHeartbeatFresh(hb) && daemonProcessIdentity(hb.pid) === 'daemon') {
414
+ if (repair && pid !== hb.pid)
415
+ writeDaemonPid(hb.pid); // lock owner heals the desync
388
416
  return hb.pid;
389
417
  }
390
- if (pid !== null)
391
- removeDaemonPid();
418
+ // A failed command-line inspection is not proof the pid is stale. Leave the
419
+ // registration intact so a sandbox/permission failure cannot erase the only
420
+ // owner record and trigger a duplicate daemon.
421
+ if (repair && pid !== null && pidIdentity !== 'unknown')
422
+ removeDaemonPidIfOwned(pid);
423
+ return null;
424
+ }
425
+ /** A live recorded pid whose command identity could not be inspected. */
426
+ function unverifiedLiveDaemonPid() {
427
+ const pid = readDaemonPid();
428
+ if (pid !== null && daemonProcessIdentity(pid) === 'unknown')
429
+ return pid;
430
+ const hb = readHeartbeat();
431
+ if (hb && isHeartbeatFresh(hb) && daemonProcessIdentity(hb.pid) === 'unknown')
432
+ return hb.pid;
392
433
  return null;
393
434
  }
394
435
  /**
395
436
  * Check whether a daemon is alive — via the pid file, or a fresh heartbeat when
396
- * the pid file has been lost (see resolveLiveDaemonPid). Heals the pid file as a
397
- * side effect so a subsequent read is consistent.
437
+ * the pid file has been lost (see resolveLiveDaemonPid). The observation itself
438
+ * is read-only; when it finds desync it opportunistically acquires daemon.lock
439
+ * and repeats the observation there before repairing. A contended probe still
440
+ * returns the observed liveness without mutating another lifecycle operation's
441
+ * state.
398
442
  */
399
443
  export function isDaemonRunning() {
400
- return resolveLiveDaemonPid() !== null;
444
+ // Fail safe for reporting/start suppression: inability to inspect a live pid
445
+ // is never permission to declare it dead and launch a duplicate.
446
+ if (unverifiedLiveDaemonPid() !== null)
447
+ return true;
448
+ const recordedPid = readDaemonPid();
449
+ const livePid = resolveLiveDaemonPid();
450
+ if (recordedPid === livePid)
451
+ return livePid !== null;
452
+ const release = acquireStartLock();
453
+ if (!release)
454
+ return livePid !== null;
455
+ try {
456
+ return resolveLiveDaemonPid(true) !== null;
457
+ }
458
+ finally {
459
+ release();
460
+ }
401
461
  }
402
462
  /**
403
463
  * Single-instance claim for the daemon foreground entrypoint.
@@ -412,9 +472,9 @@ export function isDaemonRunning() {
412
472
  * pid file, this does NOT defer to it — it evicts the incumbent and becomes the
413
473
  * survivor, so a second install can never leave two daemons running. Returns true
414
474
  * and records our PID once the incumbent is provably dead (its resources
415
- * released). Returns false ONLY when another `__daemon-run` currently holds the
416
- * O_EXCL start lock — i.e. a concurrent claimer is mid-takeover and will be the
417
- * singleton — in which case the caller must exit without touching further state.
475
+ * released). Returns false when another `__daemon-run` currently holds the
476
+ * O_EXCL start lock, or when the incumbent cannot be safely identified/evicted,
477
+ * in which case the caller must exit without touching further state.
418
478
  * The read-evict-write is serialized behind the same start lock startDaemon()
419
479
  * uses, so two `_run` processes can't both claim in the window between the
420
480
  * liveness check and the write.
@@ -431,11 +491,15 @@ export function claimDaemonInstance() {
431
491
  if (!release)
432
492
  return false;
433
493
  try {
494
+ // Do not overwrite a live-but-uninspectable owner. This is the non-
495
+ // destructive side of the same fail-closed rule stopDaemon applies.
496
+ if (unverifiedLiveDaemonPid() !== null)
497
+ return false;
434
498
  // resolveLiveDaemonPid() also consults a fresh heartbeat, so a live daemon
435
499
  // whose pid file was lost is still found and evicted — otherwise a missing
436
500
  // pid file would let both this instance AND the orphaned incumbent run a
437
501
  // JobScheduler at once and double-fire every routine.
438
- const existing = resolveLiveDaemonPid();
502
+ const existing = resolveLiveDaemonPid(true);
439
503
  if (existing !== null && existing !== process.pid) {
440
504
  // Evict, and WAIT for the incumbent to be provably dead — its graceful
441
505
  // handleShutdown releasing the browser IPC binding and the secrets broker
@@ -443,7 +507,8 @@ export function claimDaemonInstance() {
443
507
  // Binding before the release recreates the two-brokers-on-one-socket orphan
444
508
  // documented at stopDaemon below, so the pid file is not written until the
445
509
  // prior owner is gone.
446
- evictIncumbentDaemon(existing);
510
+ if (!evictIncumbentDaemon(existing))
511
+ return false;
447
512
  }
448
513
  writeDaemonPid(process.pid);
449
514
  return true;
@@ -465,24 +530,40 @@ export function claimDaemonInstance() {
465
530
  * proof-of-release requirement applies.
466
531
  */
467
532
  function evictIncumbentDaemon(pid) {
533
+ const beforeSignal = daemonProcessIdentity(pid);
534
+ if (beforeSignal === 'dead' || beforeSignal === 'other')
535
+ return true;
536
+ if (beforeSignal === 'unknown')
537
+ return false;
468
538
  if (process.platform === 'win32') {
469
539
  // No graceful termination signal on Windows — take the incumbent down and
470
540
  // still wait for the kill to land before the caller binds anything (mirrors
471
541
  // stopDaemon's win32 branch).
472
542
  killTree(pid);
473
- waitForExit(pid, STOP_KILL_GRACE_MS);
474
- return;
543
+ if (waitForExit(pid, STOP_KILL_GRACE_MS))
544
+ return true;
545
+ const afterKill = daemonProcessIdentity(pid);
546
+ return afterKill === 'dead' || afterKill === 'other';
475
547
  }
476
548
  try {
477
549
  process.kill(pid, 'SIGTERM');
478
550
  }
479
551
  catch {
480
- return; // already gone between resolveLiveDaemonPid() and here
552
+ const afterSignal = daemonProcessIdentity(pid);
553
+ return afterSignal === 'dead' || afterSignal === 'other';
481
554
  }
482
555
  if (waitForExit(pid, STOP_GRACE_MS))
483
- return; // graceful release complete
556
+ return true; // graceful release complete
557
+ const beforeKill = daemonProcessIdentity(pid);
558
+ if (beforeKill === 'dead' || beforeKill === 'other')
559
+ return true;
560
+ if (beforeKill === 'unknown')
561
+ return false;
484
562
  killTree(pid); // positive pid: SIGKILL reaches the daemon, not its job children
485
- waitForExit(pid, STOP_KILL_GRACE_MS);
563
+ if (waitForExit(pid, STOP_KILL_GRACE_MS))
564
+ return true;
565
+ const afterKill = daemonProcessIdentity(pid);
566
+ return afterKill === 'dead' || afterKill === 'other';
486
567
  }
487
568
  /** Directory that registers every live daemon of THIS device (one per state dir). */
488
569
  function getDaemonInstancesDir() {
@@ -546,7 +627,7 @@ export function reapStrayDaemons(keepPid = process.pid) {
546
627
  catch {
547
628
  return { reaped, details }; // no registry yet — nothing to reap
548
629
  }
549
- const ownerPid = readDaemonPid();
630
+ const ownerPid = resolveLiveDaemonPid();
550
631
  const dropMarker = (name) => {
551
632
  try {
552
633
  fs.rmSync(path.join(dir, name), { force: true });
@@ -566,40 +647,73 @@ export function reapStrayDaemons(keepPid = process.pid) {
566
647
  }
567
648
  // Live pid, but guard against pid reuse: only a real `__daemon-run` is ours.
568
649
  // Process ARGS are readable cross-platform (unlike ENV on hardened macOS).
569
- if (!isDaemonRunProcess(pid)) {
650
+ const identity = daemonProcessIdentity(pid);
651
+ if (identity === 'unknown') {
652
+ details.push(`could not verify stray daemon pid ${pid}; marker retained`);
653
+ continue;
654
+ }
655
+ if (identity !== 'daemon') {
570
656
  dropMarker(name);
571
657
  continue;
572
658
  }
573
659
  try {
574
660
  process.kill(pid, 'SIGTERM');
661
+ }
662
+ catch { /* already gone between the alive check and the signal */ }
663
+ if (waitForExit(pid, STOP_GRACE_MS)) {
575
664
  reaped++;
576
665
  details.push(`reaped stray daemon pid ${pid}`);
666
+ dropMarker(name);
667
+ continue;
668
+ }
669
+ // The pid may have been recycled during the grace window. Never hard-kill
670
+ // it unless it still identifies as a daemon; a stale registry marker is all
671
+ // we own when the command identity changed.
672
+ const beforeKill = daemonProcessIdentity(pid);
673
+ if (beforeKill === 'unknown') {
674
+ details.push(`could not reverify stray daemon pid ${pid}; marker retained`);
675
+ continue;
676
+ }
677
+ if (beforeKill !== 'daemon') {
678
+ dropMarker(name);
679
+ continue;
680
+ }
681
+ killTree(pid);
682
+ if (waitForExit(pid, STOP_KILL_GRACE_MS)) {
683
+ reaped++;
684
+ details.push(`reaped stray daemon pid ${pid} (escalated)`);
685
+ dropMarker(name);
686
+ }
687
+ else {
688
+ // Keep the only marker for a process that survived both signals so the
689
+ // next reaper/doctor can still see it.
690
+ details.push(`stray daemon pid ${pid} survived SIGKILL`);
577
691
  }
578
- catch { /* already gone between the alive check and the signal */ }
579
- dropMarker(name);
580
692
  }
581
693
  return { reaped, details };
582
694
  }
583
- /**
584
- * Whether `pid` is a live `agents __daemon-run` process. Reads the process's
585
- * command line (`ps -p <pid> -o command=`), which — unlike its environment — is
586
- * visible cross-platform, including on hardened macOS. Guards the reaper against
587
- * killing an unrelated process that reused a dead registrant's pid.
588
- */
589
- function isDaemonRunProcess(pid) {
590
- if (process.platform === 'win32')
591
- return false;
695
+ function daemonProcessIdentity(pid) {
696
+ if (!isAlive(pid))
697
+ return 'dead';
592
698
  try {
593
- const out = execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
594
- encoding: 'utf-8',
595
- stdio: ['ignore', 'pipe', 'ignore'],
596
- });
597
- return /\b__daemon-run\b/.test(out);
699
+ const out = process.platform === 'win32'
700
+ ? execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CommandLine`], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true, timeout: 5000 })
701
+ : execFileSync('ps', ['-ww', '-p', String(pid), '-o', 'command='], {
702
+ encoding: 'utf-8',
703
+ stdio: ['ignore', 'pipe', 'ignore'],
704
+ });
705
+ // getDaemonLaunch always places __daemon-run last. Matching it anywhere in
706
+ // the command line mistakes an agent prompt or shell script that merely
707
+ // mentions the token for the shared daemon.
708
+ return /(?:^|\s)["']?__daemon-run["']?\s*$/.test(out.trim()) ? 'daemon' : 'other';
598
709
  }
599
710
  catch {
600
- return false;
711
+ return 'unknown';
601
712
  }
602
713
  }
714
+ export function isLiveDaemon(pid) {
715
+ return daemonProcessIdentity(pid) === 'daemon';
716
+ }
603
717
  function rotateLogsIfNeeded(logPath) {
604
718
  try {
605
719
  const stat = fs.statSync(logPath);
@@ -643,6 +757,17 @@ export function log(level, message) {
643
757
  }
644
758
  catch { /* never crash the daemon on event-log failure */ }
645
759
  }
760
+ /** Keep synchronous signal-handler failures inside the daemon's crash barrier. */
761
+ export function guardSignalHandler(handler, onError) {
762
+ return () => {
763
+ try {
764
+ handler();
765
+ }
766
+ catch (err) {
767
+ onError(err);
768
+ }
769
+ };
770
+ }
646
771
  /** Main daemon loop: load jobs, schedule crons, monitor runs, and handle signals. */
647
772
  /**
648
773
  * Anchor the daemon's working directory to a stable, always-present path.
@@ -759,17 +884,47 @@ export async function runDaemon() {
759
884
  // HOME override must refuse to run against the operator's real state rather than
760
885
  // schedule against the real host. No-op in production (the marker is never set).
761
886
  assertTestDaemonHome();
887
+ // Install the shared-daemon reload signal boundary BEFORE publishing our PID
888
+ // in claimDaemonInstance(). Browser/routines clients use that PID to decide a
889
+ // daemon exists and may request a service reload immediately. POSIX otherwise
890
+ // applies its default SIGHUP action during the rest of startup and terminates
891
+ // the whole process — exactly the client-caused eviction PHNX-3605 forbids.
892
+ // Requests received before services are ready coalesce into one reload and are
893
+ // applied through the normal guarded handler once startup completes.
894
+ let reloadRequestedDuringStartup = false;
895
+ let liveReloadHandler = null;
896
+ const dispatchReloadSignal = () => {
897
+ if (liveReloadHandler)
898
+ liveReloadHandler();
899
+ else
900
+ reloadRequestedDuringStartup = true;
901
+ };
902
+ if (process.platform !== 'win32')
903
+ process.on('SIGHUP', dispatchReloadSignal);
762
904
  // Single-instance guard (last-wins, SING-11): a direct `agents __daemon-run`
763
905
  // (manual, or a service-manager restart racing a live predecessor) EVICTS the
764
906
  // incumbent and becomes the survivor. claimDaemonInstance returns false only
765
907
  // when a concurrent `__daemon-run` currently holds the start lock — that peer
766
908
  // is mid-takeover and will be the singleton, so this instance stands down.
767
909
  if (!claimDaemonInstance()) {
768
- log('WARN', `Another daemon is mid-takeover (holds the start lock); this instance (PID ${process.pid}) is exiting`);
910
+ if (process.platform !== 'win32')
911
+ process.removeListener('SIGHUP', dispatchReloadSignal);
912
+ log('WARN', `Another daemon owns lifecycle state or is mid-takeover; this instance (PID ${process.pid}) is exiting`);
769
913
  // Exit cleanly (0) so a service manager treats it as an orderly no-op
770
914
  // rather than a failure to restart-flap on.
771
915
  process.exit(0);
772
916
  }
917
+ // Deterministic integration-test seam for the PID-published/startup-complete
918
+ // signal window above. It is honored only inside an explicitly isolated test
919
+ // HOME; production daemons never pause here.
920
+ const startupDelayRaw = process.env.AGENTS_DAEMON_TEST_STARTUP_DELAY_MS;
921
+ if (process.env.AGENTS_DAEMON_TEST_HOME && startupDelayRaw) {
922
+ const startupDelayMs = Number(startupDelayRaw);
923
+ if (!Number.isInteger(startupDelayMs) || startupDelayMs < 1 || startupDelayMs > 10_000) {
924
+ throw new Error('AGENTS_DAEMON_TEST_STARTUP_DELAY_MS must be an integer from 1 to 10000');
925
+ }
926
+ await new Promise((resolve) => setTimeout(resolve, startupDelayMs));
927
+ }
773
928
  // Unlike the pid and heartbeat files, this marker is written exactly once
774
929
  // for this daemon lifetime. Status probes deliberately repair those other
775
930
  // files, so they cannot prove that the original state dir still exists.
@@ -857,18 +1012,46 @@ export async function runDaemon() {
857
1012
  supervisor.register(monitorEngineSvc);
858
1013
  else
859
1014
  log('INFO', 'Monitor engine disabled');
1015
+ // Usage and auth refresh are two INDEPENDENT supervised services (PHNX-3608)
1016
+ // so a run of usage-refresh failures parks only usage and never starves the
1017
+ // slower auth refresh — each carries its own circuit breaker.
860
1018
  if (isEnabled('account-state'))
861
- supervisor.register(new AccountStateDaemonService());
1019
+ supervisor.register(new AccountUsageService());
862
1020
  else
863
1021
  log('INFO', 'Account-state service disabled');
864
- // BrowserIPCService and BrowserTaskReapService share one long-lived
865
- // BrowserService when both are enabled. Either service can run independently.
866
- const browserService = isEnabled('browser-ipc') || isEnabled('browser-task-reap')
867
- ? new BrowserService()
868
- : null;
869
- if (isEnabled('browser-ipc') && browserService)
870
- supervisor.register(new BrowserIPCService(browserService));
1022
+ if (isEnabled('account-auth'))
1023
+ supervisor.register(new AccountAuthService());
871
1024
  else
1025
+ log('INFO', 'Account-auth service disabled');
1026
+ // The routine scheduler handle. Declared HERE — before the CatchupService
1027
+ // registration — because `supervisor.startAll()` below fires each service's
1028
+ // first tick synchronously, so the `catchup` tick reads `scheduler` during
1029
+ // startAll, BEFORE this `let` would initialise if it lived at its old textual
1030
+ // position further down. That was a real TDZ `ReferenceError` on every boot
1031
+ // ("Cannot access 'scheduler' before initialization"), not a race (PHNX-3608).
1032
+ // `bootScheduler`/`stopScheduler` (hoisted below) assign this same binding.
1033
+ let scheduler = null;
1034
+ // Catch-up recovery under the supervisor (PHNX-3608). The closures reference
1035
+ // `scheduler` (declared just above) and `catchupPass` (a hoisted function
1036
+ // declaration). The tick self-gates on the scheduler being booted, so it is a
1037
+ // cheap no-op — including on its immediate first tick during startAll, when
1038
+ // `scheduler` is still null — on a device whose scheduler.enabled gate is off.
1039
+ if (isEnabled('catchup')) {
1040
+ supervisor.register(new CatchupService({
1041
+ isSchedulerBooted: () => scheduler !== null,
1042
+ runPass: (signal) => catchupPass(signal),
1043
+ }));
1044
+ }
1045
+ else {
1046
+ log('INFO', 'Catch-up recovery service disabled');
1047
+ }
1048
+ // BrowserIPCService and BrowserTaskReapService share one long-lived
1049
+ // BrowserService. Browser IPC is registered even when disabled at boot so an
1050
+ // explicit later `agents browser start` can enable it live over SIGHUP — the
1051
+ // client owns a service transition, never a whole-daemon restart (PHNX-3605).
1052
+ const browserService = new BrowserService();
1053
+ supervisor.register(new BrowserIPCService(browserService), { enabled: isEnabled('browser-ipc') });
1054
+ if (!isEnabled('browser-ipc'))
872
1055
  log('INFO', 'Browser IPC service disabled');
873
1056
  if (isEnabled('session-index'))
874
1057
  supervisor.register(new SessionIndexService());
@@ -914,7 +1097,7 @@ export async function runDaemon() {
914
1097
  supervisor.register(new TmuxReapService());
915
1098
  else
916
1099
  log('INFO', 'Tmux reap service disabled');
917
- if (isEnabled('browser-task-reap') && browserService) {
1100
+ if (isEnabled('browser-task-reap')) {
918
1101
  supervisor.register(new BrowserTaskReapService(browserService));
919
1102
  }
920
1103
  else {
@@ -1014,15 +1197,13 @@ export async function runDaemon() {
1014
1197
  .catch(() => { });
1015
1198
  }
1016
1199
  };
1017
- let scheduler = null;
1018
- let catchupInterval;
1019
- // Catchup overlap guard. Declared up here (not beside catchupPass) because
1020
- // bootScheduler() runs before catchupPass's textual position — a `let` down
1021
- // there would still be in its TDZ at the first call and crash the daemon.
1022
- let catchingUp = false;
1023
- // Boot the scheduler + catchup recovery. Called at daemon start when the gate
1024
- // allows, and again from handleReload when the gate flips on (function
1025
- // declarations hoist — catchupPass below is in scope).
1200
+ // `scheduler` is declared earlier (before the CatchupService registration) to
1201
+ // avoid a TDZ read during supervisor.startAll — see the comment there.
1202
+ // Boot the scheduler. Called at daemon start when the gate allows, and again
1203
+ // from handleReload when the gate flips on. Catch-up recovery is a separate
1204
+ // supervised service (CatchupService, registered above) that self-gates on
1205
+ // `scheduler !== null`; here we just kick an immediate supervised pass so a
1206
+ // fresh boot catches up missed fires without waiting a full CATCHUP_TICK_MS.
1026
1207
  function bootScheduler() {
1027
1208
  scheduler = new JobScheduler(triggerJob);
1028
1209
  scheduler.loadAll();
@@ -1031,17 +1212,14 @@ export async function runDaemon() {
1031
1212
  for (const job of scheduled) {
1032
1213
  log('INFO', ` ${job.name} -> next: ${job.nextRun?.toISOString() || 'unknown'}`);
1033
1214
  }
1034
- void catchupPass();
1035
- catchupInterval = setInterval(() => { void catchupPass(); }, CATCHUP_TICK_MS);
1215
+ if (supervisor.isRegistered('catchup'))
1216
+ supervisor.runNow('catchup');
1036
1217
  }
1037
- // Stop the scheduler + catchup recovery (gate flipped off on reload).
1218
+ // Stop the scheduler (gate flipped off on reload). The CatchupService keeps its
1219
+ // supervised timer but its tick no-ops once `scheduler` is null.
1038
1220
  function stopScheduler() {
1039
1221
  scheduler?.stopAll();
1040
1222
  scheduler = null;
1041
- if (catchupInterval !== undefined) {
1042
- clearInterval(catchupInterval);
1043
- catchupInterval = undefined;
1044
- }
1045
1223
  }
1046
1224
  // Materialise opted-in project routines into the user layer on every start
1047
1225
  // so a fresh daemon picks up project YAML without a separate sync step.
@@ -1078,19 +1256,15 @@ export async function runDaemon() {
1078
1256
  // `catchup: false`, RUN late. Runs on a timer as well as at startup: a startup
1079
1257
  // pass alone misses a fire lost while the daemon stayed up but its event loop
1080
1258
  // was wedged, or one lost across an OS suspend that the process survived.
1081
- // Overlap guard, same shape as SelfHealService's supervisor-owned inFlight guard. A pass
1082
- // awaits executeJobDetached per job and an off-box (host/cloud) dispatch can
1083
- // block for a while, so a slow pass could still be working when the next tick
1084
- // fires. Both passes would then see a job the first has not yet reached as
1085
- // overdue — the miss is recorded before the await, but only for jobs already
1086
- // processed — and spawn it twice. The idempotency of the `missed` record
1087
- // guards across passes, not within one that is mid-flight.
1088
- // Function declaration (hoisted) so bootScheduler() can schedule it. Its
1089
- // guard (`catchingUp`) is declared beside `scheduler` above for TDZ safety.
1090
- async function catchupPass() {
1091
- if (catchingUp)
1092
- return;
1093
- catchingUp = true;
1259
+ // A pass awaits executeJobDetached per job and an off-box (host/cloud)
1260
+ // dispatch can block for a while. Overlap is now guarded by the supervisor's
1261
+ // per-service inFlight guard (CatchupService) — a slow pass never overlaps the
1262
+ // next supervised tick — rather than a local `catchingUp` flag; and the
1263
+ // idempotency of the `missed` record still guards across passes and daemon
1264
+ // restarts. `signal` aborts at the CatchupService deadline, so a wedged pass is
1265
+ // abandoned + restarted instead of latching (PHNX-3608). Function declaration
1266
+ // (hoisted) so the CatchupService registration above can reference it.
1267
+ async function catchupPass(signal) {
1094
1268
  try {
1095
1269
  const overdue = detectOverdueJobs();
1096
1270
  if (overdue.length === 0)
@@ -1100,6 +1274,8 @@ export async function runDaemon() {
1100
1274
  const last = job.lastRanAt ? job.lastRanAt.toISOString() : 'never';
1101
1275
  log('WARN', ` ${job.name} -- expected ${job.expectedAt.toISOString()}, last ran ${last}`);
1102
1276
  }
1277
+ if (signal?.aborted)
1278
+ return;
1103
1279
  notifyOverdue(overdue);
1104
1280
  const outcomes = await runCatchup({ overdue });
1105
1281
  for (const o of outcomes) {
@@ -1130,13 +1306,12 @@ export async function runDaemon() {
1130
1306
  }
1131
1307
  }
1132
1308
  catch (err) {
1309
+ // Ordinary pass errors are logged, not re-thrown: a transient catchup
1310
+ // failure should not trip the circuit breaker. A HANG is still caught — the
1311
+ // CatchupService deadline aborts the tick and the supervisor parks +
1312
+ // restarts it regardless of this swallow (PHNX-3608).
1133
1313
  log('ERROR', `Catchup pass failed: ${err.message}`);
1134
1314
  }
1135
- finally {
1136
- // finally, not a tail assignment: the no-overdue path returns early, and a
1137
- // throw must not leave the guard latched shut for the daemon's lifetime.
1138
- catchingUp = false;
1139
- }
1140
1315
  }
1141
1316
  // Browser orphan reap and IPC server start are now managed by BrowserIPCService
1142
1317
  // on the supervisor (RUSH-3193 P2). The orphan reap runs inside onStart().
@@ -1181,22 +1356,22 @@ export async function runDaemon() {
1181
1356
  const was = servicesConfig.services[id] !== false;
1182
1357
  const now = reloadedEnabled(id);
1183
1358
  if (was !== now) {
1184
- // scheduler and monitors' off-transition are re-evaluated live later in
1185
- // this handler, so don't tell the user they need a restart for those.
1186
- if (id === 'scheduler' || (id === 'monitors' && !now)) {
1187
- continue;
1188
- }
1189
- if (id === 'monitors') {
1190
- // monitors' on-transition still needs a restart — starting the
1191
- // engine live is not wired up (see the liveMonitorEngine block below).
1192
- log('INFO', `Service '${id}' toggled on — restart daemon to apply`);
1359
+ // The scheduler is not a supervised service — its gate is re-evaluated
1360
+ // live later in this handler, so don't tell the user to restart for it.
1361
+ if (id === 'scheduler') {
1193
1362
  continue;
1194
1363
  }
1195
- // RUSH-3193 P4: a service the supervisor already owns (it was enabled
1196
- // at daemon boot, so it was registered) takes the toggle live via
1197
- // supervisor.start/stop — no restart needed. One disabled at boot was
1198
- // never registered, so it falls through to the same "restart to
1199
- // apply" advice as before.
1364
+ // RUSH-3193 P4: a service the supervisor already owns takes the toggle
1365
+ // live via supervisor.start/stop — no restart needed. monitors is a
1366
+ // supervised service (PHNX-3608): its enable/disable takes effect through
1367
+ // that generic supervisor.start/stop path, so a disabled monitors service
1368
+ // actually stops dispatching (its supervised tick is torn down) instead of
1369
+ // being ticked with the last-loaded set. browser-ipc is deliberately
1370
+ // registered in a stopped state too (PHNX-3605), so a later browser client
1371
+ // can enable that service without restarting the shared daemon. Most other
1372
+ // services are only registered when enabled at boot; one disabled at boot
1373
+ // was never registered, so it falls through to the same "restart to apply"
1374
+ // advice as before.
1200
1375
  if (supervisor.isRegistered(id)) {
1201
1376
  // A periodic service may be inside a real tick when SIGHUP arrives.
1202
1377
  // Queue the desired transition behind that exact promise: deadlines
@@ -1258,24 +1433,19 @@ export async function runDaemon() {
1258
1433
  const reloaded = scheduler.listScheduled();
1259
1434
  log('INFO', `Reloaded ${reloaded.length} jobs`);
1260
1435
  }
1261
- // Monitor engine can be stopped on reload if disabled; starting it requires
1262
- // a restart, so we only handle the off transition here.
1436
+ // Refresh monitor CONFIGS when the engine is live and monitors stays enabled
1437
+ // (the common `monitors add/edit` + SIGHUP case). The enable/disable
1438
+ // TRANSITION itself is handled by the generic supervisor.start/stop loop
1439
+ // above (PHNX-3608) — a disabled monitors service is supervisor.stop()'d
1440
+ // there, which tears down its supervised tick so nothing dispatches; an
1441
+ // off-transition leaves getEngine() null, so this reload is correctly skipped.
1263
1442
  const liveMonitorEngine = monitorEngineSvc.getEngine();
1264
- if (liveMonitorEngine) {
1265
- if (!reloadedEnabled('monitors')) {
1266
- log('WARN', 'monitors service is now disabled — stopping monitor engine; restart daemon to re-enable');
1267
- try {
1268
- liveMonitorEngine.stop();
1269
- }
1270
- catch { /* best-effort */ }
1443
+ if (liveMonitorEngine && reloadedEnabled('monitors')) {
1444
+ try {
1445
+ liveMonitorEngine.reload();
1271
1446
  }
1272
- else {
1273
- try {
1274
- liveMonitorEngine.reload();
1275
- }
1276
- catch (err) {
1277
- log('ERROR', `Monitor engine reload failed: ${err.message}`);
1278
- }
1447
+ catch (err) {
1448
+ log('ERROR', `Monitor engine reload failed: ${err.message}`);
1279
1449
  }
1280
1450
  }
1281
1451
  };
@@ -1301,8 +1471,9 @@ export async function runDaemon() {
1301
1471
  catch {
1302
1472
  // Already removed with the state dir, or replaced by a newer owner.
1303
1473
  }
1304
- removeDaemonPid();
1305
- removeHeartbeat();
1474
+ removeDaemonPidIfOwned(process.pid);
1475
+ if (readHeartbeat()?.pid === process.pid)
1476
+ removeHeartbeat();
1306
1477
  unregisterDaemonInstance();
1307
1478
  process.exit(0);
1308
1479
  });
@@ -1325,7 +1496,20 @@ export async function runDaemon() {
1325
1496
  else {
1326
1497
  log('INFO', 'State-dir self-check disabled');
1327
1498
  }
1328
- process.on('SIGHUP', handleReload);
1499
+ liveReloadHandler = guardSignalHandler(handleReload, (err) => {
1500
+ // Signal callbacks sit outside the supervisor's service barriers. A
1501
+ // reload failure must be observable without reaching the process-wide
1502
+ // uncaughtException handler and taking down every daemon service.
1503
+ try {
1504
+ log('ERROR', `SIGHUP reload failed: ${err.message}`);
1505
+ }
1506
+ catch { /* logging must not crash the daemon */ }
1507
+ });
1508
+ if (reloadRequestedDuringStartup) {
1509
+ reloadRequestedDuringStartup = false;
1510
+ log('INFO', 'Applying service reload requested while the daemon was starting');
1511
+ liveReloadHandler();
1512
+ }
1329
1513
  process.on('SIGTERM', () => handleShutdown());
1330
1514
  process.on('SIGINT', () => handleShutdown());
1331
1515
  await new Promise(() => { });
@@ -1903,7 +2087,8 @@ export function stopResidueArtifacts(stoppedPid, survivors = []) {
1903
2087
  artifacts.push({
1904
2088
  label: 'daemon lifetime marker',
1905
2089
  present: fs.existsSync(lifetimePath),
1906
- ownedByLiveOther: lifetimeOwner !== null && lifetimeOwner !== stoppedPid && isAlive(lifetimeOwner),
2090
+ ownedByLiveOther: lifetimeOwner !== null && (survivors.includes(lifetimeOwner)
2091
+ || (lifetimeOwner !== stoppedPid && isAlive(lifetimeOwner))),
1907
2092
  reclaim: () => { try {
1908
2093
  fs.unlinkSync(lifetimePath);
1909
2094
  }
@@ -1918,7 +2103,8 @@ export function stopResidueArtifacts(stoppedPid, survivors = []) {
1918
2103
  // A stale heartbeat is not cosmetic: resolveLiveDaemonPid() trusts a FRESH
1919
2104
  // one to re-adopt a daemon whose pid file was lost, so leaving one behind
1920
2105
  // can make a dead daemon read as running.
1921
- ownedByLiveOther: hb !== null && hb.pid !== stoppedPid && isAlive(hb.pid),
2106
+ ownedByLiveOther: hb !== null && (survivors.includes(hb.pid)
2107
+ || (hb.pid !== stoppedPid && isAlive(hb.pid))),
1922
2108
  reclaim: () => removeHeartbeat(),
1923
2109
  stillPresent: () => fs.existsSync(heartbeatPath),
1924
2110
  });
@@ -1964,31 +2150,35 @@ export function stopResidueArtifacts(stoppedPid, survivors = []) {
1964
2150
  * and the stop postcondition (`stopDaemon`) already use, so the display and the
1965
2151
  * reaper agree on what a duplicate is.
1966
2152
  */
1967
- export function findSurvivingStateDirDaemons(exclude) {
2153
+ function findStateDirDaemonProcesses(exclude) {
2154
+ const live = [];
2155
+ const unverified = [];
1968
2156
  if (process.platform === 'win32')
1969
- return [];
2157
+ return { live, unverified };
1970
2158
  const dir = getDaemonInstancesDir();
1971
2159
  let entries;
1972
2160
  try {
1973
2161
  entries = fs.readdirSync(dir);
1974
2162
  }
1975
2163
  catch {
1976
- return [];
2164
+ return { live, unverified };
1977
2165
  }
1978
- const found = [];
1979
2166
  for (const name of entries) {
1980
2167
  const pid = parseInt(name, 10);
1981
2168
  if (isNaN(pid) || String(pid) !== name)
1982
2169
  continue; // not a pid marker
1983
2170
  if (exclude.has(pid))
1984
2171
  continue;
1985
- if (!isAlive(pid))
1986
- continue; // dead marker — reaper self-heals it
1987
- if (!isDaemonRunProcess(pid))
1988
- continue; // pid reused by an unrelated process
1989
- found.push(pid);
2172
+ const identity = daemonProcessIdentity(pid);
2173
+ if (identity === 'daemon')
2174
+ live.push(pid);
2175
+ else if (identity === 'unknown')
2176
+ unverified.push(pid);
1990
2177
  }
1991
- return found;
2178
+ return { live, unverified };
2179
+ }
2180
+ export function findSurvivingStateDirDaemons(exclude) {
2181
+ return findStateDirDaemonProcesses(exclude).live;
1992
2182
  }
1993
2183
  /**
1994
2184
  * Stop the daemon and ASSERT its postcondition (SING-12, RUSH-2355), unloading it
@@ -2005,11 +2195,61 @@ export function findSurvivingStateDirDaemons(exclude) {
2005
2195
  * never reports success on an unverified stop.
2006
2196
  */
2007
2197
  export function stopDaemon() {
2198
+ const releaseLock = acquireStopLock();
2199
+ if (!releaseLock) {
2200
+ return {
2201
+ ok: false,
2202
+ stoppedPid: null,
2203
+ escalated: false,
2204
+ released: [],
2205
+ surviving: [`daemon lifecycle lock remained held for ${STOP_LOCK_WAIT_MS}ms`],
2206
+ detachedChildren: listLiveRoutineChildren(),
2207
+ };
2208
+ }
2209
+ try {
2210
+ return stopDaemonLocked();
2211
+ }
2212
+ finally {
2213
+ releaseLock();
2214
+ }
2215
+ }
2216
+ function readPathIdentity(filePath) {
2217
+ try {
2218
+ const stat = fs.lstatSync(filePath);
2219
+ return { dev: stat.dev, ino: stat.ino };
2220
+ }
2221
+ catch {
2222
+ return null;
2223
+ }
2224
+ }
2225
+ function pathIdentityMatches(filePath, expected) {
2226
+ const current = readPathIdentity(filePath);
2227
+ return current !== null && current.dev === expected.dev && current.ino === expected.ino;
2228
+ }
2229
+ /** daemon.lock is held for this entire read-signal-verify-cleanup transaction. */
2230
+ function stopDaemonLocked() {
2008
2231
  const platform = os.platform();
2009
2232
  const released = [];
2010
2233
  const surviving = [];
2011
2234
  let escalated = false;
2012
2235
  const reg = serviceManagerRegistrationAllowed();
2236
+ const unverifiedPid = unverifiedLiveDaemonPid();
2237
+ if (unverifiedPid !== null) {
2238
+ return {
2239
+ ok: false,
2240
+ stoppedPid: null,
2241
+ escalated: false,
2242
+ released,
2243
+ surviving: [`daemon pid ${unverifiedPid} is live but its __daemon-run identity could not be verified`],
2244
+ detachedChildren: listLiveRoutineChildren(),
2245
+ };
2246
+ }
2247
+ // Capture the target and its path-bound resources while lifecycle writers are
2248
+ // excluded. resolveLiveDaemonPid(true) rejects a reused/non-daemon pid before
2249
+ // any service-manager teardown or direct signal and repairs only under lock.
2250
+ const pid = resolveLiveDaemonPid(true);
2251
+ const browserSock = process.platform === 'win32' ? null : getBrowserIpcSocketPath();
2252
+ const browserSockOwner = pid !== null && browserSock ? readPathIdentity(browserSock) : null;
2013
2253
  if (platform === 'darwin') {
2014
2254
  const plistPath = getLaunchdPlistPath();
2015
2255
  if (fs.existsSync(plistPath)) {
@@ -2055,65 +2295,112 @@ export function stopDaemon() {
2055
2295
  catch { /* unit file already removed */ }
2056
2296
  }
2057
2297
  }
2058
- const pid = readDaemonPid();
2059
2298
  if (pid) {
2060
2299
  if (process.platform === 'win32') {
2061
2300
  // Windows has no graceful termination signal — terminate the daemon and
2062
2301
  // its job/browser child tree in one shot (taskkill /T), so stop doesn't
2063
2302
  // report success while children keep running.
2064
- killTree(pid);
2065
- escalated = true;
2066
- }
2067
- else {
2068
- try {
2069
- process.kill(pid, 'SIGTERM');
2070
- }
2071
- catch { /* process already exited */ }
2072
- // Wait for it to actually go. This used to be a setTimeout escalation plus
2073
- // an immediate removeDaemonPid(), which had two failure modes: in a
2074
- // short-lived process (the npm postinstall) the timer never fired at all,
2075
- // and clearing the pid file while the old daemon still ran made
2076
- // isDaemonRunning() report false, so startDaemon() launched a SECOND
2077
- // daemon. Its hosted broker then unlinked the live socket and rebound,
2078
- // orphaning the first broker with every unlocked bundle still in its RAM
2079
- // and unreachable — two brokers on one socket path, seen on a real machine
2080
- // after an install into a second prefix.
2081
- if (!waitForExit(pid, STOP_GRACE_MS)) {
2303
+ if (isLiveDaemon(pid)) {
2082
2304
  killTree(pid);
2083
2305
  escalated = true;
2084
2306
  waitForExit(pid, STOP_KILL_GRACE_MS);
2085
2307
  }
2086
2308
  }
2309
+ else {
2310
+ // Revalidate immediately before the signal: the pid may have exited and
2311
+ // been reused since the ownership snapshot above.
2312
+ if (isLiveDaemon(pid)) {
2313
+ try {
2314
+ process.kill(pid, 'SIGTERM');
2315
+ }
2316
+ catch { /* process already exited */ }
2317
+ // Wait for it to actually go. This used to be a setTimeout escalation plus
2318
+ // an immediate removeDaemonPid(), which had two failure modes: in a
2319
+ // short-lived process (the npm postinstall) the timer never fired at all,
2320
+ // and clearing the pid file while the old daemon still ran made
2321
+ // isDaemonRunning() report false, so startDaemon() launched a SECOND
2322
+ // daemon. Its hosted broker then unlinked the live socket and rebound,
2323
+ // orphaning the first broker with every unlocked bundle still in its RAM
2324
+ // and unreachable — two brokers on one socket path, seen on a real machine
2325
+ // after an install into a second prefix.
2326
+ if (!waitForExit(pid, STOP_GRACE_MS) && isLiveDaemon(pid)) {
2327
+ killTree(pid);
2328
+ escalated = true;
2329
+ waitForExit(pid, STOP_KILL_GRACE_MS);
2330
+ }
2331
+ }
2332
+ }
2087
2333
  }
2088
- removeDaemonPid();
2089
2334
  // ── Assert the postcondition (SING-12) ────────────────────────────────────
2090
2335
  // No `__daemon-run` for this state dir may survive the stop.
2091
- const survivors = findSurvivingStateDirDaemons(new Set([process.pid]));
2092
- if (pid && process.platform === 'win32' && isAlive(pid) && !survivors.includes(pid)) {
2093
- survivors.push(pid); // registry is POSIX-only; check the killed pid directly
2336
+ const stateDirProcesses = findStateDirDaemonProcesses(new Set([process.pid]));
2337
+ const survivors = stateDirProcesses.live;
2338
+ const unverifiedSurvivors = stateDirProcesses.unverified;
2339
+ // The registry is best-effort and may be missing, so always re-check the
2340
+ // direct target as well. This is the only check available on Windows.
2341
+ if (pid && !survivors.includes(pid) && !unverifiedSurvivors.includes(pid)) {
2342
+ const identity = daemonProcessIdentity(pid);
2343
+ if (identity === 'daemon')
2344
+ survivors.push(pid);
2345
+ else if (identity === 'unknown')
2346
+ unverifiedSurvivors.push(pid);
2094
2347
  }
2095
2348
  if (survivors.length > 0) {
2096
2349
  for (const s of survivors)
2097
2350
  surviving.push(`__daemon-run pid ${s} still alive`);
2098
2351
  }
2099
- else if (pid) {
2352
+ for (const s of unverifiedSurvivors) {
2353
+ surviving.push(`daemon pid ${s} is live but its __daemon-run identity could not be verified`);
2354
+ }
2355
+ const targetStillOwnsResources = pid !== null
2356
+ && (survivors.includes(pid) || unverifiedSurvivors.includes(pid));
2357
+ if (pid && !targetStillOwnsResources) {
2100
2358
  released.push('daemon process');
2101
2359
  }
2360
+ // Delete the registration only after the target is provably gone, and only
2361
+ // if the file still names that target. A replacement value belongs to a
2362
+ // successor (or another writer) and is both preserved and reported.
2363
+ const currentPid = readDaemonPid();
2364
+ if (pid !== null && !targetStillOwnsResources) {
2365
+ if (removeDaemonPidIfOwned(pid))
2366
+ released.push('daemon pid registration');
2367
+ else if (currentPid === null)
2368
+ released.push('daemon pid registration');
2369
+ else
2370
+ surviving.push(`daemon pid registration changed to ${currentPid} during stop`);
2371
+ }
2372
+ else if (pid === null && currentPid === null) {
2373
+ released.push('daemon pid registration');
2374
+ }
2375
+ else if (pid === null && currentPid !== null) {
2376
+ surviving.push(`daemon pid registration changed to ${currentPid} during stop`);
2377
+ }
2102
2378
  // Browser IPC binding: on POSIX the listening socket is a filesystem object.
2103
2379
  // A graceful handleShutdown unlinks it; if it survives, the daemon exited
2104
2380
  // ungracefully (killTree) and left a stale binding — the owner is provably
2105
2381
  // dead, so reclaim it and report.
2106
2382
  if (process.platform !== 'win32') {
2107
- const browserSock = getBrowserIpcSocketPath();
2108
- if (fs.existsSync(browserSock)) {
2109
- try {
2110
- fs.unlinkSync(browserSock);
2383
+ if (browserSock && fs.existsSync(browserSock)) {
2384
+ if (browserSockOwner && pathIdentityMatches(browserSock, browserSockOwner) && !targetStillOwnsResources) {
2385
+ try {
2386
+ fs.unlinkSync(browserSock);
2387
+ }
2388
+ catch { /* failed to reclaim the captured binding */ }
2389
+ if (pathIdentityMatches(browserSock, browserSockOwner))
2390
+ surviving.push('browser IPC socket not released');
2391
+ else if (fs.existsSync(browserSock))
2392
+ surviving.push('browser IPC socket ownership changed during stop');
2393
+ else
2394
+ released.push('browser IPC socket (reclaimed)');
2395
+ }
2396
+ else {
2397
+ // The path was absent when the target was captured, there was no proven
2398
+ // daemon target, or a successor replaced the inode. In every case this
2399
+ // invocation does not own the current binding and must leave it alone.
2400
+ surviving.push(targetStillOwnsResources
2401
+ ? 'browser IPC socket still owned by a surviving daemon'
2402
+ : 'browser IPC socket ownership could not be verified');
2111
2403
  }
2112
- catch { /* raced with a fresh start */ }
2113
- if (fs.existsSync(browserSock))
2114
- surviving.push('browser IPC socket not released');
2115
- else
2116
- released.push('browser IPC socket (reclaimed)');
2117
2404
  }
2118
2405
  else {
2119
2406
  released.push('browser IPC socket');
@@ -2149,7 +2436,7 @@ export function stopDaemon() {
2149
2436
  // consults to re-adopt a "live" daemon, and a leftover registry entry is what
2150
2437
  // `reapStrayDaemons` enumerates. Same shape as the sockets above — reclaim
2151
2438
  // what a provably dead owner left, never touch what a live one owns.
2152
- for (const artifact of stopResidueArtifacts(pid, survivors)) {
2439
+ for (const artifact of stopResidueArtifacts(pid, [...survivors, ...unverifiedSurvivors])) {
2153
2440
  if (!artifact.present) {
2154
2441
  released.push(artifact.label);
2155
2442
  continue;