@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,4693 +0,0 @@
1
- import * as fs from 'fs';
2
- import * as os from 'os';
3
- import * as path from 'path';
4
- import { BrowserCdpConnectionError, CDPClient, discoverBrowserWsUrl, verifyBrowserIdentity, } from './cdp.js';
5
- import { getProfile, getProfileRuntimeDir, getProfileDownloadsDir, getProfileSessionsDir, getBrowserRuntimeDir, listProfiles, extractConfiguredPort, resolveEndpoint, parseEndpointUrl, } from './profiles.js';
6
- import { killChrome, getRunningChromeInfo, launchBrowser, allocatePort } from './chrome.js';
7
- import { assertRemoteControlAllowedForRequest } from './remote-control.js';
8
- import { connectLocal } from './drivers/local.js';
9
- import { connectSSH, shellQuote } from './drivers/ssh.js';
10
- import { adoptLegacyRuntimeIfLocal, isLegacyEndpointKey, resolveBrowserTarget, shouldForkProfile, } from './resolve-target.js';
11
- import { adoptProfileRuntimeOwner, clearProfileRuntime, listProfileCacheDirs, readProfileRuntimeMeta, isProcessAlive } from './runtime-state.js';
12
- import { resolveDomainSkill } from './domain-skills.js';
13
- import { generateTaskId, generateShortId, asConnectionKey, parseConnectionKey, keyBelongsToProfile, } from './types.js';
14
- import { reapAbandonedTasks, resolveLiveIdentities, taskOwnerIsGone, } from './hygiene.js';
15
- import { taskMatchesCaller } from './caller-identity.js';
16
- import { getRefs, resolveRefToCoords, describeRefs, healRef } from './refs.js';
17
- import { clickAtCoords, hoverAtCoords, scrollAtCoords, typeText, pressKey, focusNode } from './input.js';
18
- import { typeEditorText } from './editor.js';
19
- import { arcClickExpression, arcFillExpression, arcRefsExpression, arcScrollExpression, parseArcRefsResult, } from './arc-dom.js';
20
- import { detectUploadPattern, stageUploadFile, uploadToDropTarget, uploadToFileInput, uploadViaFileChooser, } from './upload.js';
21
- import { emit } from '../feed/events.js';
22
- import { resolveActor } from '../actor.js';
23
- import { recordBrowserSession } from '../session/db.js';
24
- import { sshExecAsync } from '../ssh-exec.js';
25
- import { resolveFfmpeg } from './ffmpeg.js';
26
- import { FirefoxCapabilityError, connectFirefox, bidiTopLevelContexts, bidiCreateTab, bidiNavigate, bidiReload, bidiCloseTab, bidiActivate, bidiEvaluate, bidiScreenshot, bidiClickAt, } from './drivers/firefox.js';
27
- import { ArcNativeCapabilityError, executeJavaScript, navigateArcTab, closeArcTab, createArcTab, enumerateArcSpaces, isArcRunning, resolveArcTab, restoreArcSelection, selectWindowTab, selectArcTab, } from './drivers/arc.js';
28
- /**
29
- * Canonical form for comparing a requested URL against what CDP reports for a
30
- * live target. `new URL('https://example.com').href` is `https://example.com/`,
31
- * which is exactly what Chrome reports — comparing the raw strings would miss
32
- * every bare-origin match. Unparseable input compares as itself.
33
- */
34
- function canonicalTabUrl(raw) {
35
- try {
36
- return new URL(raw).href;
37
- }
38
- catch {
39
- return raw;
40
- }
41
- }
42
- function isPathInside(candidate, dir) {
43
- const rel = path.relative(dir, candidate);
44
- return rel === '' || (!!rel && !rel.startsWith('..') && !path.isAbsolute(rel));
45
- }
46
- export function resolveScreenshotOutputPath(outputPath, automaticPath) {
47
- if (!outputPath)
48
- return automaticPath;
49
- const runtimeDir = getBrowserRuntimeDir();
50
- fs.mkdirSync(runtimeDir, { recursive: true });
51
- const runtimeReal = fs.realpathSync(runtimeDir);
52
- const requested = path.resolve(outputPath);
53
- const parent = path.dirname(requested);
54
- fs.mkdirSync(parent, { recursive: true });
55
- const parentReal = fs.realpathSync(parent);
56
- const resolved = path.join(parentReal, path.basename(requested));
57
- if (!isPathInside(resolved, runtimeReal)) {
58
- return automaticPath;
59
- }
60
- return resolved;
61
- }
62
- /**
63
- * Read width/height from a JPEG buffer by walking SOF markers. Returns null
64
- * if the buffer doesn't start with the JPEG SOI marker or no SOF segment is
65
- * found. We use this on every screenshot so the CLI can surface the actual
66
- * captured pixel dimensions (which differ from viewport size at non-1x DPR).
67
- */
68
- function readPngDimensions(buf) {
69
- // PNG signature (8 bytes) + IHDR chunk: length (4) + 'IHDR' (4) + width (4) + height (4)
70
- if (buf.length < 24)
71
- return null;
72
- const sig = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
73
- for (let i = 0; i < 8; i++)
74
- if (buf[i] !== sig[i])
75
- return null;
76
- return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
77
- }
78
- function readJpegDimensions(buf) {
79
- if (buf.length < 4 || buf[0] !== 0xff || buf[1] !== 0xd8)
80
- return null;
81
- let i = 2;
82
- while (i + 9 < buf.length) {
83
- if (buf[i] !== 0xff)
84
- return null;
85
- const marker = buf[i + 1];
86
- // SOF0–SOFn carry dimensions, except DHT (0xC4), JPG (0xC8), DAC (0xCC).
87
- if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
88
- return { height: buf.readUInt16BE(i + 5), width: buf.readUInt16BE(i + 7) };
89
- }
90
- i += 2 + buf.readUInt16BE(i + 2);
91
- }
92
- return null;
93
- }
94
- /**
95
- * Parse a `targetFilter` string into its kind + value, or return `null`
96
- * when the input is missing or malformed. Filter syntax:
97
- * - `url:<substring>` — picks the first page target whose URL contains the substring
98
- * - `title:<substring>` — picks the first page target whose title contains the substring
99
- *
100
- * The match is case-insensitive on both sides because Electron apps
101
- * frequently lowercase or title-case their target metadata in unpredictable ways.
102
- */
103
- export function parseTargetFilter(filter) {
104
- if (!filter)
105
- return null;
106
- const idx = filter.indexOf(':');
107
- if (idx <= 0)
108
- return null;
109
- const kind = filter.slice(0, idx).trim().toLowerCase();
110
- // Strip whitespace around the value so `url: https://x` (with a copy-pasted
111
- // space after the colon) doesn't silently fail to match — `.includes(' x')`
112
- // never hits a URL because URLs don't contain spaces.
113
- const value = filter.slice(idx + 1).trim();
114
- if (kind !== 'url' && kind !== 'title')
115
- return null;
116
- if (!value)
117
- return null;
118
- return { kind, value };
119
- }
120
- /**
121
- * URLs that the skip-invisible heuristic excludes when no explicit filter
122
- * matches. These are page targets Electron apps ship for housekeeping;
123
- * picking one means screenshots come back blank.
124
- */
125
- const INVISIBLE_URL_PATTERNS = [
126
- /^about:blank$/i,
127
- /^file:\/\//i,
128
- /\/_desktop-background-service(\?|$|\/)/i,
129
- /\/_internal(\?|$|\/)/i,
130
- /\/_background(\?|$|\/)/i,
131
- ];
132
- function isLikelyInvisible(url) {
133
- if (!url)
134
- return true;
135
- return INVISIBLE_URL_PATTERNS.some((re) => re.test(url));
136
- }
137
- /**
138
- * Choose the CDP page target that represents the visible UI.
139
- *
140
- * Order:
141
- * 1. If `filter` is set and parseable, narrow to page targets matching it
142
- * (case-insensitive substring). Among matches, prefer one that is not in
143
- * `INVISIBLE_URL_PATTERNS` — this is the tiebreaker that makes a coarse
144
- * filter like `url:https://www.canva.com/` skip the background service
145
- * (`https://www.canva.com/_desktop-background-service` *also* matches the
146
- * substring). If every match is invisible, return the first match so the
147
- * caller still gets something rather than silently falling through.
148
- * An explicit filter that finds *no* match returns `undefined` — callers
149
- * should surface this as an error rather than create an orphan window.
150
- * 2. If `filter` is unset (or unparseable), apply the skip-invisible heuristic
151
- * across all page targets.
152
- * 3. As a last resort, return the first page target.
153
- */
154
- export function pickWindowTarget(targets, filter) {
155
- const pages = targets.filter((t) => t.type === 'page');
156
- if (pages.length === 0)
157
- return undefined;
158
- const parsed = parseTargetFilter(filter);
159
- if (parsed) {
160
- const needle = parsed.value.toLowerCase();
161
- const matches = pages.filter((t) => {
162
- const hay = (parsed.kind === 'url' ? t.url : t.title) ?? '';
163
- return hay.toLowerCase().includes(needle);
164
- });
165
- if (matches.length === 0)
166
- return undefined;
167
- const visible = matches.find((t) => !isLikelyInvisible(t.url));
168
- return visible ?? matches[0];
169
- }
170
- const visible = pages.find((t) => !isLikelyInvisible(t.url));
171
- if (visible)
172
- return visible;
173
- return pages[0];
174
- }
175
- /**
176
- * Parse a `--since`/`--until` value. Accepts ISO-8601 absolute timestamps
177
- * or relative offsets like `30s`, `5m`, `2h`, `1d`.
178
- */
179
- export function parseSinceUntil(s) {
180
- const ms = Date.parse(s);
181
- if (!isNaN(ms))
182
- return new Date(ms);
183
- const m = s.match(/^(\d+)([smhd])$/);
184
- if (!m)
185
- throw new Error(`Invalid since/until: ${s}`);
186
- const n = parseInt(m[1], 10);
187
- const unitMs = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 };
188
- return new Date(Date.now() - n * unitMs[m[2]]);
189
- }
190
- async function execSSH(host, cmd) {
191
- const res = await sshExecAsync(host, cmd, { timeoutMs: 10_000 });
192
- if (res.code !== 0) {
193
- throw new Error(`ssh ${host} failed${res.stderr.trim() ? `: ${res.stderr.trim()}` : ''}`);
194
- }
195
- return res.stdout;
196
- }
197
- export function readNewestMatchingRemoteFileCommand(dir, prefix, tailLines) {
198
- const glob = `${shellQuote(dir)}/${prefix}*.jsonl`;
199
- return `latest=$(ls -1t ${glob} 2>/dev/null | head -1); if [ -n "$latest" ]; then tail -n ${tailLines} "$latest"; fi`;
200
- }
201
- export function readNewestMatchingFile(dir, prefix, tailLines) {
202
- let entries;
203
- try {
204
- entries = fs.readdirSync(dir);
205
- }
206
- catch {
207
- return '';
208
- }
209
- const candidates = entries
210
- .filter((f) => f.startsWith(prefix) && f.endsWith('.jsonl'))
211
- .map((f) => ({ f, mtime: fs.statSync(path.join(dir, f)).mtimeMs }))
212
- .sort((a, b) => b.mtime - a.mtime);
213
- if (candidates.length === 0)
214
- return '';
215
- const lines = fs
216
- .readFileSync(path.join(dir, candidates[0].f), 'utf8')
217
- .split('\n')
218
- .filter(Boolean);
219
- return lines.slice(-tailLines).join('\n');
220
- }
221
- function expandHome(p) {
222
- if (p.startsWith('~/'))
223
- return path.join(os.homedir(), p.slice(2));
224
- if (p === '~')
225
- return os.homedir();
226
- return p;
227
- }
228
- /**
229
- * Probe a cached connection before reuse. A WebSocket can quietly transition
230
- * to CLOSED without anyone noticing — most commonly when the user kills the
231
- * browser process by hand. `Browser.getVersion` is the lightest CDP call we
232
- * can make; if it doesn't round-trip within 1s the connection is dead.
233
- */
234
- async function isConnHealthy(conn, timeoutMs = 1000) {
235
- // Native Arc connections: healthy if Arc is still running (no CDP socket to probe).
236
- if (conn.backend === 'arc-native') {
237
- return isArcRunning();
238
- }
239
- // Firefox BiDi connections: healthy while the WebSocket is open (a killed
240
- // Firefox closes it). No lightweight round-trip verb is needed.
241
- if (conn.backend === 'bidi') {
242
- return conn.bidi.isOpen;
243
- }
244
- if (!conn.cdp.isOpen)
245
- return false;
246
- try {
247
- await Promise.race([
248
- conn.cdp.send('Browser.getVersion'),
249
- new Promise((_, reject) => setTimeout(() => reject(new Error('healthcheck timeout')), timeoutMs)),
250
- ]);
251
- return true;
252
- }
253
- catch {
254
- return false;
255
- }
256
- }
257
- /**
258
- * Narrow `conn` to the CDP backend, or throw the backend's capability error.
259
- * Because the union now carries `arc-native` AND `bidi`, every action that
260
- * reaches `conn.cdp` without a preceding backend branch fails to type-check —
261
- * the compiler is the checklist that no verb silently calls CDP on a
262
- * non-CDP connection.
263
- */
264
- function requireCdp(conn, capability) {
265
- if (conn.backend === 'arc-native')
266
- throw new ArcNativeCapabilityError(capability);
267
- if (conn.backend === 'bidi')
268
- throw new FirefoxCapabilityError(capability);
269
- }
270
- /** Join error lines so callers get a next command, not a dead-end message. */
271
- export function actionable(...lines) {
272
- return lines.filter((l) => l != null && l !== '').join('\n');
273
- }
274
- /**
275
- * Arc DOES expose CDP page targets and honors `Page.navigate` on the ones the
276
- * user already has open (measured live against Arc: 33 targets, PR #2786), so
277
- * agents browser drives an attached Arc by reusing existing tabs. What it CANNOT
278
- * survive is `Target.createTarget` — opening a brand-new tab crashes the user's
279
- * Arc window (verified live, PR #2778). Tab creation is the one CDP-only op that
280
- * fails clearly here (PHNX-2399): every tab-creating path throws this actionable
281
- * error instead of crashing Arc, and steers the caller to reuse an existing
282
- * tab/Space (via `--target-filter`) or to a Chromium-family browser for new tabs.
283
- */
284
- export function arcNotDrivableError(profileName) {
285
- const scope = profileName ? ` (profile "${profileName}")` : '';
286
- return new Error(actionable(`Browser "arc"${scope} cannot open a NEW tab: Arc crashes when a tab is created`, 'over CDP (Target.createTarget). agents browser drives Arc by attaching to your', 'running window and reusing an EXISTING tab — bind the profile to the tab/Space you', 'want with --target-filter (url:<substring> or title:<substring>), or navigate to a', 'URL that is already open. To open brand-new tabs, use a Chromium-family browser:', ' agents browser profiles create <name> --browser comet'));
287
- }
288
- /** Derive a human label from an explicit title or a navigated URL. */
289
- export function deriveTaskLabel(opts) {
290
- if (opts.title?.trim())
291
- return opts.title.trim();
292
- if (opts.existing && opts.existing !== 'untitled')
293
- return opts.existing;
294
- if (opts.url) {
295
- try {
296
- const host = new URL(opts.url).hostname.replace(/^www\./, '');
297
- if (host)
298
- return host;
299
- }
300
- catch {
301
- /* unparseable */
302
- }
303
- }
304
- return opts.existing ?? 'untitled';
305
- }
306
- /**
307
- * Resolve the identity stamped on a task at start: WHO (`owner`) and WHICH run
308
- * (`launchId`). The forwarded values come from the caller's own CLI process and
309
- * are authoritative — the browser daemon is shared and long-lived, so resolving
310
- * the actor daemon-side (the RUSH-2020 bug) mis-attributes every task to the
311
- * daemon's owner. `resolveLocalActor` is consulted ONLY when no actor was
312
- * forwarded (a CLI that predates the field, mid-rollout) — never to override a
313
- * forwarded one.
314
- */
315
- export function resolveTaskIdentity(forwarded, resolveLocalActor) {
316
- return {
317
- owner: forwarded.actor ?? resolveLocalActor(),
318
- launchId: forwarded.launchId,
319
- sessionId: forwarded.sessionId,
320
- };
321
- }
322
- /**
323
- * The unique URL a native Arc tab is created at before it is navigated to its
324
- * real target (PHNX-2399). Arc's `make new tab` accepts only http(s) URLs (it
325
- * rejects `data:` and `about:` outright), so the marker is an https URL on the
326
- * reserved `.invalid` TLD: DNS fails locally, no request leaves the machine,
327
- * and the tab keeps the exact URL for the enumerate-and-match that follows.
328
- */
329
- const ARC_CREATE_MARKER_PREFIX = 'https://agents-browser.invalid/';
330
- export function arcCreateMarker() {
331
- return `${ARC_CREATE_MARKER_PREFIX}${crypto.randomUUID()}`;
332
- }
333
- export function isArcCreateMarker(url) {
334
- return url.startsWith(ARC_CREATE_MARKER_PREFIX);
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
- }
341
- export class BrowserService {
342
- static SOURCE_PREFIX = {
343
- 'rush-app': 'rush-app-',
344
- 'rush-cli': 'rush-cli-',
345
- };
346
- /**
347
- * Live browsers, keyed by {@link ConnectionKey} — NOT by profile name. The
348
- * branded key type is what makes `connections.get(someProfileName)` (the
349
- * RUSH-2709 miss that made `status --profile <name>` return empty for a
350
- * running `<name>@<device>`) fail to compile. Keys are `<profile>@<device>`
351
- * (legacy leftover `<profile>@endpoint-N` dirs are still recognized).
352
- */
353
- connections = new Map();
354
- forkingProfiles = new Set();
355
- // Per-task storage for console, errors, network, downloads
356
- consoleLogs = new Map();
357
- pageErrors = new Map();
358
- networkRequests = new Map();
359
- pendingDownloads = new Map();
360
- enabledSessions = new Map(); // sessionId -> enabled domains
361
- /** Profile -> when a `touchTask` last persisted tasks.json for it. */
362
- lastTouchPersist = new Map();
363
- /** Coalescing window for the `touchTask` write. See {@link touchTask}. */
364
- static TOUCH_PERSIST_INTERVAL_MS = 60_000;
365
- /**
366
- * Serialization chains for the reopen/create critical section (PHNX-2399).
367
- * Nothing else serializes IPC requests — `BrowserIPCServer` registers a
368
- * per-connection `socket.on('data', async …)` that Node never awaits — so two
369
- * concurrent `navigate`/`tab-add` requests, or two concurrent first-use
370
- * creates for one caller, race the "is this URL already open?" lookup and each
371
- * open a duplicate tab. Each key funnels its section through a promise chain so
372
- * the second request sees the first's tab and refreshes it in place.
373
- *
374
- * Keys are DISJOINT by concern so a nested call can never wait on a lock its
375
- * own outer frame holds: task-scoped work keys on `task:<key>:<name>`, first-
376
- * use creation keys on `create:<callerId>`. `start()`'s Arc/Electron branch
377
- * calls `navigate()` (a `task:` key) while the create path holds a `create:`
378
- * key — different namespaces, so no re-entrant deadlock.
379
- */
380
- critical = new Map();
381
- /**
382
- * Run `fn` mutually exclusive against every other call sharing `key`. The
383
- * chain never rejects (each link swallows its own settlement) so one failed
384
- * section cannot wedge the queue; `fn`'s own result/throw is returned to THIS
385
- * caller unchanged. The map entry is dropped once its tail settles with no
386
- * newer waiter, so idle tasks/callers don't accumulate.
387
- */
388
- async runExclusive(key, fn) {
389
- const prev = this.critical.get(key) ?? Promise.resolve();
390
- const run = prev.then(fn, fn);
391
- const tail = run.then(() => undefined, () => undefined);
392
- this.critical.set(key, tail);
393
- try {
394
- return await run;
395
- }
396
- finally {
397
- // Only the last link clears the entry; a newer waiter has replaced it.
398
- if (this.critical.get(key) === tail)
399
- this.critical.delete(key);
400
- }
401
- }
402
- /**
403
- * The task's OWN tabs (excluding borrowed ones — a borrowed tab predates the
404
- * task, see {@link Task.borrowedTabs}) that are still live in the browser AND
405
- * whose current document canonically equals `url`. Deliberately scoped to THIS
406
- * task: it is neither {@link adoptTabShowing} (reclaims OTHER abandoned tasks'
407
- * tabs) nor {@link pickReusableTargetWithoutCreate} (borrows a USER tab on Arc)
408
- * — the same-task reopen contract only ever touches a tab the task itself owns.
409
- * A registered id with no live target (a tab closed out from under us) is
410
- * skipped, so a stale mapping never masquerades as a reopenable tab.
411
- */
412
- findOwnedTabShowing(task, url, liveTargets) {
413
- const borrowed = new Set(task.borrowedTabs ?? []);
414
- const wanted = canonicalTabUrl(url);
415
- const liveById = new Map(liveTargets.filter((t) => t.type === 'page').map((t) => [t.targetId, t]));
416
- for (const [shortId, cdpId] of Object.entries(task.tabs)) {
417
- if (borrowed.has(shortId))
418
- continue;
419
- const live = liveById.get(cdpId);
420
- if (!live)
421
- continue; // registered but gone from the browser — not reopenable
422
- if (canonicalTabUrl(live.url) === wanted)
423
- return { shortId, targetId: cdpId };
424
- }
425
- return undefined;
426
- }
427
- /**
428
- * Same-task reopen (PHNX-2399): the requested URL is already live in a tab this
429
- * task owns, so RELOAD that same tab and keep its ids rather than opening a
430
- * duplicate. Returns the retained short id, or undefined when no owned tab
431
- * shows the URL (the caller then falls through to its normal navigate/create
432
- * semantics — that no-match split is intentional and preserved).
433
- *
434
- * A real `Page.reload` is issued; a failed reload THROWS rather than reporting
435
- * a phantom refresh (a stale target was already excluded by
436
- * {@link findOwnedTabShowing}). The tab is marked current WITHOUT
437
- * `Target.activateTarget` — reopen must not steal window focus.
438
- */
439
- async reopenOwnedTab(conn, task, url) {
440
- const { targetInfos } = (await conn.cdp.send('Target.getTargets'));
441
- const match = this.findOwnedTabShowing(task, url, targetInfos);
442
- if (!match)
443
- return undefined;
444
- const sessionId = await this.getSessionId(conn, match.targetId);
445
- // Real reload of the SAME document. If it throws, the ids are left exactly as
446
- // they were and the error surfaces — never a success that didn't happen.
447
- await conn.cdp.send('Page.reload', {}, sessionId);
448
- // Mark current without foreground activation (no Target.activateTarget).
449
- task.currentTabId = match.shortId;
450
- await this.saveTaskState(task.profile, conn.tasks);
451
- return { tabId: match.shortId };
452
- }
453
- async start(profileName, opts = {}) {
454
- // A NAMED start is serialized on the task NAME so two concurrent
455
- // `start --task <name>` requests can't both pass the existence check and
456
- // both create — the second observes the first's task and takes the retry (or
457
- // conflict) path. The key is the bare name, NOT `profile:name`: the existence
458
- // check (`findTaskByHandle`) is global by name, so two starts of the same
459
- // name under DIFFERENT profiles must share one lock or they'd both pass.
460
- // Disjoint `namedstart:` namespace, so the nested navigate()/create locks
461
- // below never deadlock against it (PHNX-2399). An unnamed start needs no such
462
- // guard: it always mints a fresh id.
463
- if (opts.taskName) {
464
- return this.runExclusive(`namedstart:${opts.taskName}`, () => this.startBody(profileName, opts));
465
- }
466
- return this.startBody(profileName, opts);
467
- }
468
- async startBody(profileName, opts) {
469
- // Consent gate, before anything is resolved or launched. This is the
470
- // authoritative one: gating only the `browser start` COMMAND left the ~18
471
- // page verbs that create a browser implicitly (navigate, click, screenshot,
472
- // …) able to open a browser on a machine whose owner never opted in.
473
- assertRemoteControlAllowedForRequest(opts.fleetRemote, { actor: opts.actor });
474
- // Registry decides WHERE. Three outcomes, no fourth: local if this
475
- // device declares the name; tunnel to a declaring device otherwise;
476
- // fail loud if nobody does. Never auto-create a local browser under a
477
- // name that means a logged-in browser somewhere else.
478
- const routed = resolveBrowserTarget(profileName, {
479
- endpointName: opts.endpointName,
480
- probe: opts.probe,
481
- });
482
- if (!routed.local && routed.commandDispatch) {
483
- throw new Error(`Native Arc profile "${profileName}" is owned by ${routed.device}; dispatch the complete browser command there.`);
484
- }
485
- const composite = routed.key;
486
- const effectiveProfile = routed.profile;
487
- const taskId = generateTaskId();
488
- let taskName;
489
- if (opts.taskName) {
490
- const existing = this.findTaskByHandle(opts.taskName);
491
- if (existing) {
492
- // Same-name start RETRY (PHNX-2399): reuse the existing task rather than
493
- // erroring — but only when it is genuinely the same task. A different
494
- // profile, endpoint, or caller identity is a real conflict and must NOT
495
- // silently acquire someone else's task.
496
- return this.retryNamedStart(existing, profileName, routed, opts);
497
- }
498
- taskName = opts.taskName;
499
- }
500
- else {
501
- // Address by the short machine id — no word-salad. Status shows `label`.
502
- taskName = taskId;
503
- }
504
- const taskLabel = deriveTaskLabel({ title: opts.title, url: opts.url });
505
- const reused = await this.reuseProfileConnection(profileName, composite, routed.local);
506
- let conn = reused?.conn;
507
- let effectiveKey = reused?.key ?? composite;
508
- // Fork is a local Electron chrome-data mint. Never fork a tunnelled
509
- // connection — that would launch a logged-out browser on THIS box.
510
- if (conn && routed.local && shouldForkProfile(routed.kind, conn)) {
511
- if (this.forkingProfiles.has(composite)) {
512
- while (this.forkingProfiles.has(composite)) {
513
- await new Promise((r) => setTimeout(r, 50));
514
- }
515
- const existingFork = this.findAvailableFork(composite);
516
- if (existingFork) {
517
- conn = existingFork.conn;
518
- effectiveKey = existingFork.name;
519
- }
520
- else {
521
- throw new Error(`Fork in progress but no available fork found for "${composite}"`);
522
- }
523
- }
524
- else {
525
- this.forkingProfiles.add(composite);
526
- try {
527
- const { forkName, connection } = await this.forkElectronProfile(effectiveProfile, composite);
528
- conn = connection;
529
- effectiveKey = forkName;
530
- }
531
- finally {
532
- this.forkingProfiles.delete(composite);
533
- }
534
- }
535
- }
536
- else if (!conn) {
537
- adoptLegacyRuntimeIfLocal(routed.local, profileName, composite, getBrowserRuntimeDir());
538
- conn = await this.openConnection(effectiveProfile, routed.target, composite, profileName, { persistRemote: !routed.local });
539
- effectiveKey = composite;
540
- }
541
- if (!conn) {
542
- throw new Error(`Could not connect to profile "${profileName}"`);
543
- }
544
- if (!conn.profile)
545
- conn.profile = profileName;
546
- // Browsers launch with --no-startup-window (session-cookie persistence,
547
- // see launchBrowser), so a bare `start` with no --url would otherwise
548
- // leave the user staring at a process with zero windows. Recreate the
549
- // old startup-window affordance: if no page target exists, open a blank one.
550
- //
551
- // This tab IS registered on the task below. It used to be deliberately
552
- // unregistered ("tasks track only tabs they created") — but this daemon did
553
- // create it, and an unregistered tab is one `done`/`stop` can never close:
554
- // `stop` closes exactly the entries in `task.tabs`. So every bare `start`
555
- // left a blank globe tab behind forever, which is a large share of the
556
- // pile-up in RUSH-2622. Tabs the daemon did NOT open are still left alone —
557
- // the branch only fires when the profile has no page target at all.
558
- let startupBlankTargetId;
559
- if (!opts.url && !conn.electron && conn.backend !== 'arc-native' && conn.backend !== 'bidi') {
560
- const { targetInfos } = (await conn.cdp.send('Target.getTargets'));
561
- if (!targetInfos.some((t) => t.type === 'page')) {
562
- const created = await this.createPageTarget(conn, {
563
- url: 'about:blank',
564
- });
565
- startupBlankTargetId = created.targetId;
566
- this.invalidateTargetCache(conn);
567
- }
568
- }
569
- const now = Date.now();
570
- const task = {
571
- id: taskId,
572
- name: taskName,
573
- label: taskLabel,
574
- profile: effectiveKey,
575
- tabs: {},
576
- currentTabId: undefined,
577
- createdAt: now,
578
- // A brand-new task has done exactly one thing — start — so its last
579
- // action is its creation. The reaper's idle window runs from here.
580
- lastActionAt: now,
581
- pid: conn.pid,
582
- // Identity is forwarded from the caller (see resolveTaskIdentity): WHO
583
- // (owner), WHICH run (launchId), and WHICH agent session (sessionId).
584
- // Resolving daemon-side would attribute every task to the shared daemon's
585
- // actor (the RUSH-2020 bug).
586
- ...resolveTaskIdentity({ actor: opts.actor, launchId: opts.launchId, sessionId: opts.sessionId }, () => resolveActor().id),
587
- };
588
- if (conn.backend === 'arc-native') {
589
- const selected = await this.resolveArcSpace(conn.arcProfile);
590
- task.arcNative = {
591
- profileId: conn.arcProfile.profileId,
592
- windowId: selected.windowId,
593
- spaceId: selected.spaceId,
594
- spaceTitle: selected.spaceTitle,
595
- tabs: {},
596
- };
597
- }
598
- if (startupBlankTargetId) {
599
- const shortId = generateShortId();
600
- task.tabs[shortId] = startupBlankTargetId;
601
- task.currentTabId = shortId;
602
- }
603
- // For Electron, get the existing window as the tab
604
- if (conn.electron && conn.backend !== 'arc-native' && conn.backend !== 'bidi') {
605
- const windowId = await this.getOrCreateWindow(conn);
606
- if (windowId) {
607
- const shortId = generateShortId();
608
- task.tabs[shortId] = windowId;
609
- task.currentTabId = shortId;
610
- }
611
- }
612
- conn.tasks.set(taskName, task);
613
- await this.saveTaskState(effectiveKey, conn.tasks);
614
- // Durable identity, written ONCE at start and never deleted (RUSH-2549).
615
- // tasks.json above stays live state: `stop` drops the task from that map, so
616
- // anything recorded only there is gone the moment the task ends -- which is
617
- // why every finished task used to list as "unlinked". This row outlives the
618
- // task, the daemon, and a reboot. Metadata only: the capture bytes stay on
619
- // disk under capture_dir.
620
- // Guarded like `emit` below ("logging should never break the CLI",
621
- // events.ts): the browser task is already started and registered. An
622
- // unwritable session DB must not turn a working `agents browser start` into
623
- // a failure -- the cost of a miss is one unlinked row, not a dead browser.
624
- try {
625
- recordBrowserSession({
626
- task: taskName,
627
- profile: effectiveKey,
628
- sessionId: task.sessionId,
629
- launchId: task.launchId,
630
- actor: task.owner,
631
- startedAt: task.createdAt,
632
- captureDir: getProfileSessionsDir(effectiveKey, taskName),
633
- });
634
- }
635
- catch {
636
- // Recording is best-effort; the task itself is live either way.
637
- }
638
- // Feed events and the usage rollup are read by humans: bare name only.
639
- emit('browser.launch', { profile: profileName, task: taskName, pid: conn.pid });
640
- void import('../analytics/usage-db.js').then(({ recordUsage }) => {
641
- recordUsage({
642
- kind: 'browser',
643
- name: profileName,
644
- event: 'launch',
645
- source: 'browser',
646
- meta: { task: taskName },
647
- });
648
- }).catch(() => { });
649
- // If URL provided, reclaim a tab an abandoned task is holding on it, else
650
- // create one directly (no about:blank). `firstOpen` records what actually
651
- // happened so the owning IPC handler reports it truthfully rather than
652
- // assuming a create (PHNX-2399): ADOPTING an abandoned tab is created:false,
653
- // opening a fresh target is created:true.
654
- let tabId;
655
- let firstOpen;
656
- if (opts.url && conn.backend === 'bidi') {
657
- // Firefox: the implicit first navigate opens a fresh tab (no adopt path —
658
- // Firefox tabs aren't shared across abandoned tasks the way CDP ones are).
659
- const result = await this.navigate(taskName, opts.url, effectiveKey);
660
- tabId = result.tabId;
661
- firstOpen = { tabId: result.tabId, created: result.created, refreshed: result.refreshed, message: result.message };
662
- }
663
- else if (opts.url && conn.backend !== 'arc-native' && conn.backend !== 'bidi' && !conn.electron && conn.browserType !== 'arc') {
664
- const adopted = opts.fresh ? undefined : await this.adoptTabShowing(conn, opts.url);
665
- const targetId = adopted ?? (await this.createPageTarget(conn, { url: opts.url })).targetId;
666
- const shortId = generateShortId();
667
- task.tabs[shortId] = targetId;
668
- task.currentTabId = shortId;
669
- this.invalidateTargetCache(conn);
670
- await this.saveTaskState(effectiveKey, conn.tasks);
671
- tabId = shortId;
672
- firstOpen = { tabId: shortId, created: !adopted, refreshed: false };
673
- }
674
- else if (opts.url && (conn.backend === 'arc-native' || conn.electron || conn.browserType === 'arc')) {
675
- // Electron and Arc share the reuse-in-place path: neither may open a fresh
676
- // tab here (Electron drives its one window; Arc crashes on Target.createTarget),
677
- // so the implicit first navigate attaches to an existing tab — honoring the
678
- // profile's --target-filter — instead of throwing. This is what makes the
679
- // documented `navigate --profile arc --url …` first-use workflow attach rather
680
- // than refuse on a task-less profile (PHNX-2399 review). Carry navigate's
681
- // real result — Arc borrow is created:false, not a fabricated create.
682
- const result = await this.navigate(taskName, opts.url, effectiveKey);
683
- tabId = result.tabId;
684
- firstOpen = { tabId: result.tabId, created: result.created, refreshed: result.refreshed, message: result.message };
685
- }
686
- // Domain-skill discovery: when a URL is supplied, look up site-specific
687
- // operating instructions and pass them back so the calling agent can pick
688
- // them up alongside the task id. Failures swallowed by resolveDomainSkill.
689
- let skill;
690
- if (opts.url && !opts.skipDomainSkill) {
691
- const resolved = resolveDomainSkill(opts.url);
692
- if (resolved)
693
- skill = resolved;
694
- }
695
- return {
696
- task: taskId,
697
- name: taskName,
698
- tabId,
699
- profile: profileName,
700
- key: effectiveKey,
701
- device: routed.device,
702
- picked: routed.picked,
703
- skill,
704
- firstOpen,
705
- };
706
- }
707
- /**
708
- * Reclaim a live page already showing `url` from an ABANDONED task, instead
709
- * of opening a second copy of the same page (RUSH-2622). Returns its CDP
710
- * targetId, or undefined when there is nothing safe to reclaim.
711
- *
712
- * "Safe" is narrow on purpose, because both of the wider readings close a tab
713
- * somebody is using:
714
- *
715
- * - An UNOWNED matching page is NOT taken. No task claims it, which most
716
- * often means the user opened it themselves — and adopting it would make
717
- * this task's `done` close the user's tab.
718
- * - A page owned by a LIVE task is NOT taken. Stealing it would leave that
719
- * agent's `screenshot`/`click` throwing "No tabs open for this task", its
720
- * `navigate` silently opening the duplicate this feature exists to
721
- * prevent, and any in-flight recording truncated when the new owner calls
722
- * `done`.
723
- *
724
- * What is left is exactly the pile-up case: a page held by a task whose owner
725
- * is provably gone. Liveness uses the reaper's own predicate
726
- * (`taskOwnerIsGone`), so "dead" has one definition in this codebase rather
727
- * than two that can drift. A task the reaper cannot prove dead keeps its tab
728
- * and is closed later by the idle rule instead.
729
- *
730
- * Reclaiming transfers rather than shares: two tasks holding one targetId
731
- * means the first `done` closes the other's tab. `start --fresh` skips this
732
- * path entirely.
733
- *
734
- * URLs are compared canonically (`new URL(...).href`), so a requested
735
- * `https://example.com` matches the `https://example.com/` Chrome reports. A
736
- * page that has since redirected elsewhere simply does not match, and the
737
- * caller opens a tab as before.
738
- */
739
- /** Is this CDP target already registered to any task on the connection? */
740
- targetIsClaimed(conn, targetId) {
741
- for (const t of conn.tasks.values()) {
742
- if (Object.values(t.tabs).includes(targetId))
743
- return true;
744
- }
745
- return false;
746
- }
747
- /**
748
- * The CDP targets a task may CLOSE — its own tabs minus any it merely
749
- * borrowed. A borrowed tab existed before the task did (see `Task.borrowedTabs`),
750
- * so closing it would remove something the task never opened.
751
- */
752
- closeableTargetIds(task) {
753
- const borrowed = new Set(task.borrowedTabs ?? []);
754
- return Object.entries(task.tabs)
755
- .filter(([shortId]) => !borrowed.has(shortId))
756
- .map(([, cdpId]) => cdpId);
757
- }
758
- /**
759
- * A page target that `navigate` may take over on a browser which cannot create
760
- * tabs (Arc). Deliberately narrow: only a tab ALREADY showing the requested URL
761
- * -- re-showing that document there is what the caller asked for -- or an empty
762
- * new-tab page. A target any task already owns is left alone. Note the honest
763
- * limit: reusing a matching tab still RELOADS it, discarding scroll position and
764
- * unsaved form state. Arc cannot open a tab at all, so some intrusion is
765
- * unavoidable to show the document; a reload is the smallest one, and the tab is
766
- * borrowed rather than owned so it is never closed.
767
- */
768
- async pickReusableTargetWithoutCreate(conn, url) {
769
- const { targetInfos } = (await conn.cdp.send('Target.getTargets'));
770
- const owned = new Set();
771
- for (const t of conn.tasks.values()) {
772
- for (const id of Object.values(t.tabs))
773
- owned.add(id);
774
- }
775
- let free = targetInfos.filter((t) => t.type === 'page' && !owned.has(t.targetId));
776
- // A bound --target-filter (url:/title:<substring>) is how the profile names the
777
- // tab/Space it drives — e.g. an Arc Space pinned with `--target-filter url:notion.so`.
778
- // Scope the reusable set to tabs matching it; an explicit filter that matches nothing
779
- // returns undefined so the caller refuses rather than borrowing an unrelated tab (the
780
- // same contract pickWindowTarget holds for the tab-creating path). Without this the
781
- // filter was accepted and stored but never consulted when driving Arc (PHNX-2399 review).
782
- const parsed = parseTargetFilter(conn.targetFilter);
783
- if (parsed) {
784
- const needle = parsed.value.toLowerCase();
785
- free = free.filter((t) => ((parsed.kind === 'url' ? t.url : t.title) ?? '').toLowerCase().includes(needle));
786
- if (free.length === 0)
787
- return undefined;
788
- }
789
- const wanted = canonicalTabUrl(url);
790
- const sameUrl = free.find((t) => canonicalTabUrl(t.url) === wanted);
791
- if (sameUrl)
792
- return sameUrl.targetId;
793
- const BLANK = new Set(['', 'about:blank', 'about:newtab', 'chrome://newtab/', 'chrome://new-tab-page/']);
794
- const blank = free.find((t) => BLANK.has(t.url));
795
- if (blank)
796
- return blank.targetId;
797
- // With a filter set, every remaining tab already IS the bound Space, so reuse the
798
- // first even when it is neither the exact URL nor blank — that is the target the
799
- // operator pinned. With no filter, only an exact-URL or blank tab is safe to borrow.
800
- return parsed ? free[0]?.targetId : undefined;
801
- }
802
- async adoptTabShowing(conn, url) {
803
- const { targetInfos } = (await conn.cdp.send('Target.getTargets'));
804
- const wanted = canonicalTabUrl(url);
805
- const matching = new Set(targetInfos
806
- .filter((t) => t.type === 'page' && canonicalTabUrl(t.url) === wanted)
807
- .map((t) => t.targetId));
808
- if (matching.size === 0)
809
- return undefined;
810
- const candidates = [];
811
- for (const task of conn.tasks.values()) {
812
- const borrowed = new Set(task.borrowedTabs ?? []);
813
- for (const [shortId, cdpId] of Object.entries(task.tabs)) {
814
- // A BORROWED tab is not ours to hand on. The dead task never opened it
815
- // (Task.borrowedTabs) -- reclaiming it would register a pre-existing tab
816
- // as a fresh task's own, and that task's `done` would close it. Exactly
817
- // the "adopting it would make this task's `done` close the user's tab"
818
- // case above, arriving one hop later via reclaim instead of directly.
819
- if (borrowed.has(shortId))
820
- continue;
821
- if (matching.has(cdpId))
822
- candidates.push({ task, shortId, targetId: cdpId });
823
- }
824
- }
825
- if (candidates.length === 0)
826
- return undefined;
827
- const live = resolveLiveIdentities();
828
- for (const { task, shortId, targetId } of candidates) {
829
- // An in-flight capture means the task is in use whatever its owner looks
830
- // like — same guard the reaper applies before stopping anything.
831
- if (this.recordings.has(task.name))
832
- continue;
833
- if (!(await taskOwnerIsGone(task, live)))
834
- continue;
835
- // Re-check the claim after the await. Nothing serializes IPC requests —
836
- // `BrowserIPCServer` registers a per-connection `socket.on('data', async …)`
837
- // that Node never awaits — so two concurrent `start`s can both pass the
838
- // liveness check on one candidate and both return the same targetId,
839
- // putting two live tasks on one tab and re-opening the double-owner bug
840
- // this reclaim is careful to avoid. Whoever deletes it first owns it.
841
- if (task.tabs[shortId] !== targetId)
842
- continue;
843
- delete task.tabs[shortId];
844
- delete task.refDescriptors?.[shortId];
845
- if (task.currentTabId === shortId) {
846
- const remaining = Object.keys(task.tabs);
847
- task.currentTabId = remaining.length > 0 ? remaining[remaining.length - 1] : undefined;
848
- }
849
- try {
850
- await conn.cdp.send('Target.activateTarget', { targetId });
851
- }
852
- catch {
853
- // Bringing the tab to the front is cosmetic and not every endpoint
854
- // implements activateTarget; the tab is reclaimed either way.
855
- }
856
- return targetId;
857
- }
858
- return undefined;
859
- }
860
- /**
861
- * Every live task across every connected profile — the reaper's input
862
- * (`hygiene.ts`). A read-only view: mutating a returned `Task` mutates the
863
- * daemon's live state, so callers only read it and act through `stop`.
864
- */
865
- listTasks() {
866
- const out = [];
867
- for (const [key, conn] of this.connections) {
868
- for (const task of conn.tasks.values()) {
869
- out.push({ profile: conn.key ?? key, task });
870
- }
871
- }
872
- return out;
873
- }
874
- /**
875
- * Close tasks whose owning agent session is gone, or that have sat untouched
876
- * past the idle window. The entry point the daemon's periodic tick and
877
- * `agents browser prune` both call; the policy lives in `hygiene.ts`.
878
- */
879
- async reapAbandoned(opts = {}) {
880
- return reapAbandonedTasks(this, opts);
881
- }
882
- async stop(taskName) {
883
- // Rehydrate from tasks.json when the daemon restarted and lost RAM state.
884
- try {
885
- await this.findTask(taskName);
886
- }
887
- catch {
888
- // findTask throws when truly gone; fall through to the miss return.
889
- }
890
- for (const [key, conn] of this.connections) {
891
- const task = this.lookupTaskOnConn(conn, taskName);
892
- if (task) {
893
- // Map key may be task.name, not the caller-supplied handle.
894
- const mapKey = this.taskMapKey(conn, task) ?? taskName;
895
- // Get domains from tabs before closing (for history)
896
- const domains = new Set();
897
- if (conn.backend === 'arc-native') {
898
- // Resolve exact durable refs for history. Missing/moved tabs are not
899
- // adopted and never become candidates for a best-effort close.
900
- for (const ref of Object.values(this.requireArcTask(task).tabs)) {
901
- const live = await resolveArcTab(ref);
902
- if (live?.url) {
903
- try {
904
- const domain = new URL(live.url).hostname.replace(/^www\./, '');
905
- if (domain && domain !== 'blank')
906
- domains.add(domain);
907
- }
908
- catch { /* invalid URL */ }
909
- }
910
- }
911
- await this.saveToHistory(task, Array.from(domains));
912
- await this.closeArcNativeTabs(conn, task);
913
- }
914
- else if (conn.backend === 'bidi') {
915
- try {
916
- for (const tab of await this.listFirefoxTaskTabs(conn, task)) {
917
- try {
918
- const domain = new URL(tab.url).hostname.replace(/^www\./, '');
919
- if (domain && domain !== 'blank')
920
- domains.add(domain);
921
- }
922
- catch { /* invalid URL */ }
923
- }
924
- }
925
- catch { /* Firefox not responding */ }
926
- await this.saveToHistory(task, Array.from(domains));
927
- await this.closeFirefoxTabs(conn, task);
928
- }
929
- else {
930
- try {
931
- const { targetInfos } = (await conn.cdp.send('Target.getTargets'));
932
- for (const cdpId of Object.values(task.tabs)) {
933
- const target = targetInfos.find((t) => t.targetId === cdpId);
934
- if (target?.url) {
935
- try {
936
- const domain = new URL(target.url).hostname.replace(/^www\./, '');
937
- if (domain && domain !== 'blank')
938
- domains.add(domain);
939
- }
940
- catch {
941
- // invalid URL
942
- }
943
- }
944
- }
945
- }
946
- catch {
947
- // CDP not responding
948
- }
949
- // Save to history before closing
950
- await this.saveToHistory(task, Array.from(domains));
951
- // Close task's tabs (not the window - it's shared, and not a tab the
952
- // task merely borrowed - that one was open before the task existed).
953
- await Promise.all(this.closeableTargetIds(task).map((cdpId) => conn.cdp.send('Target.closeTarget', { targetId: cdpId }).catch(() => {
954
- // Tab already closed
955
- })));
956
- for (const cdpId of Object.values(task.tabs)) {
957
- conn.sessionCache.delete(cdpId);
958
- }
959
- this.invalidateTargetCache(conn);
960
- }
961
- conn.tasks.delete(mapKey);
962
- await this.saveTaskState(key, conn.tasks);
963
- // Feed + usage rows are read by humans: bare profile name (RUSH-2709).
964
- const bareProfile = conn.profile ?? parseConnectionKey(key).profile;
965
- emit('browser.close', { profile: bareProfile, task: task.name });
966
- void import('../analytics/usage-db.js').then(({ recordUsage }) => {
967
- recordUsage({
968
- kind: 'browser',
969
- name: bareProfile,
970
- event: 'close',
971
- source: 'browser',
972
- meta: { task: task.name },
973
- });
974
- }).catch(() => { });
975
- if (conn.forkedFrom && conn.tasks.size === 0) {
976
- if (conn.backend === 'bidi') {
977
- conn.bidi.close();
978
- if (conn.pid)
979
- killChrome(conn.pid);
980
- }
981
- else if (conn.backend !== 'arc-native') {
982
- conn.cdp.close();
983
- killChrome(conn.pid);
984
- }
985
- conn.cleanup?.();
986
- this.connections.delete(key);
987
- clearProfileRuntime(key);
988
- }
989
- return { ok: true, profile: bareProfile };
990
- }
991
- }
992
- return { ok: false };
993
- }
994
- async done(taskName) {
995
- return this.stop(taskName);
996
- }
997
- async stopProfile(profileRef, opts = {}) {
998
- // Consent gate — this is a fleet-remote destructive path (kills the
999
- // profile's browser process and clears its runtime dir) that reached the
1000
- // daemon without ever hitting resolveOrCreateTask's gate, since it is a
1001
- // task-less stop. Same per-request marker rule as every other gated verb —
1002
- // see remote-control.ts.
1003
- assertRemoteControlAllowedForRequest(opts.fleetRemote, { actor: opts.actor });
1004
- // Connections are keyed by the runtime key `<profile>@<device>` (see
1005
- // start()) while callers pass the bare profile name (or, occasionally, an
1006
- // exact key). A plain `connections.get(profileRef)` therefore missed every
1007
- // real remote connection, so `cleanup()` never ran — leaving the SSH tunnel
1008
- // and, on Windows, the WMI-spawned browser orphaned on the remote host after
1009
- // every `browser stop --profile` (#559). Same single rule as findTask and
1010
- // status: exact key, else every key belonging to that profile.
1011
- const keys = [...this.connections.keys()].filter((k) => k === profileRef || keyBelongsToProfile(k, profileRef));
1012
- for (const key of keys) {
1013
- const conn = this.connections.get(key);
1014
- if (!conn)
1015
- continue;
1016
- // Native Arc connections: close owned tabs but NEVER kill the Arc process.
1017
- // The CLI does not own the Arc process — the user started it.
1018
- if (conn.backend === 'arc-native') {
1019
- for (const task of conn.tasks.values()) {
1020
- await this.closeArcNativeTabs(conn, task);
1021
- }
1022
- this.connections.delete(key);
1023
- clearProfileRuntime(key);
1024
- continue;
1025
- }
1026
- // Firefox: close the BiDi socket and kill the Firefox WE launched (pid !=
1027
- // 0). An attached Firefox the user started (pid 0) is left running — the
1028
- // CLI does not own that process, same as Arc.
1029
- if (conn.backend === 'bidi') {
1030
- conn.bidi.close();
1031
- if (conn.pid)
1032
- killChrome(conn.pid);
1033
- conn.cleanup?.();
1034
- this.connections.delete(key);
1035
- clearProfileRuntime(key);
1036
- continue;
1037
- }
1038
- conn.cdp.close();
1039
- killChrome(conn.pid);
1040
- conn.cleanup?.();
1041
- this.connections.delete(key);
1042
- clearProfileRuntime(key);
1043
- }
1044
- // Kill stale processes and clean runtime dirs for every composite and
1045
- // fork entry belonging to this profile (including `.N` forks left by
1046
- // earlier daemon sessions that the connection loop above didn't cover).
1047
- for (const dir of listProfileCacheDirs(parseConnectionKey(profileRef).profile)) {
1048
- const dirName = path.basename(dir);
1049
- const meta = readProfileRuntimeMeta(dirName);
1050
- if (meta?.pid && meta.pid !== 0 && isProcessAlive(meta.pid, meta.command)) {
1051
- killChrome(meta.pid);
1052
- }
1053
- clearProfileRuntime(dirName);
1054
- }
1055
- }
1056
- async navigate(taskId, url, profileRef) {
1057
- const { conn, task } = await this.findTask(taskId, profileRef);
1058
- // Serialize the reopen/create section so two concurrent navigates for one
1059
- // task don't both miss the "already open?" check and open duplicates.
1060
- return this.runExclusive(`task:${task.profile}:${task.name}`, () => this.navigateLocked(conn, task, url));
1061
- }
1062
- async navigateLocked(conn, task, url) {
1063
- this.maybeUpdateLabelFromUrl(conn, task, url);
1064
- // Native Arc backend (PHNX-2399): route through the native driver instead of CDP.
1065
- if (conn.backend === 'arc-native') {
1066
- return this.navigateArcNative(conn, task, url);
1067
- }
1068
- // Firefox BiDi backend (PHNX-4043).
1069
- if (conn.backend === 'bidi') {
1070
- return this.navigateFirefox(conn, task, url);
1071
- }
1072
- requireCdp(conn, 'navigate');
1073
- // Same-task reopen (PHNX-2399): the URL is already live in a tab this task
1074
- // owns → reload THAT tab in place and keep its id, before the create/reuse
1075
- // paths below. Chrome and Arc alike; Electron drives one window and never
1076
- // has a second owned tab to match, so this is a no-op there.
1077
- const reopened = await this.reopenOwnedTab(conn, task, url);
1078
- if (reopened) {
1079
- emit('browser.navigate', { profile: parseConnectionKey(task.profile).profile, task: task.name, url, tabId: reopened.tabId, created: false });
1080
- return { tabId: reopened.tabId, url, created: false, refreshed: true, message: 'Tab already open—refreshed' };
1081
- }
1082
- // If we have a current tab, navigate in it (reuse)
1083
- const currentShortId = task.currentTabId;
1084
- if (currentShortId && task.tabs[currentShortId]) {
1085
- const cdpTargetId = task.tabs[currentShortId];
1086
- const sessionId = await this.getSessionId(conn, cdpTargetId);
1087
- await conn.cdp.send('Page.navigate', { url }, sessionId);
1088
- await this.saveTaskState(task.profile, conn.tasks);
1089
- emit('browser.navigate', { profile: parseConnectionKey(task.profile).profile, task: task.name, url, tabId: currentShortId, created: false });
1090
- return { tabId: currentShortId, url, created: false, refreshed: false };
1091
- }
1092
- // No current tab - create one
1093
- if (conn.electron) {
1094
- const cdpTargetId = conn.windowId;
1095
- if (!cdpTargetId) {
1096
- throw new Error('No existing tab to navigate in Electron app');
1097
- }
1098
- const shortId = generateShortId();
1099
- const sessionId = await this.getSessionId(conn, cdpTargetId);
1100
- await conn.cdp.send('Page.navigate', { url }, sessionId);
1101
- task.tabs[shortId] = cdpTargetId;
1102
- task.currentTabId = shortId;
1103
- await this.saveTaskState(task.profile, conn.tasks);
1104
- emit('browser.navigate', { profile: parseConnectionKey(task.profile).profile, task: task.name, url, tabId: shortId, created: true });
1105
- return { tabId: shortId, url, created: true, refreshed: false };
1106
- }
1107
- // Arc exposes page targets and honors Page.navigate on them; what it cannot
1108
- // survive is Target.createTarget (#2778). createPageTarget below therefore
1109
- // refuses outright, so the document never gets shown AT ALL and callers fall
1110
- // back to a raw `open` -- a new tab every call, which is the tab-spam #2779
1111
- // set out to end. Reuse in place instead, the same shape as the Electron
1112
- // branch above. Measured against a live Arc (#2786): 33 page targets,
1113
- // Page.navigate reused one, tab count unchanged, no crash.
1114
- if (conn.browserType === 'arc') {
1115
- const reusable = await this.pickReusableTargetWithoutCreate(conn, url);
1116
- if (reusable) {
1117
- const shortId = generateShortId();
1118
- const sessionId = await this.getSessionId(conn, reusable);
1119
- // Check and CLAIM in one synchronous tick, with no await between them --
1120
- // the ordering `adoptTabShowing` uses (service.ts: the `task.tabs[shortId]
1121
- // !== targetId` re-check sits immediately before its `delete`). Checking
1122
- // and then awaiting `Page.navigate` before writing leaves exactly the gap
1123
- // the check exists to close: a concurrent navigate passes its own check in
1124
- // that window and both tasks end up driving one tab.
1125
- if (this.targetIsClaimed(conn, reusable)) {
1126
- throw arcNotDrivableError(conn.profile);
1127
- }
1128
- task.tabs[shortId] = reusable;
1129
- // Borrowed, not opened: this tab was in the browser before the task and
1130
- // must survive `done`. See Task.borrowedTabs.
1131
- task.borrowedTabs = [...(task.borrowedTabs ?? []), shortId];
1132
- task.currentTabId = shortId;
1133
- try {
1134
- await conn.cdp.send('Page.navigate', { url }, sessionId);
1135
- }
1136
- catch (err) {
1137
- // Release the claim we took optimistically, so a failed navigate does
1138
- // not leave the task holding a tab it never drove.
1139
- delete task.tabs[shortId];
1140
- task.borrowedTabs = (task.borrowedTabs ?? []).filter((id) => id !== shortId);
1141
- if (task.currentTabId === shortId)
1142
- task.currentTabId = undefined;
1143
- throw err;
1144
- }
1145
- this.invalidateTargetCache(conn);
1146
- await this.saveTaskState(task.profile, conn.tasks);
1147
- emit('browser.navigate', {
1148
- profile: task.profile,
1149
- task: task.name,
1150
- url,
1151
- tabId: shortId,
1152
- created: false,
1153
- });
1154
- return { tabId: shortId, url, created: false, refreshed: false };
1155
- }
1156
- // Nothing safe to reuse -- fall through to the actionable refusal rather
1157
- // than taking over a page the user is reading.
1158
- }
1159
- // Chrome: create new tab
1160
- const result = await this.createPageTarget(conn, { url });
1161
- const shortId = generateShortId();
1162
- task.tabs[shortId] = result.targetId;
1163
- task.currentTabId = shortId;
1164
- this.invalidateTargetCache(conn);
1165
- await this.saveTaskState(task.profile, conn.tasks);
1166
- emit('browser.navigate', { profile: parseConnectionKey(task.profile).profile, task: task.name, url, tabId: shortId, created: true });
1167
- return { tabId: shortId, url, created: true, refreshed: false };
1168
- }
1169
- async tabAdd(taskId, url, profileRef) {
1170
- const { conn, task } = await this.findTask(taskId, profileRef);
1171
- return this.runExclusive(`task:${task.profile}:${task.name}`, () => this.tabAddLocked(conn, task, url));
1172
- }
1173
- async tabAddLocked(conn, task, url) {
1174
- if (conn.backend === 'arc-native') {
1175
- return this.navigateArcNative(conn, task, url, true);
1176
- }
1177
- if (conn.backend === 'bidi') {
1178
- return this.navigateFirefox(conn, task, url, true);
1179
- }
1180
- requireCdp(conn, 'createTab');
1181
- if (conn.electron) {
1182
- throw new Error('Electron apps do not support opening additional tabs');
1183
- }
1184
- // Same-task reopen (PHNX-2399): reopening a URL already live in one of this
1185
- // task's own tabs refreshes THAT tab rather than opening a duplicate. The
1186
- // no-match case below stays `tab add`'s intentional create-a-new-tab
1187
- // semantics — distinct from `navigate`, which reuses the current tab.
1188
- const reopened = await this.reopenOwnedTab(conn, task, url);
1189
- if (reopened) {
1190
- return { tabId: reopened.tabId, url, created: false, refreshed: true, message: 'Tab already open—refreshed' };
1191
- }
1192
- const result = await this.createPageTarget(conn, { url });
1193
- const shortId = generateShortId();
1194
- task.tabs[shortId] = result.targetId;
1195
- task.currentTabId = shortId; // new tab becomes current
1196
- this.invalidateTargetCache(conn);
1197
- await this.saveTaskState(task.profile, conn.tasks);
1198
- return { tabId: shortId, url, created: true, refreshed: false };
1199
- }
1200
- async tabFocus(taskId, tabHint) {
1201
- const { conn, task } = await this.findTask(taskId);
1202
- const resolvedTabId = await this.resolveTabHint(conn, task, tabHint);
1203
- if (conn.backend === 'arc-native') {
1204
- await this.runExclusive('arc-native:host-app', async () => {
1205
- const ref = this.requireArcTask(task).tabs[resolvedTabId];
1206
- if (!ref)
1207
- throw new Error(`Tab ${resolvedTabId} not found`);
1208
- await selectArcTab(ref);
1209
- task.currentTabId = resolvedTabId;
1210
- await this.saveTaskState(task.profile, conn.tasks);
1211
- });
1212
- return { tabId: resolvedTabId };
1213
- }
1214
- if (conn.backend === 'bidi') {
1215
- await bidiActivate(conn.bidi, this.firefoxContext(task, resolvedTabId));
1216
- task.currentTabId = resolvedTabId;
1217
- await this.saveTaskState(task.profile, conn.tasks);
1218
- return { tabId: resolvedTabId };
1219
- }
1220
- requireCdp(conn, 'enumerate');
1221
- await conn.cdp.send('Target.activateTarget', { targetId: task.tabs[resolvedTabId] });
1222
- task.currentTabId = resolvedTabId;
1223
- await this.saveTaskState(task.profile, conn.tasks);
1224
- return { tabId: resolvedTabId };
1225
- }
1226
- async tabList(taskId) {
1227
- const { conn, task } = await this.findTask(taskId);
1228
- if (conn.backend === 'arc-native') {
1229
- return (await this.listArcTaskTabs(task)).map((tab) => ({ ...tab, current: tab.current === true }));
1230
- }
1231
- if (conn.backend === 'bidi') {
1232
- return (await this.listFirefoxTaskTabs(conn, task)).map((tab) => ({
1233
- id: tab.id, url: tab.url, title: tab.title, current: tab.current === true,
1234
- }));
1235
- }
1236
- requireCdp(conn, 'enumerate');
1237
- const targets = (await conn.cdp.send('Target.getTargets'));
1238
- const tabs = [];
1239
- for (const [shortId, cdpId] of Object.entries(task.tabs)) {
1240
- const target = targets.targetInfos.find((t) => t.targetId === cdpId);
1241
- if (target) {
1242
- tabs.push({
1243
- id: shortId,
1244
- url: target.url,
1245
- title: target.title,
1246
- current: shortId === task.currentTabId,
1247
- });
1248
- }
1249
- }
1250
- return tabs;
1251
- }
1252
- /**
1253
- * Every top-level tab open in the task's profile browser, the owner's tabs
1254
- * included (`agents browser tabs --all`). Read-only: nothing is focused,
1255
- * navigated, or closed. A tab an agents-cli task owns carries that task's
1256
- * name and short id (so it can be addressed); any other tab carries the
1257
- * browser's own id and no task. Same rows on every backend.
1258
- */
1259
- async profileTabs(taskId) {
1260
- const { conn, task } = await this.findTask(taskId);
1261
- const owners = new Map();
1262
- for (const [, owner] of conn.tasks) {
1263
- const entries = conn.backend === 'arc-native'
1264
- ? Object.entries(owner.arcNative?.tabs ?? {}).map(([shortId, ref]) => [shortId, ref.tabId])
1265
- : Object.entries(owner.tabs);
1266
- for (const [shortId, nativeId] of entries)
1267
- owners.set(nativeId, { owner, shortId });
1268
- }
1269
- const row = (nativeId, url, title) => {
1270
- const owned = owners.get(nativeId);
1271
- if (!owned)
1272
- return { id: nativeId, url, title };
1273
- return {
1274
- id: owned.shortId,
1275
- url,
1276
- title,
1277
- task: owned.owner.name,
1278
- current: owned.owner === task && owned.shortId === task.currentTabId,
1279
- };
1280
- };
1281
- if (conn.backend === 'arc-native') {
1282
- const native = this.requireArcTask(task);
1283
- const space = (await enumerateArcSpaces()).find((candidate) => candidate.windowId === native.windowId && candidate.spaceId === native.spaceId);
1284
- if (!space) {
1285
- throw new Error(`Arc Space "${native.spaceTitle}" is not open in its original window (${native.windowId}); ` +
1286
- 'open it in Arc and retry.');
1287
- }
1288
- return space.tabs.map((tab) => row(tab.tabId, tab.url, tab.title));
1289
- }
1290
- if (conn.backend === 'bidi') {
1291
- const rows = [];
1292
- for (const context of await bidiTopLevelContexts(conn.bidi)) {
1293
- let title = '';
1294
- try {
1295
- title = String(await bidiEvaluate(conn.bidi, context.context, 'document.title'));
1296
- }
1297
- catch {
1298
- /* about: pages or a mid-navigation context — url is enough */
1299
- }
1300
- rows.push(row(context.context, context.url, title));
1301
- }
1302
- return rows;
1303
- }
1304
- requireCdp(conn, 'enumerate');
1305
- const { targetInfos } = (await conn.cdp.send('Target.getTargets'));
1306
- return targetInfos
1307
- .filter((target) => target.type === 'page')
1308
- .map((target) => row(target.targetId, target.url, target.title));
1309
- }
1310
- async resolveTabHint(conn, task, hint) {
1311
- // Exact match
1312
- if (task.tabs[hint])
1313
- return hint;
1314
- // Prefix match
1315
- const byPrefix = Object.keys(task.tabs).filter((id) => id.startsWith(hint));
1316
- if (byPrefix.length === 1)
1317
- return byPrefix[0];
1318
- if (byPrefix.length > 1) {
1319
- throw new Error(`Ambiguous tab hint "${hint}" — matches ${byPrefix.length} tabs`);
1320
- }
1321
- // Native Arc addresses owned tabs only by the task's stable short id. URL
1322
- // and title are display data and must never become fallback identities.
1323
- if (conn.backend === 'arc-native')
1324
- throw new Error(`Tab "${hint}" not found`);
1325
- // Firefox tabs are addressed by the task's stable short id too.
1326
- if (conn.backend === 'bidi')
1327
- throw new Error(`Tab "${hint}" not found`);
1328
- // URL substring match
1329
- const targets = (await conn.cdp.send('Target.getTargets'));
1330
- const matches = [];
1331
- for (const [shortId, cdpId] of Object.entries(task.tabs)) {
1332
- const target = targets.targetInfos.find((t) => t.targetId === cdpId);
1333
- if (target && target.url.includes(hint)) {
1334
- matches.push(shortId);
1335
- }
1336
- }
1337
- if (matches.length === 1)
1338
- return matches[0];
1339
- if (matches.length > 1) {
1340
- throw new Error(`Ambiguous tab hint "${hint}" — matches ${matches.length} tabs by URL`);
1341
- }
1342
- throw new Error(`Tab "${hint}" not found`);
1343
- }
1344
- resolveCurrentTab(task) {
1345
- const tabIds = Object.keys(task.tabs);
1346
- const id = task.currentTabId ?? tabIds[tabIds.length - 1];
1347
- if (!id)
1348
- throw new Error('No tabs open for this task');
1349
- return id;
1350
- }
1351
- getCdpTargetId(task, shortId) {
1352
- const cdpId = task.tabs[shortId];
1353
- if (!cdpId)
1354
- throw new Error(`Tab ${shortId} not found`);
1355
- return cdpId;
1356
- }
1357
- async tabs(taskId, profileRef) {
1358
- if (taskId) {
1359
- const { conn, task } = await this.findTask(taskId, profileRef);
1360
- if (conn.backend === 'arc-native')
1361
- return this.listArcTaskTabs(task);
1362
- if (conn.backend === 'bidi')
1363
- return this.listFirefoxTaskTabs(conn, task);
1364
- return this.getTabsForTask(conn.cdp, task);
1365
- }
1366
- const allTabs = [];
1367
- for (const [, conn] of this.connections) {
1368
- for (const [, task] of conn.tasks) {
1369
- const tabs = conn.backend === 'arc-native'
1370
- ? await this.listArcTaskTabs(task)
1371
- : conn.backend === 'bidi'
1372
- ? await this.listFirefoxTaskTabs(conn, task)
1373
- : await this.getTabsForTask(conn.cdp, task);
1374
- allTabs.push(...tabs);
1375
- }
1376
- }
1377
- return allTabs;
1378
- }
1379
- async tabClose(taskId, tabHint) {
1380
- const { conn, task } = await this.findTask(taskId);
1381
- if (conn.backend === 'arc-native') {
1382
- await this.runExclusive('arc-native:host-app', async () => {
1383
- await this.reconcileArcCreateIntents(conn, task);
1384
- const native = this.requireArcTask(task);
1385
- const ids = tabHint === undefined
1386
- ? Object.keys(native.tabs)
1387
- : [await this.resolveTabHint(conn, task, tabHint)];
1388
- for (const shortId of ids) {
1389
- const ref = native.tabs[shortId];
1390
- if (ref)
1391
- await closeArcTab(ref);
1392
- delete native.tabs[shortId];
1393
- delete task.tabs[shortId];
1394
- delete task.refDescriptors?.[shortId];
1395
- }
1396
- if (!task.currentTabId || ids.includes(task.currentTabId)) {
1397
- const remaining = Object.keys(native.tabs);
1398
- task.currentTabId = remaining.at(-1);
1399
- }
1400
- await this.saveTaskState(task.profile, conn.tasks);
1401
- });
1402
- return;
1403
- }
1404
- if (conn.backend === 'bidi') {
1405
- const borrowed = new Set(task.borrowedTabs ?? []);
1406
- const ids = tabHint === undefined
1407
- ? Object.keys(task.tabs)
1408
- : [await this.resolveTabHint(conn, task, tabHint)];
1409
- for (const shortId of ids) {
1410
- const context = task.tabs[shortId];
1411
- // A borrowed tab is released, not closed — it outlives this task.
1412
- if (context && !borrowed.has(shortId)) {
1413
- try {
1414
- await bidiCloseTab(conn.bidi, context);
1415
- }
1416
- catch { /* already closed */ }
1417
- }
1418
- delete task.tabs[shortId];
1419
- delete task.refDescriptors?.[shortId];
1420
- task.borrowedTabs = (task.borrowedTabs ?? []).filter((id) => id !== shortId);
1421
- }
1422
- if (!task.currentTabId || ids.includes(task.currentTabId)) {
1423
- task.currentTabId = Object.keys(task.tabs).at(-1);
1424
- }
1425
- await this.saveTaskState(task.profile, conn.tasks);
1426
- return;
1427
- }
1428
- requireCdp(conn, 'closeTab');
1429
- if (tabHint !== undefined) {
1430
- const shortId = await this.resolveTabHint(conn, task, tabHint);
1431
- const cdpId = task.tabs[shortId];
1432
- if (cdpId) {
1433
- // A borrowed tab is released, not closed — it outlives this task.
1434
- if (!(task.borrowedTabs ?? []).includes(shortId)) {
1435
- await conn.cdp.send('Target.closeTarget', { targetId: cdpId });
1436
- }
1437
- conn.sessionCache.delete(cdpId);
1438
- delete task.tabs[shortId];
1439
- // Drop the borrow record with the tab it names, or it outlives every tab
1440
- // it ever described and grows unbounded in the persisted task state.
1441
- task.borrowedTabs = (task.borrowedTabs ?? []).filter((id) => id !== shortId);
1442
- // Update currentTabId if we closed the current tab
1443
- if (task.currentTabId === shortId) {
1444
- const remaining = Object.keys(task.tabs);
1445
- task.currentTabId = remaining.length > 0 ? remaining[remaining.length - 1] : undefined;
1446
- }
1447
- }
1448
- }
1449
- else {
1450
- // Close all tabs the task opened; borrowed ones are only released.
1451
- await Promise.all(this.closeableTargetIds(task).map((cdpId) => conn.cdp.send('Target.closeTarget', { targetId: cdpId }).catch(() => { })));
1452
- for (const cdpId of Object.values(task.tabs)) {
1453
- conn.sessionCache.delete(cdpId);
1454
- }
1455
- task.tabs = {};
1456
- task.borrowedTabs = [];
1457
- task.currentTabId = undefined;
1458
- }
1459
- this.invalidateTargetCache(conn);
1460
- await this.saveTaskState(task.profile, conn.tasks);
1461
- }
1462
- async evaluate(taskId, tabHint, expression) {
1463
- const { conn, task } = await this.findTask(taskId);
1464
- const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
1465
- // Native Arc backend (PHNX-2399): synchronous JS only, isolated world.
1466
- if (conn.backend === 'arc-native') {
1467
- return this.evaluateArcNative(conn, task, shortId, expression);
1468
- }
1469
- // Firefox BiDi backend (PHNX-4043): async-capable, isolated realm.
1470
- if (conn.backend === 'bidi') {
1471
- return this.evaluateFirefox(conn, task, shortId, expression);
1472
- }
1473
- const cdpTargetId = this.getCdpTargetId(task, shortId);
1474
- const target = await this.getTarget(conn, cdpTargetId);
1475
- if (!target) {
1476
- throw new Error(`Tab ${shortId} not found`);
1477
- }
1478
- const sessionId = await this.getSessionId(conn, target.targetId);
1479
- // `awaitPromise: true` lets callers write `evaluate '(async () => {...})()'`
1480
- // and get the resolved value back instead of a stringified Promise. This
1481
- // is essential for any flow that needs sub-step waits inside the page
1482
- // (e.g. driving a multi-step modal where each step needs React to settle
1483
- // before the next call). Without it, the shell-side workaround is to
1484
- // chain N separate `evaluate` calls with `sleep` between them, which
1485
- // races against the page's own state machine.
1486
- //
1487
- // `exceptionDetails` is surfaced as a thrown error so a rejected promise
1488
- // or a thrown error inside the expression doesn't silently return `undefined`.
1489
- const result = (await conn.cdp.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }, sessionId));
1490
- if (result.exceptionDetails) {
1491
- const ex = result.exceptionDetails;
1492
- const msg = ex.exception?.description ??
1493
- (typeof ex.exception?.value === 'string' ? ex.exception.value : undefined) ??
1494
- ex.text ??
1495
- 'evaluate failed';
1496
- throw new Error(msg);
1497
- }
1498
- return result.result.value;
1499
- }
1500
- async screenshot(taskId, tabHint, outputPath, quality = 'compressed') {
1501
- const { conn, task, key: runtimeKey } = await this.findTask(taskId);
1502
- // Native Arc backend: screenshots cannot select/activate the user's tab.
1503
- if (conn.backend === 'arc-native') {
1504
- throw new ArcNativeCapabilityError('screenshot', 'Screenshot is unavailable for native Arc automation. ' +
1505
- 'Screenshots require activating the tab, which would disrupt user selection. ' +
1506
- 'This task still belongs to the Arc profile. ' +
1507
- 'Use a Chromium-family browser for screenshot capability.');
1508
- }
1509
- // Firefox BiDi backend (PHNX-4043): captureScreenshot on the context.
1510
- if (conn.backend === 'bidi') {
1511
- return this.screenshotFirefox(conn, task, runtimeKey, tabHint, outputPath, quality);
1512
- }
1513
- const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
1514
- const cdpTargetId = this.getCdpTargetId(task, shortId);
1515
- const target = await this.getTarget(conn, cdpTargetId);
1516
- if (!target) {
1517
- throw new Error(`Tab ${shortId} not found`);
1518
- }
1519
- const sessionId = await this.getSessionId(conn, target.targetId);
1520
- let buffer;
1521
- let extension;
1522
- if (quality === 'raw') {
1523
- // Pixel-faithful PNG, no downscale. For archived QA evidence where
1524
- // lossy JPEG would hide rendering bugs. Files run 0.5–3 MB.
1525
- const { data } = (await conn.cdp.send('Page.captureScreenshot', { format: 'png' }, sessionId));
1526
- buffer = Buffer.from(data, 'base64');
1527
- extension = 'png';
1528
- }
1529
- else {
1530
- // Default: JPEG quality 70, then iteratively downscale to keep the
1531
- // file under 100 KB so chat-injected screenshots stay token-cheap.
1532
- const { data } = (await conn.cdp.send('Page.captureScreenshot', { format: 'jpeg', quality: 70 }, sessionId));
1533
- buffer = Buffer.from(data, 'base64');
1534
- const MAX_SIZE = 100 * 1024;
1535
- if (buffer.length > MAX_SIZE) {
1536
- let q = 50;
1537
- while (buffer.length > MAX_SIZE && q > 10) {
1538
- const { data: resized } = (await conn.cdp.send('Page.captureScreenshot', { format: 'jpeg', quality: q }, sessionId));
1539
- buffer = Buffer.from(resized, 'base64');
1540
- q -= 10;
1541
- }
1542
- }
1543
- extension = 'jpg';
1544
- }
1545
- const sessionsDir = getProfileSessionsDir(runtimeKey, task.name);
1546
- const automaticPath = path.join(sessionsDir, `${Date.now()}.${extension}`);
1547
- const finalPath = resolveScreenshotOutputPath(outputPath, automaticPath);
1548
- await fs.promises.mkdir(path.dirname(finalPath), { recursive: true });
1549
- await fs.promises.writeFile(finalPath, buffer);
1550
- const dims = (extension === 'png' ? readPngDimensions(buffer) : readJpegDimensions(buffer)) ??
1551
- { width: 0, height: 0 };
1552
- emit('browser.screenshot', {
1553
- profile: parseConnectionKey(runtimeKey).profile,
1554
- task: task.name,
1555
- tabId: shortId,
1556
- path: finalPath,
1557
- bytes: buffer.length,
1558
- width: dims.width,
1559
- height: dims.height,
1560
- quality,
1561
- });
1562
- return { path: finalPath, bytes: buffer.length, width: dims.width, height: dims.height };
1563
- }
1564
- /**
1565
- * Export the current tab as a PDF via CDP `Page.printToPDF`. Reuses the
1566
- * screenshot session + tab resolution so `--tab`, path sandboxing, and the
1567
- * auto-path (`sessions/<task>/<ts>.pdf`) all behave identically to
1568
- * `screenshot`. `printBackground: true` matches Chrome's default print flow
1569
- * — without it, dark-mode pages render on a blank sheet.
1570
- */
1571
- async printToPdf(taskId, tabHint, outputPath) {
1572
- const { conn, task, key: runtimeKey } = await this.findTask(taskId);
1573
- requireCdp(conn, 'pdf');
1574
- const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
1575
- const cdpTargetId = this.getCdpTargetId(task, shortId);
1576
- const target = await this.getTarget(conn, cdpTargetId);
1577
- if (!target) {
1578
- throw new Error(`Tab ${shortId} not found`);
1579
- }
1580
- const sessionId = await this.getSessionId(conn, target.targetId);
1581
- const { data } = (await conn.cdp.send('Page.printToPDF', { printBackground: true, preferCSSPageSize: true }, sessionId));
1582
- const buffer = Buffer.from(data, 'base64');
1583
- const sessionsDir = getProfileSessionsDir(runtimeKey, task.name);
1584
- const automaticPath = path.join(sessionsDir, `${Date.now()}.pdf`);
1585
- const finalPath = resolveScreenshotOutputPath(outputPath, automaticPath);
1586
- await fs.promises.mkdir(path.dirname(finalPath), { recursive: true });
1587
- await fs.promises.writeFile(finalPath, buffer);
1588
- return { path: finalPath, bytes: buffer.length };
1589
- }
1590
- // ─── Recording ──────────────────────────────────────────────────────────────
1591
- //
1592
- // CDP `Page.startScreencast` emits a JPEG frame per `everyNthFrame`. We pipe
1593
- // those frames into ffmpeg's stdin (image2pipe) and encode to a webm/vp9 file
1594
- // under `sessions/<task>/recordings/`. A background watcher enforces the
1595
- // duration + size caps so a forgotten recording can't fill the disk.
1596
- recordings = new Map();
1597
- async recordStart(taskId, tabHint, opts = {}) {
1598
- if (this.recordings.has(taskId)) {
1599
- throw new Error(`Task "${taskId}" is already recording. Call record stop first.`);
1600
- }
1601
- const { conn, task, key: runtimeKey } = await this.findTask(taskId);
1602
- requireCdp(conn, 'recording');
1603
- const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
1604
- const cdpTargetId = this.getCdpTargetId(task, shortId);
1605
- const target = await this.getTarget(conn, cdpTargetId);
1606
- if (!target)
1607
- throw new Error(`Tab ${shortId} not found`);
1608
- const sessionId = await this.getSessionId(conn, target.targetId);
1609
- const fps = opts.fps ?? 5;
1610
- const durationSec = opts.duration ?? 60;
1611
- const maxMb = opts.maxMb ?? 25;
1612
- if (fps < 1 || fps > 30)
1613
- throw new Error('--fps must be between 1 and 30');
1614
- if (durationSec < 1 || durationSec > 3600)
1615
- throw new Error('--duration must be between 1 and 3600 seconds');
1616
- if (maxMb < 1 || maxMb > 500)
1617
- throw new Error('--max-mb must be between 1 and 500');
1618
- const recordingsDir = path.join(getProfileSessionsDir(runtimeKey, task.name), 'recordings');
1619
- await fs.promises.mkdir(recordingsDir, { recursive: true });
1620
- const outputPath = path.join(recordingsDir, `${Date.now()}.webm`);
1621
- // Resolve ffmpeg lazily so non-recording paths don't pay the import cost.
1622
- const [{ spawn }, ffmpegPath] = await Promise.all([
1623
- import('child_process'),
1624
- resolveFfmpeg(),
1625
- ]);
1626
- // The frame pump below clocks the latest CDP paint into stdin at exactly
1627
- // `fps`, including repeated frames while the page is static. Declare that
1628
- // cadence to image2pipe so frame count / fps is the real capture duration.
1629
- const ffmpeg = spawn(ffmpegPath, [
1630
- '-loglevel', 'error',
1631
- '-f', 'image2pipe',
1632
- '-framerate', String(fps),
1633
- '-i', '-',
1634
- '-c:v', 'libvpx-vp9',
1635
- '-b:v', '1M',
1636
- '-pix_fmt', 'yuv420p',
1637
- '-y',
1638
- outputPath,
1639
- ], { stdio: ['pipe', 'ignore', 'pipe'], windowsHide: true });
1640
- // Wait for the spawn to confirm before wiring CDP frames into a dead pipe.
1641
- await new Promise((resolve, reject) => {
1642
- const onError = (err) => {
1643
- ffmpeg.off('spawn', onSpawn);
1644
- reject(err);
1645
- };
1646
- const onSpawn = () => {
1647
- ffmpeg.off('error', onError);
1648
- resolve();
1649
- };
1650
- ffmpeg.once('error', onError);
1651
- ffmpeg.once('spawn', onSpawn);
1652
- });
1653
- // Capture ffmpeg's own diagnostics (encoder error, bad codec, etc.) so a
1654
- // failing encode is DIAGNOSABLE at recordStop instead of being discarded.
1655
- // Cap the buffer so a chatty ffmpeg can't grow it unbounded.
1656
- let stderrBuf = '';
1657
- ffmpeg.stderr?.on('data', (d) => {
1658
- stderrBuf += d.toString();
1659
- if (stderrBuf.length > 64 * 1024)
1660
- stderrBuf = stderrBuf.slice(-64 * 1024);
1661
- });
1662
- const ffmpegStderr = () => stderrBuf;
1663
- ffmpeg.on('error', () => { });
1664
- let frameCount = 0;
1665
- let latestFrame;
1666
- let stdinBlocked = false;
1667
- let stdinError;
1668
- ffmpeg.stdin?.on('drain', () => { stdinBlocked = false; });
1669
- ffmpeg.stdin?.on('error', (error) => { stdinError = error; });
1670
- const writeFrame = (frame) => {
1671
- const stdin = ffmpeg.stdin;
1672
- if (!stdin || stdin.destroyed || stdinError || stdinBlocked)
1673
- return false;
1674
- stdinBlocked = !stdin.write(frame);
1675
- frameCount += 1;
1676
- return true;
1677
- };
1678
- const frameIntervalMs = 1000 / fps;
1679
- let resolveFirstFrame;
1680
- const firstFrame = new Promise((resolve) => { resolveFirstFrame = resolve; });
1681
- const frameHandler = (params, eventSessionId) => {
1682
- if (eventSessionId !== sessionId)
1683
- return;
1684
- const p = params;
1685
- try {
1686
- latestFrame = Buffer.from(p.data, 'base64');
1687
- if (frameCount === 0) {
1688
- if (writeFrame(latestFrame))
1689
- resolveFirstFrame();
1690
- }
1691
- }
1692
- catch {
1693
- // ffmpeg exited; ignore writes
1694
- }
1695
- // Must ack every frame or CDP stops sending.
1696
- conn.cdp.send('Page.screencastFrameAck', { sessionId: p.sessionId }, sessionId).catch(() => { });
1697
- };
1698
- // CDP emits on paints, so a static page can legitimately produce only one
1699
- // event in 15 seconds. Retain the latest real frame and clock it into the
1700
- // encoder at --fps; new paints replace it, while quiet time still advances
1701
- // the WebM timeline instead of collapsing to a one-frame, 0.04s artifact.
1702
- const framePump = setInterval(() => {
1703
- if (!latestFrame)
1704
- return;
1705
- try {
1706
- writeFrame(latestFrame);
1707
- }
1708
- catch {
1709
- // ffmpeg exited; recordStop reports its exit code and stderr.
1710
- }
1711
- }, frameIntervalMs);
1712
- // Register BEFORE startScreencast. Chrome may emit its first frame before
1713
- // the command response; missing that frame also misses its ack and stalls
1714
- // the entire stream with ffmpeg's stdin still empty (PHNX-2600).
1715
- conn.cdp.on('Page.screencastFrame', frameHandler);
1716
- try {
1717
- await conn.cdp.send('Page.enable', {}, sessionId);
1718
- await conn.cdp.send('Page.startScreencast', {
1719
- format: 'jpeg',
1720
- quality: 60,
1721
- // Chrome's frames are paint-driven, not a 30fps clock. Asking for
1722
- // every third paint can suppress the only stored frame on a static
1723
- // page, so receive every paint and enforce --fps when writing stdin.
1724
- everyNthFrame: 1,
1725
- maxFramesInFlight: 1,
1726
- // A static/low-paint page may produce no new compositor frame after
1727
- // startScreencast. Ask Chrome to send its stored last frame so the
1728
- // first ack can bootstrap the stream instead of leaving ffmpeg stdin
1729
- // empty forever (confirmed live against Chrome 151 / PHNX-2600).
1730
- sendLastFrame: true,
1731
- }, sessionId);
1732
- await new Promise((resolve, reject) => {
1733
- const timer = setTimeout(() => reject(new Error('Chrome produced no screencast frame within 5s; recording was not started.')), 5000);
1734
- firstFrame.then(() => {
1735
- clearTimeout(timer);
1736
- resolve();
1737
- });
1738
- });
1739
- }
1740
- catch (error) {
1741
- conn.cdp.off('Page.screencastFrame', frameHandler);
1742
- clearInterval(framePump);
1743
- try {
1744
- await conn.cdp.send('Page.stopScreencast', {}, sessionId);
1745
- }
1746
- catch { /* session may be gone */ }
1747
- try {
1748
- ffmpeg.stdin?.end();
1749
- }
1750
- catch { /* process may already be gone */ }
1751
- await new Promise((resolve) => {
1752
- if (ffmpeg.exitCode !== null)
1753
- return resolve();
1754
- const timer = setTimeout(() => {
1755
- try {
1756
- ffmpeg.kill('SIGKILL');
1757
- }
1758
- catch { /* already gone */ }
1759
- resolve();
1760
- }, 1000);
1761
- ffmpeg.once('exit', () => {
1762
- clearTimeout(timer);
1763
- resolve();
1764
- });
1765
- });
1766
- await fs.promises.rm(outputPath, { force: true });
1767
- throw error;
1768
- }
1769
- const durationMs = durationSec * 1000;
1770
- const maxBytes = maxMb * 1024 * 1024;
1771
- const state = {
1772
- outputPath,
1773
- startedAt: Date.now(),
1774
- fps,
1775
- durationMs,
1776
- maxBytes,
1777
- ffmpeg,
1778
- ffmpegStderr,
1779
- encoderError: () => stdinError,
1780
- get frameCount() { return frameCount; },
1781
- sessionId,
1782
- conn,
1783
- frameHandler,
1784
- framePump,
1785
- durationTimer: setTimeout(() => {
1786
- this.recordStop(taskId, 'duration-cap').catch(() => { });
1787
- }, durationMs),
1788
- sizeCheckInterval: setInterval(async () => {
1789
- try {
1790
- const st = await fs.promises.stat(outputPath);
1791
- if (st.size >= maxBytes) {
1792
- await this.recordStop(taskId, 'size-cap');
1793
- }
1794
- }
1795
- catch {
1796
- // File may not exist yet
1797
- }
1798
- }, 1000),
1799
- };
1800
- this.recordings.set(taskId, state);
1801
- return { path: outputPath, fps, durationCapSec: durationSec, maxMb };
1802
- }
1803
- async recordStop(taskId, reason = 'manual') {
1804
- const activeTask = await this.findTask(taskId).catch(() => undefined);
1805
- if (activeTask)
1806
- requireCdp(activeTask.conn, 'recording');
1807
- const rec = this.recordings.get(taskId);
1808
- if (!rec) {
1809
- throw new Error(`Task "${taskId}" is not currently recording`);
1810
- }
1811
- if (rec.stopReason) {
1812
- // Already stopping (e.g. size-cap fired while user also called stop).
1813
- // Wait for in-flight finalize.
1814
- while (this.recordings.has(taskId)) {
1815
- await new Promise((r) => setTimeout(r, 25));
1816
- }
1817
- }
1818
- rec.stopReason = reason;
1819
- clearTimeout(rec.durationTimer);
1820
- clearInterval(rec.sizeCheckInterval);
1821
- clearInterval(rec.framePump);
1822
- rec.conn.cdp.off('Page.screencastFrame', rec.frameHandler);
1823
- try {
1824
- await rec.conn.cdp.send('Page.stopScreencast', {}, rec.sessionId);
1825
- }
1826
- catch {
1827
- // session may already be gone
1828
- }
1829
- // Close ffmpeg stdin so it flushes the output file cleanly, and observe how
1830
- // it exits. A non-zero exit means the encode failed — the recording must
1831
- // NOT be reported as success. If ffmpeg hangs, KILL it (don't just abandon
1832
- // the promise, which leaked the process) and treat the recording as failed.
1833
- const finalize = await new Promise((resolve) => {
1834
- let done = false;
1835
- const settle = (r) => {
1836
- if (done)
1837
- return;
1838
- done = true;
1839
- clearTimeout(timer);
1840
- resolve(r);
1841
- };
1842
- const timer = setTimeout(() => {
1843
- try {
1844
- rec.ffmpeg.kill('SIGKILL');
1845
- }
1846
- catch { /* already gone */ }
1847
- settle({ code: null, timedOut: true });
1848
- }, 5000);
1849
- rec.ffmpeg.once('exit', (code) => settle({ code, timedOut: false }));
1850
- try {
1851
- rec.ffmpeg.stdin?.end();
1852
- }
1853
- catch {
1854
- // stdin already closed; wait for exit or the hard timeout above.
1855
- }
1856
- });
1857
- const durationMs = Date.now() - rec.startedAt;
1858
- // Drop the recording from the map before any throw, so a failed stop
1859
- // doesn't wedge the task in a permanent "already recording" state.
1860
- this.recordings.delete(taskId);
1861
- if (finalize.timedOut) {
1862
- throw new Error(`ffmpeg did not exit within 5s while finalizing the recording; killed it. ` +
1863
- `The recording at ${rec.outputPath} is incomplete.`);
1864
- }
1865
- if (finalize.code !== 0) {
1866
- const err = rec.ffmpegStderr().trim();
1867
- throw new Error(`ffmpeg exited abnormally (code ${finalize.code}) while finalizing the recording at ` +
1868
- `${rec.outputPath}; the file is likely corrupt or empty.` +
1869
- (err ? ` ffmpeg: ${err.slice(-800)}` : ''));
1870
- }
1871
- const encoderError = rec.encoderError?.();
1872
- if (encoderError) {
1873
- throw new Error(`ffmpeg stopped accepting recording frames at ${rec.outputPath}: ${encoderError.message}`);
1874
- }
1875
- if (rec.frameCount === 0) {
1876
- throw new Error(`Chrome delivered no frames for the recording at ${rec.outputPath}; the output is empty.`);
1877
- }
1878
- let bytes = 0;
1879
- try {
1880
- const st = await fs.promises.stat(rec.outputPath);
1881
- bytes = st.size;
1882
- }
1883
- catch {
1884
- // ffmpeg exited 0 but the file is missing — still a failed recording.
1885
- }
1886
- return { path: rec.outputPath, bytes, durationMs, reason };
1887
- }
1888
- async recordStatus(taskId) {
1889
- // The idle reaper calls this while deciding whether a task is stale. Do
1890
- // not route an already-live task through findTask(), which stamps it as
1891
- // active and defeats idle detection. A cold daemon may still rehydrate for
1892
- // an explicit user query.
1893
- // A status query, not an action: a backend that cannot record (Arc native,
1894
- // Firefox BiDi) is truthfully "not recording". Throwing here took the
1895
- // idle reaper down with it — one Firefox task parked reaping for every task.
1896
- const live = this.findTaskByHandle(taskId) ?? await this.findTask(taskId).catch(() => undefined);
1897
- if (live && (live.conn.backend === 'arc-native' || live.conn.backend === 'bidi'))
1898
- return { recording: false };
1899
- const rec = this.recordings.get(taskId);
1900
- if (!rec)
1901
- return { recording: false };
1902
- return { recording: true, path: rec.outputPath, elapsedMs: Date.now() - rec.startedAt };
1903
- }
1904
- async refs(taskId, tabHint, opts = {}) {
1905
- const { conn, task } = await this.findTask(taskId);
1906
- const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
1907
- if (conn.backend === 'arc-native') {
1908
- const result = parseArcRefsResult(await this.evaluateArcNative(conn, task, shortId, arcRefsExpression(opts)), opts);
1909
- this.cacheRefDescriptors(task, shortId, result.nodeMap, result.opts);
1910
- await this.saveTaskState(task.profile, conn.tasks);
1911
- return result;
1912
- }
1913
- // Firefox BiDi (PHNX-4043): the same DOM-derived accessibility listing Arc
1914
- // uses, evaluated over BiDi. selector-based refs, no CDP accessibility tree.
1915
- if (conn.backend === 'bidi') {
1916
- const result = parseArcRefsResult(await this.evaluateFirefox(conn, task, shortId, arcRefsExpression(opts)), opts);
1917
- this.cacheRefDescriptors(task, shortId, result.nodeMap, result.opts);
1918
- await this.saveTaskState(task.profile, conn.tasks);
1919
- return result;
1920
- }
1921
- requireCdp(conn, 'enumerate');
1922
- const cdpTargetId = this.getCdpTargetId(task, shortId);
1923
- const target = await this.getTarget(conn, cdpTargetId);
1924
- if (!target)
1925
- throw new Error(`Tab ${shortId} not found`);
1926
- const sessionId = await this.getSessionId(conn, target.targetId);
1927
- const result = await getRefs(conn.cdp, sessionId, opts);
1928
- // Snapshot the stable descriptors AND the opts they were numbered against
1929
- // so a later click/type can self-heal a drifted ref by rebuilding with the
1930
- // same filter. `refs()` is the sole owner of this cache — actions read it,
1931
- // never overwrite it. Persist to tasks.json so it survives a daemon
1932
- // restart.
1933
- this.cacheRefDescriptors(task, shortId, result.nodeMap, result.opts);
1934
- await this.saveTaskState(task.profile, conn.tasks);
1935
- return { refs: result.refs, nodeMap: result.nodeMap };
1936
- }
1937
- /** Record the last ref listing (descriptors + opts) for a tab into state. */
1938
- cacheRefDescriptors(task, shortId, nodeMap, opts) {
1939
- if (!task.refDescriptors)
1940
- task.refDescriptors = {};
1941
- task.refDescriptors[shortId] = { descriptors: describeRefs(nodeMap), opts };
1942
- }
1943
- /**
1944
- * Re-resolve a caller-supplied ref against a freshly-built node map, healing
1945
- * it back to the right element when the integer ref has drifted since the
1946
- * cached `refs` listing. Shared by `click` and `type` so both self-heal
1947
- * identically. Returns the ref to act on plus, when a heal occurred, the
1948
- * {@link HealInfo} to surface. Throws when the cached element is gone.
1949
- */
1950
- resolveHealedRef(snapshot, nodeMap, ref) {
1951
- const cached = snapshot?.descriptors.find((d) => d.ref === ref);
1952
- if (!cached)
1953
- return { targetRef: ref };
1954
- const fresh = nodeMap.get(ref);
1955
- const stillMatches = fresh !== undefined &&
1956
- fresh.role === cached.role &&
1957
- fresh.name === cached.name &&
1958
- (fresh.backendNodeId !== undefined || fresh.selector !== undefined);
1959
- if (stillMatches)
1960
- return { targetRef: ref };
1961
- const newRef = healRef(cached, nodeMap);
1962
- if (newRef === null) {
1963
- throw new Error(`Ref ${ref} (${cached.role} "${cached.name}") could not be re-resolved: ` +
1964
- `no matching element on the current page. Re-run 'browser refs' to ` +
1965
- `refresh the ref numbers, or act by position with 'browser click --at X,Y'.`);
1966
- }
1967
- if (newRef === ref)
1968
- return { targetRef: ref };
1969
- console.error(`[browser] self-healed ref ${ref} -> ${newRef} (${cached.role} "${cached.name}") — ` +
1970
- `cached descriptor re-matched after the ref drifted`);
1971
- return { targetRef: newRef, healed: { from: ref, to: newRef, role: cached.role, name: cached.name } };
1972
- }
1973
- async click(taskId, ref, tabHint) {
1974
- const { conn, task } = await this.findTask(taskId);
1975
- const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
1976
- if (conn.backend === 'arc-native') {
1977
- const snapshot = task.refDescriptors?.[shortId];
1978
- const buildOpts = snapshot?.opts ?? { interactive: true, limit: 500 };
1979
- const { nodeMap } = parseArcRefsResult(await this.evaluateArcNative(conn, task, shortId, arcRefsExpression(buildOpts)), buildOpts);
1980
- const { targetRef, healed } = this.resolveHealedRef(snapshot, nodeMap, ref);
1981
- const selector = nodeMap.get(targetRef)?.selector;
1982
- if (!selector)
1983
- throw new Error(`Ref ${ref} has no DOM selector`);
1984
- await this.evaluateArcNative(conn, task, shortId, arcClickExpression(selector));
1985
- return healed ? { healed } : {};
1986
- }
1987
- if (conn.backend === 'bidi') {
1988
- const snapshot = task.refDescriptors?.[shortId];
1989
- const buildOpts = snapshot?.opts ?? { interactive: true, limit: 500 };
1990
- const { nodeMap } = parseArcRefsResult(await this.evaluateFirefox(conn, task, shortId, arcRefsExpression(buildOpts)), buildOpts);
1991
- const { targetRef, healed } = this.resolveHealedRef(snapshot, nodeMap, ref);
1992
- const selector = nodeMap.get(targetRef)?.selector;
1993
- if (!selector)
1994
- throw new Error(`Ref ${ref} has no DOM selector`);
1995
- const coords = await this.firefoxRefCoords(conn, task, shortId, selector);
1996
- const context = this.firefoxContext(task, shortId);
1997
- await bidiClickAt(conn.bidi, context, coords.x, coords.y);
1998
- return healed ? { healed } : {};
1999
- }
2000
- requireCdp(conn, 'click');
2001
- const cdpTargetId = this.getCdpTargetId(task, shortId);
2002
- const target = await this.getTarget(conn, cdpTargetId);
2003
- if (!target)
2004
- throw new Error(`Tab ${shortId} not found`);
2005
- const sessionId = await this.getSessionId(conn, target.targetId);
2006
- // Rebuild the node map with the SAME opts the cached listing was numbered
2007
- // against, so the caller's ref lands on the element they saw in `browser
2008
- // refs`. Rebuilding with a different filter (the old interactive:false)
2009
- // renumbers every ref and defeats self-heal on the second click. Default
2010
- // to the user-facing interactive numbering when no listing was cached yet.
2011
- const snapshot = task.refDescriptors?.[shortId];
2012
- const buildOpts = snapshot?.opts ?? { interactive: true, limit: 500 };
2013
- const { nodeMap } = await getRefs(conn.cdp, sessionId, buildOpts);
2014
- // Self-heal: the integer ref is positional and drifts on re-render. If the
2015
- // fresh node at this position no longer matches the cached descriptor,
2016
- // re-resolve (by attrs/proximity-tie-broken role+name) BEFORE clicking the
2017
- // wrong element. The cache is owned by refs() and NOT rewritten here — the
2018
- // caller's ref numbers stay anchored to the listing they came from.
2019
- const { targetRef, healed } = this.resolveHealedRef(snapshot, nodeMap, ref);
2020
- const { x, y } = await resolveRefToCoords(conn.cdp, sessionId, nodeMap, targetRef);
2021
- await clickAtCoords(conn.cdp, sessionId, x, y);
2022
- return healed ? { healed } : {};
2023
- }
2024
- /**
2025
- * Click raw viewport coordinates, bypassing ref resolution entirely. Backs
2026
- * `browser click --at X,Y` — the escape hatch when the accessibility tree
2027
- * exposes no usable ref (canvas apps, custom-drawn UI) and the caller has
2028
- * located the target from a screenshot.
2029
- */
2030
- async clickAt(taskId, x, y, tabHint) {
2031
- const { conn, task } = await this.findTask(taskId);
2032
- requireCdp(conn, 'trustedInput');
2033
- const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
2034
- const cdpTargetId = this.getCdpTargetId(task, shortId);
2035
- const target = await this.getTarget(conn, cdpTargetId);
2036
- if (!target)
2037
- throw new Error(`Tab ${shortId} not found`);
2038
- const sessionId = await this.getSessionId(conn, target.targetId);
2039
- await clickAtCoords(conn.cdp, sessionId, x, y);
2040
- }
2041
- async type(taskId, ref, text, tabHint, clear) {
2042
- const { conn, task } = await this.findTask(taskId);
2043
- const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
2044
- if (conn.backend === 'arc-native') {
2045
- const snapshot = task.refDescriptors?.[shortId];
2046
- const buildOpts = snapshot?.opts ?? { interactive: true, limit: 500 };
2047
- const { nodeMap } = parseArcRefsResult(await this.evaluateArcNative(conn, task, shortId, arcRefsExpression(buildOpts)), buildOpts);
2048
- const { targetRef } = this.resolveHealedRef(snapshot, nodeMap, ref);
2049
- const selector = nodeMap.get(targetRef)?.selector;
2050
- if (!selector)
2051
- throw new Error(`Ref ${ref} has no DOM selector`);
2052
- await this.evaluateArcNative(conn, task, shortId, arcFillExpression(selector, text, clear ?? false));
2053
- return;
2054
- }
2055
- if (conn.backend === 'bidi') {
2056
- const snapshot = task.refDescriptors?.[shortId];
2057
- const buildOpts = snapshot?.opts ?? { interactive: true, limit: 500 };
2058
- const { nodeMap } = parseArcRefsResult(await this.evaluateFirefox(conn, task, shortId, arcRefsExpression(buildOpts)), buildOpts);
2059
- const { targetRef } = this.resolveHealedRef(snapshot, nodeMap, ref);
2060
- const selector = nodeMap.get(targetRef)?.selector;
2061
- if (!selector)
2062
- throw new Error(`Ref ${ref} has no DOM selector`);
2063
- await this.evaluateFirefox(conn, task, shortId, arcFillExpression(selector, text, clear ?? false));
2064
- return;
2065
- }
2066
- requireCdp(conn, 'type');
2067
- const cdpTargetId = this.getCdpTargetId(task, shortId);
2068
- const target = await this.getTarget(conn, cdpTargetId);
2069
- if (!target)
2070
- throw new Error(`Tab ${shortId} not found`);
2071
- const sessionId = await this.getSessionId(conn, target.targetId);
2072
- // Same self-healing story as click(): rebuild against the cached listing's
2073
- // opts so refs line up with what the user saw, then heal a drifted ref
2074
- // before typing into the wrong field.
2075
- const snapshot = task.refDescriptors?.[shortId];
2076
- const buildOpts = snapshot?.opts ?? { interactive: true, limit: 500 };
2077
- const { nodeMap } = await getRefs(conn.cdp, sessionId, buildOpts);
2078
- const { targetRef } = this.resolveHealedRef(snapshot, nodeMap, ref);
2079
- const node = nodeMap.get(targetRef);
2080
- if (!node)
2081
- throw new Error(`Ref ${ref} not found`);
2082
- if (node.editor) {
2083
- await typeEditorText(conn.cdp, sessionId, node, text, clear);
2084
- }
2085
- else {
2086
- if (node.backendNodeId) {
2087
- await focusNode(conn.cdp, sessionId, node.backendNodeId);
2088
- }
2089
- await typeText(conn.cdp, sessionId, text);
2090
- }
2091
- }
2092
- async press(taskId, key, tabHint) {
2093
- const { conn, task } = await this.findTask(taskId);
2094
- requireCdp(conn, 'trustedInput');
2095
- const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
2096
- const cdpTargetId = this.getCdpTargetId(task, shortId);
2097
- const target = await this.getTarget(conn, cdpTargetId);
2098
- if (!target)
2099
- throw new Error(`Tab ${shortId} not found`);
2100
- const sessionId = await this.getSessionId(conn, target.targetId);
2101
- await pressKey(conn.cdp, sessionId, key);
2102
- }
2103
- async hover(taskId, ref, tabHint) {
2104
- const { conn, task } = await this.findTask(taskId);
2105
- requireCdp(conn, 'trustedInput');
2106
- const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
2107
- const cdpTargetId = this.getCdpTargetId(task, shortId);
2108
- const target = await this.getTarget(conn, cdpTargetId);
2109
- if (!target)
2110
- throw new Error(`Tab ${shortId} not found`);
2111
- const sessionId = await this.getSessionId(conn, target.targetId);
2112
- const { nodeMap } = await getRefs(conn.cdp, sessionId, { interactive: false, limit: 1000 });
2113
- const { x, y } = await resolveRefToCoords(conn.cdp, sessionId, nodeMap, ref);
2114
- await hoverAtCoords(conn.cdp, sessionId, x, y);
2115
- }
2116
- async scroll(taskId, deltaX, deltaY, atX, atY, tabHint) {
2117
- const { conn, task } = await this.findTask(taskId);
2118
- const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
2119
- if (conn.backend === 'arc-native') {
2120
- if (atX !== undefined || atY !== undefined)
2121
- throw new ArcNativeCapabilityError('trustedInput');
2122
- await this.evaluateArcNative(conn, task, shortId, arcScrollExpression(deltaX, deltaY));
2123
- return;
2124
- }
2125
- if (conn.backend === 'bidi') {
2126
- await this.evaluateFirefox(conn, task, shortId, arcScrollExpression(deltaX, deltaY));
2127
- return;
2128
- }
2129
- requireCdp(conn, 'scroll');
2130
- const cdpTargetId = this.getCdpTargetId(task, shortId);
2131
- const target = await this.getTarget(conn, cdpTargetId);
2132
- if (!target)
2133
- throw new Error(`Tab ${shortId} not found`);
2134
- const sessionId = await this.getSessionId(conn, target.targetId);
2135
- await scrollAtCoords(conn.cdp, sessionId, atX ?? 0, atY ?? 0, deltaX, deltaY);
2136
- }
2137
- async upload(taskId, files, options) {
2138
- const { conn, task } = await this.findTask(taskId);
2139
- requireCdp(conn, 'upload');
2140
- const shortId = options.tabHint
2141
- ? await this.resolveTabHint(conn, task, options.tabHint)
2142
- : this.resolveCurrentTab(task);
2143
- const cdpTargetId = this.getCdpTargetId(task, shortId);
2144
- const target = await this.getTarget(conn, cdpTargetId);
2145
- if (!target)
2146
- throw new Error(`Tab ${shortId} not found`);
2147
- const sessionId = await this.getSessionId(conn, target.targetId);
2148
- // Match the user-facing ref numbering from `agents browser refs` (which
2149
- // defaults to interactive=true). The other action helpers in this file
2150
- // use interactive=false historically, but that produces ref numbers the
2151
- // user never sees — `--ref 1` then resolves to the RootWebArea instead of
2152
- // the first interactive element. Match the listing the user actually saw.
2153
- const { nodeMap } = await getRefs(conn.cdp, sessionId, { interactive: true, limit: 1000 });
2154
- const mode = options.mode ?? 'auto';
2155
- if (options.trigger !== undefined || mode === 'chooser') {
2156
- const ref = options.trigger ?? options.ref;
2157
- if (ref === undefined) {
2158
- throw new Error('chooser mode requires --trigger <ref> (or --ref) pointing at the button that opens the file dialog');
2159
- }
2160
- const node = nodeMap.get(ref);
2161
- if (!node)
2162
- throw new Error(`Ref ${ref} not found`);
2163
- await uploadViaFileChooser(conn.cdp, sessionId, { node, nodeMap }, files, options.timeout);
2164
- return { mode: 'chooser' };
2165
- }
2166
- if (options.ref === undefined) {
2167
- throw new Error('upload requires --ref <n> (target element) or --trigger <n> (button that opens chooser)');
2168
- }
2169
- const node = nodeMap.get(options.ref);
2170
- if (!node)
2171
- throw new Error(`Ref ${options.ref} not found`);
2172
- if (!node.backendNodeId)
2173
- throw new Error(`Ref ${options.ref} has no DOM node`);
2174
- let resolved;
2175
- if (mode === 'input') {
2176
- resolved = 'input';
2177
- }
2178
- else if (mode === 'drop') {
2179
- resolved = 'drop';
2180
- }
2181
- else {
2182
- resolved = await detectUploadPattern(conn.cdp, sessionId, node.backendNodeId);
2183
- }
2184
- if (resolved === 'input') {
2185
- await uploadToFileInput(conn.cdp, sessionId, node.backendNodeId, files);
2186
- }
2187
- else {
2188
- await uploadToDropTarget(conn.cdp, sessionId, node.backendNodeId, files);
2189
- }
2190
- return { mode: resolved };
2191
- }
2192
- stageUpload(source) {
2193
- return { path: stageUploadFile(source) };
2194
- }
2195
- /**
2196
- * Status for one profile (bare name) or every live profile.
2197
- *
2198
- * `profileName` is a {@link ProfileName} — a BARE name, never a runtime key.
2199
- * Every candidate is selected by the one rule ({@link keyBelongsToProfile}),
2200
- * and a scoped query that finds no live connection falls through to disk, so
2201
- * `status --profile comet-local` reports the LIVE `comet-local@<device>`
2202
- * (and leftover `comet-local@endpoint-0` dirs) instead of the empty list
2203
- * it used to return (RUSH-2709).
2204
- */
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
- };
2213
- // Reconnect live browsers after a daemon restart so status is not empty
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
- });
2220
- // A caller who pasted a runtime key out of an older listing still gets the
2221
- // profile it belongs to, rather than an empty result.
2222
- const profileName = profileRef ? parseConnectionKey(profileRef).profile : undefined;
2223
- const seenProfiles = new Set();
2224
- const statuses = [];
2225
- const candidates = profileName
2226
- ? [...this.connections.keys()].filter((k) => keyBelongsToProfile(k, profileName))
2227
- : Array.from(this.connections.keys());
2228
- for (const key of candidates) {
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
- }
2249
- if (status) {
2250
- statuses.push(status);
2251
- seenProfiles.add(status.name);
2252
- }
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
- };
2313
- if (!profileName) {
2314
- for (const profile of await listProfiles()) {
2315
- if (seenProfiles.has(profile.name))
2316
- continue;
2317
- await reconcile(profile.name);
2318
- }
2319
- }
2320
- else if (statuses.length === 0) {
2321
- await reconcile(profileName);
2322
- }
2323
- return statuses;
2324
- }
2325
- /**
2326
- * Rebuild a profile's status from its runtime dirs when no live connection is
2327
- * registered — a daemon that never connected this profile, or a browser
2328
- * started by an earlier daemon.
2329
- *
2330
- * Takes a BARE profile name and walks every runtime key that belongs to it
2331
- * ({@link listProfileCacheDirs}), including the legacy pre-composite dir named
2332
- * exactly the profile.
2333
- */
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) {
2341
- const dirs = listProfileCacheDirs(profileName);
2342
- for (const dir of dirs) {
2343
- const key = asConnectionKey(path.basename(dir));
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
- }
2355
- }
2356
- return null;
2357
- }
2358
- async profileStatusFromDisk(profileName, runtimeKey, port, pid) {
2359
- const profile = await getProfile(profileName);
2360
- const tasks = this.loadTaskState(runtimeKey);
2361
- const taskStatuses = [];
2362
- for (const [, task] of tasks) {
2363
- taskStatuses.push({
2364
- id: task.id,
2365
- name: task.name,
2366
- label: task.label ?? task.name,
2367
- tabCount: Object.keys(task.tabs).length,
2368
- currentTabId: task.currentTabId,
2369
- createdAt: task.createdAt,
2370
- });
2371
- }
2372
- const configuredPort = profile ? extractConfiguredPort(profile) : undefined;
2373
- return {
2374
- // Bare name for the user; the endpoint travels in its own field.
2375
- name: profileName,
2376
- endpoint: parseConnectionKey(runtimeKey).endpoint,
2377
- key: runtimeKey,
2378
- running: true,
2379
- port,
2380
- pid,
2381
- configuredPort: configuredPort !== port ? configuredPort : undefined,
2382
- tasks: taskStatuses,
2383
- };
2384
- }
2385
- // ─── Viewport & Device Emulation ──────────────────────────────────────────────
2386
- async setViewport(taskId, width, height, options = {}) {
2387
- const { conn, task } = await this.findTask(taskId);
2388
- requireCdp(conn, 'viewport');
2389
- const shortId = options.tabHint ? await this.resolveTabHint(conn, task, options.tabHint) : this.resolveCurrentTab(task);
2390
- const cdpTargetId = this.getCdpTargetId(task, shortId);
2391
- const target = await this.getTarget(conn, cdpTargetId);
2392
- if (!target)
2393
- throw new Error(`Tab ${shortId} not found`);
2394
- const sessionId = await this.getSessionId(conn, target.targetId);
2395
- await conn.cdp.send('Emulation.setDeviceMetricsOverride', {
2396
- width,
2397
- height,
2398
- deviceScaleFactor: options.deviceScaleFactor ?? 1,
2399
- mobile: options.mobile ?? false,
2400
- }, sessionId);
2401
- }
2402
- async setDevice(taskId, deviceName, tabHint) {
2403
- const { getDevice } = await import('./devices.js');
2404
- const device = getDevice(deviceName);
2405
- if (!device) {
2406
- const { listDevices } = await import('./devices.js');
2407
- throw new Error(`Unknown device "${deviceName}". Available: ${listDevices().join(', ')}`);
2408
- }
2409
- await this.setViewport(taskId, device.width, device.height, {
2410
- mobile: device.mobile,
2411
- deviceScaleFactor: device.deviceScaleFactor,
2412
- tabHint,
2413
- });
2414
- }
2415
- // ─── Console & Errors ────────────────────────────────────────────────────────
2416
- async enableRuntimeForSession(conn, sessionId) {
2417
- const key = `${sessionId}:Runtime`;
2418
- if (this.enabledSessions.get(sessionId)?.has('Runtime'))
2419
- return;
2420
- await conn.cdp.send('Runtime.enable', {}, sessionId);
2421
- if (!this.enabledSessions.has(sessionId)) {
2422
- this.enabledSessions.set(sessionId, new Set());
2423
- }
2424
- this.enabledSessions.get(sessionId).add('Runtime');
2425
- conn.cdp.on('Runtime.consoleAPICalled', (params) => {
2426
- if (params.sessionId !== sessionId)
2427
- return;
2428
- const taskId = this.findTaskBySession(conn, sessionId);
2429
- if (!taskId)
2430
- return;
2431
- const entry = {
2432
- level: params.type === 'warning' ? 'warn' : params.type,
2433
- text: params.args?.map((a) => a.value ?? a.description ?? '').join(' ') || '',
2434
- timestamp: Date.now(),
2435
- url: params.stackTrace?.callFrames?.[0]?.url,
2436
- line: params.stackTrace?.callFrames?.[0]?.lineNumber,
2437
- };
2438
- if (!this.consoleLogs.has(taskId))
2439
- this.consoleLogs.set(taskId, []);
2440
- const logs = this.consoleLogs.get(taskId);
2441
- logs.push(entry);
2442
- if (logs.length > 1000)
2443
- logs.shift();
2444
- });
2445
- conn.cdp.on('Runtime.exceptionThrown', (params) => {
2446
- if (params.sessionId !== sessionId)
2447
- return;
2448
- const taskId = this.findTaskBySession(conn, sessionId);
2449
- if (!taskId)
2450
- return;
2451
- const ex = params.exceptionDetails;
2452
- const entry = {
2453
- message: ex.exception?.description || ex.text || 'Unknown error',
2454
- stack: ex.stackTrace?.callFrames?.map((f) => ` at ${f.functionName || '<anonymous>'} (${f.url}:${f.lineNumber})`).join('\n'),
2455
- timestamp: Date.now(),
2456
- url: ex.url,
2457
- line: ex.lineNumber,
2458
- };
2459
- if (!this.pageErrors.has(taskId))
2460
- this.pageErrors.set(taskId, []);
2461
- const errors = this.pageErrors.get(taskId);
2462
- errors.push(entry);
2463
- if (errors.length > 500)
2464
- errors.shift();
2465
- });
2466
- }
2467
- async getConsoleLogs(taskId, options = {}) {
2468
- const { conn, task } = await this.findTask(taskId);
2469
- requireCdp(conn, 'consoleCapture');
2470
- const shortId = options.tabHint ? await this.resolveTabHint(conn, task, options.tabHint) : this.resolveCurrentTab(task);
2471
- const cdpTargetId = this.getCdpTargetId(task, shortId);
2472
- const target = await this.getTarget(conn, cdpTargetId);
2473
- if (!target)
2474
- throw new Error(`Tab ${shortId} not found`);
2475
- const sessionId = await this.getSessionId(conn, target.targetId);
2476
- await this.enableRuntimeForSession(conn, sessionId);
2477
- let logs = this.consoleLogs.get(taskId) || [];
2478
- if (options.level) {
2479
- logs = logs.filter((l) => l.level === options.level);
2480
- }
2481
- if (options.clear) {
2482
- this.consoleLogs.set(taskId, []);
2483
- }
2484
- return logs;
2485
- }
2486
- async getErrors(taskId, options = {}) {
2487
- const { conn, task } = await this.findTask(taskId);
2488
- requireCdp(conn, 'consoleCapture');
2489
- const shortId = options.tabHint ? await this.resolveTabHint(conn, task, options.tabHint) : this.resolveCurrentTab(task);
2490
- const cdpTargetId = this.getCdpTargetId(task, shortId);
2491
- const target = await this.getTarget(conn, cdpTargetId);
2492
- if (!target)
2493
- throw new Error(`Tab ${shortId} not found`);
2494
- const sessionId = await this.getSessionId(conn, target.targetId);
2495
- await this.enableRuntimeForSession(conn, sessionId);
2496
- const errors = this.pageErrors.get(taskId) || [];
2497
- if (options.clear) {
2498
- this.pageErrors.set(taskId, []);
2499
- }
2500
- return errors;
2501
- }
2502
- // ─── Network Requests ────────────────────────────────────────────────────────
2503
- async enableNetworkForSession(conn, sessionId, taskId) {
2504
- if (this.enabledSessions.get(sessionId)?.has('Network'))
2505
- return;
2506
- await conn.cdp.send('Network.enable', {}, sessionId);
2507
- if (!this.enabledSessions.has(sessionId)) {
2508
- this.enabledSessions.set(sessionId, new Set());
2509
- }
2510
- this.enabledSessions.get(sessionId).add('Network');
2511
- const requestMap = new Map();
2512
- conn.cdp.on('Network.requestWillBeSent', (params) => {
2513
- if (params.sessionId !== sessionId)
2514
- return;
2515
- const req = {
2516
- id: params.requestId,
2517
- url: params.request.url,
2518
- method: params.request.method,
2519
- timestamp: Date.now(),
2520
- };
2521
- requestMap.set(params.requestId, req);
2522
- if (!this.networkRequests.has(taskId))
2523
- this.networkRequests.set(taskId, []);
2524
- const reqs = this.networkRequests.get(taskId);
2525
- reqs.push(req);
2526
- if (reqs.length > 500)
2527
- reqs.shift();
2528
- });
2529
- conn.cdp.on('Network.responseReceived', (params) => {
2530
- if (params.sessionId !== sessionId)
2531
- return;
2532
- const req = requestMap.get(params.requestId);
2533
- if (req) {
2534
- req.status = params.response.status;
2535
- req.mimeType = params.response.mimeType;
2536
- }
2537
- });
2538
- }
2539
- async getNetworkRequests(taskId, options = {}) {
2540
- const { conn, task } = await this.findTask(taskId);
2541
- requireCdp(conn, 'networkCapture');
2542
- const shortId = options.tabHint ? await this.resolveTabHint(conn, task, options.tabHint) : this.resolveCurrentTab(task);
2543
- const cdpTargetId = this.getCdpTargetId(task, shortId);
2544
- const target = await this.getTarget(conn, cdpTargetId);
2545
- if (!target)
2546
- throw new Error(`Tab ${shortId} not found`);
2547
- const sessionId = await this.getSessionId(conn, target.targetId);
2548
- await this.enableNetworkForSession(conn, sessionId, taskId);
2549
- let requests = this.networkRequests.get(taskId) || [];
2550
- if (options.filter) {
2551
- const f = options.filter.toLowerCase();
2552
- requests = requests.filter((r) => r.url.toLowerCase().includes(f));
2553
- }
2554
- if (options.clear) {
2555
- this.networkRequests.set(taskId, []);
2556
- }
2557
- return requests;
2558
- }
2559
- async getResponseBody(taskId, urlPattern, options = {}) {
2560
- const { conn, task } = await this.findTask(taskId);
2561
- requireCdp(conn, 'networkCapture');
2562
- const shortId = options.tabHint ? await this.resolveTabHint(conn, task, options.tabHint) : this.resolveCurrentTab(task);
2563
- const cdpTargetId = this.getCdpTargetId(task, shortId);
2564
- const target = await this.getTarget(conn, cdpTargetId);
2565
- if (!target)
2566
- throw new Error(`Tab ${shortId} not found`);
2567
- const sessionId = await this.getSessionId(conn, target.targetId);
2568
- await this.enableNetworkForSession(conn, sessionId, taskId);
2569
- const timeout = options.timeout ?? 30000;
2570
- const maxChars = options.maxChars ?? 200000;
2571
- const start = Date.now();
2572
- const pattern = urlPattern.includes('*')
2573
- ? new RegExp(urlPattern.replace(/\*/g, '.*'), 'i')
2574
- : null;
2575
- while (Date.now() - start < timeout) {
2576
- const requests = this.networkRequests.get(taskId) || [];
2577
- const match = requests.find((r) => pattern ? pattern.test(r.url) : r.url.includes(urlPattern));
2578
- if (match && match.status) {
2579
- try {
2580
- const { body, base64Encoded } = (await conn.cdp.send('Network.getResponseBody', { requestId: match.id }, sessionId));
2581
- const text = base64Encoded ? Buffer.from(body, 'base64').toString('utf-8') : body;
2582
- return text.slice(0, maxChars);
2583
- }
2584
- catch {
2585
- // Request may have been evicted, continue waiting
2586
- }
2587
- }
2588
- await new Promise((r) => setTimeout(r, 200));
2589
- }
2590
- throw new Error(`No response matching "${urlPattern}" within ${timeout}ms`);
2591
- }
2592
- // ─── App Logs (source-side JSONL) ───────────────────────────────────────────
2593
- async getAppLogs(taskId, opts) {
2594
- const { task } = await this.findTask(taskId);
2595
- const baseProfileName = task.profile.split('@')[0];
2596
- const profile = await getProfile(baseProfileName);
2597
- if (!profile?.logDir) {
2598
- throw new Error(`Profile '${task.profile}' has no logDir set`);
2599
- }
2600
- const logDir = expandHome(profile.logDir);
2601
- const sources = opts.source ? [opts.source] : ['rush-app', 'rush-cli'];
2602
- const since = opts.since ? parseSinceUntil(opts.since) : null;
2603
- const until = opts.until ? parseSinceUntil(opts.until) : null;
2604
- const tailN = since ? 100_000 : (opts.lines ?? 200);
2605
- const raws = await Promise.all(sources.map(async (src) => {
2606
- const prefix = BrowserService.SOURCE_PREFIX[src];
2607
- if (!prefix)
2608
- return '';
2609
- if (profile.logHost) {
2610
- return execSSH(profile.logHost, readNewestMatchingRemoteFileCommand(logDir, prefix, tailN));
2611
- }
2612
- return readNewestMatchingFile(logDir, prefix, tailN);
2613
- }));
2614
- const entries = raws
2615
- .flatMap((r) => r.split('\n').filter(Boolean))
2616
- .map((line) => {
2617
- try {
2618
- return JSON.parse(line);
2619
- }
2620
- catch {
2621
- return { raw: line };
2622
- }
2623
- })
2624
- .filter((e) => !opts.level || e.level === opts.level)
2625
- .filter((e) => !opts.message || e.message === opts.message)
2626
- .filter((e) => !opts.filter || JSON.stringify(e).includes(opts.filter))
2627
- .filter((e) => !since || (e.timestamp && new Date(e.timestamp) >= since))
2628
- .filter((e) => !until || (e.timestamp && new Date(e.timestamp) <= until))
2629
- .sort((a, b) => new Date(a.timestamp ?? 0).getTime() - new Date(b.timestamp ?? 0).getTime());
2630
- return since ? entries : entries.slice(-(opts.lines ?? 200));
2631
- }
2632
- // ─── Wait Conditions ─────────────────────────────────────────────────────────
2633
- async wait(taskId, type, value, options = {}) {
2634
- const timeout = options.timeout ?? 30000;
2635
- const { conn, task } = await this.findTask(taskId);
2636
- requireCdp(conn, 'asyncEvaluate');
2637
- if (type === 'time') {
2638
- await new Promise((r) => setTimeout(r, typeof value === 'number' ? value : parseInt(value, 10)));
2639
- return;
2640
- }
2641
- const shortId = options.tabHint ? await this.resolveTabHint(conn, task, options.tabHint) : this.resolveCurrentTab(task);
2642
- const cdpTargetId = this.getCdpTargetId(task, shortId);
2643
- const target = await this.getTarget(conn, cdpTargetId);
2644
- if (!target)
2645
- throw new Error(`Tab ${shortId} not found`);
2646
- const sessionId = await this.getSessionId(conn, target.targetId);
2647
- const start = Date.now();
2648
- while (Date.now() - start < timeout) {
2649
- let condition = false;
2650
- if (type === 'selector') {
2651
- const result = (await conn.cdp.send('Runtime.evaluate', { expression: `!!document.querySelector(${JSON.stringify(value)})`, returnByValue: true }, sessionId));
2652
- condition = result.result.value === true;
2653
- }
2654
- else if (type === 'url') {
2655
- const result = (await conn.cdp.send('Runtime.evaluate', { expression: 'location.href', returnByValue: true }, sessionId));
2656
- const pattern = value.includes('*')
2657
- ? new RegExp(value.replace(/\*/g, '.*'), 'i')
2658
- : null;
2659
- condition = pattern ? pattern.test(result.result.value) : result.result.value.includes(value);
2660
- }
2661
- else if (type === 'function') {
2662
- const result = (await conn.cdp.send('Runtime.evaluate', { expression: `!!(${value})`, returnByValue: true }, sessionId));
2663
- condition = result.result.value === true;
2664
- }
2665
- else if (type === 'load') {
2666
- const result = (await conn.cdp.send('Runtime.evaluate', { expression: 'document.readyState', returnByValue: true }, sessionId));
2667
- if (value === 'domcontentloaded') {
2668
- condition = result.result.value !== 'loading';
2669
- }
2670
- else if (value === 'load' || value === 'complete') {
2671
- condition = result.result.value === 'complete';
2672
- }
2673
- else if (value === 'networkidle') {
2674
- // Simplified: check if document is complete
2675
- condition = result.result.value === 'complete';
2676
- }
2677
- }
2678
- if (condition)
2679
- return;
2680
- await new Promise((r) => setTimeout(r, 100));
2681
- }
2682
- throw new Error(`Wait condition "${type}:${value}" not met within ${timeout}ms`);
2683
- }
2684
- // ─── Downloads ───────────────────────────────────────────────────────────────
2685
- /**
2686
- * Point the browser's default download destination at the profile's downloads
2687
- * dir, browser-global, at connect time. Without this a download the agent never
2688
- * explicitly routed (`browser download --path`) falls to Chromium's own default
2689
- * — for an attached user browser, wherever that browser was last configured,
2690
- * which is how downloads used to escape into random locations. Sent on the root
2691
- * session (no sessionId) so every current and future tab inherits it. Best
2692
- * effort: a remote CDP endpoint that doesn't expose the Browser domain must not
2693
- * fail the whole connect.
2694
- */
2695
- async applyDefaultDownloadBehavior(conn, key) {
2696
- const downloadPath = getProfileDownloadsDir(key);
2697
- try {
2698
- await fs.promises.mkdir(downloadPath, { recursive: true });
2699
- await conn.cdp.send('Browser.setDownloadBehavior', {
2700
- behavior: 'allow',
2701
- downloadPath,
2702
- eventsEnabled: true,
2703
- });
2704
- }
2705
- catch {
2706
- // Best effort: a remote CDP endpoint (ssh://, ws(s)://) may not expose the
2707
- // Browser domain. Downloads then keep the endpoint's own default; the connect
2708
- // must still succeed.
2709
- }
2710
- }
2711
- async setDownloadPath(taskId, downloadPath, tabHint) {
2712
- const { conn, task, key: runtimeKey } = await this.findTask(taskId);
2713
- requireCdp(conn, 'download');
2714
- const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
2715
- const cdpTargetId = this.getCdpTargetId(task, shortId);
2716
- const target = await this.getTarget(conn, cdpTargetId);
2717
- if (!target)
2718
- throw new Error(`Tab ${shortId} not found`);
2719
- const sessionId = await this.getSessionId(conn, target.targetId);
2720
- // No explicit --path: fall back to the profile's downloads dir, the same
2721
- // destination already set browser-global at connect (applyDefaultDownloadBehavior).
2722
- const resolvedPath = downloadPath ?? getProfileDownloadsDir(runtimeKey);
2723
- await fs.promises.mkdir(resolvedPath, { recursive: true });
2724
- await conn.cdp.send('Browser.setDownloadBehavior', {
2725
- behavior: 'allow',
2726
- downloadPath: resolvedPath,
2727
- eventsEnabled: true,
2728
- }, sessionId);
2729
- this.pendingDownloads.set(taskId, { path: resolvedPath, completed: false });
2730
- conn.cdp.on('Browser.downloadProgress', (params) => {
2731
- if (params.state === 'completed') {
2732
- const dl = this.pendingDownloads.get(taskId);
2733
- if (dl) {
2734
- dl.completed = true;
2735
- dl.filename = params.suggestedFilename;
2736
- }
2737
- }
2738
- });
2739
- return resolvedPath;
2740
- }
2741
- async waitForDownload(taskId, timeout = 60000) {
2742
- const { conn } = await this.findTask(taskId);
2743
- requireCdp(conn, 'download');
2744
- const start = Date.now();
2745
- while (Date.now() - start < timeout) {
2746
- const dl = this.pendingDownloads.get(taskId);
2747
- if (dl?.completed) {
2748
- const fullPath = dl.filename ? `${dl.path}/${dl.filename}` : dl.path;
2749
- this.pendingDownloads.delete(taskId);
2750
- return fullPath;
2751
- }
2752
- await new Promise((r) => setTimeout(r, 200));
2753
- }
2754
- throw new Error(`Download not completed within ${timeout}ms`);
2755
- }
2756
- // ─── Helpers ─────────────────────────────────────────────────────────────────
2757
- findTaskBySession(conn, sessionId) {
2758
- for (const [taskId, task] of conn.tasks) {
2759
- for (const tabId of Object.values(task.tabs)) {
2760
- if (conn.sessionCache.get(tabId) === sessionId) {
2761
- return taskId;
2762
- }
2763
- }
2764
- }
2765
- return undefined;
2766
- }
2767
- async shutdown() {
2768
- // Drain any in-flight recordings first so we don't orphan ffmpeg processes
2769
- // or leak the duration/size-check timers when the daemon goes down.
2770
- for (const [taskId, rec] of this.recordings) {
2771
- clearTimeout(rec.durationTimer);
2772
- clearInterval(rec.sizeCheckInterval);
2773
- try {
2774
- rec.conn.cdp.off('Page.screencastFrame', rec.frameHandler);
2775
- }
2776
- catch { /* socket may be gone */ }
2777
- clearInterval(rec.framePump);
2778
- try {
2779
- rec.ffmpeg.stdin?.end();
2780
- }
2781
- catch { /* already closed */ }
2782
- // Give ffmpeg up to 1s to flush; then SIGKILL.
2783
- const exited = await new Promise((resolve) => {
2784
- let done = false;
2785
- rec.ffmpeg.once('exit', () => { done = true; resolve(true); });
2786
- setTimeout(() => { if (!done)
2787
- resolve(false); }, 1000);
2788
- });
2789
- if (!exited) {
2790
- try {
2791
- rec.ffmpeg.kill('SIGKILL');
2792
- }
2793
- catch { /* already dead */ }
2794
- }
2795
- this.recordings.delete(taskId);
2796
- }
2797
- for (const [, conn] of this.connections) {
2798
- if (conn.backend === 'bidi')
2799
- conn.bidi.close();
2800
- else if (conn.backend !== 'arc-native')
2801
- conn.cdp.close();
2802
- conn.cleanup?.();
2803
- }
2804
- this.connections.clear();
2805
- }
2806
- findAvailableFork(baseKey) {
2807
- for (const [name, conn] of this.connections) {
2808
- if (conn.forkedFrom === baseKey && conn.tasks.size === 0) {
2809
- return { name, conn };
2810
- }
2811
- }
2812
- return null;
2813
- }
2814
- async forkElectronProfile(profile, baseKey) {
2815
- let forkNum = 2;
2816
- while (this.connections.has(asConnectionKey(`${baseKey}.${forkNum}`))) {
2817
- forkNum++;
2818
- }
2819
- const forkName = asConnectionKey(`${baseKey}.${forkNum}`);
2820
- const port = allocatePort();
2821
- const chromeOpts = { ...profile.chrome, viewport: profile.viewport };
2822
- const { pid, wsUrl } = await launchBrowser(forkName, profile.browser, port, chromeOpts, profile.secrets, profile.binary, profile.electron === true);
2823
- const cdp = new CDPClient();
2824
- await cdp.connect(wsUrl);
2825
- await this.enableDomains(cdp);
2826
- const connection = {
2827
- backend: 'cdp',
2828
- cdp,
2829
- port,
2830
- pid,
2831
- electron: true,
2832
- browserType: profile.browser,
2833
- targetFilter: profile.targetFilter,
2834
- key: forkName,
2835
- profile: profile.name,
2836
- forkedFrom: baseKey,
2837
- tasks: new Map(),
2838
- sessionCache: new Map(),
2839
- };
2840
- this.connections.set(forkName, connection);
2841
- await this.applyDefaultDownloadBehavior(connection, forkName);
2842
- return { forkName, connection };
2843
- }
2844
- /**
2845
- * Connect to a profile at a specific endpoint preset. The caller has
2846
- * already resolved the endpoint and built the `effectiveProfile` with
2847
- * the per-endpoint binary/targetFilter overrides applied; we just use it.
2848
- *
2849
- * `key` is the runtime key (`<profile>@<device>`) and is what every on-disk
2850
- * lookup uses. It is a SEPARATE argument because `effectiveProfile.name`
2851
- * stays the bare, user-facing name (RUSH-2709).
2852
- */
2853
- async connectProfile(effectiveProfile, target, key, opts = {}) {
2854
- const existingInfo = getRunningChromeInfo(key);
2855
- const tunnelled = opts.persistRemote || target.startsWith('ssh:');
2856
- // Native Arc and Firefox BiDi speak their own transport, not CDP — never
2857
- // route them through the CDP reuse branch (which would probe /json/version
2858
- // and wipe the runtime meta the Firefox driver wrote). connectEndpoint owns
2859
- // their attach-or-launch decision.
2860
- const nativeTransport = target.startsWith('arc-native:') || target.startsWith('firefox-bidi:');
2861
- if (tunnelled) {
2862
- // A leftover local chrome-data / pid file under this key is the
2863
- // pre-T2 logged-out browser on THIS box. Attaching localhost CDP
2864
- // here is the original bug. Only a recorded SSH tunnel is ours to
2865
- // reuse, and connectSSH already does that via isOwnTunnel.
2866
- const meta = readProfileRuntimeMeta(key);
2867
- if (meta && meta.kind !== 'tunnel') {
2868
- clearProfileRuntime(key);
2869
- }
2870
- }
2871
- else if (existingInfo && !nativeTransport) {
2872
- try {
2873
- const { wsUrl, browser } = await discoverBrowserWsUrl(existingInfo.port, 'localhost', effectiveProfile.name);
2874
- verifyBrowserIdentity(browser, effectiveProfile.browser, existingInfo.port);
2875
- const cdp = new CDPClient();
2876
- await cdp.connect(wsUrl);
2877
- await this.enableDomains(cdp);
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);
2885
- return {
2886
- backend: 'cdp',
2887
- cdp,
2888
- port: existingInfo.port,
2889
- pid: existingInfo.pid,
2890
- electron: effectiveProfile.electron,
2891
- browserType: effectiveProfile.browser,
2892
- targetFilter: effectiveProfile.targetFilter,
2893
- tasks,
2894
- sessionCache: new Map(),
2895
- };
2896
- }
2897
- catch (err) {
2898
- // pid file says a process is alive on that port, but nothing is
2899
- // actually responding to CDP — most commonly because the user
2900
- // changed the configured endpoint port after a previous launch,
2901
- // or because the OS reused the pid for an unrelated process.
2902
- // Wipe the stale runtime files and fall through to a fresh
2903
- // connect against the profile's currently-configured endpoint.
2904
- clearProfileRuntime(key);
2905
- }
2906
- }
2907
- const conn = await this.connectEndpoint(effectiveProfile, target, key, opts);
2908
- if (!conn) {
2909
- throw new Error(`Could not connect to endpoint ${target} for profile "${effectiveProfile.name}"`);
2910
- }
2911
- return conn;
2912
- }
2913
- async connectEndpoint(profile, endpoint, key, opts = {}) {
2914
- const url = new URL(endpoint);
2915
- if (url.protocol === 'cdp:') {
2916
- const conn = await connectLocal(endpoint, profile, key);
2917
- await this.enableDomains(conn.cdp);
2918
- return {
2919
- backend: 'cdp',
2920
- cdp: conn.cdp,
2921
- port: conn.port,
2922
- pid: conn.pid,
2923
- electron: profile.electron,
2924
- browserType: profile.browser,
2925
- targetFilter: profile.targetFilter,
2926
- tasks: conn.pid === 0 ? this.loadTaskState(key) : new Map(),
2927
- sessionCache: new Map(),
2928
- };
2929
- }
2930
- if (url.protocol === 'ssh:') {
2931
- const conn = await connectSSH(endpoint, profile, key, {
2932
- persistRemote: opts.persistRemote,
2933
- });
2934
- await this.enableDomains(conn.cdp);
2935
- return {
2936
- backend: 'cdp',
2937
- cdp: conn.cdp,
2938
- port: conn.port,
2939
- pid: conn.pid,
2940
- electron: profile.electron,
2941
- browserType: profile.browser,
2942
- targetFilter: profile.targetFilter,
2943
- tasks: new Map(),
2944
- sessionCache: new Map(),
2945
- cleanup: conn.cleanup,
2946
- };
2947
- }
2948
- if (url.protocol === 'wss:' || url.protocol === 'ws:') {
2949
- const port = parseInt(url.port || (url.protocol === 'wss:' ? '443' : '80'), 10);
2950
- const cdp = new CDPClient();
2951
- try {
2952
- await cdp.connect(endpoint);
2953
- }
2954
- catch {
2955
- throw new BrowserCdpConnectionError(port, profile.name, url.hostname || 'localhost');
2956
- }
2957
- await this.enableDomains(cdp);
2958
- return {
2959
- backend: 'cdp',
2960
- cdp,
2961
- port: 0,
2962
- pid: 0,
2963
- electron: profile.electron,
2964
- browserType: profile.browser,
2965
- targetFilter: profile.targetFilter,
2966
- tasks: this.loadTaskState(key),
2967
- sessionCache: new Map(),
2968
- };
2969
- }
2970
- if (url.protocol === 'http:' || url.protocol === 'https:') {
2971
- const port = parseInt(url.port || (url.protocol === 'https:' ? '443' : '80'), 10);
2972
- const { wsUrl, browser } = await discoverBrowserWsUrl(port, url.hostname, profile.name);
2973
- verifyBrowserIdentity(browser, profile.browser, port, url.hostname);
2974
- const cdp = new CDPClient();
2975
- await cdp.connect(wsUrl);
2976
- await this.enableDomains(cdp);
2977
- return {
2978
- backend: 'cdp',
2979
- cdp,
2980
- port,
2981
- pid: 0,
2982
- electron: profile.electron,
2983
- browserType: profile.browser,
2984
- targetFilter: profile.targetFilter,
2985
- tasks: this.loadTaskState(key),
2986
- sessionCache: new Map(),
2987
- };
2988
- }
2989
- // Firefox BiDi backend (PHNX-4043): `firefox-bidi://127.0.0.1:<port>` connects
2990
- // over a WebDriver BiDi WebSocket. The driver attaches to a Firefox already
2991
- // serving the port, else launches one headless bound to the pinned profile
2992
- // directory, else fails loud (Firefox is single-instance per profile).
2993
- if (url.protocol === 'firefox-bidi:') {
2994
- if (!profile.firefox) {
2995
- throw new Error(`Firefox profile "${profile.name}" has no discovered profile metadata. ` +
2996
- `Re-select it with: agents browser use ${profile.name}`);
2997
- }
2998
- const profileDir = profile.userDataDir;
2999
- if (!profileDir) {
3000
- throw new Error(`Firefox profile "${profile.name}" has no profile directory.`);
3001
- }
3002
- const port = parseInt(url.port || '9600', 10);
3003
- const conn = await connectFirefox(profile, key, port, {
3004
- profileDir,
3005
- headless: profile.chrome?.headless,
3006
- });
3007
- return {
3008
- backend: 'bidi',
3009
- browserType: 'firefox',
3010
- bidi: conn.bidi,
3011
- firefoxProfile: profile.firefox,
3012
- sessionId: conn.sessionId,
3013
- profileDir,
3014
- port: conn.port,
3015
- pid: conn.pid,
3016
- electron: false,
3017
- targetFilter: profile.targetFilter,
3018
- tasks: this.loadTaskState(key),
3019
- sessionCache: new Map(),
3020
- };
3021
- }
3022
- // Native Arc backend (PHNX-2399): `arc-native:` protocol connects through
3023
- // Apple Events instead of CDP. No debugging port, no browser launch.
3024
- if (url.protocol === 'arc-native:') {
3025
- if (process.platform !== 'darwin') {
3026
- throw new Error('Native Arc automation is only available on macOS');
3027
- }
3028
- if (!(await isArcRunning())) {
3029
- throw new Error('Arc is not running. Start Arc normally, then retry.\n' +
3030
- 'Native Arc automation drives your existing browser — it does not launch one.');
3031
- }
3032
- if (!profile.arc) {
3033
- throw new Error(`Native Arc profile "${profile.name}" has no stable native profile metadata. ` +
3034
- `Re-select it with: agents browser use arc-<profile-id>`);
3035
- }
3036
- return {
3037
- port: 0,
3038
- pid: 0,
3039
- electron: false,
3040
- browserType: 'arc',
3041
- targetFilter: profile.targetFilter,
3042
- tasks: this.loadTaskState(key),
3043
- sessionCache: new Map(),
3044
- backend: 'arc-native',
3045
- arcProfile: profile.arc,
3046
- };
3047
- }
3048
- return null;
3049
- }
3050
- async enableDomains(cdp) {
3051
- await cdp.send('Target.setDiscoverTargets', { discover: true });
3052
- }
3053
- /**
3054
- * The single path that opens a new CDP page/window target. It exists so the
3055
- * Arc guard lives in exactly one place: `Target.createTarget` crashes Arc
3056
- * (verified, PR #2778), so an Arc connection throws a clear, actionable error
3057
- * here instead of every call site re-checking. Every drivable Chromium-family
3058
- * browser goes straight through.
3059
- */
3060
- async createPageTarget(conn, params) {
3061
- if (conn.browserType === 'arc') {
3062
- throw arcNotDrivableError(conn.profile);
3063
- }
3064
- // Firefox opens tabs through the BiDi driver, never Target.createTarget.
3065
- requireCdp(conn, 'createTab');
3066
- return (await conn.cdp.send('Target.createTarget', params));
3067
- }
3068
- async getOrCreateWindow(conn) {
3069
- // Already have a window for this profile?
3070
- if (conn.windowId) {
3071
- // Verify it still exists via CDP
3072
- const { targetInfos } = (await conn.cdp.send('Target.getTargets'));
3073
- if (targetInfos.some((t) => t.targetId === conn.windowId && t.type === 'page')) {
3074
- return conn.windowId;
3075
- }
3076
- // Window was closed, fall through to find/create new
3077
- }
3078
- // Check if browser already has a page target we can use
3079
- const { targetInfos } = (await conn.cdp.send('Target.getTargets'));
3080
- const existing = pickWindowTarget(targetInfos, conn.targetFilter);
3081
- if (existing) {
3082
- conn.windowId = existing.targetId;
3083
- return existing.targetId;
3084
- }
3085
- // If we have an explicit filter, `pickWindowTarget` returns undefined when nothing
3086
- // matches. That almost always means the profile is misconfigured (typo in the
3087
- // filter, target hasn't loaded yet, app version moved the URL). Falling through
3088
- // to `Target.createTarget` would silently create an orphan tab the user can't see.
3089
- // Surface the failure instead, with the candidate list so the fix is obvious.
3090
- if (parseTargetFilter(conn.targetFilter)) {
3091
- const candidates = targetInfos
3092
- .filter((t) => t.type === 'page')
3093
- .map((t) => ` - url=${t.url ?? ''} title=${t.title ?? ''}`)
3094
- .join('\n');
3095
- throw new Error(`Target filter ${JSON.stringify(conn.targetFilter)} matched no page target.\n` +
3096
- `Available page targets:\n${candidates || ' (none)'}`);
3097
- }
3098
- // First ever use - create window
3099
- const result = await this.createPageTarget(conn, {
3100
- url: 'about:blank',
3101
- newWindow: true,
3102
- });
3103
- conn.windowId = result.targetId;
3104
- return result.targetId;
3105
- }
3106
- hasTaskNamed(name) {
3107
- for (const conn of this.connections.values()) {
3108
- if (this.lookupTaskOnConn(conn, name))
3109
- return true;
3110
- }
3111
- return false;
3112
- }
3113
- /** The (connection, task) a handle names anywhere, or undefined. */
3114
- findTaskByHandle(handle) {
3115
- for (const conn of this.connections.values()) {
3116
- const task = this.lookupTaskOnConn(conn, handle);
3117
- if (task)
3118
- return { conn, task };
3119
- }
3120
- return undefined;
3121
- }
3122
- /**
3123
- * Same-name `start --task <name>` retry (PHNX-2399). When a task by that name
3124
- * already exists this REUSES it — reopening the URL in the same tab (same id,
3125
- * a real reload, the `Tab already open—refreshed` note) — but only after
3126
- * proving it is genuinely the same task: same bare profile, same endpoint, and
3127
- * a matching caller identity. A mismatch on any of those is a real conflict and
3128
- * throws, so a different caller/profile/endpoint can never silently acquire
3129
- * another task. Serialized by the `namedstart:` lock in {@link start}.
3130
- */
3131
- async retryNamedStart(existing, profileName, routed, opts) {
3132
- const { conn, task } = existing;
3133
- const existingKey = conn.key ?? task.profile;
3134
- // Profile + endpoint must match what this retry asked for.
3135
- if (!keyBelongsToProfile(existingKey, profileName)) {
3136
- throw new Error(actionable(`Task "${opts.taskName}" already exists on profile "${parseConnectionKey(existingKey).profile}", not "${profileName}".`, `Next: stop it first (agents browser done --task ${task.name}) or pick a different --task name.`));
3137
- }
3138
- const wantEndpoint = parseConnectionKey(routed.key).endpoint;
3139
- const haveEndpoint = parseConnectionKey(existingKey).endpoint;
3140
- if (opts.endpointName && wantEndpoint !== haveEndpoint) {
3141
- throw new Error(actionable(`Task "${opts.taskName}" already exists on endpoint "${haveEndpoint}", not "${wantEndpoint}".`, `Next: stop it first (agents browser done --task ${task.name}) or pick a different --task name.`));
3142
- }
3143
- // Caller identity must match. Both identity-less (a human shell) is fine;
3144
- // otherwise the SAME session/launch must own it — never another caller's.
3145
- const caller = { sessionId: opts.sessionId, launchId: opts.launchId };
3146
- const bothAnon = !task.sessionId && !task.launchId && !caller.sessionId && !caller.launchId;
3147
- if (!bothAnon && !taskMatchesCaller(task, caller)) {
3148
- throw new Error(actionable(`Task "${opts.taskName}" already exists and belongs to a different caller.`, `Next: stop it first (agents browser done --task ${task.name}) or pick a different --task name.`));
3149
- }
3150
- // Genuine same-task retry. When a URL is given, run the real navigate — which
3151
- // reopens the same owned tab (refreshed) OR reuses the current tab for a
3152
- // different URL (created:false, refreshed:false) — and carry its ACTUAL
3153
- // result. With NO URL there is NO page operation: no reload, no counter
3154
- // change; it is a pure task reuse (created/refreshed both false).
3155
- let firstOpen;
3156
- if (opts.url) {
3157
- const nav = await this.navigate(task.name, opts.url, existingKey);
3158
- firstOpen = { tabId: nav.tabId, created: nav.created, refreshed: nav.refreshed, message: nav.message };
3159
- }
3160
- else {
3161
- firstOpen = { tabId: task.currentTabId, created: false, refreshed: false };
3162
- }
3163
- let skill;
3164
- if (opts.url && !opts.skipDomainSkill) {
3165
- const resolved = resolveDomainSkill(opts.url);
3166
- if (resolved)
3167
- skill = resolved;
3168
- }
3169
- return {
3170
- task: task.id,
3171
- name: task.name,
3172
- tabId: firstOpen.tabId,
3173
- profile: profileName,
3174
- key: existingKey,
3175
- device: routed.device,
3176
- picked: routed.picked,
3177
- skill,
3178
- reused: true,
3179
- firstOpen,
3180
- };
3181
- }
3182
- /** Map key for a task on a connection (tasks are keyed by `name`). */
3183
- taskMapKey(conn, task) {
3184
- for (const [key, t] of conn.tasks) {
3185
- if (t === task || t.id === task.id || t.name === task.name)
3186
- return key;
3187
- }
3188
- return undefined;
3189
- }
3190
- /** Find a task by map key, id, or name on one connection. */
3191
- lookupTaskOnConn(conn, handle) {
3192
- const direct = conn.tasks.get(handle);
3193
- if (direct)
3194
- return direct;
3195
- for (const task of conn.tasks.values()) {
3196
- if (task.id === handle || task.name === handle)
3197
- return task;
3198
- }
3199
- return undefined;
3200
- }
3201
- maybeUpdateLabelFromUrl(conn, task, url) {
3202
- if (task.label && task.label !== 'untitled')
3203
- return;
3204
- const next = deriveTaskLabel({ url, existing: task.label });
3205
- if (next === task.label)
3206
- return;
3207
- task.label = next;
3208
- void this.saveTaskState(task.profile, conn.tasks).catch(() => { });
3209
- }
3210
- /**
3211
- * Resolve a task for a page/close verb.
3212
- *
3213
- * - explicit `task` handle → look up (with disk rehydrate on RAM miss)
3214
- * - no handle, exactly one live task for this caller → use it
3215
- * - no handle, multiple for this caller → error listing them + `--task`
3216
- * - no handle, zero for this caller, createIfMissing → start a new task
3217
- * - no handle, zero, !createIfMissing → return null (caller reports "nothing to close")
3218
- */
3219
- async resolveOrCreateTask(opts) {
3220
- // Consent gate — the one chokepoint every drive/mutate verb passes through.
3221
- // ipc.ts routes each PAGE_RESOLVE_VERB and CLOSE_VERB here via bindTask, so
3222
- // gating at the top refuses a fleet-remote ATTACH (an explicit --task, or a
3223
- // lone identity match) exactly as it already refused an implicit CREATE.
3224
- // Gating only the create branch below left tab-add/navigate/done on an
3225
- // already-running browser ungated, so a remote caller could drive the
3226
- // owner's authenticated profile with consent off (RUSH-3064). Read the
3227
- // per-request marker, never the daemon's env — see remote-control.ts.
3228
- assertRemoteControlAllowedForRequest(opts.fleetRemote, { actor: opts.actor });
3229
- if (opts.task) {
3230
- const found = await this.findTask(opts.task, opts.profile);
3231
- return { ...found, created: false };
3232
- }
3233
- // Serialize identity resolution + implicit creation per caller so two
3234
- // concurrent first-use requests (e.g. two `navigate <url>` with no --task)
3235
- // don't both see zero matches and both start a task. The `create:` namespace
3236
- // is disjoint from the `task:` locks `navigate`/`tabAdd` take, so the nested
3237
- // `start → navigate` (Arc/Electron) can never wait on a lock this frame holds
3238
- // (PHNX-2399). Distinct profiles create distinct tasks, so profile is in the
3239
- // key; identity-less human shells share one `anon` lane.
3240
- const createKey = `create:${opts.sessionId ?? opts.launchId ?? 'anon'}:${opts.profile ?? 'default'}`;
3241
- return this.runExclusive(createKey, () => this.resolveOrCreateByIdentity(opts));
3242
- }
3243
- async resolveOrCreateByIdentity(opts) {
3244
- // After a daemon restart the connections map is empty while tasks.json still
3245
- // holds live tasks. Rehydrate before identity matching so done/screenshot
3246
- // without --task still find the caller's task.
3247
- await this.rehydrateAllFromDisk();
3248
- const caller = {
3249
- sessionId: opts.sessionId,
3250
- launchId: opts.launchId,
3251
- };
3252
- const matches = this.listTasksForCaller(caller);
3253
- if (matches.length === 1) {
3254
- const m = matches[0];
3255
- await this.prepareTask(m.conn, m.task);
3256
- await this.touchTask(m.conn, m.task);
3257
- return { conn: m.conn, task: m.task, key: m.key, created: false };
3258
- }
3259
- if (matches.length > 1) {
3260
- const lines = matches.map((m) => {
3261
- const ageMs = Date.now() - m.task.createdAt;
3262
- const age = ageMs < 60_000
3263
- ? `${Math.round(ageMs / 1000)}s`
3264
- : `${Math.round(ageMs / 60_000)}m`;
3265
- const label = m.task.label ?? m.task.name;
3266
- const url = this.currentUrlHint(m.conn, m.task) ?? '-';
3267
- return ` ${label} id=${m.task.name} url=${url} age=${age}`;
3268
- });
3269
- throw new Error(actionable(`Multiple browser tasks for this session — pass --task <name>:`, ...lines, `Next: agents browser status`));
3270
- }
3271
- // Zero matches for this caller.
3272
- if (!opts.createIfMissing)
3273
- return null;
3274
- // The top-of-function consent gate already refused a fleet-remote create
3275
- // here — before ensureDefaultBrowserProfile() below resolves a default — so
3276
- // a refused request never touches the target machine. Since PHNX-3296 that
3277
- // resolver never mints a profile: with no launchable default it throws, and
3278
- // the throw surfaces to the caller exactly like any other start failure.
3279
- // Implicit start on the default / named profile.
3280
- let profileName = opts.profile;
3281
- if (!profileName) {
3282
- const { ensureDefaultBrowserProfile } = await import('./profiles.js');
3283
- const detected = await ensureDefaultBrowserProfile();
3284
- profileName = detected.name;
3285
- }
3286
- const started = await this.start(profileName, {
3287
- fleetRemote: opts.fleetRemote,
3288
- url: opts.url,
3289
- actor: opts.actor,
3290
- launchId: opts.launchId,
3291
- sessionId: opts.sessionId,
3292
- title: opts.title,
3293
- });
3294
- return {
3295
- ...(await this.findTask(started.name, started.profile)),
3296
- created: true,
3297
- picked: started.picked,
3298
- device: started.device,
3299
- // `start` already opened opts.url on this tab; the handler must not open
3300
- // it a second time (a duplicate tab for tab-add; a redundant reload for
3301
- // navigate). Carry the ACTUAL page result so created/refreshed are truthful.
3302
- firstOpen: started.firstOpen,
3303
- };
3304
- }
3305
- listTasksForCaller(caller) {
3306
- const out = [];
3307
- const hasIdentity = !!(caller.sessionId || caller.launchId);
3308
- for (const [key, conn] of this.connections) {
3309
- for (const task of conn.tasks.values()) {
3310
- if (hasIdentity) {
3311
- if (taskMatchesCaller(task, caller)) {
3312
- out.push({ conn, task, key: conn.key ?? key });
3313
- }
3314
- }
3315
- else {
3316
- // No caller identity: only match tasks that also lack identity
3317
- // (a human-driven shell). If there is exactly one live task total,
3318
- // the single-task path still needs that listed — fall through below.
3319
- if (!task.sessionId && !task.launchId) {
3320
- out.push({ conn, task, key: conn.key ?? key });
3321
- }
3322
- }
3323
- }
3324
- }
3325
- // No-identity caller with zero unscoped matches: if the daemon holds
3326
- // exactly one live task that ALSO has no identity (a human-started one),
3327
- // use it. Never steal a task stamped with another caller's session/launch.
3328
- if (!hasIdentity && out.length === 0) {
3329
- const unowned = this.listTasks().filter((t) => !t.task.sessionId && !t.task.launchId);
3330
- if (unowned.length === 1) {
3331
- const only = unowned[0];
3332
- const conn = this.connections.get(only.profile) ??
3333
- [...this.connections.entries()].find(([, c]) => c.tasks.has(only.task.name) || [...c.tasks.values()].includes(only.task))?.[1];
3334
- if (conn) {
3335
- out.push({ conn, task: only.task, key: only.profile });
3336
- }
3337
- }
3338
- }
3339
- return out;
3340
- }
3341
- currentUrlHint(conn, task) {
3342
- const shortId = task.currentTabId;
3343
- if (!shortId)
3344
- return undefined;
3345
- const cdpId = task.tabs[shortId];
3346
- if (!cdpId)
3347
- return undefined;
3348
- const cached = conn.targetCache?.targets.find((t) => t.targetId === cdpId);
3349
- return cached?.url;
3350
- }
3351
- /**
3352
- * Mark a task as active, so the reaper's idle window measures time since the
3353
- * last real use rather than time since `start` (RUSH-2622).
3354
- *
3355
- * Called from `findTask` — the single funnel every task-scoped operation
3356
- * resolves through — rather than from each of the ~26 call sites, so a new
3357
- * action added later cannot forget to stamp it.
3358
- *
3359
- * The in-memory stamp is what the reaper reads: it runs inside the same
3360
- * daemon that owns these `Task` objects. The write to tasks.json exists only
3361
- * so a daemon RESTART does not inherit a stale stamp and reap a task an agent
3362
- * has been clicking through for the last half hour — `click`, `type`,
3363
- * `evaluate`, and `screenshot` never call `saveTaskState` on their own, so
3364
- * without this the on-disk stamp would sit at the last navigation. It is
3365
- * coalesced to at most one write per profile per minute so a screenshot loop
3366
- * does not become a write loop; the resulting on-disk stamp trails by at most
3367
- * a minute, well inside the 30-minute idle window.
3368
- */
3369
- async touchTask(conn, task) {
3370
- const now = Date.now();
3371
- task.lastActionAt = now;
3372
- const last = this.lastTouchPersist.get(task.profile) ?? 0;
3373
- if (now - last < BrowserService.TOUCH_PERSIST_INTERVAL_MS)
3374
- return;
3375
- this.lastTouchPersist.set(task.profile, now);
3376
- try {
3377
- await this.saveTaskState(task.profile, conn.tasks);
3378
- }
3379
- catch {
3380
- // Durability here is best-effort — the in-memory stamp above is
3381
- // authoritative for the running daemon — and an unwritable runtime dir
3382
- // must not turn a working browser action into a failure. Same guard the
3383
- // `recordBrowserSession` call in `start` uses for the same reason.
3384
- }
3385
- }
3386
- async prepareTask(conn, task) {
3387
- if (conn.backend !== 'arc-native')
3388
- return;
3389
- await this.runExclusive('arc-native:host-app', async () => {
3390
- await this.reconcileArcCreateIntents(conn, task);
3391
- const native = this.requireArcTask(task);
3392
- if (native.profileId !== conn.arcProfile.profileId) {
3393
- throw new Error(`Arc task profile ${JSON.stringify(native.profileId)} does not match connected profile ` +
3394
- `${JSON.stringify(conn.arcProfile.profileId)}.`);
3395
- }
3396
- });
3397
- }
3398
- async findTask(taskId, profileRef) {
3399
- if (profileRef) {
3400
- // Accept bare profile names against composite connection keys.
3401
- // One rule: an exact runtime key, else every key belonging to that bare
3402
- // profile. Callers pass whichever they hold — the composite is theirs to
3403
- // ignore (RUSH-2709).
3404
- const keys = [...this.connections.keys()].filter((k) => k === profileRef || keyBelongsToProfile(k, profileRef));
3405
- for (const key of keys) {
3406
- const conn = this.connections.get(key);
3407
- const task = this.lookupTaskOnConn(conn, taskId);
3408
- if (task) {
3409
- await this.prepareTask(conn, task);
3410
- await this.touchTask(conn, task);
3411
- return { conn, task, key: conn.key ?? key };
3412
- }
3413
- }
3414
- // RAM miss → rehydrate from disk for this profile.
3415
- const rehydrated = await this.rehydrateTaskFromDisk(taskId, profileRef);
3416
- if (rehydrated) {
3417
- await this.prepareTask(rehydrated.conn, rehydrated.task);
3418
- await this.touchTask(rehydrated.conn, rehydrated.task);
3419
- return rehydrated;
3420
- }
3421
- throw new Error(actionable(`Task "${taskId}" not found on profile "${profileRef}".`, `Next: agents browser status --profile ${parseConnectionKey(profileRef).profile}`));
3422
- }
3423
- for (const [key, conn] of this.connections) {
3424
- const task = this.lookupTaskOnConn(conn, taskId);
3425
- if (task) {
3426
- await this.prepareTask(conn, task);
3427
- await this.touchTask(conn, task);
3428
- return { conn, task, key: conn.key ?? key };
3429
- }
3430
- }
3431
- // RAM miss after a daemon restart: tasks.json still has the task and
3432
- // meta.json still has the browser pid/port. Reconnect and adopt.
3433
- const rehydrated = await this.rehydrateTaskFromDisk(taskId);
3434
- if (rehydrated) {
3435
- await this.prepareTask(rehydrated.conn, rehydrated.task);
3436
- await this.touchTask(rehydrated.conn, rehydrated.task);
3437
- return rehydrated;
3438
- }
3439
- const live = this.listTasks()
3440
- .map(({ task }) => task.label ?? task.name)
3441
- .join(', ');
3442
- throw new Error(actionable(`Task "${taskId}" not found.`, `Active tasks: ${live || 'none'}`, `It may have been closed by the idle reaper (30 min).`, `Next: agents browser status | agents browser navigate <url>`));
3443
- }
3444
- /**
3445
- * Soft-attach to an already-running browser for a runtime dir.
3446
- *
3447
- * Never launches a browser and never clears pid/port files on failure —
3448
- * `connectProfile` does both, which made `status()` wipe disk state when
3449
- * CDP was unreachable (CI / post-reaper) and then return an empty list.
3450
- * Returns null when nothing is speaking CDP so callers can fall through
3451
- * to disk reconcile.
3452
- */
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) {
3460
- const { profile: bare, endpoint: endpointFromKey } = parseConnectionKey(key);
3461
- const profile = await getProfile(bare);
3462
- if (!profile)
3463
- return null;
3464
- let resolved;
3465
- try {
3466
- // Legacy keys encode the preset (`endpoint-0`). New keys encode the
3467
- // declaring device, which is not a preset name — resolve via registry.
3468
- if (endpointFromKey && /^endpoint-\d+$/.test(endpointFromKey)) {
3469
- resolved = resolveEndpoint(profile, endpointFromKey);
3470
- }
3471
- else {
3472
- const routed = resolveBrowserTarget(bare);
3473
- resolved = { name: routed.device, target: routed.target };
3474
- }
3475
- }
3476
- catch {
3477
- return null;
3478
- }
3479
- // Native Arc endpoints (PHNX-2399): reconnect via the native driver if Arc
3480
- // is still running. No port, no CDP — just verify Arc is alive and restore
3481
- // the task state.
3482
- if (resolved.target.startsWith('arc-native:')) {
3483
- if (process.platform !== 'darwin' || !(await (arcRunning ?? isArcRunning)()))
3484
- return null;
3485
- const tasks = this.loadTaskState(key);
3486
- for (const [k, t] of diskTasks) {
3487
- if (!tasks.has(k))
3488
- tasks.set(k, t);
3489
- }
3490
- if (!profile.arc)
3491
- return null;
3492
- return {
3493
- port: 0,
3494
- pid: 0,
3495
- electron: false,
3496
- browserType: 'arc',
3497
- targetFilter: profile.targetFilter,
3498
- key,
3499
- profile: bare,
3500
- tasks,
3501
- sessionCache: new Map(),
3502
- backend: 'arc-native',
3503
- arcProfile: profile.arc,
3504
- };
3505
- }
3506
- // Firefox BiDi endpoints (PHNX-4043): a daemon restart dropped the BiDi
3507
- // socket, but the Firefox we launched is still serving the port — reattach
3508
- // by opening a fresh session, then merge the disk tasks. connectFirefox
3509
- // attaches (never launches) when the port is already served. A failure
3510
- // returns null like the CDP path, so the caller falls through to disk
3511
- // reconcile instead of crashing.
3512
- if (resolved.target.startsWith('firefox-bidi:')) {
3513
- if (!profile.firefox || !profile.userDataDir)
3514
- return null;
3515
- const port = parseEndpointUrl(resolved.target)?.port ?? 9600;
3516
- try {
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);
3522
- const tasks = this.loadTaskState(key);
3523
- for (const [k, t] of diskTasks) {
3524
- if (!tasks.has(k))
3525
- tasks.set(k, t);
3526
- }
3527
- return {
3528
- backend: 'bidi',
3529
- browserType: 'firefox',
3530
- bidi: ff.bidi,
3531
- firefoxProfile: profile.firefox,
3532
- sessionId: ff.sessionId,
3533
- profileDir: profile.userDataDir,
3534
- port: ff.port,
3535
- pid: ff.pid,
3536
- electron: false,
3537
- targetFilter: profile.targetFilter,
3538
- key,
3539
- profile: bare,
3540
- tasks,
3541
- sessionCache: new Map(),
3542
- };
3543
- }
3544
- catch {
3545
- return null;
3546
- }
3547
- }
3548
- const existingInfo = getRunningChromeInfo(key);
3549
- const parsed = parseEndpointUrl(resolved.target);
3550
- const port = existingInfo?.port ?? parsed?.port;
3551
- if (port === undefined)
3552
- return null;
3553
- const host = parsed?.host && parsed.host !== 'localhost' ? parsed.host : 'localhost';
3554
- // ssh:// endpoints: a daemon restart killed this box's tunnel, but the
3555
- // browser is still running on the FAR side — so the caller's tasks are
3556
- // valid, only the local hop is gone. Re-establish the tunnel and reconnect
3557
- // CDP (connectSSH attaches to the already-running remote browser via
3558
- // isOwnTunnel — it never launches one, so this stays a soft-attach), then
3559
- // merge the disk tasks. Without this, a remote agent driving a browser host
3560
- // after a restart got "Unknown browser task" for a tab that was still alive
3561
- // (PHNX-2663). A failure returns null exactly like the local-CDP path, so
3562
- // the caller falls through to disk reconcile rather than crashing.
3563
- if (resolved.target.startsWith('ssh:')) {
3564
- try {
3565
- const conn = await connectSSH(resolved.target, profile, key, { persistRemote: true });
3566
- await this.enableDomains(conn.cdp);
3567
- const tasks = this.loadTaskState(key);
3568
- for (const [k, t] of diskTasks) {
3569
- if (!tasks.has(k))
3570
- tasks.set(k, t);
3571
- }
3572
- return {
3573
- backend: 'cdp',
3574
- cdp: conn.cdp,
3575
- port: conn.port,
3576
- pid: conn.pid,
3577
- electron: profile.electron,
3578
- browserType: profile.browser,
3579
- targetFilter: resolved.targetFilter ?? profile.targetFilter,
3580
- key,
3581
- profile: bare,
3582
- tasks,
3583
- sessionCache: new Map(),
3584
- // Carry the tunnel teardown so removing this connection kills the
3585
- // ssh hop — otherwise it leaks across the next restart (see the
3586
- // `cleanup` docblock on ProfileConnection).
3587
- cleanup: conn.cleanup,
3588
- };
3589
- }
3590
- catch {
3591
- return null;
3592
- }
3593
- }
3594
- try {
3595
- const { wsUrl, browser } = await discoverBrowserWsUrl(port, host, bare);
3596
- verifyBrowserIdentity(browser, profile.browser, port, host);
3597
- const cdp = new CDPClient();
3598
- await cdp.connect(wsUrl);
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);
3608
- const tasks = this.loadTaskState(key);
3609
- for (const [k, t] of diskTasks) {
3610
- if (!tasks.has(k))
3611
- tasks.set(k, t);
3612
- }
3613
- const conn = {
3614
- backend: 'cdp',
3615
- cdp,
3616
- port,
3617
- pid: existingInfo?.pid ?? 0,
3618
- electron: profile.electron,
3619
- browserType: profile.browser,
3620
- targetFilter: resolved.targetFilter ?? profile.targetFilter,
3621
- key,
3622
- profile: bare,
3623
- tasks,
3624
- sessionCache: new Map(),
3625
- };
3626
- return conn;
3627
- }
3628
- catch {
3629
- return null;
3630
- }
3631
- }
3632
- /**
3633
- * Reconnect every profile runtime dir that still has a live browser and
3634
- * tasks.json entries. Used before identity-based resolution so a daemon
3635
- * restart does not make the caller's tasks invisible.
3636
- */
3637
- /**
3638
- * Register a freshly-rehydrated connection, unless a concurrent rehydrate won
3639
- * the race and already registered one for this key. `attachRunningProfile`
3640
- * awaits an ssh-tunnel spawn / CDP connect, so two commands landing right
3641
- * after a daemon restart can each build a connection for the same key; the
3642
- * loser must be released instead of silently overwriting the winner (whose
3643
- * `cleanup` would then never run).
3644
- *
3645
- * The subtlety is the SSH tunnel: when the loser's `connectSSH` landed after
3646
- * the winner's tunnel had already bound the local port, `isOwnTunnel` makes it
3647
- * **reuse** that same OS process (`drivers/ssh.ts`) — so both connections carry
3648
- * the same `pid`/`port`. Calling the loser's `cleanup()` then does
3649
- * `tunnel.kill()` on the SHARED process and breaks the winner's live CDP. So
3650
- * when the loser shares the winner's tunnel we close ONLY the loser's own CDP
3651
- * socket and leave the tunnel to the winner; only a loser that spawned its own
3652
- * distinct tunnel gets the full `cleanup()` (else it would leak). Returns the
3653
- * winning connection to use.
3654
- */
3655
- registerRehydratedConnection(key, conn) {
3656
- const existing = this.connections.get(key);
3657
- if (!existing) {
3658
- this.connections.set(key, conn);
3659
- return conn;
3660
- }
3661
- const sharesTunnel = conn.pid !== 0 && conn.pid === existing.pid && conn.port === existing.port;
3662
- if (conn.pid !== 0 && !sharesTunnel && conn.cleanup) {
3663
- // Distinct tunnel — full teardown so this loser's tunnel doesn't leak.
3664
- try {
3665
- conn.cleanup();
3666
- }
3667
- catch { /* best effort — the winner stays live */ }
3668
- }
3669
- else {
3670
- // Shared/borrowed tunnel (or a local connection with no tunnel): close
3671
- // only our own client; killing the tunnel would break the winner.
3672
- if (conn.backend === 'bidi') {
3673
- try {
3674
- conn.bidi.close();
3675
- }
3676
- catch { /* best effort */ }
3677
- }
3678
- else if (conn.backend !== 'arc-native') {
3679
- try {
3680
- conn.cdp.close();
3681
- }
3682
- catch { /* best effort */ }
3683
- }
3684
- }
3685
- return existing;
3686
- }
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) {
3695
- const runtimeRoot = getBrowserRuntimeDir();
3696
- let dirNames = [];
3697
- try {
3698
- dirNames = fs.readdirSync(runtimeRoot).filter((d) => {
3699
- try {
3700
- return fs.statSync(path.join(runtimeRoot, d)).isDirectory();
3701
- }
3702
- catch {
3703
- return false;
3704
- }
3705
- });
3706
- }
3707
- catch {
3708
- return;
3709
- }
3710
- for (const dirName of dirNames) {
3711
- // A runtime dir name IS a connection key — that is what wrote it.
3712
- const key = asConnectionKey(dirName);
3713
- if (this.connections.has(key))
3714
- continue;
3715
- // Skip non-runtime dirs (e.g. profiles/)
3716
- if (dirName === 'profiles' || dirName === 'sessions' || dirName === 'exports')
3717
- continue;
3718
- try {
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
- }
3738
- }
3739
- catch (err) {
3740
- if (!onError)
3741
- throw err;
3742
- onError(key, err);
3743
- }
3744
- }
3745
- }
3746
- /**
3747
- * On a RAM miss, scan profile runtime dirs for a tasks.json entry matching
3748
- * `taskId`, soft-attach when CDP is still up, and register the connection.
3749
- * Does not launch browsers or clear runtime files.
3750
- */
3751
- async rehydrateTaskFromDisk(taskId, profileHint) {
3752
- const runtimeRoot = getBrowserRuntimeDir();
3753
- let dirNames = [];
3754
- try {
3755
- dirNames = fs.readdirSync(runtimeRoot).filter((d) => {
3756
- try {
3757
- return fs.statSync(path.join(runtimeRoot, d)).isDirectory();
3758
- }
3759
- catch {
3760
- return false;
3761
- }
3762
- });
3763
- }
3764
- catch {
3765
- return null;
3766
- }
3767
- if (profileHint) {
3768
- dirNames = dirNames.filter((d) => d === profileHint || keyBelongsToProfile(d, profileHint));
3769
- }
3770
- for (const dirName of dirNames) {
3771
- if (dirName === 'profiles' || dirName === 'sessions' || dirName === 'exports')
3772
- continue;
3773
- const key = asConnectionKey(dirName);
3774
- const tasks = this.loadTaskState(key);
3775
- let found;
3776
- for (const task of tasks.values()) {
3777
- if (task.id === taskId || task.name === taskId) {
3778
- found = task;
3779
- break;
3780
- }
3781
- }
3782
- // Also allow map-key match
3783
- if (!found)
3784
- found = tasks.get(taskId);
3785
- if (!found)
3786
- continue;
3787
- // Already connected under this key?
3788
- let conn = this.connections.get(key);
3789
- if (!conn) {
3790
- const attached = await this.attachRunningProfile(key, tasks);
3791
- if (!attached) {
3792
- // CDP down — cannot rehydrate a live connection; caller will error.
3793
- continue;
3794
- }
3795
- // Keep whichever connection a concurrent rehydrate registered first and
3796
- // tear our loser's tunnel down instead of leaking it (see the helper).
3797
- conn = this.registerRehydratedConnection(key, attached);
3798
- if (conn === attached) {
3799
- try {
3800
- if (conn.backend !== 'arc-native' && conn.backend !== 'bidi')
3801
- await this.applyDefaultDownloadBehavior(conn, key);
3802
- }
3803
- catch {
3804
- // Non-fatal.
3805
- }
3806
- }
3807
- }
3808
- const task = this.lookupTaskOnConn(conn, taskId) ?? found;
3809
- // Ensure the rehydrated task is on the connection map.
3810
- if (!this.lookupTaskOnConn(conn, task.name)) {
3811
- conn.tasks.set(task.name, task);
3812
- }
3813
- return { conn, task, key: conn.key ?? key };
3814
- }
3815
- return null;
3816
- }
3817
- async getTabsForTask(cdp, task) {
3818
- const targets = (await cdp.send('Target.getTargets'));
3819
- const tabs = [];
3820
- for (const [shortId, cdpId] of Object.entries(task.tabs)) {
3821
- const target = targets.targetInfos.find((t) => t.targetId === cdpId);
3822
- if (target) {
3823
- tabs.push({
3824
- id: shortId,
3825
- url: target.url,
3826
- title: target.title,
3827
- task: task.name,
3828
- });
3829
- }
3830
- }
3831
- return tabs;
3832
- }
3833
- /** Live status for ONE registered connection, addressed by its runtime key. */
3834
- async getProfileStatus(key, arc) {
3835
- const conn = this.connections.get(key);
3836
- if (!conn)
3837
- return null;
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());
3843
- const tasks = [];
3844
- for (const task of conn.tasks.values()) {
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
- }
3857
- const domains = tabs.flatMap((tab) => {
3858
- try {
3859
- const domain = new URL(tab.url).hostname.replace(/^www\./, '');
3860
- return domain && domain !== 'blank' ? [domain] : [];
3861
- }
3862
- catch {
3863
- return [];
3864
- }
3865
- });
3866
- tasks.push({
3867
- id: task.id,
3868
- name: task.name,
3869
- label: task.label ?? task.name,
3870
- tabCount: Object.keys(task.tabs).length,
3871
- currentTabId: task.currentTabId,
3872
- createdAt: task.createdAt,
3873
- tabs: tabs.length ? tabs : undefined,
3874
- domains: [...new Set(domains)],
3875
- unavailable,
3876
- });
3877
- }
3878
- const parsed = parseConnectionKey(key);
3879
- return {
3880
- name: conn.profile ?? parsed.profile,
3881
- endpoint: parsed.endpoint,
3882
- key,
3883
- running: await running(),
3884
- port: 0,
3885
- pid: 0,
3886
- tasks,
3887
- };
3888
- }
3889
- if (conn.backend === 'bidi') {
3890
- const tasks = [];
3891
- for (const task of conn.tasks.values()) {
3892
- let tabs = [];
3893
- try {
3894
- tabs = await this.listFirefoxTaskTabs(conn, task);
3895
- }
3896
- catch { /* Firefox down */ }
3897
- const domains = tabs.flatMap((tab) => {
3898
- try {
3899
- const domain = new URL(tab.url).hostname.replace(/^www\./, '');
3900
- return domain && domain !== 'blank' ? [domain] : [];
3901
- }
3902
- catch {
3903
- return [];
3904
- }
3905
- });
3906
- tasks.push({
3907
- id: task.id,
3908
- name: task.name,
3909
- label: task.label ?? task.name,
3910
- tabCount: Object.keys(task.tabs).length,
3911
- currentTabId: task.currentTabId,
3912
- createdAt: task.createdAt,
3913
- tabs: tabs.length ? tabs.map((t) => ({ id: t.id, url: t.url, title: t.title, current: t.current })) : undefined,
3914
- domains: domains.length ? [...new Set(domains)] : undefined,
3915
- });
3916
- }
3917
- const parsed = parseConnectionKey(key);
3918
- return {
3919
- name: conn.profile ?? parsed.profile,
3920
- endpoint: parsed.endpoint,
3921
- key,
3922
- running: conn.bidi.isOpen,
3923
- port: conn.port,
3924
- pid: conn.pid,
3925
- tasks,
3926
- };
3927
- }
3928
- // Fetch all targets once for efficiency
3929
- let targets = [];
3930
- try {
3931
- const result = (await conn.cdp.send('Target.getTargets'));
3932
- targets = result.targetInfos;
3933
- }
3934
- catch {
3935
- // CDP not responding, fall back to metadata only
3936
- }
3937
- const tasks = [];
3938
- for (const [, task] of conn.tasks) {
3939
- const tabs = [];
3940
- const domainSet = new Set();
3941
- for (const [shortId, cdpId] of Object.entries(task.tabs)) {
3942
- const target = targets.find((t) => t.targetId === cdpId);
3943
- if (target) {
3944
- tabs.push({
3945
- id: shortId,
3946
- url: target.url,
3947
- title: target.title,
3948
- current: shortId === task.currentTabId,
3949
- });
3950
- try {
3951
- const domain = new URL(target.url).hostname.replace(/^www\./, '');
3952
- if (domain && domain !== 'blank')
3953
- domainSet.add(domain);
3954
- }
3955
- catch {
3956
- // invalid URL
3957
- }
3958
- }
3959
- }
3960
- tasks.push({
3961
- id: task.id,
3962
- name: task.name,
3963
- label: task.label ?? task.name,
3964
- tabCount: Object.keys(task.tabs).length,
3965
- currentTabId: task.currentTabId,
3966
- createdAt: task.createdAt,
3967
- tabs: tabs.length > 0 ? tabs : undefined,
3968
- domains: domainSet.size > 0 ? Array.from(domainSet) : undefined,
3969
- });
3970
- }
3971
- const parsed = parseConnectionKey(key);
3972
- const bare = conn.profile ?? parsed.profile;
3973
- const profile = await getProfile(bare);
3974
- const configuredPort = profile ? extractConfiguredPort(profile) : undefined;
3975
- return {
3976
- name: bare,
3977
- endpoint: parsed.endpoint,
3978
- key,
3979
- running: true,
3980
- port: conn.port,
3981
- pid: conn.pid,
3982
- configuredPort: configuredPort !== conn.port ? configuredPort : undefined,
3983
- tasks,
3984
- };
3985
- }
3986
- async getTarget(conn, tabId) {
3987
- const now = Date.now();
3988
- if (!conn.targetCache || now - conn.targetCache.ts > 1000) {
3989
- const { targetInfos } = (await conn.cdp.send('Target.getTargets'));
3990
- conn.targetCache = { targets: targetInfos, ts: now };
3991
- }
3992
- return conn.targetCache.targets.find((target) => target.targetId === tabId);
3993
- }
3994
- async getSessionId(conn, tabId) {
3995
- const cachedSessionId = conn.sessionCache.get(tabId);
3996
- if (cachedSessionId) {
3997
- return cachedSessionId;
3998
- }
3999
- const { sessionId } = (await conn.cdp.send('Target.attachToTarget', {
4000
- targetId: tabId,
4001
- flatten: true,
4002
- }));
4003
- // Inject a stealth shim before any page script runs. Chromium exposes
4004
- // navigator.webdriver = true whenever a remote-debug transport is attached;
4005
- // Cloudflare Turnstile, hCaptcha, and similar bot checks read it first.
4006
- //
4007
- // Only attach-to-running profiles (conn.pid === 0 — Comet / Arc / Brave the
4008
- // user launched themselves) need this. Browsers agents-cli spawns already
4009
- // carry the --disable-blink-features=AutomationControlled launch flag, which
4010
- // makes navigator.webdriver a native Navigator.prototype getter returning
4011
- // false — indistinguishable from an untouched browser. Injecting on top of
4012
- // that is actively harmful: it defines an OWN getter on the instance, and an
4013
- // own `webdriver` descriptor (native lives on the prototype) returning
4014
- // `undefined` (native returns `false`) is itself a tampering signal that
4015
- // bot.sannysoft.com and similar tests flag as "WebDriver present".
4016
- //
4017
- // When we do inject (attach mode), mirror native semantics exactly: define
4018
- // on Navigator.prototype and return false, so no own descriptor leaks and
4019
- // the value matches a real browser. Non-page targets (workers, service
4020
- // workers) reject these calls; swallow the error and keep going.
4021
- if (conn.pid === 0) {
4022
- try {
4023
- await conn.cdp.send('Page.enable', {}, sessionId);
4024
- await conn.cdp.send('Page.addScriptToEvaluateOnNewDocument', {
4025
- source: "Object.defineProperty(Navigator.prototype,'webdriver',{get:()=>false,configurable:true});",
4026
- }, sessionId);
4027
- }
4028
- catch {
4029
- // Target doesn't support Page domain — nothing to inject.
4030
- }
4031
- }
4032
- conn.sessionCache.set(tabId, sessionId);
4033
- return sessionId;
4034
- }
4035
- invalidateTargetCache(conn) {
4036
- conn.targetCache = undefined;
4037
- }
4038
- async saveTaskState(key, tasks) {
4039
- // tasks.json holds EVERY task on this connection, so two tasks persisting
4040
- // concurrently (the per-task locks are disjoint, and `touchTask`/label writes
4041
- // fire outside any task lock) would interleave writes of the same file and
4042
- // could truncate or corrupt it. Serialize the whole file write per runtime
4043
- // key — a `persist:` namespace disjoint from the `task:`/`create:` locks, so a
4044
- // save issued while holding a task lock never deadlocks (PHNX-2399).
4045
- await this.runExclusive(`persist:${key}`, async () => {
4046
- const runtimeDir = getProfileRuntimeDir(key);
4047
- await fs.promises.mkdir(runtimeDir, { recursive: true });
4048
- const state = Object.fromEntries(tasks);
4049
- // Write to a temp sibling then rename, so a reader never sees a half-written
4050
- // file even if the process dies mid-write (rename is atomic on one fs).
4051
- const target = path.join(runtimeDir, 'tasks.json');
4052
- const tmp = `${target}.${process.pid}.tmp`;
4053
- await fs.promises.writeFile(tmp, JSON.stringify(state, null, 2));
4054
- await fs.promises.rename(tmp, target);
4055
- });
4056
- }
4057
- loadTaskState(key) {
4058
- const runtimeDir = getProfileRuntimeDir(key);
4059
- const tasksFile = path.join(runtimeDir, 'tasks.json');
4060
- if (!fs.existsSync(tasksFile)) {
4061
- return new Map();
4062
- }
4063
- const state = JSON.parse(fs.readFileSync(tasksFile, 'utf-8'));
4064
- const tasks = new Map();
4065
- let needsMigration = false;
4066
- for (const [key, raw] of Object.entries(state)) {
4067
- const task = raw;
4068
- // Migrate old format (tabIds array) to new format (tabs object)
4069
- if (Array.isArray(task.tabIds) && !task.tabs) {
4070
- needsMigration = true;
4071
- const tabs = {};
4072
- for (const cdpId of task.tabIds) {
4073
- const shortId = generateShortId();
4074
- tabs[shortId] = cdpId;
4075
- }
4076
- const tabIds = Object.keys(tabs);
4077
- tasks.set(key, {
4078
- id: task.id,
4079
- name: task.name || key,
4080
- profile: asConnectionKey(task.profile),
4081
- tabs,
4082
- currentTabId: tabIds.length > 0 ? tabIds[tabIds.length - 1] : undefined,
4083
- createdAt: task.createdAt,
4084
- lastActionAt: task.lastActionAt ?? task.createdAt,
4085
- pid: task.pid,
4086
- });
4087
- }
4088
- else {
4089
- const loaded = task;
4090
- // Tasks persisted before RUSH-2622 carry no lastActionAt. Normalize on
4091
- // read so every in-memory task has one: its own createdAt is the last
4092
- // moment we can prove the task did anything.
4093
- if (typeof loaded.lastActionAt !== 'number') {
4094
- loaded.lastActionAt = loaded.createdAt;
4095
- }
4096
- tasks.set(key, loaded);
4097
- }
4098
- }
4099
- // Save migrated data back to disk
4100
- if (needsMigration) {
4101
- const migratedState = Object.fromEntries(tasks);
4102
- fs.writeFileSync(tasksFile, JSON.stringify(migratedState, null, 2));
4103
- }
4104
- return tasks;
4105
- }
4106
- async saveToHistory(task, domains) {
4107
- const historyDir = getBrowserRuntimeDir();
4108
- await fs.promises.mkdir(historyDir, { recursive: true });
4109
- const historyFile = path.join(historyDir, 'history.json');
4110
- let history = [];
4111
- if (fs.existsSync(historyFile)) {
4112
- try {
4113
- history = JSON.parse(fs.readFileSync(historyFile, 'utf-8'));
4114
- }
4115
- catch {
4116
- // Corrupted file, start fresh
4117
- }
4118
- }
4119
- history.unshift({
4120
- id: task.id,
4121
- name: task.name,
4122
- // `task.profile` is the runtime key; history is a user-facing table, so
4123
- // it carries the bare name (RUSH-2709).
4124
- profile: parseConnectionKey(task.profile).profile,
4125
- createdAt: task.createdAt,
4126
- endedAt: Date.now(),
4127
- domains,
4128
- tabCount: Object.keys(task.tabs).length,
4129
- ...(task.owner ? { owner: task.owner } : {}), // carry who launched it (RUSH-2020)
4130
- });
4131
- // Keep only last 50 entries
4132
- history = history.slice(0, 50);
4133
- await fs.promises.writeFile(historyFile, JSON.stringify(history, null, 2));
4134
- }
4135
- /**
4136
- * The cached connection for `key`, or undefined — evicting it first if the
4137
- * browser died underneath us.
4138
- *
4139
- * A browser killed externally (Cmd-Q, crash, a user clearing the profile by
4140
- * hand) leaves a closed WebSocket in the map. Without this the next
4141
- * `cdp.send` throws "CDP connection not open" and there is no recovery short
4142
- * of killing the daemon.
4143
- */
4144
- async reuseHealthyConnection(key) {
4145
- const conn = this.connections.get(key);
4146
- if (!conn)
4147
- return undefined;
4148
- if (await isConnHealthy(conn))
4149
- return conn;
4150
- if (conn.backend === 'bidi') {
4151
- try {
4152
- conn.bidi.close();
4153
- }
4154
- catch { /* already closed */ }
4155
- }
4156
- else if (conn.backend !== 'arc-native') {
4157
- try {
4158
- conn.cdp.close();
4159
- }
4160
- catch { /* already closed */ }
4161
- }
4162
- conn.cleanup?.();
4163
- this.connections.delete(key);
4164
- return undefined;
4165
- }
4166
- /**
4167
- * Reuse a live connection for this profile: the new `<name>@<device>` key
4168
- * first, then any leftover `<name>@endpoint-N` key still in the map so a
4169
- * browser started before the key collapse is not abandoned.
4170
- */
4171
- async reuseProfileConnection(profileName, preferred, local) {
4172
- const preferredConn = await this.reuseHealthyConnection(preferred);
4173
- if (preferredConn)
4174
- return { conn: preferredConn, key: preferred };
4175
- // Leftover `@endpoint-N` connections are THIS machine's pre-T2 local
4176
- // browser. Reusing them on a tunnelled resolve is the original bug.
4177
- if (!local)
4178
- return undefined;
4179
- for (const key of [...this.connections.keys()]) {
4180
- if (key === preferred)
4181
- continue;
4182
- if (!isLegacyEndpointKey(key, profileName))
4183
- continue;
4184
- if (parseConnectionKey(key).fork !== undefined)
4185
- continue;
4186
- const conn = await this.reuseHealthyConnection(key);
4187
- if (conn)
4188
- return { conn, key };
4189
- }
4190
- return undefined;
4191
- }
4192
- // ---------------------------------------------------------------------------
4193
- // Native Arc backend helpers (PHNX-2399)
4194
- // ---------------------------------------------------------------------------
4195
- /**
4196
- * Bind a task to the profile's Space in the visible Arc window that holds it.
4197
- * The Space is the profile (PHNX-2399); only its live window id is looked up.
4198
- */
4199
- async resolveArcSpace(profile) {
4200
- const live = (await enumerateArcSpaces()).filter((space) => space.spaceId === profile.spaceId);
4201
- if (live.length !== 1) {
4202
- throw new Error(live.length === 0
4203
- ? `Arc Space ${JSON.stringify(profile.spaceTitle)} (${profile.spaceId}) is not open in a visible Arc window.`
4204
- : `Arc Space ${JSON.stringify(profile.spaceTitle)} is open in more than one window; refusing an ambiguous native address.`);
4205
- }
4206
- return {
4207
- windowId: live[0].windowId,
4208
- spaceId: live[0].spaceId,
4209
- spaceTitle: profile.spaceTitle,
4210
- };
4211
- }
4212
- requireArcTask(task) {
4213
- const native = task.arcNative;
4214
- if (!native || typeof native.profileId !== 'string' || typeof native.windowId !== 'string' ||
4215
- typeof native.spaceId !== 'string' || !native.tabs || typeof native.tabs !== 'object') {
4216
- throw new Error(`Task ${JSON.stringify(task.name)} has no valid durable Arc identity.`);
4217
- }
4218
- for (const [shortId, ref] of Object.entries(native.tabs)) {
4219
- if (!ref || typeof ref.windowId !== 'string' || typeof ref.spaceId !== 'string' ||
4220
- typeof ref.tabId !== 'string' || ref.windowId !== native.windowId || ref.spaceId !== native.spaceId) {
4221
- throw new Error(`Owned Arc tab ${JSON.stringify(shortId)} has no stable id in its original window/Space.`);
4222
- }
4223
- }
4224
- for (const [shortId, intent] of Object.entries(native.createIntents ?? {})) {
4225
- if (!intent || intent.tabId !== shortId || typeof intent.markerUrl !== 'string' ||
4226
- !isArcCreateMarker(intent.markerUrl) ||
4227
- typeof intent.targetUrl !== 'string' || typeof intent.createdAt !== 'number') {
4228
- throw new Error(`Arc creation intent ${JSON.stringify(shortId)} is invalid; refusing recovery.`);
4229
- }
4230
- if (intent.ref && (intent.ref.windowId !== native.windowId || intent.ref.spaceId !== native.spaceId ||
4231
- typeof intent.ref.tabId !== 'string')) {
4232
- throw new Error(`Arc creation intent ${JSON.stringify(shortId)} left its original window/Space.`);
4233
- }
4234
- }
4235
- return native;
4236
- }
4237
- async reconcileArcCreateIntents(conn, task) {
4238
- const native = this.requireArcTask(task);
4239
- for (const intent of Object.values(native.createIntents ?? {})) {
4240
- let ref = intent.ref;
4241
- if (ref) {
4242
- const live = await resolveArcTab(ref);
4243
- if (!live) {
4244
- throw new Error(`Owned Arc tab ${intent.tabId} left its original window/Space while creation was incomplete; refusing to adopt it elsewhere.`);
4245
- }
4246
- if (live.url === intent.targetUrl) {
4247
- native.tabs[intent.tabId] = ref;
4248
- task.tabs[intent.tabId] = ref.tabId;
4249
- if (intent.previousTabId) {
4250
- await restoreArcSelection(ref, intent.previousTabId);
4251
- }
4252
- delete native.createIntents?.[intent.tabId];
4253
- await this.saveTaskState(task.profile, conn.tasks);
4254
- continue;
4255
- }
4256
- if (live.url !== intent.markerUrl) {
4257
- throw new Error(`Owned Arc tab ${intent.tabId} changed outside this task during creation; refusing to navigate it.`);
4258
- }
4259
- }
4260
- else {
4261
- const originalSpace = (await enumerateArcSpaces()).find((space) => space.windowId === native.windowId && space.spaceId === native.spaceId);
4262
- if (!originalSpace) {
4263
- throw new Error('The original Arc window/Space for this task is no longer present.');
4264
- }
4265
- const markerMatches = originalSpace.tabs.filter((tab) => tab.url === intent.markerUrl);
4266
- if (markerMatches.length > 1) {
4267
- throw new Error(`Arc creation marker ${JSON.stringify(intent.markerUrl)} is not unique in the original Space.`);
4268
- }
4269
- if (markerMatches[0]) {
4270
- ref = markerMatches[0];
4271
- }
4272
- else {
4273
- try {
4274
- ref = await createArcTab({ windowId: native.windowId, spaceId: native.spaceId }, intent.markerUrl);
4275
- }
4276
- catch (error) {
4277
- // Arc selects the Space it was asked to create in even when the
4278
- // creation itself fails. Put the owner back on their tab before
4279
- // surfacing the error, so a failed agent verb never leaves them
4280
- // staring at the wrong Space.
4281
- if (intent.previousTabId) {
4282
- await selectWindowTab(native.windowId, intent.previousTabId).catch(() => undefined);
4283
- }
4284
- throw error;
4285
- }
4286
- }
4287
- intent.ref = ref;
4288
- native.tabs[intent.tabId] = ref;
4289
- task.tabs[intent.tabId] = ref.tabId;
4290
- await this.saveTaskState(task.profile, conn.tasks);
4291
- }
4292
- await navigateArcTab(ref, intent.targetUrl);
4293
- if (intent.previousTabId) {
4294
- await restoreArcSelection(ref, intent.previousTabId);
4295
- }
4296
- delete native.createIntents?.[intent.tabId];
4297
- task.currentTabId = intent.tabId;
4298
- await this.saveTaskState(task.profile, conn.tasks);
4299
- }
4300
- }
4301
- async createArcOwnedTab(conn, task, url) {
4302
- const native = this.requireArcTask(task);
4303
- const tabId = generateShortId();
4304
- const markerUrl = arcCreateMarker();
4305
- const originalSpace = (await enumerateArcSpaces()).find((space) => space.windowId === native.windowId && space.spaceId === native.spaceId);
4306
- if (!originalSpace)
4307
- throw new Error('The original Arc window/Space for this task is no longer present.');
4308
- native.createIntents ??= {};
4309
- native.createIntents[tabId] = {
4310
- tabId,
4311
- markerUrl,
4312
- targetUrl: url,
4313
- createdAt: Date.now(),
4314
- previousTabId: originalSpace.activeTabId,
4315
- };
4316
- await this.saveTaskState(task.profile, conn.tasks);
4317
- await this.reconcileArcCreateIntents(conn, task);
4318
- return tabId;
4319
- }
4320
- async navigateArcNative(conn, task, url, addTab = false) {
4321
- return this.runExclusive('arc-native:host-app', async () => {
4322
- await this.reconcileArcCreateIntents(conn, task);
4323
- const native = this.requireArcTask(task);
4324
- if (!addTab) {
4325
- for (const [shortId, ref] of Object.entries(native.tabs)) {
4326
- const live = await resolveArcTab(ref);
4327
- if (live && canonicalTabUrl(live.url) === canonicalTabUrl(url)) {
4328
- await navigateArcTab(ref, url);
4329
- task.currentTabId = shortId;
4330
- await this.saveTaskState(task.profile, conn.tasks);
4331
- return { tabId: shortId, url, created: false, refreshed: true, message: 'Tab already open—refreshed' };
4332
- }
4333
- }
4334
- }
4335
- const currentId = task.currentTabId;
4336
- if (!addTab && currentId) {
4337
- const ref = native.tabs[currentId];
4338
- if (!ref || !(await resolveArcTab(ref))) {
4339
- throw new Error(`Owned Arc tab ${currentId} is missing from its original window/Space; refusing to adopt a moved tab.`);
4340
- }
4341
- await navigateArcTab(ref, url);
4342
- return { tabId: currentId, url, created: false, refreshed: false };
4343
- }
4344
- const tabId = await this.createArcOwnedTab(conn, task, url);
4345
- return { tabId, url, created: true, refreshed: false };
4346
- });
4347
- }
4348
- /**
4349
- * Execute JavaScript in a native Arc tab. Sync JS only, isolated world —
4350
- * async/promise evaluation fails honestly with an AppleScript error.
4351
- */
4352
- async evaluateArcNative(conn, task, shortId, expression) {
4353
- const ref = this.requireArcTask(task).tabs[shortId];
4354
- if (!ref || !(await resolveArcTab(ref))) {
4355
- throw new Error(`Owned Arc tab ${shortId} is missing from its original window/Space.`);
4356
- }
4357
- return executeJavaScript(ref, expression);
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
- }
4392
- async listArcTaskTabs(task) {
4393
- const native = this.requireArcTask(task);
4394
- const tabs = [];
4395
- for (const [shortId, ref] of Object.entries(native.tabs)) {
4396
- const live = await resolveArcTab(ref);
4397
- if (!live) {
4398
- throw new Error(`Owned Arc tab ${shortId} is missing from its original window/Space; refusing to adopt a moved tab.`);
4399
- }
4400
- tabs.push({
4401
- id: shortId,
4402
- url: live.url,
4403
- title: live.title,
4404
- task: task.name,
4405
- current: shortId === task.currentTabId,
4406
- });
4407
- }
4408
- return tabs;
4409
- }
4410
- /**
4411
- * Close native Arc tabs owned by a task. Only closes tabs the task created
4412
- * (not borrowed ones). Never kills Arc. Identifies tabs only by stable ids in
4413
- * the original window and Space.
4414
- */
4415
- async closeArcNativeTabs(conn, task) {
4416
- await this.runExclusive('arc-native:host-app', async () => {
4417
- await this.reconcileArcCreateIntents(conn, task);
4418
- const native = this.requireArcTask(task);
4419
- for (const [shortId, ref] of Object.entries(native.tabs)) {
4420
- await closeArcTab(ref);
4421
- delete native.tabs[shortId];
4422
- delete task.tabs[shortId];
4423
- delete task.refDescriptors?.[shortId];
4424
- }
4425
- task.currentTabId = undefined;
4426
- });
4427
- }
4428
- // ---------------------------------------------------------------------------
4429
- // Firefox WebDriver BiDi backend helpers (PHNX-4043)
4430
- // ---------------------------------------------------------------------------
4431
- /** The BiDi browsing-context id a task owns for `shortId`. */
4432
- firefoxContext(task, shortId) {
4433
- const context = task.tabs[shortId];
4434
- if (!context)
4435
- throw new Error(`Tab ${shortId} not found`);
4436
- return context;
4437
- }
4438
- /** The context ids currently live in this Firefox (a killed tab drops out). */
4439
- async firefoxLiveContexts(conn) {
4440
- const contexts = await bidiTopLevelContexts(conn.bidi);
4441
- return new Set(contexts.map((c) => c.context));
4442
- }
4443
- /**
4444
- * Same-task reopen for Firefox (mirrors {@link reopenOwnedTab}): the URL is
4445
- * already live in one of this task's own tabs → reload THAT tab in place and
4446
- * keep its id, rather than opening a duplicate. Borrowed tabs are excluded —
4447
- * Firefox never borrows, but the guard keeps parity. Returns the retained
4448
- * short id, or undefined when nothing matches.
4449
- */
4450
- async reopenOwnedFirefoxTab(conn, task, url) {
4451
- const borrowed = new Set(task.borrowedTabs ?? []);
4452
- // Read each live tab's URL from browsingContext.getTree metadata rather than
4453
- // evaluating location.href per tab: the same source the CDP path reads
4454
- // (Target.getTargets), so a mid-navigation or just-closed sibling tab can
4455
- // never throw a BiDi "no such frame" and fail an unrelated navigate. A tab
4456
- // absent from the tree is simply not a reopen candidate.
4457
- const liveUrlByContext = new Map((await bidiTopLevelContexts(conn.bidi)).map((c) => [c.context, c.url]));
4458
- const wanted = canonicalTabUrl(url);
4459
- for (const [shortId, context] of Object.entries(task.tabs)) {
4460
- if (borrowed.has(shortId))
4461
- continue;
4462
- const liveUrl = liveUrlByContext.get(context);
4463
- if (liveUrl === undefined)
4464
- continue; // registered but gone from the browser
4465
- if (canonicalTabUrl(liveUrl) === wanted) {
4466
- await bidiReload(conn.bidi, context);
4467
- task.currentTabId = shortId;
4468
- await this.saveTaskState(task.profile, conn.tasks);
4469
- return { tabId: shortId };
4470
- }
4471
- }
4472
- return undefined;
4473
- }
4474
- async navigateFirefox(conn, task, url, addTab = false) {
4475
- if (!addTab) {
4476
- const reopened = await this.reopenOwnedFirefoxTab(conn, task, url);
4477
- if (reopened) {
4478
- emit('browser.navigate', { profile: parseConnectionKey(task.profile).profile, task: task.name, url, tabId: reopened.tabId, created: false });
4479
- return { tabId: reopened.tabId, url, created: false, refreshed: true, message: 'Tab already open—refreshed' };
4480
- }
4481
- // Reuse the current tab when there is one — navigate() semantics.
4482
- const currentShortId = task.currentTabId;
4483
- if (currentShortId && task.tabs[currentShortId]) {
4484
- const context = task.tabs[currentShortId];
4485
- const live = await this.firefoxLiveContexts(conn);
4486
- if (live.has(context)) {
4487
- await bidiNavigate(conn.bidi, context, url);
4488
- await this.saveTaskState(task.profile, conn.tasks);
4489
- emit('browser.navigate', { profile: parseConnectionKey(task.profile).profile, task: task.name, url, tabId: currentShortId, created: false });
4490
- return { tabId: currentShortId, url, created: false, refreshed: false };
4491
- }
4492
- }
4493
- }
4494
- else {
4495
- const reopened = await this.reopenOwnedFirefoxTab(conn, task, url);
4496
- if (reopened) {
4497
- return { tabId: reopened.tabId, url, created: false, refreshed: true, message: 'Tab already open—refreshed' };
4498
- }
4499
- }
4500
- // Open a fresh tab.
4501
- const context = await bidiCreateTab(conn.bidi);
4502
- await bidiNavigate(conn.bidi, context, url);
4503
- const shortId = generateShortId();
4504
- task.tabs[shortId] = context;
4505
- task.currentTabId = shortId;
4506
- await this.saveTaskState(task.profile, conn.tasks);
4507
- emit('browser.navigate', { profile: parseConnectionKey(task.profile).profile, task: task.name, url, tabId: shortId, created: true });
4508
- return { tabId: shortId, url, created: true, refreshed: false };
4509
- }
4510
- async evaluateFirefox(conn, task, shortId, expression) {
4511
- const context = this.firefoxContext(task, shortId);
4512
- const live = await this.firefoxLiveContexts(conn);
4513
- if (!live.has(context))
4514
- throw new Error(`Tab ${shortId} is no longer open in Firefox.`);
4515
- return bidiEvaluate(conn.bidi, context, expression);
4516
- }
4517
- async listFirefoxTaskTabs(conn, task) {
4518
- const live = await bidiTopLevelContexts(conn.bidi);
4519
- const byId = new Map(live.map((c) => [c.context, c]));
4520
- const tabs = [];
4521
- for (const [shortId, context] of Object.entries(task.tabs)) {
4522
- const info = byId.get(context);
4523
- if (!info)
4524
- continue; // tab closed out from under the task — drop it silently.
4525
- let title = '';
4526
- try {
4527
- title = String(await bidiEvaluate(conn.bidi, context, 'document.title'));
4528
- }
4529
- catch {
4530
- /* about: pages or a mid-navigation context — url is enough */
4531
- }
4532
- tabs.push({ id: shortId, url: info.url, title, task: task.name, current: shortId === task.currentTabId });
4533
- }
4534
- return tabs;
4535
- }
4536
- /**
4537
- * Close every non-borrowed tab a Firefox task owns. Never kills the Firefox
4538
- * process (that is `stopProfile`'s job) — a killed browser would take the
4539
- * user's other tabs with it. A tab already gone is skipped.
4540
- */
4541
- async closeFirefoxTabs(conn, task) {
4542
- const borrowed = new Set(task.borrowedTabs ?? []);
4543
- const live = await this.firefoxLiveContexts(conn);
4544
- for (const [shortId, context] of Object.entries(task.tabs)) {
4545
- if (borrowed.has(shortId))
4546
- continue;
4547
- if (live.has(context)) {
4548
- try {
4549
- await bidiCloseTab(conn.bidi, context);
4550
- }
4551
- catch { /* already closed */ }
4552
- }
4553
- }
4554
- }
4555
- /**
4556
- * Viewport-center coordinates of a selector-addressed element in a Firefox
4557
- * tab, scrolled into view first — the (x, y) `input.performActions` clicks.
4558
- * Throws when the element is gone or has zero size (off-screen / display:none).
4559
- */
4560
- async firefoxRefCoords(conn, task, shortId, selector) {
4561
- const expr = `(() => {
4562
- const el = document.querySelector(${JSON.stringify(selector)});
4563
- if (!el) throw new Error('DOM ref is missing');
4564
- el.scrollIntoView({ block: 'center', inline: 'center' });
4565
- const r = el.getBoundingClientRect();
4566
- if (r.width === 0 && r.height === 0) throw new Error('DOM ref has no layout box');
4567
- return JSON.stringify({ x: r.left + r.width / 2, y: r.top + r.height / 2 });
4568
- })()`;
4569
- const raw = await this.evaluateFirefox(conn, task, shortId, expr);
4570
- const parsed = JSON.parse(String(raw));
4571
- return parsed;
4572
- }
4573
- /**
4574
- * Screenshot a Firefox tab over BiDi. Same output contract as the CDP path:
4575
- * `raw` is a pixel-faithful PNG; the default is JPEG iteratively downscaled
4576
- * under 100 KB so chat-injected screenshots stay token-cheap.
4577
- */
4578
- async screenshotFirefox(conn, task, runtimeKey, tabHint, outputPath, quality) {
4579
- const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
4580
- const context = this.firefoxContext(task, shortId);
4581
- const live = await this.firefoxLiveContexts(conn);
4582
- if (!live.has(context))
4583
- throw new Error(`Tab ${shortId} is no longer open in Firefox.`);
4584
- let buffer;
4585
- let extension;
4586
- if (quality === 'raw') {
4587
- buffer = await bidiScreenshot(conn.bidi, context, { type: 'image/png' });
4588
- extension = 'png';
4589
- }
4590
- else {
4591
- buffer = await bidiScreenshot(conn.bidi, context, { type: 'image/jpeg', quality: 0.7 });
4592
- const MAX_SIZE = 100 * 1024;
4593
- let q = 0.5;
4594
- while (buffer.length > MAX_SIZE && q > 0.1) {
4595
- buffer = await bidiScreenshot(conn.bidi, context, { type: 'image/jpeg', quality: q });
4596
- q -= 0.1;
4597
- }
4598
- extension = 'jpg';
4599
- }
4600
- const sessionsDir = getProfileSessionsDir(runtimeKey, task.name);
4601
- const automaticPath = path.join(sessionsDir, `${Date.now()}.${extension}`);
4602
- const finalPath = resolveScreenshotOutputPath(outputPath, automaticPath);
4603
- await fs.promises.mkdir(path.dirname(finalPath), { recursive: true });
4604
- await fs.promises.writeFile(finalPath, buffer);
4605
- const dims = (extension === 'png' ? readPngDimensions(buffer) : readJpegDimensions(buffer)) ??
4606
- { width: 0, height: 0 };
4607
- emit('browser.screenshot', {
4608
- profile: parseConnectionKey(runtimeKey).profile,
4609
- task: task.name,
4610
- tabId: shortId,
4611
- path: finalPath,
4612
- bytes: buffer.length,
4613
- width: dims.width,
4614
- height: dims.height,
4615
- quality,
4616
- });
4617
- return { path: finalPath, bytes: buffer.length, width: dims.width, height: dims.height };
4618
- }
4619
- /** Connect a profile at `target`, register it under `key`, and arm downloads. */
4620
- async openConnection(profile, target, key, profileName, opts = {}) {
4621
- const conn = await this.connectProfile(profile, target, key, opts);
4622
- conn.key = key;
4623
- conn.profile = profileName;
4624
- this.connections.set(key, conn);
4625
- // Download behavior configuration is CDP-specific; skip for native Arc + Firefox.
4626
- if (conn.backend !== 'arc-native' && conn.backend !== 'bidi') {
4627
- await this.applyDefaultDownloadBehavior(conn, key);
4628
- }
4629
- return conn;
4630
- }
4631
- /**
4632
- * Open a tab for a HUMAN to read, bound to no task.
4633
- *
4634
- * That is the entire difference from {@link start}, and it is the point. The
4635
- * abandoned-task reaper closes a task's tabs when the calling session dies or
4636
- * after the idle window, but it "deliberately never touches a tab that is not
4637
- * in `task.tabs`" (hygiene.ts) — so a viewer tab must not be in one, or the
4638
- * artifact the user is reading vanishes when the agent that rendered it exits.
4639
- *
4640
- * Consequently `stop`/`done` do not close it either, which is correct: it is
4641
- * the user's tab now.
4642
- */
4643
- async showUrl(profileName, url, opts = {}) {
4644
- // Same consent gate as start(): this opens a browser, so a fleet-remote
4645
- // caller needs the target machine's opt-in.
4646
- assertRemoteControlAllowedForRequest(opts.fleetRemote, { actor: opts.actor });
4647
- const routed = resolveBrowserTarget(profileName, {
4648
- endpointName: opts.endpointName,
4649
- probe: opts.probe,
4650
- });
4651
- if (!routed.local && routed.commandDispatch) {
4652
- throw new Error(`Native Arc profile "${profileName}" is owned by ${routed.device}; dispatch the complete browser command there.`);
4653
- }
4654
- const key = routed.key;
4655
- const effectiveProfile = routed.profile;
4656
- const reused = await this.reuseProfileConnection(profileName, key, routed.local);
4657
- let conn = reused?.conn;
4658
- const effectiveKey = reused?.key ?? key;
4659
- if (!conn) {
4660
- adoptLegacyRuntimeIfLocal(routed.local, profileName, key, getBrowserRuntimeDir());
4661
- conn = await this.openConnection(effectiveProfile, routed.target, key, profileName, { persistRemote: !routed.local });
4662
- }
4663
- if (conn.backend === 'arc-native') {
4664
- throw new ArcNativeCapabilityError('show', 'Task-less viewer tabs are unavailable for native Arc because creation intent cannot be bound to an owning task.');
4665
- }
4666
- if (conn.backend === 'bidi') {
4667
- throw new FirefoxCapabilityError('show', 'Task-less viewer tabs are unavailable for a headless-automation Firefox profile. ' +
4668
- 'Use a Chromium-family profile, or open the URL in your OS browser.');
4669
- }
4670
- // createPageTarget refuses Arc with an actionable error rather than crashing it.
4671
- const created = await this.createPageTarget(conn, { url });
4672
- this.invalidateTargetCache(conn);
4673
- return {
4674
- profile: profileName,
4675
- key: effectiveKey,
4676
- tabId: created.targetId,
4677
- device: routed.device,
4678
- picked: routed.picked,
4679
- };
4680
- }
4681
- async getHistory(limit = 10) {
4682
- const historyFile = path.join(getBrowserRuntimeDir(), 'history.json');
4683
- if (!fs.existsSync(historyFile))
4684
- return [];
4685
- try {
4686
- const history = JSON.parse(fs.readFileSync(historyFile, 'utf-8'));
4687
- return history.slice(0, limit);
4688
- }
4689
- catch {
4690
- return [];
4691
- }
4692
- }
4693
- }