@steipete/oracle 0.19.0 → 0.20.1

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 (41) hide show
  1. package/dist/bin/oracle-cli.js +72 -29
  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 +49 -6
  6. package/dist/src/browser/actions/webSearch.js +96 -0
  7. package/dist/src/browser/chromeLifecycle.js +28 -16
  8. package/dist/src/browser/config.js +1 -1
  9. package/dist/src/browser/index.js +144 -49
  10. package/dist/src/browser/liveTabs.js +11 -4
  11. package/dist/src/browser/profileState.js +6 -1
  12. package/dist/src/browser/promptFingerprint.js +54 -0
  13. package/dist/src/browser/providers/chatgptDomProvider.js +1 -0
  14. package/dist/src/browser/reattach.js +178 -109
  15. package/dist/src/browser/recoveryTarget.js +156 -0
  16. package/dist/src/browser/sessionRunner.js +33 -8
  17. package/dist/src/browser/tabLeaseRegistry.js +20 -0
  18. package/dist/src/browser/targetClaim.js +54 -0
  19. package/dist/src/cli/browserConfig.js +33 -3
  20. package/dist/src/cli/browserDefaults.js +3 -0
  21. package/dist/src/cli/browserTabs.js +38 -3
  22. package/dist/src/cli/detach.js +10 -1
  23. package/dist/src/cli/detachedSession.js +36 -0
  24. package/dist/src/cli/options.js +15 -0
  25. package/dist/src/cli/recoveredBrowserHarvest.js +50 -0
  26. package/dist/src/cli/runOptions.js +19 -4
  27. package/dist/src/cli/sessionDisplay.js +15 -8
  28. package/dist/src/cli/sessionRunner.js +153 -41
  29. package/dist/src/mcp/tools/consult.js +2 -2
  30. package/dist/src/mcp/types.js +1 -1
  31. package/dist/src/oracle/config.js +14 -0
  32. package/dist/src/oracle/geminiModels.js +1 -0
  33. package/dist/src/oracle/run.js +27 -4
  34. package/dist/src/remote/client.js +3 -0
  35. package/dist/src/remote/server.js +2 -0
  36. package/dist/src/sessionManager.js +8 -1
  37. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  38. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  39. package/package.json +10 -10
  40. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  41. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
@@ -81,6 +81,15 @@ function assertResolvedModelSelection(desiredModel, resolvedLabel) {
81
81
  const resolved = resolvedLabel.toLowerCase();
82
82
  const normalizedDesired = normalizeResolvedModelLabel(desired);
83
83
  const normalizedResolved = normalizeResolvedModelLabel(resolved);
84
+ if (desired === "latest") {
85
+ // The advanced radio is localized, but only the documented exact labels are
86
+ // evidence of GPT-6 Astra. Do not let a generic picker result verify Latest.
87
+ if (resolvedLabel.normalize("NFC").trim() === "Latest" ||
88
+ resolvedLabel.normalize("NFC").trim() === "最新") {
89
+ return;
90
+ }
91
+ throw new Error(`Model picker selected "${resolvedLabel}" while "${desiredModel}" requires GPT-6 Astra (Latest).`);
92
+ }
84
93
  const wantsGpt56Sol = /(?:^| )5 6(?: |$)/.test(normalizedDesired) && normalizedDesired.split(" ").includes("sol");
85
94
  if (wantsGpt56Sol) {
86
95
  const resolvedTokens = normalizedResolved.split(" ");
@@ -169,6 +178,17 @@ function buildModelSelectionExpression(targetModel, strategy) {
169
178
  const hasToken = (value, token) => normalizeText(value).split(' ').includes(token);
170
179
  // Normalize every candidate token to keep fuzzy matching deterministic.
171
180
  const normalizedTarget = normalizeText(PRIMARY_LABEL);
181
+ // "Latest" (GPT-6 since 2026-09) is a radio in the advanced view whose composer pill reads
182
+ // "6 Pro" / "6 High"…, while GPT-5.6 Sol's reads "5.6 Pro". Declared up front: getResolvedLabel
183
+ // runs on the picker-less path before the selection helpers below are initialized.
184
+ const targetIsLatest = normalizedTarget === 'latest';
185
+ // ChatGPT localizes the Latest radio itself (for example, Japanese "最新") but
186
+ // keeps the GPT-6 composer pill numeric. Keep this allow-list exact so GPT-5.6
187
+ // Sol or an arbitrary localized menu row can never satisfy a Latest request.
188
+ const isLatestModelLabel = (value) => {
189
+ const label = String(value ?? '').normalize('NFC').trim();
190
+ return label === 'Latest' || label === '最新';
191
+ };
172
192
  const normalizedTokens = Array.from(new Set([normalizedTarget, ...LABEL_TOKENS]))
173
193
  .map((token) => normalizeText(token))
174
194
  .filter(Boolean);
@@ -310,6 +330,14 @@ function buildModelSelectionExpression(targetModel, strategy) {
310
330
  };
311
331
 
312
332
  const getButtonLabel = () => (findModelButton()?.textContent ?? '').trim();
333
+ // With the picker closed the only evidence for "Latest" is the composer pill, so a version-less
334
+ // "latest" target must be decided on it: the blank composer signal would otherwise pass as
335
+ // "already selected" while GPT-5.6 Sol is active. Defined here, before getResolvedLabel, because
336
+ // the "current" strategy resolves the label before the selection helpers further down exist.
337
+ const latestButtonSelected = () => {
338
+ const label = normalizeText(getButtonLabel());
339
+ return /^(chatgpt |gpt )?6(?![0-9 .]*[0-9])/.test(label) && !/(^| )5 6/.test(label);
340
+ };
313
341
  const getComposerModelLabel = () =>
314
342
  (document.querySelector(COMPOSER_MODEL_SIGNAL_SELECTOR)?.textContent ?? '').trim();
315
343
  const readComposerModelSignal = () => normalizeText(getComposerModelLabel());
@@ -481,15 +509,37 @@ function buildModelSelectionExpression(targetModel, strategy) {
481
509
  if (wantsInstant) return label.includes('instant');
482
510
  if (wantsThinking) return Boolean(desiredVersion) && !labelHasProWord(label);
483
511
  if (desiredVersion) return true;
512
+ // A version-less target ("Latest") must match the radio that is actually checked in the
513
+ // advanced view: the opener's text lists every radio label, so a substring test would
514
+ // report "Latest" as selected while GPT-5.6 Sol is the checked model.
515
+ const checkedAdvancedRadio = findCheckedAdvancedModelRadio(parentMenu);
516
+ if (checkedAdvancedRadio) {
517
+ const checkedLabel = checkedAdvancedRadio.textContent ?? '';
518
+ return targetIsLatest
519
+ ? isLatestModelLabel(checkedLabel)
520
+ : normalizedTokens.some((token) => token && normalizeText(checkedLabel) === token);
521
+ }
484
522
  return normalizedTokens.some((token) => token && label.includes(token));
485
523
  };
524
+ const findCheckedAdvancedModelRadio = (menu = null) => {
525
+ const scope = menu || findUnifiedPickerMenu() || document;
526
+ return (
527
+ scope?.querySelector?.(
528
+ '[data-testid="composer-model-picker-slider-advanced-view"] [role="menuitemradio"][aria-checked="true"]',
529
+ ) ?? null
530
+ );
531
+ };
486
532
  const getAdvancedModelLabel = () => {
487
533
  const opener = findModelSubmenuOpener(findUnifiedPickerMenu());
488
534
  if (!opener) return '';
489
535
  const raw = (opener.textContent ?? '').trim();
490
536
  const normalized = normalizeText(pickerNodeLabel(opener));
491
537
  const version = versionFromLabel(normalized);
492
- if (!version) return raw;
538
+ if (!version) {
539
+ const checkedAdvancedRadio = findCheckedAdvancedModelRadio(findUnifiedPickerMenu());
540
+ const checkedLabel = (checkedAdvancedRadio?.textContent ?? '').trim();
541
+ return checkedLabel || raw;
542
+ }
493
543
  const [major, minor] = version.split('-');
494
544
  const suffix = normalized.split(' ').includes('sol') ? ' Sol' : '';
495
545
  return 'GPT-' + major + '.' + minor + suffix;
@@ -503,6 +553,17 @@ function buildModelSelectionExpression(targetModel, strategy) {
503
553
  );
504
554
  };
505
555
  const getResolvedLabel = (observedOptionLabel = '') => {
556
+ if (targetIsLatest) {
557
+ const checkedAdvancedRadio = findCheckedAdvancedModelRadio();
558
+ if (checkedAdvancedRadio) return (checkedAdvancedRadio.textContent ?? '').trim();
559
+ // Picker closed: the pill ("6 Pro") is the evidence; report the radio's name so callers
560
+ // can compare against the requested target instead of the tier-suffixed pill text.
561
+ if (latestButtonSelected()) return 'Latest';
562
+ const currentButtonLabel = getButtonLabel();
563
+ if (currentButtonLabel) return currentButtonLabel;
564
+ // No picker button at all (e.g. the "current" strategy on a page that hides it): fall back
565
+ // to the generic composer/observed label resolution below.
566
+ }
506
567
  if (configuredSelectionMatchesTarget()) {
507
568
  const variant = getConfiguredVariantLabel();
508
569
  const version = formatModelOptionLabel(getConfiguredVersionLabel());
@@ -644,6 +705,13 @@ function buildModelSelectionExpression(targetModel, strategy) {
644
705
  return COMPOSER_SIGNAL_INCLUDES.some((token) => token && signal.includes(token));
645
706
  };
646
707
  const activeSelectionMatchesTarget = () => {
708
+ if (targetIsLatest) {
709
+ const checkedAdvancedRadio = findCheckedAdvancedModelRadio();
710
+ if (checkedAdvancedRadio) {
711
+ return isLatestModelLabel(checkedAdvancedRadio.textContent ?? '');
712
+ }
713
+ return latestButtonSelected();
714
+ }
647
715
  if (advancedModelSignalMatchesTarget()) {
648
716
  return true;
649
717
  }
@@ -718,6 +786,11 @@ function buildModelSelectionExpression(targetModel, strategy) {
718
786
 
719
787
  const scoreOption = (normalizedText, testid, node) => {
720
788
  // Assign a score to every node so we can pick the most likely match without brittle equality checks.
789
+ // Latest is localized in the advanced radio list. Match the documented labels
790
+ // exactly instead of falling through to generic scoring, which could select Sol.
791
+ if (targetIsLatest) {
792
+ return isLatestModelLabel(node?.textContent ?? '') ? 2000 : 0;
793
+ }
721
794
  if (!normalizedText && !testid) {
722
795
  return 0;
723
796
  }
@@ -1368,6 +1441,10 @@ function buildModelMatchersLiteral(targetModel) {
1368
1441
  testIdTokens.add("gpt5-6");
1369
1442
  testIdTokens.add("gpt56");
1370
1443
  }
1444
+ if (base === "latest") {
1445
+ // ChatGPT's Japanese advanced-model radio is named exactly "最新".
1446
+ push("最新", labelTokens);
1447
+ }
1371
1448
  // Numeric variations (5.5 <-> 55 <-> gpt-5-5)
1372
1449
  if (base.includes("5.5") || base.includes("5-5") || base.includes("55")) {
1373
1450
  push("5.5", labelTokens);
@@ -11,6 +11,7 @@ import { stageAttachmentPrompt } from "./attachmentPrompt.js";
11
11
  import { BrowserAutomationError } from "../../oracle/errors.js";
12
12
  import { buildAttachmentEvidenceExpression } from "./attachmentEvidence.js";
13
13
  import { buildAttachmentProgressExpression } from "./attachmentProgress.js";
14
+ import { activateWebSearch } from "./webSearch.js";
14
15
  const ENTER_KEY_EVENT = {
15
16
  key: "Enter",
16
17
  code: "Enter",
@@ -196,6 +197,8 @@ export async function submitPrompt(deps, prompt, logger) {
196
197
  observedLength,
197
198
  });
198
199
  }
200
+ if (deps.webSearch)
201
+ await activateWebSearch(runtime, input, prompt, logger);
199
202
  const clicked = await attemptSendButton(runtime, input, logger, deps?.attachmentNames, deps?.attachmentTimeoutMs, deps?.page, deps?.attachmentNavigationUrl);
200
203
  if (!clicked) {
201
204
  await dispatchEnterKey(input);
@@ -192,6 +192,10 @@ function buildThinkingTimeExpression(level, desiredModel) {
192
192
  const targetLevelLiteral = JSON.stringify(level.toLowerCase());
193
193
  const targetModelKindLiteral = JSON.stringify(inferThinkingTargetModelKind(desiredModel));
194
194
  const targetIsGpt56ModelLiteral = JSON.stringify(/(?:^|[^0-9])5[._ -]6(?:[^0-9]|$)/i.test(desiredModel ?? ""));
195
+ // Astra resolves the gpt-6-pro request to this exact model-picker alias. Keep
196
+ // this deliberately narrow: a generic unknown model must not claim its
197
+ // version-prefixed effort pill whose ownership we cannot establish.
198
+ const targetIsAstraLatestLiteral = JSON.stringify((desiredModel ?? "").trim().toLowerCase() === "latest");
195
199
  return `(async () => {
196
200
  ${buildClickDispatcher()}
197
201
 
@@ -201,13 +205,14 @@ function buildThinkingTimeExpression(level, desiredModel) {
201
205
  const TARGET_LEVEL = ${targetLevelLiteral};
202
206
  const TARGET_MODEL_KIND = ${targetModelKindLiteral};
203
207
  const TARGET_IS_GPT56_MODEL = ${targetIsGpt56ModelLiteral};
208
+ const TARGET_IS_ASTRA_LATEST = ${targetIsAstraLatestLiteral};
204
209
 
205
210
  // Multilingual matchers: English level token + observed localized variants.
206
211
  const LEVEL_TOKENS = {
207
212
  light: ['light', 'instant', 'sofort', 'leicht', '最速', '轻', '极速', '즉시'],
208
213
  standard: ['standard', 'medium', 'mittel', '中程度', '标准', '中', '중간'],
209
214
  extended: ['extended', 'high', 'hoch', 'erweitert', '高い', '扩展', '深度', '加强', '高', '높음'],
210
- 'extra-high': ['extra high', 'sehr hoch', '非常に高い', '极高', '매우 높음'],
215
+ 'extra-high': ['extra high', 'sehr hoch', '非常に高い', '極高', '极高', '매우 높음'],
211
216
  heavy: ['heavy', 'schwer', '重度', '加重'],
212
217
  };
213
218
  // Pro is a tier you can request, but it is also a MODEL name, so it must never
@@ -216,6 +221,29 @@ function buildThinkingTimeExpression(level, desiredModel) {
216
221
  // "Instant"/"Pro" would look like a tier list. Keep it to target matching only.
217
222
  const TARGET_LEVEL_TOKENS = { ...LEVEL_TOKENS, pro: ['pro'] };
218
223
  const targetTokens = TARGET_LEVEL_TOKENS[TARGET_LEVEL] || [TARGET_LEVEL];
224
+ const normalizeAstraLabel = (value) =>
225
+ String(value ?? '')
226
+ .normalize('NFC')
227
+ .toLowerCase()
228
+ .split(String.fromCharCode(9)).join(' ')
229
+ .split(String.fromCharCode(10)).join(' ')
230
+ .split(String.fromCharCode(13)).join(' ')
231
+ .split(String.fromCharCode(12)).join(' ')
232
+ .split(' ').filter(Boolean).join(' ');
233
+ const isAstraLatestEffortPill = (label) =>
234
+ TARGET_IS_ASTRA_LATEST &&
235
+ Object.values(TARGET_LEVEL_TOKENS).some((tokens) =>
236
+ tokens.some((token) => {
237
+ // Do not use the generic matcher here: ownership needs an exact
238
+ // version prefix plus a known localized tier, not token containment.
239
+ const tier = normalizeAstraLabel(token);
240
+ const observed = normalizeAstraLabel(label);
241
+ return observed === '6 ' + tier || observed === '6' + tier;
242
+ }),
243
+ );
244
+ const isSolModelPillForLatest = (label) =>
245
+ TARGET_IS_ASTRA_LATEST &&
246
+ ['5.6 pro', '5.6pro', '5 6 pro'].includes(normalizeAstraLabel(label));
219
247
 
220
248
  const INITIAL_WAIT_MS = 150;
221
249
  const STEP_WAIT_MS = 200;
@@ -1002,11 +1030,12 @@ function buildThinkingTimeExpression(level, desiredModel) {
1002
1030
  .filter(Boolean);
1003
1031
  if (selections.length !== 1) return null;
1004
1032
  const { label, index, level } = selections[0];
1005
- // This adapter owns the observed five-tier layout only. A different range
1006
- // or contradictory announcement must not turn a numeric guess into proof.
1007
- if (thumb.getAttribute('aria-valuemin') !== '0' || thumb.getAttribute('aria-valuemax') !== '4' ||
1008
- thumb.getAttribute('aria-valuenow') !== String(index)) return null;
1009
- return { control, label, index, level };
1033
+ // Quota-limited accounts expose four tiers; the fourth remains Extra High.
1034
+ // Require an observed range and matching label/index before trusting either.
1035
+ const maximum = thumb.getAttribute('aria-valuemax');
1036
+ if (thumb.getAttribute('aria-valuemin') !== '0' || !['3', '4'].includes(maximum) ||
1037
+ index > Number(maximum) || thumb.getAttribute('aria-valuenow') !== String(index)) return null;
1038
+ return { control, label, index, level, maximum: Number(maximum) };
1010
1039
  };
1011
1040
  let current = resolve();
1012
1041
  const finish = (result) => { closeOpenMenus(); return result; };
@@ -1026,6 +1055,10 @@ function buildThinkingTimeExpression(level, desiredModel) {
1026
1055
  }
1027
1056
  return finish(failure('option-not-found'));
1028
1057
  }
1058
+ const unavailable = () => finish(failure('option-disabled', {
1059
+ label: 'Pro', notice: 'the available four-tier effort slider does not include Pro',
1060
+ }));
1061
+ if (targetIndex > current.maximum) return unavailable();
1029
1062
  if (current.level === target) return finish({ status: 'already-selected', label: current.label });
1030
1063
  const deadline = performance.now() + MAX_WAIT_MS;
1031
1064
  for (let attempt = 0; attempt < 4 && performance.now() < deadline; attempt += 1) {
@@ -1041,6 +1074,7 @@ function buildThinkingTimeExpression(level, desiredModel) {
1041
1074
  if (next && next.index !== previousIndex) { current = next; break; }
1042
1075
  }
1043
1076
  if (!current) return finish(failure('selection-unverified'));
1077
+ if (targetIndex > current.maximum) return unavailable();
1044
1078
  if (current.level === target) return finish({ status: 'switched', label: current.label });
1045
1079
  }
1046
1080
  return finish(failure('selection-unverified'));
@@ -1062,6 +1096,9 @@ function buildThinkingTimeExpression(level, desiredModel) {
1062
1096
  if (seen.has(button) || !isVisible(button)) continue;
1063
1097
  seen.add(button);
1064
1098
  if (button.getAttribute?.('data-testid') === 'model-switcher-dropdown-button') continue;
1099
+ // A 5.6 Pro model pill is not Astra Latest's 6-prefixed effort owner.
1100
+ // Keep this rejection ahead of the generic compatibility matcher.
1101
+ if (isSolModelPillForLatest(button.textContent ?? '')) continue;
1065
1102
  const label = normalize(
1066
1103
  (button.getAttribute?.('aria-label') ?? '') + ' ' +
1067
1104
  (button.getAttribute?.('data-testid') ?? '') + ' ' +
@@ -1071,6 +1108,11 @@ function buildThinkingTimeExpression(level, desiredModel) {
1071
1108
  (TARGET_MODEL_KIND === 'pro' && hasToken(label, 'pro') && !hasToken(label, 'thinking')) ||
1072
1109
  (TARGET_MODEL_KIND === 'thinking' && hasToken(label, 'thinking') && !hasToken(label, 'pro')) ||
1073
1110
  (!TARGET_MODEL_KIND && hasToken(label, 'thinking')) ||
1111
+ // Astra Latest prefixes a supported effort label with "6" (for
1112
+ // example, "6 Pro" or textContent-concatenated "6Pro"). This is
1113
+ // recognized only for the exact Latest target; selection still
1114
+ // requires the direct slider's leading label and numeric ARIA proof.
1115
+ isAstraLatestEffortPill(button.textContent ?? '') ||
1074
1116
  (button.matches?.('button.__composer-pill') && matchesAnyEffortLevel(label))
1075
1117
  ) {
1076
1118
  return button;
@@ -1157,6 +1199,7 @@ function buildThinkingTimeExpression(level, desiredModel) {
1157
1199
  tokens.some((token) => normalize(token) === pillLabel),
1158
1200
  );
1159
1201
  const pillNamesEffortNotModel =
1202
+ TARGET_IS_ASTRA_LATEST ||
1160
1203
  TARGET_IS_GPT56_MODEL ||
1161
1204
  (pillIsBareEffortTier && Boolean(document.querySelector(INTELLIGENCE_MENU_SELECTOR)));
1162
1205
  const composerModelKind =
@@ -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
+ }
@@ -7,6 +7,7 @@ import { launch, Launcher, } from "chrome-launcher";
7
7
  import { cleanupStaleProfileState } from "./profileState.js";
8
8
  import { delay } from "./utils.js";
9
9
  import { isWsl, resolveWslChromeLaunchRoute } from "./wslHost.js";
10
+ import { BrowserCancellation } from "./cancellation.js";
10
11
  export async function launchChrome(config, userDataDir, logger) {
11
12
  const { connectHost, debugBindAddress, usePatchedLauncher } = resolveWslChromeLaunchRoute();
12
13
  const debugPort = config.debugPort ?? parseDebugPortEnv();
@@ -352,9 +353,10 @@ export async function connectToRemoteChrome(host, port, logger, targetUrl, brows
352
353
  return {
353
354
  client: targetConnection.client,
354
355
  targetId: targetConnection.targetId,
355
- close: async () => {
356
+ close: async (closeOptions) => {
356
357
  await targetConnection.client.close().catch(() => undefined);
357
- await closeRemoteChromeTarget(host, port, targetConnection.targetId, logger);
358
+ if (!closeOptions?.preserveTarget)
359
+ await closeRemoteChromeTarget(host, port, targetConnection.targetId, logger);
358
360
  },
359
361
  };
360
362
  }
@@ -387,21 +389,31 @@ export async function closeRemoteChromeTarget(host, port, targetId, logger) {
387
389
  }
388
390
  }
389
391
  export async function listRemoteChromeTargets(options) {
390
- if (!options.browserWSEndpoint) {
391
- const targets = await CDP.List({ host: options.host, port: options.port });
392
- return targets;
393
- }
394
- const browser = await connectToBrowserWebSocket(options.host, options.port, options.browserWSEndpoint, options.logger ?? (() => { }), options.approvalWaitMs);
392
+ const logger = options.logger ?? (() => { });
393
+ const cancellation = new BrowserCancellation(options.signal, logger);
395
394
  try {
396
- const result = await browser.Target.getTargets();
397
- return (result.targetInfos ?? []).map((target) => ({
398
- targetId: target.targetId,
399
- type: target.type,
400
- url: target.url,
401
- }));
395
+ return await cancellation.run(async () => {
396
+ if (!options.browserWSEndpoint) {
397
+ const targets = await cancellation.call(() => CDP.List({ host: options.host, port: options.port }));
398
+ return targets;
399
+ }
400
+ const browser = await cancellation.acquire(() => connectToBrowserWebSocket(options.host, options.port, options.browserWSEndpoint, logger, options.approvalWaitMs), (lateBrowser) => lateBrowser.close());
401
+ try {
402
+ const client = cancellation.client(browser);
403
+ const result = await client.Target.getTargets();
404
+ return (result.targetInfos ?? []).map((target) => ({
405
+ targetId: target.targetId,
406
+ type: target.type,
407
+ url: target.url,
408
+ }));
409
+ }
410
+ finally {
411
+ await browser.close().catch(() => undefined);
412
+ }
413
+ });
402
414
  }
403
415
  finally {
404
- await browser.close().catch(() => undefined);
416
+ cancellation.dispose();
405
417
  }
406
418
  }
407
419
  export async function connectToRemoteChromeTarget(host, port, logger, options) {
@@ -431,9 +443,9 @@ export async function connectToRemoteChromeTarget(host, port, logger, options) {
431
443
  client,
432
444
  targetId,
433
445
  browserWSEndpoint: options.browserWSEndpoint,
434
- close: async () => {
446
+ close: async (closeOptions) => {
435
447
  await browser.Target.detachFromTarget({ sessionId: attached.sessionId }).catch(() => undefined);
436
- if (options.closeTargetOnDispose && targetId) {
448
+ if (options.closeTargetOnDispose && targetId && !closeOptions?.preserveTarget) {
437
449
  await browser.Target.closeTarget({ targetId }).catch(() => undefined);
438
450
  }
439
451
  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";