@phnx-labs/agents-cli 1.22.47 → 1.22.49

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 (249) hide show
  1. package/CHANGELOG.md +401 -17
  2. package/README.md +66 -32
  3. package/dist/bootstrap.js +4 -2
  4. package/dist/cli/command-registry.d.ts +1 -0
  5. package/dist/cli/command-registry.js +2 -0
  6. package/dist/commands/accounts.d.ts +58 -0
  7. package/dist/commands/accounts.js +206 -32
  8. package/dist/commands/artifacts.js +7 -6
  9. package/dist/commands/attach.js +1 -1
  10. package/dist/commands/browser.js +501 -103
  11. package/dist/commands/config.d.ts +0 -1
  12. package/dist/commands/config.js +0 -20
  13. package/dist/commands/daemon.d.ts +21 -0
  14. package/dist/commands/daemon.js +147 -12
  15. package/dist/commands/exec.js +52 -15
  16. package/dist/commands/focus.d.ts +14 -0
  17. package/dist/commands/focus.js +48 -5
  18. package/dist/commands/go.d.ts +3 -0
  19. package/dist/commands/go.js +16 -3
  20. package/dist/commands/inspect.js +2 -2
  21. package/dist/commands/lease.d.ts +1 -1
  22. package/dist/commands/lease.js +4 -3
  23. package/dist/commands/mailboxes.js +4 -3
  24. package/dist/commands/menubar.js +11 -4
  25. package/dist/commands/projects.d.ts +16 -1
  26. package/dist/commands/projects.js +58 -64
  27. package/dist/commands/resume.js +1 -1
  28. package/dist/commands/route.js +4 -4
  29. package/dist/commands/routines.js +3 -2
  30. package/dist/commands/run-broadcast.d.ts +1 -1
  31. package/dist/commands/run-broadcast.js +1 -1
  32. package/dist/commands/sessions-picker.d.ts +22 -0
  33. package/dist/commands/sessions-picker.js +48 -9
  34. package/dist/commands/sessions-trace.js +1 -1
  35. package/dist/commands/sessions.js +1 -0
  36. package/dist/commands/setup-secrets.js +23 -0
  37. package/dist/commands/share.d.ts +20 -5
  38. package/dist/commands/share.js +152 -52
  39. package/dist/commands/ssh.js +54 -7
  40. package/dist/commands/traces.d.ts +2 -0
  41. package/dist/commands/traces.js +184 -0
  42. package/dist/commands/view.d.ts +1 -1
  43. package/dist/commands/view.js +17 -34
  44. package/dist/index.d.ts +1 -0
  45. package/dist/index.js +5 -0
  46. package/dist/lib/accounting/usage.d.ts +14 -3
  47. package/dist/lib/accounting/usage.js +64 -18
  48. package/dist/lib/agent-spec/agents.js +11 -9
  49. package/dist/lib/browser/drivers/ssh.d.ts +16 -2
  50. package/dist/lib/browser/drivers/ssh.js +36 -9
  51. package/dist/lib/browser/ipc.js +8 -3
  52. package/dist/lib/browser/profiles.d.ts +35 -101
  53. package/dist/lib/browser/profiles.js +130 -301
  54. package/dist/lib/browser/registry.d.ts +39 -0
  55. package/dist/lib/browser/registry.js +149 -0
  56. package/dist/lib/browser/remote-control.d.ts +8 -6
  57. package/dist/lib/browser/remote-control.js +8 -6
  58. package/dist/lib/browser/resolve-target.d.ts +68 -0
  59. package/dist/lib/browser/resolve-target.js +250 -0
  60. package/dist/lib/browser/runtime-state.d.ts +40 -16
  61. package/dist/lib/browser/runtime-state.js +92 -25
  62. package/dist/lib/browser/service.d.ts +28 -9
  63. package/dist/lib/browser/service.js +139 -62
  64. package/dist/lib/browser/task-index.d.ts +70 -0
  65. package/dist/lib/browser/task-index.js +180 -0
  66. package/dist/lib/byok-usage.js +2 -2
  67. package/dist/lib/claude-statusline.d.ts +29 -0
  68. package/dist/lib/claude-statusline.js +139 -0
  69. package/dist/lib/cloud/host.d.ts +1 -1
  70. package/dist/lib/cloud/host.js +1 -1
  71. package/dist/lib/cloud/rush.js +1 -1
  72. package/dist/lib/computer/computer-rpc.js +2 -2
  73. package/dist/lib/computer/download.d.ts +15 -15
  74. package/dist/lib/computer/download.js +35 -98
  75. package/dist/lib/computer/ssh-tunnel.d.ts +0 -3
  76. package/dist/lib/computer/ssh-tunnel.js +12 -22
  77. package/dist/lib/config-keys.d.ts +2 -8
  78. package/dist/lib/config-keys.js +2 -13
  79. package/dist/lib/crabbox/cli.d.ts +1 -1
  80. package/dist/lib/crabbox/cli.js +2 -2
  81. package/dist/lib/crabbox/lease.d.ts +9 -0
  82. package/dist/lib/crabbox/lease.js +70 -15
  83. package/dist/lib/crabbox/runtimes.js +1 -1
  84. package/dist/lib/daemon/account-state-daemon-service.d.ts +16 -0
  85. package/dist/lib/daemon/account-state-daemon-service.js +26 -0
  86. package/dist/lib/daemon/browser-ipc-service.d.ts +20 -0
  87. package/dist/lib/daemon/browser-ipc-service.js +46 -0
  88. package/dist/lib/daemon/daemon.d.ts +3 -0
  89. package/dist/lib/daemon/daemon.js +212 -444
  90. package/dist/lib/daemon/device-probe-service.d.ts +26 -0
  91. package/dist/lib/daemon/device-probe-service.js +47 -0
  92. package/dist/lib/daemon/keychain-reap-service.d.ts +17 -0
  93. package/dist/lib/daemon/keychain-reap-service.js +32 -0
  94. package/dist/lib/daemon/monitor-engine-service.d.ts +22 -0
  95. package/dist/lib/daemon/monitor-engine-service.js +29 -0
  96. package/dist/lib/daemon/secrets-broker-service.d.ts +20 -0
  97. package/dist/lib/daemon/secrets-broker-service.js +66 -0
  98. package/dist/lib/daemon/self-heal-service.d.ts +27 -0
  99. package/dist/lib/daemon/self-heal-service.js +48 -0
  100. package/dist/lib/daemon/service.d.ts +102 -0
  101. package/dist/lib/daemon/service.js +98 -0
  102. package/dist/lib/daemon/session-index-service.d.ts +22 -0
  103. package/dist/lib/daemon/session-index-service.js +40 -0
  104. package/dist/lib/daemon/state-dir-check-service.d.ts +43 -0
  105. package/dist/lib/daemon/state-dir-check-service.js +63 -0
  106. package/dist/lib/daemon/supervisor.d.ts +87 -0
  107. package/dist/lib/daemon/supervisor.js +298 -0
  108. package/dist/lib/daemon/watchdog-service.d.ts +19 -0
  109. package/dist/lib/daemon/watchdog-service.js +40 -0
  110. package/dist/lib/daemon-health.d.ts +15 -0
  111. package/dist/lib/daemon-health.js +29 -5
  112. package/dist/lib/daemon-services.d.ts +8 -1
  113. package/dist/lib/daemon-services.js +55 -0
  114. package/dist/lib/daemon-ticks.d.ts +13 -2
  115. package/dist/lib/daemon-ticks.js +20 -18
  116. package/dist/lib/device-config.d.ts +0 -2
  117. package/dist/lib/device-config.js +1 -23
  118. package/dist/lib/devices/registry.d.ts +1 -1
  119. package/dist/lib/devices/registry.js +1 -1
  120. package/dist/lib/devices/stats-cache.d.ts +20 -0
  121. package/dist/lib/devices/stats-cache.js +49 -5
  122. package/dist/lib/devices/worker-pick.d.ts +48 -0
  123. package/dist/lib/devices/worker-pick.js +113 -0
  124. package/dist/lib/exec.d.ts +67 -13
  125. package/dist/lib/exec.js +85 -29
  126. package/dist/lib/harness/adapters/cursor.js +8 -11
  127. package/dist/lib/helper-download.d.ts +108 -0
  128. package/dist/lib/helper-download.js +194 -0
  129. package/dist/lib/helper-versions.d.ts +52 -0
  130. package/dist/lib/helper-versions.js +58 -0
  131. package/dist/lib/hooks/cache.js +11 -0
  132. package/dist/lib/hooks/install.js +2 -2
  133. package/dist/lib/hosts/credentials.d.ts +1 -1
  134. package/dist/lib/hosts/credentials.js +1 -1
  135. package/dist/lib/hosts/dispatch.d.ts +7 -1
  136. package/dist/lib/hosts/dispatch.js +24 -5
  137. package/dist/lib/hosts/passthrough.d.ts +8 -15
  138. package/dist/lib/hosts/passthrough.js +19 -16
  139. package/dist/lib/hosts/providers/devices.d.ts +1 -1
  140. package/dist/lib/hosts/providers/devices.js +1 -1
  141. package/dist/lib/hosts/reconnect.d.ts +179 -21
  142. package/dist/lib/hosts/reconnect.js +266 -45
  143. package/dist/lib/installations/migrate.d.ts +4 -5
  144. package/dist/lib/installations/migrate.js +6 -7
  145. package/dist/lib/installations/shims.d.ts +1 -1
  146. package/dist/lib/installations/shims.js +23 -13
  147. package/dist/lib/installations/versions.js +25 -2
  148. package/dist/lib/linear-autoclose.d.ts +1 -1
  149. package/dist/lib/linear-autoclose.js +1 -1
  150. package/dist/lib/loop.d.ts +2 -2
  151. package/dist/lib/loop.js +1 -1
  152. package/dist/lib/menubar/download-menubar.d.ts +47 -0
  153. package/dist/lib/menubar/download-menubar.js +60 -0
  154. package/dist/lib/menubar/install-menubar.d.ts +31 -7
  155. package/dist/lib/menubar/install-menubar.js +79 -21
  156. package/dist/lib/menubar/notify-desktop.d.ts +3 -3
  157. package/dist/lib/menubar/notify-desktop.js +6 -6
  158. package/dist/lib/model-tiers.d.ts +1 -1
  159. package/dist/lib/project-probe.d.ts +1 -1
  160. package/dist/lib/project-probe.js +1 -1
  161. package/dist/lib/project-pull.d.ts +1 -1
  162. package/dist/lib/project-pull.js +2 -2
  163. package/dist/lib/projects.d.ts +1 -1
  164. package/dist/lib/projects.js +1 -1
  165. package/dist/lib/sandbox.js +6 -7
  166. package/dist/lib/secrets/download-keychain.d.ts +47 -0
  167. package/dist/lib/secrets/download-keychain.js +70 -0
  168. package/dist/lib/secrets/install-helper.d.ts +20 -1
  169. package/dist/lib/secrets/install-helper.js +44 -3
  170. package/dist/lib/session/active.js +17 -1
  171. package/dist/lib/session/db.d.ts +11 -1
  172. package/dist/lib/session/db.js +138 -6
  173. package/dist/lib/session/discover.d.ts +21 -1
  174. package/dist/lib/session/discover.js +33 -5
  175. package/dist/lib/session/highlights.d.ts +49 -1
  176. package/dist/lib/session/highlights.js +95 -0
  177. package/dist/lib/session/host-link.d.ts +16 -2
  178. package/dist/lib/session/host-link.js +7 -1
  179. package/dist/lib/session/parse.js +33 -1
  180. package/dist/lib/session/remote/remote-list.d.ts +9 -0
  181. package/dist/lib/session/remote/remote-list.js +19 -2
  182. package/dist/lib/session/remote/watch.js +11 -2
  183. package/dist/lib/session/session-cache.d.ts +53 -0
  184. package/dist/lib/session/session-cache.js +88 -0
  185. package/dist/lib/session/state.js +1 -1
  186. package/dist/lib/session/tool-calls.d.ts +2 -0
  187. package/dist/lib/session/tool-calls.js +1 -1
  188. package/dist/lib/session/trajectory-html.d.ts +5 -1
  189. package/dist/lib/session/trajectory-html.js +42 -22
  190. package/dist/lib/session/trajectory-text.js +9 -2
  191. package/dist/lib/session/trajectory.js +53 -4
  192. package/dist/lib/session/types.d.ts +15 -0
  193. package/dist/lib/sha256-asset.d.ts +26 -0
  194. package/dist/lib/sha256-asset.js +41 -0
  195. package/dist/lib/share/backend.d.ts +95 -0
  196. package/dist/lib/share/backend.js +139 -0
  197. package/dist/lib/share/delete.d.ts +6 -2
  198. package/dist/lib/share/delete.js +8 -9
  199. package/dist/lib/share/html.d.ts +22 -0
  200. package/dist/lib/share/html.js +88 -0
  201. package/dist/lib/share/provision.d.ts +18 -4
  202. package/dist/lib/share/provision.js +38 -4
  203. package/dist/lib/share/publish.d.ts +39 -15
  204. package/dist/lib/share/publish.js +55 -26
  205. package/dist/lib/share/worker-template.js +171 -11
  206. package/dist/lib/sqlite.js +16 -3
  207. package/dist/lib/ssh-exec.d.ts +65 -0
  208. package/dist/lib/ssh-exec.js +120 -4
  209. package/dist/lib/startup/command-registry.js +1 -1
  210. package/dist/lib/startup/dev-build.js +3 -3
  211. package/dist/lib/state.d.ts +1 -1
  212. package/dist/lib/state.js +7 -3
  213. package/dist/lib/subagents-registry.js +1 -1
  214. package/dist/lib/terminal/index.d.ts +1 -1
  215. package/dist/lib/terminal/types.d.ts +1 -1
  216. package/dist/lib/terminal/types.js +1 -1
  217. package/dist/lib/tmux/orphan-reap.js +1 -1
  218. package/dist/lib/tmux/session.js +31 -0
  219. package/dist/lib/traces/backend.d.ts +29 -0
  220. package/dist/lib/traces/backend.js +46 -0
  221. package/dist/lib/traces/classify.d.ts +52 -0
  222. package/dist/lib/traces/classify.js +72 -0
  223. package/dist/lib/traces/config.d.ts +10 -0
  224. package/dist/lib/traces/config.js +2 -0
  225. package/dist/lib/traces/provision.d.ts +19 -0
  226. package/dist/lib/traces/provision.js +30 -0
  227. package/dist/lib/traces/sync.d.ts +150 -0
  228. package/dist/lib/traces/sync.js +447 -0
  229. package/dist/lib/traces/worker-template.d.ts +4 -0
  230. package/dist/lib/traces/worker-template.js +159 -0
  231. package/dist/lib/types.d.ts +18 -19
  232. package/dist/lib/types.js +14 -0
  233. package/dist/lib/usage-refresh.d.ts +1 -1
  234. package/dist/lib/usage-refresh.js +11 -8
  235. package/dist/lib/workflows.d.ts +1 -1
  236. package/package.json +4 -6
  237. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  238. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +0 -24
  239. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  240. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  241. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +0 -128
  242. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  243. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +0 -26
  244. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  245. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  246. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +0 -135
  247. package/dist/lib/secrets/Agents CLI.app/Contents/embedded.provisionprofile +0 -0
  248. package/dist/lib/usage-fleet.d.ts +0 -32
  249. package/dist/lib/usage-fleet.js +0 -125
@@ -16,25 +16,51 @@ import { isAlive, killTree, backgroundSpawnOptions, waitForExit } from '../platf
16
16
  import { listJobs as listAllJobs } from '../scheduling/routines.js';
17
17
  import { syncAllProjectRoutines } from '../routines-project.js';
18
18
  import { JobScheduler } from '../scheduler.js';
19
- import { MonitorEngine } from '../monitors/engine.js';
19
+ // MonitorEngine is now owned by MonitorEngineService (daemon/monitor-engine-service.ts).
20
20
  import { executeJobDetached, monitorRunningJobs, listLiveRoutineChildren } from './runner.js';
21
21
  import { detectOverdueJobs, notifyOverdue } from '../overdue.js';
22
22
  import { runCatchup } from '../catchup.js';
23
23
  import { notifyRoutineStart, notifyRoutineFinish, notifyRoutineStartFailed } from '../routine-notify.js';
24
24
  import { notifyOwnerRoutineFinish, notifyOwnerRoutineStartFailed } from '../routine-notify-owner.js';
25
25
  import { BrowserService } from '../browser/service.js';
26
- import { BrowserIPCServer, getSocketPath as getBrowserIpcSocketPath } from '../browser/ipc.js';
26
+ import { getSocketPath as getBrowserIpcSocketPath } from '../browser/ipc.js';
27
27
  import { startHostedWebhookReceivers } from '../daemon-webhooks.js';
28
28
  import { secretsBrokerSocketPath, brokerPidAlive } from '../secrets/agent.js';
29
29
  import { redactSecrets } from '../redact.js';
30
30
  import { getAgentsBinPath, getCliLaunch, BUN_VIRTUAL_ROOT } from '../cli-entry.js';
31
- import { isSchedulerEnabled, assertSchedulerEnabled, isDaemonEnabled, getConfigValue, resolveBrowserTaskIdleMs } from '../device-config.js';
31
+ import { isSchedulerEnabled, assertSchedulerEnabled, isDaemonEnabled, resolveBrowserTaskIdleMs } from '../device-config.js';
32
32
  import { reapTerminalRoutineProcesses } from '../routine-process-cleanup.js';
33
- import { recordSubsystemOk, recordSubsystemError, recordSubsystemErrorReason, readSubsystemHealth, SUBSYSTEM_SECRETS_BROKER, SUBSYSTEM_BROWSER_IPC, SUBSYSTEM_DAEMON_START } from '../daemon-health.js';
34
- import { startAccountStateService } from '../account-state-service.js';
35
- import { runActiveSessionsWarmTick, runFleetCacheWarmTick, runSessionIndexWarmTick, runUsageRefreshTick } from '../daemon-ticks.js';
33
+ import { recordSubsystemOk, recordSubsystemError, recordSubsystemErrorReason, readSubsystemHealth, SUBSYSTEM_DAEMON_START } from '../daemon-health.js';
34
+ import { runActiveSessionsWarmTick } from '../daemon-ticks.js';
35
+ import { watchActiveSessionsReaderPresence } from '../session/session-cache.js';
36
+ import { ServiceSupervisor } from './supervisor.js';
37
+ import { SessionIndexService } from './session-index-service.js';
38
+ import { SecretsBrokerService } from './secrets-broker-service.js';
39
+ import { MonitorEngineService } from './monitor-engine-service.js';
40
+ import { AccountStateDaemonService } from './account-state-daemon-service.js';
41
+ import { BrowserIPCService } from './browser-ipc-service.js';
42
+ import { WatchdogService } from './watchdog-service.js';
43
+ import { DeviceProbeService } from './device-probe-service.js';
44
+ import { SelfHealService } from './self-heal-service.js';
45
+ import { KeychainReapService } from './keychain-reap-service.js';
46
+ import { StateDirCheckService } from './state-dir-check-service.js';
36
47
  import { emit, emitRoutineEnd } from '../feed/events.js';
37
- import { readDaemonServicesConfig } from '../daemon-services.js';
48
+ import { readDaemonServicesConfig, drainDaemonServiceRestartQueue } from '../daemon-services.js';
49
+ /**
50
+ * The live `ServiceSupervisor` for the current `runDaemon()` invocation, or
51
+ * `null` before boot / after shutdown. In-process only — a separate `agents
52
+ * daemon status` process cannot see this; per-service health that must
53
+ * survive across processes goes through `daemon-health.ts` instead (which
54
+ * `ServiceSupervisor` already writes on every tick). This getter exists so a
55
+ * FUTURE same-process consumer (e.g. a `daemon services` live-status IPC
56
+ * handler) can read the supervisor's richer state (`parked`, not just
57
+ * ok/error) without needing its own reference to `runDaemon()`'s locals.
58
+ */
59
+ let activeServiceSupervisor = null;
60
+ /** Health for every service currently registered on the live supervisor, or `null` if the daemon isn't running in this process. */
61
+ export function getServiceSupervisorHealth() {
62
+ return activeServiceSupervisor?.health() ?? null;
63
+ }
38
64
  const PID_FILE = 'daemon.pid';
39
65
  const LIFETIME_FILE = 'daemon.lifetime';
40
66
  const LOCK_FILE = 'daemon.lock';
@@ -96,42 +122,28 @@ const CATCHUP_TICK_MS = 5 * 60_000;
96
122
  /**
97
123
  * Cadences for the in-process background ticks, named here beside the other
98
124
  * tick constants rather than left as inline literals at their `setInterval`
99
- * (RUSH-2423). Each is a deliberate trade, not a round number:
125
+ * (RUSH-2423). Self-heal, keychain-reap, state-dir-check, watchdog, and
126
+ * device-probe cadences moved to their own `*-service.ts` files (RUSH-3193
127
+ * P3, alongside session-index/account-state/etc.) — see those files for the
128
+ * per-service trade-offs (self-heal's 6h/cheap-to-be-late repair cadence,
129
+ * keychain-reap's 5min `ps`-shell cost bound, state-dir-check's env override
130
+ * for tests, watchdog/device-probe's shared 3min in-process housekeeping
131
+ * cadence, NOT a routine — RUSH-2495).
100
132
  *
101
- * - **Self-heal** repairs slow rot (a stale non-default version, an invalid
102
- * plugin manifest), so it is cheap to be late and expensive to run often —
103
- * hence 6h, plus one staggered kickoff shortly after start so shims and PATH
104
- * settle without making launch itself busy.
105
133
  * - **Broker self-heal** is a bare `agentPing`, and the failure it recovers
106
134
  * from wedges every keychain-backed secret on the box, so it runs minutely.
107
- * - **Keychain reap** shells `ps` once per pass; 5 min bounds that cost while
108
- * still clearing orphans well inside a human's attention span.
109
- * - **State-dir self-check** is two `fs` reads. Its env override exists for
110
- * tests, which cannot wait a minute to observe the self-terminate guard.
111
135
  */
112
- const SELF_HEAL_TICK_MS = 6 * 60 * 60_000;
113
- const SELF_HEAL_KICKOFF_MS = 30_000;
114
- const BROKER_SELF_HEAL_TICK_MS = 60_000;
115
- const KEYCHAIN_REAP_TICK_MS = 5 * 60_000;
136
+ // BROKER_SELF_HEAL_TICK_MS moved to secrets-broker-service.ts (RUSH-3193 P2).
116
137
  // RUSH-2501: reap tmux sessions whose panes are all dead every 5 minutes.
117
138
  const DEAD_PANE_REAP_TICK_MS = 5 * 60_000;
118
139
  // RUSH-2622: close abandoned browser-task tabs every 5 minutes.
119
140
  const BROWSER_TASK_REAP_TICK_MS = 5 * 60_000;
120
- const STATE_DIR_CHECK_TICK_MS = 60_000;
121
- // Watchdog nudges this host's own stalled sessions; device-probe refreshes
122
- // registered devices' reachability and surfaces newly appeared tailnet nodes.
123
- // Both are daemon-owned housekeeping timers, NOT routines (RUSH-2495) — plain
124
- // in-process intervals the daemon holds directly, same cadence the old timers ran.
125
- const WATCHDOG_TICK_MS = 3 * 60_000;
126
- const DEVICE_PROBE_TICK_MS = 3 * 60_000;
127
141
  // Keep the active-session cache/journal fresh for sessions watch + Factory.
128
142
  // Matches DEFAULT_ACTIVE_CACHE_MAX_AGE_MS (15s) so long-lived watchers never
129
143
  // outlive the publisher (RUSH-2484 — CLI-owned continuous publish).
130
144
  const ACTIVE_SESSIONS_WARM_TICK_MS = 15_000;
131
- // Incrementally scan this host's transcript dirs into the local index so a
132
- // locally-started session is indexed within seconds, not on the next unrelated
133
- // `agents sessions*` call (RUSH-2682 — indexing was lazy, nothing scheduled it).
134
- const SESSION_INDEX_WARM_TICK_MS = 20_000;
145
+ // Session-index warm interval/deadline live in session-index-service.ts now
146
+ // (RUSH-3193 migrated onto ServiceSupervisor).
135
147
  const WEDGE_THRESHOLD_TICKS = 3;
136
148
  /**
137
149
  * Crash-loop prevention (RUSH-2418). Three layers, because none of them alone
@@ -704,75 +716,17 @@ export function warnEphemeralDaemonRoot(resolveBin = getAgentsBinPath) {
704
716
  // named in stack traces, readable without scrolling through runDaemon's
705
717
  // 500-line body, and not recreated on every function invocation.
706
718
  //
707
- // Two functions (runBrokerSelfHeal, runStateDirSelfCheck) remain inline:
708
- // runBrokerSelfHeal closes over `hostedBroker`a mutable runDaemon-
709
- // local var also used by handleShutdown; lifting it requires both vars to
710
- // be module-level, which is wider scope than this ticket.
711
- // runStateDirSelfCheck closes over `lifetimePath` and `lifetimeToken`
712
- // per-boot constants that cannot be pre-computed at module load time.
719
+ // self-heal and keychain-reap moved to SelfHealService / KeychainReapService
720
+ // on the ServiceSupervisor (RUSH-3193 P3) the supervisor's own per-tick
721
+ // deadline + inFlight guard replaces their local `healing`/`reapingKeychain`
722
+ // flags. state-dir-check moved to StateDirCheckService (RUSH-3193 P3),
723
+ // registered after `handleShutdown` is declared see its registration site
724
+ // below. runBrokerSelfHeal was moved into SecretsBrokerService (RUSH-3193 P2).
713
725
  // ---------------------------------------------------------------------------
714
726
  // In-flight guards: each flag prevents a slow tick from being re-entered
715
727
  // by the next timer fire before the previous one finishes.
716
- let healing = false;
717
- let reapingKeychain = false;
718
728
  let reapingDeadPanes = false;
719
729
  let reapingBrowserTasks = false;
720
- /**
721
- * Resource self-heal: fill missing resources, repair invalid manifests, and
722
- * fast-forward pristine stale plugins. Conservative 'safe' mode: never
723
- * overwrites hand-edited content. Runs ~every 6h plus once ~30s after startup.
724
- *
725
- * Does not run when the daemon's state directory no longer exists — that is the
726
- * self-terminate guard's signal to shut down; background maintenance must not
727
- * recreate the tree while it is mid-exit.
728
- */
729
- async function runHealCheck() {
730
- if (healing)
731
- return;
732
- if (!fs.existsSync(getDaemonDir()))
733
- return;
734
- healing = true;
735
- try {
736
- const { runSelfHeal, selfHealChangedAnything, selfHealNeedsAttention, summarizeSelfHeal } = await import('../self-heal/registry.js');
737
- if (!fs.existsSync(getDaemonDir()))
738
- return;
739
- const report = await runSelfHeal({ mode: 'safe' });
740
- if (selfHealChangedAnything(report) || selfHealNeedsAttention(report)) {
741
- log('INFO', `self-heal: ${summarizeSelfHeal(report)}`);
742
- }
743
- }
744
- catch (err) {
745
- log('ERROR', `self-heal check failed: ${err.message}`);
746
- }
747
- finally {
748
- healing = false;
749
- }
750
- }
751
- /**
752
- * Keychain orphan reaper: kill stuck keychain helper and `agents` processes
753
- * whose keychain call never returned. Runs every 5 min. Single-executor: only
754
- * the daemon runs this, so no cross-device race.
755
- */
756
- async function runKeychainReap() {
757
- if (reapingKeychain)
758
- return;
759
- reapingKeychain = true;
760
- try {
761
- const { reapOrphanedKeychainProcesses } = await import('../secrets/reaper.js');
762
- const result = reapOrphanedKeychainProcesses();
763
- if (result.reaped > 0) {
764
- log('WARN', `Reaped ${result.reaped} keychain orphan/stuck process(es)`);
765
- for (const d of result.details)
766
- log('WARN', ` ${d}`);
767
- }
768
- }
769
- catch (err) {
770
- log('ERROR', `Keychain reaper failed: ${err.message}`);
771
- }
772
- finally {
773
- reapingKeychain = false;
774
- }
775
- }
776
730
  /**
777
731
  * RUSH-2501: kill tmux sessions on the helper socket whose panes are ALL dead.
778
732
  * RUSH-2521: and terminate the helper processes (MCP servers, harness background
@@ -813,7 +767,7 @@ async function runDeadPaneReap() {
813
767
  /**
814
768
  * RUSH-2622: close abandoned browser-task tabs — a task whose owning agent
815
769
  * session has exited, or one idle past `browser.task-idle-minutes` (default 30,
816
- * 0 disables idle reaping only). Same policy `agents browser gc` triggers on
770
+ * 0 disables idle reaping only). Same policy `agents browser prune` triggers on
817
771
  * demand; this is the periodic side, and the daemon is the single executor so
818
772
  * no UI surface can race it. `service` is the daemon's own long-lived
819
773
  * BrowserService — the browser IPC server it started earlier in `runDaemon()`.
@@ -929,36 +883,59 @@ export async function runDaemon() {
929
883
  catch (err) {
930
884
  log('ERROR', `Stray daemon reaper failed: ${err.message}`);
931
885
  }
932
- // #416: host the secrets broker socket-first before the scheduler and the
933
- // heavy browser services so `agents secrets` resolves within
934
- // ms of daemon start. Only host when no broker is already reachable, so we
935
- // never orphan a live standalone broker's clients (that broker stays the
936
- // server until it idle-exits or the daemon restarts). Best-effort: a failure
937
- // here must not stop the daemon. Retiring the standalone launchd service is
938
- // the follow-on (#416 step 2 / #417).
939
- let hostedBroker = null;
940
- if (isEnabled('secrets-broker')) {
941
- try {
942
- const { agentPing, startHostedBroker } = await import('../secrets/agent.js');
943
- if ((await agentPing()).reachable) {
944
- log('INFO', 'Secrets broker already running (standalone); daemon not hosting it');
945
- }
946
- else {
947
- hostedBroker = await startHostedBroker();
948
- if (hostedBroker)
949
- log('INFO', 'Secrets broker hosted in daemon (socket-first)');
950
- }
951
- recordSubsystemOk(SUBSYSTEM_SECRETS_BROKER);
952
- }
953
- catch (err) {
954
- const message = err.message;
955
- log('WARN', `Secrets broker host skipped: ${message}`);
956
- recordSubsystemError(SUBSYSTEM_SECRETS_BROKER, message);
957
- }
958
- }
959
- else {
886
+ // Socket services: secrets broker, monitor engine, account-state, and
887
+ // browser IPC are all managed by the ServiceSupervisor (RUSH-3193 P2).
888
+ // The supervisor is created here before the scheduler so the secrets
889
+ // broker starts socket-first (#416) and `agents secrets` resolves within
890
+ // ms of daemon start, exactly as before.
891
+ const supervisor = new ServiceSupervisor();
892
+ if (isEnabled('secrets-broker'))
893
+ supervisor.register(new SecretsBrokerService());
894
+ else
960
895
  log('INFO', 'Secrets broker service disabled; daemon not hosting it');
961
- }
896
+ const monitorEngineSvc = new MonitorEngineService();
897
+ if (isEnabled('monitors'))
898
+ supervisor.register(monitorEngineSvc);
899
+ else
900
+ log('INFO', 'Monitor engine disabled');
901
+ if (isEnabled('account-state'))
902
+ supervisor.register(new AccountStateDaemonService());
903
+ else
904
+ log('INFO', 'Account-state service disabled');
905
+ // BrowserIPCService owns the orphan reap (previously daemon.ts:1303-1318)
906
+ // and the BrowserIPCServer lifecycle. BrowserService is also constructed here
907
+ // so daemon.ts can access it for the browser-task-reap interval (task #3).
908
+ const browserSvcForReap = isEnabled('browser-ipc') ? new BrowserService() : null;
909
+ if (browserSvcForReap)
910
+ supervisor.register(new BrowserIPCService(browserSvcForReap));
911
+ else
912
+ log('INFO', 'Browser IPC service disabled');
913
+ if (isEnabled('session-index'))
914
+ supervisor.register(new SessionIndexService());
915
+ else
916
+ log('INFO', 'Session-index warm service disabled');
917
+ // Watchdog, device-probe, self-heal, and keychain-reap are all periodic
918
+ // services managed by the ServiceSupervisor (RUSH-3193 P3). Each is gated
919
+ // the same way as the socket services above; state-dir-check is registered
920
+ // separately, later, after `handleShutdown` exists (see below).
921
+ if (isEnabled('watchdog'))
922
+ supervisor.register(new WatchdogService());
923
+ else
924
+ log('INFO', 'Watchdog service disabled');
925
+ if (isEnabled('device-probe'))
926
+ supervisor.register(new DeviceProbeService());
927
+ else
928
+ log('INFO', 'Device-probe service disabled');
929
+ if (isEnabled('self-heal'))
930
+ supervisor.register(new SelfHealService());
931
+ else
932
+ log('INFO', 'Self-heal service disabled');
933
+ if (isEnabled('keychain-reap'))
934
+ supervisor.register(new KeychainReapService());
935
+ else
936
+ log('INFO', 'Keychain-reap service disabled');
937
+ await supervisor.startAll({ log });
938
+ activeServiceSupervisor = supervisor;
962
939
  // scheduler.enabled=false in this machine's device doc means NO routines fire
963
940
  // here — the scheduler and its catchup recovery simply never start, while the
964
941
  // daemon keeps its other duties (secrets broker, browser IPC, session sync).
@@ -1093,21 +1070,12 @@ export async function runDaemon() {
1093
1070
  }
1094
1071
  if (schedulerEnabledAtBoot)
1095
1072
  bootScheduler();
1096
- // Usage and authentication are first-party device state, so the daemon owns
1097
- // their timers directly rather than scheduling them as built-in routines.
1098
- // Explicit CLI refreshes converge on the same cross-process refresh leases.
1099
- const accountStateService = isEnabled('account-state')
1100
- ? startAccountStateService({
1101
- refreshUsage: runUsageRefreshTick,
1102
- refreshAuth: runFleetCacheWarmTick,
1103
- onError: (area, error) => log('WARN', `${area} state refresh failed: ${error.message}`),
1104
- })
1105
- : null;
1106
- if (!accountStateService)
1107
- log('INFO', 'Account-state service disabled');
1108
1073
  // Active-sessions publish-own: continuous journal writer for `sessions watch`.
1109
1074
  // Fire once immediately so a cold daemon does not leave watchers on
1110
- // "awaiting publisher" for a full tick (RUSH-2484).
1075
+ // "awaiting publisher" for a full tick (RUSH-2484). This boot-time fire alone
1076
+ // no-ops on a genuinely cold boot (no reader has connected yet) — the
1077
+ // presence watch below is what actually closes the gap for a watcher that
1078
+ // connects later, whether at cold boot or after the reader-idle window.
1111
1079
  let activeSessionsWarmInFlight = false;
1112
1080
  const runActiveSessionsWarm = async () => {
1113
1081
  if (activeSessionsWarmInFlight)
@@ -1125,135 +1093,23 @@ export async function runDaemon() {
1125
1093
  };
1126
1094
  void runActiveSessionsWarm();
1127
1095
  const activeSessionsWarmInterval = setInterval(() => { void runActiveSessionsWarm(); }, ACTIVE_SESSIONS_WARM_TICK_MS);
1128
- // Session-index warm (RUSH-2682): keep THIS host's transcript index current so
1129
- // a locally-started session is discoverable within seconds. Incremental +
1130
- // single-flight (the DB scan claim), overlap-guarded so a slow scan never
1131
- // stacks on the next tick.
1132
- let sessionIndexWarmInFlight = false;
1133
- const runSessionIndexWarm = async () => {
1134
- if (sessionIndexWarmInFlight)
1135
- return;
1136
- sessionIndexWarmInFlight = true;
1137
- try {
1138
- const { indexed, claimed } = await runSessionIndexWarmTick();
1139
- // Log only when the tick did something. A silent tick is what let it
1140
- // report 0 forever unnoticed (RUSH-2691); a line on every idle 20s tick
1141
- // would drown the log, so the steady state (claimed, nothing changed)
1142
- // stays quiet and both interesting outcomes are visible.
1143
- if (!claimed)
1144
- log('INFO', 'session-index warm: skipped, another process holds the scan claim');
1145
- else if (indexed > 0)
1146
- log('INFO', `session-index warm: indexed ${indexed} transcript(s)`);
1147
- }
1148
- catch (err) {
1149
- log('WARN', `session-index warm failed: ${err.message}`);
1150
- }
1151
- finally {
1152
- sessionIndexWarmInFlight = false;
1153
- }
1154
- };
1155
- void runSessionIndexWarm();
1156
- const sessionIndexWarmInterval = setInterval(() => { void runSessionIndexWarm(); }, SESSION_INDEX_WARM_TICK_MS);
1157
- // Watchdog: nudge this host's own stalled agent sessions. Gated on the
1158
- // `watchdog.enabled` device-config flag (`agents watchdog enable`), so the
1159
- // timer always fires but only does work when the user opted in. Overlap-safe
1160
- // via the in-flight guard (a slow pass never overlaps the next tick).
1161
- let watchdogInterval;
1162
- if (isEnabled('watchdog')) {
1163
- let watchdogInFlight = false;
1164
- const runWatchdogTick = async () => {
1165
- if (watchdogInFlight)
1166
- return;
1167
- watchdogInFlight = true;
1168
- try {
1169
- if (getConfigValue('watchdog.enabled').value !== true)
1170
- return;
1171
- const { runWatchdogPass } = await import('../watchdog/service.js');
1172
- const result = await runWatchdogPass({ nudge: true });
1173
- log('INFO', `watchdog: ${result.counts.total} live, ${result.counts.stalled} stalled, ${result.counts.nudged} nudged`);
1174
- emit('watchdog.action', {
1175
- module: 'watchdog',
1176
- total: result.counts.total,
1177
- stalled: result.counts.stalled,
1178
- nudged: result.counts.nudged,
1179
- });
1180
- }
1181
- catch (err) {
1182
- log('WARN', `watchdog tick failed: ${err.message}`);
1183
- }
1184
- finally {
1185
- watchdogInFlight = false;
1186
- }
1187
- };
1188
- watchdogInterval = setInterval(() => { void runWatchdogTick(); }, WATCHDOG_TICK_MS);
1189
- }
1190
- else {
1191
- log('INFO', 'Watchdog service disabled');
1192
- }
1193
- // Device probe: refresh registered devices' reachability and detect newly
1194
- // appeared tailnet nodes, dropping a sentinel per pending device so the
1195
- // menu-bar helper can surface "NEW DEVICES → Register / Ignore". Refresh mode
1196
- // never auto-registers a newcomer; a machine without tailscale is a clean
1197
- // no-op. `reconcilePendingSentinels` re-subtracts the ignore-list AND the
1198
- // registered roster, so a dismissed or already-known device is never
1199
- // re-surfaced (RUSH-2495 + registry-empty pollution). On soft-fail (no
1200
- // tailscale) we still prune registered/ignored sentinels so a hermetic test
1201
- // leak cannot leave fleet boxes in NEW DEVICES forever.
1202
- let deviceProbeInterval;
1203
- if (isEnabled('device-probe')) {
1204
- let deviceProbeInFlight = false;
1205
- const runDeviceProbeTick = async () => {
1206
- if (deviceProbeInFlight)
1207
- return;
1208
- deviceProbeInFlight = true;
1209
- try {
1210
- const { runDeviceSync } = await import('../devices/sync.js');
1211
- const { reconcilePendingSentinels, pruneDismissedPendingSentinels, } = await import('../devices/pending.js');
1212
- const dev = await runDeviceSync({ soft: true, mode: 'refresh' });
1213
- if (!dev.ok) {
1214
- await pruneDismissedPendingSentinels();
1215
- if (dev.reason)
1216
- log('WARN', `device probe soft-fail: ${dev.reason}`);
1217
- return;
1218
- }
1219
- await reconcilePendingSentinels(dev.pending);
1220
- if (dev.pending.length) {
1221
- log('INFO', `devices: ${dev.pending.length} new pending (${dev.pending.map((p) => p.name).join(', ')})`);
1222
- }
1223
- }
1224
- catch (err) {
1225
- log('WARN', `device probe tick failed: ${err.message}`);
1226
- }
1227
- finally {
1228
- deviceProbeInFlight = false;
1229
- }
1230
- };
1231
- // Fire once on start so a leftover pollution set is cleared without waiting
1232
- // for the first interval tick (the 3-minute lag is how the menubar sat on
1233
- // 20 phantom NEW DEVICES after a hermetic leak).
1234
- void runDeviceProbeTick();
1235
- deviceProbeInterval = setInterval(() => { void runDeviceProbeTick(); }, DEVICE_PROBE_TICK_MS);
1236
- }
1237
- else {
1238
- log('INFO', 'Device-probe service disabled');
1239
- }
1240
- // Monitor engine: event-triggered watchers, beside the cron scheduler. Same
1241
- // daemon, same dispatch seam — a monitor is a routine whose trigger is a
1242
- // watched source instead of a clock. Reloads on SIGHUP alongside the scheduler.
1243
- const monitorEngine = isEnabled('monitors')
1244
- ? new MonitorEngine((level, message) => log(level, message))
1245
- : null;
1246
- if (monitorEngine) {
1247
- try {
1248
- monitorEngine.start();
1249
- }
1250
- catch (err) {
1251
- log('ERROR', `Monitor engine failed to start: ${err.message}`);
1252
- }
1253
- }
1254
- else {
1255
- log('INFO', 'Monitor engine disabled');
1256
- }
1096
+ // Out-of-band trigger (RUSH-2484): a reader connecting only writes a presence
1097
+ // timestamp (noteActiveSessionsJournalReader in watch.ts) with no path back to
1098
+ // this timer, so on its own the interval above leaves a fresh connection
1099
+ // waiting up to ACTIVE_SESSIONS_WARM_TICK_MS for its first rows. This watches
1100
+ // for the idle->recent edge and gathers immediately instead of waiting for the
1101
+ // next scheduled tick a genuinely idle box with no reader still never gathers.
1102
+ const stopActiveSessionsReaderWatch = watchActiveSessionsReaderPresence(() => { void runActiveSessionsWarm(); });
1103
+ // Session-index warm (RUSH-2682) is registered on the supervisor above
1104
+ // (RUSH-3193 P2) alongside the socket services.
1105
+ // Watchdog and device-probe are now managed by WatchdogService /
1106
+ // DeviceProbeService on the supervisor (RUSH-3193 P3), registered above
1107
+ // alongside the socket services. The supervisor fires an immediate first
1108
+ // tick on start, which replaces device-probe's old `void
1109
+ // runDeviceProbeTick()` kick-off (the 3-minute lag that used to leave the
1110
+ // menubar showing 20 phantom NEW DEVICES after a hermetic leak).
1111
+ // Monitor engine is now managed by MonitorEngineService on the supervisor
1112
+ // (RUSH-3193 P2). Access it via monitorEngineSvc.getEngine() in handleReload.
1257
1113
  // Backlog recovery: any enabled recurring job whose most-recent expected fire
1258
1114
  // is older than its most-recent recorded run was missed — the laptop slept,
1259
1115
  // the machine was off, or the daemon crashed through the fire. croner only
@@ -1263,7 +1119,7 @@ export async function runDaemon() {
1263
1119
  // `catchup: false`, RUN late. Runs on a timer as well as at startup: a startup
1264
1120
  // pass alone misses a fire lost while the daemon stayed up but its event loop
1265
1121
  // was wedged, or one lost across an OS suspend that the process survived.
1266
- // Overlap guard, same shape as runHealCheck (above). A pass
1122
+ // Overlap guard, same shape as SelfHealService's supervisor-owned inFlight guard. A pass
1267
1123
  // awaits executeJobDetached per job and an off-box (host/cloud) dispatch can
1268
1124
  // block for a while, so a slow pass could still be working when the next tick
1269
1125
  // fires. Both passes would then see a job the first has not yet reached as
@@ -1323,42 +1179,9 @@ export async function runDaemon() {
1323
1179
  catchingUp = false;
1324
1180
  }
1325
1181
  }
1326
- // Before the BrowserService comes up, reap browser + tunnel processes
1327
- // spawned by previous daemons that are no longer alive. Without this,
1328
- // a daemon hard-crash (SIGKILL, OOM) would leak every browser and SSH
1329
- // tunnel it had open — and the next session would either hijack those
1330
- // (cdp:// profile silently driven via stale ssh tunnel) or fail to
1331
- // bind because the ports are still claimed.
1332
- try {
1333
- const { reapOrphanedProcesses } = await import('../browser/runtime-state.js');
1334
- const result = reapOrphanedProcesses();
1335
- if (result.reaped > 0) {
1336
- log('INFO', `Reaped ${result.reaped} orphan process(es) from prior daemon(s)`);
1337
- for (const d of result.details)
1338
- log('INFO', ` ${d}`);
1339
- }
1340
- }
1341
- catch (err) {
1342
- log('ERROR', `Orphan reaper failed: ${err.message}`);
1343
- }
1344
- const browserService = isEnabled('browser-ipc') ? new BrowserService() : null;
1345
- const browserIPC = browserService ? new BrowserIPCServer(browserService) : null;
1346
- if (browserIPC) {
1347
- try {
1348
- await browserIPC.start();
1349
- log('INFO', 'Browser IPC server started');
1350
- recordSubsystemOk(SUBSYSTEM_BROWSER_IPC);
1351
- }
1352
- catch (err) {
1353
- const message = err.message;
1354
- log('ERROR', `Browser IPC failed to start: ${message}`);
1355
- recordSubsystemError(SUBSYSTEM_BROWSER_IPC, message);
1356
- }
1357
- }
1358
- else {
1359
- log('INFO', 'Browser IPC service disabled');
1360
- }
1361
- // 5th hosted service: signed webhook receiver(s) + their funnel (RUSH-2548).
1182
+ // Browser orphan reap and IPC server start are now managed by BrowserIPCService
1183
+ // on the supervisor (RUSH-3193 P2). The orphan reap runs inside onStart().
1184
+ // Webhook receivers: signed webhook receiver(s) + their funnel (RUSH-2548).
1362
1185
  // Started after the broker (above) so it resolves each receiver's signing
1363
1186
  // secret headlessly — no AGENTS_SECRETS_PASSPHRASE, no nohup. Binds nothing
1364
1187
  // unless daemon/webhooks.yaml declares a receiver, so an unconfigured box no-ops.
@@ -1379,122 +1202,29 @@ export async function runDaemon() {
1379
1202
  }
1380
1203
  runMonitorTick();
1381
1204
  const monitorInterval = setInterval(runMonitorTick, MONITOR_TICK_MS);
1382
- // Resource safety check: see runHealCheck above. Runs ~every 6h plus once
1383
- // ~30s after startup so shims/PATH settle shortly after the daemon starts.
1384
- let healInterval;
1385
- let healKickoff;
1386
- if (isEnabled('self-heal')) {
1387
- healInterval = setInterval(() => { void runHealCheck(); }, SELF_HEAL_TICK_MS);
1388
- healKickoff = setTimeout(() => { void runHealCheck(); }, SELF_HEAL_KICKOFF_MS);
1389
- }
1390
- else {
1391
- log('INFO', 'Self-heal service disabled');
1392
- }
1393
- // RUSH-1817: the startup host decision above is one-shot. If a standalone
1394
- // broker answered agentPing() at daemon start, the daemon declined to host —
1395
- // but should that standalone later die or crash-loop, nothing takes over and
1396
- // every `agents secrets unlock|export|start` fails until a manual restart
1397
- // (this wedged all keychain-backed secrets on zion and blocked a release).
1398
- // Re-probe on a cadence: whenever the daemon is NOT itself hosting AND no
1399
- // healthy broker answers a ping, take over hosting. startHostedBroker binds
1400
- // the socket only when it is free, so a take-over never races a live broker.
1401
- // Inline (not module-level): closes over `hostedBroker`, a mutable runDaemon-
1402
- // local var also closed over by handleShutdown. Lifting it would require
1403
- // hostedBroker to be module-level state too — wider than this refactor's scope.
1404
- let brokerSelfHealInterval;
1405
- if (isEnabled('secrets-broker')) {
1406
- let selfHealingBroker = false;
1407
- const runBrokerSelfHeal = async () => {
1408
- if (selfHealingBroker)
1409
- return;
1410
- selfHealingBroker = true;
1411
- try {
1412
- const { agentPing, startHostedBroker } = await import('../secrets/agent.js');
1413
- const reachable = (await agentPing()).reachable;
1414
- if (!shouldTakeOverBroker(hostedBroker != null, reachable))
1415
- return;
1416
- hostedBroker = await startHostedBroker();
1417
- if (hostedBroker) {
1418
- log('WARN', 'Secrets broker was unreachable; daemon took over hosting (self-heal)');
1419
- }
1420
- }
1421
- catch (err) {
1422
- log('WARN', `Secrets broker self-heal skipped: ${err.message}`);
1423
- }
1424
- finally {
1425
- selfHealingBroker = false;
1426
- }
1427
- };
1428
- brokerSelfHealInterval = setInterval(() => { void runBrokerSelfHeal(); }, BROKER_SELF_HEAL_TICK_MS);
1429
- }
1430
- // RUSH-2232: reap orphaned keychain helpers and `agents` processes stuck on a
1431
- // keychain call. Runs as a 5-min interval in the daemon (the single executor)
1432
- // so no UI surface can race it. See runKeychainReap above.
1433
- let keychainReapInterval;
1434
- if (isEnabled('keychain-reap')) {
1435
- keychainReapInterval = setInterval(() => { void runKeychainReap(); }, KEYCHAIN_REAP_TICK_MS);
1436
- }
1437
- else {
1438
- log('INFO', 'Keychain-reap service disabled');
1439
- }
1205
+ // Resource self-heal is now managed by SelfHealService on the supervisor
1206
+ // (RUSH-3193 P3), registered above alongside the socket services. The
1207
+ // supervisor's immediate first tick on start replaces the old
1208
+ // SELF_HEAL_KICKOFF_MS (30s) delayed kickoff timer — see self-heal-service.ts.
1209
+ // Broker self-heal (RUSH-1817) is now encapsulated in SecretsBrokerService
1210
+ // (RUSH-3193 P2). The interval fires inside onStart() and is stopped in onStop().
1211
+ // RUSH-2232: keychain-reap is now managed by KeychainReapService on the
1212
+ // supervisor (RUSH-3193 P3), registered above alongside the socket services.
1440
1213
  // RUSH-2501: reap tmux sessions whose panes are all dead. Runs on the same
1441
1214
  // 5-min cadence as the keychain reaper. Daemon-only (single executor).
1442
1215
  void runDeadPaneReap(); // kick-off on startup so the backlog clears immediately
1443
1216
  const deadPaneReapInterval = setInterval(() => { void runDeadPaneReap(); }, DEAD_PANE_REAP_TICK_MS);
1444
1217
  // RUSH-2622: close abandoned browser-task tabs on the same 5-min cadence,
1445
- // reusing the daemon's own long-lived BrowserService instead of a second
1446
- // one. Only when browser IPC is actually up — there is no service to reap
1447
- // through otherwise.
1218
+ // reusing the daemon's own long-lived BrowserService. `browserSvcForReap` is
1219
+ // the BrowserService created for BrowserIPCService above; null when disabled.
1448
1220
  let browserTaskReapInterval;
1449
- if (browserService) {
1450
- void runBrowserTaskReap(browserService); // kick-off on startup
1451
- browserTaskReapInterval = setInterval(() => { void runBrowserTaskReap(browserService); }, BROWSER_TASK_REAP_TICK_MS);
1452
- }
1453
- // RUSH-2367: self-terminate if this daemon's own state dir has been removed
1454
- // out from under it — the shape of a leaked test-fixture daemon whose /tmp
1455
- // HOME was deleted by its test's own cleanup while the process itself
1456
- // somehow survived (lost the SIGTERM/SIGKILL race, or outlived a killed
1457
- // test runner before its `finally` ever ran). Nothing else can reach a
1458
- // daemon in that state: no `agents daemon` command targets it, since a
1459
- // different HOME resolves a different ensureDaemonDir() and therefore a
1460
- // different instance registry — without this it runs forever. Reads
1461
- // the lifetime marker directly, never the local ensureDaemonDir() wrapper,
1462
- // which recreates the directory as a side effect and would defeat the check.
1463
- // Heartbeat/status paths may recreate the directory and pid file after a
1464
- // deletion; they never recreate this per-lifetime token.
1465
- // Inline (not module-level): closes over `lifetimePath` and `lifetimeToken`,
1466
- // per-boot constants computed once at runDaemon() start that cannot be
1467
- // pre-computed at module load time.
1468
- let stateDirCheckInterval;
1469
- if (isEnabled('state-dir-check')) {
1470
- let checkingStateDir = false;
1471
- const runStateDirSelfCheck = () => {
1472
- if (checkingStateDir)
1473
- return;
1474
- checkingStateDir = true;
1475
- try {
1476
- let markerMatches = false;
1477
- try {
1478
- markerMatches = fs.readFileSync(lifetimePath, 'utf-8') === lifetimeToken;
1479
- }
1480
- catch {
1481
- // A missing state dir or marker is the condition this guard detects.
1482
- }
1483
- if (!markerMatches) {
1484
- log('WARN', `Daemon state dir ${getDaemonDir()} no longer exists; exiting (self-terminate guard)`);
1485
- void handleShutdown();
1486
- }
1487
- }
1488
- finally {
1489
- checkingStateDir = false;
1490
- }
1491
- };
1492
- const stateDirCheckMs = Number(process.env.AGENTS_DAEMON_STATE_DIR_CHECK_MS) || STATE_DIR_CHECK_TICK_MS;
1493
- stateDirCheckInterval = setInterval(runStateDirSelfCheck, stateDirCheckMs);
1494
- }
1495
- else {
1496
- log('INFO', 'State-dir self-check disabled');
1221
+ if (browserSvcForReap) {
1222
+ void runBrowserTaskReap(browserSvcForReap); // kick-off on startup
1223
+ browserTaskReapInterval = setInterval(() => { void runBrowserTaskReap(browserSvcForReap); }, BROWSER_TASK_REAP_TICK_MS);
1497
1224
  }
1225
+ // RUSH-2367 / RUSH-3193 P3: state-dir-check (self-terminate guard) is
1226
+ // registered on the supervisor further below, once `handleShutdown` exists
1227
+ // — see the registration site after its declaration for why.
1498
1228
  // RUSH-2418: startup is over — the scheduler, browser IPC, broker decision,
1499
1229
  // monitor engine and every background tick are up. Only NOW does this daemon
1500
1230
  // clear the auto-start failure streak `ensureDaemonStarted` reads. Clearing it
@@ -1512,16 +1242,46 @@ export async function runDaemon() {
1512
1242
  const was = servicesConfig.services[id] !== false;
1513
1243
  const now = reloadedEnabled(id);
1514
1244
  if (was !== now) {
1515
- // scheduler and monitors are re-evaluated live later in this handler,
1516
- // so don't tell the user they need a restart for those.
1245
+ // scheduler and monitors' off-transition are re-evaluated live later in
1246
+ // this handler, so don't tell the user they need a restart for those.
1517
1247
  if (id === 'scheduler' || (id === 'monitors' && !now)) {
1518
1248
  continue;
1519
1249
  }
1520
- log('INFO', `Service '${id}' toggled ${now ? 'on' : 'off'} — restart daemon to apply`);
1250
+ if (id === 'monitors') {
1251
+ // monitors' on-transition still needs a restart — starting the
1252
+ // engine live is not wired up (see the liveMonitorEngine block below).
1253
+ log('INFO', `Service '${id}' toggled on — restart daemon to apply`);
1254
+ continue;
1255
+ }
1256
+ // RUSH-3193 P4: a service the supervisor already owns (it was enabled
1257
+ // at daemon boot, so it was registered) takes the toggle live via
1258
+ // supervisor.start/stop — no restart needed. One disabled at boot was
1259
+ // never registered, so it falls through to the same "restart to
1260
+ // apply" advice as before.
1261
+ if (supervisor.isRegistered(id)) {
1262
+ const action = now ? supervisor.start(id) : supervisor.stop(id);
1263
+ void action
1264
+ .then(() => log('INFO', `Service '${id}' ${now ? 'started' : 'stopped'} live (SIGHUP reload)`))
1265
+ .catch((err) => log('WARN', `Service '${id}' live ${now ? 'start' : 'stop'} failed: ${err.message}`));
1266
+ }
1267
+ else {
1268
+ log('INFO', `Service '${id}' toggled ${now ? 'on' : 'off'} — restart daemon to apply`);
1269
+ }
1521
1270
  }
1522
1271
  }
1523
1272
  // Remember the reloaded state so subsequent reloads log transitions truthfully.
1524
1273
  servicesConfig = reloadedConfig;
1274
+ // Drain queued `agents daemon services restart <id>` requests (RUSH-3193 P4).
1275
+ for (const id of drainDaemonServiceRestartQueue()) {
1276
+ if (supervisor.isRegistered(id)) {
1277
+ void supervisor.restartOne(id)
1278
+ .then(() => log('INFO', `Service '${id}' restarted live (SIGHUP reload)`))
1279
+ .catch((err) => log('WARN', `Service '${id}' live restart failed: ${err.message}`));
1280
+ }
1281
+ else {
1282
+ log('WARN', `Restart requested for '${id}' but it is not supervisor-managed on this daemon — restart the daemon instead`);
1283
+ }
1284
+ }
1525
1285
  // Refresh user-layer copies of opted-in project routines BEFORE the
1526
1286
  // scheduler reloads, so YAML edits under `<project>/.agents/routines/`
1527
1287
  // take effect on the next fire without a manual `routines sync`.
@@ -1557,17 +1317,18 @@ export async function runDaemon() {
1557
1317
  }
1558
1318
  // Monitor engine can be stopped on reload if disabled; starting it requires
1559
1319
  // a restart, so we only handle the off transition here.
1560
- if (monitorEngine) {
1320
+ const liveMonitorEngine = monitorEngineSvc.getEngine();
1321
+ if (liveMonitorEngine) {
1561
1322
  if (!reloadedEnabled('monitors')) {
1562
1323
  log('WARN', 'monitors service is now disabled — stopping monitor engine; restart daemon to re-enable');
1563
1324
  try {
1564
- monitorEngine.stop();
1325
+ liveMonitorEngine.stop();
1565
1326
  }
1566
1327
  catch { /* best-effort */ }
1567
1328
  }
1568
1329
  else {
1569
1330
  try {
1570
- monitorEngine.reload();
1331
+ liveMonitorEngine.reload();
1571
1332
  }
1572
1333
  catch (err) {
1573
1334
  log('ERROR', `Monitor engine reload failed: ${err.message}`);
@@ -1576,39 +1337,28 @@ export async function runDaemon() {
1576
1337
  }
1577
1338
  };
1578
1339
  // Structurally single-shot (RUSH-2423). Shutdown is reachable from SIGTERM,
1579
- // SIGINT, and the state-dir self-check's independent `void handleShutdown()`,
1580
- // and two of those can arrive together — a service manager that SIGTERMs a
1581
- // daemon whose state dir was just removed. It was only INCIDENTALLY safe
1582
- // before (every step inside happens to be idempotent); the guard makes
1583
- // single-shot a property of the function rather than one that every step
1584
- // added later has to re-earn.
1340
+ // SIGINT, and StateDirCheckService's independent `onMissing` callback (which
1341
+ // calls this same handler), and two of those can arrive together — a
1342
+ // service manager that SIGTERMs a daemon whose state dir was just removed.
1343
+ // It was only INCIDENTALLY safe before (every step inside happens to be
1344
+ // idempotent); the guard makes single-shot a property of the function
1345
+ // rather than one that every step added later has to re-earn.
1585
1346
  const handleShutdown = singleShot(async () => {
1586
1347
  log('INFO', 'Daemon shutting down');
1587
- accountStateService?.stop();
1588
1348
  clearInterval(activeSessionsWarmInterval);
1589
- clearInterval(sessionIndexWarmInterval);
1590
- if (watchdogInterval)
1591
- clearInterval(watchdogInterval);
1592
- if (deviceProbeInterval)
1593
- clearInterval(deviceProbeInterval);
1349
+ stopActiveSessionsReaderWatch();
1350
+ // supervisor.stopAll() stops every registered service: secrets-broker
1351
+ // (closes hostedBroker + self-heal timer), monitor engine, account-state,
1352
+ // browser IPC, session-index, watchdog, device-probe, self-heal,
1353
+ // keychain-reap, and (once registered just below) state-dir-check.
1354
+ await supervisor.stopAll();
1355
+ activeServiceSupervisor = null;
1594
1356
  stopScheduler();
1595
- monitorEngine?.stop();
1596
- await browserIPC?.stop();
1597
1357
  await webhookReceivers?.close();
1598
1358
  clearInterval(monitorInterval);
1599
- if (healInterval)
1600
- clearInterval(healInterval);
1601
- if (healKickoff)
1602
- clearTimeout(healKickoff);
1603
- if (brokerSelfHealInterval)
1604
- clearInterval(brokerSelfHealInterval);
1605
- if (keychainReapInterval)
1606
- clearInterval(keychainReapInterval);
1607
1359
  clearInterval(deadPaneReapInterval);
1608
1360
  if (browserTaskReapInterval)
1609
1361
  clearInterval(browserTaskReapInterval);
1610
- if (stateDirCheckInterval)
1611
- clearInterval(stateDirCheckInterval);
1612
1362
  try {
1613
1363
  if (fs.readFileSync(lifetimePath, 'utf-8') === lifetimeToken)
1614
1364
  fs.unlinkSync(lifetimePath);
@@ -1616,12 +1366,30 @@ export async function runDaemon() {
1616
1366
  catch {
1617
1367
  // Already removed with the state dir, or replaced by a newer owner.
1618
1368
  }
1619
- hostedBroker?.close();
1620
1369
  removeDaemonPid();
1621
1370
  removeHeartbeat();
1622
1371
  unregisterDaemonInstance();
1623
1372
  process.exit(0);
1624
1373
  });
1374
+ // State-dir self-check (RUSH-2367 self-terminate guard) is registered on
1375
+ // the supervisor here — AFTER `handleShutdown` above — rather than
1376
+ // alongside watchdog/device-probe/self-heal/keychain-reap earlier. The
1377
+ // supervisor fires an immediate first tick on `register()`+`start()`; doing
1378
+ // that before `handleShutdown` exists would reference the const in its
1379
+ // temporal dead zone the moment a mismatch is ever detected. Registering it
1380
+ // here, once `handleShutdown` is a real function, removes that risk
1381
+ // entirely rather than relying on the marker always matching on tick one.
1382
+ if (isEnabled('state-dir-check')) {
1383
+ supervisor.register(new StateDirCheckService({
1384
+ lifetimePath,
1385
+ lifetimeToken,
1386
+ onMissing: () => { void handleShutdown(); },
1387
+ }));
1388
+ await supervisor.start('state-dir-check');
1389
+ }
1390
+ else {
1391
+ log('INFO', 'State-dir self-check disabled');
1392
+ }
1625
1393
  process.on('SIGHUP', handleReload);
1626
1394
  process.on('SIGTERM', () => handleShutdown());
1627
1395
  process.on('SIGINT', () => handleShutdown());