@phnx-labs/agents-cli 1.22.117 → 1.22.118

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 (248) hide show
  1. package/CHANGELOG.md +235 -0
  2. package/dist/bootstrap.js +4 -11
  3. package/dist/commands/browser.d.ts +52 -70
  4. package/dist/commands/browser.js +192 -3562
  5. package/dist/commands/daemon.d.ts +39 -2
  6. package/dist/commands/daemon.js +72 -73
  7. package/dist/commands/events.js +1 -1
  8. package/dist/commands/exec.js +53 -1
  9. package/dist/commands/repo.js +13 -49
  10. package/dist/commands/route.js +5 -5
  11. package/dist/commands/routines.js +39 -12
  12. package/dist/commands/sessions-render.d.ts +1 -1
  13. package/dist/commands/sessions-render.js +0 -1
  14. package/dist/commands/sessions.test-fixture.d.ts +0 -1
  15. package/dist/commands/sessions.test-fixture.js +0 -37
  16. package/dist/commands/setup-browser.d.ts +17 -12
  17. package/dist/commands/setup-browser.js +52 -87
  18. package/dist/commands/setup-preferences.d.ts +0 -17
  19. package/dist/commands/setup-preferences.js +18 -49
  20. package/dist/commands/setup.js +9 -9
  21. package/dist/commands/ssh.js +10 -1
  22. package/dist/commands/sync.js +6 -44
  23. package/dist/commands/teams.js +0 -1
  24. package/dist/commands/update.d.ts +19 -0
  25. package/dist/commands/update.js +34 -6
  26. package/dist/commands/view.d.ts +12 -1
  27. package/dist/commands/view.js +47 -2
  28. package/dist/index.js +107 -26
  29. package/dist/lib/account-capabilities.js +0 -2
  30. package/dist/lib/account-catalog.d.ts +38 -0
  31. package/dist/lib/account-catalog.js +110 -26
  32. package/dist/lib/account-provider-registry.js +1 -1
  33. package/dist/lib/account-registry.d.ts +1 -1
  34. package/dist/lib/account-registry.js +36 -2
  35. package/dist/lib/accounting/rotate.js +22 -8
  36. package/dist/lib/accounting/usage-ingest.d.ts +6 -1
  37. package/dist/lib/accounting/usage-ingest.js +115 -45
  38. package/dist/lib/accounting/usage-sync.d.ts +134 -31
  39. package/dist/lib/accounting/usage-sync.js +227 -66
  40. package/dist/lib/accounting/usage.d.ts +16 -3
  41. package/dist/lib/accounting/usage.js +26 -8
  42. package/dist/lib/accounts/slots.d.ts +8 -0
  43. package/dist/lib/accounts/slots.js +17 -0
  44. package/dist/lib/add-dir.js +0 -1
  45. package/dist/lib/agent-cli-commands.js +0 -1
  46. package/dist/lib/agent-spec/agents.d.ts +6 -7
  47. package/dist/lib/agent-spec/agents.js +6 -60
  48. package/dist/lib/auth-health.d.ts +69 -1
  49. package/dist/lib/auth-health.js +133 -4
  50. package/dist/lib/browser/context.d.ts +82 -0
  51. package/dist/lib/browser/context.js +88 -0
  52. package/dist/lib/browser/paths.d.ts +25 -0
  53. package/dist/lib/browser/paths.js +51 -0
  54. package/dist/lib/browser/record.d.ts +38 -0
  55. package/dist/lib/browser/record.js +57 -0
  56. package/dist/lib/browser/sessions-list.js +1 -2
  57. package/dist/lib/browser-client.d.ts +158 -0
  58. package/dist/lib/browser-client.js +234 -0
  59. package/dist/lib/channels/owner-sink.d.ts +1 -18
  60. package/dist/lib/channels/owner-sink.js +29 -82
  61. package/dist/lib/channels/providers/rush.d.ts +7 -7
  62. package/dist/lib/channels/providers/rush.js +127 -57
  63. package/dist/lib/claude-statusline.d.ts +1 -1
  64. package/dist/lib/claude-statusline.js +15 -3
  65. package/dist/lib/commands.js +1 -1
  66. package/dist/lib/crabbox/lease.js +1 -1
  67. package/dist/lib/crabbox/runtimes.js +1 -3
  68. package/dist/lib/daemon/account-state-daemon-service.js +1 -0
  69. package/dist/lib/daemon/auth-sync-service.d.ts +21 -18
  70. package/dist/lib/daemon/auth-sync-service.js +37 -43
  71. package/dist/lib/daemon/daemon.d.ts +25 -14
  72. package/dist/lib/daemon/daemon.js +92 -154
  73. package/dist/lib/daemon/runner.d.ts +2 -2
  74. package/dist/lib/daemon/runner.js +32 -9
  75. package/dist/lib/daemon/self-update-service.d.ts +35 -34
  76. package/dist/lib/daemon/self-update-service.js +40 -36
  77. package/dist/lib/daemon/service.d.ts +12 -8
  78. package/dist/lib/daemon/supervisor.d.ts +65 -54
  79. package/dist/lib/daemon/supervisor.js +136 -171
  80. package/dist/lib/daemon/testdata/process-view-start.js +5 -2
  81. package/dist/lib/daemon/usage-sync-service.d.ts +21 -30
  82. package/dist/lib/daemon/usage-sync-service.js +46 -78
  83. package/dist/lib/daemon-health.d.ts +22 -5
  84. package/dist/lib/daemon-health.js +47 -3
  85. package/dist/lib/daemon-services.d.ts +1 -1
  86. package/dist/lib/daemon-services.js +0 -10
  87. package/dist/lib/daemon-ticks.js +4 -2
  88. package/dist/lib/devices/doctor-findings.js +8 -11
  89. package/dist/lib/devices/harness-inventory.js +14 -5
  90. package/dist/lib/exec-account-home.js +5 -0
  91. package/dist/lib/exec.js +0 -11
  92. package/dist/lib/feed/events.js +0 -1
  93. package/dist/lib/feed/tool-activity.js +5 -2
  94. package/dist/lib/fleet-shared-state.d.ts +45 -0
  95. package/dist/lib/fleet-shared-state.js +78 -9
  96. package/dist/lib/fs-atomic.js +25 -0
  97. package/dist/lib/harness-auth-capabilities.js +0 -1
  98. package/dist/lib/helper-versions.js +1 -1
  99. package/dist/lib/hooks/cache.d.ts +7 -0
  100. package/dist/lib/hooks/cache.js +27 -2
  101. package/dist/lib/hooks/install.js +7 -1
  102. package/dist/lib/hosts/ready.d.ts +38 -8
  103. package/dist/lib/hosts/ready.js +62 -14
  104. package/dist/lib/installations/active-check.d.ts +27 -0
  105. package/dist/lib/installations/active-check.js +61 -0
  106. package/dist/lib/installations/migrate.js +6 -3
  107. package/dist/lib/installations/shims.js +9 -7
  108. package/dist/lib/installations/update.js +21 -15
  109. package/dist/lib/installations/versions.js +12 -1
  110. package/dist/lib/manifest.js +1 -1
  111. package/dist/lib/models.js +0 -2
  112. package/dist/lib/open-url.js +28 -54
  113. package/dist/lib/profiles.js +0 -2
  114. package/dist/lib/refresh.js +1 -1
  115. package/dist/lib/routine-readiness.d.ts +35 -0
  116. package/dist/lib/routine-readiness.js +74 -16
  117. package/dist/lib/rules/compile.js +6 -0
  118. package/dist/lib/sandbox.js +0 -2
  119. package/dist/lib/secrets-policy.d.ts +89 -11
  120. package/dist/lib/secrets-policy.js +175 -28
  121. package/dist/lib/session/db.d.ts +1 -1
  122. package/dist/lib/session/db.js +65 -18
  123. package/dist/lib/session/discover.js +1 -283
  124. package/dist/lib/session/mirror.d.ts +4 -3
  125. package/dist/lib/session/mirror.js +4 -3
  126. package/dist/lib/session/parse.d.ts +0 -2
  127. package/dist/lib/session/parse.js +1 -138
  128. package/dist/lib/session/throughput.d.ts +1 -3
  129. package/dist/lib/session/throughput.js +0 -20
  130. package/dist/lib/session/types.d.ts +2 -2
  131. package/dist/lib/session/types.js +2 -2
  132. package/dist/lib/sessions-client.d.ts +60 -1
  133. package/dist/lib/sessions-client.js +227 -4
  134. package/dist/lib/setup-tool-status.js +5 -10
  135. package/dist/lib/signin-badge.d.ts +3 -2
  136. package/dist/lib/signin-badge.js +6 -3
  137. package/dist/lib/smart-launch.js +4 -1
  138. package/dist/lib/staleness/detectors/commands.d.ts +1 -1
  139. package/dist/lib/staleness/detectors/hooks.d.ts +1 -1
  140. package/dist/lib/staleness/detectors/mcp.d.ts +1 -1
  141. package/dist/lib/staleness/detectors/permissions.d.ts +1 -1
  142. package/dist/lib/staleness/detectors/plugins.d.ts +1 -1
  143. package/dist/lib/staleness/detectors/rules.d.ts +1 -1
  144. package/dist/lib/staleness/detectors/skills.d.ts +1 -1
  145. package/dist/lib/staleness/detectors/subagents.d.ts +1 -1
  146. package/dist/lib/staleness/detectors/workflows.d.ts +1 -1
  147. package/dist/lib/staleness/writers/commands.d.ts +1 -1
  148. package/dist/lib/staleness/writers/hooks.d.ts +1 -1
  149. package/dist/lib/staleness/writers/mcp.d.ts +1 -1
  150. package/dist/lib/staleness/writers/permissions.d.ts +1 -1
  151. package/dist/lib/staleness/writers/plugins.d.ts +1 -1
  152. package/dist/lib/staleness/writers/rules.d.ts +1 -1
  153. package/dist/lib/staleness/writers/skills.d.ts +1 -1
  154. package/dist/lib/staleness/writers/sources.js +5 -0
  155. package/dist/lib/staleness/writers/subagents.d.ts +1 -1
  156. package/dist/lib/staleness/writers/workflows.d.ts +1 -1
  157. package/dist/lib/state.d.ts +6 -4
  158. package/dist/lib/state.js +7 -17
  159. package/dist/lib/teams/parsers.d.ts +1 -1
  160. package/dist/lib/teams/parsers.js +1 -136
  161. package/dist/lib/teams/placement-probe.js +50 -15
  162. package/dist/lib/teams/scheduler.d.ts +7 -0
  163. package/dist/lib/types.d.ts +10 -1
  164. package/dist/lib/types.js +1 -1
  165. package/dist/lib/view-types.d.ts +32 -0
  166. package/dist/lib/watchdog/read.js +0 -1
  167. package/dist/lib/watchdog/rotate.js +1 -2
  168. package/dist/lib/watchdog/runner.js +2 -2
  169. package/dist/lib/watchdog/watchdog.d.ts +1 -1
  170. package/dist/lib/watchdog/watchdogTail.js +0 -31
  171. package/package.json +1 -1
  172. package/scripts/postinstall.js +8 -8
  173. package/dist/browser.d.ts +0 -2
  174. package/dist/browser.js +0 -17
  175. package/dist/commands/browser-picker.d.ts +0 -18
  176. package/dist/commands/browser-picker.js +0 -97
  177. package/dist/lib/browser/arc-discovery.d.ts +0 -52
  178. package/dist/lib/browser/arc-discovery.js +0 -197
  179. package/dist/lib/browser/arc-dom.d.ts +0 -14
  180. package/dist/lib/browser/arc-dom.js +0 -121
  181. package/dist/lib/browser/caller-identity.d.ts +0 -40
  182. package/dist/lib/browser/caller-identity.js +0 -175
  183. package/dist/lib/browser/cdp.d.ts +0 -51
  184. package/dist/lib/browser/cdp.js +0 -254
  185. package/dist/lib/browser/chrome.d.ts +0 -141
  186. package/dist/lib/browser/chrome.js +0 -704
  187. package/dist/lib/browser/chromium-discovery.d.ts +0 -28
  188. package/dist/lib/browser/chromium-discovery.js +0 -96
  189. package/dist/lib/browser/devices.d.ts +0 -34
  190. package/dist/lib/browser/devices.js +0 -61
  191. package/dist/lib/browser/domain-skills.d.ts +0 -71
  192. package/dist/lib/browser/domain-skills.js +0 -195
  193. package/dist/lib/browser/drivers/arc.d.ts +0 -40
  194. package/dist/lib/browser/drivers/arc.js +0 -276
  195. package/dist/lib/browser/drivers/firefox.d.ts +0 -99
  196. package/dist/lib/browser/drivers/firefox.js +0 -377
  197. package/dist/lib/browser/drivers/local.d.ts +0 -55
  198. package/dist/lib/browser/drivers/local.js +0 -266
  199. package/dist/lib/browser/drivers/ssh.d.ts +0 -120
  200. package/dist/lib/browser/drivers/ssh.js +0 -467
  201. package/dist/lib/browser/editor.d.ts +0 -3
  202. package/dist/lib/browser/editor.js +0 -50
  203. package/dist/lib/browser/ffmpeg.d.ts +0 -12
  204. package/dist/lib/browser/ffmpeg.js +0 -184
  205. package/dist/lib/browser/firefox-discovery.d.ts +0 -69
  206. package/dist/lib/browser/firefox-discovery.js +0 -162
  207. package/dist/lib/browser/har.d.ts +0 -85
  208. package/dist/lib/browser/har.js +0 -77
  209. package/dist/lib/browser/hygiene.d.ts +0 -97
  210. package/dist/lib/browser/hygiene.js +0 -153
  211. package/dist/lib/browser/index.d.ts +0 -5
  212. package/dist/lib/browser/index.js +0 -5
  213. package/dist/lib/browser/input.d.ts +0 -7
  214. package/dist/lib/browser/input.js +0 -92
  215. package/dist/lib/browser/ipc.d.ts +0 -204
  216. package/dist/lib/browser/ipc.js +0 -1453
  217. package/dist/lib/browser/login-detection.d.ts +0 -87
  218. package/dist/lib/browser/login-detection.js +0 -274
  219. package/dist/lib/browser/profiles.d.ts +0 -422
  220. package/dist/lib/browser/profiles.js +0 -1157
  221. package/dist/lib/browser/refs.d.ts +0 -89
  222. package/dist/lib/browser/refs.js +0 -191
  223. package/dist/lib/browser/registry.d.ts +0 -70
  224. package/dist/lib/browser/registry.js +0 -252
  225. package/dist/lib/browser/remote-control.d.ts +0 -59
  226. package/dist/lib/browser/remote-control.js +0 -85
  227. package/dist/lib/browser/resolve-target.d.ts +0 -66
  228. package/dist/lib/browser/resolve-target.js +0 -264
  229. package/dist/lib/browser/runtime-state.d.ts +0 -291
  230. package/dist/lib/browser/runtime-state.js +0 -584
  231. package/dist/lib/browser/secret-ref.d.ts +0 -10
  232. package/dist/lib/browser/secret-ref.js +0 -14
  233. package/dist/lib/browser/service.d.ts +0 -812
  234. package/dist/lib/browser/service.js +0 -4693
  235. package/dist/lib/browser/stream.d.ts +0 -17
  236. package/dist/lib/browser/stream.js +0 -72
  237. package/dist/lib/browser/task-index.d.ts +0 -78
  238. package/dist/lib/browser/task-index.js +0 -199
  239. package/dist/lib/browser/types.d.ts +0 -647
  240. package/dist/lib/browser/types.js +0 -61
  241. package/dist/lib/browser/upload.d.ts +0 -29
  242. package/dist/lib/browser/upload.js +0 -298
  243. package/dist/lib/daemon/browser-ipc-service.d.ts +0 -20
  244. package/dist/lib/daemon/browser-ipc-service.js +0 -46
  245. package/dist/lib/daemon/browser-task-reap-service.d.ts +0 -14
  246. package/dist/lib/daemon/browser-task-reap-service.js +0 -26
  247. package/dist/lib/fleet-shared-repo-sync.d.ts +0 -55
  248. package/dist/lib/fleet-shared-repo-sync.js +0 -490
@@ -1,1453 +0,0 @@
1
- import * as net from 'net';
2
- import * as fs from 'fs';
3
- import * as path from 'path';
4
- import { IS_WINDOWS, ipcEndpoint } from '../platform/index.js';
5
- import { getHelpersDir } from '../state.js';
6
- import { resolveBrowserTaskIdleMs } from '../device-config.js';
7
- import { getDaemonLogPath, isDaemonRunning, signalDaemonReload, startDaemon, } from '../daemon/daemon.js';
8
- import { getCliVersion } from '../version.js';
9
- import { compareVersions } from '../agent-spec/primitives.js';
10
- import { scheduleSelfUpdateExit, selfUpdateSyncDeclineReason, triggerSelfUpdateInBackground } from '../daemon/self-update-service.js';
11
- import { log as daemonLog } from '../daemon/daemon.js';
12
- import { isDaemonServiceEnabled, queueDaemonServiceRestart, setDaemonServiceEnabled, } from '../daemon-services.js';
13
- import { isFleetRemoteInvocation } from './remote-control.js';
14
- import { resolveCallerIdentity } from './caller-identity.js';
15
- import { actionable } from './service.js';
16
- /**
17
- * Verbs that imply a page and may CREATE a task when none resolves.
18
- * Observation-only verbs (console/logs/…) resolve but never create — an
19
- * empty `agents browser logs` must not open a browser just to return [].
20
- */
21
- const PAGE_CREATE_VERBS = new Set([
22
- 'navigate',
23
- 'tab-add',
24
- 'evaluate',
25
- 'screenshot',
26
- 'pdf',
27
- 'refs',
28
- 'click',
29
- 'type',
30
- 'press',
31
- 'hover',
32
- 'scroll',
33
- 'set-viewport',
34
- 'set-device',
35
- 'wait',
36
- 'set-download-path',
37
- 'wait-download',
38
- 'upload',
39
- 'record-start',
40
- ]);
41
- /** Task-scoped verbs that resolve identity but never create. */
42
- const PAGE_RESOLVE_VERBS = new Set([
43
- ...PAGE_CREATE_VERBS,
44
- 'tab-focus',
45
- 'tab-close',
46
- 'tab-list',
47
- 'console',
48
- 'errors',
49
- 'requests',
50
- 'response-body',
51
- 'getAppLogs',
52
- 'record-stop',
53
- ]);
54
- /** Close verbs never create a task. */
55
- const CLOSE_VERBS = new Set(['done', 'stop']);
56
- const SOCKET_NAME = 'browser.sock';
57
- function readSocketIdentity(socketPath) {
58
- try {
59
- const stat = fs.lstatSync(socketPath);
60
- return { dev: stat.dev, ino: stat.ino };
61
- }
62
- catch {
63
- return null;
64
- }
65
- }
66
- function isSameSocket(socketPath, expected) {
67
- const current = readSocketIdentity(socketPath);
68
- return current !== null && current.dev === expected.dev && current.ino === expected.ino;
69
- }
70
- /**
71
- * Backstop for {@link BrowserIPCServer.stop} — how long to wait for the socket
72
- * to be released before unlinking it anyway (RUSH-2421).
73
- *
74
- * This MUST stay well under the daemon's SIGTERM grace window
75
- * (`STOP_GRACE_MS`, daemon.ts). `handleShutdown` awaits `stop()`, so a close
76
- * that takes as long as the grace window means `stopDaemon` gives up waiting
77
- * and escalates to `killTree` — turning every graceful stop into a kill. The
78
- * first version of this constant was set to exactly 5000, the same value as
79
- * `STOP_GRACE_MS`, and did precisely that whenever a browser client held its
80
- * (deliberately) long-lived connection open.
81
- *
82
- * With connections now ended explicitly in `closeServer`, reaching this
83
- * timeout at all means a socket refused to die; 1.5s bounds that without
84
- * coming near the grace window.
85
- */
86
- const IPC_CLOSE_TIMEOUT_MS = 1_500;
87
- /**
88
- * Client-side ceiling on the on-demand `request-self-update` call inside
89
- * {@link reconcileDaemonVersion}. The daemon handler is DECOUPLED from the
90
- * install — it kicks the self-update off in the background and answers
91
- * immediately — so this only needs to be long enough to confirm the daemon
92
- * ACCEPTED the trigger, never long enough to wait out a check/install/verify.
93
- * It must stay short because a version-skewed browser verb routes through this
94
- * reconcile on its way to the real request; a wedged or gone daemon degrades to
95
- * the advisory PHNX-3605 shipped rather than stalling the verb.
96
- */
97
- const DAEMON_SELF_UPDATE_TRIGGER_TIMEOUT_MS = 5_000;
98
- /**
99
- * How long {@link waitForBrowserService} waits for the browser service to come up before
100
- * failing loud (PHNX-3289).
101
- *
102
- * The old flat 6s ceiling was the wedge: the shared daemon's browser IPC server
103
- * restarts (version reconcile, supervisor restart, a hard-crashed predecessor's
104
- * successor claiming the socket), and a client that started its wait *during*
105
- * one of those restart windows could burn the whole 6s and throw
106
- * `Timeout waiting for browser service socket` on an otherwise-healthy daemon. A
107
- * browser start/navigate then failed intermittently and self-healed on the next
108
- * try. 15s comfortably spans a restart; the stable-probe requirement below is
109
- * what keeps a socket that "appears and is immediately destroyed" (#556) from
110
- * being mistaken for a ready daemon.
111
- */
112
- const SOCKET_WAIT_TIMEOUT_MS = 15_000;
113
- /**
114
- * Consecutive successful probes required before {@link waitForBrowserService} declares
115
- * the service ready. A single accept can land in the sliver between a restarting
116
- * server binding and tearing back down; requiring two accepts ~100ms apart means
117
- * we only return once the daemon is *staying* up, so the caller's real request
118
- * doesn't race a restart it happened to probe mid-flight.
119
- */
120
- const SOCKET_WAIT_STABLE_PROBES = 2;
121
- /**
122
- * How long a single {@link probeBrowserServiceResponsive} attempt waits for the service to
123
- * answer a trivial `version` request before giving up (PHNX-3411).
124
- *
125
- * A unix-socket `connect` succeeds at the KERNEL level the moment the connection
126
- * is queued in the listen backlog — it does NOT require the server's event loop
127
- * to run. So a daemon whose event loop is blocked (e.g. a long synchronous burst
128
- * on the shared loop) still passes {@link isBrowserServiceReachable}: the socket accepts,
129
- * but the request is never serviced. The live symptom on zion was exactly this —
130
- * `browser.sock` accepted every connection while the daemon re-indexed sessions,
131
- * then never replied, so cross-device browser drives hung or surfaced a confusing
132
- * bare socket timeout. Requiring an actual reply is the only probe that tells a
133
- * healthy daemon apart from a wedged one.
134
- */
135
- const RESPONSIVENESS_PROBE_TIMEOUT_MS = 1_500;
136
- /**
137
- * Consecutive missed {@link probeBrowserServiceResponsive} attempts before a *reachable*
138
- * daemon is declared wedged. One missed reply can be a transient GC pause on an
139
- * otherwise-healthy loop, so a single failure never condemns the daemon; a daemon
140
- * that cannot answer a trivial `version` request across this whole window has a
141
- * genuinely blocked event loop. The added latency (attempts × timeout) is paid
142
- * ONLY on the wedged path — where the alternative was an indefinite hang — while
143
- * a healthy daemon answers the first attempt in ~1ms.
144
- */
145
- const RESPONSIVENESS_PROBE_ATTEMPTS = 3;
146
- export class BrowserServiceNotRunningError extends Error {
147
- constructor() {
148
- super(formatBrowserServiceNotRunningError());
149
- this.name = 'BrowserServiceNotRunningError';
150
- }
151
- }
152
- export function formatBrowserServiceNotRunningError(daemonRunning = isDaemonRunning()) {
153
- return [
154
- 'Browser service is not running.',
155
- `Shared daemon: ${daemonRunning ? 'running (other services are unaffected)' : 'stopped'}.`,
156
- 'Start it with: agents browser start (enables only the browser-ipc service and uses this machine\'s configured default browser)',
157
- 'Pick / pin a profile: agents browser use <name> (or: agents browser start --profile <name>)',
158
- 'List profiles: agents browser profiles list',
159
- ].join('\n');
160
- }
161
- export function getSocketPath() {
162
- return path.join(getHelpersDir(), 'browser', SOCKET_NAME);
163
- }
164
- /**
165
- * The address the daemon actually listens on / clients connect to: the unix
166
- * socket file on POSIX, a `\\.\pipe\` named pipe on Windows. `getSocketPath`
167
- * stays the canonical key (and the POSIX socket path); on Windows it's only used
168
- * to derive a stable pipe name, never touched on disk.
169
- */
170
- function getIpcEndpoint() {
171
- return ipcEndpoint(getSocketPath());
172
- }
173
- /** Can we open a connection to the daemon right now? Resolves false on any
174
- * error. This is the authoritative liveness check on every platform: a live
175
- * daemon accepts the connection, while a stale POSIX socket file left behind by
176
- * a crashed daemon (or one that "appears and is immediately destroyed", #556)
177
- * rejects with ECONNREFUSED and is correctly reported as not reachable —
178
- * something fs.existsSync can't distinguish. */
179
- function probeBrowserService(endpoint, timeoutMs = 500) {
180
- return new Promise((resolve) => {
181
- const sock = net.createConnection(endpoint);
182
- let done = false;
183
- const finish = (ok) => { if (done)
184
- return; done = true; sock.destroy(); resolve(ok); };
185
- const timer = setTimeout(() => finish(false), timeoutMs);
186
- sock.on('connect', () => { clearTimeout(timer); finish(true); });
187
- sock.on('error', () => { clearTimeout(timer); finish(false); });
188
- });
189
- }
190
- /** Is the daemon reachable? A real connect probe on every platform — a socket
191
- * file existing on disk is not proof a daemon is listening on it. */
192
- export async function isBrowserServiceReachable() {
193
- return probeBrowserService(getIpcEndpoint());
194
- }
195
- /**
196
- * Can the daemon actually REPLY right now? Opens a connection and requires a
197
- * parseable response to a `version` request within `timeoutMs`. Unlike
198
- * {@link probeBrowserService} (which resolves on the kernel-level `connect`), this only
199
- * succeeds when the daemon's event loop is running and services the request — so
200
- * it is the one probe that distinguishes a healthy daemon from a wedged one whose
201
- * loop is blocked (PHNX-3411). Resolves false on connect error, timeout, an early
202
- * close, or an unparseable reply. `version` is the right probe: its handler is
203
- * trivial and synchronous (never touches the browser), so a slow reply means the
204
- * loop is blocked, not that a real action is in flight.
205
- */
206
- function probeBrowserServiceResponsive(endpoint, timeoutMs = RESPONSIVENESS_PROBE_TIMEOUT_MS) {
207
- return new Promise((resolve) => {
208
- const sock = net.createConnection(endpoint);
209
- let buffer = '';
210
- let done = false;
211
- const finish = (ok) => { if (done)
212
- return; done = true; clearTimeout(timer); sock.destroy(); resolve(ok); };
213
- const timer = setTimeout(() => finish(false), timeoutMs);
214
- sock.on('connect', () => { sock.write(JSON.stringify({ action: 'version' }) + '\n'); });
215
- sock.on('data', (data) => {
216
- buffer += data.toString();
217
- const idx = buffer.indexOf('\n');
218
- if (idx === -1)
219
- return;
220
- try {
221
- const resp = JSON.parse(buffer.slice(0, idx));
222
- finish(resp.ok === true);
223
- }
224
- catch {
225
- finish(false);
226
- }
227
- });
228
- sock.on('error', () => finish(false));
229
- sock.on('close', () => finish(false));
230
- });
231
- }
232
- /**
233
- * Is a *reachable* daemon actually responsive, or is its event loop wedged
234
- * (PHNX-3411)? Retries {@link probeBrowserServiceResponsive} up to
235
- * {@link RESPONSIVENESS_PROBE_ATTEMPTS} times so a single transient miss (a GC
236
- * pause) never condemns a healthy daemon; returns true as soon as any attempt
237
- * gets a reply, and false only when every attempt fails.
238
- */
239
- export async function isBrowserServiceResponsive(endpoint = getIpcEndpoint(), attempts = RESPONSIVENESS_PROBE_ATTEMPTS) {
240
- for (let i = 0; i < attempts; i++) {
241
- if (await probeBrowserServiceResponsive(endpoint))
242
- return true;
243
- }
244
- return false;
245
- }
246
- /**
247
- * Wait until the browser service is genuinely reachable, or throw.
248
- *
249
- * Re-probes across the whole window rather than latching on the first accept, so
250
- * it survives an IPC-server restart that happens mid-wait (PHNX-3289): a restart
251
- * just resets the consecutive-accept counter, and the loop keeps going until the
252
- * daemon is *stably* up or the deadline passes. Bounded and fail-loud — a daemon
253
- * that never comes up throws a message naming the endpoint and the budget, never
254
- * a silent hang.
255
- */
256
- export async function waitForBrowserService(_socketPath, timeoutMs = SOCKET_WAIT_TIMEOUT_MS) {
257
- const endpoint = getIpcEndpoint();
258
- const deadline = Date.now() + timeoutMs;
259
- let consecutive = 0;
260
- while (Date.now() < deadline) {
261
- if (await probeBrowserService(endpoint)) {
262
- consecutive += 1;
263
- if (consecutive >= SOCKET_WAIT_STABLE_PROBES)
264
- return;
265
- }
266
- else {
267
- // A dropped probe means a restart (or the daemon isn't up yet) — start the
268
- // stability count over rather than counting accepts from before the gap.
269
- consecutive = 0;
270
- }
271
- await new Promise((resolve) => setTimeout(resolve, 100));
272
- }
273
- throw new Error(`Timeout waiting for browser service socket after ${Math.round(timeoutMs / 1000)}s (${endpoint}).`);
274
- }
275
- /** How long {@link stopBrowserService} waits for the endpoint to go quiet after
276
- * signalling the daemon-owned service transition. Bounded so a wedged daemon
277
- * fails loud instead of leaving the client hung on a control request. */
278
- const SERVICE_STOP_QUIESCE_MS = 5_000;
279
- /**
280
- * Stop only the daemon-hosted browser IPC service so a subsequent `start` comes
281
- * up clean (PHNX-3605). The short-lived browser client persists the
282
- * `browser-ipc` service off and asks the daemon to apply that transition over
283
- * its existing SIGHUP control path. It never stops or restarts the shared
284
- * supervisor, so routines, usage sync, secrets, and every sibling service keep
285
- * their process and in-flight work.
286
- *
287
- * Once the endpoint is quiet, a stale socket left by a hard-crashed browser
288
- * service is removed. If the daemon event loop itself is wedged and cannot
289
- * consume SIGHUP, the endpoint remains reachable and this fails loud with the
290
- * deliberate operator-owned whole-daemon restart command; the client never
291
- * escalates into that process-wide action itself.
292
- */
293
- export async function stopBrowserService() {
294
- const endpoint = getIpcEndpoint();
295
- const wasRunning = await probeBrowserService(endpoint);
296
- const daemonRunning = isDaemonRunning();
297
- setDaemonServiceEnabled('browser-ipc', false);
298
- if (daemonRunning && !signalDaemonReload()) {
299
- throw new Error(actionable('Browser service stop could not be delivered to the shared daemon.', `Endpoint: ${endpoint}`, 'Next: agents daemon restart (operator-owned recovery when SIGHUP is unavailable)'));
300
- }
301
- // Wait for the listener to actually release the endpoint. We must decide
302
- // reachability BEFORE touching the socket file: unlinking a path out from
303
- // under a live server makes new connects ENOENT (so it would *look* cleared)
304
- // while the server keeps running — the two-servers orphan the eviction
305
- // protocol exists to prevent. So a still-reachable endpoint fails loud here,
306
- // and only a genuinely dead one gets its stale file removed below.
307
- const deadline = Date.now() + SERVICE_STOP_QUIESCE_MS;
308
- let reachable = wasRunning;
309
- while (reachable && Date.now() < deadline) {
310
- await new Promise((resolve) => setTimeout(resolve, 100));
311
- reachable = await probeBrowserService(endpoint);
312
- }
313
- if (reachable) {
314
- throw new Error(actionable('Browser service is still reachable after its stop request — the shared daemon did not apply the transition.', `Endpoint: ${endpoint}`, 'Next: agents daemon restart (operator-owned recovery for a wedged daemon event loop)'));
315
- }
316
- // Nothing is listening now — clear the leftover socket file a hard-crashed
317
- // daemon left behind, so the next `start` binds clean instead of unlinking it
318
- // blindly. (Named pipes vanish with their owning process, so Windows has no
319
- // stale file to clear.)
320
- const socketCleared = IS_WINDOWS ? false : await clearDeadSocketFile(endpoint, getSocketPath());
321
- return { wasRunning, daemonRunning, socketCleared };
322
- }
323
- /**
324
- * Remove a leftover browser socket FILE, but only when nothing is listening on
325
- * it — re-probing liveness IMMEDIATELY before the unlink to close the TOCTOU
326
- * window (PHNX-3289 review). Between {@link stopBrowserService}'s quiesce loop
327
- * deciding the endpoint was unreachable and this unlink, a concurrent
328
- * `browser start` could bind a NEW listener on the same path; an unconditional
329
- * unlink would then delete a LIVE daemon's socket — the exact two-servers orphan
330
- * this function exists to prevent. A listener seen here means the wedge is already
331
- * resolved (a fresh daemon owns the path), so its socket is left intact. Returns
332
- * true only when a genuinely dead file was removed.
333
- */
334
- export async function clearDeadSocketFile(endpoint, socketPath) {
335
- if (!fs.existsSync(socketPath))
336
- return false;
337
- // A listener bound again since the quiesce loop → do not touch its socket.
338
- if (await probeBrowserService(endpoint))
339
- return false;
340
- try {
341
- fs.unlinkSync(socketPath);
342
- return true;
343
- }
344
- catch {
345
- // Raced with a fresh start that already claimed it — the goal (no stale
346
- // socket) still holds, so report cleared only when it is genuinely gone.
347
- return !fs.existsSync(socketPath);
348
- }
349
- }
350
- /**
351
- * One long-lived connection to the existing browser service. Requests are
352
- * serialized so the daemon's newline-delimited responses always map to the
353
- * caller that produced them, while the process and socket stay warm between
354
- * actions.
355
- */
356
- class BrowserIPCConnection {
357
- socket;
358
- buffer = '';
359
- pending;
360
- tail = Promise.resolve();
361
- closed = false;
362
- constructor(socket) {
363
- this.socket = socket;
364
- socket.on('data', (data) => this.handleData(data));
365
- socket.on('error', (error) => this.fail(error));
366
- socket.on('close', () => {
367
- this.fail(new Error('Browser service connection closed'));
368
- });
369
- }
370
- request(request) {
371
- const stamped = stampCallerIdentity(request);
372
- const result = this.tail.then(() => this.requestOnce(stamped));
373
- this.tail = result.then(() => undefined, () => undefined);
374
- return result;
375
- }
376
- async close() {
377
- await this.tail;
378
- if (this.closed || this.socket.destroyed)
379
- return;
380
- await new Promise((resolve) => {
381
- this.socket.once('close', resolve);
382
- this.socket.end();
383
- });
384
- }
385
- requestOnce(request) {
386
- if (this.closed || this.socket.destroyed) {
387
- return Promise.reject(new Error('Browser service connection is closed'));
388
- }
389
- return new Promise((resolve, reject) => {
390
- this.pending = { resolve, reject };
391
- this.socket.write(`${JSON.stringify(request)}\n`, (error) => {
392
- if (!error)
393
- return;
394
- if (this.pending?.reject === reject)
395
- this.pending = undefined;
396
- reject(error);
397
- });
398
- });
399
- }
400
- handleData(data) {
401
- this.buffer += data.toString();
402
- while (true) {
403
- const index = this.buffer.indexOf('\n');
404
- if (index === -1)
405
- return;
406
- const line = this.buffer.slice(0, index);
407
- this.buffer = this.buffer.slice(index + 1);
408
- if (!line.trim())
409
- continue;
410
- const pending = this.pending;
411
- if (!pending) {
412
- this.socket.destroy();
413
- this.fail(new Error('Browser service sent an unexpected response'));
414
- return;
415
- }
416
- this.pending = undefined;
417
- try {
418
- pending.resolve(JSON.parse(line));
419
- }
420
- catch {
421
- const error = new Error('Browser service returned invalid JSON');
422
- pending.reject(error);
423
- this.socket.destroy();
424
- this.fail(error);
425
- }
426
- }
427
- }
428
- fail(error) {
429
- if (this.closed)
430
- return;
431
- this.closed = true;
432
- const pending = this.pending;
433
- this.pending = undefined;
434
- pending?.reject(error);
435
- }
436
- }
437
- export class BrowserIPCServer {
438
- server = null;
439
- /** Filesystem identity of the POSIX binding this server created. */
440
- socketIdentity = null;
441
- service;
442
- /** Live client connections, so {@link stop} can end them rather than wait. */
443
- connections = new Set();
444
- /** In-flight stop, so a second SIGTERM awaits the same close, never skips it. */
445
- stopping = null;
446
- /**
447
- * Optional explicit socket path for TEST ISOLATION only (POSIX). When set, the
448
- * server binds (and unlinks a stale copy of) THIS path instead of the shared
449
- * production `browser.sock`, so a test never unlinks the user's live daemon
450
- * socket. Default (undefined) is unchanged. Not for production use.
451
- */
452
- socketPathOverride;
453
- constructor(service, socketPathOverride) {
454
- this.service = service;
455
- this.socketPathOverride = socketPathOverride;
456
- }
457
- async start() {
458
- const socketPath = this.socketPathOverride ?? getSocketPath();
459
- // On POSIX the endpoint IS the socket path; an override addresses the same
460
- // isolated file. (Windows keeps its derived pipe name; the override is
461
- // POSIX-only test machinery.)
462
- const endpoint = this.socketPathOverride ?? getIpcEndpoint();
463
- const socketDir = path.dirname(socketPath);
464
- fs.mkdirSync(socketDir, { recursive: true, mode: 0o700 });
465
- if (!IS_WINDOWS) {
466
- fs.chmodSync(socketDir, 0o700);
467
- // Remove a stale unix socket from a prior crash. (Named pipes are not
468
- // filesystem objects and vanish with their owning process.)
469
- if (fs.existsSync(socketPath)) {
470
- fs.unlinkSync(socketPath);
471
- }
472
- }
473
- this.server = net.createServer((socket) => {
474
- // `net.Server.close()` stops accepting but does not complete until every
475
- // EXISTING connection ends, and clients here hold one open on purpose
476
- // ("the process and socket stay warm between actions"). Tracking them is
477
- // what lets stop() end them deliberately instead of waiting out a timeout.
478
- this.connections.add(socket);
479
- socket.on('close', () => this.connections.delete(socket));
480
- let buffer = '';
481
- socket.on('data', async (data) => {
482
- buffer += data.toString();
483
- const lines = buffer.split('\n');
484
- buffer = lines.pop() || '';
485
- for (const line of lines) {
486
- if (!line.trim())
487
- continue;
488
- try {
489
- const request = JSON.parse(line);
490
- const response = await this.handleRequest(request);
491
- socket.write(JSON.stringify(response) + '\n');
492
- }
493
- catch (err) {
494
- const error = err instanceof Error ? err.message : 'Unknown error';
495
- socket.write(JSON.stringify({ ok: false, error }) + '\n');
496
- }
497
- }
498
- });
499
- socket.on('error', () => {
500
- // Client disconnected
501
- });
502
- });
503
- return new Promise((resolve, reject) => {
504
- if (IS_WINDOWS) {
505
- // Windows named pipe: no umask/chmod — filesystem perms don't apply and
506
- // pipe ACLs default to the creating user.
507
- this.server.listen(endpoint, () => resolve());
508
- this.server.on('error', (err) => reject(err));
509
- return;
510
- }
511
- // Lock down the browser socket dir before opening the socket; on macOS
512
- // the parent dir is the real local-user boundary for AF_UNIX sockets.
513
- const prevUmask = process.umask(0o077);
514
- let restored = false;
515
- const restoreUmask = () => {
516
- if (restored)
517
- return;
518
- restored = true;
519
- process.umask(prevUmask);
520
- };
521
- this.server.listen(socketPath, () => {
522
- try {
523
- fs.chmodSync(socketPath, 0o600);
524
- this.socketIdentity = readSocketIdentity(socketPath);
525
- resolve();
526
- }
527
- catch (err) {
528
- reject(err);
529
- }
530
- finally {
531
- restoreUmask();
532
- }
533
- });
534
- this.server.on('error', (err) => {
535
- restoreUmask();
536
- reject(err);
537
- });
538
- });
539
- }
540
- /**
541
- * Stop accepting, release the binding, and only then resolve.
542
- *
543
- * RUSH-2421: this used to call `server.close()` and move on. `close()` is
544
- * asynchronous — it stops accepting immediately but the binding is not
545
- * actually released until the `'close'` event fires — so `stop()` resolved
546
- * while the socket (or named pipe) was still held. The daemon's
547
- * `handleShutdown` awaits this before it exits, and a successor's
548
- * `claimDaemonInstance` waits for that exit as its proof the predecessor's
549
- * resources are free (SING-11). Resolving early made that proof false: the
550
- * newcomer could bind before the incumbent's binding was gone, which is the
551
- * two-servers-on-one-socket orphan the eviction protocol exists to prevent.
552
- *
553
- * The first version of this waited out a timeout instead, and that made every
554
- * graceful daemon stop escalate to `killTree`: `close()` does not complete
555
- * while a connection is open, browser clients hold one open on purpose, and
556
- * the timeout was set equal to the daemon's own SIGTERM grace window
557
- * (`STOP_GRACE_MS`) — so `handleShutdown` was still inside `stop()` when
558
- * `stopDaemon` gave up waiting and killed it. So we END the connections
559
- * rather than wait for them: `close()` stops accepting, destroying the
560
- * tracked sockets lets it complete, and the timeout is only a backstop for a
561
- * socket that will not die, deliberately far below `STOP_GRACE_MS`.
562
- */
563
- async stop() {
564
- // A second SIGTERM must await the SAME stop, not skip past it because
565
- // `this.server` was already nulled by the first.
566
- if (!this.stopping)
567
- this.stopping = this.doStop();
568
- return this.stopping;
569
- }
570
- async doStop() {
571
- const socketPath = getSocketPath();
572
- const owned = this.socketIdentity;
573
- let preservedReplacement = null;
574
- if (!IS_WINDOWS && owned && readSocketIdentity(socketPath) && !isSameSocket(socketPath, owned)) {
575
- // Node/libuv remembers the pathname passed to listen() and unlinks that
576
- // pathname during server.close(). If a successor has already replaced the
577
- // directory entry, an inode check AFTER close is too late: libuv has
578
- // deleted the successor. Move the replacement aside for the close and put
579
- // it back before returning. The listening socket stays live across rename.
580
- preservedReplacement = `${socketPath}.preserved-${process.pid}-${Date.now()}`;
581
- fs.renameSync(socketPath, preservedReplacement);
582
- }
583
- try {
584
- await this.closeServer();
585
- }
586
- finally {
587
- if (preservedReplacement && fs.existsSync(preservedReplacement) && !fs.existsSync(socketPath)) {
588
- fs.renameSync(preservedReplacement, socketPath);
589
- }
590
- }
591
- if (!IS_WINDOWS) {
592
- this.socketIdentity = null;
593
- if (owned && isSameSocket(socketPath, owned)) {
594
- fs.unlinkSync(socketPath);
595
- }
596
- }
597
- await this.service.shutdown();
598
- }
599
- /** Release the listening binding, resolving only once it is genuinely gone. */
600
- closeServer() {
601
- const server = this.server;
602
- this.server = null;
603
- if (!server)
604
- return Promise.resolve();
605
- return new Promise((resolve) => {
606
- const done = () => { clearTimeout(timer); resolve(); };
607
- // Backstop only, for a socket that refuses to die. Far below the daemon's
608
- // SIGTERM grace window so a slow close can never be what gets the daemon
609
- // killed — the whole point is that the graceful path stays graceful.
610
- const timer = setTimeout(done, IPC_CLOSE_TIMEOUT_MS);
611
- // `close()` reports an error when the server was never listening —
612
- // nothing to release, so that is a completed close, not a failure.
613
- server.close(() => done());
614
- // `close()` waits for existing connections; ending them is what lets it
615
- // finish now instead of at the timeout.
616
- for (const socket of this.connections)
617
- socket.destroy();
618
- this.connections.clear();
619
- });
620
- }
621
- /**
622
- * Resolve `request.task` from caller identity when omitted. Page verbs may
623
- * create; done/stop never create. Returns an error response to short-circuit
624
- * the action, or undefined when the request is ready (task filled in).
625
- */
626
- async bindTask(request) {
627
- const createIfMissing = PAGE_CREATE_VERBS.has(request.action);
628
- const isClose = CLOSE_VERBS.has(request.action);
629
- // NEVER trust these off the wire — they are server-derived bind state written
630
- // BELOW from the resolver's own result. A client that pre-set `firstOpen` in
631
- // its request JSON could otherwise steer navigate/tab-add to short-circuit
632
- // onto an arbitrary tab id / forged created flag (or forge a picked-device
633
- // hint). Clear before binding so only this handler's values survive (PHNX-2399).
634
- delete request.firstOpen;
635
- delete request.picked;
636
- // stop with --profile only (no task) is handled by the stop case itself.
637
- if (request.action === 'stop' && request.profile && !request.task)
638
- return undefined;
639
- try {
640
- const resolved = await this.service.resolveOrCreateTask({
641
- task: request.task,
642
- profile: request.profile,
643
- actor: request.actor,
644
- launchId: request.launchId,
645
- sessionId: request.sessionId,
646
- fleetRemote: request.fleetRemote,
647
- createIfMissing,
648
- title: request.title,
649
- url: request.url,
650
- });
651
- if (!resolved) {
652
- if (isClose) {
653
- return { ok: true, message: 'nothing to close' };
654
- }
655
- return {
656
- ok: false,
657
- error: actionable('No browser task for this session.', 'Next: agents browser navigate <url> | agents browser start'),
658
- };
659
- }
660
- request.task = resolved.task.name;
661
- if (resolved.created && resolved.picked) {
662
- request.picked = resolved.picked;
663
- }
664
- // The implicit create already opened request.url on this tab. Carry the
665
- // ACTUAL page result so the navigate/tab-add handler reports that first
666
- // open honestly (created/refreshed as it happened) instead of executing
667
- // the URL a second time (PHNX-2399).
668
- if (resolved.created && resolved.firstOpen) {
669
- request.firstOpen = resolved.firstOpen;
670
- }
671
- return undefined;
672
- }
673
- catch (err) {
674
- return { ok: false, error: err instanceof Error ? err.message : String(err) };
675
- }
676
- }
677
- async handleRequest(request) {
678
- if (request.action === 'upload-stage') {
679
- const source = request.source;
680
- if (!source) {
681
- return { ok: false, error: 'Source required' };
682
- }
683
- const result = this.service.stageUpload(source);
684
- return { ok: true, path: result.path };
685
- }
686
- // Task-scoped actions: resolve identity / create / refuse once here.
687
- if (PAGE_RESOLVE_VERBS.has(request.action) || CLOSE_VERBS.has(request.action)) {
688
- const early = await this.bindTask(request);
689
- if (early)
690
- return early;
691
- }
692
- switch (request.action) {
693
- case 'version': {
694
- return { ok: true, version: getCliVersion() };
695
- }
696
- // On-demand self-update, triggered by a client that noticed version
697
- // skew (reconcileDaemonVersion below). DECOUPLED from the install
698
- // (PHNX-3605): this handler MUST NOT await the check→download→install→
699
- // verify, because it sits on the path of EVERY version-skewed
700
- // `agents browser <verb>` (reconcileDaemonVersion → prepareIPC → here), so
701
- // awaiting the whole thing would synchronously stall an ordinary browser
702
- // verb for tens of seconds — worst case ~15 min — behind daemon
703
- // housekeeping, exactly the client-stall PHNX-3605 exists to prevent.
704
- // Instead: kick the self-update off in the BACKGROUND (bounded, and
705
- // sharing the module-level in-flight guard so two triggers can't race a
706
- // second install) and respond IMMEDIATELY that it was accepted. The daemon
707
- // does install→verify→exit(0) on its own; the OS supervisor relaunches it
708
- // and the browser reconnects. scheduleSelfUpdateExit (fired from the
709
- // background continuation on a verified update) still delays the exit so
710
- // this response has flushed to the socket first, and serializes against
711
- // the periodic tick's own exit.
712
- case 'request-self-update': {
713
- // Instant, network-free decline gate FIRST (dev build / shadowed
714
- // install), so a version-skewed browser client gets the PHNX-3605
715
- // "nothing changed, not evicting" advisory immediately rather than a
716
- // "triggered" it would never act on — reconcileDaemonVersion keys the
717
- // advisory on !selfUpdateTriggered. Only a genuinely-eligible install is
718
- // backgrounded below; the slow registry/download/install/verify never
719
- // runs on this response path.
720
- const declineReason = selfUpdateSyncDeclineReason();
721
- if (declineReason) {
722
- return { ok: true, selfUpdateTriggered: false, reason: declineReason };
723
- }
724
- void triggerSelfUpdateInBackground({ log: daemonLog })
725
- .then((outcome) => {
726
- if (outcome.updated)
727
- scheduleSelfUpdateExit();
728
- })
729
- .catch((err) => {
730
- daemonLog('ERROR', `self-update: background trigger failed: ${err instanceof Error ? err.message : String(err)}`);
731
- });
732
- return { ok: true, selfUpdateTriggered: true };
733
- }
734
- case 'show': {
735
- // Task-less by design — see BrowserService.showUrl. Absent from both
736
- // PAGE_CREATE_VERBS and PAGE_RESOLVE_VERBS, so bindTask never ran above
737
- // and this request carries no task.
738
- if (!request.url) {
739
- return { ok: false, error: actionable('URL required.', 'Next: agents browser navigate <url>') };
740
- }
741
- if (!request.profile) {
742
- return { ok: false, error: actionable('Profile required.', 'Next: agents browser profiles list') };
743
- }
744
- const shown = await this.service.showUrl(request.profile, request.url, {
745
- fleetRemote: request.fleetRemote,
746
- actor: request.actor,
747
- });
748
- return { ok: true, tabId: shown.tabId, message: shown.picked };
749
- }
750
- case 'start': {
751
- if (!request.profile) {
752
- return { ok: false, error: actionable('Profile required.', 'Next: agents browser profiles list') };
753
- }
754
- const result = await this.service.start(request.profile, {
755
- taskName: request.taskName,
756
- url: request.url,
757
- endpointName: request.endpoint,
758
- skipDomainSkill: request.skipDomainSkill,
759
- fresh: request.fresh,
760
- actor: request.actor,
761
- launchId: request.launchId,
762
- sessionId: request.sessionId,
763
- fleetRemote: request.fleetRemote,
764
- title: request.title,
765
- });
766
- // `reused` is task-level (a same-name retry matched an existing task);
767
- // `created`/`refreshed` describe the actual PAGE operation, carried from
768
- // `firstOpen`. A no-URL retry is `reused: true` with created/refreshed
769
- // both false — no reload happened, so it must NOT read as refreshed. A
770
- // pure task reuse with no page refresh gets the "Reused existing task"
771
- // note; a same-URL reopen gets its own "Tab already open—refreshed" note
772
- // from `firstOpen.message` (PHNX-2399).
773
- const startReuseNote = result.reused && !result.firstOpen?.refreshed && !result.firstOpen?.created
774
- ? 'Reused existing task'
775
- : undefined;
776
- return {
777
- ok: true,
778
- task: result.name,
779
- tabId: result.tabId,
780
- windowTargetId: result.windowId,
781
- skill: result.skill,
782
- reused: result.reused,
783
- created: result.firstOpen?.created,
784
- refreshed: result.firstOpen?.refreshed,
785
- message: [result.picked, result.firstOpen?.message, startReuseNote].filter(Boolean).join(' · ') || undefined,
786
- };
787
- }
788
- // The out-of-process seam onto the abandoned-task reaper: the daemon
789
- // owns the live BrowserService, so a CLI verb (`agents browser prune`) can
790
- // only reach `reapAbandoned` through IPC. The daemon's own periodic tick
791
- // calls the service method directly.
792
- case 'gc': {
793
- // 0 is the "disable idle reaping" signal (browser.task-idle-minutes),
794
- // not a zero-ms window — translate it to reapAbandoned's `null` rather
795
- // than let it hit the idleMs<=0 guard. Omitting the flag falls back to
796
- // this box's own configured default (or 30 minutes when unset).
797
- const idleMs = request.idleMinutes !== undefined
798
- ? (request.idleMinutes === 0 ? null : request.idleMinutes * 60_000)
799
- : await resolveBrowserTaskIdleMs();
800
- const reaped = await this.service.reapAbandoned({ idleMs, dryRun: request.dryRun });
801
- return { ok: true, reaped };
802
- }
803
- case 'done': {
804
- if (!request.task) {
805
- return { ok: true, message: 'nothing to close' };
806
- }
807
- const result = await this.service.done(request.task);
808
- return {
809
- ok: result.ok,
810
- error: result.ok
811
- ? undefined
812
- : actionable(`Task "${request.task}" not found.`, 'Next: agents browser status'),
813
- task: result.ok ? request.task : undefined,
814
- };
815
- }
816
- case 'stop': {
817
- if (request.task) {
818
- const result = await this.service.stop(request.task);
819
- return {
820
- ok: result.ok,
821
- error: result.ok
822
- ? undefined
823
- : actionable(`Task "${request.task}" not found.`, 'Next: agents browser status'),
824
- task: result.ok ? request.task : undefined,
825
- };
826
- }
827
- if (request.profile) {
828
- try {
829
- await this.service.stopProfile(request.profile, {
830
- fleetRemote: request.fleetRemote,
831
- actor: request.actor,
832
- });
833
- }
834
- catch (err) {
835
- return { ok: false, error: err instanceof Error ? err.message : String(err) };
836
- }
837
- return { ok: true };
838
- }
839
- return {
840
- ok: false,
841
- error: actionable('Task or profile required.', 'Next: agents browser status | agents browser stop --profile <name>'),
842
- };
843
- }
844
- case 'status': {
845
- const profiles = await this.service.status(request.profile);
846
- return { ok: true, profiles };
847
- }
848
- case 'history': {
849
- const history = await this.service.getHistory(request.limit ?? 10);
850
- return { ok: true, history };
851
- }
852
- case 'navigate': {
853
- if (!request.url) {
854
- return {
855
- ok: false,
856
- error: actionable('URL required.', 'Next: agents browser navigate <url>'),
857
- };
858
- }
859
- if (!request.task) {
860
- return {
861
- ok: false,
862
- error: actionable('No browser task for this session.', 'Next: agents browser navigate <url> | agents browser start'),
863
- };
864
- }
865
- // The implicit create already opened this URL on this tab. Report the
866
- // ACTUAL first-open result (adopt → created:false, fresh → created:true)
867
- // instead of re-navigating it or assuming a create (PHNX-2399).
868
- const firstOpen = request.firstOpen;
869
- const picked = request.picked;
870
- if (firstOpen) {
871
- return {
872
- ok: true,
873
- tabId: firstOpen.tabId,
874
- task: request.task,
875
- created: firstOpen.created,
876
- refreshed: firstOpen.refreshed,
877
- message: [picked, firstOpen.message].filter(Boolean).join(' · ') || undefined,
878
- };
879
- }
880
- const result = await this.service.navigate(request.task, request.url, request.profile);
881
- return {
882
- ok: true,
883
- tabId: result.tabId,
884
- task: request.task,
885
- created: result.created,
886
- refreshed: result.refreshed,
887
- // A picked-device hint and a reopen note are distinct signals; combine
888
- // them so a first navigate on a remote-picked device still surfaces the
889
- // pick, and a plain reopen surfaces the refresh note.
890
- message: [picked, result.message].filter(Boolean).join(' · ') || undefined,
891
- };
892
- }
893
- case 'tab-add': {
894
- if (!request.task || !request.url) {
895
- return { ok: false, error: 'Task and URL required' };
896
- }
897
- const tabAddPicked = request.picked;
898
- const firstOpen = request.firstOpen;
899
- if (firstOpen) {
900
- return {
901
- ok: true,
902
- tabId: firstOpen.tabId,
903
- task: request.task,
904
- created: firstOpen.created,
905
- refreshed: firstOpen.refreshed,
906
- message: [tabAddPicked, firstOpen.message].filter(Boolean).join(' · ') || undefined,
907
- };
908
- }
909
- const result = await this.service.tabAdd(request.task, request.url, request.profile);
910
- return {
911
- ok: true,
912
- tabId: result.tabId,
913
- task: request.task,
914
- created: result.created,
915
- refreshed: result.refreshed,
916
- // Keep the picked-device hint AND any reopen note (same shape as navigate).
917
- message: [tabAddPicked, result.message].filter(Boolean).join(' · ') || undefined,
918
- };
919
- }
920
- case 'tab-focus': {
921
- if (!request.task || !request.tabId) {
922
- return { ok: false, error: 'Task and tabId required' };
923
- }
924
- const result = await this.service.tabFocus(request.task, request.tabId);
925
- return { ok: true, tabId: result.tabId };
926
- }
927
- case 'tab-close': {
928
- if (!request.task) {
929
- return { ok: false, error: 'Task required' };
930
- }
931
- await this.service.tabClose(request.task, request.tabId);
932
- return { ok: true };
933
- }
934
- case 'tab-list': {
935
- if (!request.task) {
936
- return { ok: false, error: 'Task required' };
937
- }
938
- if (request.all) {
939
- const tabs = await this.service.profileTabs(request.task);
940
- return { ok: true, tabs: tabs.map(t => ({ id: t.id, url: t.url, title: t.title, current: t.current, task: t.task })) };
941
- }
942
- const tabs = await this.service.tabList(request.task);
943
- // Carry `current` through — it is the one field a caller needs to show
944
- // which tab URL-less verbs act on, and dropping it here left `agents
945
- // browser tabs` unable to mark the selection (PHNX-2399).
946
- return { ok: true, tabs: tabs.map(t => ({ id: t.id, url: t.url, title: t.title, current: t.current, task: request.task })) };
947
- }
948
- case 'evaluate': {
949
- if (!request.task || !request.expr) {
950
- return { ok: false, error: 'Task and expression required' };
951
- }
952
- const result = await this.service.evaluate(request.task, request.tabId, request.expr);
953
- return { ok: true, result };
954
- }
955
- case 'record-start': {
956
- if (!request.task)
957
- return { ok: false, error: 'Task required' };
958
- try {
959
- const r = await this.service.recordStart(request.task, request.tabId, {
960
- fps: request.fps,
961
- duration: request.duration,
962
- maxMb: request.maxMb,
963
- });
964
- return { ok: true, path: r.path, fps: r.fps, durationCapSec: r.durationCapSec, maxMb: r.maxMb };
965
- }
966
- catch (err) {
967
- return { ok: false, error: err instanceof Error ? err.message : String(err) };
968
- }
969
- }
970
- case 'record-stop': {
971
- if (!request.task)
972
- return { ok: false, error: 'Task required' };
973
- try {
974
- const r = await this.service.recordStop(request.task);
975
- return { ok: true, path: r.path, bytes: r.bytes, durationMs: r.durationMs, stopReason: r.reason };
976
- }
977
- catch (err) {
978
- return { ok: false, error: err instanceof Error ? err.message : String(err) };
979
- }
980
- }
981
- case 'screenshot': {
982
- if (!request.task) {
983
- return {
984
- ok: false,
985
- error: actionable('No browser task for this session.', 'Next: agents browser navigate <url> | agents browser start'),
986
- };
987
- }
988
- const shot = await this.service.screenshot(request.task, request.tabId, request.path, request.quality);
989
- return {
990
- ok: true,
991
- path: shot.path,
992
- bytes: shot.bytes,
993
- width: shot.width,
994
- height: shot.height,
995
- task: request.task,
996
- };
997
- }
998
- case 'pdf': {
999
- if (!request.task) {
1000
- return { ok: false, error: 'Task required' };
1001
- }
1002
- const doc = await this.service.printToPdf(request.task, request.tabId, request.path);
1003
- return { ok: true, path: doc.path, bytes: doc.bytes };
1004
- }
1005
- case 'refs': {
1006
- if (!request.task) {
1007
- return { ok: false, error: 'Task required' };
1008
- }
1009
- const { refs, nodeMap } = await this.service.refs(request.task, request.tabId, {
1010
- interactive: request.interactive ?? true,
1011
- limit: request.limit ?? 500,
1012
- });
1013
- const nodes = Array.from(nodeMap.values()).map(n => {
1014
- const entry = { ref: n.ref, role: n.role, name: n.name, attrs: n.attrs };
1015
- if (n.editor !== undefined)
1016
- entry.editor = n.editor;
1017
- return entry;
1018
- });
1019
- return { ok: true, refs, nodes };
1020
- }
1021
- case 'click': {
1022
- if (!request.task) {
1023
- return { ok: false, error: 'Task required' };
1024
- }
1025
- // Coordinate click (`--at X,Y`) bypasses ref resolution entirely.
1026
- if (request.atX !== undefined && request.atY !== undefined) {
1027
- await this.service.clickAt(request.task, request.atX, request.atY, request.tabId);
1028
- return { ok: true };
1029
- }
1030
- if (request.ref === undefined) {
1031
- return { ok: false, error: 'Task and ref (or --at X,Y) required' };
1032
- }
1033
- const { healed } = await this.service.click(request.task, request.ref, request.tabId);
1034
- if (healed) {
1035
- return {
1036
- ok: true,
1037
- message: `self-healed ref ${healed.from} -> ${healed.to} (${healed.role} "${healed.name}")`,
1038
- };
1039
- }
1040
- return { ok: true };
1041
- }
1042
- case 'type': {
1043
- if (!request.task || request.ref === undefined || !request.text) {
1044
- return { ok: false, error: 'Task, ref, and text required' };
1045
- }
1046
- await this.service.type(request.task, request.ref, request.text, request.tabId, request.clear);
1047
- return { ok: true };
1048
- }
1049
- case 'press': {
1050
- if (!request.task || !request.key) {
1051
- return { ok: false, error: 'Task and key required' };
1052
- }
1053
- await this.service.press(request.task, request.key, request.tabId);
1054
- return { ok: true };
1055
- }
1056
- case 'hover': {
1057
- if (!request.task || request.ref === undefined) {
1058
- return { ok: false, error: 'Task and ref required' };
1059
- }
1060
- await this.service.hover(request.task, request.ref, request.tabId);
1061
- return { ok: true };
1062
- }
1063
- case 'scroll': {
1064
- if (!request.task) {
1065
- return { ok: false, error: 'Task required' };
1066
- }
1067
- await this.service.scroll(request.task, request.scrollX ?? 0, request.scrollY ?? 0, request.scrollAtX, request.scrollAtY, request.tabId);
1068
- return { ok: true };
1069
- }
1070
- case 'set-viewport': {
1071
- if (!request.task || !request.width || !request.height) {
1072
- return { ok: false, error: 'Task, width, and height required' };
1073
- }
1074
- await this.service.setViewport(request.task, request.width, request.height, {
1075
- mobile: request.mobile,
1076
- deviceScaleFactor: request.deviceScaleFactor,
1077
- tabHint: request.tabId,
1078
- });
1079
- return { ok: true };
1080
- }
1081
- case 'set-device': {
1082
- if (!request.task || !request.deviceName) {
1083
- return { ok: false, error: 'Task and device name required' };
1084
- }
1085
- await this.service.setDevice(request.task, request.deviceName, request.tabId);
1086
- return { ok: true };
1087
- }
1088
- case 'console': {
1089
- if (!request.task) {
1090
- return { ok: false, error: 'Task required' };
1091
- }
1092
- const logs = await this.service.getConsoleLogs(request.task, {
1093
- level: request.level,
1094
- clear: request.clear,
1095
- tabHint: request.tabId,
1096
- });
1097
- return { ok: true, logs };
1098
- }
1099
- case 'errors': {
1100
- if (!request.task) {
1101
- return { ok: false, error: 'Task required' };
1102
- }
1103
- const errors = await this.service.getErrors(request.task, {
1104
- clear: request.clear,
1105
- tabHint: request.tabId,
1106
- });
1107
- return { ok: true, errors };
1108
- }
1109
- case 'requests': {
1110
- if (!request.task) {
1111
- return { ok: false, error: 'Task required' };
1112
- }
1113
- const requests = await this.service.getNetworkRequests(request.task, {
1114
- filter: request.filter,
1115
- clear: request.clear,
1116
- tabHint: request.tabId,
1117
- });
1118
- return { ok: true, requests };
1119
- }
1120
- case 'response-body': {
1121
- if (!request.task || !request.urlPattern) {
1122
- return { ok: false, error: 'Task and URL pattern required' };
1123
- }
1124
- const body = await this.service.getResponseBody(request.task, request.urlPattern, {
1125
- timeout: request.timeout,
1126
- maxChars: request.maxChars,
1127
- tabHint: request.tabId,
1128
- });
1129
- return { ok: true, body };
1130
- }
1131
- case 'wait': {
1132
- if (!request.task || !request.waitType || request.waitValue === undefined) {
1133
- return { ok: false, error: 'Task, wait type, and wait value required' };
1134
- }
1135
- await this.service.wait(request.task, request.waitType, request.waitValue, {
1136
- timeout: request.timeout,
1137
- tabHint: request.tabId,
1138
- });
1139
- return { ok: true };
1140
- }
1141
- case 'set-download-path': {
1142
- if (!request.task) {
1143
- return { ok: false, error: 'Task required' };
1144
- }
1145
- // downloadPath optional: omitted → service defaults to the profile's downloads dir.
1146
- const resolved = await this.service.setDownloadPath(request.task, request.downloadPath, request.tabId);
1147
- return { ok: true, downloadPath: resolved };
1148
- }
1149
- case 'wait-download': {
1150
- if (!request.task) {
1151
- return { ok: false, error: 'Task required' };
1152
- }
1153
- const downloadPath = await this.service.waitForDownload(request.task, request.timeout);
1154
- return { ok: true, downloadPath };
1155
- }
1156
- case 'getAppLogs': {
1157
- if (!request.task) {
1158
- return { ok: false, error: 'Task required' };
1159
- }
1160
- const appLogs = await this.service.getAppLogs(request.task, {
1161
- lines: request.lines,
1162
- level: request.appLevel,
1163
- filter: request.filter,
1164
- message: request.message,
1165
- source: request.source,
1166
- since: request.since,
1167
- until: request.until,
1168
- });
1169
- return { ok: true, appLogs };
1170
- }
1171
- case 'upload': {
1172
- if (!request.task || !request.files || request.files.length === 0) {
1173
- return { ok: false, error: 'Task and at least one file required' };
1174
- }
1175
- const result = await this.service.upload(request.task, request.files, {
1176
- ref: request.ref,
1177
- trigger: request.trigger,
1178
- mode: request.uploadMode,
1179
- tabHint: request.tabId,
1180
- timeout: request.timeout,
1181
- });
1182
- return { ok: true, uploadMode: result.mode };
1183
- }
1184
- default:
1185
- return { ok: false, error: `Unknown action: ${request.action}` };
1186
- }
1187
- }
1188
- }
1189
- let versionReconciledThisProcess = false;
1190
- /**
1191
- * Decide whether a running daemon is stale enough to name an operator refresh.
1192
- *
1193
- * FORWARD ONLY: recommend a refresh when the daemon is *older* than this CLI.
1194
- * An older CLI rides a newer daemon without noise. A client never performs the
1195
- * refresh itself — two installs sharing one daemon dir (keyed off $HOME) must
1196
- * not flap the daemon or interrupt one another's hosted services.
1197
- *
1198
- * `undefined`/`'unknown'` means the daemon is too old to answer the `version`
1199
- * action reliably — don't recommend an action on that ambiguous signal.
1200
- *
1201
- * When numeric compare cannot order two distinct strings (e.g. two
1202
- * `0.0.0-dev.*` builds), surface the mismatch without deciding either client
1203
- * owns the shared process.
1204
- */
1205
- export function shouldRecommendDaemonRefresh(daemonVersion, clientVersion) {
1206
- if (!daemonVersion || daemonVersion === 'unknown')
1207
- return false;
1208
- if (daemonVersion === clientVersion)
1209
- return false;
1210
- const cmp = compareVersions(daemonVersion, clientVersion);
1211
- if (cmp < 0)
1212
- return true; // daemon older than client → upgrade forward
1213
- if (cmp > 0)
1214
- return false; // daemon newer → older CLI rides it
1215
- // cmp === 0 but strings differ (unorderable tails like 0.0.0-dev.abc)
1216
- return true;
1217
- }
1218
- /**
1219
- * Reconcile the running daemon's version with ours without evicting it. If the
1220
- * daemon is serving stale code, ask the daemon to run its OWN self-update path
1221
- * (`request-self-update`, same fail-closed check/install/verify/exit
1222
- * SelfUpdateService's periodic tick runs, self-update-service.ts) rather than
1223
- * telling a human to run `agents daemon restart` by hand. A browser client
1224
- * still owns neither the shared supervisor nor its sibling services, so
1225
- * version skew can never be permission for THIS client to stop or restart
1226
- * that process directly (PHNX-3605 holds) — it only asks the daemon to run a
1227
- * path the daemon already runs on its own schedule, which only exits once the
1228
- * daemon itself has verified the new version is good. If self-update declines
1229
- * (dev build, shadowed install, already current, or a real failure) this
1230
- * degrades to the same "nothing changed" outcome PHNX-3605 shipped.
1231
- */
1232
- export async function reconcileDaemonVersion() {
1233
- if (versionReconciledThisProcess)
1234
- return;
1235
- versionReconciledThisProcess = true;
1236
- let daemon;
1237
- try {
1238
- const resp = await sendRawIPCRequest({ action: 'version' }, { autoStartDaemon: false });
1239
- daemon = resp.version;
1240
- }
1241
- catch {
1242
- // Daemon unreachable or too old to speak 'version' — leave it alone.
1243
- return;
1244
- }
1245
- const client = getCliVersion();
1246
- if (!shouldRecommendDaemonRefresh(daemon, client))
1247
- return;
1248
- process.stderr.write(`\nBrowser service is running on shared daemon ${daemon}, while this CLI is ${client}. `
1249
- + 'Asking the daemon to self-update in the background (verify-then-exit; the OS supervisor relaunches it onto the new code)...\n');
1250
- try {
1251
- // SHORT timeout — the handler is decoupled from the install now (it kicks
1252
- // the self-update off in the background and answers at once), so this only
1253
- // waits for the daemon to CONFIRM it accepted the trigger, not for the
1254
- // install to finish. It must stay short so the real browser verb that
1255
- // triggered this reconcile proceeds promptly; a wedged/gone daemon degrades
1256
- // to the same advisory PHNX-3605 shipped.
1257
- const resp = await sendRawIPCRequest({ action: 'request-self-update' }, { autoStartDaemon: false, timeoutMs: DAEMON_SELF_UPDATE_TRIGGER_TIMEOUT_MS });
1258
- if (resp.selfUpdateTriggered) {
1259
- process.stderr.write('Daemon self-update requested — it verifies a newer version in the background and restarts if one is available. '
1260
- + 'Continuing without waiting.\n\n');
1261
- }
1262
- else {
1263
- // Two shapes reach here: a CURRENT daemon that ran the instant decline
1264
- // gate (dev build / shadowed install) and answered {selfUpdateTriggered:
1265
- // false, reason}, and an OLDER daemon that predates the field entirely and
1266
- // answered without it (no reason). Both degrade to the same non-evicting
1267
- // advisory PHNX-3605 shipped; only the lead sentence differs.
1268
- const declineDetail = resp.reason
1269
- ? `Daemon self-update declined (${resp.reason}). `
1270
- : 'Daemon did not accept the self-update request. ';
1271
- process.stderr.write(declineDetail
1272
- + 'Continuing without evicting the daemon or its other services. '
1273
- + 'To load current daemon code when it is safe to interrupt every hosted service: agents daemon restart\n\n');
1274
- }
1275
- }
1276
- catch (err) {
1277
- process.stderr.write(`Could not reach the daemon to request a self-update (${err instanceof Error ? err.message : String(err)}). `
1278
- + 'To load current daemon code when it is safe to interrupt every hosted service: agents daemon restart\n\n');
1279
- }
1280
- }
1281
- /** Test-only: `reconcileDaemonVersion` runs at most once per real process — this clears that latch so a test file can drive more than one branch. */
1282
- export function resetVersionReconciliationForTest() {
1283
- versionReconciledThisProcess = false;
1284
- }
1285
- export async function sendIPCRequest(request, opts = {}) {
1286
- // Stamp caller identity ONCE here so the 28+ call sites don't each have to.
1287
- // The daemon cannot resolve the caller's actor/session itself.
1288
- const stamped = stampCallerIdentity(request);
1289
- return sendRawIPCRequest(stamped, opts);
1290
- }
1291
- /**
1292
- * Fill actor / launchId / sessionId from the calling process when the request
1293
- * left them blank. Explicit values on the request always win.
1294
- */
1295
- export function stampCallerIdentity(request, env = process.env) {
1296
- // The consent marker is stamped FIRST, above the identity early-return: a
1297
- // request that already carries a full identity (every streamed request does)
1298
- // would otherwise skip stamping entirely and reach the daemon unmarked.
1299
- // OR-ed, never overwritten — a client may assert the marker but must not be
1300
- // able to clear one this environment sets.
1301
- const fleetRemote = isFleetRemoteInvocation(env) || request.fleetRemote === true;
1302
- const marked = request.fleetRemote === fleetRemote ? request : { ...request, fleetRemote };
1303
- if (marked.actor && marked.launchId && marked.sessionId)
1304
- return marked;
1305
- const id = resolveCallerIdentity();
1306
- return {
1307
- ...marked,
1308
- actor: marked.actor ?? id.actor,
1309
- launchId: marked.launchId ?? id.launchId,
1310
- sessionId: marked.sessionId ?? id.sessionId,
1311
- };
1312
- }
1313
- /**
1314
- * Open one connection that can serve many browser operations. This uses the
1315
- * same daemon readiness and version-reconciliation path as sendIPCRequest;
1316
- * only the client process and IPC socket lifetime differ.
1317
- */
1318
- export async function connectBrowserIPC(opts = {}) {
1319
- const autoStartDaemon = opts.autoStartDaemon ?? true;
1320
- await prepareIPC('status', opts);
1321
- return new Promise((resolve, reject) => {
1322
- const socket = net.createConnection(getIpcEndpoint());
1323
- const onError = (error) => {
1324
- socket.destroy();
1325
- if (!autoStartDaemon && (error.code === 'ENOENT' || error.code === 'ECONNREFUSED')) {
1326
- reject(new BrowserServiceNotRunningError());
1327
- return;
1328
- }
1329
- reject(new Error(`IPC error: ${error.message}`));
1330
- };
1331
- socket.once('error', onError);
1332
- socket.once('connect', () => {
1333
- socket.off('error', onError);
1334
- resolve(new BrowserIPCConnection(socket));
1335
- });
1336
- });
1337
- }
1338
- /**
1339
- * Exported (rather than kept module-private) so `ipc.test.ts` can drive the
1340
- * `opts.timeoutMs` client-side deadline directly against a raw `net.Server`
1341
- * test double that never responds — the hermetic way to prove the timeout
1342
- * actually fires without needing a slow real IPC action to provoke it.
1343
- */
1344
- export async function sendRawIPCRequest(request, opts = {}) {
1345
- const endpoint = getIpcEndpoint();
1346
- const autoStartDaemon = opts.autoStartDaemon ?? true;
1347
- await prepareIPC(request.action, opts);
1348
- return new Promise((resolve, reject) => {
1349
- const socket = net.createConnection(endpoint);
1350
- let buffer = '';
1351
- let settled = false;
1352
- let deadline;
1353
- const settle = (fn) => {
1354
- if (settled)
1355
- return;
1356
- settled = true;
1357
- if (deadline)
1358
- clearTimeout(deadline);
1359
- fn();
1360
- };
1361
- if (opts.timeoutMs) {
1362
- deadline = setTimeout(() => {
1363
- settle(() => {
1364
- socket.destroy();
1365
- reject(new Error(`IPC request '${request.action}' timed out after ${opts.timeoutMs}ms`));
1366
- });
1367
- }, opts.timeoutMs);
1368
- }
1369
- socket.on('connect', () => {
1370
- socket.write(JSON.stringify(request) + '\n');
1371
- });
1372
- socket.on('data', (data) => {
1373
- buffer += data.toString();
1374
- const idx = buffer.indexOf('\n');
1375
- if (idx !== -1) {
1376
- const response = JSON.parse(buffer.slice(0, idx));
1377
- settle(() => {
1378
- socket.end();
1379
- resolve(response);
1380
- });
1381
- }
1382
- });
1383
- socket.on('error', (err) => {
1384
- settle(() => {
1385
- if (!autoStartDaemon && (err.code === 'ENOENT' || err.code === 'ECONNREFUSED')) {
1386
- reject(new BrowserServiceNotRunningError());
1387
- return;
1388
- }
1389
- reject(new Error(`IPC error: ${err.message}`));
1390
- });
1391
- });
1392
- socket.on('close', () => {
1393
- settle(() => {
1394
- if (!buffer.includes('\n')) {
1395
- reject(new Error('Connection closed before response'));
1396
- }
1397
- });
1398
- });
1399
- });
1400
- }
1401
- async function prepareIPC(action, opts) {
1402
- const socketPath = getSocketPath();
1403
- const autoStartDaemon = opts.autoStartDaemon ?? true;
1404
- if (!(await isBrowserServiceReachable())) {
1405
- if (!autoStartDaemon) {
1406
- throw new BrowserServiceNotRunningError();
1407
- }
1408
- const serviceEnabled = isDaemonServiceEnabled('browser-ipc');
1409
- if (!serviceEnabled)
1410
- setDaemonServiceEnabled('browser-ipc', true);
1411
- if (!IS_WINDOWS) {
1412
- await fs.promises.mkdir(path.dirname(socketPath), { recursive: true, mode: 0o700 });
1413
- await fs.promises.chmod(path.dirname(socketPath), 0o700);
1414
- }
1415
- if (isDaemonRunning()) {
1416
- // A stopped service is enabled by the config transition. An enabled but
1417
- // absent service is failed/parked, so request a supervised restart. Both
1418
- // actions are daemon-owned and in-flight-gated by ServiceSupervisor.
1419
- if (serviceEnabled)
1420
- queueDaemonServiceRestart('browser-ipc');
1421
- if (!signalDaemonReload()) {
1422
- throw new Error(actionable('Browser service is not running and its start request could not be delivered.', `Endpoint: ${getIpcEndpoint()}`, 'Next: agents daemon restart (operator-owned recovery when SIGHUP is unavailable)'));
1423
- }
1424
- }
1425
- else {
1426
- startDaemon();
1427
- }
1428
- if (!(await isBrowserServiceReachable())) {
1429
- await waitForBrowserService(socketPath);
1430
- }
1431
- if (!(await isBrowserServiceReachable())) {
1432
- throw new Error(actionable('Failed to start browser service.', `Log: ${getDaemonLogPath()}`, 'Next: agents doctor (checks for a second agents-cli install)'));
1433
- }
1434
- await new Promise((r) => setTimeout(r, 300));
1435
- }
1436
- // The socket accepts connections — but a unix-socket `connect` succeeds at the
1437
- // kernel level even when the daemon's event loop is blocked and never services
1438
- // the request (PHNX-3411). Require a real reply before treating the daemon as
1439
- // usable, so a wedged daemon fails LOUD with an accurate, actionable error
1440
- // instead of the confusing bare socket timeout / indefinite hang the browser
1441
- // verb would otherwise hit (including inside reconcileDaemonVersion's own
1442
- // version probe, which has no response timeout). Skipped for callers that opt
1443
- // out of auto-start — they want the clean BrowserServiceNotRunningError instead.
1444
- if (autoStartDaemon && !(await isBrowserServiceResponsive(getIpcEndpoint()))) {
1445
- throw new Error(actionable('Browser service is running but unresponsive — the shared daemon event loop is blocked, so it accepts the connection but never replies.', `Endpoint: ${getIpcEndpoint()}`, `Log: ${getDaemonLogPath()}`, 'Next: agents browser stop --service (stops only browser IPC; if SIGHUP cannot run, it names the operator-owned daemon recovery)'));
1446
- }
1447
- // Before serving a real request, make sure the daemon isn't running stale
1448
- // code. Skips the internal `version` probe (avoids recursion) and callers
1449
- // that opt out of auto-start. No-ops once reconciled or when versions match.
1450
- if (action !== 'version' && autoStartDaemon) {
1451
- await reconcileDaemonVersion();
1452
- }
1453
- }