@phnx-labs/agents-cli 1.22.93 → 1.22.95

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 (33) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/commands/browser.d.ts +1 -0
  3. package/dist/commands/browser.js +156 -11
  4. package/dist/lib/browser/arc-discovery.d.ts +60 -0
  5. package/dist/lib/browser/arc-discovery.js +187 -0
  6. package/dist/lib/browser/arc-dom.d.ts +14 -0
  7. package/dist/lib/browser/arc-dom.js +121 -0
  8. package/dist/lib/browser/chrome.d.ts +17 -0
  9. package/dist/lib/browser/chrome.js +121 -16
  10. package/dist/lib/browser/chromium-discovery.d.ts +27 -0
  11. package/dist/lib/browser/chromium-discovery.js +96 -0
  12. package/dist/lib/browser/drivers/arc.d.ts +57 -0
  13. package/dist/lib/browser/drivers/arc.js +281 -0
  14. package/dist/lib/browser/drivers/firefox.d.ts +103 -0
  15. package/dist/lib/browser/drivers/firefox.js +377 -0
  16. package/dist/lib/browser/drivers/local.d.ts +8 -0
  17. package/dist/lib/browser/drivers/local.js +38 -3
  18. package/dist/lib/browser/firefox-discovery.d.ts +68 -0
  19. package/dist/lib/browser/firefox-discovery.js +162 -0
  20. package/dist/lib/browser/profiles.d.ts +39 -1
  21. package/dist/lib/browser/profiles.js +239 -9
  22. package/dist/lib/browser/refs.d.ts +2 -0
  23. package/dist/lib/browser/refs.js +2 -1
  24. package/dist/lib/browser/resolve-target.d.ts +2 -0
  25. package/dist/lib/browser/resolve-target.js +14 -0
  26. package/dist/lib/browser/runtime-state.js +9 -1
  27. package/dist/lib/browser/service.d.ts +86 -3
  28. package/dist/lib/browser/service.js +1045 -42
  29. package/dist/lib/browser/types.d.ts +85 -2
  30. package/dist/lib/daemon/usage-sync-service.js +12 -0
  31. package/dist/lib/open-url.js +6 -0
  32. package/dist/lib/types.d.ts +20 -1
  33. package/package.json +1 -1
@@ -16,12 +16,15 @@ import { taskMatchesCaller } from './caller-identity.js';
16
16
  import { getRefs, resolveRefToCoords, describeRefs, healRef } from './refs.js';
17
17
  import { clickAtCoords, hoverAtCoords, scrollAtCoords, typeText, pressKey, focusNode } from './input.js';
18
18
  import { typeEditorText } from './editor.js';
19
+ import { arcClickExpression, arcFillExpression, arcRefsExpression, arcScrollExpression, parseArcRefsResult, } from './arc-dom.js';
19
20
  import { detectUploadPattern, stageUploadFile, uploadToDropTarget, uploadToFileInput, uploadViaFileChooser, } from './upload.js';
20
21
  import { emit } from '../feed/events.js';
21
22
  import { resolveActor } from '../actor.js';
22
23
  import { recordBrowserSession } from '../session/db.js';
23
24
  import { sshExecAsync } from '../ssh-exec.js';
24
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';
25
28
  /**
26
29
  * Canonical form for comparing a requested URL against what CDP reports for a
27
30
  * live target. `new URL('https://example.com').href` is `https://example.com/`,
@@ -229,6 +232,15 @@ function expandHome(p) {
229
232
  * can make; if it doesn't round-trip within 1s the connection is dead.
230
233
  */
231
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
+ }
232
244
  if (!conn.cdp.isOpen)
233
245
  return false;
234
246
  try {
@@ -242,6 +254,19 @@ async function isConnHealthy(conn, timeoutMs = 1000) {
242
254
  return false;
243
255
  }
244
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
+ }
245
270
  /** Join error lines so callers get a next command, not a dead-end message. */
246
271
  export function actionable(...lines) {
247
272
  return lines.filter((l) => l != null && l !== '').join('\n');
@@ -294,6 +319,20 @@ export function resolveTaskIdentity(forwarded, resolveLocalActor) {
294
319
  sessionId: forwarded.sessionId,
295
320
  };
296
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
+ }
297
336
  export class BrowserService {
298
337
  static SOURCE_PREFIX = {
299
338
  'rush-app': 'rush-app-',
@@ -435,6 +474,9 @@ export class BrowserService {
435
474
  endpointName: opts.endpointName,
436
475
  probe: opts.probe,
437
476
  });
477
+ if (!routed.local && routed.commandDispatch) {
478
+ throw new Error(`Native Arc profile "${profileName}" is owned by ${routed.device}; dispatch the complete browser command there.`);
479
+ }
438
480
  const composite = routed.key;
439
481
  const effectiveProfile = routed.profile;
440
482
  const taskId = generateTaskId();
@@ -509,7 +551,7 @@ export class BrowserService {
509
551
  // pile-up in RUSH-2622. Tabs the daemon did NOT open are still left alone —
510
552
  // the branch only fires when the profile has no page target at all.
511
553
  let startupBlankTargetId;
512
- if (!opts.url && !conn.electron) {
554
+ if (!opts.url && !conn.electron && conn.backend !== 'arc-native' && conn.backend !== 'bidi') {
513
555
  const { targetInfos } = (await conn.cdp.send('Target.getTargets'));
514
556
  if (!targetInfos.some((t) => t.type === 'page')) {
515
557
  const created = await this.createPageTarget(conn, {
@@ -538,13 +580,23 @@ export class BrowserService {
538
580
  // actor (the RUSH-2020 bug).
539
581
  ...resolveTaskIdentity({ actor: opts.actor, launchId: opts.launchId, sessionId: opts.sessionId }, () => resolveActor().id),
540
582
  };
583
+ if (conn.backend === 'arc-native') {
584
+ const selected = await this.resolveArcSpace(conn.arcProfile);
585
+ task.arcNative = {
586
+ profileId: conn.arcProfile.profileId,
587
+ windowId: selected.windowId,
588
+ spaceId: selected.spaceId,
589
+ spaceTitle: selected.spaceTitle,
590
+ tabs: {},
591
+ };
592
+ }
541
593
  if (startupBlankTargetId) {
542
594
  const shortId = generateShortId();
543
595
  task.tabs[shortId] = startupBlankTargetId;
544
596
  task.currentTabId = shortId;
545
597
  }
546
598
  // For Electron, get the existing window as the tab
547
- if (conn.electron) {
599
+ if (conn.electron && conn.backend !== 'arc-native' && conn.backend !== 'bidi') {
548
600
  const windowId = await this.getOrCreateWindow(conn);
549
601
  if (windowId) {
550
602
  const shortId = generateShortId();
@@ -596,7 +648,14 @@ export class BrowserService {
596
648
  // opening a fresh target is created:true.
597
649
  let tabId;
598
650
  let firstOpen;
599
- if (opts.url && !conn.electron && conn.browserType !== 'arc') {
651
+ if (opts.url && conn.backend === 'bidi') {
652
+ // Firefox: the implicit first navigate opens a fresh tab (no adopt path —
653
+ // Firefox tabs aren't shared across abandoned tasks the way CDP ones are).
654
+ const result = await this.navigate(taskName, opts.url, effectiveKey);
655
+ tabId = result.tabId;
656
+ firstOpen = { tabId: result.tabId, created: result.created, refreshed: result.refreshed, message: result.message };
657
+ }
658
+ else if (opts.url && conn.backend !== 'arc-native' && conn.backend !== 'bidi' && !conn.electron && conn.browserType !== 'arc') {
600
659
  const adopted = opts.fresh ? undefined : await this.adoptTabShowing(conn, opts.url);
601
660
  const targetId = adopted ?? (await this.createPageTarget(conn, { url: opts.url })).targetId;
602
661
  const shortId = generateShortId();
@@ -607,7 +666,7 @@ export class BrowserService {
607
666
  tabId = shortId;
608
667
  firstOpen = { tabId: shortId, created: !adopted, refreshed: false };
609
668
  }
610
- else if (opts.url && (conn.electron || conn.browserType === 'arc')) {
669
+ else if (opts.url && (conn.backend === 'arc-native' || conn.electron || conn.browserType === 'arc')) {
611
670
  // Electron and Arc share the reuse-in-place path: neither may open a fresh
612
671
  // tab here (Electron drives its one window; Arc crashes on Target.createTarget),
613
672
  // so the implicit first navigate attaches to an existing tab — honoring the
@@ -830,36 +889,70 @@ export class BrowserService {
830
889
  const mapKey = this.taskMapKey(conn, task) ?? taskName;
831
890
  // Get domains from tabs before closing (for history)
832
891
  const domains = new Set();
833
- try {
834
- const { targetInfos } = (await conn.cdp.send('Target.getTargets'));
835
- for (const cdpId of Object.values(task.tabs)) {
836
- const target = targetInfos.find((t) => t.targetId === cdpId);
837
- if (target?.url) {
892
+ if (conn.backend === 'arc-native') {
893
+ // Resolve exact durable refs for history. Missing/moved tabs are not
894
+ // adopted and never become candidates for a best-effort close.
895
+ for (const ref of Object.values(this.requireArcTask(task).tabs)) {
896
+ const live = await resolveArcTab(ref);
897
+ if (live?.url) {
838
898
  try {
839
- const domain = new URL(target.url).hostname.replace(/^www\./, '');
899
+ const domain = new URL(live.url).hostname.replace(/^www\./, '');
840
900
  if (domain && domain !== 'blank')
841
901
  domains.add(domain);
842
902
  }
843
- catch {
844
- // invalid URL
845
- }
903
+ catch { /* invalid URL */ }
846
904
  }
847
905
  }
906
+ await this.saveToHistory(task, Array.from(domains));
907
+ await this.closeArcNativeTabs(conn, task);
848
908
  }
849
- catch {
850
- // CDP not responding
909
+ else if (conn.backend === 'bidi') {
910
+ try {
911
+ for (const tab of await this.listFirefoxTaskTabs(conn, task)) {
912
+ try {
913
+ const domain = new URL(tab.url).hostname.replace(/^www\./, '');
914
+ if (domain && domain !== 'blank')
915
+ domains.add(domain);
916
+ }
917
+ catch { /* invalid URL */ }
918
+ }
919
+ }
920
+ catch { /* Firefox not responding */ }
921
+ await this.saveToHistory(task, Array.from(domains));
922
+ await this.closeFirefoxTabs(conn, task);
851
923
  }
852
- // Save to history before closing
853
- await this.saveToHistory(task, Array.from(domains));
854
- // Close task's tabs (not the window - it's shared, and not a tab the
855
- // task merely borrowed - that one was open before the task existed).
856
- await Promise.all(this.closeableTargetIds(task).map((cdpId) => conn.cdp.send('Target.closeTarget', { targetId: cdpId }).catch(() => {
857
- // Tab already closed
858
- })));
859
- for (const cdpId of Object.values(task.tabs)) {
860
- conn.sessionCache.delete(cdpId);
924
+ else {
925
+ try {
926
+ const { targetInfos } = (await conn.cdp.send('Target.getTargets'));
927
+ for (const cdpId of Object.values(task.tabs)) {
928
+ const target = targetInfos.find((t) => t.targetId === cdpId);
929
+ if (target?.url) {
930
+ try {
931
+ const domain = new URL(target.url).hostname.replace(/^www\./, '');
932
+ if (domain && domain !== 'blank')
933
+ domains.add(domain);
934
+ }
935
+ catch {
936
+ // invalid URL
937
+ }
938
+ }
939
+ }
940
+ }
941
+ catch {
942
+ // CDP not responding
943
+ }
944
+ // Save to history before closing
945
+ await this.saveToHistory(task, Array.from(domains));
946
+ // Close task's tabs (not the window - it's shared, and not a tab the
947
+ // task merely borrowed - that one was open before the task existed).
948
+ await Promise.all(this.closeableTargetIds(task).map((cdpId) => conn.cdp.send('Target.closeTarget', { targetId: cdpId }).catch(() => {
949
+ // Tab already closed
950
+ })));
951
+ for (const cdpId of Object.values(task.tabs)) {
952
+ conn.sessionCache.delete(cdpId);
953
+ }
954
+ this.invalidateTargetCache(conn);
861
955
  }
862
- this.invalidateTargetCache(conn);
863
956
  conn.tasks.delete(mapKey);
864
957
  await this.saveTaskState(key, conn.tasks);
865
958
  // Feed + usage rows are read by humans: bare profile name (RUSH-2709).
@@ -875,8 +968,15 @@ export class BrowserService {
875
968
  });
876
969
  }).catch(() => { });
877
970
  if (conn.forkedFrom && conn.tasks.size === 0) {
878
- conn.cdp.close();
879
- killChrome(conn.pid);
971
+ if (conn.backend === 'bidi') {
972
+ conn.bidi.close();
973
+ if (conn.pid)
974
+ killChrome(conn.pid);
975
+ }
976
+ else if (conn.backend !== 'arc-native') {
977
+ conn.cdp.close();
978
+ killChrome(conn.pid);
979
+ }
880
980
  conn.cleanup?.();
881
981
  this.connections.delete(key);
882
982
  clearProfileRuntime(key);
@@ -908,6 +1008,28 @@ export class BrowserService {
908
1008
  const conn = this.connections.get(key);
909
1009
  if (!conn)
910
1010
  continue;
1011
+ // Native Arc connections: close owned tabs but NEVER kill the Arc process.
1012
+ // The CLI does not own the Arc process — the user started it.
1013
+ if (conn.backend === 'arc-native') {
1014
+ for (const task of conn.tasks.values()) {
1015
+ await this.closeArcNativeTabs(conn, task);
1016
+ }
1017
+ this.connections.delete(key);
1018
+ clearProfileRuntime(key);
1019
+ continue;
1020
+ }
1021
+ // Firefox: close the BiDi socket and kill the Firefox WE launched (pid !=
1022
+ // 0). An attached Firefox the user started (pid 0) is left running — the
1023
+ // CLI does not own that process, same as Arc.
1024
+ if (conn.backend === 'bidi') {
1025
+ conn.bidi.close();
1026
+ if (conn.pid)
1027
+ killChrome(conn.pid);
1028
+ conn.cleanup?.();
1029
+ this.connections.delete(key);
1030
+ clearProfileRuntime(key);
1031
+ continue;
1032
+ }
911
1033
  conn.cdp.close();
912
1034
  killChrome(conn.pid);
913
1035
  conn.cleanup?.();
@@ -934,6 +1056,15 @@ export class BrowserService {
934
1056
  }
935
1057
  async navigateLocked(conn, task, url) {
936
1058
  this.maybeUpdateLabelFromUrl(conn, task, url);
1059
+ // Native Arc backend (PHNX-2399): route through the native driver instead of CDP.
1060
+ if (conn.backend === 'arc-native') {
1061
+ return this.navigateArcNative(conn, task, url);
1062
+ }
1063
+ // Firefox BiDi backend (PHNX-4043).
1064
+ if (conn.backend === 'bidi') {
1065
+ return this.navigateFirefox(conn, task, url);
1066
+ }
1067
+ requireCdp(conn, 'navigate');
937
1068
  // Same-task reopen (PHNX-2399): the URL is already live in a tab this task
938
1069
  // owns → reload THAT tab in place and keep its id, before the create/reuse
939
1070
  // paths below. Chrome and Arc alike; Electron drives one window and never
@@ -1035,6 +1166,13 @@ export class BrowserService {
1035
1166
  return this.runExclusive(`task:${task.profile}:${task.name}`, () => this.tabAddLocked(conn, task, url));
1036
1167
  }
1037
1168
  async tabAddLocked(conn, task, url) {
1169
+ if (conn.backend === 'arc-native') {
1170
+ return this.navigateArcNative(conn, task, url, true);
1171
+ }
1172
+ if (conn.backend === 'bidi') {
1173
+ return this.navigateFirefox(conn, task, url, true);
1174
+ }
1175
+ requireCdp(conn, 'createTab');
1038
1176
  if (conn.electron) {
1039
1177
  throw new Error('Electron apps do not support opening additional tabs');
1040
1178
  }
@@ -1057,12 +1195,38 @@ export class BrowserService {
1057
1195
  async tabFocus(taskId, tabHint) {
1058
1196
  const { conn, task } = await this.findTask(taskId);
1059
1197
  const resolvedTabId = await this.resolveTabHint(conn, task, tabHint);
1198
+ if (conn.backend === 'arc-native') {
1199
+ await this.runExclusive('arc-native:host-app', async () => {
1200
+ const ref = this.requireArcTask(task).tabs[resolvedTabId];
1201
+ if (!ref)
1202
+ throw new Error(`Tab ${resolvedTabId} not found`);
1203
+ await selectArcTab(ref);
1204
+ task.currentTabId = resolvedTabId;
1205
+ await this.saveTaskState(task.profile, conn.tasks);
1206
+ });
1207
+ return { tabId: resolvedTabId };
1208
+ }
1209
+ if (conn.backend === 'bidi') {
1210
+ await bidiActivate(conn.bidi, this.firefoxContext(task, resolvedTabId));
1211
+ task.currentTabId = resolvedTabId;
1212
+ await this.saveTaskState(task.profile, conn.tasks);
1213
+ return { tabId: resolvedTabId };
1214
+ }
1060
1215
  task.currentTabId = resolvedTabId;
1061
1216
  await this.saveTaskState(task.profile, conn.tasks);
1062
1217
  return { tabId: resolvedTabId };
1063
1218
  }
1064
1219
  async tabList(taskId) {
1065
1220
  const { conn, task } = await this.findTask(taskId);
1221
+ if (conn.backend === 'arc-native') {
1222
+ return (await this.listArcTaskTabs(task)).map((tab) => ({ ...tab, current: tab.current === true }));
1223
+ }
1224
+ if (conn.backend === 'bidi') {
1225
+ return (await this.listFirefoxTaskTabs(conn, task)).map((tab) => ({
1226
+ id: tab.id, url: tab.url, title: tab.title, current: tab.current === true,
1227
+ }));
1228
+ }
1229
+ requireCdp(conn, 'enumerate');
1066
1230
  const targets = (await conn.cdp.send('Target.getTargets'));
1067
1231
  const tabs = [];
1068
1232
  for (const [shortId, cdpId] of Object.entries(task.tabs)) {
@@ -1089,6 +1253,13 @@ export class BrowserService {
1089
1253
  if (byPrefix.length > 1) {
1090
1254
  throw new Error(`Ambiguous tab hint "${hint}" — matches ${byPrefix.length} tabs`);
1091
1255
  }
1256
+ // Native Arc addresses owned tabs only by the task's stable short id. URL
1257
+ // and title are display data and must never become fallback identities.
1258
+ if (conn.backend === 'arc-native')
1259
+ throw new Error(`Tab "${hint}" not found`);
1260
+ // Firefox tabs are addressed by the task's stable short id too.
1261
+ if (conn.backend === 'bidi')
1262
+ throw new Error(`Tab "${hint}" not found`);
1092
1263
  // URL substring match
1093
1264
  const targets = (await conn.cdp.send('Target.getTargets'));
1094
1265
  const matches = [];
@@ -1121,12 +1292,20 @@ export class BrowserService {
1121
1292
  async tabs(taskId, profileRef) {
1122
1293
  if (taskId) {
1123
1294
  const { conn, task } = await this.findTask(taskId, profileRef);
1295
+ if (conn.backend === 'arc-native')
1296
+ return this.listArcTaskTabs(task);
1297
+ if (conn.backend === 'bidi')
1298
+ return this.listFirefoxTaskTabs(conn, task);
1124
1299
  return this.getTabsForTask(conn.cdp, task);
1125
1300
  }
1126
1301
  const allTabs = [];
1127
1302
  for (const [, conn] of this.connections) {
1128
1303
  for (const [, task] of conn.tasks) {
1129
- const tabs = await this.getTabsForTask(conn.cdp, task);
1304
+ const tabs = conn.backend === 'arc-native'
1305
+ ? await this.listArcTaskTabs(task)
1306
+ : conn.backend === 'bidi'
1307
+ ? await this.listFirefoxTaskTabs(conn, task)
1308
+ : await this.getTabsForTask(conn.cdp, task);
1130
1309
  allTabs.push(...tabs);
1131
1310
  }
1132
1311
  }
@@ -1134,6 +1313,54 @@ export class BrowserService {
1134
1313
  }
1135
1314
  async tabClose(taskId, tabHint) {
1136
1315
  const { conn, task } = await this.findTask(taskId);
1316
+ if (conn.backend === 'arc-native') {
1317
+ await this.runExclusive('arc-native:host-app', async () => {
1318
+ await this.reconcileArcCreateIntents(conn, task);
1319
+ const native = this.requireArcTask(task);
1320
+ const ids = tabHint === undefined
1321
+ ? Object.keys(native.tabs)
1322
+ : [await this.resolveTabHint(conn, task, tabHint)];
1323
+ for (const shortId of ids) {
1324
+ const ref = native.tabs[shortId];
1325
+ if (ref)
1326
+ await closeArcTab(ref);
1327
+ delete native.tabs[shortId];
1328
+ delete task.tabs[shortId];
1329
+ delete task.refDescriptors?.[shortId];
1330
+ }
1331
+ if (!task.currentTabId || ids.includes(task.currentTabId)) {
1332
+ const remaining = Object.keys(native.tabs);
1333
+ task.currentTabId = remaining.at(-1);
1334
+ }
1335
+ await this.saveTaskState(task.profile, conn.tasks);
1336
+ });
1337
+ return;
1338
+ }
1339
+ if (conn.backend === 'bidi') {
1340
+ const borrowed = new Set(task.borrowedTabs ?? []);
1341
+ const ids = tabHint === undefined
1342
+ ? Object.keys(task.tabs)
1343
+ : [await this.resolveTabHint(conn, task, tabHint)];
1344
+ for (const shortId of ids) {
1345
+ const context = task.tabs[shortId];
1346
+ // A borrowed tab is released, not closed — it outlives this task.
1347
+ if (context && !borrowed.has(shortId)) {
1348
+ try {
1349
+ await bidiCloseTab(conn.bidi, context);
1350
+ }
1351
+ catch { /* already closed */ }
1352
+ }
1353
+ delete task.tabs[shortId];
1354
+ delete task.refDescriptors?.[shortId];
1355
+ task.borrowedTabs = (task.borrowedTabs ?? []).filter((id) => id !== shortId);
1356
+ }
1357
+ if (!task.currentTabId || ids.includes(task.currentTabId)) {
1358
+ task.currentTabId = Object.keys(task.tabs).at(-1);
1359
+ }
1360
+ await this.saveTaskState(task.profile, conn.tasks);
1361
+ return;
1362
+ }
1363
+ requireCdp(conn, 'closeTab');
1137
1364
  if (tabHint !== undefined) {
1138
1365
  const shortId = await this.resolveTabHint(conn, task, tabHint);
1139
1366
  const cdpId = task.tabs[shortId];
@@ -1170,6 +1397,14 @@ export class BrowserService {
1170
1397
  async evaluate(taskId, tabHint, expression) {
1171
1398
  const { conn, task } = await this.findTask(taskId);
1172
1399
  const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
1400
+ // Native Arc backend (PHNX-2399): synchronous JS only, isolated world.
1401
+ if (conn.backend === 'arc-native') {
1402
+ return this.evaluateArcNative(conn, task, shortId, expression);
1403
+ }
1404
+ // Firefox BiDi backend (PHNX-4043): async-capable, isolated realm.
1405
+ if (conn.backend === 'bidi') {
1406
+ return this.evaluateFirefox(conn, task, shortId, expression);
1407
+ }
1173
1408
  const cdpTargetId = this.getCdpTargetId(task, shortId);
1174
1409
  const target = await this.getTarget(conn, cdpTargetId);
1175
1410
  if (!target) {
@@ -1199,6 +1434,17 @@ export class BrowserService {
1199
1434
  }
1200
1435
  async screenshot(taskId, tabHint, outputPath, quality = 'compressed') {
1201
1436
  const { conn, task, key: runtimeKey } = await this.findTask(taskId);
1437
+ // Native Arc backend: screenshots cannot select/activate the user's tab.
1438
+ if (conn.backend === 'arc-native') {
1439
+ throw new ArcNativeCapabilityError('screenshot', 'Screenshot is unavailable for native Arc automation. ' +
1440
+ 'Screenshots require activating the tab, which would disrupt user selection. ' +
1441
+ 'This task still belongs to the Arc profile. ' +
1442
+ 'Use a Chromium-family browser for screenshot capability.');
1443
+ }
1444
+ // Firefox BiDi backend (PHNX-4043): captureScreenshot on the context.
1445
+ if (conn.backend === 'bidi') {
1446
+ return this.screenshotFirefox(conn, task, runtimeKey, tabHint, outputPath, quality);
1447
+ }
1202
1448
  const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
1203
1449
  const cdpTargetId = this.getCdpTargetId(task, shortId);
1204
1450
  const target = await this.getTarget(conn, cdpTargetId);
@@ -1259,6 +1505,7 @@ export class BrowserService {
1259
1505
  */
1260
1506
  async printToPdf(taskId, tabHint, outputPath) {
1261
1507
  const { conn, task, key: runtimeKey } = await this.findTask(taskId);
1508
+ requireCdp(conn, 'pdf');
1262
1509
  const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
1263
1510
  const cdpTargetId = this.getCdpTargetId(task, shortId);
1264
1511
  const target = await this.getTarget(conn, cdpTargetId);
@@ -1287,6 +1534,7 @@ export class BrowserService {
1287
1534
  throw new Error(`Task "${taskId}" is already recording. Call record stop first.`);
1288
1535
  }
1289
1536
  const { conn, task, key: runtimeKey } = await this.findTask(taskId);
1537
+ requireCdp(conn, 'screenshot');
1290
1538
  const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
1291
1539
  const cdpTargetId = this.getCdpTargetId(task, shortId);
1292
1540
  const target = await this.getTarget(conn, cdpTargetId);
@@ -1488,6 +1736,9 @@ export class BrowserService {
1488
1736
  return { path: outputPath, fps, durationCapSec: durationSec, maxMb };
1489
1737
  }
1490
1738
  async recordStop(taskId, reason = 'manual') {
1739
+ const activeTask = await this.findTask(taskId).catch(() => undefined);
1740
+ if (activeTask)
1741
+ requireCdp(activeTask.conn, 'screenshot');
1491
1742
  const rec = this.recordings.get(taskId);
1492
1743
  if (!rec) {
1493
1744
  throw new Error(`Task "${taskId}" is not currently recording`);
@@ -1570,6 +1821,13 @@ export class BrowserService {
1570
1821
  return { path: rec.outputPath, bytes, durationMs, reason };
1571
1822
  }
1572
1823
  async recordStatus(taskId) {
1824
+ // The idle reaper calls this while deciding whether a task is stale. Do
1825
+ // not route an already-live task through findTask(), which stamps it as
1826
+ // active and defeats idle detection. A cold daemon may still rehydrate for
1827
+ // an explicit user query.
1828
+ const live = this.findTaskByHandle(taskId) ?? await this.findTask(taskId).catch(() => undefined);
1829
+ if (live)
1830
+ requireCdp(live.conn, 'screenshot');
1573
1831
  const rec = this.recordings.get(taskId);
1574
1832
  if (!rec)
1575
1833
  return { recording: false };
@@ -1578,6 +1836,21 @@ export class BrowserService {
1578
1836
  async refs(taskId, tabHint, opts = {}) {
1579
1837
  const { conn, task } = await this.findTask(taskId);
1580
1838
  const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
1839
+ if (conn.backend === 'arc-native') {
1840
+ const result = parseArcRefsResult(await this.evaluateArcNative(conn, task, shortId, arcRefsExpression(opts)), opts);
1841
+ this.cacheRefDescriptors(task, shortId, result.nodeMap, result.opts);
1842
+ await this.saveTaskState(task.profile, conn.tasks);
1843
+ return result;
1844
+ }
1845
+ // Firefox BiDi (PHNX-4043): the same DOM-derived accessibility listing Arc
1846
+ // uses, evaluated over BiDi. selector-based refs, no CDP accessibility tree.
1847
+ if (conn.backend === 'bidi') {
1848
+ const result = parseArcRefsResult(await this.evaluateFirefox(conn, task, shortId, arcRefsExpression(opts)), opts);
1849
+ this.cacheRefDescriptors(task, shortId, result.nodeMap, result.opts);
1850
+ await this.saveTaskState(task.profile, conn.tasks);
1851
+ return result;
1852
+ }
1853
+ requireCdp(conn, 'enumerate');
1581
1854
  const cdpTargetId = this.getCdpTargetId(task, shortId);
1582
1855
  const target = await this.getTarget(conn, cdpTargetId);
1583
1856
  if (!target)
@@ -1614,7 +1887,7 @@ export class BrowserService {
1614
1887
  const stillMatches = fresh !== undefined &&
1615
1888
  fresh.role === cached.role &&
1616
1889
  fresh.name === cached.name &&
1617
- fresh.backendNodeId !== undefined;
1890
+ (fresh.backendNodeId !== undefined || fresh.selector !== undefined);
1618
1891
  if (stillMatches)
1619
1892
  return { targetRef: ref };
1620
1893
  const newRef = healRef(cached, nodeMap);
@@ -1632,6 +1905,31 @@ export class BrowserService {
1632
1905
  async click(taskId, ref, tabHint) {
1633
1906
  const { conn, task } = await this.findTask(taskId);
1634
1907
  const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
1908
+ if (conn.backend === 'arc-native') {
1909
+ const snapshot = task.refDescriptors?.[shortId];
1910
+ const buildOpts = snapshot?.opts ?? { interactive: true, limit: 500 };
1911
+ const { nodeMap } = parseArcRefsResult(await this.evaluateArcNative(conn, task, shortId, arcRefsExpression(buildOpts)), buildOpts);
1912
+ const { targetRef, healed } = this.resolveHealedRef(snapshot, nodeMap, ref);
1913
+ const selector = nodeMap.get(targetRef)?.selector;
1914
+ if (!selector)
1915
+ throw new Error(`Ref ${ref} has no DOM selector`);
1916
+ await this.evaluateArcNative(conn, task, shortId, arcClickExpression(selector));
1917
+ return healed ? { healed } : {};
1918
+ }
1919
+ if (conn.backend === 'bidi') {
1920
+ const snapshot = task.refDescriptors?.[shortId];
1921
+ const buildOpts = snapshot?.opts ?? { interactive: true, limit: 500 };
1922
+ const { nodeMap } = parseArcRefsResult(await this.evaluateFirefox(conn, task, shortId, arcRefsExpression(buildOpts)), buildOpts);
1923
+ const { targetRef, healed } = this.resolveHealedRef(snapshot, nodeMap, ref);
1924
+ const selector = nodeMap.get(targetRef)?.selector;
1925
+ if (!selector)
1926
+ throw new Error(`Ref ${ref} has no DOM selector`);
1927
+ const coords = await this.firefoxRefCoords(conn, task, shortId, selector);
1928
+ const context = this.firefoxContext(task, shortId);
1929
+ await bidiClickAt(conn.bidi, context, coords.x, coords.y);
1930
+ return healed ? { healed } : {};
1931
+ }
1932
+ requireCdp(conn, 'click');
1635
1933
  const cdpTargetId = this.getCdpTargetId(task, shortId);
1636
1934
  const target = await this.getTarget(conn, cdpTargetId);
1637
1935
  if (!target)
@@ -1663,6 +1961,7 @@ export class BrowserService {
1663
1961
  */
1664
1962
  async clickAt(taskId, x, y, tabHint) {
1665
1963
  const { conn, task } = await this.findTask(taskId);
1964
+ requireCdp(conn, 'trustedInput');
1666
1965
  const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
1667
1966
  const cdpTargetId = this.getCdpTargetId(task, shortId);
1668
1967
  const target = await this.getTarget(conn, cdpTargetId);
@@ -1674,6 +1973,29 @@ export class BrowserService {
1674
1973
  async type(taskId, ref, text, tabHint, clear) {
1675
1974
  const { conn, task } = await this.findTask(taskId);
1676
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 } = 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, arcFillExpression(selector, text, clear ?? false));
1985
+ return;
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 } = 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
+ await this.evaluateFirefox(conn, task, shortId, arcFillExpression(selector, text, clear ?? false));
1996
+ return;
1997
+ }
1998
+ requireCdp(conn, 'type');
1677
1999
  const cdpTargetId = this.getCdpTargetId(task, shortId);
1678
2000
  const target = await this.getTarget(conn, cdpTargetId);
1679
2001
  if (!target)
@@ -1701,6 +2023,7 @@ export class BrowserService {
1701
2023
  }
1702
2024
  async press(taskId, key, tabHint) {
1703
2025
  const { conn, task } = await this.findTask(taskId);
2026
+ requireCdp(conn, 'trustedInput');
1704
2027
  const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
1705
2028
  const cdpTargetId = this.getCdpTargetId(task, shortId);
1706
2029
  const target = await this.getTarget(conn, cdpTargetId);
@@ -1711,6 +2034,7 @@ export class BrowserService {
1711
2034
  }
1712
2035
  async hover(taskId, ref, tabHint) {
1713
2036
  const { conn, task } = await this.findTask(taskId);
2037
+ requireCdp(conn, 'trustedInput');
1714
2038
  const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
1715
2039
  const cdpTargetId = this.getCdpTargetId(task, shortId);
1716
2040
  const target = await this.getTarget(conn, cdpTargetId);
@@ -1724,6 +2048,17 @@ export class BrowserService {
1724
2048
  async scroll(taskId, deltaX, deltaY, atX, atY, tabHint) {
1725
2049
  const { conn, task } = await this.findTask(taskId);
1726
2050
  const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
2051
+ if (conn.backend === 'arc-native') {
2052
+ if (atX !== undefined || atY !== undefined)
2053
+ throw new ArcNativeCapabilityError('trustedInput');
2054
+ await this.evaluateArcNative(conn, task, shortId, arcScrollExpression(deltaX, deltaY));
2055
+ return;
2056
+ }
2057
+ if (conn.backend === 'bidi') {
2058
+ await this.evaluateFirefox(conn, task, shortId, arcScrollExpression(deltaX, deltaY));
2059
+ return;
2060
+ }
2061
+ requireCdp(conn, 'scroll');
1727
2062
  const cdpTargetId = this.getCdpTargetId(task, shortId);
1728
2063
  const target = await this.getTarget(conn, cdpTargetId);
1729
2064
  if (!target)
@@ -1733,6 +2068,7 @@ export class BrowserService {
1733
2068
  }
1734
2069
  async upload(taskId, files, options) {
1735
2070
  const { conn, task } = await this.findTask(taskId);
2071
+ requireCdp(conn, 'upload');
1736
2072
  const shortId = options.tabHint
1737
2073
  ? await this.resolveTabHint(conn, task, options.tabHint)
1738
2074
  : this.resolveCurrentTab(task);
@@ -1883,6 +2219,7 @@ export class BrowserService {
1883
2219
  // ─── Viewport & Device Emulation ──────────────────────────────────────────────
1884
2220
  async setViewport(taskId, width, height, options = {}) {
1885
2221
  const { conn, task } = await this.findTask(taskId);
2222
+ requireCdp(conn, 'viewport');
1886
2223
  const shortId = options.tabHint ? await this.resolveTabHint(conn, task, options.tabHint) : this.resolveCurrentTab(task);
1887
2224
  const cdpTargetId = this.getCdpTargetId(task, shortId);
1888
2225
  const target = await this.getTarget(conn, cdpTargetId);
@@ -1963,6 +2300,7 @@ export class BrowserService {
1963
2300
  }
1964
2301
  async getConsoleLogs(taskId, options = {}) {
1965
2302
  const { conn, task } = await this.findTask(taskId);
2303
+ requireCdp(conn, 'consoleCapture');
1966
2304
  const shortId = options.tabHint ? await this.resolveTabHint(conn, task, options.tabHint) : this.resolveCurrentTab(task);
1967
2305
  const cdpTargetId = this.getCdpTargetId(task, shortId);
1968
2306
  const target = await this.getTarget(conn, cdpTargetId);
@@ -1981,6 +2319,7 @@ export class BrowserService {
1981
2319
  }
1982
2320
  async getErrors(taskId, options = {}) {
1983
2321
  const { conn, task } = await this.findTask(taskId);
2322
+ requireCdp(conn, 'consoleCapture');
1984
2323
  const shortId = options.tabHint ? await this.resolveTabHint(conn, task, options.tabHint) : this.resolveCurrentTab(task);
1985
2324
  const cdpTargetId = this.getCdpTargetId(task, shortId);
1986
2325
  const target = await this.getTarget(conn, cdpTargetId);
@@ -2033,6 +2372,7 @@ export class BrowserService {
2033
2372
  }
2034
2373
  async getNetworkRequests(taskId, options = {}) {
2035
2374
  const { conn, task } = await this.findTask(taskId);
2375
+ requireCdp(conn, 'networkCapture');
2036
2376
  const shortId = options.tabHint ? await this.resolveTabHint(conn, task, options.tabHint) : this.resolveCurrentTab(task);
2037
2377
  const cdpTargetId = this.getCdpTargetId(task, shortId);
2038
2378
  const target = await this.getTarget(conn, cdpTargetId);
@@ -2052,6 +2392,7 @@ export class BrowserService {
2052
2392
  }
2053
2393
  async getResponseBody(taskId, urlPattern, options = {}) {
2054
2394
  const { conn, task } = await this.findTask(taskId);
2395
+ requireCdp(conn, 'networkCapture');
2055
2396
  const shortId = options.tabHint ? await this.resolveTabHint(conn, task, options.tabHint) : this.resolveCurrentTab(task);
2056
2397
  const cdpTargetId = this.getCdpTargetId(task, shortId);
2057
2398
  const target = await this.getTarget(conn, cdpTargetId);
@@ -2125,11 +2466,12 @@ export class BrowserService {
2125
2466
  // ─── Wait Conditions ─────────────────────────────────────────────────────────
2126
2467
  async wait(taskId, type, value, options = {}) {
2127
2468
  const timeout = options.timeout ?? 30000;
2469
+ const { conn, task } = await this.findTask(taskId);
2470
+ requireCdp(conn, 'asyncEvaluate');
2128
2471
  if (type === 'time') {
2129
2472
  await new Promise((r) => setTimeout(r, typeof value === 'number' ? value : parseInt(value, 10)));
2130
2473
  return;
2131
2474
  }
2132
- const { conn, task } = await this.findTask(taskId);
2133
2475
  const shortId = options.tabHint ? await this.resolveTabHint(conn, task, options.tabHint) : this.resolveCurrentTab(task);
2134
2476
  const cdpTargetId = this.getCdpTargetId(task, shortId);
2135
2477
  const target = await this.getTarget(conn, cdpTargetId);
@@ -2202,6 +2544,7 @@ export class BrowserService {
2202
2544
  }
2203
2545
  async setDownloadPath(taskId, downloadPath, tabHint) {
2204
2546
  const { conn, task, key: runtimeKey } = await this.findTask(taskId);
2547
+ requireCdp(conn, 'download');
2205
2548
  const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
2206
2549
  const cdpTargetId = this.getCdpTargetId(task, shortId);
2207
2550
  const target = await this.getTarget(conn, cdpTargetId);
@@ -2230,6 +2573,8 @@ export class BrowserService {
2230
2573
  return resolvedPath;
2231
2574
  }
2232
2575
  async waitForDownload(taskId, timeout = 60000) {
2576
+ const { conn } = await this.findTask(taskId);
2577
+ requireCdp(conn, 'download');
2233
2578
  const start = Date.now();
2234
2579
  while (Date.now() - start < timeout) {
2235
2580
  const dl = this.pendingDownloads.get(taskId);
@@ -2284,7 +2629,10 @@ export class BrowserService {
2284
2629
  this.recordings.delete(taskId);
2285
2630
  }
2286
2631
  for (const [, conn] of this.connections) {
2287
- conn.cdp.close();
2632
+ if (conn.backend === 'bidi')
2633
+ conn.bidi.close();
2634
+ else if (conn.backend !== 'arc-native')
2635
+ conn.cdp.close();
2288
2636
  conn.cleanup?.();
2289
2637
  }
2290
2638
  this.connections.clear();
@@ -2310,6 +2658,7 @@ export class BrowserService {
2310
2658
  await cdp.connect(wsUrl);
2311
2659
  await this.enableDomains(cdp);
2312
2660
  const connection = {
2661
+ backend: 'cdp',
2313
2662
  cdp,
2314
2663
  port,
2315
2664
  pid,
@@ -2338,6 +2687,11 @@ export class BrowserService {
2338
2687
  async connectProfile(effectiveProfile, target, key, opts = {}) {
2339
2688
  const existingInfo = getRunningChromeInfo(key);
2340
2689
  const tunnelled = opts.persistRemote || target.startsWith('ssh:');
2690
+ // Native Arc and Firefox BiDi speak their own transport, not CDP — never
2691
+ // route them through the CDP reuse branch (which would probe /json/version
2692
+ // and wipe the runtime meta the Firefox driver wrote). connectEndpoint owns
2693
+ // their attach-or-launch decision.
2694
+ const nativeTransport = target.startsWith('arc-native:') || target.startsWith('firefox-bidi:');
2341
2695
  if (tunnelled) {
2342
2696
  // A leftover local chrome-data / pid file under this key is the
2343
2697
  // pre-T2 logged-out browser on THIS box. Attaching localhost CDP
@@ -2348,7 +2702,7 @@ export class BrowserService {
2348
2702
  clearProfileRuntime(key);
2349
2703
  }
2350
2704
  }
2351
- else if (existingInfo) {
2705
+ else if (existingInfo && !nativeTransport) {
2352
2706
  try {
2353
2707
  const { wsUrl, browser } = await discoverBrowserWsUrl(existingInfo.port, 'localhost', effectiveProfile.name);
2354
2708
  verifyBrowserIdentity(browser, effectiveProfile.browser, existingInfo.port);
@@ -2357,6 +2711,7 @@ export class BrowserService {
2357
2711
  await this.enableDomains(cdp);
2358
2712
  const tasks = this.loadTaskState(key);
2359
2713
  return {
2714
+ backend: 'cdp',
2360
2715
  cdp,
2361
2716
  port: existingInfo.port,
2362
2717
  pid: existingInfo.pid,
@@ -2389,6 +2744,7 @@ export class BrowserService {
2389
2744
  const conn = await connectLocal(endpoint, profile, key);
2390
2745
  await this.enableDomains(conn.cdp);
2391
2746
  return {
2747
+ backend: 'cdp',
2392
2748
  cdp: conn.cdp,
2393
2749
  port: conn.port,
2394
2750
  pid: conn.pid,
@@ -2405,6 +2761,7 @@ export class BrowserService {
2405
2761
  });
2406
2762
  await this.enableDomains(conn.cdp);
2407
2763
  return {
2764
+ backend: 'cdp',
2408
2765
  cdp: conn.cdp,
2409
2766
  port: conn.port,
2410
2767
  pid: conn.pid,
@@ -2427,6 +2784,7 @@ export class BrowserService {
2427
2784
  }
2428
2785
  await this.enableDomains(cdp);
2429
2786
  return {
2787
+ backend: 'cdp',
2430
2788
  cdp,
2431
2789
  port: 0,
2432
2790
  pid: 0,
@@ -2445,6 +2803,7 @@ export class BrowserService {
2445
2803
  await cdp.connect(wsUrl);
2446
2804
  await this.enableDomains(cdp);
2447
2805
  return {
2806
+ backend: 'cdp',
2448
2807
  cdp,
2449
2808
  port,
2450
2809
  pid: 0,
@@ -2455,6 +2814,65 @@ export class BrowserService {
2455
2814
  sessionCache: new Map(),
2456
2815
  };
2457
2816
  }
2817
+ // Firefox BiDi backend (PHNX-4043): `firefox-bidi://127.0.0.1:<port>` connects
2818
+ // over a WebDriver BiDi WebSocket. The driver attaches to a Firefox already
2819
+ // serving the port, else launches one headless bound to the pinned profile
2820
+ // directory, else fails loud (Firefox is single-instance per profile).
2821
+ if (url.protocol === 'firefox-bidi:') {
2822
+ if (!profile.firefox) {
2823
+ throw new Error(`Firefox profile "${profile.name}" has no discovered profile metadata. ` +
2824
+ `Re-select it with: agents browser use ${profile.name}`);
2825
+ }
2826
+ const profileDir = profile.userDataDir;
2827
+ if (!profileDir) {
2828
+ throw new Error(`Firefox profile "${profile.name}" has no profile directory.`);
2829
+ }
2830
+ const port = parseInt(url.port || '9600', 10);
2831
+ const conn = await connectFirefox(profile, key, port, {
2832
+ profileDir,
2833
+ headless: profile.chrome?.headless,
2834
+ });
2835
+ return {
2836
+ backend: 'bidi',
2837
+ browserType: 'firefox',
2838
+ bidi: conn.bidi,
2839
+ firefoxProfile: profile.firefox,
2840
+ sessionId: conn.sessionId,
2841
+ profileDir,
2842
+ port: conn.port,
2843
+ pid: conn.pid,
2844
+ electron: false,
2845
+ targetFilter: profile.targetFilter,
2846
+ tasks: this.loadTaskState(key),
2847
+ sessionCache: new Map(),
2848
+ };
2849
+ }
2850
+ // Native Arc backend (PHNX-2399): `arc-native:` protocol connects through
2851
+ // Apple Events instead of CDP. No debugging port, no browser launch.
2852
+ if (url.protocol === 'arc-native:') {
2853
+ if (process.platform !== 'darwin') {
2854
+ throw new Error('Native Arc automation is only available on macOS');
2855
+ }
2856
+ if (!(await isArcRunning())) {
2857
+ throw new Error('Arc is not running. Start Arc normally, then retry.\n' +
2858
+ 'Native Arc automation drives your existing browser — it does not launch one.');
2859
+ }
2860
+ if (!profile.arc) {
2861
+ throw new Error(`Native Arc profile "${profile.name}" has no stable native profile metadata. ` +
2862
+ `Re-select it with: agents browser use arc-<profile-id>`);
2863
+ }
2864
+ return {
2865
+ port: 0,
2866
+ pid: 0,
2867
+ electron: false,
2868
+ browserType: 'arc',
2869
+ targetFilter: profile.targetFilter,
2870
+ tasks: this.loadTaskState(key),
2871
+ sessionCache: new Map(),
2872
+ backend: 'arc-native',
2873
+ arcProfile: profile.arc,
2874
+ };
2875
+ }
2458
2876
  return null;
2459
2877
  }
2460
2878
  async enableDomains(cdp) {
@@ -2471,6 +2889,8 @@ export class BrowserService {
2471
2889
  if (conn.browserType === 'arc') {
2472
2890
  throw arcNotDrivableError(conn.profile);
2473
2891
  }
2892
+ // Firefox opens tabs through the BiDi driver, never Target.createTarget.
2893
+ requireCdp(conn, 'createTab');
2474
2894
  return (await conn.cdp.send('Target.createTarget', params));
2475
2895
  }
2476
2896
  async getOrCreateWindow(conn) {
@@ -2660,6 +3080,7 @@ export class BrowserService {
2660
3080
  const matches = this.listTasksForCaller(caller);
2661
3081
  if (matches.length === 1) {
2662
3082
  const m = matches[0];
3083
+ await this.prepareTask(m.conn, m.task);
2663
3084
  await this.touchTask(m.conn, m.task);
2664
3085
  return { conn: m.conn, task: m.task, key: m.key, created: false };
2665
3086
  }
@@ -2790,6 +3211,18 @@ export class BrowserService {
2790
3211
  // `recordBrowserSession` call in `start` uses for the same reason.
2791
3212
  }
2792
3213
  }
3214
+ async prepareTask(conn, task) {
3215
+ if (conn.backend !== 'arc-native')
3216
+ return;
3217
+ await this.runExclusive('arc-native:host-app', async () => {
3218
+ await this.reconcileArcCreateIntents(conn, task);
3219
+ const native = this.requireArcTask(task);
3220
+ if (native.profileId !== conn.arcProfile.profileId) {
3221
+ throw new Error(`Arc task profile ${JSON.stringify(native.profileId)} does not match connected profile ` +
3222
+ `${JSON.stringify(conn.arcProfile.profileId)}.`);
3223
+ }
3224
+ });
3225
+ }
2793
3226
  async findTask(taskId, profileRef) {
2794
3227
  if (profileRef) {
2795
3228
  // Accept bare profile names against composite connection keys.
@@ -2801,6 +3234,7 @@ export class BrowserService {
2801
3234
  const conn = this.connections.get(key);
2802
3235
  const task = this.lookupTaskOnConn(conn, taskId);
2803
3236
  if (task) {
3237
+ await this.prepareTask(conn, task);
2804
3238
  await this.touchTask(conn, task);
2805
3239
  return { conn, task, key: conn.key ?? key };
2806
3240
  }
@@ -2808,6 +3242,7 @@ export class BrowserService {
2808
3242
  // RAM miss → rehydrate from disk for this profile.
2809
3243
  const rehydrated = await this.rehydrateTaskFromDisk(taskId, profileRef);
2810
3244
  if (rehydrated) {
3245
+ await this.prepareTask(rehydrated.conn, rehydrated.task);
2811
3246
  await this.touchTask(rehydrated.conn, rehydrated.task);
2812
3247
  return rehydrated;
2813
3248
  }
@@ -2816,6 +3251,7 @@ export class BrowserService {
2816
3251
  for (const [key, conn] of this.connections) {
2817
3252
  const task = this.lookupTaskOnConn(conn, taskId);
2818
3253
  if (task) {
3254
+ await this.prepareTask(conn, task);
2819
3255
  await this.touchTask(conn, task);
2820
3256
  return { conn, task, key: conn.key ?? key };
2821
3257
  }
@@ -2824,6 +3260,7 @@ export class BrowserService {
2824
3260
  // meta.json still has the browser pid/port. Reconnect and adopt.
2825
3261
  const rehydrated = await this.rehydrateTaskFromDisk(taskId);
2826
3262
  if (rehydrated) {
3263
+ await this.prepareTask(rehydrated.conn, rehydrated.task);
2827
3264
  await this.touchTask(rehydrated.conn, rehydrated.task);
2828
3265
  return rehydrated;
2829
3266
  }
@@ -2861,6 +3298,71 @@ export class BrowserService {
2861
3298
  catch {
2862
3299
  return null;
2863
3300
  }
3301
+ // Native Arc endpoints (PHNX-2399): reconnect via the native driver if Arc
3302
+ // is still running. No port, no CDP — just verify Arc is alive and restore
3303
+ // the task state.
3304
+ if (resolved.target.startsWith('arc-native:')) {
3305
+ if (process.platform !== 'darwin' || !(await isArcRunning()))
3306
+ return null;
3307
+ const tasks = this.loadTaskState(key);
3308
+ for (const [k, t] of diskTasks) {
3309
+ if (!tasks.has(k))
3310
+ tasks.set(k, t);
3311
+ }
3312
+ if (!profile.arc)
3313
+ return null;
3314
+ return {
3315
+ port: 0,
3316
+ pid: 0,
3317
+ electron: false,
3318
+ browserType: 'arc',
3319
+ targetFilter: profile.targetFilter,
3320
+ key,
3321
+ profile: bare,
3322
+ tasks,
3323
+ sessionCache: new Map(),
3324
+ backend: 'arc-native',
3325
+ arcProfile: profile.arc,
3326
+ };
3327
+ }
3328
+ // Firefox BiDi endpoints (PHNX-4043): a daemon restart dropped the BiDi
3329
+ // socket, but the Firefox we launched is still serving the port — reattach
3330
+ // by opening a fresh session, then merge the disk tasks. connectFirefox
3331
+ // attaches (never launches) when the port is already served. A failure
3332
+ // returns null like the CDP path, so the caller falls through to disk
3333
+ // reconcile instead of crashing.
3334
+ if (resolved.target.startsWith('firefox-bidi:')) {
3335
+ if (!profile.firefox || !profile.userDataDir)
3336
+ return null;
3337
+ const port = parseEndpointUrl(resolved.target)?.port ?? 9600;
3338
+ try {
3339
+ const ff = await connectFirefox(profile, key, port, { profileDir: profile.userDataDir });
3340
+ const tasks = this.loadTaskState(key);
3341
+ for (const [k, t] of diskTasks) {
3342
+ if (!tasks.has(k))
3343
+ tasks.set(k, t);
3344
+ }
3345
+ return {
3346
+ backend: 'bidi',
3347
+ browserType: 'firefox',
3348
+ bidi: ff.bidi,
3349
+ firefoxProfile: profile.firefox,
3350
+ sessionId: ff.sessionId,
3351
+ profileDir: profile.userDataDir,
3352
+ port: ff.port,
3353
+ pid: ff.pid,
3354
+ electron: false,
3355
+ targetFilter: profile.targetFilter,
3356
+ key,
3357
+ profile: bare,
3358
+ tasks,
3359
+ sessionCache: new Map(),
3360
+ };
3361
+ }
3362
+ catch {
3363
+ return null;
3364
+ }
3365
+ }
2864
3366
  const existingInfo = getRunningChromeInfo(key);
2865
3367
  const parsed = parseEndpointUrl(resolved.target);
2866
3368
  const port = existingInfo?.port ?? parsed?.port;
@@ -2886,6 +3388,7 @@ export class BrowserService {
2886
3388
  tasks.set(k, t);
2887
3389
  }
2888
3390
  return {
3391
+ backend: 'cdp',
2889
3392
  cdp: conn.cdp,
2890
3393
  port: conn.port,
2891
3394
  pid: conn.pid,
@@ -2918,6 +3421,7 @@ export class BrowserService {
2918
3421
  tasks.set(k, t);
2919
3422
  }
2920
3423
  const conn = {
3424
+ backend: 'cdp',
2921
3425
  cdp,
2922
3426
  port,
2923
3427
  pid: existingInfo?.pid ?? 0,
@@ -2974,11 +3478,19 @@ export class BrowserService {
2974
3478
  }
2975
3479
  else {
2976
3480
  // Shared/borrowed tunnel (or a local connection with no tunnel): close
2977
- // only our own CDP client; killing the tunnel would break the winner.
2978
- try {
2979
- conn.cdp.close();
3481
+ // only our own client; killing the tunnel would break the winner.
3482
+ if (conn.backend === 'bidi') {
3483
+ try {
3484
+ conn.bidi.close();
3485
+ }
3486
+ catch { /* best effort */ }
3487
+ }
3488
+ else if (conn.backend !== 'arc-native') {
3489
+ try {
3490
+ conn.cdp.close();
3491
+ }
3492
+ catch { /* best effort */ }
2980
3493
  }
2981
- catch { /* best effort */ }
2982
3494
  }
2983
3495
  return existing;
2984
3496
  }
@@ -3019,7 +3531,8 @@ export class BrowserService {
3019
3531
  if (registered !== conn)
3020
3532
  continue;
3021
3533
  try {
3022
- await this.applyDefaultDownloadBehavior(conn, key);
3534
+ if (conn.backend !== 'arc-native' && conn.backend !== 'bidi')
3535
+ await this.applyDefaultDownloadBehavior(conn, key);
3023
3536
  }
3024
3537
  catch {
3025
3538
  // Non-fatal for rehydrate.
@@ -3080,7 +3593,8 @@ export class BrowserService {
3080
3593
  conn = this.registerRehydratedConnection(key, attached);
3081
3594
  if (conn === attached) {
3082
3595
  try {
3083
- await this.applyDefaultDownloadBehavior(conn, key);
3596
+ if (conn.backend !== 'arc-native' && conn.backend !== 'bidi')
3597
+ await this.applyDefaultDownloadBehavior(conn, key);
3084
3598
  }
3085
3599
  catch {
3086
3600
  // Non-fatal.
@@ -3117,6 +3631,80 @@ export class BrowserService {
3117
3631
  const conn = this.connections.get(key);
3118
3632
  if (!conn)
3119
3633
  return null;
3634
+ if (conn.backend === 'arc-native') {
3635
+ const tasks = [];
3636
+ for (const task of conn.tasks.values()) {
3637
+ const tabs = await this.listArcTaskTabs(task);
3638
+ const domains = tabs.flatMap((tab) => {
3639
+ try {
3640
+ const domain = new URL(tab.url).hostname.replace(/^www\./, '');
3641
+ return domain && domain !== 'blank' ? [domain] : [];
3642
+ }
3643
+ catch {
3644
+ return [];
3645
+ }
3646
+ });
3647
+ tasks.push({
3648
+ id: task.id,
3649
+ name: task.name,
3650
+ label: task.label ?? task.name,
3651
+ tabCount: Object.keys(task.tabs).length,
3652
+ currentTabId: task.currentTabId,
3653
+ createdAt: task.createdAt,
3654
+ tabs: tabs.length ? tabs : undefined,
3655
+ domains: [...new Set(domains)],
3656
+ });
3657
+ }
3658
+ const parsed = parseConnectionKey(key);
3659
+ return {
3660
+ name: conn.profile ?? parsed.profile,
3661
+ endpoint: parsed.endpoint,
3662
+ key,
3663
+ running: await isArcRunning(),
3664
+ port: 0,
3665
+ pid: 0,
3666
+ tasks,
3667
+ };
3668
+ }
3669
+ if (conn.backend === 'bidi') {
3670
+ const tasks = [];
3671
+ for (const task of conn.tasks.values()) {
3672
+ let tabs = [];
3673
+ try {
3674
+ tabs = await this.listFirefoxTaskTabs(conn, task);
3675
+ }
3676
+ catch { /* Firefox down */ }
3677
+ const domains = tabs.flatMap((tab) => {
3678
+ try {
3679
+ const domain = new URL(tab.url).hostname.replace(/^www\./, '');
3680
+ return domain && domain !== 'blank' ? [domain] : [];
3681
+ }
3682
+ catch {
3683
+ return [];
3684
+ }
3685
+ });
3686
+ tasks.push({
3687
+ id: task.id,
3688
+ name: task.name,
3689
+ label: task.label ?? task.name,
3690
+ tabCount: Object.keys(task.tabs).length,
3691
+ currentTabId: task.currentTabId,
3692
+ createdAt: task.createdAt,
3693
+ tabs: tabs.length ? tabs.map((t) => ({ id: t.id, url: t.url, title: t.title, current: t.current })) : undefined,
3694
+ domains: domains.length ? [...new Set(domains)] : undefined,
3695
+ });
3696
+ }
3697
+ const parsed = parseConnectionKey(key);
3698
+ return {
3699
+ name: conn.profile ?? parsed.profile,
3700
+ endpoint: parsed.endpoint,
3701
+ key,
3702
+ running: conn.bidi.isOpen,
3703
+ port: conn.port,
3704
+ pid: conn.pid,
3705
+ tasks,
3706
+ };
3707
+ }
3120
3708
  // Fetch all targets once for efficiency
3121
3709
  let targets = [];
3122
3710
  try {
@@ -3339,10 +3927,18 @@ export class BrowserService {
3339
3927
  return undefined;
3340
3928
  if (await isConnHealthy(conn))
3341
3929
  return conn;
3342
- try {
3343
- conn.cdp.close();
3930
+ if (conn.backend === 'bidi') {
3931
+ try {
3932
+ conn.bidi.close();
3933
+ }
3934
+ catch { /* already closed */ }
3935
+ }
3936
+ else if (conn.backend !== 'arc-native') {
3937
+ try {
3938
+ conn.cdp.close();
3939
+ }
3940
+ catch { /* already closed */ }
3344
3941
  }
3345
- catch { /* already closed */ }
3346
3942
  conn.cleanup?.();
3347
3943
  this.connections.delete(key);
3348
3944
  return undefined;
@@ -3373,13 +3969,410 @@ export class BrowserService {
3373
3969
  }
3374
3970
  return undefined;
3375
3971
  }
3972
+ // ---------------------------------------------------------------------------
3973
+ // Native Arc backend helpers (PHNX-2399)
3974
+ // ---------------------------------------------------------------------------
3975
+ /**
3976
+ * Bind a task to the profile's Space in the visible Arc window that holds it.
3977
+ * The Space is the profile (PHNX-2399); only its live window id is looked up.
3978
+ */
3979
+ async resolveArcSpace(profile) {
3980
+ const live = (await enumerateArcSpaces()).filter((space) => space.spaceId === profile.spaceId);
3981
+ if (live.length !== 1) {
3982
+ throw new Error(live.length === 0
3983
+ ? `Arc Space ${JSON.stringify(profile.spaceTitle)} (${profile.spaceId}) is not open in a visible Arc window.`
3984
+ : `Arc Space ${JSON.stringify(profile.spaceTitle)} is open in more than one window; refusing an ambiguous native address.`);
3985
+ }
3986
+ return {
3987
+ windowId: live[0].windowId,
3988
+ spaceId: live[0].spaceId,
3989
+ spaceTitle: profile.spaceTitle,
3990
+ };
3991
+ }
3992
+ requireArcTask(task) {
3993
+ const native = task.arcNative;
3994
+ if (!native || typeof native.profileId !== 'string' || typeof native.windowId !== 'string' ||
3995
+ typeof native.spaceId !== 'string' || !native.tabs || typeof native.tabs !== 'object') {
3996
+ throw new Error(`Task ${JSON.stringify(task.name)} has no valid durable Arc identity.`);
3997
+ }
3998
+ for (const [shortId, ref] of Object.entries(native.tabs)) {
3999
+ if (!ref || typeof ref.windowId !== 'string' || typeof ref.spaceId !== 'string' ||
4000
+ typeof ref.tabId !== 'string' || ref.windowId !== native.windowId || ref.spaceId !== native.spaceId) {
4001
+ throw new Error(`Owned Arc tab ${JSON.stringify(shortId)} has no stable id in its original window/Space.`);
4002
+ }
4003
+ }
4004
+ for (const [shortId, intent] of Object.entries(native.createIntents ?? {})) {
4005
+ if (!intent || intent.tabId !== shortId || typeof intent.markerUrl !== 'string' ||
4006
+ !isArcCreateMarker(intent.markerUrl) ||
4007
+ typeof intent.targetUrl !== 'string' || typeof intent.createdAt !== 'number') {
4008
+ throw new Error(`Arc creation intent ${JSON.stringify(shortId)} is invalid; refusing recovery.`);
4009
+ }
4010
+ if (intent.ref && (intent.ref.windowId !== native.windowId || intent.ref.spaceId !== native.spaceId ||
4011
+ typeof intent.ref.tabId !== 'string')) {
4012
+ throw new Error(`Arc creation intent ${JSON.stringify(shortId)} left its original window/Space.`);
4013
+ }
4014
+ }
4015
+ return native;
4016
+ }
4017
+ async reconcileArcCreateIntents(conn, task) {
4018
+ const native = this.requireArcTask(task);
4019
+ for (const intent of Object.values(native.createIntents ?? {})) {
4020
+ let ref = intent.ref;
4021
+ if (ref) {
4022
+ const live = await resolveArcTab(ref);
4023
+ if (!live) {
4024
+ throw new Error(`Owned Arc tab ${intent.tabId} left its original window/Space while creation was incomplete; refusing to adopt it elsewhere.`);
4025
+ }
4026
+ if (live.url === intent.targetUrl) {
4027
+ native.tabs[intent.tabId] = ref;
4028
+ task.tabs[intent.tabId] = ref.tabId;
4029
+ if (intent.previousTabId) {
4030
+ await restoreArcSelection(ref, intent.previousTabId);
4031
+ }
4032
+ delete native.createIntents?.[intent.tabId];
4033
+ await this.saveTaskState(task.profile, conn.tasks);
4034
+ continue;
4035
+ }
4036
+ if (live.url !== intent.markerUrl) {
4037
+ throw new Error(`Owned Arc tab ${intent.tabId} changed outside this task during creation; refusing to navigate it.`);
4038
+ }
4039
+ }
4040
+ else {
4041
+ const originalSpace = (await enumerateArcSpaces()).find((space) => space.windowId === native.windowId && space.spaceId === native.spaceId);
4042
+ if (!originalSpace) {
4043
+ throw new Error('The original Arc window/Space for this task is no longer present.');
4044
+ }
4045
+ const markerMatches = originalSpace.tabs.filter((tab) => tab.url === intent.markerUrl);
4046
+ if (markerMatches.length > 1) {
4047
+ throw new Error(`Arc creation marker ${JSON.stringify(intent.markerUrl)} is not unique in the original Space.`);
4048
+ }
4049
+ if (markerMatches[0]) {
4050
+ ref = markerMatches[0];
4051
+ }
4052
+ else {
4053
+ try {
4054
+ ref = await createArcTab({ windowId: native.windowId, spaceId: native.spaceId }, intent.markerUrl);
4055
+ }
4056
+ catch (error) {
4057
+ // Arc selects the Space it was asked to create in even when the
4058
+ // creation itself fails. Put the owner back on their tab before
4059
+ // surfacing the error, so a failed agent verb never leaves them
4060
+ // staring at the wrong Space.
4061
+ if (intent.previousTabId) {
4062
+ await selectWindowTab(native.windowId, intent.previousTabId).catch(() => undefined);
4063
+ }
4064
+ throw error;
4065
+ }
4066
+ }
4067
+ intent.ref = ref;
4068
+ native.tabs[intent.tabId] = ref;
4069
+ task.tabs[intent.tabId] = ref.tabId;
4070
+ await this.saveTaskState(task.profile, conn.tasks);
4071
+ }
4072
+ await navigateArcTab(ref, intent.targetUrl);
4073
+ if (intent.previousTabId) {
4074
+ await restoreArcSelection(ref, intent.previousTabId);
4075
+ }
4076
+ delete native.createIntents?.[intent.tabId];
4077
+ task.currentTabId = intent.tabId;
4078
+ await this.saveTaskState(task.profile, conn.tasks);
4079
+ }
4080
+ }
4081
+ async createArcOwnedTab(conn, task, url) {
4082
+ const native = this.requireArcTask(task);
4083
+ const tabId = generateShortId();
4084
+ const markerUrl = arcCreateMarker();
4085
+ const originalSpace = (await enumerateArcSpaces()).find((space) => space.windowId === native.windowId && space.spaceId === native.spaceId);
4086
+ if (!originalSpace)
4087
+ throw new Error('The original Arc window/Space for this task is no longer present.');
4088
+ native.createIntents ??= {};
4089
+ native.createIntents[tabId] = {
4090
+ tabId,
4091
+ markerUrl,
4092
+ targetUrl: url,
4093
+ createdAt: Date.now(),
4094
+ previousTabId: originalSpace.activeTabId,
4095
+ };
4096
+ await this.saveTaskState(task.profile, conn.tasks);
4097
+ await this.reconcileArcCreateIntents(conn, task);
4098
+ return tabId;
4099
+ }
4100
+ async navigateArcNative(conn, task, url, addTab = false) {
4101
+ return this.runExclusive('arc-native:host-app', async () => {
4102
+ await this.reconcileArcCreateIntents(conn, task);
4103
+ const native = this.requireArcTask(task);
4104
+ if (!addTab) {
4105
+ for (const [shortId, ref] of Object.entries(native.tabs)) {
4106
+ const live = await resolveArcTab(ref);
4107
+ if (live && canonicalTabUrl(live.url) === canonicalTabUrl(url)) {
4108
+ await navigateArcTab(ref, url);
4109
+ task.currentTabId = shortId;
4110
+ await this.saveTaskState(task.profile, conn.tasks);
4111
+ return { tabId: shortId, url, created: false, refreshed: true, message: 'Tab already open—refreshed' };
4112
+ }
4113
+ }
4114
+ }
4115
+ const currentId = task.currentTabId;
4116
+ if (!addTab && currentId) {
4117
+ const ref = native.tabs[currentId];
4118
+ if (!ref || !(await resolveArcTab(ref))) {
4119
+ throw new Error(`Owned Arc tab ${currentId} is missing from its original window/Space; refusing to adopt a moved tab.`);
4120
+ }
4121
+ await navigateArcTab(ref, url);
4122
+ return { tabId: currentId, url, created: false, refreshed: false };
4123
+ }
4124
+ const tabId = await this.createArcOwnedTab(conn, task, url);
4125
+ return { tabId, url, created: true, refreshed: false };
4126
+ });
4127
+ }
4128
+ /**
4129
+ * Execute JavaScript in a native Arc tab. Sync JS only, isolated world —
4130
+ * async/promise evaluation fails honestly with an AppleScript error.
4131
+ */
4132
+ async evaluateArcNative(conn, task, shortId, expression) {
4133
+ const ref = this.requireArcTask(task).tabs[shortId];
4134
+ if (!ref || !(await resolveArcTab(ref))) {
4135
+ throw new Error(`Owned Arc tab ${shortId} is missing from its original window/Space.`);
4136
+ }
4137
+ return executeJavaScript(ref, expression);
4138
+ }
4139
+ async listArcTaskTabs(task) {
4140
+ const native = this.requireArcTask(task);
4141
+ const tabs = [];
4142
+ for (const [shortId, ref] of Object.entries(native.tabs)) {
4143
+ const live = await resolveArcTab(ref);
4144
+ if (!live) {
4145
+ throw new Error(`Owned Arc tab ${shortId} is missing from its original window/Space; refusing to adopt a moved tab.`);
4146
+ }
4147
+ tabs.push({
4148
+ id: shortId,
4149
+ url: live.url,
4150
+ title: live.title,
4151
+ task: task.name,
4152
+ current: shortId === task.currentTabId,
4153
+ });
4154
+ }
4155
+ return tabs;
4156
+ }
4157
+ /**
4158
+ * Close native Arc tabs owned by a task. Only closes tabs the task created
4159
+ * (not borrowed ones). Never kills Arc. Identifies tabs only by stable ids in
4160
+ * the original window and Space.
4161
+ */
4162
+ async closeArcNativeTabs(conn, task) {
4163
+ await this.runExclusive('arc-native:host-app', async () => {
4164
+ await this.reconcileArcCreateIntents(conn, task);
4165
+ const native = this.requireArcTask(task);
4166
+ for (const [shortId, ref] of Object.entries(native.tabs)) {
4167
+ await closeArcTab(ref);
4168
+ delete native.tabs[shortId];
4169
+ delete task.tabs[shortId];
4170
+ delete task.refDescriptors?.[shortId];
4171
+ }
4172
+ task.currentTabId = undefined;
4173
+ });
4174
+ }
4175
+ // ---------------------------------------------------------------------------
4176
+ // Firefox WebDriver BiDi backend helpers (PHNX-4043)
4177
+ // ---------------------------------------------------------------------------
4178
+ /** The BiDi browsing-context id a task owns for `shortId`. */
4179
+ firefoxContext(task, shortId) {
4180
+ const context = task.tabs[shortId];
4181
+ if (!context)
4182
+ throw new Error(`Tab ${shortId} not found`);
4183
+ return context;
4184
+ }
4185
+ /** The context ids currently live in this Firefox (a killed tab drops out). */
4186
+ async firefoxLiveContexts(conn) {
4187
+ const contexts = await bidiTopLevelContexts(conn.bidi);
4188
+ return new Set(contexts.map((c) => c.context));
4189
+ }
4190
+ /**
4191
+ * Same-task reopen for Firefox (mirrors {@link reopenOwnedTab}): the URL is
4192
+ * already live in one of this task's own tabs → reload THAT tab in place and
4193
+ * keep its id, rather than opening a duplicate. Borrowed tabs are excluded —
4194
+ * Firefox never borrows, but the guard keeps parity. Returns the retained
4195
+ * short id, or undefined when nothing matches.
4196
+ */
4197
+ async reopenOwnedFirefoxTab(conn, task, url) {
4198
+ const borrowed = new Set(task.borrowedTabs ?? []);
4199
+ // Read each live tab's URL from browsingContext.getTree metadata rather than
4200
+ // evaluating location.href per tab: the same source the CDP path reads
4201
+ // (Target.getTargets), so a mid-navigation or just-closed sibling tab can
4202
+ // never throw a BiDi "no such frame" and fail an unrelated navigate. A tab
4203
+ // absent from the tree is simply not a reopen candidate.
4204
+ const liveUrlByContext = new Map((await bidiTopLevelContexts(conn.bidi)).map((c) => [c.context, c.url]));
4205
+ const wanted = canonicalTabUrl(url);
4206
+ for (const [shortId, context] of Object.entries(task.tabs)) {
4207
+ if (borrowed.has(shortId))
4208
+ continue;
4209
+ const liveUrl = liveUrlByContext.get(context);
4210
+ if (liveUrl === undefined)
4211
+ continue; // registered but gone from the browser
4212
+ if (canonicalTabUrl(liveUrl) === wanted) {
4213
+ await bidiReload(conn.bidi, context);
4214
+ task.currentTabId = shortId;
4215
+ await this.saveTaskState(task.profile, conn.tasks);
4216
+ return { tabId: shortId };
4217
+ }
4218
+ }
4219
+ return undefined;
4220
+ }
4221
+ async navigateFirefox(conn, task, url, addTab = false) {
4222
+ if (!addTab) {
4223
+ const reopened = await this.reopenOwnedFirefoxTab(conn, task, url);
4224
+ if (reopened) {
4225
+ emit('browser.navigate', { profile: parseConnectionKey(task.profile).profile, task: task.name, url, tabId: reopened.tabId, created: false });
4226
+ return { tabId: reopened.tabId, url, created: false, refreshed: true, message: 'Tab already open—refreshed' };
4227
+ }
4228
+ // Reuse the current tab when there is one — navigate() semantics.
4229
+ const currentShortId = task.currentTabId;
4230
+ if (currentShortId && task.tabs[currentShortId]) {
4231
+ const context = task.tabs[currentShortId];
4232
+ const live = await this.firefoxLiveContexts(conn);
4233
+ if (live.has(context)) {
4234
+ await bidiNavigate(conn.bidi, context, url);
4235
+ await this.saveTaskState(task.profile, conn.tasks);
4236
+ emit('browser.navigate', { profile: parseConnectionKey(task.profile).profile, task: task.name, url, tabId: currentShortId, created: false });
4237
+ return { tabId: currentShortId, url, created: false, refreshed: false };
4238
+ }
4239
+ }
4240
+ }
4241
+ else {
4242
+ const reopened = await this.reopenOwnedFirefoxTab(conn, task, url);
4243
+ if (reopened) {
4244
+ return { tabId: reopened.tabId, url, created: false, refreshed: true, message: 'Tab already open—refreshed' };
4245
+ }
4246
+ }
4247
+ // Open a fresh tab.
4248
+ const context = await bidiCreateTab(conn.bidi);
4249
+ await bidiNavigate(conn.bidi, context, url);
4250
+ const shortId = generateShortId();
4251
+ task.tabs[shortId] = context;
4252
+ task.currentTabId = shortId;
4253
+ await this.saveTaskState(task.profile, conn.tasks);
4254
+ emit('browser.navigate', { profile: parseConnectionKey(task.profile).profile, task: task.name, url, tabId: shortId, created: true });
4255
+ return { tabId: shortId, url, created: true, refreshed: false };
4256
+ }
4257
+ async evaluateFirefox(conn, task, shortId, expression) {
4258
+ const context = this.firefoxContext(task, shortId);
4259
+ const live = await this.firefoxLiveContexts(conn);
4260
+ if (!live.has(context))
4261
+ throw new Error(`Tab ${shortId} is no longer open in Firefox.`);
4262
+ return bidiEvaluate(conn.bidi, context, expression);
4263
+ }
4264
+ async listFirefoxTaskTabs(conn, task) {
4265
+ const live = await bidiTopLevelContexts(conn.bidi);
4266
+ const byId = new Map(live.map((c) => [c.context, c]));
4267
+ const tabs = [];
4268
+ for (const [shortId, context] of Object.entries(task.tabs)) {
4269
+ const info = byId.get(context);
4270
+ if (!info)
4271
+ continue; // tab closed out from under the task — drop it silently.
4272
+ let title = '';
4273
+ try {
4274
+ title = String(await bidiEvaluate(conn.bidi, context, 'document.title'));
4275
+ }
4276
+ catch {
4277
+ /* about: pages or a mid-navigation context — url is enough */
4278
+ }
4279
+ tabs.push({ id: shortId, url: info.url, title, task: task.name, current: shortId === task.currentTabId });
4280
+ }
4281
+ return tabs;
4282
+ }
4283
+ /**
4284
+ * Close every non-borrowed tab a Firefox task owns. Never kills the Firefox
4285
+ * process (that is `stopProfile`'s job) — a killed browser would take the
4286
+ * user's other tabs with it. A tab already gone is skipped.
4287
+ */
4288
+ async closeFirefoxTabs(conn, task) {
4289
+ const borrowed = new Set(task.borrowedTabs ?? []);
4290
+ const live = await this.firefoxLiveContexts(conn);
4291
+ for (const [shortId, context] of Object.entries(task.tabs)) {
4292
+ if (borrowed.has(shortId))
4293
+ continue;
4294
+ if (live.has(context)) {
4295
+ try {
4296
+ await bidiCloseTab(conn.bidi, context);
4297
+ }
4298
+ catch { /* already closed */ }
4299
+ }
4300
+ }
4301
+ }
4302
+ /**
4303
+ * Viewport-center coordinates of a selector-addressed element in a Firefox
4304
+ * tab, scrolled into view first — the (x, y) `input.performActions` clicks.
4305
+ * Throws when the element is gone or has zero size (off-screen / display:none).
4306
+ */
4307
+ async firefoxRefCoords(conn, task, shortId, selector) {
4308
+ const expr = `(() => {
4309
+ const el = document.querySelector(${JSON.stringify(selector)});
4310
+ if (!el) throw new Error('DOM ref is missing');
4311
+ el.scrollIntoView({ block: 'center', inline: 'center' });
4312
+ const r = el.getBoundingClientRect();
4313
+ if (r.width === 0 && r.height === 0) throw new Error('DOM ref has no layout box');
4314
+ return JSON.stringify({ x: r.left + r.width / 2, y: r.top + r.height / 2 });
4315
+ })()`;
4316
+ const raw = await this.evaluateFirefox(conn, task, shortId, expr);
4317
+ const parsed = JSON.parse(String(raw));
4318
+ return parsed;
4319
+ }
4320
+ /**
4321
+ * Screenshot a Firefox tab over BiDi. Same output contract as the CDP path:
4322
+ * `raw` is a pixel-faithful PNG; the default is JPEG iteratively downscaled
4323
+ * under 100 KB so chat-injected screenshots stay token-cheap.
4324
+ */
4325
+ async screenshotFirefox(conn, task, runtimeKey, tabHint, outputPath, quality) {
4326
+ const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
4327
+ const context = this.firefoxContext(task, shortId);
4328
+ const live = await this.firefoxLiveContexts(conn);
4329
+ if (!live.has(context))
4330
+ throw new Error(`Tab ${shortId} is no longer open in Firefox.`);
4331
+ let buffer;
4332
+ let extension;
4333
+ if (quality === 'raw') {
4334
+ buffer = await bidiScreenshot(conn.bidi, context, { type: 'image/png' });
4335
+ extension = 'png';
4336
+ }
4337
+ else {
4338
+ buffer = await bidiScreenshot(conn.bidi, context, { type: 'image/jpeg', quality: 0.7 });
4339
+ const MAX_SIZE = 100 * 1024;
4340
+ let q = 0.5;
4341
+ while (buffer.length > MAX_SIZE && q > 0.1) {
4342
+ buffer = await bidiScreenshot(conn.bidi, context, { type: 'image/jpeg', quality: q });
4343
+ q -= 0.1;
4344
+ }
4345
+ extension = 'jpg';
4346
+ }
4347
+ const sessionsDir = getProfileSessionsDir(runtimeKey, task.name);
4348
+ const automaticPath = path.join(sessionsDir, `${Date.now()}.${extension}`);
4349
+ const finalPath = resolveScreenshotOutputPath(outputPath, automaticPath);
4350
+ await fs.promises.mkdir(path.dirname(finalPath), { recursive: true });
4351
+ await fs.promises.writeFile(finalPath, buffer);
4352
+ const dims = (extension === 'png' ? readPngDimensions(buffer) : readJpegDimensions(buffer)) ??
4353
+ { width: 0, height: 0 };
4354
+ emit('browser.screenshot', {
4355
+ profile: parseConnectionKey(runtimeKey).profile,
4356
+ task: task.name,
4357
+ tabId: shortId,
4358
+ path: finalPath,
4359
+ bytes: buffer.length,
4360
+ width: dims.width,
4361
+ height: dims.height,
4362
+ quality,
4363
+ });
4364
+ return { path: finalPath, bytes: buffer.length, width: dims.width, height: dims.height };
4365
+ }
3376
4366
  /** Connect a profile at `target`, register it under `key`, and arm downloads. */
3377
4367
  async openConnection(profile, target, key, profileName, opts = {}) {
3378
4368
  const conn = await this.connectProfile(profile, target, key, opts);
3379
4369
  conn.key = key;
3380
4370
  conn.profile = profileName;
3381
4371
  this.connections.set(key, conn);
3382
- await this.applyDefaultDownloadBehavior(conn, key);
4372
+ // Download behavior configuration is CDP-specific; skip for native Arc + Firefox.
4373
+ if (conn.backend !== 'arc-native' && conn.backend !== 'bidi') {
4374
+ await this.applyDefaultDownloadBehavior(conn, key);
4375
+ }
3383
4376
  return conn;
3384
4377
  }
3385
4378
  /**
@@ -3402,6 +4395,9 @@ export class BrowserService {
3402
4395
  endpointName: opts.endpointName,
3403
4396
  probe: opts.probe,
3404
4397
  });
4398
+ if (!routed.local && routed.commandDispatch) {
4399
+ throw new Error(`Native Arc profile "${profileName}" is owned by ${routed.device}; dispatch the complete browser command there.`);
4400
+ }
3405
4401
  const key = routed.key;
3406
4402
  const effectiveProfile = routed.profile;
3407
4403
  const reused = await this.reuseProfileConnection(profileName, key, routed.local);
@@ -3411,6 +4407,13 @@ export class BrowserService {
3411
4407
  adoptLegacyRuntimeIfLocal(routed.local, profileName, key, getBrowserRuntimeDir());
3412
4408
  conn = await this.openConnection(effectiveProfile, routed.target, key, profileName, { persistRemote: !routed.local });
3413
4409
  }
4410
+ if (conn.backend === 'arc-native') {
4411
+ throw new ArcNativeCapabilityError('show', 'Task-less viewer tabs are unavailable for native Arc because creation intent cannot be bound to an owning task.');
4412
+ }
4413
+ if (conn.backend === 'bidi') {
4414
+ throw new FirefoxCapabilityError('show', 'Task-less viewer tabs are unavailable for a headless-automation Firefox profile. ' +
4415
+ 'Use a Chromium-family profile, or open the URL in your OS browser.');
4416
+ }
3414
4417
  // createPageTarget refuses Arc with an actionable error rather than crashing it.
3415
4418
  const created = await this.createPageTarget(conn, { url });
3416
4419
  this.invalidateTargetCache(conn);