@phnx-labs/agents-cli 1.22.66 → 1.22.68

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +7 -2
  3. package/dist/bootstrap.js +3 -3
  4. package/dist/commands/browser.js +46 -21
  5. package/dist/commands/daemon-test-harness.d.ts +1 -1
  6. package/dist/commands/daemon-test-harness.js +2 -2
  7. package/dist/commands/daemon.js +21 -22
  8. package/dist/commands/exec.js +40 -0
  9. package/dist/commands/monitors.js +2 -2
  10. package/dist/commands/repo.js +57 -19
  11. package/dist/commands/resume.d.ts +16 -0
  12. package/dist/commands/resume.js +41 -8
  13. package/dist/commands/routines.js +42 -21
  14. package/dist/commands/sessions-optimize.js +1 -1
  15. package/dist/commands/sessions.js +4 -11
  16. package/dist/commands/share.d.ts +5 -3
  17. package/dist/commands/share.js +33 -9
  18. package/dist/lib/accounting/account-pool-collect.d.ts +6 -4
  19. package/dist/lib/accounting/account-pool-collect.js +6 -4
  20. package/dist/lib/accounting/usage-ingest.js +4 -2
  21. package/dist/lib/accounting/usage-sync.d.ts +38 -93
  22. package/dist/lib/accounting/usage-sync.js +66 -210
  23. package/dist/lib/accounting/usage.d.ts +11 -3
  24. package/dist/lib/accounting/usage.js +23 -10
  25. package/dist/lib/auth-health.d.ts +8 -0
  26. package/dist/lib/auth-health.js +4 -4
  27. package/dist/lib/browser/caller-identity.d.ts +12 -0
  28. package/dist/lib/browser/caller-identity.js +19 -0
  29. package/dist/lib/browser/ipc.d.ts +37 -32
  30. package/dist/lib/browser/ipc.js +146 -94
  31. package/dist/lib/browser/task-index.d.ts +10 -2
  32. package/dist/lib/browser/task-index.js +22 -3
  33. package/dist/lib/daemon/account-state-daemon-service.d.ts +49 -9
  34. package/dist/lib/daemon/account-state-daemon-service.js +81 -18
  35. package/dist/lib/daemon/auth-sync-service.d.ts +4 -4
  36. package/dist/lib/daemon/auth-sync-service.js +17 -6
  37. package/dist/lib/daemon/catchup-service.d.ts +51 -0
  38. package/dist/lib/daemon/catchup-service.js +51 -0
  39. package/dist/lib/daemon/daemon.d.ts +12 -22
  40. package/dist/lib/daemon/daemon.js +463 -176
  41. package/dist/lib/daemon/runner.js +2 -0
  42. package/dist/lib/daemon/service.d.ts +22 -4
  43. package/dist/lib/daemon/service.js +2 -2
  44. package/dist/lib/daemon/supervisor.d.ts +55 -15
  45. package/dist/lib/daemon/supervisor.js +119 -29
  46. package/dist/lib/daemon/usage-sync-service.d.ts +4 -6
  47. package/dist/lib/daemon/usage-sync-service.js +22 -18
  48. package/dist/lib/daemon-health.js +36 -31
  49. package/dist/lib/daemon-services.d.ts +1 -1
  50. package/dist/lib/daemon-services.js +12 -2
  51. package/dist/lib/daemon-ticks.d.ts +9 -6
  52. package/dist/lib/daemon-ticks.js +14 -8
  53. package/dist/lib/devices/registry.js +2 -0
  54. package/dist/lib/exec.js +11 -0
  55. package/dist/lib/feed/watch.d.ts +3 -0
  56. package/dist/lib/feed/watch.js +13 -3
  57. package/dist/lib/fleet-shared-repo-sync.d.ts +36 -0
  58. package/dist/lib/fleet-shared-repo-sync.js +333 -0
  59. package/dist/lib/fleet-shared-state.d.ts +38 -0
  60. package/dist/lib/fleet-shared-state.js +105 -0
  61. package/dist/lib/hosts/remote-cmd.d.ts +2 -0
  62. package/dist/lib/hosts/remote-cmd.js +12 -3
  63. package/dist/lib/lock-compromise.d.ts +8 -0
  64. package/dist/lib/lock-compromise.js +12 -0
  65. package/dist/lib/monitors/engine.d.ts +2 -1
  66. package/dist/lib/monitors/engine.js +9 -2
  67. package/dist/lib/refresh-coordinator.js +2 -0
  68. package/dist/lib/run-trace-sync.d.ts +13 -0
  69. package/dist/lib/run-trace-sync.js +77 -0
  70. package/dist/lib/secrets/push.d.ts +10 -0
  71. package/dist/lib/secrets/push.js +86 -7
  72. package/dist/lib/secrets/remote.d.ts +18 -6
  73. package/dist/lib/secrets/remote.js +29 -4
  74. package/dist/lib/secrets/reserved-sync.d.ts +28 -27
  75. package/dist/lib/secrets/reserved-sync.js +119 -101
  76. package/dist/lib/session/active.d.ts +3 -1
  77. package/dist/lib/session/active.js +2 -0
  78. package/dist/lib/session/actor-sidecar.d.ts +12 -0
  79. package/dist/lib/session/actor-sidecar.js +2 -0
  80. package/dist/lib/session/db.d.ts +5 -2
  81. package/dist/lib/session/db.js +54 -22
  82. package/dist/lib/session/discover.d.ts +12 -3
  83. package/dist/lib/session/discover.js +106 -25
  84. package/dist/lib/session/prompt.d.ts +11 -0
  85. package/dist/lib/session/prompt.js +25 -0
  86. package/dist/lib/session/recovery.d.ts +30 -2
  87. package/dist/lib/session/recovery.js +56 -4
  88. package/dist/lib/session/remote/remote-list.d.ts +17 -1
  89. package/dist/lib/session/remote/remote-list.js +29 -4
  90. package/dist/lib/session/remote/watch.d.ts +25 -2
  91. package/dist/lib/session/remote/watch.js +185 -11
  92. package/dist/lib/session/session-cache.d.ts +2 -1
  93. package/dist/lib/session/session-cache.js +1 -0
  94. package/dist/lib/session/types.d.ts +2 -0
  95. package/dist/lib/share/publish.d.ts +55 -9
  96. package/dist/lib/share/publish.js +87 -9
  97. package/dist/lib/share/worker-template.js +100 -8
  98. package/dist/lib/ssh-exec.d.ts +2 -0
  99. package/dist/lib/ssh-exec.js +20 -4
  100. package/dist/lib/teams/registry.js +2 -0
  101. package/dist/lib/testdata/daemon-health-writer.d.ts +1 -0
  102. package/dist/lib/testdata/daemon-health-writer.js +8 -0
  103. package/dist/lib/traces/sync.d.ts +7 -0
  104. package/dist/lib/traces/sync.js +9 -0
  105. package/dist/lib/usage-refresh.d.ts +8 -2
  106. package/dist/lib/usage-refresh.js +3 -3
  107. package/package.json +1 -1
  108. package/dist/lib/account-state-service.d.ts +0 -21
  109. package/dist/lib/account-state-service.js +0 -60
@@ -4,10 +4,10 @@ import * as path from 'path';
4
4
  import { IS_WINDOWS, ipcEndpoint } from '../platform/index.js';
5
5
  import { getHelpersDir } from '../state.js';
6
6
  import { resolveBrowserTaskIdleMs } from '../device-config.js';
7
- import { startDaemon, stopDaemon } from '../daemon/daemon.js';
7
+ import { getDaemonLogPath, isDaemonRunning, signalDaemonReload, startDaemon, } from '../daemon/daemon.js';
8
8
  import { getCliVersion } from '../version.js';
9
9
  import { compareVersions } from '../agent-spec/primitives.js';
10
- import { getDaemonLogPath } from '../daemon/daemon.js';
10
+ import { isDaemonServiceEnabled, queueDaemonServiceRestart, setDaemonServiceEnabled, } from '../daemon-services.js';
11
11
  import { isFleetRemoteInvocation } from './remote-control.js';
12
12
  import { resolveCallerIdentity } from './caller-identity.js';
13
13
  import { actionable } from './service.js';
@@ -52,6 +52,19 @@ const PAGE_RESOLVE_VERBS = new Set([
52
52
  /** Close verbs never create a task. */
53
53
  const CLOSE_VERBS = new Set(['done', 'stop']);
54
54
  const SOCKET_NAME = 'browser.sock';
55
+ function readSocketIdentity(socketPath) {
56
+ try {
57
+ const stat = fs.lstatSync(socketPath);
58
+ return { dev: stat.dev, ino: stat.ino };
59
+ }
60
+ catch {
61
+ return null;
62
+ }
63
+ }
64
+ function isSameSocket(socketPath, expected) {
65
+ const current = readSocketIdentity(socketPath);
66
+ return current !== null && current.dev === expected.dev && current.ino === expected.ino;
67
+ }
55
68
  /**
56
69
  * Backstop for {@link BrowserIPCServer.stop} — how long to wait for the socket
57
70
  * to be released before unlinking it anyway (RUSH-2421).
@@ -70,14 +83,14 @@ const SOCKET_NAME = 'browser.sock';
70
83
  */
71
84
  const IPC_CLOSE_TIMEOUT_MS = 1_500;
72
85
  /**
73
- * How long {@link waitForSocket} waits for the browser daemon to come up before
86
+ * How long {@link waitForBrowserService} waits for the browser service to come up before
74
87
  * failing loud (PHNX-3289).
75
88
  *
76
89
  * The old flat 6s ceiling was the wedge: the shared daemon's browser IPC server
77
90
  * restarts (version reconcile, supervisor restart, a hard-crashed predecessor's
78
91
  * successor claiming the socket), and a client that started its wait *during*
79
92
  * one of those restart windows could burn the whole 6s and throw
80
- * `Timeout waiting for browser daemon socket` on an otherwise-healthy daemon. A
93
+ * `Timeout waiting for browser service socket` on an otherwise-healthy daemon. A
81
94
  * browser start/navigate then failed intermittently and self-healed on the next
82
95
  * try. 15s comfortably spans a restart; the stable-probe requirement below is
83
96
  * what keeps a socket that "appears and is immediately destroyed" (#556) from
@@ -85,21 +98,21 @@ const IPC_CLOSE_TIMEOUT_MS = 1_500;
85
98
  */
86
99
  const SOCKET_WAIT_TIMEOUT_MS = 15_000;
87
100
  /**
88
- * Consecutive successful probes required before {@link waitForSocket} declares
89
- * the daemon ready. A single accept can land in the sliver between a restarting
101
+ * Consecutive successful probes required before {@link waitForBrowserService} declares
102
+ * the service ready. A single accept can land in the sliver between a restarting
90
103
  * server binding and tearing back down; requiring two accepts ~100ms apart means
91
104
  * we only return once the daemon is *staying* up, so the caller's real request
92
105
  * doesn't race a restart it happened to probe mid-flight.
93
106
  */
94
107
  const SOCKET_WAIT_STABLE_PROBES = 2;
95
108
  /**
96
- * How long a single {@link probeDaemonResponsive} attempt waits for the daemon to
109
+ * How long a single {@link probeBrowserServiceResponsive} attempt waits for the service to
97
110
  * answer a trivial `version` request before giving up (PHNX-3411).
98
111
  *
99
112
  * A unix-socket `connect` succeeds at the KERNEL level the moment the connection
100
113
  * is queued in the listen backlog — it does NOT require the server's event loop
101
114
  * to run. So a daemon whose event loop is blocked (e.g. a long synchronous burst
102
- * on the shared loop) still passes {@link isDaemonReachable}: the socket accepts,
115
+ * on the shared loop) still passes {@link isBrowserServiceReachable}: the socket accepts,
103
116
  * but the request is never serviced. The live symptom on zion was exactly this —
104
117
  * `browser.sock` accepted every connection while the daemon re-indexed sessions,
105
118
  * then never replied, so cross-device browser drives hung or surfaced a confusing
@@ -108,7 +121,7 @@ const SOCKET_WAIT_STABLE_PROBES = 2;
108
121
  */
109
122
  const RESPONSIVENESS_PROBE_TIMEOUT_MS = 1_500;
110
123
  /**
111
- * Consecutive missed {@link probeDaemonResponsive} attempts before a *reachable*
124
+ * Consecutive missed {@link probeBrowserServiceResponsive} attempts before a *reachable*
112
125
  * daemon is declared wedged. One missed reply can be a transient GC pause on an
113
126
  * otherwise-healthy loop, so a single failure never condemns the daemon; a daemon
114
127
  * that cannot answer a trivial `version` request across this whole window has a
@@ -117,16 +130,17 @@ const RESPONSIVENESS_PROBE_TIMEOUT_MS = 1_500;
117
130
  * a healthy daemon answers the first attempt in ~1ms.
118
131
  */
119
132
  const RESPONSIVENESS_PROBE_ATTEMPTS = 3;
120
- export class BrowserDaemonNotRunningError extends Error {
133
+ export class BrowserServiceNotRunningError extends Error {
121
134
  constructor() {
122
- super(formatBrowserDaemonNotRunningError());
123
- this.name = 'BrowserDaemonNotRunningError';
135
+ super(formatBrowserServiceNotRunningError());
136
+ this.name = 'BrowserServiceNotRunningError';
124
137
  }
125
138
  }
126
- export function formatBrowserDaemonNotRunningError() {
139
+ export function formatBrowserServiceNotRunningError(daemonRunning = isDaemonRunning()) {
127
140
  return [
128
- 'Browser daemon not running.',
129
- 'Start it with: agents browser start (uses this machine\'s configured default browser)',
141
+ 'Browser service is not running.',
142
+ `Shared daemon: ${daemonRunning ? 'running (other services are unaffected)' : 'stopped'}.`,
143
+ 'Start it with: agents browser start (enables only the browser-ipc service and uses this machine\'s configured default browser)',
130
144
  'Pick / pin a profile: agents browser use <name> (or: agents browser start --profile <name>)',
131
145
  'List profiles: agents browser profiles list',
132
146
  ].join('\n');
@@ -149,7 +163,7 @@ function getIpcEndpoint() {
149
163
  * a crashed daemon (or one that "appears and is immediately destroyed", #556)
150
164
  * rejects with ECONNREFUSED and is correctly reported as not reachable —
151
165
  * something fs.existsSync can't distinguish. */
152
- function probeDaemon(endpoint, timeoutMs = 500) {
166
+ function probeBrowserService(endpoint, timeoutMs = 500) {
153
167
  return new Promise((resolve) => {
154
168
  const sock = net.createConnection(endpoint);
155
169
  let done = false;
@@ -162,13 +176,13 @@ function probeDaemon(endpoint, timeoutMs = 500) {
162
176
  }
163
177
  /** Is the daemon reachable? A real connect probe on every platform — a socket
164
178
  * file existing on disk is not proof a daemon is listening on it. */
165
- export async function isDaemonReachable() {
166
- return probeDaemon(getIpcEndpoint());
179
+ export async function isBrowserServiceReachable() {
180
+ return probeBrowserService(getIpcEndpoint());
167
181
  }
168
182
  /**
169
183
  * Can the daemon actually REPLY right now? Opens a connection and requires a
170
184
  * parseable response to a `version` request within `timeoutMs`. Unlike
171
- * {@link probeDaemon} (which resolves on the kernel-level `connect`), this only
185
+ * {@link probeBrowserService} (which resolves on the kernel-level `connect`), this only
172
186
  * succeeds when the daemon's event loop is running and services the request — so
173
187
  * it is the one probe that distinguishes a healthy daemon from a wedged one whose
174
188
  * loop is blocked (PHNX-3411). Resolves false on connect error, timeout, an early
@@ -176,7 +190,7 @@ export async function isDaemonReachable() {
176
190
  * trivial and synchronous (never touches the browser), so a slow reply means the
177
191
  * loop is blocked, not that a real action is in flight.
178
192
  */
179
- function probeDaemonResponsive(endpoint, timeoutMs = RESPONSIVENESS_PROBE_TIMEOUT_MS) {
193
+ function probeBrowserServiceResponsive(endpoint, timeoutMs = RESPONSIVENESS_PROBE_TIMEOUT_MS) {
180
194
  return new Promise((resolve) => {
181
195
  const sock = net.createConnection(endpoint);
182
196
  let buffer = '';
@@ -204,20 +218,20 @@ function probeDaemonResponsive(endpoint, timeoutMs = RESPONSIVENESS_PROBE_TIMEOU
204
218
  }
205
219
  /**
206
220
  * Is a *reachable* daemon actually responsive, or is its event loop wedged
207
- * (PHNX-3411)? Retries {@link probeDaemonResponsive} up to
221
+ * (PHNX-3411)? Retries {@link probeBrowserServiceResponsive} up to
208
222
  * {@link RESPONSIVENESS_PROBE_ATTEMPTS} times so a single transient miss (a GC
209
223
  * pause) never condemns a healthy daemon; returns true as soon as any attempt
210
224
  * gets a reply, and false only when every attempt fails.
211
225
  */
212
- export async function isDaemonResponsive(endpoint = getIpcEndpoint(), attempts = RESPONSIVENESS_PROBE_ATTEMPTS) {
226
+ export async function isBrowserServiceResponsive(endpoint = getIpcEndpoint(), attempts = RESPONSIVENESS_PROBE_ATTEMPTS) {
213
227
  for (let i = 0; i < attempts; i++) {
214
- if (await probeDaemonResponsive(endpoint))
228
+ if (await probeBrowserServiceResponsive(endpoint))
215
229
  return true;
216
230
  }
217
231
  return false;
218
232
  }
219
233
  /**
220
- * Wait until the browser daemon is genuinely reachable, or throw.
234
+ * Wait until the browser service is genuinely reachable, or throw.
221
235
  *
222
236
  * Re-probes across the whole window rather than latching on the first accept, so
223
237
  * it survives an IPC-server restart that happens mid-wait (PHNX-3289): a restart
@@ -226,12 +240,12 @@ export async function isDaemonResponsive(endpoint = getIpcEndpoint(), attempts =
226
240
  * that never comes up throws a message naming the endpoint and the budget, never
227
241
  * a silent hang.
228
242
  */
229
- export async function waitForSocket(_socketPath, timeoutMs = SOCKET_WAIT_TIMEOUT_MS) {
243
+ export async function waitForBrowserService(_socketPath, timeoutMs = SOCKET_WAIT_TIMEOUT_MS) {
230
244
  const endpoint = getIpcEndpoint();
231
245
  const deadline = Date.now() + timeoutMs;
232
246
  let consecutive = 0;
233
247
  while (Date.now() < deadline) {
234
- if (await probeDaemon(endpoint)) {
248
+ if (await probeBrowserService(endpoint)) {
235
249
  consecutive += 1;
236
250
  if (consecutive >= SOCKET_WAIT_STABLE_PROBES)
237
251
  return;
@@ -243,55 +257,60 @@ export async function waitForSocket(_socketPath, timeoutMs = SOCKET_WAIT_TIMEOUT
243
257
  }
244
258
  await new Promise((resolve) => setTimeout(resolve, 100));
245
259
  }
246
- throw new Error(`Timeout waiting for browser daemon socket after ${Math.round(timeoutMs / 1000)}s (${endpoint}).`);
260
+ throw new Error(`Timeout waiting for browser service socket after ${Math.round(timeoutMs / 1000)}s (${endpoint}).`);
247
261
  }
248
- /** How long {@link resetBrowserDaemon} waits for the endpoint to go quiet after
249
- * signalling a stop before it clears the socket and re-checks. Bounded so the
250
- * command fails loud instead of hanging on a daemon that will not die. */
251
- const DAEMON_RESET_QUIESCE_MS = 5_000;
262
+ /** How long {@link stopBrowserService} waits for the endpoint to go quiet after
263
+ * signalling the daemon-owned service transition. Bounded so a wedged daemon
264
+ * fails loud instead of leaving the client hung on a control request. */
265
+ const SERVICE_STOP_QUIESCE_MS = 5_000;
252
266
  /**
253
- * Clear a wedged browser daemon so a subsequent `start` comes up clean
254
- * (PHNX-3289). Stops the shared daemon (the same `stopDaemon` path
255
- * `reconcileDaemonVersion` uses for a stale-version restart), waits for the IPC
256
- * endpoint to stop accepting, then unlinks any stale `browser.sock` a
257
- * hard-crashed daemon left behind — the file a fresh `start` would otherwise
258
- * `unlink` blindly, racing whatever still holds it.
267
+ * Stop only the daemon-hosted browser IPC service so a subsequent `start` comes
268
+ * up clean (PHNX-3605). The short-lived browser client persists the
269
+ * `browser-ipc` service off and asks the daemon to apply that transition over
270
+ * its existing SIGHUP control path. It never stops or restarts the shared
271
+ * supervisor, so routines, usage sync, secrets, and every sibling service keep
272
+ * their process and in-flight work.
259
273
  *
260
- * Fails loud: if the endpoint is *still* reachable after the quiesce window, a
261
- * live server is holding it and clearing the socket under it would orphan two
262
- * servers on one path, so we throw rather than pretend the reset worked. The
263
- * daemon auto-restarts on the next browser command.
274
+ * Once the endpoint is quiet, a stale socket left by a hard-crashed browser
275
+ * service is removed. If the daemon event loop itself is wedged and cannot
276
+ * consume SIGHUP, the endpoint remains reachable and this fails loud with the
277
+ * deliberate operator-owned whole-daemon restart command; the client never
278
+ * escalates into that process-wide action itself.
264
279
  */
265
- export async function resetBrowserDaemon() {
280
+ export async function stopBrowserService() {
266
281
  const endpoint = getIpcEndpoint();
267
- const wasRunning = await probeDaemon(endpoint);
268
- stopDaemon();
282
+ const wasRunning = await probeBrowserService(endpoint);
283
+ const daemonRunning = isDaemonRunning();
284
+ setDaemonServiceEnabled('browser-ipc', false);
285
+ if (daemonRunning && !signalDaemonReload()) {
286
+ 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)'));
287
+ }
269
288
  // Wait for the listener to actually release the endpoint. We must decide
270
289
  // reachability BEFORE touching the socket file: unlinking a path out from
271
290
  // under a live server makes new connects ENOENT (so it would *look* cleared)
272
291
  // while the server keeps running — the two-servers orphan the eviction
273
292
  // protocol exists to prevent. So a still-reachable endpoint fails loud here,
274
293
  // and only a genuinely dead one gets its stale file removed below.
275
- const deadline = Date.now() + DAEMON_RESET_QUIESCE_MS;
294
+ const deadline = Date.now() + SERVICE_STOP_QUIESCE_MS;
276
295
  let reachable = wasRunning;
277
296
  while (reachable && Date.now() < deadline) {
278
297
  await new Promise((resolve) => setTimeout(resolve, 100));
279
- reachable = await probeDaemon(endpoint);
298
+ reachable = await probeBrowserService(endpoint);
280
299
  }
281
300
  if (reachable) {
282
- throw new Error(actionable('Browser daemon is still reachable after stop — a live server is holding the socket.', `Endpoint: ${endpoint}`, 'Next: agents daemon status (find and stop the process holding it)'));
301
+ 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)'));
283
302
  }
284
303
  // Nothing is listening now — clear the leftover socket file a hard-crashed
285
304
  // daemon left behind, so the next `start` binds clean instead of unlinking it
286
305
  // blindly. (Named pipes vanish with their owning process, so Windows has no
287
306
  // stale file to clear.)
288
307
  const socketCleared = IS_WINDOWS ? false : await clearDeadSocketFile(endpoint, getSocketPath());
289
- return { wasRunning, socketCleared };
308
+ return { wasRunning, daemonRunning, socketCleared };
290
309
  }
291
310
  /**
292
311
  * Remove a leftover browser socket FILE, but only when nothing is listening on
293
312
  * it — re-probing liveness IMMEDIATELY before the unlink to close the TOCTOU
294
- * window (PHNX-3289 review). Between {@link resetBrowserDaemon}'s quiesce loop
313
+ * window (PHNX-3289 review). Between {@link stopBrowserService}'s quiesce loop
295
314
  * deciding the endpoint was unreachable and this unlink, a concurrent
296
315
  * `browser start` could bind a NEW listener on the same path; an unconditional
297
316
  * unlink would then delete a LIVE daemon's socket — the exact two-servers orphan
@@ -303,7 +322,7 @@ export async function clearDeadSocketFile(endpoint, socketPath) {
303
322
  if (!fs.existsSync(socketPath))
304
323
  return false;
305
324
  // A listener bound again since the quiesce loop → do not touch its socket.
306
- if (await probeDaemon(endpoint))
325
+ if (await probeBrowserService(endpoint))
307
326
  return false;
308
327
  try {
309
328
  fs.unlinkSync(socketPath);
@@ -316,7 +335,7 @@ export async function clearDeadSocketFile(endpoint, socketPath) {
316
335
  }
317
336
  }
318
337
  /**
319
- * One long-lived connection to the existing browser daemon. Requests are
338
+ * One long-lived connection to the existing browser service. Requests are
320
339
  * serialized so the daemon's newline-delimited responses always map to the
321
340
  * caller that produced them, while the process and socket stay warm between
322
341
  * actions.
@@ -332,7 +351,7 @@ export class BrowserIPCConnection {
332
351
  socket.on('data', (data) => this.handleData(data));
333
352
  socket.on('error', (error) => this.fail(error));
334
353
  socket.on('close', () => {
335
- this.fail(new Error('Browser daemon connection closed'));
354
+ this.fail(new Error('Browser service connection closed'));
336
355
  });
337
356
  }
338
357
  request(request) {
@@ -352,7 +371,7 @@ export class BrowserIPCConnection {
352
371
  }
353
372
  requestOnce(request) {
354
373
  if (this.closed || this.socket.destroyed) {
355
- return Promise.reject(new Error('Browser daemon connection is closed'));
374
+ return Promise.reject(new Error('Browser service connection is closed'));
356
375
  }
357
376
  return new Promise((resolve, reject) => {
358
377
  this.pending = { resolve, reject };
@@ -378,7 +397,7 @@ export class BrowserIPCConnection {
378
397
  const pending = this.pending;
379
398
  if (!pending) {
380
399
  this.socket.destroy();
381
- this.fail(new Error('Browser daemon sent an unexpected response'));
400
+ this.fail(new Error('Browser service sent an unexpected response'));
382
401
  return;
383
402
  }
384
403
  this.pending = undefined;
@@ -386,7 +405,7 @@ export class BrowserIPCConnection {
386
405
  pending.resolve(JSON.parse(line));
387
406
  }
388
407
  catch {
389
- const error = new Error('Browser daemon returned invalid JSON');
408
+ const error = new Error('Browser service returned invalid JSON');
390
409
  pending.reject(error);
391
410
  this.socket.destroy();
392
411
  this.fail(error);
@@ -404,6 +423,8 @@ export class BrowserIPCConnection {
404
423
  }
405
424
  export class BrowserIPCServer {
406
425
  server = null;
426
+ /** Filesystem identity of the POSIX binding this server created. */
427
+ socketIdentity = null;
407
428
  service;
408
429
  /** Live client connections, so {@link stop} can end them rather than wait. */
409
430
  connections = new Set();
@@ -476,6 +497,7 @@ export class BrowserIPCServer {
476
497
  this.server.listen(socketPath, () => {
477
498
  try {
478
499
  fs.chmodSync(socketPath, 0o600);
500
+ this.socketIdentity = readSocketIdentity(socketPath);
479
501
  resolve();
480
502
  }
481
503
  catch (err) {
@@ -522,10 +544,29 @@ export class BrowserIPCServer {
522
544
  return this.stopping;
523
545
  }
524
546
  async doStop() {
525
- await this.closeServer();
547
+ const socketPath = getSocketPath();
548
+ const owned = this.socketIdentity;
549
+ let preservedReplacement = null;
550
+ if (!IS_WINDOWS && owned && readSocketIdentity(socketPath) && !isSameSocket(socketPath, owned)) {
551
+ // Node/libuv remembers the pathname passed to listen() and unlinks that
552
+ // pathname during server.close(). If a successor has already replaced the
553
+ // directory entry, an inode check AFTER close is too late: libuv has
554
+ // deleted the successor. Move the replacement aside for the close and put
555
+ // it back before returning. The listening socket stays live across rename.
556
+ preservedReplacement = `${socketPath}.preserved-${process.pid}-${Date.now()}`;
557
+ fs.renameSync(socketPath, preservedReplacement);
558
+ }
559
+ try {
560
+ await this.closeServer();
561
+ }
562
+ finally {
563
+ if (preservedReplacement && fs.existsSync(preservedReplacement) && !fs.existsSync(socketPath)) {
564
+ fs.renameSync(preservedReplacement, socketPath);
565
+ }
566
+ }
526
567
  if (!IS_WINDOWS) {
527
- const socketPath = getSocketPath();
528
- if (fs.existsSync(socketPath)) {
568
+ this.socketIdentity = null;
569
+ if (owned && isSameSocket(socketPath, owned)) {
529
570
  fs.unlinkSync(socketPath);
530
571
  }
531
572
  }
@@ -999,21 +1040,21 @@ export class BrowserIPCServer {
999
1040
  }
1000
1041
  let versionReconciledThisProcess = false;
1001
1042
  /**
1002
- * Decide whether a running daemon is stale and must be restarted.
1043
+ * Decide whether a running daemon is stale enough to name an operator refresh.
1003
1044
  *
1004
- * FORWARD ONLY: restart when the daemon is *older* than this CLI so a newer
1005
- * install loads current code. An older CLI rides a newer daemon instead of
1006
- * evicting it — two installs sharing one daemon dir (keyed off $HOME) must
1007
- * not flap the daemon indefinitely.
1045
+ * FORWARD ONLY: recommend a refresh when the daemon is *older* than this CLI.
1046
+ * An older CLI rides a newer daemon without noise. A client never performs the
1047
+ * refresh itself — two installs sharing one daemon dir (keyed off $HOME) must
1048
+ * not flap the daemon or interrupt one another's hosted services.
1008
1049
  *
1009
1050
  * `undefined`/`'unknown'` means the daemon is too old to answer the `version`
1010
- * action reliably — don't churn it on that ambiguous signal.
1051
+ * action reliably — don't recommend an action on that ambiguous signal.
1011
1052
  *
1012
1053
  * When numeric compare cannot order two distinct strings (e.g. two
1013
- * `0.0.0-dev.*` builds), treat the mismatch as a restart so a concrete
1014
- * code change still loads.
1054
+ * `0.0.0-dev.*` builds), surface the mismatch without deciding either client
1055
+ * owns the shared process.
1015
1056
  */
1016
- export function shouldRestartStaleDaemon(daemonVersion, clientVersion) {
1057
+ export function shouldRecommendDaemonRefresh(daemonVersion, clientVersion) {
1017
1058
  if (!daemonVersion || daemonVersion === 'unknown')
1018
1059
  return false;
1019
1060
  if (daemonVersion === clientVersion)
@@ -1027,13 +1068,13 @@ export function shouldRestartStaleDaemon(daemonVersion, clientVersion) {
1027
1068
  return true;
1028
1069
  }
1029
1070
  /**
1030
- * Reconcile the running daemon's version with ours. If the daemon is serving
1031
- * stale code, stop and restart it so this request — and the rest of the
1032
- * session — runs the current build. Runs at most once per CLI process. The
1033
- * whole reason this exists: a launchd-managed daemon kept serving stale code
1034
- * to a dev-build CLI for an entire session and nothing surfaced it (#291).
1071
+ * Reconcile the running daemon's version with ours without evicting it. If the
1072
+ * daemon is serving stale code, surface the deliberate operator command once
1073
+ * and continue on the already-running service. A browser client owns neither
1074
+ * the shared supervisor nor its sibling services, so version skew can never be
1075
+ * permission to stop or restart that process (PHNX-3605).
1035
1076
  */
1036
- async function reconcileDaemonVersion(socketPath) {
1077
+ async function reconcileDaemonVersion() {
1037
1078
  if (versionReconciledThisProcess)
1038
1079
  return;
1039
1080
  versionReconciledThisProcess = true;
@@ -1047,15 +1088,11 @@ async function reconcileDaemonVersion(socketPath) {
1047
1088
  return;
1048
1089
  }
1049
1090
  const client = getCliVersion();
1050
- if (!shouldRestartStaleDaemon(daemon, client))
1091
+ if (!shouldRecommendDaemonRefresh(daemon, client))
1051
1092
  return;
1052
- process.stderr.write(`\nbrowser daemon was on ${daemon}, this CLI is on ${client} — restarting it to load current code.\n\n`);
1053
- stopDaemon();
1054
- startDaemon();
1055
- if (!(await isDaemonReachable())) {
1056
- await waitForSocket(socketPath);
1057
- }
1058
- await new Promise((r) => setTimeout(r, 300));
1093
+ process.stderr.write(`\nBrowser service is running on shared daemon ${daemon}, while this CLI is ${client}. `
1094
+ + 'Continuing without evicting the daemon or its other services.\n'
1095
+ + 'To load current daemon code when it is safe to interrupt every hosted service: agents daemon restart\n\n');
1059
1096
  }
1060
1097
  export async function sendIPCRequest(request, opts = {}) {
1061
1098
  // Stamp caller identity ONCE here so the 28+ call sites don't each have to.
@@ -1098,7 +1135,7 @@ export async function connectBrowserIPC(opts = {}) {
1098
1135
  const onError = (error) => {
1099
1136
  socket.destroy();
1100
1137
  if (!autoStartDaemon && (error.code === 'ENOENT' || error.code === 'ECONNREFUSED')) {
1101
- reject(new BrowserDaemonNotRunningError());
1138
+ reject(new BrowserServiceNotRunningError());
1102
1139
  return;
1103
1140
  }
1104
1141
  reject(new Error(`IPC error: ${error.message}`));
@@ -1131,7 +1168,7 @@ async function sendRawIPCRequest(request, opts = {}) {
1131
1168
  });
1132
1169
  socket.on('error', (err) => {
1133
1170
  if (!autoStartDaemon && (err.code === 'ENOENT' || err.code === 'ECONNREFUSED')) {
1134
- reject(new BrowserDaemonNotRunningError());
1171
+ reject(new BrowserServiceNotRunningError());
1135
1172
  return;
1136
1173
  }
1137
1174
  reject(new Error(`IPC error: ${err.message}`));
@@ -1146,20 +1183,35 @@ async function sendRawIPCRequest(request, opts = {}) {
1146
1183
  async function prepareIPC(action, opts) {
1147
1184
  const socketPath = getSocketPath();
1148
1185
  const autoStartDaemon = opts.autoStartDaemon ?? true;
1149
- if (!(await isDaemonReachable())) {
1186
+ if (!(await isBrowserServiceReachable())) {
1150
1187
  if (!autoStartDaemon) {
1151
- throw new BrowserDaemonNotRunningError();
1188
+ throw new BrowserServiceNotRunningError();
1152
1189
  }
1190
+ const serviceEnabled = isDaemonServiceEnabled('browser-ipc');
1191
+ if (!serviceEnabled)
1192
+ setDaemonServiceEnabled('browser-ipc', true);
1153
1193
  if (!IS_WINDOWS) {
1154
1194
  await fs.promises.mkdir(path.dirname(socketPath), { recursive: true, mode: 0o700 });
1155
1195
  await fs.promises.chmod(path.dirname(socketPath), 0o700);
1156
1196
  }
1157
- startDaemon();
1158
- if (!(await isDaemonReachable())) {
1159
- await waitForSocket(socketPath);
1197
+ if (isDaemonRunning()) {
1198
+ // A stopped service is enabled by the config transition. An enabled but
1199
+ // absent service is failed/parked, so request a supervised restart. Both
1200
+ // actions are daemon-owned and in-flight-gated by ServiceSupervisor.
1201
+ if (serviceEnabled)
1202
+ queueDaemonServiceRestart('browser-ipc');
1203
+ if (!signalDaemonReload()) {
1204
+ 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)'));
1205
+ }
1206
+ }
1207
+ else {
1208
+ startDaemon();
1209
+ }
1210
+ if (!(await isBrowserServiceReachable())) {
1211
+ await waitForBrowserService(socketPath);
1160
1212
  }
1161
- if (!(await isDaemonReachable())) {
1162
- throw new Error(actionable('Failed to start browser daemon.', `Log: ${getDaemonLogPath()}`, 'Next: agents doctor (checks for a second agents-cli install)'));
1213
+ if (!(await isBrowserServiceReachable())) {
1214
+ throw new Error(actionable('Failed to start browser service.', `Log: ${getDaemonLogPath()}`, 'Next: agents doctor (checks for a second agents-cli install)'));
1163
1215
  }
1164
1216
  await new Promise((r) => setTimeout(r, 300));
1165
1217
  }
@@ -1170,14 +1222,14 @@ async function prepareIPC(action, opts) {
1170
1222
  // instead of the confusing bare socket timeout / indefinite hang the browser
1171
1223
  // verb would otherwise hit (including inside reconcileDaemonVersion's own
1172
1224
  // version probe, which has no response timeout). Skipped for callers that opt
1173
- // out of auto-start — they want the clean BrowserDaemonNotRunningError instead.
1174
- if (autoStartDaemon && !(await isDaemonResponsive(getIpcEndpoint()))) {
1175
- throw new Error(actionable('Browser daemon is running but unresponsive — its event loop is blocked, so it accepts the connection but never replies.', `Endpoint: ${getIpcEndpoint()}`, `Log: ${getDaemonLogPath()}`, 'Next: agents browser stop --daemon (resets the wedged daemon; the next browser command restarts it)'));
1225
+ // out of auto-start — they want the clean BrowserServiceNotRunningError instead.
1226
+ if (autoStartDaemon && !(await isBrowserServiceResponsive(getIpcEndpoint()))) {
1227
+ 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)'));
1176
1228
  }
1177
1229
  // Before serving a real request, make sure the daemon isn't running stale
1178
1230
  // code. Skips the internal `version` probe (avoids recursion) and callers
1179
1231
  // that opt out of auto-start. No-ops once reconciled or when versions match.
1180
1232
  if (action !== 'version' && autoStartDaemon) {
1181
- await reconcileDaemonVersion(socketPath);
1233
+ await reconcileDaemonVersion();
1182
1234
  }
1183
1235
  }
@@ -23,6 +23,7 @@ export type TaskRoute = {
23
23
  kind: 'reject-device';
24
24
  message: string;
25
25
  };
26
+ export declare function isTerminalBrowserVerb(verb: string): boolean;
26
27
  export declare function taskIndexPath(): string;
27
28
  export declare function readTaskIndex(): TaskIndex;
28
29
  export declare function getTaskBinding(name: string): TaskBinding | undefined;
@@ -52,8 +53,14 @@ export declare function ambiguousTasksMessage(entries: Array<{
52
53
  * `--device` on a later verb is always rejected (bind it at start).
53
54
  * A named task that is not in the local index fails loud with the open-task
54
55
  * list. Two or more tasks for this caller, with no `--task`, fail the same way.
55
- * Zero matches with no `--task` proceeds locally — the daemon still resolves
56
- * from caller identity for the first implicit create.
56
+ * Zero matches with no `--task` follows the fleet browser hub when the caller
57
+ * passed one (`opts.hub` — the box drives `browser.device` by default), so a
58
+ * cold page verb reaches the logged-in hub browser exactly as a bare
59
+ * `agents browser start` already does; with no hub it proceeds locally and the
60
+ * daemon resolves from caller identity for the first implicit create. The caller
61
+ * (the CLI's task-routing hook) supplies `opts.hub` from `defaultBrowserHub()`,
62
+ * which is already undefined on the hub itself and on a fleet-remote re-exec, so
63
+ * this can never forward to self or loop.
57
64
  */
58
65
  export declare function resolveTaskRoute(opts: {
59
66
  task?: string;
@@ -61,6 +68,7 @@ export declare function resolveTaskRoute(opts: {
61
68
  sessionId?: string;
62
69
  launchId?: string;
63
70
  self?: string;
71
+ hub?: string;
64
72
  }): TaskRoute;
65
73
  /**
66
74
  * The daemon sandboxes screenshot writes to the browser runtime dir, so `-o`
@@ -19,6 +19,19 @@ import { machineId } from '../machine-id.js';
19
19
  export const REJECT_DEVICE_MESSAGE = '--device is only valid on `agents browser start`.\n' +
20
20
  'The task is bound to a device at start; later verbs resolve it from --task.\n' +
21
21
  'Next: agents browser start --task <name> --device <device>';
22
+ /**
23
+ * Verbs that CLOSE a browsing context. A cold (no bound task) close verb must
24
+ * never follow the fleet hub: with no task it would target a browsing context
25
+ * this session never opened, and on the hub could stop an unrelated task. They
26
+ * stay local unless an explicit `--task` or a local binding routes them. The
27
+ * driving/read verbs (navigate, screenshot, click, …) are safe to forward cold
28
+ * because the hub creates the caller's OWN task, keyed to the identity the
29
+ * dispatch now forwards.
30
+ */
31
+ const TERMINAL_BROWSER_VERBS = new Set(['done', 'stop']);
32
+ export function isTerminalBrowserVerb(verb) {
33
+ return TERMINAL_BROWSER_VERBS.has(verb);
34
+ }
22
35
  export function taskIndexPath() {
23
36
  return path.join(getBrowserRuntimeDir(), 'task-index.json');
24
37
  }
@@ -138,8 +151,14 @@ export function ambiguousTasksMessage(entries) {
138
151
  * `--device` on a later verb is always rejected (bind it at start).
139
152
  * A named task that is not in the local index fails loud with the open-task
140
153
  * list. Two or more tasks for this caller, with no `--task`, fail the same way.
141
- * Zero matches with no `--task` proceeds locally — the daemon still resolves
142
- * from caller identity for the first implicit create.
154
+ * Zero matches with no `--task` follows the fleet browser hub when the caller
155
+ * passed one (`opts.hub` — the box drives `browser.device` by default), so a
156
+ * cold page verb reaches the logged-in hub browser exactly as a bare
157
+ * `agents browser start` already does; with no hub it proceeds locally and the
158
+ * daemon resolves from caller identity for the first implicit create. The caller
159
+ * (the CLI's task-routing hook) supplies `opts.hub` from `defaultBrowserHub()`,
160
+ * which is already undefined on the hub itself and on a fleet-remote re-exec, so
161
+ * this can never forward to self or loop.
143
162
  */
144
163
  export function resolveTaskRoute(opts) {
145
164
  if (opts.device) {
@@ -161,7 +180,7 @@ export function resolveTaskRoute(opts) {
161
180
  const only = matches[0];
162
181
  return { kind: 'proceed', task: only.name, device: only.device };
163
182
  }
164
- return { kind: 'proceed', device: self };
183
+ return { kind: 'proceed', device: opts.hub ?? self };
165
184
  }
166
185
  /**
167
186
  * The daemon sandboxes screenshot writes to the browser runtime dir, so `-o`