@steipete/oracle 0.19.0 → 0.20.0

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 (35) hide show
  1. package/dist/bin/oracle-cli.js +11 -13
  2. package/dist/src/browser/actions/deepResearch.js +168 -44
  3. package/dist/src/browser/actions/modelSelection.js +78 -1
  4. package/dist/src/browser/actions/promptComposer.js +3 -0
  5. package/dist/src/browser/actions/thinkingTime.js +38 -1
  6. package/dist/src/browser/actions/webSearch.js +96 -0
  7. package/dist/src/browser/chromeLifecycle.js +5 -4
  8. package/dist/src/browser/config.js +1 -1
  9. package/dist/src/browser/index.js +111 -43
  10. package/dist/src/browser/liveTabs.js +3 -4
  11. package/dist/src/browser/providers/chatgptDomProvider.js +1 -0
  12. package/dist/src/browser/reattach.js +21 -1
  13. package/dist/src/browser/recoveryTarget.js +131 -0
  14. package/dist/src/browser/sessionRunner.js +3 -1
  15. package/dist/src/browser/tabLeaseRegistry.js +20 -0
  16. package/dist/src/browser/targetClaim.js +54 -0
  17. package/dist/src/cli/browserConfig.js +33 -3
  18. package/dist/src/cli/browserTabs.js +3 -0
  19. package/dist/src/cli/options.js +15 -0
  20. package/dist/src/cli/recoveredBrowserHarvest.js +50 -0
  21. package/dist/src/cli/runOptions.js +19 -4
  22. package/dist/src/cli/sessionDisplay.js +4 -5
  23. package/dist/src/cli/sessionRunner.js +4 -5
  24. package/dist/src/mcp/tools/consult.js +2 -2
  25. package/dist/src/mcp/types.js +1 -1
  26. package/dist/src/oracle/config.js +14 -0
  27. package/dist/src/oracle/geminiModels.js +1 -0
  28. package/dist/src/oracle/run.js +27 -4
  29. package/dist/src/remote/client.js +3 -0
  30. package/dist/src/remote/server.js +1 -0
  31. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  32. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  33. package/package.json +3 -3
  34. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  35. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
@@ -0,0 +1,96 @@
1
+ import { BrowserAutomationError } from "../../oracle/errors.js";
2
+ import { INPUT_SELECTORS } from "../constants.js";
3
+ import { delay } from "../utils.js";
4
+ import { activateComposerPlus, captureComposerNavigationUrl, assertComposerPlusStayedInPlace, } from "./attachments.js";
5
+ import { buildComposerNavigationValidationExpression } from "./attachmentContext.js";
6
+ import { buildClickDispatcher } from "./domEvents.js";
7
+ export function matchesWebSearchMenuLabel(value) {
8
+ return [
9
+ "search",
10
+ "searchfindontheweb",
11
+ "websearch",
12
+ "websearchfindreal-timenewsandinfo",
13
+ ].includes(value.replace(/\s+/g, "").toLowerCase());
14
+ }
15
+ export function buildWebSearchVerificationExpression(prompt) {
16
+ return `(() => {
17
+ const visible = node => node instanceof HTMLElement && node.getBoundingClientRect().width > 0 && node.getBoundingClientRect().height > 0;
18
+ const editor = ${JSON.stringify(INPUT_SELECTORS)}.flatMap(selector => Array.from(document.querySelectorAll(selector))).find(visible);
19
+ if (!editor) return { selected: false, promptMatches: false };
20
+ const chip = editor.querySelector('[data-inline-selection-pill][data-id="search"][data-system-hint-type="search"]');
21
+ const copy = editor.cloneNode(true);
22
+ copy.querySelectorAll('[data-inline-selection-pill], [data-inline-selection-pill-cursor-target]').forEach(node => node.remove());
23
+ const readText = node => {
24
+ if (node.nodeType === 3) return node.textContent ?? '';
25
+ const text = Array.from(node.childNodes).map(readText).join('');
26
+ return text + (['P', 'DIV', 'BR', 'LI', 'PRE'].includes(node.nodeName) ? '\\n' : '');
27
+ };
28
+ const normalize = text => String(text ?? '').replace(/[\\u200b\\ufeff]/g, '').replace(/\\s+/g, ' ').trim();
29
+ return { selected: Boolean(chip && visible(chip)), promptMatches: normalize(readText(copy)) === normalize(${JSON.stringify(prompt)}) };
30
+ })()`;
31
+ }
32
+ export function buildWebSearchSelectionExpression(navigationUrl) {
33
+ return `(() => {
34
+ ${buildClickDispatcher()}
35
+ const matchesLabel = ${matchesWebSearchMenuLabel.toString()};
36
+ const navigation = ${buildComposerNavigationValidationExpression(navigationUrl)};
37
+ if (!navigation.contextMatches || navigation.workSelected || navigation.modeUnverified) return 'context-changed';
38
+ const visible = node => node instanceof HTMLElement && node.getBoundingClientRect().width > 0 && node.getBoundingClientRect().height > 0;
39
+ const roots = Array.from(document.querySelectorAll('main .popover, [data-radix-popper-content-wrapper], [data-floating-ui-portal], [role="menu"], [role="listbox"]')).filter(visible);
40
+ const candidates = roots.flatMap(root => Array.from(root.querySelectorAll('[data-radix-collection-item], [role="menuitem"], [role="option"], .__menu-item, [class*="menu-item"]')));
41
+ const match = candidates.find(node => {
42
+ if (!visible(node) || node.hasAttribute('disabled') || node.getAttribute('aria-disabled') === 'true') return false;
43
+ return matchesLabel(node.textContent ?? '');
44
+ });
45
+ if (!match) return 'missing';
46
+ dispatchClickSequence(match);
47
+ return 'clicked';
48
+ })()`;
49
+ }
50
+ /** Web Search is an inline editor hint, so activate it after staging text/attachments. */
51
+ export async function activateWebSearch(runtime, input, prompt, logger) {
52
+ const navigationUrl = await captureComposerNavigationUrl(runtime);
53
+ const verify = async () => {
54
+ const { result, exceptionDetails } = await runtime.evaluate({
55
+ expression: buildWebSearchVerificationExpression(prompt),
56
+ returnByValue: true,
57
+ });
58
+ if (exceptionDetails)
59
+ return false;
60
+ return result?.value?.selected === true && result?.value?.promptMatches === true;
61
+ };
62
+ if (await verify())
63
+ return;
64
+ const activated = await activateComposerPlus(runtime, input, navigationUrl);
65
+ if (activated.method === "unavailable")
66
+ throw new BrowserAutomationError("Web Search requires the ChatGPT composer tools menu.", {
67
+ stage: "web-search-activate",
68
+ });
69
+ const deadline = Date.now() + 5_000;
70
+ let clicked = false;
71
+ while (Date.now() < deadline) {
72
+ const outcome = await runtime.evaluate({
73
+ expression: buildWebSearchSelectionExpression(navigationUrl),
74
+ returnByValue: true,
75
+ });
76
+ if (outcome.result?.value === "clicked") {
77
+ clicked = true;
78
+ break;
79
+ }
80
+ if (outcome.exceptionDetails || outcome.result?.value !== "missing")
81
+ break;
82
+ await delay(100);
83
+ }
84
+ if (clicked) {
85
+ const confirmationDeadline = Date.now() + 3_000;
86
+ do {
87
+ await assertComposerPlusStayedInPlace(runtime, navigationUrl);
88
+ if (await verify()) {
89
+ logger("Web Search selected; inline search hint and staged prompt verified.");
90
+ return;
91
+ }
92
+ await delay(100);
93
+ } while (Date.now() < confirmationDeadline);
94
+ }
95
+ throw new BrowserAutomationError("Web Search selection could not be verified; the prompt was not submitted. This pilot supports the English ChatGPT Web search control.", { stage: "web-search-activate" });
96
+ }
@@ -352,9 +352,10 @@ export async function connectToRemoteChrome(host, port, logger, targetUrl, brows
352
352
  return {
353
353
  client: targetConnection.client,
354
354
  targetId: targetConnection.targetId,
355
- close: async () => {
355
+ close: async (closeOptions) => {
356
356
  await targetConnection.client.close().catch(() => undefined);
357
- await closeRemoteChromeTarget(host, port, targetConnection.targetId, logger);
357
+ if (!closeOptions?.preserveTarget)
358
+ await closeRemoteChromeTarget(host, port, targetConnection.targetId, logger);
358
359
  },
359
360
  };
360
361
  }
@@ -431,9 +432,9 @@ export async function connectToRemoteChromeTarget(host, port, logger, options) {
431
432
  client,
432
433
  targetId,
433
434
  browserWSEndpoint: options.browserWSEndpoint,
434
- close: async () => {
435
+ close: async (closeOptions) => {
435
436
  await browser.Target.detachFromTarget({ sessionId: attached.sessionId }).catch(() => undefined);
436
- if (options.closeTargetOnDispose && targetId) {
437
+ if (options.closeTargetOnDispose && targetId && !closeOptions?.preserveTarget) {
437
438
  await browser.Target.closeTarget({ targetId }).catch(() => undefined);
438
439
  }
439
440
  await browser.close().catch(() => undefined);
@@ -123,7 +123,7 @@ export function resolveBrowserConfig(config) {
123
123
  };
124
124
  }
125
125
  function normalizeResearchMode(value) {
126
- return value === "deep" ? "deep" : "off";
126
+ return value === "deep" || value === "search" ? value : "off";
127
127
  }
128
128
  function normalizeArchiveMode(value) {
129
129
  return value === "always" || value === "never" ? value : "auto";
@@ -2,6 +2,8 @@ import { mkdtemp, rm, mkdir } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import os from "node:os";
4
4
  import net from "node:net";
5
+ import { randomUUID } from "node:crypto";
6
+ import { claimBrowserTarget } from "./targetClaim.js";
5
7
  import { resolveBrowserConfig } from "./config.js";
6
8
  import { copyChromeProfile } from "./profileCopy.js";
7
9
  import { BrowserCancellation, withoutBrowserCancellation } from "./cancellation.js";
@@ -554,19 +556,13 @@ export function isLocalChromeHostForTest(host) {
554
556
  return isLocalChromeHost(host);
555
557
  }
556
558
  async function closeRemoteConnectionAfterRun(options) {
557
- if (options.connectionClosedUnexpectedly) {
558
- return;
559
- }
560
559
  if (!options.connection) {
561
560
  await options.client?.close();
562
561
  return;
563
562
  }
564
- if (options.runStatus === "complete") {
565
- await options.connection.close();
566
- }
567
- else {
568
- await options.client?.close();
569
- }
563
+ await options.connection.close({
564
+ preserveTarget: options.connectionClosedUnexpectedly || options.preserveTarget,
565
+ });
570
566
  }
571
567
  function shouldCloseOwnedRunTargetAfterRun(options) {
572
568
  return (options.ownsTarget &&
@@ -712,8 +708,11 @@ async function runBrowserModeInternal(options, cancellation) {
712
708
  let lastTargetId;
713
709
  let lastUrl;
714
710
  let promptSubmitted = false;
711
+ let ownedRecoveryTarget;
712
+ const targetClaimId = randomUUID();
715
713
  let modelSelectionEvidence;
716
714
  let thinkingSelectionEvidence;
715
+ let researchPlan;
717
716
  let tabLease = null;
718
717
  let conversationUrlMonitor = null;
719
718
  const emitRuntimeHint = async () => {
@@ -729,8 +728,10 @@ async function runBrowserModeInternal(options, cancellation) {
729
728
  tabUrl: lastUrl,
730
729
  conversationId,
731
730
  promptSubmitted,
731
+ ownedRecoveryTarget,
732
732
  userDataDir,
733
733
  controllerPid: process.pid,
734
+ researchPlan,
734
735
  };
735
736
  try {
736
737
  await runtimeHintCb?.(hint, modelSelectionEvidence);
@@ -929,7 +930,15 @@ async function runBrowserModeInternal(options, cancellation) {
929
930
  });
930
931
  client = cancellation.client(connection.client);
931
932
  isolatedTargetId = connection.targetId ?? null;
932
- ownsTarget = true;
933
+ ownsTarget = Boolean(connection.targetId);
934
+ if (connection.targetId && (!config.keepBrowser || options.closeOwnedTabOnComplete)) {
935
+ ownedRecoveryTarget = {
936
+ host: chromeHost,
937
+ port: chrome.port,
938
+ targetId: connection.targetId,
939
+ claimId: targetClaimId,
940
+ };
941
+ }
933
942
  }
934
943
  if (tabLease && isolatedTargetId) {
935
944
  await tabLease.update({
@@ -977,7 +986,9 @@ async function runBrowserModeInternal(options, cancellation) {
977
986
  ? extractConversationIdFromUrl(liveness.matchedUrl ?? lastUrl ?? "")
978
987
  : undefined,
979
988
  promptSubmitted,
989
+ ownedRecoveryTarget,
980
990
  controllerPid: process.pid,
991
+ researchPlan,
981
992
  },
982
993
  }));
983
994
  })();
@@ -990,6 +1001,8 @@ async function runBrowserModeInternal(options, cancellation) {
990
1001
  domainEnablers.push(DOM.enable());
991
1002
  }
992
1003
  await Promise.all(domainEnablers);
1004
+ if (config.browserTabRef)
1005
+ await claimBrowserTarget(Runtime, targetClaimId);
993
1006
  if (!config.headless && config.hideWindow) {
994
1007
  await positionChromeWindowOffscreen(client, userDataDir, logger);
995
1008
  }
@@ -1251,6 +1264,7 @@ async function runBrowserModeInternal(options, cancellation) {
1251
1264
  await withoutBrowserCancellation(() => handle.release()).catch(() => undefined);
1252
1265
  };
1253
1266
  const submitOnce = async (prompt, submissionAttachments) => {
1267
+ await claimBrowserTarget(Runtime, targetClaimId);
1254
1268
  const baselineSnapshot = await readAssistantSnapshot(Runtime).catch(() => null);
1255
1269
  const baselineAssistantText = typeof baselineSnapshot?.text === "string" ? baselineSnapshot.text.trim() : "";
1256
1270
  const attachmentNames = submissionAttachments.map((a) => path.basename(a.path));
@@ -1313,6 +1327,7 @@ async function runBrowserModeInternal(options, cancellation) {
1313
1327
  attachmentNames: attachmentExpectations,
1314
1328
  attachmentNavigationUrl,
1315
1329
  onPromptSubmitted: markPromptSubmitted,
1330
+ webSearch: config.researchMode === "search",
1316
1331
  };
1317
1332
  const deepResearchTargetBaseline = deepResearch && client
1318
1333
  ? await captureDeepResearchTargetBaseline(client, logger)
@@ -1387,7 +1402,17 @@ async function runBrowserModeInternal(options, cancellation) {
1387
1402
  }
1388
1403
  const imageArtifactMinTurnIndex = baselineTurns;
1389
1404
  if (deepResearch) {
1390
- await raceWithDisconnect(waitForResearchPlanAutoConfirm(Runtime, logger));
1405
+ await raceWithDisconnect(waitForResearchPlanAutoConfirm(Runtime, logger, undefined, {
1406
+ Page,
1407
+ client,
1408
+ ignoredTargetKeys: deepResearchTargetKeys,
1409
+ targetBaselineCaptured: deepResearchTargetBaselineCaptured,
1410
+ minTurnIndex: baselineTurns,
1411
+ onPlan: async (plan) => {
1412
+ researchPlan = plan;
1413
+ await emitRuntimeHint();
1414
+ },
1415
+ }));
1391
1416
  const researchResult = await raceWithDisconnect(waitForDeepResearchCompletion(Runtime, logger, config.timeoutMs, baselineTurns, Page, client, {
1392
1417
  ignoredTargetKeys: deepResearchTargetKeys,
1393
1418
  targetBaselineCaptured: deepResearchTargetBaselineCaptured,
@@ -1438,7 +1463,9 @@ async function runBrowserModeInternal(options, cancellation) {
1438
1463
  tabUrl: lastUrl,
1439
1464
  conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
1440
1465
  promptSubmitted,
1466
+ ownedRecoveryTarget,
1441
1467
  controllerPid: process.pid,
1468
+ researchPlan,
1442
1469
  };
1443
1470
  }
1444
1471
  // Helper to normalize text for echo detection (collapse whitespace, lowercase)
@@ -1528,6 +1555,7 @@ async function runBrowserModeInternal(options, cancellation) {
1528
1555
  tabUrl: lastUrl,
1529
1556
  conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
1530
1557
  promptSubmitted,
1558
+ ownedRecoveryTarget,
1531
1559
  controllerPid: process.pid,
1532
1560
  },
1533
1561
  });
@@ -1584,6 +1612,7 @@ async function runBrowserModeInternal(options, cancellation) {
1584
1612
  tabUrl: lastUrl,
1585
1613
  conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
1586
1614
  promptSubmitted,
1615
+ ownedRecoveryTarget,
1587
1616
  controllerPid: process.pid,
1588
1617
  };
1589
1618
  throw await createAssistantTimeoutError({
@@ -1809,6 +1838,7 @@ async function runBrowserModeInternal(options, cancellation) {
1809
1838
  tabUrl: lastUrl,
1810
1839
  conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
1811
1840
  promptSubmitted,
1841
+ ownedRecoveryTarget,
1812
1842
  controllerPid: process.pid,
1813
1843
  },
1814
1844
  }),
@@ -1876,6 +1906,7 @@ async function runBrowserModeInternal(options, cancellation) {
1876
1906
  tabUrl: lastUrl,
1877
1907
  conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
1878
1908
  promptSubmitted,
1909
+ ownedRecoveryTarget,
1879
1910
  controllerPid: process.pid,
1880
1911
  };
1881
1912
  }
@@ -1902,6 +1933,7 @@ async function runBrowserModeInternal(options, cancellation) {
1902
1933
  chromeTargetId: lastTargetId,
1903
1934
  tabUrl: lastUrl,
1904
1935
  promptSubmitted,
1936
+ ownedRecoveryTarget,
1905
1937
  controllerPid: process.pid,
1906
1938
  };
1907
1939
  const reuseProfileHint = `oracle --engine browser --browser-manual-login ` +
@@ -1965,7 +1997,9 @@ async function runBrowserModeInternal(options, cancellation) {
1965
1997
  ? extractConversationIdFromUrl(liveness.matchedUrl ?? lastUrl ?? "")
1966
1998
  : undefined,
1967
1999
  promptSubmitted,
2000
+ ownedRecoveryTarget,
1968
2001
  controllerPid: process.pid,
2002
+ researchPlan,
1969
2003
  },
1970
2004
  }, normalizedError);
1971
2005
  }
@@ -2363,8 +2397,11 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2363
2397
  let tabLease = null;
2364
2398
  let lastUrl;
2365
2399
  let promptSubmitted = false;
2400
+ let ownedRecoveryTarget;
2401
+ const targetClaimId = randomUUID();
2366
2402
  let modelSelectionEvidence;
2367
2403
  let thinkingSelectionEvidence;
2404
+ let researchPlan;
2368
2405
  let attachedExistingTab = false;
2369
2406
  let ownsTarget = true;
2370
2407
  let conversationUrlMonitor = null;
@@ -2382,7 +2419,9 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2382
2419
  tabUrl: lastUrl,
2383
2420
  conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
2384
2421
  promptSubmitted,
2422
+ ownedRecoveryTarget,
2385
2423
  controllerPid: process.pid,
2424
+ researchPlan,
2386
2425
  }, modelSelectionEvidence);
2387
2426
  await tabLease?.update({
2388
2427
  chromeHost: host,
@@ -2452,7 +2491,16 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2452
2491
  }), (connection) => connection.close());
2453
2492
  client = cancellation.client(connection.client);
2454
2493
  remoteTargetId = connection.targetId ?? null;
2455
- ownsTarget = true;
2494
+ ownsTarget = Boolean(connection.targetId);
2495
+ if (connection.targetId && (!config.keepBrowser || options.closeOwnedTabOnComplete)) {
2496
+ ownedRecoveryTarget = {
2497
+ host,
2498
+ port,
2499
+ targetId: connection.targetId,
2500
+ browserWSEndpoint,
2501
+ claimId: targetClaimId,
2502
+ };
2503
+ }
2456
2504
  }
2457
2505
  if (tabLease && remoteTargetId) {
2458
2506
  await tabLease.update({
@@ -2472,6 +2520,8 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2472
2520
  domainEnablers.push(DOM.enable());
2473
2521
  }
2474
2522
  await Promise.all(domainEnablers);
2523
+ if (config.browserTabRef)
2524
+ await claimBrowserTarget(Runtime, targetClaimId);
2475
2525
  removeDialogHandler = installJavaScriptDialogAutoDismissal(Page, logger);
2476
2526
  await enableFocusEmulation(client, logger, "remote target");
2477
2527
  const activeConversationUrlMonitor = createConversationUrlMonitor({
@@ -2572,6 +2622,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2572
2622
  });
2573
2623
  }
2574
2624
  const submitOnce = async (prompt, submissionAttachments) => {
2625
+ await claimBrowserTarget(Runtime, targetClaimId);
2575
2626
  const baselineSnapshot = await readAssistantSnapshot(Runtime).catch(() => null);
2576
2627
  const baselineAssistantText = typeof baselineSnapshot?.text === "string" ? baselineSnapshot.text.trim() : "";
2577
2628
  const attachmentNames = submissionAttachments.map((a) => path.basename(a.path));
@@ -2629,6 +2680,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2629
2680
  attachmentNames: attachmentExpectations,
2630
2681
  attachmentNavigationUrl,
2631
2682
  onPromptSubmitted: markPromptSubmitted,
2683
+ webSearch: config.researchMode === "search",
2632
2684
  };
2633
2685
  const deepResearchTargetBaseline = deepResearch && client
2634
2686
  ? await captureDeepResearchTargetBaseline(client, logger)
@@ -2679,7 +2731,17 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2679
2731
  deepResearchTargetBaselineCaptured = submission.deepResearchTargetBaselineCaptured ?? false;
2680
2732
  const imageArtifactMinTurnIndex = baselineTurns;
2681
2733
  if (deepResearch) {
2682
- await waitForResearchPlanAutoConfirm(Runtime, logger);
2734
+ await waitForResearchPlanAutoConfirm(Runtime, logger, undefined, {
2735
+ Page,
2736
+ client,
2737
+ ignoredTargetKeys: deepResearchTargetKeys,
2738
+ targetBaselineCaptured: deepResearchTargetBaselineCaptured,
2739
+ minTurnIndex: baselineTurns,
2740
+ onPlan: async (plan) => {
2741
+ researchPlan = plan;
2742
+ await emitRuntimeHint();
2743
+ },
2744
+ });
2683
2745
  const researchResult = await waitForDeepResearchCompletion(Runtime, logger, config.timeoutMs, baselineTurns, Page, client, {
2684
2746
  ignoredTargetKeys: deepResearchTargetKeys,
2685
2747
  targetBaselineCaptured: deepResearchTargetBaselineCaptured,
@@ -2728,7 +2790,9 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2728
2790
  tabUrl: lastUrl,
2729
2791
  conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
2730
2792
  promptSubmitted,
2793
+ ownedRecoveryTarget,
2731
2794
  controllerPid: process.pid,
2795
+ researchPlan,
2732
2796
  };
2733
2797
  }
2734
2798
  // Helper to normalize text for echo detection (collapse whitespace, lowercase)
@@ -2817,6 +2881,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2817
2881
  tabUrl: lastUrl,
2818
2882
  conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
2819
2883
  promptSubmitted,
2884
+ ownedRecoveryTarget,
2820
2885
  controllerPid: process.pid,
2821
2886
  },
2822
2887
  });
@@ -2875,6 +2940,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2875
2940
  tabUrl: lastUrl,
2876
2941
  conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
2877
2942
  promptSubmitted,
2943
+ ownedRecoveryTarget,
2878
2944
  controllerPid: process.pid,
2879
2945
  };
2880
2946
  throw await createAssistantTimeoutError({
@@ -3058,6 +3124,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
3058
3124
  tabUrl: lastUrl,
3059
3125
  conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
3060
3126
  promptSubmitted,
3127
+ ownedRecoveryTarget,
3061
3128
  controllerPid: process.pid,
3062
3129
  },
3063
3130
  }),
@@ -3120,6 +3187,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
3120
3187
  tabUrl: lastUrl,
3121
3188
  conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
3122
3189
  promptSubmitted,
3190
+ ownedRecoveryTarget,
3123
3191
  artifacts: savedArtifacts,
3124
3192
  generatedImages: imageArtifacts.generatedImages,
3125
3193
  savedImages: imageArtifacts.savedImages,
@@ -3168,7 +3236,9 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
3168
3236
  ? extractConversationIdFromUrl(liveness.matchedUrl ?? lastUrl ?? "")
3169
3237
  : undefined,
3170
3238
  promptSubmitted,
3239
+ ownedRecoveryTarget,
3171
3240
  controllerPid: process.pid,
3241
+ researchPlan,
3172
3242
  },
3173
3243
  });
3174
3244
  }
@@ -3176,17 +3246,6 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
3176
3246
  await withoutBrowserCancellation(async () => {
3177
3247
  stopThinkingMonitor?.();
3178
3248
  await conversationUrlMonitor?.stop();
3179
- try {
3180
- await closeRemoteConnectionAfterRun({
3181
- connectionClosedUnexpectedly,
3182
- connection,
3183
- client,
3184
- runStatus,
3185
- });
3186
- }
3187
- catch {
3188
- // ignore
3189
- }
3190
3249
  removeDialogHandler?.();
3191
3250
  const keepRemoteBrowser = Boolean(config.keepBrowser);
3192
3251
  const shouldCloseOwnedRemoteTarget = shouldCloseOwnedRunTargetAfterRun({
@@ -3196,33 +3255,42 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
3196
3255
  closeOwnedTabOnComplete: options.closeOwnedTabOnComplete,
3197
3256
  closeOwnedTabOnCancel: options.closeOwnedTabOnCancel,
3198
3257
  });
3199
- const closeOwnedRemoteTarget = async () => {
3200
- if (!shouldCloseOwnedRemoteTarget || !remoteTargetId) {
3201
- return;
3202
- }
3203
- const safeToClose = !keepRemoteBrowser ||
3204
- Boolean(await ensureChromePageTargetAfterClose(port, remoteTargetId, logger, host));
3205
- if (!safeToClose) {
3206
- logger(`[browser] Leaving completed remote browser tab open because Chrome has no replacement page target.`);
3207
- return;
3208
- }
3209
- const closeConfirmed = await closeTab(port, remoteTargetId, logger, host);
3210
- if (!closeConfirmed && keepRemoteBrowser) {
3211
- const replacementTargetId = await createChromePageTarget(port, logger, host);
3212
- if (!replacementTargetId) {
3213
- logger(`[browser] Remote Chrome page retention could not be verified after closing ${remoteTargetId}.`);
3258
+ const closeConnection = async () => {
3259
+ let preserveTarget = !shouldCloseOwnedRemoteTarget;
3260
+ if (!preserveTarget && keepRemoteBrowser && client && remoteTargetId) {
3261
+ try {
3262
+ const { targetInfos } = await client.Target.getTargets();
3263
+ if (!targetInfos.some((target) => target.type === "page" && target.targetId !== remoteTargetId)) {
3264
+ const replacement = await client.Target.createTarget({ url: "about:blank" });
3265
+ if (!replacement.targetId)
3266
+ preserveTarget = true;
3267
+ }
3268
+ }
3269
+ catch {
3270
+ preserveTarget = true;
3214
3271
  }
3215
3272
  }
3273
+ await closeRemoteConnectionAfterRun({
3274
+ connectionClosedUnexpectedly,
3275
+ connection,
3276
+ client,
3277
+ preserveTarget,
3278
+ });
3216
3279
  };
3217
3280
  if (tabLease) {
3218
3281
  const handle = tabLease;
3219
3282
  tabLease = null;
3220
- await handle
3221
- .release({ onRelease: async () => closeOwnedRemoteTarget() })
3222
- .catch(() => undefined);
3283
+ await handle.release({ onRelease: closeConnection }).catch(async () => {
3284
+ await closeRemoteConnectionAfterRun({
3285
+ connectionClosedUnexpectedly,
3286
+ connection,
3287
+ client,
3288
+ preserveTarget: true,
3289
+ }).catch(() => undefined);
3290
+ });
3223
3291
  }
3224
3292
  else {
3225
- await closeOwnedRemoteTarget();
3293
+ await closeConnection().catch(() => undefined);
3226
3294
  }
3227
3295
  // Don't kill remote Chrome - it's not ours to manage
3228
3296
  const totalSeconds = (Date.now() - startedAt) / 1000;
@@ -1,6 +1,6 @@
1
1
  import CDP from "chrome-remote-interface";
2
2
  import { createHash } from "node:crypto";
3
- import { ANSWER_SELECTORS, ASSISTANT_ROLE_SELECTOR, INPUT_SELECTORS, MODEL_BUTTON_SELECTOR, SEND_BUTTON_SELECTORS, STOP_BUTTON_SELECTOR, } from "./constants.js";
3
+ import { ANSWER_SELECTORS, ASSISTANT_ROLE_SELECTOR, INPUT_SELECTORS, MODEL_BUTTON_SELECTOR, SEND_BUTTON_SELECTORS, STOP_BUTTON_SELECTORS, } from "./constants.js";
4
4
  import { captureAssistantMarkdown, readAssistantSnapshot } from "./actions/assistantResponse.js";
5
5
  import { buildConversationTurnListExpression } from "./conversationTurns.js";
6
6
  import { extractStableConversationIdFromUrl } from "./conversationUrl.js";
@@ -62,7 +62,7 @@ function buildTabInspectionExpression() {
62
62
  const answerSelectorsLiteral = JSON.stringify(ANSWER_SELECTORS);
63
63
  const assistantRoleLiteral = escapeLiteral(ASSISTANT_ROLE_SELECTOR);
64
64
  const modelButtonSelectorLiteral = escapeLiteral(MODEL_BUTTON_SELECTOR);
65
- const stopSelectorLiteral = escapeLiteral(STOP_BUTTON_SELECTOR);
65
+ const stopSelectorLiteral = escapeLiteral(STOP_BUTTON_SELECTORS.join(","));
66
66
  return `(() => {
67
67
  const INPUT_SELECTORS = ${inputSelectorsLiteral};
68
68
  const SEND_SELECTORS = ${sendSelectorsLiteral};
@@ -91,8 +91,7 @@ function buildTabInspectionExpression() {
91
91
  const label = normalize(node.textContent || node.getAttribute('aria-label') || node.getAttribute('title'));
92
92
  return LOGIN_CTA.test(label);
93
93
  });
94
- const stopButton = document.querySelector(STOP_BUTTON_SELECTOR);
95
- const stopExists = Boolean(stopButton && isVisible(stopButton));
94
+ const stopExists = Array.from(document.querySelectorAll(STOP_BUTTON_SELECTOR)).some(isVisible);
96
95
  const sendButton = firstVisible(SEND_SELECTORS);
97
96
  const sendExists = Boolean(sendButton);
98
97
  const promptNode = firstVisible(INPUT_SELECTORS);
@@ -27,6 +27,7 @@ async function submitPromptViaAdapter(ctx) {
27
27
  inputTimeoutMs: state.inputTimeoutMs ?? undefined,
28
28
  attachmentTimeoutMs: state.attachmentTimeoutMs ?? undefined,
29
29
  onPromptSubmitted: state.onPromptSubmitted,
30
+ webSearch: state.webSearch,
30
31
  }, ctx.prompt, state.logger);
31
32
  state.committedTurns =
32
33
  typeof committedTurns === "number" && Number.isFinite(committedTurns) ? committedTurns : null;
@@ -66,6 +66,19 @@ export async function resumeBrowserSession(runtime, config, logger, deps = {}) {
66
66
  closeAttachedConnection = () => connection.close();
67
67
  const client = connection.client;
68
68
  const { Runtime, DOM, Page } = client;
69
+ const captureIdentity = async () => {
70
+ const targetId = target?.targetId ?? target?.id;
71
+ if (!targetId || !port)
72
+ return undefined;
73
+ const { result } = await withTimeout(Runtime.evaluate({ expression: "location.href", returnByValue: true }), 2_000, "Recovery target identity unavailable");
74
+ return {
75
+ host,
76
+ port,
77
+ targetId,
78
+ browserWSEndpoint,
79
+ conversationId: extractConversationIdFromUrl(typeof result?.value === "string" ? result.value : ""),
80
+ };
81
+ };
69
82
  if (Runtime?.enable) {
70
83
  await Runtime.enable();
71
84
  }
@@ -117,10 +130,12 @@ export async function resumeBrowserSession(runtime, config, logger, deps = {}) {
117
130
  const researchResult = await withTimeout(waitForDeepResearch(Runtime, logger, timeoutMs, minTurnIndex ?? undefined, Page, client, {
118
131
  requireScopedTargetOwner: true,
119
132
  }), timeoutMs + 5_000, "Reattach Deep Research response timed out");
133
+ const captureTarget = await captureIdentity().catch(() => undefined);
120
134
  await closeAttached();
121
135
  return {
122
136
  answerText: researchResult.text,
123
137
  answerMarkdown: researchResult.text,
138
+ captureTarget,
124
139
  };
125
140
  }
126
141
  const promptEcho = buildPromptEchoMatcher(deps.promptPreview);
@@ -128,8 +143,13 @@ export async function resumeBrowserSession(runtime, config, logger, deps = {}) {
128
143
  const recovered = await recoverPromptEcho(Runtime, answer, promptEcho, logger, minTurnIndex, timeoutMs);
129
144
  const markdown = (await withTimeout(captureMarkdown(Runtime, recovered.meta, logger), 15_000, "Reattach markdown capture timed out")) ?? recovered.text;
130
145
  const aligned = alignPromptEchoMarkdown(recovered.text, markdown, promptEcho, logger);
146
+ const captureTarget = await captureIdentity().catch(() => undefined);
131
147
  await closeAttached();
132
- return { answerText: aligned.answerText, answerMarkdown: aligned.answerMarkdown };
148
+ return {
149
+ answerText: aligned.answerText,
150
+ answerMarkdown: aligned.answerMarkdown,
151
+ captureTarget,
152
+ };
133
153
  }
134
154
  catch (error) {
135
155
  await closeAttached();