@phnx-labs/agents-cli 1.22.94 → 1.22.96

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.
@@ -23,6 +23,7 @@ import { resolveActor } from '../actor.js';
23
23
  import { recordBrowserSession } from '../session/db.js';
24
24
  import { sshExecAsync } from '../ssh-exec.js';
25
25
  import { resolveFfmpeg } from './ffmpeg.js';
26
+ import { FirefoxCapabilityError, connectFirefox, bidiTopLevelContexts, bidiCreateTab, bidiNavigate, bidiReload, bidiCloseTab, bidiActivate, bidiEvaluate, bidiScreenshot, bidiClickAt, } from './drivers/firefox.js';
26
27
  import { ArcNativeCapabilityError, executeJavaScript, navigateArcTab, closeArcTab, createArcTab, enumerateArcSpaces, isArcRunning, resolveArcTab, restoreArcSelection, selectWindowTab, selectArcTab, } from './drivers/arc.js';
27
28
  /**
28
29
  * Canonical form for comparing a requested URL against what CDP reports for a
@@ -235,6 +236,11 @@ async function isConnHealthy(conn, timeoutMs = 1000) {
235
236
  if (conn.backend === 'arc-native') {
236
237
  return isArcRunning();
237
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
+ }
238
244
  if (!conn.cdp.isOpen)
239
245
  return false;
240
246
  try {
@@ -248,9 +254,18 @@ async function isConnHealthy(conn, timeoutMs = 1000) {
248
254
  return false;
249
255
  }
250
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
+ */
251
264
  function requireCdp(conn, capability) {
252
265
  if (conn.backend === 'arc-native')
253
266
  throw new ArcNativeCapabilityError(capability);
267
+ if (conn.backend === 'bidi')
268
+ throw new FirefoxCapabilityError(capability);
254
269
  }
255
270
  /** Join error lines so callers get a next command, not a dead-end message. */
256
271
  export function actionable(...lines) {
@@ -536,7 +551,7 @@ export class BrowserService {
536
551
  // pile-up in RUSH-2622. Tabs the daemon did NOT open are still left alone —
537
552
  // the branch only fires when the profile has no page target at all.
538
553
  let startupBlankTargetId;
539
- if (!opts.url && !conn.electron && conn.backend !== 'arc-native') {
554
+ if (!opts.url && !conn.electron && conn.backend !== 'arc-native' && conn.backend !== 'bidi') {
540
555
  const { targetInfos } = (await conn.cdp.send('Target.getTargets'));
541
556
  if (!targetInfos.some((t) => t.type === 'page')) {
542
557
  const created = await this.createPageTarget(conn, {
@@ -581,7 +596,7 @@ export class BrowserService {
581
596
  task.currentTabId = shortId;
582
597
  }
583
598
  // For Electron, get the existing window as the tab
584
- if (conn.electron && conn.backend !== 'arc-native') {
599
+ if (conn.electron && conn.backend !== 'arc-native' && conn.backend !== 'bidi') {
585
600
  const windowId = await this.getOrCreateWindow(conn);
586
601
  if (windowId) {
587
602
  const shortId = generateShortId();
@@ -633,7 +648,14 @@ export class BrowserService {
633
648
  // opening a fresh target is created:true.
634
649
  let tabId;
635
650
  let firstOpen;
636
- if (opts.url && conn.backend !== 'arc-native' && !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') {
637
659
  const adopted = opts.fresh ? undefined : await this.adoptTabShowing(conn, opts.url);
638
660
  const targetId = adopted ?? (await this.createPageTarget(conn, { url: opts.url })).targetId;
639
661
  const shortId = generateShortId();
@@ -884,6 +906,21 @@ export class BrowserService {
884
906
  await this.saveToHistory(task, Array.from(domains));
885
907
  await this.closeArcNativeTabs(conn, task);
886
908
  }
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);
923
+ }
887
924
  else {
888
925
  try {
889
926
  const { targetInfos } = (await conn.cdp.send('Target.getTargets'));
@@ -931,7 +968,12 @@ export class BrowserService {
931
968
  });
932
969
  }).catch(() => { });
933
970
  if (conn.forkedFrom && conn.tasks.size === 0) {
934
- if (conn.backend !== 'arc-native') {
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') {
935
977
  conn.cdp.close();
936
978
  killChrome(conn.pid);
937
979
  }
@@ -976,6 +1018,18 @@ export class BrowserService {
976
1018
  clearProfileRuntime(key);
977
1019
  continue;
978
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
+ }
979
1033
  conn.cdp.close();
980
1034
  killChrome(conn.pid);
981
1035
  conn.cleanup?.();
@@ -1006,6 +1060,10 @@ export class BrowserService {
1006
1060
  if (conn.backend === 'arc-native') {
1007
1061
  return this.navigateArcNative(conn, task, url);
1008
1062
  }
1063
+ // Firefox BiDi backend (PHNX-4043).
1064
+ if (conn.backend === 'bidi') {
1065
+ return this.navigateFirefox(conn, task, url);
1066
+ }
1009
1067
  requireCdp(conn, 'navigate');
1010
1068
  // Same-task reopen (PHNX-2399): the URL is already live in a tab this task
1011
1069
  // owns → reload THAT tab in place and keep its id, before the create/reuse
@@ -1111,6 +1169,9 @@ export class BrowserService {
1111
1169
  if (conn.backend === 'arc-native') {
1112
1170
  return this.navigateArcNative(conn, task, url, true);
1113
1171
  }
1172
+ if (conn.backend === 'bidi') {
1173
+ return this.navigateFirefox(conn, task, url, true);
1174
+ }
1114
1175
  requireCdp(conn, 'createTab');
1115
1176
  if (conn.electron) {
1116
1177
  throw new Error('Electron apps do not support opening additional tabs');
@@ -1145,6 +1206,12 @@ export class BrowserService {
1145
1206
  });
1146
1207
  return { tabId: resolvedTabId };
1147
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
+ }
1148
1215
  task.currentTabId = resolvedTabId;
1149
1216
  await this.saveTaskState(task.profile, conn.tasks);
1150
1217
  return { tabId: resolvedTabId };
@@ -1154,6 +1221,11 @@ export class BrowserService {
1154
1221
  if (conn.backend === 'arc-native') {
1155
1222
  return (await this.listArcTaskTabs(task)).map((tab) => ({ ...tab, current: tab.current === true }));
1156
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
+ }
1157
1229
  requireCdp(conn, 'enumerate');
1158
1230
  const targets = (await conn.cdp.send('Target.getTargets'));
1159
1231
  const tabs = [];
@@ -1170,6 +1242,64 @@ export class BrowserService {
1170
1242
  }
1171
1243
  return tabs;
1172
1244
  }
1245
+ /**
1246
+ * Every top-level tab open in the task's profile browser, the owner's tabs
1247
+ * included (`agents browser tabs --all`). Read-only: nothing is focused,
1248
+ * navigated, or closed. A tab an agents-cli task owns carries that task's
1249
+ * name and short id (so it can be addressed); any other tab carries the
1250
+ * browser's own id and no task. Same rows on every backend.
1251
+ */
1252
+ async profileTabs(taskId) {
1253
+ const { conn, task } = await this.findTask(taskId);
1254
+ const owners = new Map();
1255
+ for (const [, owner] of conn.tasks) {
1256
+ const entries = conn.backend === 'arc-native'
1257
+ ? Object.entries(owner.arcNative?.tabs ?? {}).map(([shortId, ref]) => [shortId, ref.tabId])
1258
+ : Object.entries(owner.tabs);
1259
+ for (const [shortId, nativeId] of entries)
1260
+ owners.set(nativeId, { owner, shortId });
1261
+ }
1262
+ const row = (nativeId, url, title) => {
1263
+ const owned = owners.get(nativeId);
1264
+ if (!owned)
1265
+ return { id: nativeId, url, title };
1266
+ return {
1267
+ id: owned.shortId,
1268
+ url,
1269
+ title,
1270
+ task: owned.owner.name,
1271
+ current: owned.owner === task && owned.shortId === task.currentTabId,
1272
+ };
1273
+ };
1274
+ if (conn.backend === 'arc-native') {
1275
+ const native = this.requireArcTask(task);
1276
+ const space = (await enumerateArcSpaces()).find((candidate) => candidate.windowId === native.windowId && candidate.spaceId === native.spaceId);
1277
+ if (!space) {
1278
+ throw new Error(`Arc Space "${native.spaceTitle}" is not open in its original window (${native.windowId}); ` +
1279
+ 'open it in Arc and retry.');
1280
+ }
1281
+ return space.tabs.map((tab) => row(tab.tabId, tab.url, tab.title));
1282
+ }
1283
+ if (conn.backend === 'bidi') {
1284
+ const rows = [];
1285
+ for (const context of await bidiTopLevelContexts(conn.bidi)) {
1286
+ let title = '';
1287
+ try {
1288
+ title = String(await bidiEvaluate(conn.bidi, context.context, 'document.title'));
1289
+ }
1290
+ catch {
1291
+ /* about: pages or a mid-navigation context — url is enough */
1292
+ }
1293
+ rows.push(row(context.context, context.url, title));
1294
+ }
1295
+ return rows;
1296
+ }
1297
+ requireCdp(conn, 'enumerate');
1298
+ const { targetInfos } = (await conn.cdp.send('Target.getTargets'));
1299
+ return targetInfos
1300
+ .filter((target) => target.type === 'page')
1301
+ .map((target) => row(target.targetId, target.url, target.title));
1302
+ }
1173
1303
  async resolveTabHint(conn, task, hint) {
1174
1304
  // Exact match
1175
1305
  if (task.tabs[hint])
@@ -1185,6 +1315,9 @@ export class BrowserService {
1185
1315
  // and title are display data and must never become fallback identities.
1186
1316
  if (conn.backend === 'arc-native')
1187
1317
  throw new Error(`Tab "${hint}" not found`);
1318
+ // Firefox tabs are addressed by the task's stable short id too.
1319
+ if (conn.backend === 'bidi')
1320
+ throw new Error(`Tab "${hint}" not found`);
1188
1321
  // URL substring match
1189
1322
  const targets = (await conn.cdp.send('Target.getTargets'));
1190
1323
  const matches = [];
@@ -1219,6 +1352,8 @@ export class BrowserService {
1219
1352
  const { conn, task } = await this.findTask(taskId, profileRef);
1220
1353
  if (conn.backend === 'arc-native')
1221
1354
  return this.listArcTaskTabs(task);
1355
+ if (conn.backend === 'bidi')
1356
+ return this.listFirefoxTaskTabs(conn, task);
1222
1357
  return this.getTabsForTask(conn.cdp, task);
1223
1358
  }
1224
1359
  const allTabs = [];
@@ -1226,7 +1361,9 @@ export class BrowserService {
1226
1361
  for (const [, task] of conn.tasks) {
1227
1362
  const tabs = conn.backend === 'arc-native'
1228
1363
  ? await this.listArcTaskTabs(task)
1229
- : await this.getTabsForTask(conn.cdp, task);
1364
+ : conn.backend === 'bidi'
1365
+ ? await this.listFirefoxTaskTabs(conn, task)
1366
+ : await this.getTabsForTask(conn.cdp, task);
1230
1367
  allTabs.push(...tabs);
1231
1368
  }
1232
1369
  }
@@ -1257,6 +1394,30 @@ export class BrowserService {
1257
1394
  });
1258
1395
  return;
1259
1396
  }
1397
+ if (conn.backend === 'bidi') {
1398
+ const borrowed = new Set(task.borrowedTabs ?? []);
1399
+ const ids = tabHint === undefined
1400
+ ? Object.keys(task.tabs)
1401
+ : [await this.resolveTabHint(conn, task, tabHint)];
1402
+ for (const shortId of ids) {
1403
+ const context = task.tabs[shortId];
1404
+ // A borrowed tab is released, not closed — it outlives this task.
1405
+ if (context && !borrowed.has(shortId)) {
1406
+ try {
1407
+ await bidiCloseTab(conn.bidi, context);
1408
+ }
1409
+ catch { /* already closed */ }
1410
+ }
1411
+ delete task.tabs[shortId];
1412
+ delete task.refDescriptors?.[shortId];
1413
+ task.borrowedTabs = (task.borrowedTabs ?? []).filter((id) => id !== shortId);
1414
+ }
1415
+ if (!task.currentTabId || ids.includes(task.currentTabId)) {
1416
+ task.currentTabId = Object.keys(task.tabs).at(-1);
1417
+ }
1418
+ await this.saveTaskState(task.profile, conn.tasks);
1419
+ return;
1420
+ }
1260
1421
  requireCdp(conn, 'closeTab');
1261
1422
  if (tabHint !== undefined) {
1262
1423
  const shortId = await this.resolveTabHint(conn, task, tabHint);
@@ -1298,6 +1459,10 @@ export class BrowserService {
1298
1459
  if (conn.backend === 'arc-native') {
1299
1460
  return this.evaluateArcNative(conn, task, shortId, expression);
1300
1461
  }
1462
+ // Firefox BiDi backend (PHNX-4043): async-capable, isolated realm.
1463
+ if (conn.backend === 'bidi') {
1464
+ return this.evaluateFirefox(conn, task, shortId, expression);
1465
+ }
1301
1466
  const cdpTargetId = this.getCdpTargetId(task, shortId);
1302
1467
  const target = await this.getTarget(conn, cdpTargetId);
1303
1468
  if (!target) {
@@ -1334,6 +1499,10 @@ export class BrowserService {
1334
1499
  'This task still belongs to the Arc profile. ' +
1335
1500
  'Use a Chromium-family browser for screenshot capability.');
1336
1501
  }
1502
+ // Firefox BiDi backend (PHNX-4043): captureScreenshot on the context.
1503
+ if (conn.backend === 'bidi') {
1504
+ return this.screenshotFirefox(conn, task, runtimeKey, tabHint, outputPath, quality);
1505
+ }
1337
1506
  const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
1338
1507
  const cdpTargetId = this.getCdpTargetId(task, shortId);
1339
1508
  const target = await this.getTarget(conn, cdpTargetId);
@@ -1731,6 +1900,14 @@ export class BrowserService {
1731
1900
  await this.saveTaskState(task.profile, conn.tasks);
1732
1901
  return result;
1733
1902
  }
1903
+ // Firefox BiDi (PHNX-4043): the same DOM-derived accessibility listing Arc
1904
+ // uses, evaluated over BiDi. selector-based refs, no CDP accessibility tree.
1905
+ if (conn.backend === 'bidi') {
1906
+ const result = parseArcRefsResult(await this.evaluateFirefox(conn, task, shortId, arcRefsExpression(opts)), opts);
1907
+ this.cacheRefDescriptors(task, shortId, result.nodeMap, result.opts);
1908
+ await this.saveTaskState(task.profile, conn.tasks);
1909
+ return result;
1910
+ }
1734
1911
  requireCdp(conn, 'enumerate');
1735
1912
  const cdpTargetId = this.getCdpTargetId(task, shortId);
1736
1913
  const target = await this.getTarget(conn, cdpTargetId);
@@ -1797,6 +1974,19 @@ export class BrowserService {
1797
1974
  await this.evaluateArcNative(conn, task, shortId, arcClickExpression(selector));
1798
1975
  return healed ? { healed } : {};
1799
1976
  }
1977
+ if (conn.backend === 'bidi') {
1978
+ const snapshot = task.refDescriptors?.[shortId];
1979
+ const buildOpts = snapshot?.opts ?? { interactive: true, limit: 500 };
1980
+ const { nodeMap } = parseArcRefsResult(await this.evaluateFirefox(conn, task, shortId, arcRefsExpression(buildOpts)), buildOpts);
1981
+ const { targetRef, healed } = this.resolveHealedRef(snapshot, nodeMap, ref);
1982
+ const selector = nodeMap.get(targetRef)?.selector;
1983
+ if (!selector)
1984
+ throw new Error(`Ref ${ref} has no DOM selector`);
1985
+ const coords = await this.firefoxRefCoords(conn, task, shortId, selector);
1986
+ const context = this.firefoxContext(task, shortId);
1987
+ await bidiClickAt(conn.bidi, context, coords.x, coords.y);
1988
+ return healed ? { healed } : {};
1989
+ }
1800
1990
  requireCdp(conn, 'click');
1801
1991
  const cdpTargetId = this.getCdpTargetId(task, shortId);
1802
1992
  const target = await this.getTarget(conn, cdpTargetId);
@@ -1852,6 +2042,17 @@ export class BrowserService {
1852
2042
  await this.evaluateArcNative(conn, task, shortId, arcFillExpression(selector, text, clear ?? false));
1853
2043
  return;
1854
2044
  }
2045
+ if (conn.backend === 'bidi') {
2046
+ const snapshot = task.refDescriptors?.[shortId];
2047
+ const buildOpts = snapshot?.opts ?? { interactive: true, limit: 500 };
2048
+ const { nodeMap } = parseArcRefsResult(await this.evaluateFirefox(conn, task, shortId, arcRefsExpression(buildOpts)), buildOpts);
2049
+ const { targetRef } = this.resolveHealedRef(snapshot, nodeMap, ref);
2050
+ const selector = nodeMap.get(targetRef)?.selector;
2051
+ if (!selector)
2052
+ throw new Error(`Ref ${ref} has no DOM selector`);
2053
+ await this.evaluateFirefox(conn, task, shortId, arcFillExpression(selector, text, clear ?? false));
2054
+ return;
2055
+ }
1855
2056
  requireCdp(conn, 'type');
1856
2057
  const cdpTargetId = this.getCdpTargetId(task, shortId);
1857
2058
  const target = await this.getTarget(conn, cdpTargetId);
@@ -1911,6 +2112,10 @@ export class BrowserService {
1911
2112
  await this.evaluateArcNative(conn, task, shortId, arcScrollExpression(deltaX, deltaY));
1912
2113
  return;
1913
2114
  }
2115
+ if (conn.backend === 'bidi') {
2116
+ await this.evaluateFirefox(conn, task, shortId, arcScrollExpression(deltaX, deltaY));
2117
+ return;
2118
+ }
1914
2119
  requireCdp(conn, 'scroll');
1915
2120
  const cdpTargetId = this.getCdpTargetId(task, shortId);
1916
2121
  const target = await this.getTarget(conn, cdpTargetId);
@@ -2482,7 +2687,9 @@ export class BrowserService {
2482
2687
  this.recordings.delete(taskId);
2483
2688
  }
2484
2689
  for (const [, conn] of this.connections) {
2485
- if (conn.backend !== 'arc-native')
2690
+ if (conn.backend === 'bidi')
2691
+ conn.bidi.close();
2692
+ else if (conn.backend !== 'arc-native')
2486
2693
  conn.cdp.close();
2487
2694
  conn.cleanup?.();
2488
2695
  }
@@ -2538,6 +2745,11 @@ export class BrowserService {
2538
2745
  async connectProfile(effectiveProfile, target, key, opts = {}) {
2539
2746
  const existingInfo = getRunningChromeInfo(key);
2540
2747
  const tunnelled = opts.persistRemote || target.startsWith('ssh:');
2748
+ // Native Arc and Firefox BiDi speak their own transport, not CDP — never
2749
+ // route them through the CDP reuse branch (which would probe /json/version
2750
+ // and wipe the runtime meta the Firefox driver wrote). connectEndpoint owns
2751
+ // their attach-or-launch decision.
2752
+ const nativeTransport = target.startsWith('arc-native:') || target.startsWith('firefox-bidi:');
2541
2753
  if (tunnelled) {
2542
2754
  // A leftover local chrome-data / pid file under this key is the
2543
2755
  // pre-T2 logged-out browser on THIS box. Attaching localhost CDP
@@ -2548,7 +2760,7 @@ export class BrowserService {
2548
2760
  clearProfileRuntime(key);
2549
2761
  }
2550
2762
  }
2551
- else if (existingInfo) {
2763
+ else if (existingInfo && !nativeTransport) {
2552
2764
  try {
2553
2765
  const { wsUrl, browser } = await discoverBrowserWsUrl(existingInfo.port, 'localhost', effectiveProfile.name);
2554
2766
  verifyBrowserIdentity(browser, effectiveProfile.browser, existingInfo.port);
@@ -2660,6 +2872,39 @@ export class BrowserService {
2660
2872
  sessionCache: new Map(),
2661
2873
  };
2662
2874
  }
2875
+ // Firefox BiDi backend (PHNX-4043): `firefox-bidi://127.0.0.1:<port>` connects
2876
+ // over a WebDriver BiDi WebSocket. The driver attaches to a Firefox already
2877
+ // serving the port, else launches one headless bound to the pinned profile
2878
+ // directory, else fails loud (Firefox is single-instance per profile).
2879
+ if (url.protocol === 'firefox-bidi:') {
2880
+ if (!profile.firefox) {
2881
+ throw new Error(`Firefox profile "${profile.name}" has no discovered profile metadata. ` +
2882
+ `Re-select it with: agents browser use ${profile.name}`);
2883
+ }
2884
+ const profileDir = profile.userDataDir;
2885
+ if (!profileDir) {
2886
+ throw new Error(`Firefox profile "${profile.name}" has no profile directory.`);
2887
+ }
2888
+ const port = parseInt(url.port || '9600', 10);
2889
+ const conn = await connectFirefox(profile, key, port, {
2890
+ profileDir,
2891
+ headless: profile.chrome?.headless,
2892
+ });
2893
+ return {
2894
+ backend: 'bidi',
2895
+ browserType: 'firefox',
2896
+ bidi: conn.bidi,
2897
+ firefoxProfile: profile.firefox,
2898
+ sessionId: conn.sessionId,
2899
+ profileDir,
2900
+ port: conn.port,
2901
+ pid: conn.pid,
2902
+ electron: false,
2903
+ targetFilter: profile.targetFilter,
2904
+ tasks: this.loadTaskState(key),
2905
+ sessionCache: new Map(),
2906
+ };
2907
+ }
2663
2908
  // Native Arc backend (PHNX-2399): `arc-native:` protocol connects through
2664
2909
  // Apple Events instead of CDP. No debugging port, no browser launch.
2665
2910
  if (url.protocol === 'arc-native:') {
@@ -2702,6 +2947,8 @@ export class BrowserService {
2702
2947
  if (conn.browserType === 'arc') {
2703
2948
  throw arcNotDrivableError(conn.profile);
2704
2949
  }
2950
+ // Firefox opens tabs through the BiDi driver, never Target.createTarget.
2951
+ requireCdp(conn, 'createTab');
2705
2952
  return (await conn.cdp.send('Target.createTarget', params));
2706
2953
  }
2707
2954
  async getOrCreateWindow(conn) {
@@ -3136,6 +3383,44 @@ export class BrowserService {
3136
3383
  arcProfile: profile.arc,
3137
3384
  };
3138
3385
  }
3386
+ // Firefox BiDi endpoints (PHNX-4043): a daemon restart dropped the BiDi
3387
+ // socket, but the Firefox we launched is still serving the port — reattach
3388
+ // by opening a fresh session, then merge the disk tasks. connectFirefox
3389
+ // attaches (never launches) when the port is already served. A failure
3390
+ // returns null like the CDP path, so the caller falls through to disk
3391
+ // reconcile instead of crashing.
3392
+ if (resolved.target.startsWith('firefox-bidi:')) {
3393
+ if (!profile.firefox || !profile.userDataDir)
3394
+ return null;
3395
+ const port = parseEndpointUrl(resolved.target)?.port ?? 9600;
3396
+ try {
3397
+ const ff = await connectFirefox(profile, key, port, { profileDir: profile.userDataDir });
3398
+ const tasks = this.loadTaskState(key);
3399
+ for (const [k, t] of diskTasks) {
3400
+ if (!tasks.has(k))
3401
+ tasks.set(k, t);
3402
+ }
3403
+ return {
3404
+ backend: 'bidi',
3405
+ browserType: 'firefox',
3406
+ bidi: ff.bidi,
3407
+ firefoxProfile: profile.firefox,
3408
+ sessionId: ff.sessionId,
3409
+ profileDir: profile.userDataDir,
3410
+ port: ff.port,
3411
+ pid: ff.pid,
3412
+ electron: false,
3413
+ targetFilter: profile.targetFilter,
3414
+ key,
3415
+ profile: bare,
3416
+ tasks,
3417
+ sessionCache: new Map(),
3418
+ };
3419
+ }
3420
+ catch {
3421
+ return null;
3422
+ }
3423
+ }
3139
3424
  const existingInfo = getRunningChromeInfo(key);
3140
3425
  const parsed = parseEndpointUrl(resolved.target);
3141
3426
  const port = existingInfo?.port ?? parsed?.port;
@@ -3251,8 +3536,14 @@ export class BrowserService {
3251
3536
  }
3252
3537
  else {
3253
3538
  // Shared/borrowed tunnel (or a local connection with no tunnel): close
3254
- // only our own CDP client; killing the tunnel would break the winner.
3255
- if (conn.backend !== 'arc-native') {
3539
+ // only our own client; killing the tunnel would break the winner.
3540
+ if (conn.backend === 'bidi') {
3541
+ try {
3542
+ conn.bidi.close();
3543
+ }
3544
+ catch { /* best effort */ }
3545
+ }
3546
+ else if (conn.backend !== 'arc-native') {
3256
3547
  try {
3257
3548
  conn.cdp.close();
3258
3549
  }
@@ -3298,7 +3589,7 @@ export class BrowserService {
3298
3589
  if (registered !== conn)
3299
3590
  continue;
3300
3591
  try {
3301
- if (conn.backend !== 'arc-native')
3592
+ if (conn.backend !== 'arc-native' && conn.backend !== 'bidi')
3302
3593
  await this.applyDefaultDownloadBehavior(conn, key);
3303
3594
  }
3304
3595
  catch {
@@ -3360,7 +3651,7 @@ export class BrowserService {
3360
3651
  conn = this.registerRehydratedConnection(key, attached);
3361
3652
  if (conn === attached) {
3362
3653
  try {
3363
- if (conn.backend !== 'arc-native')
3654
+ if (conn.backend !== 'arc-native' && conn.backend !== 'bidi')
3364
3655
  await this.applyDefaultDownloadBehavior(conn, key);
3365
3656
  }
3366
3657
  catch {
@@ -3433,6 +3724,45 @@ export class BrowserService {
3433
3724
  tasks,
3434
3725
  };
3435
3726
  }
3727
+ if (conn.backend === 'bidi') {
3728
+ const tasks = [];
3729
+ for (const task of conn.tasks.values()) {
3730
+ let tabs = [];
3731
+ try {
3732
+ tabs = await this.listFirefoxTaskTabs(conn, task);
3733
+ }
3734
+ catch { /* Firefox down */ }
3735
+ const domains = tabs.flatMap((tab) => {
3736
+ try {
3737
+ const domain = new URL(tab.url).hostname.replace(/^www\./, '');
3738
+ return domain && domain !== 'blank' ? [domain] : [];
3739
+ }
3740
+ catch {
3741
+ return [];
3742
+ }
3743
+ });
3744
+ tasks.push({
3745
+ id: task.id,
3746
+ name: task.name,
3747
+ label: task.label ?? task.name,
3748
+ tabCount: Object.keys(task.tabs).length,
3749
+ currentTabId: task.currentTabId,
3750
+ createdAt: task.createdAt,
3751
+ tabs: tabs.length ? tabs.map((t) => ({ id: t.id, url: t.url, title: t.title, current: t.current })) : undefined,
3752
+ domains: domains.length ? [...new Set(domains)] : undefined,
3753
+ });
3754
+ }
3755
+ const parsed = parseConnectionKey(key);
3756
+ return {
3757
+ name: conn.profile ?? parsed.profile,
3758
+ endpoint: parsed.endpoint,
3759
+ key,
3760
+ running: conn.bidi.isOpen,
3761
+ port: conn.port,
3762
+ pid: conn.pid,
3763
+ tasks,
3764
+ };
3765
+ }
3436
3766
  // Fetch all targets once for efficiency
3437
3767
  let targets = [];
3438
3768
  try {
@@ -3655,7 +3985,13 @@ export class BrowserService {
3655
3985
  return undefined;
3656
3986
  if (await isConnHealthy(conn))
3657
3987
  return conn;
3658
- if (conn.backend !== 'arc-native') {
3988
+ if (conn.backend === 'bidi') {
3989
+ try {
3990
+ conn.bidi.close();
3991
+ }
3992
+ catch { /* already closed */ }
3993
+ }
3994
+ else if (conn.backend !== 'arc-native') {
3659
3995
  try {
3660
3996
  conn.cdp.close();
3661
3997
  }
@@ -3894,14 +4230,205 @@ export class BrowserService {
3894
4230
  task.currentTabId = undefined;
3895
4231
  });
3896
4232
  }
4233
+ // ---------------------------------------------------------------------------
4234
+ // Firefox WebDriver BiDi backend helpers (PHNX-4043)
4235
+ // ---------------------------------------------------------------------------
4236
+ /** The BiDi browsing-context id a task owns for `shortId`. */
4237
+ firefoxContext(task, shortId) {
4238
+ const context = task.tabs[shortId];
4239
+ if (!context)
4240
+ throw new Error(`Tab ${shortId} not found`);
4241
+ return context;
4242
+ }
4243
+ /** The context ids currently live in this Firefox (a killed tab drops out). */
4244
+ async firefoxLiveContexts(conn) {
4245
+ const contexts = await bidiTopLevelContexts(conn.bidi);
4246
+ return new Set(contexts.map((c) => c.context));
4247
+ }
4248
+ /**
4249
+ * Same-task reopen for Firefox (mirrors {@link reopenOwnedTab}): the URL is
4250
+ * already live in one of this task's own tabs → reload THAT tab in place and
4251
+ * keep its id, rather than opening a duplicate. Borrowed tabs are excluded —
4252
+ * Firefox never borrows, but the guard keeps parity. Returns the retained
4253
+ * short id, or undefined when nothing matches.
4254
+ */
4255
+ async reopenOwnedFirefoxTab(conn, task, url) {
4256
+ const borrowed = new Set(task.borrowedTabs ?? []);
4257
+ // Read each live tab's URL from browsingContext.getTree metadata rather than
4258
+ // evaluating location.href per tab: the same source the CDP path reads
4259
+ // (Target.getTargets), so a mid-navigation or just-closed sibling tab can
4260
+ // never throw a BiDi "no such frame" and fail an unrelated navigate. A tab
4261
+ // absent from the tree is simply not a reopen candidate.
4262
+ const liveUrlByContext = new Map((await bidiTopLevelContexts(conn.bidi)).map((c) => [c.context, c.url]));
4263
+ const wanted = canonicalTabUrl(url);
4264
+ for (const [shortId, context] of Object.entries(task.tabs)) {
4265
+ if (borrowed.has(shortId))
4266
+ continue;
4267
+ const liveUrl = liveUrlByContext.get(context);
4268
+ if (liveUrl === undefined)
4269
+ continue; // registered but gone from the browser
4270
+ if (canonicalTabUrl(liveUrl) === wanted) {
4271
+ await bidiReload(conn.bidi, context);
4272
+ task.currentTabId = shortId;
4273
+ await this.saveTaskState(task.profile, conn.tasks);
4274
+ return { tabId: shortId };
4275
+ }
4276
+ }
4277
+ return undefined;
4278
+ }
4279
+ async navigateFirefox(conn, task, url, addTab = false) {
4280
+ if (!addTab) {
4281
+ const reopened = await this.reopenOwnedFirefoxTab(conn, task, url);
4282
+ if (reopened) {
4283
+ emit('browser.navigate', { profile: parseConnectionKey(task.profile).profile, task: task.name, url, tabId: reopened.tabId, created: false });
4284
+ return { tabId: reopened.tabId, url, created: false, refreshed: true, message: 'Tab already open—refreshed' };
4285
+ }
4286
+ // Reuse the current tab when there is one — navigate() semantics.
4287
+ const currentShortId = task.currentTabId;
4288
+ if (currentShortId && task.tabs[currentShortId]) {
4289
+ const context = task.tabs[currentShortId];
4290
+ const live = await this.firefoxLiveContexts(conn);
4291
+ if (live.has(context)) {
4292
+ await bidiNavigate(conn.bidi, context, url);
4293
+ await this.saveTaskState(task.profile, conn.tasks);
4294
+ emit('browser.navigate', { profile: parseConnectionKey(task.profile).profile, task: task.name, url, tabId: currentShortId, created: false });
4295
+ return { tabId: currentShortId, url, created: false, refreshed: false };
4296
+ }
4297
+ }
4298
+ }
4299
+ else {
4300
+ const reopened = await this.reopenOwnedFirefoxTab(conn, task, url);
4301
+ if (reopened) {
4302
+ return { tabId: reopened.tabId, url, created: false, refreshed: true, message: 'Tab already open—refreshed' };
4303
+ }
4304
+ }
4305
+ // Open a fresh tab.
4306
+ const context = await bidiCreateTab(conn.bidi);
4307
+ await bidiNavigate(conn.bidi, context, url);
4308
+ const shortId = generateShortId();
4309
+ task.tabs[shortId] = context;
4310
+ task.currentTabId = shortId;
4311
+ await this.saveTaskState(task.profile, conn.tasks);
4312
+ emit('browser.navigate', { profile: parseConnectionKey(task.profile).profile, task: task.name, url, tabId: shortId, created: true });
4313
+ return { tabId: shortId, url, created: true, refreshed: false };
4314
+ }
4315
+ async evaluateFirefox(conn, task, shortId, expression) {
4316
+ const context = this.firefoxContext(task, shortId);
4317
+ const live = await this.firefoxLiveContexts(conn);
4318
+ if (!live.has(context))
4319
+ throw new Error(`Tab ${shortId} is no longer open in Firefox.`);
4320
+ return bidiEvaluate(conn.bidi, context, expression);
4321
+ }
4322
+ async listFirefoxTaskTabs(conn, task) {
4323
+ const live = await bidiTopLevelContexts(conn.bidi);
4324
+ const byId = new Map(live.map((c) => [c.context, c]));
4325
+ const tabs = [];
4326
+ for (const [shortId, context] of Object.entries(task.tabs)) {
4327
+ const info = byId.get(context);
4328
+ if (!info)
4329
+ continue; // tab closed out from under the task — drop it silently.
4330
+ let title = '';
4331
+ try {
4332
+ title = String(await bidiEvaluate(conn.bidi, context, 'document.title'));
4333
+ }
4334
+ catch {
4335
+ /* about: pages or a mid-navigation context — url is enough */
4336
+ }
4337
+ tabs.push({ id: shortId, url: info.url, title, task: task.name, current: shortId === task.currentTabId });
4338
+ }
4339
+ return tabs;
4340
+ }
4341
+ /**
4342
+ * Close every non-borrowed tab a Firefox task owns. Never kills the Firefox
4343
+ * process (that is `stopProfile`'s job) — a killed browser would take the
4344
+ * user's other tabs with it. A tab already gone is skipped.
4345
+ */
4346
+ async closeFirefoxTabs(conn, task) {
4347
+ const borrowed = new Set(task.borrowedTabs ?? []);
4348
+ const live = await this.firefoxLiveContexts(conn);
4349
+ for (const [shortId, context] of Object.entries(task.tabs)) {
4350
+ if (borrowed.has(shortId))
4351
+ continue;
4352
+ if (live.has(context)) {
4353
+ try {
4354
+ await bidiCloseTab(conn.bidi, context);
4355
+ }
4356
+ catch { /* already closed */ }
4357
+ }
4358
+ }
4359
+ }
4360
+ /**
4361
+ * Viewport-center coordinates of a selector-addressed element in a Firefox
4362
+ * tab, scrolled into view first — the (x, y) `input.performActions` clicks.
4363
+ * Throws when the element is gone or has zero size (off-screen / display:none).
4364
+ */
4365
+ async firefoxRefCoords(conn, task, shortId, selector) {
4366
+ const expr = `(() => {
4367
+ const el = document.querySelector(${JSON.stringify(selector)});
4368
+ if (!el) throw new Error('DOM ref is missing');
4369
+ el.scrollIntoView({ block: 'center', inline: 'center' });
4370
+ const r = el.getBoundingClientRect();
4371
+ if (r.width === 0 && r.height === 0) throw new Error('DOM ref has no layout box');
4372
+ return JSON.stringify({ x: r.left + r.width / 2, y: r.top + r.height / 2 });
4373
+ })()`;
4374
+ const raw = await this.evaluateFirefox(conn, task, shortId, expr);
4375
+ const parsed = JSON.parse(String(raw));
4376
+ return parsed;
4377
+ }
4378
+ /**
4379
+ * Screenshot a Firefox tab over BiDi. Same output contract as the CDP path:
4380
+ * `raw` is a pixel-faithful PNG; the default is JPEG iteratively downscaled
4381
+ * under 100 KB so chat-injected screenshots stay token-cheap.
4382
+ */
4383
+ async screenshotFirefox(conn, task, runtimeKey, tabHint, outputPath, quality) {
4384
+ const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
4385
+ const context = this.firefoxContext(task, shortId);
4386
+ const live = await this.firefoxLiveContexts(conn);
4387
+ if (!live.has(context))
4388
+ throw new Error(`Tab ${shortId} is no longer open in Firefox.`);
4389
+ let buffer;
4390
+ let extension;
4391
+ if (quality === 'raw') {
4392
+ buffer = await bidiScreenshot(conn.bidi, context, { type: 'image/png' });
4393
+ extension = 'png';
4394
+ }
4395
+ else {
4396
+ buffer = await bidiScreenshot(conn.bidi, context, { type: 'image/jpeg', quality: 0.7 });
4397
+ const MAX_SIZE = 100 * 1024;
4398
+ let q = 0.5;
4399
+ while (buffer.length > MAX_SIZE && q > 0.1) {
4400
+ buffer = await bidiScreenshot(conn.bidi, context, { type: 'image/jpeg', quality: q });
4401
+ q -= 0.1;
4402
+ }
4403
+ extension = 'jpg';
4404
+ }
4405
+ const sessionsDir = getProfileSessionsDir(runtimeKey, task.name);
4406
+ const automaticPath = path.join(sessionsDir, `${Date.now()}.${extension}`);
4407
+ const finalPath = resolveScreenshotOutputPath(outputPath, automaticPath);
4408
+ await fs.promises.mkdir(path.dirname(finalPath), { recursive: true });
4409
+ await fs.promises.writeFile(finalPath, buffer);
4410
+ const dims = (extension === 'png' ? readPngDimensions(buffer) : readJpegDimensions(buffer)) ??
4411
+ { width: 0, height: 0 };
4412
+ emit('browser.screenshot', {
4413
+ profile: parseConnectionKey(runtimeKey).profile,
4414
+ task: task.name,
4415
+ tabId: shortId,
4416
+ path: finalPath,
4417
+ bytes: buffer.length,
4418
+ width: dims.width,
4419
+ height: dims.height,
4420
+ quality,
4421
+ });
4422
+ return { path: finalPath, bytes: buffer.length, width: dims.width, height: dims.height };
4423
+ }
3897
4424
  /** Connect a profile at `target`, register it under `key`, and arm downloads. */
3898
4425
  async openConnection(profile, target, key, profileName, opts = {}) {
3899
4426
  const conn = await this.connectProfile(profile, target, key, opts);
3900
4427
  conn.key = key;
3901
4428
  conn.profile = profileName;
3902
4429
  this.connections.set(key, conn);
3903
- // Download behavior configuration is CDP-specific; skip for native Arc.
3904
- if (conn.backend !== 'arc-native') {
4430
+ // Download behavior configuration is CDP-specific; skip for native Arc + Firefox.
4431
+ if (conn.backend !== 'arc-native' && conn.backend !== 'bidi') {
3905
4432
  await this.applyDefaultDownloadBehavior(conn, key);
3906
4433
  }
3907
4434
  return conn;
@@ -3941,6 +4468,10 @@ export class BrowserService {
3941
4468
  if (conn.backend === 'arc-native') {
3942
4469
  throw new ArcNativeCapabilityError('show', 'Task-less viewer tabs are unavailable for native Arc because creation intent cannot be bound to an owning task.');
3943
4470
  }
4471
+ if (conn.backend === 'bidi') {
4472
+ throw new FirefoxCapabilityError('show', 'Task-less viewer tabs are unavailable for a headless-automation Firefox profile. ' +
4473
+ 'Use a Chromium-family profile, or open the URL in your OS browser.');
4474
+ }
3944
4475
  // createPageTarget refuses Arc with an actionable error rather than crashing it.
3945
4476
  const created = await this.createPageTarget(conn, { url });
3946
4477
  this.invalidateTargetCache(conn);