@steipete/oracle 0.16.1 → 0.17.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 (70) hide show
  1. package/README.md +72 -337
  2. package/dist/bin/oracle-cli.js +185 -56
  3. package/dist/docs-site/.nojekyll +0 -0
  4. package/dist/docs-site/CNAME +1 -0
  5. package/dist/docs-site/RELEASING.html +410 -0
  6. package/dist/docs-site/agents.html +374 -0
  7. package/dist/docs-site/anthropic.html +368 -0
  8. package/dist/docs-site/bridge.html +416 -0
  9. package/dist/docs-site/browser-mode.html +594 -0
  10. package/dist/docs-site/chromium-forks.html +347 -0
  11. package/dist/docs-site/cli-reference.html +346 -0
  12. package/dist/docs-site/configuration.html +462 -0
  13. package/dist/docs-site/favicon.svg +14 -0
  14. package/dist/docs-site/followup.html +375 -0
  15. package/dist/docs-site/gemini.html +383 -0
  16. package/dist/docs-site/grok.html +325 -0
  17. package/dist/docs-site/index.html +360 -0
  18. package/dist/docs-site/install.html +335 -0
  19. package/dist/docs-site/linux.html +321 -0
  20. package/dist/docs-site/llms.txt +43 -0
  21. package/dist/docs-site/manual-tests.html +596 -0
  22. package/dist/docs-site/mcp.html +391 -0
  23. package/dist/docs-site/multimodel.html +364 -0
  24. package/dist/docs-site/mythical-pro-agents.html +360 -0
  25. package/dist/docs-site/notifier.html +338 -0
  26. package/dist/docs-site/openai-endpoints.html +410 -0
  27. package/dist/docs-site/openrouter.html +344 -0
  28. package/dist/docs-site/quickstart.html +369 -0
  29. package/dist/docs-site/refactor/ux.html +532 -0
  30. package/dist/docs-site/sessions.html +389 -0
  31. package/dist/docs-site/social-card.png +0 -0
  32. package/dist/docs-site/social-card.svg +79 -0
  33. package/dist/docs-site/spec.html +363 -0
  34. package/dist/docs-site/testing.html +320 -0
  35. package/dist/docs-site/tui-debug.html +326 -0
  36. package/dist/docs-site/windows-work.html +324 -0
  37. package/dist/docs-site/windows.html +320 -0
  38. package/dist/scripts/test-browser.js +1 -25
  39. package/dist/src/browser/actions/modelSelection.js +36 -34
  40. package/dist/src/browser/actions/navigation.js +8 -3
  41. package/dist/src/browser/actions/thinkingTime.js +96 -45
  42. package/dist/src/browser/chromeLifecycle.js +8 -37
  43. package/dist/src/browser/index.js +23 -8
  44. package/dist/src/browser/modelDisplay.js +67 -0
  45. package/dist/src/browser/reattach.js +14 -1
  46. package/dist/src/browser/recoverConversation.js +2 -1
  47. package/dist/src/browser/sessionRunner.js +9 -10
  48. package/dist/src/browser/wslHost.js +50 -0
  49. package/dist/src/cli/browserConfig.js +31 -11
  50. package/dist/src/cli/detach.js +21 -4
  51. package/dist/src/cli/dryRun.js +13 -2
  52. package/dist/src/cli/engine.js +2 -2
  53. package/dist/src/cli/options.js +5 -1
  54. package/dist/src/cli/sessionDisplay.js +60 -8
  55. package/dist/src/cli/sessionLifecycle.js +2 -1
  56. package/dist/src/cli/sessionRunner.js +110 -60
  57. package/dist/src/cli/sessionTable.js +5 -1
  58. package/dist/src/cli/tui/index.js +12 -4
  59. package/dist/src/duration.js +3 -0
  60. package/dist/src/gemini-web/client.js +19 -1
  61. package/dist/src/oracle/modelResolver.js +8 -1
  62. package/dist/src/oracle/request.js +9 -2
  63. package/dist/src/oracle/run.js +43 -3
  64. package/dist/src/oracle/thinkingTime.js +9 -3
  65. package/dist/src/sessionManager.js +41 -12
  66. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  67. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  68. package/package.json +17 -17
  69. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  70. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
@@ -55,7 +55,9 @@ export async function ensureThinkingTime(Runtime, level, logger, desiredModel) {
55
55
  if (strictProEffort) {
56
56
  throw new Error(`${message}; refusing to submit without confirmed Pro Extended.`);
57
57
  }
58
- logger(formatBrowserThinkingLog(`${message}; continuing with ChatGPT default.`));
58
+ // Nothing was clicked, so the tab keeps whatever effort it already had —
59
+ // which is not necessarily ChatGPT's default.
60
+ logger(formatBrowserThinkingLog(`${message}; keeping the effort already selected in ChatGPT.`));
59
61
  return;
60
62
  }
61
63
  default: {
@@ -72,7 +74,7 @@ export async function ensureThinkingTime(Runtime, level, logger, desiredModel) {
72
74
  /**
73
75
  * Best-effort selection of a thinking time level in ChatGPT's composer pill menu.
74
76
  * Safe by default: if the pill/menu/option isn't present, we continue without throwing.
75
- * @param level - The thinking time intensity: 'light', 'standard', 'extended', or 'heavy'
77
+ * @param level - The thinking time intensity: 'light', 'standard', 'extended', 'extra-high', or 'heavy'
76
78
  */
77
79
  export async function ensureThinkingTimeIfAvailable(Runtime, level, logger, desiredModel) {
78
80
  try {
@@ -135,12 +137,13 @@ function buildThinkingTimeExpression(level, desiredModel) {
135
137
  const TARGET_MODEL_KIND = ${targetModelKindLiteral};
136
138
  const TARGET_IS_GPT56_MODEL = ${targetIsGpt56ModelLiteral};
137
139
 
138
- // Bilingual matchers: English level token + observed Chinese variants.
140
+ // Multilingual matchers: English level token + observed German/Chinese variants.
139
141
  const LEVEL_TOKENS = {
140
- light: ['light', 'instant', '轻', '极速'],
141
- standard: ['standard', 'medium', '标准', '中'],
142
- extended: ['extended', 'high', '扩展', '深度', '加强', '高'],
143
- heavy: ['heavy', 'extra high', '重度', '加重', '极高'],
142
+ light: ['light', 'instant', 'sofort', 'leicht', '轻', '极速'],
143
+ standard: ['standard', 'medium', 'mittel', '标准', '中'],
144
+ extended: ['extended', 'high', 'hoch', 'erweitert', '扩展', '深度', '加强', '高'],
145
+ 'extra-high': ['extra high', 'sehr hoch', '极高'],
146
+ heavy: ['heavy', 'schwer', '重度', '加重'],
144
147
  };
145
148
  const targetTokens = LEVEL_TOKENS[TARGET_LEVEL] || [TARGET_LEVEL];
146
149
 
@@ -155,19 +158,43 @@ function buildThinkingTimeExpression(level, desiredModel) {
155
158
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
156
159
  // Keep CJK characters so we can match Chinese labels against LEVEL_TOKENS.
157
160
  const normalize = (value) => (value || '')
161
+ // Compose first so NFD umlauts fold too, then map them onto ASCII before
162
+ // the strip below would drop them (and split the token in half).
163
+ .normalize('NFC')
158
164
  .toLowerCase()
165
+ .replace(/ä/g, 'a')
166
+ .replace(/ö/g, 'o')
167
+ .replace(/ü/g, 'u')
168
+ .replace(/ß/g, 'ss')
159
169
  .replace(/[^a-z0-9\\u4e00-\\u9fa5]+/g, ' ')
160
170
  .replace(/\\s+/g, ' ')
161
171
  .trim();
162
172
  const hasToken = (text, token) => normalize(text).split(' ').includes(token);
163
- const matchesLevel = (text) => {
173
+ // Whole-word/phrase containment. Latin effort labels are short words that also
174
+ // occur inside unrelated UI text ("Hochladen", "Ermitteln") and inside their own
175
+ // row descriptions ("Hoch – für sehr komplexe Aufgaben"), so plain substring
176
+ // matching misclassifies rows. CJK labels have no word separators, so they keep
177
+ // substring semantics.
178
+ const hasPhrase = (text, phrase) => {
179
+ const haystack = ' ' + normalize(text) + ' ';
180
+ const needle = normalize(phrase);
181
+ if (!needle) return false;
182
+ return /^[a-z0-9 ]+$/.test(needle)
183
+ ? haystack.includes(' ' + needle + ' ')
184
+ : haystack.includes(needle);
185
+ };
186
+ // ChatGPT's Pro effort tiers are "Pro Extended"/"Pro Erweitert" per UI language.
187
+ const hasExtendedWord = (text) => hasPhrase(text, 'extended') || hasPhrase(text, 'erweitert');
188
+ const matchesTokens = (text, tokens) => {
164
189
  const t = normalize(text);
165
190
  if (!t) return false;
166
- return targetTokens.some((tok) => {
191
+ return tokens.some((tok) => {
167
192
  const token = normalize(tok);
168
193
  if (!token) return false;
169
- if (token === 'high') return hasToken(t, 'high') && !hasToken(t, 'extra');
170
- if (token === 'extra high') return hasToken(t, 'extra') && hasToken(t, 'high');
194
+ if (token === 'high') return hasPhrase(t, 'high') && !hasPhrase(t, 'extra high');
195
+ if (token === 'extra high') return hasPhrase(t, 'extra high');
196
+ if (token === 'hoch') return hasPhrase(t, 'hoch') && !hasPhrase(t, 'sehr hoch');
197
+ if (token === 'sehr hoch') return hasPhrase(t, 'sehr hoch');
171
198
  if (token === '极速') {
172
199
  const suffix = t.slice(token.length);
173
200
  return t === token || hasToken(t, token) || /^[0-9]/.test(suffix);
@@ -175,27 +202,15 @@ function buildThinkingTimeExpression(level, desiredModel) {
175
202
  if (['中', '高', '极高'].includes(token)) {
176
203
  return t === token || hasToken(t, token);
177
204
  }
205
+ if (/^[a-z0-9 ]+$/.test(token)) {
206
+ return hasPhrase(t, token);
207
+ }
178
208
  return t === token || hasToken(t, token) || t.includes(token);
179
209
  });
180
210
  };
181
- const matchesAnyEffortLevel = (text) => {
182
- const normalizedText = normalize(text);
183
- if (!normalizedText) return false;
184
- for (const tokens of Object.values(LEVEL_TOKENS)) {
185
- for (const rawToken of tokens) {
186
- const token = normalize(rawToken);
187
- if (!token) continue;
188
- if (token.includes(' ')) {
189
- if (token.split(' ').every((part) => hasToken(normalizedText, part))) return true;
190
- } else if (/^[a-z0-9]+$/.test(token)) {
191
- if (hasToken(normalizedText, token)) return true;
192
- } else if (normalizedText.includes(token)) {
193
- return true;
194
- }
195
- }
196
- }
197
- return false;
198
- };
211
+ const matchesLevel = (text) => matchesTokens(text, targetTokens);
212
+ const matchesAnyEffortLevel = (text) =>
213
+ Object.values(LEVEL_TOKENS).some((tokens) => matchesTokens(text, tokens));
199
214
  const optionIsSelected = (node) => {
200
215
  if (!(node instanceof HTMLElement)) return false;
201
216
  const ariaChecked = node.getAttribute('aria-checked');
@@ -347,7 +362,8 @@ function buildThinkingTimeExpression(level, desiredModel) {
347
362
  return true;
348
363
  }
349
364
  const label = menu?.querySelector?.('.__menu-label, [class*="menu-label"]');
350
- return normalize(label?.textContent ?? '').includes('intelligence');
365
+ // 'intelligen' matches both "Intelligence" and German "Intelligenz".
366
+ return normalize(label?.textContent ?? '').includes('intelligen');
351
367
  };
352
368
  const failure = (status, extra = {}) => ({
353
369
  status,
@@ -390,6 +406,10 @@ function buildThinkingTimeExpression(level, desiredModel) {
390
406
  return null;
391
407
  }
392
408
  }
409
+ // Generic effort-label match for every model/level. GPT-5.6 heavy used to
410
+ // short-circuit to the Pro row before reaching here; it no longer does, so
411
+ // a UI without a matching tier (e.g. German, which has no "heavy") falls
412
+ // through to null and the caller keeps the current selection.
393
413
  for (const item of items) {
394
414
  const itemText = normalize(
395
415
  (item.textContent ?? '') + ' ' + (item.getAttribute?.('aria-label') ?? ''),
@@ -404,10 +424,10 @@ function buildThinkingTimeExpression(level, desiredModel) {
404
424
  return item;
405
425
  }
406
426
  }
407
- if (TARGET_LEVEL === 'heavy') {
408
- // Older Chinese layouts used bare 高 for the highest effort. Keep it
409
- // only as a second-pass exact fallback so a current 高 row can never
410
- // win before the primary 极高 row.
427
+ if (TARGET_LEVEL === 'extra-high') {
428
+ // Older Chinese layouts used bare 高 for the highest non-Pro effort.
429
+ // Keep it only as a second-pass exact fallback so a current 高 row can
430
+ // never win before the primary 极高 row.
411
431
  for (const item of items) {
412
432
  const itemText = normalize(item.textContent ?? '');
413
433
  const ariaLabel = normalize(item.getAttribute?.('aria-label') ?? '');
@@ -416,11 +436,15 @@ function buildThinkingTimeExpression(level, desiredModel) {
416
436
  }
417
437
  return null;
418
438
  };
439
+ // Menu-shape heuristic only. This reads the whole menu's textContent, where
440
+ // adjacent row labels concatenate without a separator ("Pro StandardPro
441
+ // Extended"), so word-boundary matching does not apply here — substring is
442
+ // deliberate. Row-level classification uses matchesLevel/matchesTokens.
419
443
  const countEffortLevels = (menu) => {
420
444
  const text = normalize(menu?.textContent ?? '');
421
445
  let hits = 0;
422
446
  for (const tokens of Object.values(LEVEL_TOKENS)) {
423
- if (tokens.some((token) => text.includes(String(token).toLowerCase()))) hits += 1;
447
+ if (tokens.some((token) => text.includes(normalize(token)))) hits += 1;
424
448
  }
425
449
  return hits;
426
450
  };
@@ -431,16 +455,22 @@ function buildThinkingTimeExpression(level, desiredModel) {
431
455
  const label = menu.querySelector?.('.__menu-label, [class*="menu-label"]');
432
456
  const labelText = normalize(label?.textContent ?? '');
433
457
  return (
434
- labelText.includes('intelligence') ||
458
+ labelText.includes('intelligen') ||
435
459
  labelText.includes('thinking time') ||
436
460
  labelText.includes('thinking effort') ||
461
+ labelText.includes('denkdauer') ||
462
+ labelText.includes('denkzeit') ||
437
463
  countEffortLevels(menu) >= 2
438
464
  );
439
465
  };
440
466
  const isProEffortMenu = (menu) => {
441
467
  if (!isVisible(menu)) return false;
442
468
  const text = normalize(menu?.textContent ?? '');
443
- return text.includes('pro standard') && text.includes('pro extended');
469
+ // Aggregate menu text, so plain substring only (see countEffortLevels).
470
+ return (
471
+ text.includes('pro standard') &&
472
+ (text.includes('pro extended') || text.includes('pro erweitert'))
473
+ );
444
474
  };
445
475
  const controlledMenu = (trigger) => {
446
476
  const id = trigger?.getAttribute?.('aria-controls');
@@ -475,10 +505,10 @@ function buildThinkingTimeExpression(level, desiredModel) {
475
505
  (node?.textContent ?? '') + ' ' + (node?.getAttribute?.('aria-label') ?? ''),
476
506
  );
477
507
  if (TARGET_LEVEL === 'standard') {
478
- return text.includes('pro') && text.includes('standard');
508
+ return hasPhrase(text, 'pro') && hasPhrase(text, 'standard');
479
509
  }
480
510
  if (TARGET_LEVEL === 'extended') {
481
- return text.includes('pro') && text.includes('extended');
511
+ return hasPhrase(text, 'pro') && hasExtendedWord(text);
482
512
  }
483
513
  return false;
484
514
  };
@@ -503,29 +533,50 @@ function buildThinkingTimeExpression(level, desiredModel) {
503
533
  }
504
534
  const label = normalize(button?.textContent ?? '');
505
535
  if (TARGET_LEVEL === 'standard') {
506
- return hasToken(label, 'pro') && !hasToken(label, 'extended');
536
+ return hasToken(label, 'pro') && !hasExtendedWord(label);
507
537
  }
508
538
  if (TARGET_LEVEL === 'extended') {
509
- return hasToken(label, 'pro') && hasToken(label, 'extended');
539
+ return hasToken(label, 'pro') && hasExtendedWord(label);
510
540
  }
511
541
  return false;
512
542
  };
513
543
  const currentEffortPillMatchesTarget = (trigger, modelKindOverride = null) => {
514
544
  if (currentProEffortPillMatchesTarget(trigger, modelKindOverride)) return true;
515
545
  const button = freshComposerTrigger(trigger) || findModelButton();
546
+ const normalizedLabel = normalize(
547
+ (button?.textContent ?? '') + ' ' + (button?.getAttribute?.('aria-label') ?? ''),
548
+ );
549
+ // No 5.6-heavy "a Pro pill counts as heavy" shortcut here: that would also
550
+ // make post-click verification pass on an unchanged Pro pill. selectAndVerify
551
+ // handles the already-on-Pro case explicitly before any click.
516
552
  if ((modelKindOverride || TARGET_MODEL_KIND || modelKindFromNode(button)) === 'pro') {
517
553
  return false;
518
554
  }
519
- const label = (button?.textContent ?? '') + ' ' + (button?.getAttribute?.('aria-label') ?? '');
520
- return matchesLevel(label);
555
+ return matchesLevel(normalizedLabel);
521
556
  };
522
557
  const selectAndVerify = async (trigger, findOption, modelKindOverride = null) => {
523
- const option = findOption();
524
558
  const triggerModelKind =
525
559
  modelKindOverride ||
526
560
  TARGET_MODEL_KIND ||
527
561
  modelKindFromNode(trigger) ||
528
562
  effectiveTargetModelKind();
563
+ const option = findOption();
564
+ if (!option && TARGET_IS_GPT56_MODEL && TARGET_LEVEL === 'heavy') {
565
+ // GPT-5.6 has no "heavy" tier: Pro is the closest thing. Accept a pill that
566
+ // is already on Pro as satisfying the request, but never click Pro to get
567
+ // there, and never let this stand in for post-click verification.
568
+ const pill = freshComposerTrigger(trigger) || findModelButton();
569
+ const pillLabel = normalize(
570
+ (pill?.textContent ?? '') + ' ' + (pill?.getAttribute?.('aria-label') ?? ''),
571
+ );
572
+ if (
573
+ hasToken(pillLabel, 'pro') ||
574
+ currentEffortPillMatchesTarget(trigger, triggerModelKind)
575
+ ) {
576
+ closeOpenMenus();
577
+ return { status: 'already-selected', label: trigger.textContent?.trim?.() || null };
578
+ }
579
+ }
529
580
  if (!option) return failure('option-not-found', { modelKind: triggerModelKind });
530
581
  const label = option.textContent?.trim?.() || null;
531
582
  if (optionIsSelected(option)) {
@@ -821,7 +872,7 @@ function buildThinkingTimeExpression(level, desiredModel) {
821
872
  const text = normalize(
822
873
  (node?.textContent ?? '') + ' ' + (node?.getAttribute?.('aria-label') ?? ''),
823
874
  );
824
- return text.includes('pro') && text.includes('extended');
875
+ return hasPhrase(text, 'pro') && hasExtendedWord(text);
825
876
  };
826
877
  const findProExtendedOption = () => {
827
878
  const menu = document.querySelector(INTELLIGENCE_MENU_SELECTOR);
@@ -1,17 +1,14 @@
1
1
  import { rm } from "node:fs/promises";
2
- import { readFileSync } from "node:fs";
3
- import os from "node:os";
4
2
  import net from "node:net";
5
3
  import CDP from "chrome-remote-interface";
6
4
  import { launch, Launcher } from "chrome-launcher";
7
5
  import { cleanupStaleProfileState } from "./profileState.js";
8
6
  import { delay } from "./utils.js";
7
+ import { isWsl, resolveWslChromeLaunchRoute } from "./wslHost.js";
9
8
  export async function launchChrome(config, userDataDir, logger) {
10
- const connectHost = resolveRemoteDebugHost();
11
- const debugBindAddress = connectHost && connectHost !== "127.0.0.1" ? "0.0.0.0" : connectHost;
9
+ const { connectHost, debugBindAddress, usePatchedLauncher } = resolveWslChromeLaunchRoute();
12
10
  const debugPort = config.debugPort ?? parseDebugPortEnv();
13
11
  const chromeFlags = buildChromeFlags(config.headless ?? false, debugBindAddress, config.hideWindow ?? false);
14
- const usePatchedLauncher = Boolean(connectHost && connectHost !== "127.0.0.1");
15
12
  // copy-profile reuses a copied signed-in profile whose cookies are
16
13
  // Keychain-encrypted, so it must launch with the real Keychain (not mocked):
17
14
  // strip the keychain-mocking flags from both chrome-launcher's defaults and
@@ -545,6 +542,12 @@ function buildChromeFlags(headless, debugBindAddress, hideWindow = false) {
545
542
  "--disable-features=TranslateUI,AutomationControlled",
546
543
  "--mute-audio",
547
544
  "--window-size=1280,720",
545
+ // Chrome that *we* launch is pinned to English, so ChatGPT renders the labels
546
+ // our selectors were written against. This does not make English the only case
547
+ // to handle: --browser-attach-running and --remote-chrome never build these
548
+ // flags (see controlPlan.ts), so those runs inherit the user's own Chrome
549
+ // locale, and a ChatGPT account language setting can localize the UI even here.
550
+ // That is why the model/effort matchers must stay language-tolerant.
548
551
  "--lang=en-US",
549
552
  "--accept-lang=en-US,en",
550
553
  ];
@@ -595,38 +598,6 @@ function parseDebugPortEnv() {
595
598
  }
596
599
  return value;
597
600
  }
598
- function resolveRemoteDebugHost() {
599
- const override = process.env.ORACLE_BROWSER_REMOTE_DEBUG_HOST?.trim() || process.env.WSL_HOST_IP?.trim();
600
- if (override) {
601
- return override;
602
- }
603
- if (!isWsl()) {
604
- return null;
605
- }
606
- try {
607
- const resolv = readFileSync("/etc/resolv.conf", "utf8");
608
- for (const line of resolv.split("\n")) {
609
- const match = line.match(/^nameserver\s+([0-9.]+)/);
610
- if (match?.[1]) {
611
- return match[1];
612
- }
613
- }
614
- }
615
- catch {
616
- // ignore; fall back to localhost
617
- }
618
- return null;
619
- }
620
- function isWsl() {
621
- if (process.platform !== "linux") {
622
- return false;
623
- }
624
- if (process.env.WSL_DISTRO_NAME) {
625
- return true;
626
- }
627
- const release = os.release();
628
- return release.toLowerCase().includes("microsoft");
629
- }
630
601
  async function launchWithCustomHost({ chromeFlags, chromePath, userDataDir, host, requestedPort, ignoreDefaultFlags, }) {
631
602
  const launcher = new Launcher({
632
603
  chromePath: chromePath ?? undefined,
@@ -71,6 +71,9 @@ function classifyPreservedBrowserError(error, headless) {
71
71
  function shouldPreserveBrowserOnError(error, headless) {
72
72
  return classifyPreservedBrowserError(error, headless) !== null;
73
73
  }
74
+ function normalizeAuthenticatedModelSelectionError(error) {
75
+ return error instanceof Error ? error : new Error(String(error));
76
+ }
74
77
  function shouldKeepLocalBrowserOpen(options) {
75
78
  if (options.usingCopiedProfile)
76
79
  return false;
@@ -1054,11 +1057,9 @@ export async function runBrowserMode(options) {
1054
1057
  }
1055
1058
  },
1056
1059
  })).catch((error) => {
1057
- const base = error instanceof Error ? error.message : String(error);
1058
- const hint = appliedCookies === 0
1059
- ? " No cookies were applied; log in to ChatGPT in Chrome or provide inline cookies (--browser-inline-cookies[(-file)] or ORACLE_BROWSER_COOKIES_JSON)."
1060
- : "";
1061
- throw new Error(`${base}${hint}`);
1060
+ // Login has already been verified above. Preserve the picker failure instead of
1061
+ // misdiagnosing an unavailable model as missing cookies.
1062
+ throw normalizeAuthenticatedModelSelectionError(error);
1062
1063
  });
1063
1064
  await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
1064
1065
  logger(`Prompt textarea ready (after model switch, ${promptText.length.toLocaleString()} chars queued)`);
@@ -1342,7 +1343,11 @@ export async function runBrowserMode(options) {
1342
1343
  if (conversationUrl && isConversationUrl(conversationUrl)) {
1343
1344
  logger(`[browser] Rechecking assistant response at ${conversationUrl}`);
1344
1345
  await raceWithDisconnect(Page.navigate({ url: conversationUrl }));
1345
- await raceWithDisconnect(delay(1000));
1346
+ await raceWithDisconnect(waitForResumedConversationHydration(Runtime, recheckTimeoutMs || 30_000, logger, {
1347
+ requirePriorTurns: true,
1348
+ requirePromptReady: false,
1349
+ expectedConversationUrl: conversationUrl,
1350
+ }));
1346
1351
  }
1347
1352
  // Validate session before attempting recheck - sessions can expire during the delay
1348
1353
  const sessionValid = await validateChatGPTSession(Runtime, logger);
@@ -2607,7 +2612,11 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2607
2612
  lastUrl = conversationUrl;
2608
2613
  logger(`[browser] Rechecking assistant response at ${conversationUrl}`);
2609
2614
  await Page.navigate({ url: conversationUrl });
2610
- await delay(1000);
2615
+ await waitForResumedConversationHydration(Runtime, recheckTimeoutMs || 30_000, logger, {
2616
+ requirePriorTurns: true,
2617
+ requirePromptReady: false,
2618
+ expectedConversationUrl: conversationUrl,
2619
+ });
2611
2620
  }
2612
2621
  // Validate session before attempting recheck - sessions can expire during the delay
2613
2622
  const sessionValid = await validateChatGPTSession(Runtime, logger);
@@ -3051,10 +3060,12 @@ export const __test__ = {
3051
3060
  isManualLoginProfileInitialized,
3052
3061
  isImageOnlyUiChromeText,
3053
3062
  listIgnoredRemoteChromeFlags,
3063
+ normalizeAuthenticatedModelSelectionError,
3054
3064
  resolveManualLoginWaitMs,
3055
3065
  shouldCleanupBlankTabsAfterLastLease,
3056
3066
  shouldCloseOwnedRunTargetAfterRun,
3057
3067
  shouldKeepLocalBrowserOpen,
3068
+ waitForAssistantResponseWithReload,
3058
3069
  };
3059
3070
  export { syncCookies } from "./cookies.js";
3060
3071
  export { navigateToChatGPT, ensureNotBlocked, ensurePromptReady, ensureModelSelection, submitPrompt, waitForAssistantResponse, captureAssistantMarkdown, uploadAttachmentFile, waitForAttachmentCompletion, } from "./pageActions.js";
@@ -3086,7 +3097,11 @@ async function waitForAssistantResponseWithReload(Runtime, Page, timeoutMs, logg
3086
3097
  }
3087
3098
  logger("Assistant response stalled; reloading conversation and retrying once");
3088
3099
  await Page.navigate({ url: conversationUrl });
3089
- await delay(1000);
3100
+ await waitForResumedConversationHydration(Runtime, timeoutMs, logger, {
3101
+ requirePriorTurns: true,
3102
+ requirePromptReady: false,
3103
+ expectedConversationUrl: conversationUrl,
3104
+ });
3090
3105
  return await waitForAssistantResponse(Runtime, timeoutMs, logger, minTurnIndex, expectedConversationId);
3091
3106
  }
3092
3107
  }
@@ -0,0 +1,67 @@
1
+ function cleanLabel(value) {
2
+ const label = value?.trim();
3
+ return label ? label : null;
4
+ }
5
+ function sameLabel(left, right) {
6
+ return left.localeCompare(right, undefined, { sensitivity: "accent" }) === 0;
7
+ }
8
+ /**
9
+ * Describe what a browser run will try to select without presenting the target as observed fact.
10
+ */
11
+ export function formatBrowserModelTarget({ model, desiredModel, modelStrategy, }) {
12
+ const requested = cleanLabel(model) ?? "n/a";
13
+ if (modelStrategy === "current" || modelStrategy === "ignore") {
14
+ return `picker=${modelStrategy}; requested=${requested}`;
15
+ }
16
+ const target = cleanLabel(desiredModel);
17
+ if (!target) {
18
+ return requested;
19
+ }
20
+ return `target=${target}; requested=${requested}`;
21
+ }
22
+ /**
23
+ * Prefer picker evidence only when Oracle verified it. Otherwise retain the requested CLI key.
24
+ * In particular, a bare `Pro` picker label must not be expanded to a server-side model version.
25
+ */
26
+ export function resolveBrowserModelDisplayName({ model, evidence, }) {
27
+ const verifiedLabel = evidence?.verified ? cleanLabel(evidence.resolvedLabel) : null;
28
+ return verifiedLabel ?? cleanLabel(model) ?? "n/a";
29
+ }
30
+ export function formatBrowserModelWithRequestedKey(input) {
31
+ const displayName = resolveBrowserModelDisplayName(input);
32
+ const requested = cleanLabel(input.model);
33
+ if (!requested || sameLabel(displayName, requested)) {
34
+ return displayName;
35
+ }
36
+ return `${displayName} (requested ${requested})`;
37
+ }
38
+ export function resolveSessionBrowserModelDisplayName(metadata, model = metadata.model) {
39
+ const sessionModel = cleanLabel(metadata.model);
40
+ const requestedModel = cleanLabel(model);
41
+ const evidenceApplies = requestedModel === null
42
+ ? sessionModel === null
43
+ : sessionModel !== null && sameLabel(requestedModel, sessionModel);
44
+ return resolveBrowserModelDisplayName({
45
+ model,
46
+ evidence: evidenceApplies ? metadata.browser?.modelSelection : undefined,
47
+ });
48
+ }
49
+ export function formatSessionBrowserModelWithRequestedKey(metadata, model = metadata.model) {
50
+ const sessionModel = cleanLabel(metadata.model);
51
+ const requestedModel = cleanLabel(model);
52
+ const evidenceApplies = requestedModel === null
53
+ ? sessionModel === null
54
+ : sessionModel !== null && sameLabel(requestedModel, sessionModel);
55
+ return formatBrowserModelWithRequestedKey({
56
+ model,
57
+ evidence: evidenceApplies ? metadata.browser?.modelSelection : undefined,
58
+ });
59
+ }
60
+ export function formatBrowserModelSelectionEvidence(evidence, model) {
61
+ const requestedKey = cleanLabel(model) ?? "(none)";
62
+ const target = cleanLabel(evidence.requestedModel) ?? "(none)";
63
+ const resolvedLabel = cleanLabel(evidence.resolvedLabel) ?? "(unavailable)";
64
+ const strategy = evidence.strategy ?? "(default)";
65
+ const verified = evidence.verified ? "yes" : "no";
66
+ return `requestedKey=${requestedKey}; target=${target}; resolvedLabel=${resolvedLabel}; status=${evidence.status}; strategy=${strategy}; verified=${verified}; source=${evidence.source}; capturedAt=${evidence.capturedAt}`;
67
+ }
@@ -2,7 +2,7 @@ import CDP from "chrome-remote-interface";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { mkdtemp, mkdir, rm } from "node:fs/promises";
5
- import { waitForAssistantResponse, captureAssistantMarkdown, navigateToChatGPT, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, } from "./pageActions.js";
5
+ import { waitForAssistantResponse, captureAssistantMarkdown, navigateToChatGPT, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, waitForResumedConversationHydration, } from "./pageActions.js";
6
6
  import { launchChrome, connectToChrome, positionChromeWindowOffscreen, connectToRemoteChromeTarget, listRemoteChromeTargets, } from "./chromeLifecycle.js";
7
7
  import { resolveBrowserConfig } from "./config.js";
8
8
  import { clearStaleChatGptConversationCookies, syncCookies } from "./cookies.js";
@@ -98,6 +98,13 @@ export async function resumeBrowserSession(runtime, config, logger, deps = {}) {
98
98
  const pingTimeoutMs = Math.min(5_000, Math.max(1_500, Math.floor(timeoutMs * 0.05)));
99
99
  await withTimeout(Runtime.evaluate({ expression: "1+1", returnByValue: true }), pingTimeoutMs, "Reattach target did not respond");
100
100
  await ensureConversationOpen();
101
+ const waitForHydration = deps.waitForConversationHydration ?? waitForResumedConversationHydration;
102
+ const expectedConversationUrl = buildConversationUrl(runtime, resolveBrowserConfig(config ?? {}).url);
103
+ await waitForHydration(Runtime, timeoutMs, logger, {
104
+ requirePriorTurns: true,
105
+ requirePromptReady: false,
106
+ expectedConversationUrl: expectedConversationUrl ?? undefined,
107
+ });
101
108
  const minTurnIndex = (await readPromptPreviewTurnIndex(Runtime, deps.promptPreview)) ??
102
109
  (deps.promptPreview ? null : await readConversationTurnIndex(Runtime, logger));
103
110
  if (config?.researchMode === "deep") {
@@ -226,6 +233,12 @@ async function resumeBrowserSessionViaNewChrome(runtime, config, logger, deps) {
226
233
  }
227
234
  await waitForLocationChange(Runtime, 15_000);
228
235
  }
236
+ const waitForHydration = deps.waitForConversationHydration ?? waitForResumedConversationHydration;
237
+ await waitForHydration(Runtime, resolved.inputTimeoutMs, logger, {
238
+ requirePriorTurns: true,
239
+ requirePromptReady: false,
240
+ expectedConversationUrl: conversationUrl ?? undefined,
241
+ });
229
242
  const waitForResponse = deps.waitForAssistantResponse ?? waitForAssistantResponse;
230
243
  const captureMarkdown = deps.captureAssistantMarkdown ?? captureAssistantMarkdown;
231
244
  const timeoutMs = resolved.timeoutMs ?? 120_000;
@@ -67,7 +67,8 @@ export function isRecoveredConversationHarvestReady(harvested) {
67
67
  (typeof harvested.lastAssistantTurnIndex === "number" &&
68
68
  typeof harvested.lastUserTurnIndex === "number" &&
69
69
  harvested.lastAssistantTurnIndex > harvested.lastUserTurnIndex);
70
- return (harvested.stopExists === true ||
70
+ const hasHydratedUserTurn = typeof harvested.lastUserTurnIndex === "number" && harvested.lastUserTurnIndex >= 0;
71
+ return ((harvested.stopExists === true && hasHydratedUserTurn) ||
71
72
  ((harvested.assistantCount ?? 0) > 0 &&
72
73
  assistantFollowsLatestUser &&
73
74
  latestAssistant.trim().length > 0 &&
@@ -5,6 +5,7 @@ import { runBrowserMode } from "../browserMode.js";
5
5
  import { assembleBrowserPrompt } from "./prompt.js";
6
6
  import { BrowserAutomationError } from "../oracle/errors.js";
7
7
  import { appendArtifacts, saveBrowserTranscriptArtifact, saveDeepResearchReportArtifact, } from "./artifacts.js";
8
+ import { formatBrowserModelSelectionEvidence, formatBrowserModelTarget, resolveBrowserModelDisplayName, } from "./modelDisplay.js";
8
9
  const LARGE_PRO_FAST_INPUT_TOKEN_THRESHOLD = 25_000;
9
10
  const LARGE_PRO_FAST_ELAPSED_MS_THRESHOLD = 120_000;
10
11
  function buildUnavailableModelSelectionEvidence(browserConfig) {
@@ -21,13 +22,6 @@ function buildUnavailableModelSelectionEvidence(browserConfig) {
21
22
  capturedAt: new Date().toISOString(),
22
23
  };
23
24
  }
24
- function formatModelSelectionEvidence(evidence) {
25
- const requested = evidence.requestedModel ?? "(none)";
26
- const resolved = evidence.resolvedLabel ?? "(unavailable)";
27
- const strategy = evidence.strategy ?? "(default)";
28
- const verified = evidence.verified ? "yes" : "no";
29
- return `[browser] Model selection evidence: requested=${requested}; resolved=${resolved}; status=${evidence.status}; strategy=${strategy}; verified=${verified}.`;
30
- }
31
25
  function isRequestedProBrowserRun(runOptions, browserConfig, evidence) {
32
26
  const candidates = [
33
27
  runOptions.model,
@@ -87,7 +81,12 @@ export async function runBrowserSessionExecution({ runOptions, browserConfig, cw
87
81
  if (promptArtifacts.bundled) {
88
82
  log(chalk.dim(`Packed ${promptArtifacts.bundled.originalCount} files into 1 bundle (contents counted in token estimate).`));
89
83
  }
90
- const headerLine = `Launching browser mode (${runOptions.model}) with ~${promptArtifacts.estimatedInputTokens.toLocaleString()} tokens.`;
84
+ const launchModel = formatBrowserModelTarget({
85
+ model: runOptions.model,
86
+ desiredModel: browserConfig.desiredModel,
87
+ modelStrategy: browserConfig.modelStrategy,
88
+ });
89
+ const headerLine = `Launching browser mode (${launchModel}) with ~${promptArtifacts.estimatedInputTokens.toLocaleString()} tokens.`;
91
90
  const automationLogger = ((message) => {
92
91
  if (typeof message !== "string")
93
92
  return;
@@ -150,7 +149,7 @@ export async function runBrowserSessionExecution({ runOptions, browserConfig, cw
150
149
  }
151
150
  const modelSelection = browserResult.modelSelection ?? buildUnavailableModelSelectionEvidence(browserConfig);
152
151
  if (modelSelection) {
153
- log(formatModelSelectionEvidence(modelSelection));
152
+ log(`[browser] Model selection evidence: ${formatBrowserModelSelectionEvidence(modelSelection, runOptions.model)}`);
154
153
  }
155
154
  const warnings = buildBrowserRunWarnings({
156
155
  runOptions,
@@ -199,7 +198,7 @@ export async function runBrowserSessionExecution({ runOptions, browserConfig, cw
199
198
  })();
200
199
  const { line1, line2 } = formatFinishLine({
201
200
  elapsedMs: browserResult.tookMs,
202
- model: `${runOptions.model}[browser]`,
201
+ model: `${resolveBrowserModelDisplayName({ model: runOptions.model, evidence: modelSelection })}[browser]`,
203
202
  tokensPart,
204
203
  detailParts: [
205
204
  runOptions.file && runOptions.file.length > 0 ? `files=${runOptions.file.length}` : null,
@@ -0,0 +1,50 @@
1
+ import { readFileSync } from "node:fs";
2
+ import os from "node:os";
3
+ export function isWsl() {
4
+ if (process.platform !== "linux")
5
+ return false;
6
+ if (process.env.WSL_DISTRO_NAME)
7
+ return true;
8
+ return os.release().toLowerCase().includes("microsoft");
9
+ }
10
+ export function parseWslResolverHost(resolvConf) {
11
+ for (const line of resolvConf.split("\n")) {
12
+ const match = line.match(/^nameserver\s+([0-9.]+)/);
13
+ if (match?.[1]) {
14
+ return match[1].startsWith("127.") ? "127.0.0.1" : match[1];
15
+ }
16
+ }
17
+ return null;
18
+ }
19
+ export function resolveWslHost() {
20
+ if (!isWsl())
21
+ return null;
22
+ try {
23
+ return parseWslResolverHost(readFileSync("/etc/resolv.conf", "utf8"));
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
29
+ export function resolveWslChromeHost(options = {}) {
30
+ const remoteDebugHost = options.remoteDebugHost === undefined
31
+ ? process.env.ORACLE_BROWSER_REMOTE_DEBUG_HOST
32
+ : options.remoteDebugHost;
33
+ const wslHostIp = options.wslHostIp === undefined ? process.env.WSL_HOST_IP : options.wslHostIp;
34
+ const override = remoteDebugHost?.trim() || wslHostIp?.trim();
35
+ if (override)
36
+ return override;
37
+ if (options.resolvConf !== undefined) {
38
+ return options.resolvConf === null ? null : parseWslResolverHost(options.resolvConf);
39
+ }
40
+ return resolveWslHost();
41
+ }
42
+ export function resolveWslChromeLaunchRoute(options = {}) {
43
+ const connectHost = resolveWslChromeHost(options);
44
+ const usePatchedLauncher = Boolean(connectHost && connectHost !== "127.0.0.1");
45
+ return {
46
+ connectHost,
47
+ debugBindAddress: usePatchedLauncher ? "0.0.0.0" : connectHost,
48
+ usePatchedLauncher,
49
+ };
50
+ }