@phnx-labs/agents-cli 1.22.114 → 1.22.116

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 (243) hide show
  1. package/CHANGELOG.md +245 -0
  2. package/README.md +22 -102
  3. package/dist/browser.js +0 -0
  4. package/dist/cli/command-registry.js +0 -7
  5. package/dist/commands/accounts.d.ts +3 -77
  6. package/dist/commands/accounts.js +13 -366
  7. package/dist/commands/audit.js +1 -1
  8. package/dist/commands/auth.js +0 -2
  9. package/dist/commands/browser.d.ts +53 -0
  10. package/dist/commands/browser.js +210 -9
  11. package/dist/commands/doctor.d.ts +2 -3
  12. package/dist/commands/doctor.js +0 -11
  13. package/dist/commands/events.d.ts +1 -3
  14. package/dist/commands/events.js +4 -7
  15. package/dist/commands/exec.d.ts +0 -1
  16. package/dist/commands/exec.js +214 -148
  17. package/dist/commands/feed-watch.js +52 -8
  18. package/dist/commands/feed.js +65 -14
  19. package/dist/commands/focus.js +1 -1
  20. package/dist/commands/go.d.ts +0 -17
  21. package/dist/commands/go.js +2 -19
  22. package/dist/commands/logs.js +1 -1
  23. package/dist/commands/mcp.js +8 -83
  24. package/dist/commands/memory.js +4 -47
  25. package/dist/commands/message.js +4 -4
  26. package/dist/commands/plugins.js +1 -93
  27. package/dist/commands/repo.js +0 -44
  28. package/dist/commands/resume.js +4 -1
  29. package/dist/commands/secrets-passthrough.js +2 -2
  30. package/dist/commands/send.d.ts +4 -4
  31. package/dist/commands/send.js +6 -51
  32. package/dist/commands/sessions-backup-setup.js +1 -1
  33. package/dist/commands/sessions-picker.d.ts +11 -0
  34. package/dist/commands/sessions-picker.js +88 -7
  35. package/dist/commands/sessions-resume.js +0 -1
  36. package/dist/commands/sessions-share.d.ts +5 -7
  37. package/dist/commands/sessions-share.js +98 -49
  38. package/dist/commands/sessions.d.ts +21 -2
  39. package/dist/commands/sessions.js +158 -11
  40. package/dist/commands/setup-browser.js +18 -2
  41. package/dist/commands/setup-computer.js +20 -6
  42. package/dist/commands/setup-secrets.d.ts +2 -2
  43. package/dist/commands/setup-secrets.js +24 -4
  44. package/dist/commands/setup-term.d.ts +24 -0
  45. package/dist/commands/setup-term.js +70 -0
  46. package/dist/commands/setup-terminal.d.ts +3 -0
  47. package/dist/commands/setup-terminal.js +22 -0
  48. package/dist/commands/setup.d.ts +1 -1
  49. package/dist/commands/setup.js +29 -11
  50. package/dist/commands/skills.js +0 -8
  51. package/dist/commands/ssh.d.ts +6 -0
  52. package/dist/commands/ssh.js +93 -302
  53. package/dist/commands/sync.js +14 -5
  54. package/dist/commands/teams.js +1 -1
  55. package/dist/commands/traces.js +1 -1
  56. package/dist/lib/accounting/rotate.d.ts +63 -1
  57. package/dist/lib/accounting/rotate.js +56 -0
  58. package/dist/lib/accounts/add.d.ts +0 -5
  59. package/dist/lib/accounts/add.js +3 -9
  60. package/dist/lib/accounts/slots.js +32 -2
  61. package/dist/lib/answer-router.d.ts +11 -2
  62. package/dist/lib/answer-router.js +26 -2
  63. package/dist/lib/artifacts-client.d.ts +20 -0
  64. package/dist/lib/artifacts-client.js +46 -0
  65. package/dist/lib/auth-mint.d.ts +5 -4
  66. package/dist/lib/auth-mint.js +6 -5
  67. package/dist/lib/browser/drivers/arc.d.ts +1 -1
  68. package/dist/lib/browser/runtime-state.d.ts +55 -0
  69. package/dist/lib/browser/runtime-state.js +99 -18
  70. package/dist/lib/browser/service.d.ts +10 -0
  71. package/dist/lib/browser/service.js +229 -37
  72. package/dist/lib/browser/types.d.ts +18 -0
  73. package/dist/lib/cli-resources.js +3 -1
  74. package/dist/lib/cloud/dispatch.js +1 -1
  75. package/dist/lib/cloudflare/creds.d.ts +10 -0
  76. package/dist/lib/cloudflare/creds.js +46 -0
  77. package/dist/lib/cloudflare/provision.d.ts +35 -0
  78. package/dist/lib/cloudflare/provision.js +144 -0
  79. package/dist/lib/computer/sessions-list.d.ts +55 -0
  80. package/dist/lib/computer/sessions-list.js +168 -1
  81. package/dist/lib/config-keys.d.ts +1 -1
  82. package/dist/lib/config-keys.js +5 -0
  83. package/dist/lib/daemon/daemon.js +8 -1
  84. package/dist/lib/daemon/feed-stream-service.d.ts +23 -0
  85. package/dist/lib/daemon/feed-stream-service.js +40 -0
  86. package/dist/lib/daemon-services.d.ts +1 -1
  87. package/dist/lib/daemon-services.js +5 -0
  88. package/dist/lib/device-config.js +61 -0
  89. package/dist/lib/devices/connect.d.ts +49 -5
  90. package/dist/lib/devices/connect.js +169 -21
  91. package/dist/lib/devices/doctor-findings.js +2 -6
  92. package/dist/lib/feed/answer.d.ts +153 -4
  93. package/dist/lib/feed/answer.js +716 -105
  94. package/dist/lib/feed/envelope.d.ts +79 -0
  95. package/dist/lib/feed/envelope.js +23 -0
  96. package/dist/lib/feed/events.d.ts +6 -0
  97. package/dist/lib/feed/events.js +8 -0
  98. package/dist/lib/feed/feed.d.ts +61 -1
  99. package/dist/lib/feed/feed.js +226 -14
  100. package/dist/lib/feed/hub-server.d.ts +141 -0
  101. package/dist/lib/feed/hub-server.js +586 -0
  102. package/dist/lib/feed/hub.d.ts +95 -0
  103. package/dist/lib/feed/hub.js +255 -0
  104. package/dist/lib/feed/pr-status.d.ts +8 -0
  105. package/dist/lib/feed/pr-status.js +9 -1
  106. package/dist/lib/feed/tool-activity.d.ts +108 -0
  107. package/dist/lib/feed/tool-activity.js +313 -0
  108. package/dist/lib/feed/tools.d.ts +198 -0
  109. package/dist/lib/feed/tools.js +265 -0
  110. package/dist/lib/feed/watch.d.ts +50 -50
  111. package/dist/lib/feed/watch.js +147 -16
  112. package/dist/lib/feed-outcome.d.ts +1 -1
  113. package/dist/lib/feed-outcome.js +9 -2
  114. package/dist/lib/feed-policy.js +9 -3
  115. package/dist/lib/fleet/auth-sync.d.ts +2 -55
  116. package/dist/lib/fleet/auth-sync.js +2 -89
  117. package/dist/lib/format.d.ts +1 -1
  118. package/dist/lib/format.js +1 -1
  119. package/dist/lib/git.d.ts +0 -16
  120. package/dist/lib/git.js +0 -58
  121. package/dist/lib/harness-auth-capabilities.js +7 -2
  122. package/dist/lib/helper-versions.js +1 -1
  123. package/dist/lib/hosts/dispatch.d.ts +20 -1
  124. package/dist/lib/hosts/dispatch.js +52 -30
  125. package/dist/lib/hosts/remote-cmd.d.ts +72 -1
  126. package/dist/lib/hosts/remote-cmd.js +151 -10
  127. package/dist/lib/hosts/remote-cmd.test-fixture.d.ts +2 -0
  128. package/dist/lib/hosts/remote-cmd.test-fixture.js +22 -0
  129. package/dist/lib/mailbox.d.ts +12 -0
  130. package/dist/lib/mailbox.js +16 -2
  131. package/dist/lib/mcp.js +17 -11
  132. package/dist/lib/menubar/snapshot.d.ts +51 -0
  133. package/dist/lib/menubar/snapshot.js +42 -3
  134. package/dist/lib/open-url.js +2 -2
  135. package/dist/lib/probe.d.ts +4 -1
  136. package/dist/lib/probe.js +5 -2
  137. package/dist/lib/projects.d.ts +23 -0
  138. package/dist/lib/projects.js +78 -0
  139. package/dist/lib/pwsh.d.ts +33 -0
  140. package/dist/lib/pwsh.js +56 -0
  141. package/dist/lib/redact.d.ts +8 -0
  142. package/dist/lib/redact.js +11 -0
  143. package/dist/lib/refresh.d.ts +6 -2
  144. package/dist/lib/refresh.js +92 -72
  145. package/dist/lib/secrets-cli.d.ts +11 -0
  146. package/dist/lib/secrets-cli.js +30 -0
  147. package/dist/lib/secrets-client.js +3 -2
  148. package/dist/lib/session/active.d.ts +1 -0
  149. package/dist/lib/session/active.js +8 -0
  150. package/dist/lib/session/db.d.ts +67 -3
  151. package/dist/lib/session/db.js +381 -126
  152. package/dist/lib/session/detached.d.ts +7 -0
  153. package/dist/lib/session/detached.js +29 -0
  154. package/dist/lib/session/prompt.d.ts +23 -7
  155. package/dist/lib/session/prompt.js +46 -8
  156. package/dist/lib/session/remote/peer-stream.d.ts +24 -2
  157. package/dist/lib/session/remote/peer-stream.js +33 -6
  158. package/dist/lib/session/remote/remote-list.d.ts +20 -0
  159. package/dist/lib/session/remote/remote-list.js +22 -6
  160. package/dist/lib/session/remote/watch.d.ts +12 -0
  161. package/dist/lib/session/remote/watch.js +9 -0
  162. package/dist/lib/session/remote-preview-cache.d.ts +29 -0
  163. package/dist/lib/session/remote-preview-cache.js +373 -0
  164. package/dist/lib/session/sync/backend.d.ts +3 -3
  165. package/dist/lib/session/sync/backend.js +3 -3
  166. package/dist/lib/session/sync/provision.d.ts +1 -1
  167. package/dist/lib/session/sync/provision.js +2 -2
  168. package/dist/lib/session/tail.d.ts +50 -0
  169. package/dist/lib/session/tail.js +219 -0
  170. package/dist/lib/sessions-client.js +0 -3
  171. package/dist/lib/setup-tool-install.d.ts +3 -0
  172. package/dist/lib/setup-tool-install.js +27 -0
  173. package/dist/lib/setup-tool-status.d.ts +22 -0
  174. package/dist/lib/setup-tool-status.js +220 -0
  175. package/dist/lib/share-runtime.d.ts +11 -0
  176. package/dist/lib/share-runtime.js +63 -0
  177. package/dist/lib/signin-badge.d.ts +19 -4
  178. package/dist/lib/signin-badge.js +29 -11
  179. package/dist/lib/smart-launch.d.ts +1 -5
  180. package/dist/lib/smart-launch.js +3 -11
  181. package/dist/lib/ssh-exec.d.ts +44 -0
  182. package/dist/lib/ssh-exec.js +119 -0
  183. package/dist/lib/startup/command-registry.d.ts +6 -4
  184. package/dist/lib/startup/command-registry.js +10 -7
  185. package/dist/lib/state.js +2 -2
  186. package/dist/lib/storage/selection.d.ts +2 -2
  187. package/dist/lib/storage/selection.js +2 -2
  188. package/dist/lib/sync-umbrella.d.ts +5 -0
  189. package/dist/lib/sync-umbrella.js +18 -10
  190. package/dist/lib/term-driver.d.ts +24 -0
  191. package/dist/lib/term-driver.js +36 -0
  192. package/dist/lib/terminal/index.d.ts +1 -1
  193. package/dist/lib/terminal/index.js +1 -1
  194. package/dist/lib/terminal/inject.d.ts +38 -0
  195. package/dist/lib/terminal/inject.js +55 -9
  196. package/dist/lib/terminal/transport.d.ts +15 -5
  197. package/dist/lib/terminal/transport.js +61 -11
  198. package/dist/lib/traces/backend.d.ts +1 -2
  199. package/dist/lib/traces/backend.js +1 -2
  200. package/dist/lib/traces/provision.d.ts +1 -1
  201. package/dist/lib/traces/provision.js +2 -2
  202. package/dist/lib/types.d.ts +8 -6
  203. package/package.json +2 -3
  204. package/dist/commands/artifacts-setup.d.ts +0 -53
  205. package/dist/commands/artifacts-setup.js +0 -161
  206. package/dist/commands/artifacts.d.ts +0 -18
  207. package/dist/commands/artifacts.js +0 -58
  208. package/dist/commands/attach.d.ts +0 -12
  209. package/dist/commands/attach.js +0 -86
  210. package/dist/commands/auth-mint.d.ts +0 -12
  211. package/dist/commands/auth-mint.js +0 -108
  212. package/dist/commands/reconnect.d.ts +0 -46
  213. package/dist/commands/reconnect.js +0 -115
  214. package/dist/commands/share.d.ts +0 -293
  215. package/dist/commands/share.js +0 -1424
  216. package/dist/lib/fleet/remote-login.d.ts +0 -170
  217. package/dist/lib/fleet/remote-login.js +0 -568
  218. package/dist/lib/share/analytics.d.ts +0 -13
  219. package/dist/lib/share/analytics.js +0 -45
  220. package/dist/lib/share/backend.d.ts +0 -120
  221. package/dist/lib/share/backend.js +0 -176
  222. package/dist/lib/share/capture.d.ts +0 -31
  223. package/dist/lib/share/capture.js +0 -174
  224. package/dist/lib/share/config.d.ts +0 -72
  225. package/dist/lib/share/config.js +0 -211
  226. package/dist/lib/share/delete.d.ts +0 -123
  227. package/dist/lib/share/delete.js +0 -173
  228. package/dist/lib/share/html.d.ts +0 -20
  229. package/dist/lib/share/html.js +0 -88
  230. package/dist/lib/share/http-error.d.ts +0 -53
  231. package/dist/lib/share/http-error.js +0 -65
  232. package/dist/lib/share/og.d.ts +0 -26
  233. package/dist/lib/share/og.js +0 -84
  234. package/dist/lib/share/provision.d.ts +0 -127
  235. package/dist/lib/share/provision.js +0 -285
  236. package/dist/lib/share/publish.d.ts +0 -379
  237. package/dist/lib/share/publish.js +0 -818
  238. package/dist/lib/share/worker-template.d.ts +0 -27
  239. package/dist/lib/share/worker-template.js +0 -2424
  240. package/dist/lib/storage/index.d.ts +0 -14
  241. package/dist/lib/storage/index.js +0 -14
  242. package/dist/lib/storage/visibility.d.ts +0 -82
  243. package/dist/lib/storage/visibility.js +0 -99
@@ -258,6 +258,39 @@ function readTaskCount(dir) {
258
258
  * alone — we'd rather leak than wrongly kill a user-owned process that
259
259
  * happens to share metadata.
260
260
  */
261
+ /**
262
+ * Clean up what a DEAD daemon genuinely left behind — which is not the browser.
263
+ *
264
+ * A live local browser outliving its daemon is the NORMAL case, not an orphan.
265
+ * `BrowserService.shutdown` deliberately closes only CDP and leaves the browser
266
+ * running, and on macOS the spawn is detached so the daemon's own positive-pid
267
+ * `killTree` reaches the daemon alone. This reaper used to read "the recorded
268
+ * `daemonPid` has exited" as "everything it recorded is garbage" and SIGTERM the
269
+ * browser — so replacing the daemon (a menubar activation, an upgrade, a crash
270
+ * restart) killed a browser the user was looking at, and `clearProfileRuntime`
271
+ * then removed the `port`/meta that {@link readProfileRuntime} needs, so even a
272
+ * survivor could not be re-attached and a second browser would launch beside it.
273
+ *
274
+ * The three cases are now decided independently:
275
+ *
276
+ * - **A live local browser/electron is preserved untouched** — not signalled, not
277
+ * cleared, and its `daemonPid` deliberately NOT rewritten. Ownership is adopted
278
+ * only after an identity-checked attach succeeds (`adoptProfileRuntimeOwner`,
279
+ * called from the reuse branch in `BrowserService.connectProfile`), so nothing
280
+ * claims a browser it has not proven it can talk to. Re-running this is a no-op.
281
+ * - **A dead browser record is cleared**, with no signal sent: there is nothing
282
+ * alive to signal, and the stale pid/port would otherwise mislead the next
283
+ * attach.
284
+ * - **A stale tunnel IS reaped.** A local `ssh -L` cannot outlive its purpose, so
285
+ * a dead owner does make it an orphan. This is the one thing the old code was
286
+ * right about; it just shared a code path with the browser.
287
+ *
288
+ * The accepted trade: a genuinely leaked browser from a long-dead daemon is no
289
+ * longer force-killed here. Idle/abandoned cleanup has its own owner in
290
+ * `browser/hygiene.ts` (session-dead + idle), which the daemon's periodic tick and
291
+ * `agents browser prune` both call — that is the right place for it, because it can
292
+ * tell "idle and abandoned" from "in use right now" and this function cannot.
293
+ */
261
294
  export function reapOrphanedProcesses() {
262
295
  const root = getBrowserRuntimeDir();
263
296
  if (!fs.existsSync(root))
@@ -275,28 +308,76 @@ export function reapOrphanedProcesses() {
275
308
  // Owning daemon still alive — leave its kids alone.
276
309
  if (isProcessAlive(meta.daemonPid))
277
310
  continue;
278
- // Kill what the dead daemon left behind. Best-effort.
279
- const kill = (pid, label) => {
280
- if (!pid || pid === 0)
281
- return;
282
- // Only kill if it matches the recorded command — guards against
283
- // pid reuse handing us an unrelated process to murder.
284
- if (meta.command && !matchesCommand(pid, meta.command) &&
285
- !matchesCommand(pid, 'ssh'))
286
- return;
287
- try {
288
- process.kill(pid, 'SIGTERM');
289
- reaped++;
290
- details.push(`reaped ${label ?? 'pid'} ${pid} (profile ${profileName})`);
311
+ // A local `ssh -L` whose owner is gone is genuinely orphaned: nothing is
312
+ // driving it and it cannot be re-adopted, so it is the one process to signal.
313
+ // Matched against the recorded command (or `ssh`) so a recycled pid is not
314
+ // mistaken for our tunnel.
315
+ if (meta.tunnelPid && meta.tunnelPid !== 0) {
316
+ const cmdOk = !meta.command
317
+ || matchesCommand(meta.tunnelPid, meta.command)
318
+ || matchesCommand(meta.tunnelPid, 'ssh');
319
+ if (cmdOk) {
320
+ try {
321
+ process.kill(meta.tunnelPid, 'SIGTERM');
322
+ reaped++;
323
+ details.push(`reaped tunnel ${meta.tunnelPid} (profile ${profileName})`);
324
+ }
325
+ catch { /* already gone */ }
291
326
  }
292
- catch { /* already gone */ }
293
- };
294
- kill(meta.pid, 'browser');
295
- kill(meta.tunnelPid, 'tunnel');
327
+ }
328
+ // A tunnel-kind record has no local browser of its own, so clearing it is the
329
+ // whole cleanup. `pid` on such a record is the REMOTE browser (normally 0) and
330
+ // is never ours to signal.
331
+ if (meta.kind === 'tunnel') {
332
+ clearProfileRuntime(profileName);
333
+ continue;
334
+ }
335
+ // `isProcessAlive` re-checks the recorded command, so a recycled pid does not
336
+ // read as our browser. A record with no pid at all is stale by definition.
337
+ const liveBrowser = !!meta.pid && meta.pid !== 0 && isProcessAlive(meta.pid, meta.command);
338
+ if (liveBrowser) {
339
+ details.push(`preserved live browser ${meta.pid} (profile ${profileName})`);
340
+ continue;
341
+ }
342
+ // Dead browser: clear the stale record. No signal — there is nothing there.
296
343
  clearProfileRuntime(profileName);
344
+ if (meta.pid)
345
+ details.push(`cleared dead browser record ${meta.pid} (profile ${profileName})`);
297
346
  }
298
347
  return { reaped, details };
299
348
  }
349
+ /**
350
+ * Take ownership of a runtime record after an identity-checked attach succeeded.
351
+ *
352
+ * Called from the reuse branch in `BrowserService.connectProfile`, which has just
353
+ * probed `/json/version` and run `verifyBrowserIdentity` against the profile's
354
+ * expected browser family — so by this point the process on the recorded port is
355
+ * proven to be ours. Only then may a new service claim it.
356
+ *
357
+ * Rewrites `daemonPid` ALONE. Every other field — `pid`, `port`, `command`,
358
+ * `userDataDir`, `spawnedAt`, `kind`, `tunnelPid` — is the original launch's
359
+ * metadata and stays exactly as recorded: this is an ownership handover, not a
360
+ * re-registration, and rewriting the rest would lose the provenance the reaper and
361
+ * the hygiene pass both read. A record that has since disappeared is left alone
362
+ * rather than recreated from a partial view.
363
+ */
364
+ export function adoptProfileRuntimeOwner(profileName, daemonPid = process.pid) {
365
+ const meta = readProfileRuntimeMeta(profileName);
366
+ if (!meta)
367
+ return false;
368
+ if (meta.daemonPid === daemonPid)
369
+ return true;
370
+ const dir = getProfileRuntimeDir(profileName);
371
+ try {
372
+ fs.writeFileSync(path.join(dir, META_FILE), JSON.stringify({ ...meta, daemonPid }));
373
+ return true;
374
+ }
375
+ catch {
376
+ // Losing the handover is not worth failing an otherwise good attach; the next
377
+ // startup simply preserves the browser again and re-attaches.
378
+ return false;
379
+ }
380
+ }
300
381
  /**
301
382
  * True when ANY cache dir belonging to this profile still has a live browser or
302
383
  * SSH tunnel, or an open task. The composite naming (`<name>@<endpoint>`) means
@@ -478,7 +559,7 @@ function matchesCommand(pid, expectedCommand) {
478
559
  * determined. The process-listing API differs per OS: Windows has no `ps`, so
479
560
  * we query `tasklist` (CSV image name in column 1); POSIX uses `ps -o comm=`.
480
561
  */
481
- function liveProcessCommand(pid) {
562
+ export function liveProcessCommand(pid) {
482
563
  try {
483
564
  if (process.platform === 'win32') {
484
565
  const out = execFileSync('tasklist', ['/FI', `PID eq ${pid}`, '/NH', '/FO', 'CSV'], {
@@ -731,6 +731,16 @@ export declare class BrowserService {
731
731
  * async/promise evaluation fails honestly with an AppleScript error.
732
732
  */
733
733
  private evaluateArcNative;
734
+ /**
735
+ * Resolve a task's owned Arc tabs against one shared snapshot of every
736
+ * window/space/tab, rather than asking Arc per tab as `listArcTaskTabs` does.
737
+ *
738
+ * Same ownership rule as the live path — `requireArcTask`, then the recorded
739
+ * ref must be present in its original window and Space. The snapshot changes
740
+ * how often Arc is asked, never what counts as owned. Actions keep the live
741
+ * path, because a snapshot can go stale before it is acted on.
742
+ */
743
+ private listArcTaskTabsFromSnapshot;
734
744
  private listArcTaskTabs;
735
745
  /**
736
746
  * Close native Arc tabs owned by a task. Only closes tabs the task created
@@ -8,7 +8,7 @@ import { assertRemoteControlAllowedForRequest } from './remote-control.js';
8
8
  import { connectLocal } from './drivers/local.js';
9
9
  import { connectSSH, shellQuote } from './drivers/ssh.js';
10
10
  import { adoptLegacyRuntimeIfLocal, isLegacyEndpointKey, resolveBrowserTarget, shouldForkProfile, } from './resolve-target.js';
11
- import { clearProfileRuntime, listProfileCacheDirs, readProfileRuntimeMeta, isProcessAlive } from './runtime-state.js';
11
+ import { adoptProfileRuntimeOwner, clearProfileRuntime, listProfileCacheDirs, readProfileRuntimeMeta, isProcessAlive } from './runtime-state.js';
12
12
  import { resolveDomainSkill } from './domain-skills.js';
13
13
  import { generateTaskId, generateShortId, asConnectionKey, parseConnectionKey, keyBelongsToProfile, } from './types.js';
14
14
  import { reapAbandonedTasks, resolveLiveIdentities, taskOwnerIsGone, } from './hygiene.js';
@@ -333,6 +333,11 @@ export function arcCreateMarker() {
333
333
  export function isArcCreateMarker(url) {
334
334
  return url.startsWith(ARC_CREATE_MARKER_PREFIX);
335
335
  }
336
+ /** Run `fn` once per pass and share its promise, rejections included. */
337
+ function onceAsync(fn) {
338
+ let pending;
339
+ return () => (pending ??= fn());
340
+ }
336
341
  export class BrowserService {
337
342
  static SOURCE_PREFIX = {
338
343
  'rush-app': 'rush-app-',
@@ -1212,6 +1217,8 @@ export class BrowserService {
1212
1217
  await this.saveTaskState(task.profile, conn.tasks);
1213
1218
  return { tabId: resolvedTabId };
1214
1219
  }
1220
+ requireCdp(conn, 'enumerate');
1221
+ await conn.cdp.send('Target.activateTarget', { targetId: task.tabs[resolvedTabId] });
1215
1222
  task.currentTabId = resolvedTabId;
1216
1223
  await this.saveTaskState(task.profile, conn.tasks);
1217
1224
  return { tabId: resolvedTabId };
@@ -2196,9 +2203,20 @@ export class BrowserService {
2196
2203
  * it used to return (RUSH-2709).
2197
2204
  */
2198
2205
  async status(profileRef) {
2206
+ // Built before rehydration: a cold pass rehydrates every saved runtime dir
2207
+ // first, and each Arc one probed Arc on its own, spending the budget before
2208
+ // the snapshot existed. One enumeration and one liveness read cover the pass.
2209
+ const arc = {
2210
+ spaces: onceAsync(() => enumerateArcSpaces()),
2211
+ running: onceAsync(() => isArcRunning()),
2212
+ };
2199
2213
  // Reconnect live browsers after a daemon restart so status is not empty
2200
- // while tasks.json still holds open work.
2201
- await this.rehydrateAllFromDisk();
2214
+ // while tasks.json still holds open work. Rehydration is per runtime dir:
2215
+ // one unreadable saved profile is reported below, never fatal to the pass.
2216
+ const rehydrateErrors = new Map();
2217
+ await this.rehydrateAllFromDisk(arc.running, (key, err) => {
2218
+ rehydrateErrors.set(key, err instanceof Error ? err.message : String(err));
2219
+ });
2202
2220
  // A caller who pasted a runtime key out of an older listing still gets the
2203
2221
  // profile it belongs to, rather than an empty result.
2204
2222
  const profileName = profileRef ? parseConnectionKey(profileRef).profile : undefined;
@@ -2208,25 +2226,99 @@ export class BrowserService {
2208
2226
  ? [...this.connections.keys()].filter((k) => keyBelongsToProfile(k, profileName))
2209
2227
  : Array.from(this.connections.keys());
2210
2228
  for (const key of candidates) {
2211
- const status = await this.getProfileStatus(key);
2229
+ // Read-only: a profile that cannot be read is reported as unreadable, not
2230
+ // allowed to abort the listing. Before this, a single stale Arc task made
2231
+ // `status` throw and EVERY profile vanished, including healthy ones.
2232
+ let status;
2233
+ try {
2234
+ status = await this.getProfileStatus(key, arc);
2235
+ }
2236
+ catch (err) {
2237
+ const parsed = parseConnectionKey(key);
2238
+ statuses.push({
2239
+ name: this.connections.get(key)?.profile ?? parsed.profile,
2240
+ endpoint: parsed.endpoint,
2241
+ key,
2242
+ running: false,
2243
+ tasks: [],
2244
+ unavailable: err instanceof Error ? err.message : String(err),
2245
+ });
2246
+ seenProfiles.add(parsed.profile);
2247
+ continue;
2248
+ }
2212
2249
  if (status) {
2213
2250
  statuses.push(status);
2214
2251
  seenProfiles.add(status.name);
2215
2252
  }
2216
2253
  }
2254
+ // Reported BEFORE the disk reconcile, and keyed by the runtime dir that
2255
+ // actually failed. Reconcile reads the same `tasks.json`, so without this
2256
+ // ordering one corrupt runtime produced two rows for the same profile: one
2257
+ // keyed here and one unkeyed from reconcile, which the dedupe then missed.
2258
+ for (const [key, message] of rehydrateErrors) {
2259
+ const parsed = parseConnectionKey(key);
2260
+ if (profileName && parsed.profile !== profileName)
2261
+ continue;
2262
+ if (statuses.some((status) => status.key === key))
2263
+ continue;
2264
+ statuses.push({
2265
+ name: parsed.profile,
2266
+ endpoint: parsed.endpoint,
2267
+ key,
2268
+ running: false,
2269
+ tasks: [],
2270
+ unavailable: message,
2271
+ });
2272
+ seenProfiles.add(parsed.profile);
2273
+ }
2274
+ // Disk reconcile reads `tasks.json` too, so it fails on exactly the same
2275
+ // corrupt file — report that profile rather than let it empty the table.
2276
+ const reconcile = async (name) => {
2277
+ const failures = new Map();
2278
+ let reconciled = null;
2279
+ try {
2280
+ reconciled = await this.reconcileFromDisk(name, (key, err) => {
2281
+ failures.set(key, err instanceof Error ? err.message : String(err));
2282
+ });
2283
+ }
2284
+ catch (err) {
2285
+ // Listing the profile's runtime dirs failed, so no single runtime is at
2286
+ // fault and none is invented: this is reported at profile level.
2287
+ statuses.push({
2288
+ name,
2289
+ running: false,
2290
+ tasks: [],
2291
+ unavailable: err instanceof Error ? err.message : String(err),
2292
+ });
2293
+ return;
2294
+ }
2295
+ if (reconciled)
2296
+ statuses.push(reconciled);
2297
+ // Keyed by the runtime dir that actually failed, so a profile with several
2298
+ // runtimes names the unreadable one rather than guessing at the first.
2299
+ for (const [key, message] of failures) {
2300
+ if (statuses.some((status) => status.key === key))
2301
+ continue;
2302
+ const parsed = parseConnectionKey(key);
2303
+ statuses.push({
2304
+ name,
2305
+ endpoint: parsed.endpoint,
2306
+ key,
2307
+ running: false,
2308
+ tasks: [],
2309
+ unavailable: message,
2310
+ });
2311
+ }
2312
+ };
2217
2313
  if (!profileName) {
2218
2314
  for (const profile of await listProfiles()) {
2219
2315
  if (seenProfiles.has(profile.name))
2220
2316
  continue;
2221
- const reconciled = await this.reconcileFromDisk(profile.name);
2222
- if (reconciled)
2223
- statuses.push(reconciled);
2317
+ await reconcile(profile.name);
2224
2318
  }
2225
2319
  }
2226
2320
  else if (statuses.length === 0) {
2227
- const reconciled = await this.reconcileFromDisk(profileName);
2228
- if (reconciled)
2229
- statuses.push(reconciled);
2321
+ await reconcile(profileName);
2230
2322
  }
2231
2323
  return statuses;
2232
2324
  }
@@ -2239,14 +2331,27 @@ export class BrowserService {
2239
2331
  * ({@link listProfileCacheDirs}), including the legacy pre-composite dir named
2240
2332
  * exactly the profile.
2241
2333
  */
2242
- async reconcileFromDisk(profileName) {
2334
+ async reconcileFromDisk(profileName,
2335
+ /**
2336
+ * Read-only callers pass a sink so an unreadable runtime dir is attributed
2337
+ * to the dir that actually failed, and the remaining dirs are still tried.
2338
+ * Callers without a sink keep the strict behaviour.
2339
+ */
2340
+ onError) {
2243
2341
  const dirs = listProfileCacheDirs(profileName);
2244
2342
  for (const dir of dirs) {
2245
2343
  const key = asConnectionKey(path.basename(dir));
2246
- const info = getRunningChromeInfo(key);
2247
- if (!info)
2248
- continue;
2249
- return this.profileStatusFromDisk(profileName, key, info.port, info.pid);
2344
+ try {
2345
+ const info = getRunningChromeInfo(key);
2346
+ if (!info)
2347
+ continue;
2348
+ return await this.profileStatusFromDisk(profileName, key, info.port, info.pid);
2349
+ }
2350
+ catch (err) {
2351
+ if (!onError)
2352
+ throw err;
2353
+ onError(key, err);
2354
+ }
2250
2355
  }
2251
2356
  return null;
2252
2357
  }
@@ -2771,6 +2876,12 @@ export class BrowserService {
2771
2876
  await cdp.connect(wsUrl);
2772
2877
  await this.enableDomains(cdp);
2773
2878
  const tasks = this.loadTaskState(key);
2879
+ // The attach is now PROVEN: the stored port answered /json/version and
2880
+ // `verifyBrowserIdentity` matched the profile's browser family. Only here
2881
+ // may this service claim a browser the startup reaper deliberately left
2882
+ // alone — it rewrites `daemonPid` only, keeping the original launch's
2883
+ // metadata. An ambiguous identity throws above and adopts nothing.
2884
+ adoptProfileRuntimeOwner(key);
2774
2885
  return {
2775
2886
  backend: 'cdp',
2776
2887
  cdp,
@@ -3339,7 +3450,13 @@ export class BrowserService {
3339
3450
  * Returns null when nothing is speaking CDP so callers can fall through
3340
3451
  * to disk reconcile.
3341
3452
  */
3342
- async attachRunningProfile(key, diskTasks) {
3453
+ async attachRunningProfile(key, diskTasks,
3454
+ /**
3455
+ * Shared Arc liveness for one status pass; each saved Arc runtime used to
3456
+ * probe on its own. Callers with no pass to share pass nothing and probe
3457
+ * directly, as before.
3458
+ */
3459
+ arcRunning) {
3343
3460
  const { profile: bare, endpoint: endpointFromKey } = parseConnectionKey(key);
3344
3461
  const profile = await getProfile(bare);
3345
3462
  if (!profile)
@@ -3363,7 +3480,7 @@ export class BrowserService {
3363
3480
  // is still running. No port, no CDP — just verify Arc is alive and restore
3364
3481
  // the task state.
3365
3482
  if (resolved.target.startsWith('arc-native:')) {
3366
- if (process.platform !== 'darwin' || !(await isArcRunning()))
3483
+ if (process.platform !== 'darwin' || !(await (arcRunning ?? isArcRunning)()))
3367
3484
  return null;
3368
3485
  const tasks = this.loadTaskState(key);
3369
3486
  for (const [k, t] of diskTasks) {
@@ -3398,6 +3515,10 @@ export class BrowserService {
3398
3515
  const port = parseEndpointUrl(resolved.target)?.port ?? 9600;
3399
3516
  try {
3400
3517
  const ff = await connectFirefox(profile, key, port, { profileDir: profile.userDataDir });
3518
+ // A served port that accepted a fresh BiDi session is this profile's own
3519
+ // Firefox, so claim the record for this daemon (see the local-CDP claim
3520
+ // below for why adoption waits for a proven attach).
3521
+ adoptProfileRuntimeOwner(key);
3401
3522
  const tasks = this.loadTaskState(key);
3402
3523
  for (const [k, t] of diskTasks) {
3403
3524
  if (!tasks.has(k))
@@ -3476,6 +3597,14 @@ export class BrowserService {
3476
3597
  const cdp = new CDPClient();
3477
3598
  await cdp.connect(wsUrl);
3478
3599
  await this.enableDomains(cdp);
3600
+ // This is the path a replacement daemon takes to recover a browser its
3601
+ // predecessor launched, so it is where ownership actually transfers. Only
3602
+ // now — after `verifyBrowserIdentity` confirmed the browser family on the
3603
+ // recorded port and CDP is attached — is it proven that this record
3604
+ // describes a browser we are driving. `adoptProfileRuntimeOwner` rewrites
3605
+ // `daemonPid` alone; an ambiguous identity throws above and adopts
3606
+ // nothing, leaving the record (and the browser) untouched.
3607
+ adoptProfileRuntimeOwner(key);
3479
3608
  const tasks = this.loadTaskState(key);
3480
3609
  for (const [k, t] of diskTasks) {
3481
3610
  if (!tasks.has(k))
@@ -3555,7 +3684,14 @@ export class BrowserService {
3555
3684
  }
3556
3685
  return existing;
3557
3686
  }
3558
- async rehydrateAllFromDisk() {
3687
+ async rehydrateAllFromDisk(arcRunning,
3688
+ /**
3689
+ * Read-only callers pass a sink to make rehydration per-runtime: one saved
3690
+ * dir that cannot be read (corrupt `tasks.json`, an Arc Apple Event that
3691
+ * times out) is reported through this instead of aborting the pass. Callers
3692
+ * without a sink keep the strict behaviour and the error propagates.
3693
+ */
3694
+ onError) {
3559
3695
  const runtimeRoot = getBrowserRuntimeDir();
3560
3696
  let dirNames = [];
3561
3697
  try {
@@ -3579,24 +3715,31 @@ export class BrowserService {
3579
3715
  // Skip non-runtime dirs (e.g. profiles/)
3580
3716
  if (dirName === 'profiles' || dirName === 'sessions' || dirName === 'exports')
3581
3717
  continue;
3582
- const tasks = this.loadTaskState(key);
3583
- if (tasks.size === 0)
3584
- continue;
3585
- // Soft attach only — never launch / never clear pid files.
3586
- const conn = await this.attachRunningProfile(key, tasks);
3587
- if (!conn)
3588
- continue;
3589
- // A concurrent rehydrate may have registered one for this key while we
3590
- // awaited the attach; keep the winner and tear our loser's tunnel down.
3591
- const registered = this.registerRehydratedConnection(key, conn);
3592
- if (registered !== conn)
3593
- continue;
3594
3718
  try {
3595
- if (conn.backend !== 'arc-native' && conn.backend !== 'bidi')
3596
- await this.applyDefaultDownloadBehavior(conn, key);
3719
+ const tasks = this.loadTaskState(key);
3720
+ if (tasks.size === 0)
3721
+ continue;
3722
+ // Soft attach only — never launch / never clear pid files.
3723
+ const conn = await this.attachRunningProfile(key, tasks, arcRunning);
3724
+ if (!conn)
3725
+ continue;
3726
+ // A concurrent rehydrate may have registered one for this key while we
3727
+ // awaited the attach; keep the winner and tear our loser's tunnel down.
3728
+ const registered = this.registerRehydratedConnection(key, conn);
3729
+ if (registered !== conn)
3730
+ continue;
3731
+ try {
3732
+ if (conn.backend !== 'arc-native' && conn.backend !== 'bidi')
3733
+ await this.applyDefaultDownloadBehavior(conn, key);
3734
+ }
3735
+ catch {
3736
+ // Non-fatal for rehydrate.
3737
+ }
3597
3738
  }
3598
- catch {
3599
- // Non-fatal for rehydrate.
3739
+ catch (err) {
3740
+ if (!onError)
3741
+ throw err;
3742
+ onError(key, err);
3600
3743
  }
3601
3744
  }
3602
3745
  }
@@ -3688,14 +3831,29 @@ export class BrowserService {
3688
3831
  return tabs;
3689
3832
  }
3690
3833
  /** Live status for ONE registered connection, addressed by its runtime key. */
3691
- async getProfileStatus(key) {
3834
+ async getProfileStatus(key, arc) {
3692
3835
  const conn = this.connections.get(key);
3693
3836
  if (!conn)
3694
3837
  return null;
3695
3838
  if (conn.backend === 'arc-native') {
3839
+ // One snapshot and one liveness read for the whole pass, shared across
3840
+ // every task and every Arc profile (built in `status`).
3841
+ const spaces = arc?.spaces ?? onceAsync(() => enumerateArcSpaces());
3842
+ const running = arc?.running ?? onceAsync(() => isArcRunning());
3696
3843
  const tasks = [];
3697
3844
  for (const task of conn.tasks.values()) {
3698
- const tabs = await this.listArcTaskTabs(task);
3845
+ // The moved-tab refusal is the ownership guarantee actions rely on and
3846
+ // stays strict. `status` only reads, so one stale task must not take down
3847
+ // its neighbours: report what disk knows plus the reason, and leave
3848
+ // `tabs` ABSENT rather than empty so nothing claims a moved tab is live.
3849
+ let tabs = [];
3850
+ let unavailable;
3851
+ try {
3852
+ tabs = await this.listArcTaskTabsFromSnapshot(task, spaces);
3853
+ }
3854
+ catch (err) {
3855
+ unavailable = err instanceof Error ? err.message : String(err);
3856
+ }
3699
3857
  const domains = tabs.flatMap((tab) => {
3700
3858
  try {
3701
3859
  const domain = new URL(tab.url).hostname.replace(/^www\./, '');
@@ -3714,6 +3872,7 @@ export class BrowserService {
3714
3872
  createdAt: task.createdAt,
3715
3873
  tabs: tabs.length ? tabs : undefined,
3716
3874
  domains: [...new Set(domains)],
3875
+ unavailable,
3717
3876
  });
3718
3877
  }
3719
3878
  const parsed = parseConnectionKey(key);
@@ -3721,7 +3880,7 @@ export class BrowserService {
3721
3880
  name: conn.profile ?? parsed.profile,
3722
3881
  endpoint: parsed.endpoint,
3723
3882
  key,
3724
- running: await isArcRunning(),
3883
+ running: await running(),
3725
3884
  port: 0,
3726
3885
  pid: 0,
3727
3886
  tasks,
@@ -4197,6 +4356,39 @@ export class BrowserService {
4197
4356
  }
4198
4357
  return executeJavaScript(ref, expression);
4199
4358
  }
4359
+ /**
4360
+ * Resolve a task's owned Arc tabs against one shared snapshot of every
4361
+ * window/space/tab, rather than asking Arc per tab as `listArcTaskTabs` does.
4362
+ *
4363
+ * Same ownership rule as the live path — `requireArcTask`, then the recorded
4364
+ * ref must be present in its original window and Space. The snapshot changes
4365
+ * how often Arc is asked, never what counts as owned. Actions keep the live
4366
+ * path, because a snapshot can go stale before it is acted on.
4367
+ */
4368
+ async listArcTaskTabsFromSnapshot(task, spaces) {
4369
+ // Before the snapshot on purpose: a ref naming a window its task does not
4370
+ // own is a moved tab on the record alone, so it needs no round trip and
4371
+ // reports the right reason even when Arc is unreachable.
4372
+ const native = this.requireArcTask(task);
4373
+ const snapshot = await spaces();
4374
+ const tabs = [];
4375
+ for (const [shortId, ref] of Object.entries(native.tabs)) {
4376
+ const live = snapshot
4377
+ .find((space) => space.windowId === ref.windowId && space.spaceId === ref.spaceId)
4378
+ ?.tabs.find((tab) => tab.tabId === ref.tabId);
4379
+ if (!live) {
4380
+ throw new Error(`Owned Arc tab ${shortId} is missing from its original window/Space; refusing to adopt a moved tab.`);
4381
+ }
4382
+ tabs.push({
4383
+ id: shortId,
4384
+ url: live.url,
4385
+ title: live.title,
4386
+ task: task.name,
4387
+ current: shortId === task.currentTabId,
4388
+ });
4389
+ }
4390
+ return tabs;
4391
+ }
4200
4392
  async listArcTaskTabs(task) {
4201
4393
  const native = this.requireArcTask(task);
4202
4394
  const tabs = [];
@@ -358,6 +358,15 @@ export interface ProfileStatus {
358
358
  /** The port declared in the profile's first endpoint, when it differs from the running port. */
359
359
  configuredPort?: number;
360
360
  tasks: TaskStatus[];
361
+ /**
362
+ * Why this profile's live state could not be read, when it could not be.
363
+ *
364
+ * `status` is read-only, so one unreadable profile must not hide the others:
365
+ * the profile is still listed, with what is known from disk, and this says
366
+ * what is missing. Absent means the read succeeded — it is never set to paper
367
+ * over a failure that did not happen.
368
+ */
369
+ unavailable?: string;
361
370
  }
362
371
  export interface TaskStatus {
363
372
  id: string;
@@ -376,6 +385,15 @@ export interface TaskStatus {
376
385
  current?: boolean;
377
386
  task?: string;
378
387
  }>;
388
+ /**
389
+ * Why this task's live tabs could not be enumerated, when they could not be.
390
+ *
391
+ * Set together with an ABSENT {@link tabs} — the recorded {@link tabCount}
392
+ * still reports what the task owns on disk, but nothing claims those tabs are
393
+ * live, because that is exactly what could not be verified. An Arc tab moved
394
+ * to another window or Space is the case this exists for.
395
+ */
396
+ unavailable?: string;
379
397
  }
380
398
  export interface HistoricalTask {
381
399
  id: string;
@@ -23,6 +23,7 @@ import * as yaml from 'yaml';
23
23
  import { listResources, resolveResource } from './resources.js';
24
24
  import { probeCapture } from './probe.js';
25
25
  import { composeWin32CommandLine } from './platform/index.js';
26
+ import { execFileShellSpec } from './platform/exec.js';
26
27
  import { localBinDir } from './platform/posixpath.js';
27
28
  // ─── Validation primitives ───────────────────────────────────────────────────
28
29
  /** Token allowed inside `check:` strings — letters, digits, underscore, dot, slash, dash. */
@@ -457,7 +458,8 @@ export function buildInstallCommand(method) {
457
458
  function runInstallMethod(method) {
458
459
  if ('npm' in method) {
459
460
  assertNpmPackage(method.npm);
460
- const r = spawnSync('npm', ['install', '-g', method.npm], { stdio: 'inherit' });
461
+ const invocation = execFileShellSpec('npm', ['install', '-g', method.npm]);
462
+ const r = spawnSync(invocation.command, invocation.args, { stdio: 'inherit', shell: invocation.shell });
461
463
  if (r.status !== 0) {
462
464
  throw new Error(`npm install -g ${method.npm} exited with status ${r.status ?? 'unknown'}`);
463
465
  }
@@ -16,7 +16,7 @@ import { insertTask, updateTaskStatus } from './store.js';
16
16
  import { renderStream } from './stream.js';
17
17
  import { MissingTargetError, MAX_IMAGES_PER_DISPATCH } from './types.js';
18
18
  import { emit } from '../feed/events.js';
19
- import { shareRuntimeEnv } from '../share/config.js';
19
+ import { shareRuntimeEnv } from '../share-runtime.js';
20
20
  /** Map a supported image file extension to its wire mimeType. Rejects anything else. */
21
21
  function imageMimeFromPath(file) {
22
22
  const ext = path.extname(file).toLowerCase();
@@ -0,0 +1,10 @@
1
+ export declare const DEFAULT_CF_BUNDLE = "cloudflare";
2
+ /** Cloudflare API credentials for provisioning, read from `cloudflare` (or a
3
+ * user-named bundle). Fuzzy-matches key names so it works across bundle layouts. */
4
+ export declare function readCloudflareCreds(bundle?: string, override?: {
5
+ apiToken?: string;
6
+ accountId?: string;
7
+ }): {
8
+ apiToken: string;
9
+ accountId: string;
10
+ };