@steipete/oracle 0.15.2 → 0.16.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 (67) hide show
  1. package/dist/bin/oracle-cli.js +1 -1
  2. package/dist/src/browser/actions/assistantResponse.js +218 -126
  3. package/dist/src/browser/actions/modelSelection.js +133 -22
  4. package/dist/src/browser/actions/navigation.js +235 -14
  5. package/dist/src/browser/actions/thinkingStatus.js +228 -0
  6. package/dist/src/browser/actions/thinkingTime.js +68 -11
  7. package/dist/src/browser/chromeLifecycle.js +29 -28
  8. package/dist/src/browser/config.js +14 -1
  9. package/dist/src/browser/constants.js +5 -1
  10. package/dist/src/browser/controlPlan.js +2 -2
  11. package/dist/src/browser/index.js +56 -16
  12. package/dist/src/browser/liveTabs.js +113 -31
  13. package/dist/src/browser/pageActions.js +1 -1
  14. package/dist/src/browser/projectSourcesRunner.js +4 -4
  15. package/dist/src/browser/reattach.js +2 -2
  16. package/dist/src/browser/recoverConversation.js +90 -29
  17. package/dist/src/cli/browserConfig.js +5 -1
  18. package/dist/src/cli/browserTabs.js +54 -33
  19. package/dist/src/cli/options.js +24 -0
  20. package/dist/src/cli/runOptions.js +6 -1
  21. package/dist/src/cli/sessionDisplay.js +38 -14
  22. package/dist/src/oracle/config.js +25 -0
  23. package/dist/src/oracle/geminiModels.js +2 -0
  24. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  25. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  26. package/package.json +9 -10
  27. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  28. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  29. package/dist/bin/oracle.js +0 -569
  30. package/dist/docs-site/.nojekyll +0 -0
  31. package/dist/docs-site/CNAME +0 -1
  32. package/dist/docs-site/RELEASING.html +0 -410
  33. package/dist/docs-site/agents.html +0 -374
  34. package/dist/docs-site/anthropic.html +0 -368
  35. package/dist/docs-site/bridge.html +0 -416
  36. package/dist/docs-site/browser-mode.html +0 -594
  37. package/dist/docs-site/chromium-forks.html +0 -347
  38. package/dist/docs-site/cli-reference.html +0 -346
  39. package/dist/docs-site/configuration.html +0 -462
  40. package/dist/docs-site/favicon.svg +0 -14
  41. package/dist/docs-site/followup.html +0 -375
  42. package/dist/docs-site/gemini.html +0 -383
  43. package/dist/docs-site/grok.html +0 -325
  44. package/dist/docs-site/index.html +0 -360
  45. package/dist/docs-site/install.html +0 -335
  46. package/dist/docs-site/linux.html +0 -321
  47. package/dist/docs-site/llms.txt +0 -43
  48. package/dist/docs-site/manual-tests.html +0 -596
  49. package/dist/docs-site/mcp.html +0 -391
  50. package/dist/docs-site/multimodel.html +0 -364
  51. package/dist/docs-site/mythical-pro-agents.html +0 -360
  52. package/dist/docs-site/notifier.html +0 -338
  53. package/dist/docs-site/openai-endpoints.html +0 -399
  54. package/dist/docs-site/openrouter.html +0 -344
  55. package/dist/docs-site/quickstart.html +0 -369
  56. package/dist/docs-site/refactor/ux.html +0 -532
  57. package/dist/docs-site/sessions.html +0 -388
  58. package/dist/docs-site/social-card.png +0 -0
  59. package/dist/docs-site/social-card.svg +0 -79
  60. package/dist/docs-site/spec.html +0 -363
  61. package/dist/docs-site/testing.html +0 -320
  62. package/dist/docs-site/tui-debug.html +0 -326
  63. package/dist/docs-site/windows-work.html +0 -323
  64. package/dist/docs-site/windows.html +0 -320
  65. package/dist/src/browser/chromeCookies.js +0 -312
  66. package/dist/src/browser/keytarShim.js +0 -56
  67. package/dist/src/browser/windowsCookies.js +0 -219
@@ -36,9 +36,10 @@ export async function ensureModelSelection(Runtime, desiredModel, logger, strate
36
36
  switch (result?.status) {
37
37
  case "already-selected":
38
38
  case "switched": {
39
- const label = result.label?.trim() || (strategy === "current" ? null : desiredModel);
39
+ const observedLabel = result.label?.trim() || null;
40
+ const label = observedLabel || (strategy === "current" ? null : desiredModel);
40
41
  if (strategy !== "current") {
41
- assertResolvedModelSelection(desiredModel, label ?? desiredModel);
42
+ assertResolvedModelSelection(desiredModel, observedLabel ?? "");
42
43
  }
43
44
  logger(`Model picker: ${label ?? "current model (label unavailable)"}`);
44
45
  return {
@@ -70,6 +71,18 @@ export async function ensureModelSelection(Runtime, desiredModel, logger, strate
70
71
  function assertResolvedModelSelection(desiredModel, resolvedLabel) {
71
72
  const desired = desiredModel.toLowerCase();
72
73
  const resolved = resolvedLabel.toLowerCase();
74
+ const normalizedDesired = normalizeResolvedModelLabel(desired);
75
+ const normalizedResolved = normalizeResolvedModelLabel(resolved);
76
+ const wantsGpt56Sol = /(?:^| )5 6(?: |$)/.test(normalizedDesired) && normalizedDesired.split(" ").includes("sol");
77
+ if (wantsGpt56Sol) {
78
+ const resolvedTokens = normalizedResolved.split(" ");
79
+ if (!/(?:^| )5 6(?: |$)/.test(normalizedResolved) ||
80
+ !resolvedTokens.includes("sol") ||
81
+ resolvedTokens.includes("pro")) {
82
+ throw new Error(`Model picker selected "${resolvedLabel}" while "${desiredModel}" requires GPT-5.6 Sol.`);
83
+ }
84
+ return;
85
+ }
73
86
  const wantsGpt55Pro = desired === "pro" ||
74
87
  desired === "chatgpt pro" ||
75
88
  desired === "gpt-5.5-pro" ||
@@ -152,10 +165,12 @@ function buildModelSelectionExpression(targetModel, strategy) {
152
165
  .map((token) => normalizeText(token))
153
166
  .filter(Boolean);
154
167
  const targetWords = normalizedTarget.split(' ').filter(Boolean);
155
- const desiredVersion = normalizedTarget.includes('5 4')
156
- ? '5-4'
168
+ const desiredVersion = normalizedTarget.includes('5 6')
169
+ ? '5-6'
157
170
  : normalizedTarget.includes('5 5')
158
171
  ? '5-5'
172
+ : normalizedTarget.includes('5 4')
173
+ ? '5-4'
159
174
  : normalizedTarget.includes('5 3')
160
175
  ? '5-3'
161
176
  : normalizedTarget.includes('5 2')
@@ -165,6 +180,7 @@ function buildModelSelectionExpression(targetModel, strategy) {
165
180
  : normalizedTarget.includes('5 0')
166
181
  ? '5-0'
167
182
  : null;
183
+ const desiredModelVariant = normalizedTarget.includes(' sol') ? 'sol' : null;
168
184
  const wantsPro = normalizedTarget.includes(' pro') || normalizedTarget.endsWith(' pro') || normalizedTokens.includes('pro');
169
185
  const wantsInstant = normalizedTarget.includes('instant');
170
186
  const wantsThinking = normalizedTarget.includes('thinking');
@@ -280,12 +296,14 @@ function buildModelSelectionExpression(targetModel, strategy) {
280
296
  label === 'medium' ||
281
297
  label === 'high' ||
282
298
  label === 'extra high' ||
299
+ label === 'pro' ||
283
300
  label === 'extended' ||
284
301
  label === 'standard' ||
285
302
  label === 'heavy' ||
286
303
  label === 'light';
287
304
  const formatModelOptionLabel = (label) => {
288
305
  const normalized = normalizeText(label ?? '');
306
+ if (normalized === '5 6' || normalized === 'gpt 5 6') return 'GPT-5.6';
289
307
  if (normalized === '5 5' || normalized === 'gpt 5 5') return 'GPT-5.5';
290
308
  if (normalized === '5 4' || normalized === 'gpt 5 4') return 'GPT-5.4';
291
309
  if (normalized === '5 3' || normalized === 'gpt 5 3') return 'GPT-5.3';
@@ -319,17 +337,12 @@ function buildModelSelectionExpression(targetModel, strategy) {
319
337
  return 1000;
320
338
  };
321
339
  const versionFromLabel = (label) => {
322
- if (label === '5 5' || label === 'gpt 5 5') return '5-5';
323
- if (label === '5 4' || label === 'gpt 5 4') return '5-4';
324
- if (label === '5 3' || label === 'gpt 5 3') return '5-3';
325
- if (label === '5 2' || label === 'gpt 5 2') return '5-2';
326
- if (label === '5 1' || label === 'gpt 5 1') return '5-1';
327
- if (label === '5 0' || label === 'gpt 5 0') return '5-0';
328
- if (label === '4 5' || label === 'gpt 4 5') return '4-5';
329
- return null;
340
+ const match = normalizeText(label).match(/(?:^| )(?:gpt )?(\\d+) (\\d+)(?: |$)/);
341
+ return match ? match[1] + '-' + match[2] : null;
330
342
  };
331
343
  const versionFromTestId = (testid) => {
332
344
  const normalized = normalizeText(testid);
345
+ if (normalized.includes('5 6') || normalized.includes('gpt56')) return '5-6';
333
346
  if (normalized.includes('5 5') || normalized.includes('gpt55')) return '5-5';
334
347
  if (normalized.includes('5 4') || normalized.includes('gpt54')) return '5-4';
335
348
  if (normalized.includes('5 3') || normalized.includes('gpt53')) return '5-3';
@@ -355,9 +368,16 @@ function buildModelSelectionExpression(targetModel, strategy) {
355
368
  return label;
356
369
  };
357
370
  const configuredSelectionMatchesTarget = () => {
358
- const configuredVersion = versionFromLabel(normalizeText(getConfiguredVersionLabel()));
371
+ const configuredVersionLabel = normalizeText(getConfiguredVersionLabel());
372
+ const configuredVersion = versionFromLabel(configuredVersionLabel);
359
373
  if (!configuredVersion || configuredVersion !== desiredVersion) return false;
360
374
  const configuredVariant = normalizeText(getConfiguredVariantLabel());
375
+ if (
376
+ desiredModelVariant &&
377
+ !configuredVersionLabel.includes(desiredModelVariant) &&
378
+ !configuredVariant.includes(desiredModelVariant)
379
+ ) return false;
380
+ if (desiredModelVariant === 'sol' && labelHasProWord(configuredVariant)) return false;
361
381
  if (wantsPro) return labelHasProWord(configuredVariant);
362
382
  if (wantsInstant) return configuredVariant.includes('instant');
363
383
  if (wantsThinking) {
@@ -372,10 +392,36 @@ function buildModelSelectionExpression(targetModel, strategy) {
372
392
  return [variant, version].filter(Boolean).join(' ');
373
393
  }
374
394
  const composerLabel = getComposerModelLabel();
375
- if (composerLabel) return withProPillSignal(composerLabel);
395
+ const normalizedComposerLabel = normalizeText(composerLabel);
396
+ if (
397
+ desiredVersion &&
398
+ desiredModelVariant &&
399
+ versionFromLabel(normalizedComposerLabel) === desiredVersion &&
400
+ normalizedComposerLabel.split(' ').includes(desiredModelVariant)
401
+ ) {
402
+ return withProPillSignal(composerLabel);
403
+ }
376
404
  const buttonLabel = getButtonLabel();
377
405
  const normalizedButton = normalizeText(buttonLabel);
406
+ if (
407
+ desiredVersion &&
408
+ desiredModelVariant &&
409
+ versionFromLabel(normalizedButton) === desiredVersion &&
410
+ normalizedButton.split(' ').includes(desiredModelVariant)
411
+ ) {
412
+ return withProPillSignal(buttonLabel);
413
+ }
378
414
  const fallbackLabel = formatModelOptionLabel(fallback);
415
+ const normalizedFallback = normalizeText(fallbackLabel);
416
+ if (
417
+ desiredVersion &&
418
+ desiredModelVariant &&
419
+ versionFromLabel(normalizedFallback) === desiredVersion &&
420
+ normalizedFallback.split(' ').includes(desiredModelVariant)
421
+ ) {
422
+ return fallbackLabel;
423
+ }
424
+ if (composerLabel) return withProPillSignal(composerLabel);
379
425
  if (fallbackLabel && !wantsPro && isIntelligenceEffortLabel(normalizedButton)) {
380
426
  return fallbackLabel;
381
427
  }
@@ -397,6 +443,7 @@ function buildModelSelectionExpression(targetModel, strategy) {
397
443
  if (configuredSelectionMatchesTarget()) return true;
398
444
  const normalizedLabel = normalizeText(getButtonLabel());
399
445
  if (!normalizedLabel) return false;
446
+ if (desiredModelVariant === 'sol' && hasProComposerPill()) return false;
400
447
  if (wantsThinking && !wantsPro && hasProComposerPill()) return false;
401
448
  if (isTargetGpt55VisibleAlias(normalizedLabel)) return true;
402
449
  if (
@@ -421,6 +468,7 @@ function buildModelSelectionExpression(targetModel, strategy) {
421
468
  return true;
422
469
  }
423
470
  if (desiredVersion) {
471
+ if (desiredVersion === '5-6' && !normalizedLabel.includes('5 6')) return false;
424
472
  if (desiredVersion === '5-5' && !normalizedLabel.includes('5 5')) return false;
425
473
  if (desiredVersion === '5-4' && !normalizedLabel.includes('5 4')) return false;
426
474
  if (desiredVersion === '5-3' && !normalizedLabel.includes('5 3')) return false;
@@ -428,6 +476,7 @@ function buildModelSelectionExpression(targetModel, strategy) {
428
476
  if (desiredVersion === '5-1' && !normalizedLabel.includes('5 1')) return false;
429
477
  if (desiredVersion === '5-0' && !normalizedLabel.includes('5 0')) return false;
430
478
  }
479
+ if (desiredModelVariant && !normalizedLabel.includes(desiredModelVariant)) return false;
431
480
  if (wantsPro && labelHasLegacyProVersion(normalizedLabel)) return false;
432
481
  if (wantsPro && !labelHasProWord(normalizedLabel)) return false;
433
482
  if (wantsInstant && !normalizedLabel.includes('instant')) return false;
@@ -455,6 +504,9 @@ function buildModelSelectionExpression(targetModel, strategy) {
455
504
  if (!signal) {
456
505
  return COMPOSER_SIGNAL_ALLOW_BLANK;
457
506
  }
507
+ if (desiredModelVariant === 'sol' && hasProComposerPill()) {
508
+ return false;
509
+ }
458
510
  if (wantsPro && labelHasLegacyProVersion(signal)) {
459
511
  return false;
460
512
  }
@@ -542,6 +594,9 @@ function buildModelSelectionExpression(targetModel, strategy) {
542
594
  let score = 0;
543
595
  const normalizedTestId = (testid ?? '').toLowerCase();
544
596
  const candidateTextVersion = versionFromLabel(normalizedText);
597
+ const candidateIsVersionSubmenuTrigger = Boolean(
598
+ candidateTextVersion && node?.getAttribute?.('aria-haspopup') === 'menu'
599
+ );
545
600
  const candidateIsVersionCombobox =
546
601
  node?.getAttribute?.('role') === 'combobox' &&
547
602
  node?.getAttribute?.('aria-labelledby') === 'model-selection-label';
@@ -553,6 +608,20 @@ function buildModelSelectionExpression(targetModel, strategy) {
553
608
  desiredVersion !== '5-5' &&
554
609
  (normalizedTestId === 'model-configure-modal' ||
555
610
  (candidateIsVersionCombobox && node?.getAttribute?.('aria-expanded') !== 'true'));
611
+ const candidateIsConfiguredVersionNavigation =
612
+ Boolean(getConfigurationDialog()) &&
613
+ candidateTextVersion === desiredVersion &&
614
+ (node?.getAttribute?.('role') === 'option' ||
615
+ node?.getAttribute?.('role') === 'menuitemradio');
616
+ if (
617
+ desiredModelVariant &&
618
+ !normalizedText.includes(desiredModelVariant) &&
619
+ !candidateIsVersionSubmenuTrigger &&
620
+ !candidateOpensConfiguration &&
621
+ !candidateIsConfiguredVersionNavigation
622
+ ) {
623
+ return 0;
624
+ }
556
625
  if (candidateOpensConfiguration) {
557
626
  return 2000;
558
627
  }
@@ -560,6 +629,12 @@ function buildModelSelectionExpression(targetModel, strategy) {
560
629
  if (normalizedTestId) {
561
630
  if (desiredVersion) {
562
631
  // data-testid strings have been observed with both dotted and dashed versions (e.g. gpt-5.2-pro vs gpt-5-2-pro).
632
+ const has56 =
633
+ normalizedTestId.includes('5-6') ||
634
+ normalizedTestId.includes('5.6') ||
635
+ normalizedTestId.includes('gpt-5-6') ||
636
+ normalizedTestId.includes('gpt-5.6') ||
637
+ normalizedTestId.includes('gpt56');
563
638
  const has52 =
564
639
  normalizedTestId.includes('5-2') ||
565
640
  normalizedTestId.includes('5.2') ||
@@ -596,8 +671,10 @@ function buildModelSelectionExpression(targetModel, strategy) {
596
671
  normalizedTestId.includes('gpt-5-0') ||
597
672
  normalizedTestId.includes('gpt-5.0') ||
598
673
  normalizedTestId.includes('gpt50');
599
- const candidateVersion = has55
600
- ? '5-5'
674
+ const candidateVersion = has56
675
+ ? '5-6'
676
+ : has55
677
+ ? '5-5'
601
678
  : has54
602
679
  ? '5-4'
603
680
  : has53
@@ -645,10 +722,11 @@ function buildModelSelectionExpression(targetModel, strategy) {
645
722
  const candidateClearsProForThinking =
646
723
  wantsThinking && !wantsPro && hasActiveProPill && candidateIsNonProThinkingEffort;
647
724
  const candidateOpensVersionSubmenu =
648
- wantsThinking &&
649
- desiredVersion !== '5-5' &&
650
- normalizedText === 'gpt 5 5' &&
651
- !normalizedTestId.includes('pro');
725
+ (desiredVersion === '5-6' && candidateIsVersionSubmenuTrigger) ||
726
+ (wantsThinking &&
727
+ desiredVersion !== '5-5' &&
728
+ normalizedText === 'gpt 5 5' &&
729
+ !normalizedTestId.includes('pro'));
652
730
  const candidateSelectsDesiredVersion =
653
731
  Boolean(desiredVersion) && candidateTextVersion === desiredVersion;
654
732
  if (
@@ -695,6 +773,7 @@ function buildModelSelectionExpression(targetModel, strategy) {
695
773
  node?.getAttribute?.('aria-haspopup') === 'menu' ||
696
774
  node?.getAttribute?.('data-has-submenu') !== null);
697
775
  if (wantsPro && candidateHasThinking) return 0;
776
+ if (desiredModelVariant === 'sol' && candidateHasPro) return 0;
698
777
  if (wantsPro && candidateHasLegacyProVersion && !candidateSelectsDesiredVersion) return 0;
699
778
  if (wantsPro && !candidateHasPro && !candidateSelectsDesiredVersion) return 0;
700
779
  if (
@@ -850,10 +929,17 @@ function buildModelSelectionExpression(targetModel, strategy) {
850
929
  if (getConfigurationDialog() && !configuredSelectionMatchesTarget()) return false;
851
930
  const optionVersion = versionFromLabel(normalizedText) ?? versionFromTestId(testid);
852
931
  if (desiredVersion && optionVersion !== desiredVersion) return false;
932
+ if (desiredModelVariant && !normalizedText.includes(desiredModelVariant)) return false;
933
+ if (
934
+ desiredModelVariant === 'sol' &&
935
+ (labelHasProWord(normalizedText) || normalizeText(testid ?? '').includes('pro'))
936
+ ) return false;
937
+ if (desiredVersion === '5-6') return !hasProComposerPill();
853
938
  const currentButtonLabel = normalizeText(getButtonLabel());
854
939
  return !labelHasProWord(currentButtonLabel) && !hasProComposerPill();
855
940
  };
856
941
  const openedSubmenuKeys = new Set();
942
+ let clickedTargetOption = false;
857
943
  const submenuKey = (normalizedText, testid) =>
858
944
  normalizeText(testid ?? '') + '|' + normalizedText;
859
945
 
@@ -927,10 +1013,13 @@ function buildModelSelectionExpression(targetModel, strategy) {
927
1013
  });
928
1014
  const dispatchHoverSequence = (target) => {
929
1015
  if (!target || !(target instanceof EventTarget)) return false;
1016
+ const rect = target.getBoundingClientRect?.();
1017
+ const clientX = rect ? rect.x + rect.width / 2 : 0;
1018
+ const clientY = rect ? rect.y + rect.height / 2 : 0;
930
1019
  const types = ['pointerover', 'pointerenter', 'mouseover', 'mouseenter', 'pointermove', 'mousemove'];
931
1020
  for (const type of types) {
932
1021
  try {
933
- const common = { bubbles: true, cancelable: true, view: window };
1022
+ const common = { bubbles: true, cancelable: true, view: window, clientX, clientY };
934
1023
  const event =
935
1024
  type.startsWith('pointer') && 'PointerEvent' in window
936
1025
  ? new PointerEvent(type, { ...common, pointerId: 1, pointerType: 'mouse' })
@@ -1002,7 +1091,10 @@ function buildModelSelectionExpression(targetModel, strategy) {
1002
1091
  ) {
1003
1092
  const resolvedLabel = getResolvedLabel(match.label);
1004
1093
  closeMenu();
1005
- resolve({ status: 'already-selected', label: resolvedLabel });
1094
+ resolve({
1095
+ status: clickedTargetOption ? 'switched' : 'already-selected',
1096
+ label: resolvedLabel,
1097
+ });
1006
1098
  return;
1007
1099
  }
1008
1100
  const previousButtonLabel = normalizeText(getButtonLabel());
@@ -1016,6 +1108,7 @@ function buildModelSelectionExpression(targetModel, strategy) {
1016
1108
  setTimeout(attempt, REOPEN_INTERVAL_MS / 2);
1017
1109
  return;
1018
1110
  }
1111
+ clickedTargetOption = true;
1019
1112
  dispatchClickSequence(match.node);
1020
1113
  // Wait for the selected model signal to settle before reopening the picker.
1021
1114
  waitForTargetSelection(previousButtonLabel, previousComposerSignal).then((selectionSettled) => {
@@ -1052,6 +1145,9 @@ function buildComposerSignalMatchers(targetModel) {
1052
1145
  .replace(/[^a-z0-9]+/g, " ")
1053
1146
  .replace(/\s+/g, " ")
1054
1147
  .trim();
1148
+ if (normalized.includes("5 6")) {
1149
+ return { includesAny: ["5 6 sol"], excludesAny: ["pro"], allowBlank: false };
1150
+ }
1055
1151
  if (normalized.includes("pro")) {
1056
1152
  return { includesAny: ["pro"], excludesAny: ["thinking"], allowBlank: false };
1057
1153
  }
@@ -1086,6 +1182,21 @@ function buildModelMatchersLiteral(targetModel) {
1086
1182
  push(`chatgpt ${dotless}`, labelTokens);
1087
1183
  push(`gpt ${base}`, labelTokens);
1088
1184
  push(`gpt ${dotless}`, labelTokens);
1185
+ // Numeric variations (5.6 <-> 56 <-> gpt-5-6)
1186
+ if (base.includes("5.6") || base.includes("5-6") || base.includes("56")) {
1187
+ push("5.6", labelTokens);
1188
+ push("gpt-5.6", labelTokens);
1189
+ push("gpt5.6", labelTokens);
1190
+ push("gpt-5-6", labelTokens);
1191
+ push("gpt5-6", labelTokens);
1192
+ push("gpt56", labelTokens);
1193
+ push("gpt-5.6 sol", labelTokens);
1194
+ push("chatgpt 5.6", labelTokens);
1195
+ testIdTokens.add("model-switcher-gpt-5-6");
1196
+ testIdTokens.add("gpt-5-6");
1197
+ testIdTokens.add("gpt5-6");
1198
+ testIdTokens.add("gpt56");
1199
+ }
1089
1200
  // Numeric variations (5.5 <-> 55 <-> gpt-5-5)
1090
1201
  if (base.includes("5.5") || base.includes("5-5") || base.includes("55")) {
1091
1202
  push("5.5", labelTokens);
@@ -124,6 +124,172 @@ export async function navigateToPromptReadyWithFallback(Page, Runtime, options,
124
124
  return { usedFallback: true };
125
125
  }
126
126
  }
127
+ function buildChatModeProbeExpression() {
128
+ return `(() => {
129
+ const normalize = (value) => String(value || '').toLowerCase().replace(/\\s+/g, ' ').trim();
130
+ const isVisible = (node) => {
131
+ if (!(node instanceof HTMLElement)) return false;
132
+ const rect = node.getBoundingClientRect();
133
+ if (rect.width <= 0 || rect.height <= 0) return false;
134
+ const style = window.getComputedStyle(node);
135
+ return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0';
136
+ };
137
+ const isSelected = (node) =>
138
+ node?.getAttribute?.('aria-checked') === 'true' ||
139
+ node?.getAttribute?.('data-state') === 'on';
140
+ const conversationIdFromPath = (value) => {
141
+ const match = String(value || '').match(/\\/c\\/([a-zA-Z0-9-]+)/);
142
+ return match?.[1] || null;
143
+ };
144
+ const isStructuredWorkBadge = (node) =>
145
+ node instanceof HTMLElement &&
146
+ node.tagName === 'SPAN' &&
147
+ normalize(node.textContent) === 'work' &&
148
+ node.childElementCount === 0 &&
149
+ !node.hasAttribute('dir') &&
150
+ node.classList.contains('shrink-0') &&
151
+ node.parentElement?.matches('span.flex.items-center');
152
+
153
+ const pathname = typeof location?.pathname === 'string' ? location.pathname : '';
154
+ const conversationId = conversationIdFromPath(pathname);
155
+ if (conversationId) {
156
+ // Conversation messages can contain same-origin links to the current thread. Only sidebar
157
+ // history items use ChatGPT's renderer-owned menu-item anchor class.
158
+ const activeHistoryLinks = Array.from(
159
+ document.querySelectorAll('a.__menu-item[href*="/c/"]'),
160
+ ).filter((node) => {
161
+ try {
162
+ const candidateUrl = new URL(node.getAttribute('href') || '', location.origin);
163
+ return candidateUrl.origin === location.origin && conversationIdFromPath(candidateUrl.pathname) === conversationId;
164
+ } catch {
165
+ return false;
166
+ }
167
+ });
168
+ if (activeHistoryLinks.length > 0) {
169
+ const hasWorkBadge = activeHistoryLinks.some((link) =>
170
+ Array.from(link.querySelectorAll('span')).some(isStructuredWorkBadge),
171
+ );
172
+ if (hasWorkBadge) return { status: 'work-conversation' };
173
+
174
+ const ariaLabels = activeHistoryLinks
175
+ .map((link) => normalize(link.getAttribute('aria-label')))
176
+ .filter(Boolean);
177
+ if (ariaLabels.length === 0 || ariaLabels.some((aria) => /,\\s*work\\s*$/.test(aria))) {
178
+ return { status: 'conversation-unresolved' };
179
+ }
180
+ return { status: 'chat-conversation' };
181
+ }
182
+ return { status: 'conversation-unresolved' };
183
+ }
184
+
185
+ const radios = Array.from(document.querySelectorAll('button[role="radio"]')).filter(isVisible);
186
+ const chat = radios.find((node) => normalize(node.textContent) === 'chat');
187
+ const work = radios.find((node) => normalize(node.textContent) === 'work');
188
+ if (!chat && !work) return { status: 'controls-absent' };
189
+ if (!chat || !work) return { status: 'mode-unresolved' };
190
+ if (isSelected(chat)) return { status: 'chat-selected' };
191
+ if (isSelected(work)) {
192
+ const rect = chat.getBoundingClientRect();
193
+ return {
194
+ status: 'work-selected',
195
+ chatPoint: { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 },
196
+ };
197
+ }
198
+ return { status: 'mode-unresolved' };
199
+ })()`;
200
+ }
201
+ export async function ensureChatMode(Runtime, Input, timeoutMs, logger, options = {}) {
202
+ const verificationWindowMs = Math.min(Math.max(0, timeoutMs), 10_000);
203
+ let deadline = Date.now() + verificationWindowMs;
204
+ const pollMs = Math.max(0, options.pollMs ?? 200);
205
+ let changedFromWork = false;
206
+ let clickedChat = false;
207
+ for (;;) {
208
+ const outcome = await Runtime.evaluate({
209
+ expression: buildChatModeProbeExpression(),
210
+ returnByValue: true,
211
+ });
212
+ const probe = outcome.result?.value;
213
+ if (probe?.status === "chat-selected" || probe?.status === "chat-conversation") {
214
+ logger(changedFromWork ? "ChatGPT mode: Chat (switched from Work)" : "ChatGPT mode: Chat");
215
+ return changedFromWork ? "switched" : "chat";
216
+ }
217
+ if (probe?.status === "conversation-unresolved") {
218
+ if (Date.now() < deadline) {
219
+ await delay(pollMs);
220
+ continue;
221
+ }
222
+ if (changedFromWork)
223
+ break;
224
+ if (options.resetWorkConversation) {
225
+ logger("ChatGPT conversation mode unresolved; opening a new Chat");
226
+ await options.resetWorkConversation();
227
+ changedFromWork = true;
228
+ deadline = Date.now() + verificationWindowMs;
229
+ await delay(pollMs);
230
+ continue;
231
+ }
232
+ throw new BrowserAutomationError("Oracle could not verify whether the selected ChatGPT conversation uses Chat or Work mode, so it cannot safely resume that conversation.", { stage: "chat-mode-selection", details: { mode: "conversation-unresolved" } });
233
+ }
234
+ if (probe?.status === "controls-absent") {
235
+ if (changedFromWork) {
236
+ if (Date.now() < deadline) {
237
+ await delay(pollMs);
238
+ continue;
239
+ }
240
+ break;
241
+ }
242
+ return "unavailable";
243
+ }
244
+ if (probe?.status === "work-conversation") {
245
+ if (changedFromWork)
246
+ break;
247
+ if (options.resetWorkConversation) {
248
+ logger("ChatGPT mode: Work conversation; opening a new Chat");
249
+ await options.resetWorkConversation();
250
+ changedFromWork = true;
251
+ deadline = Date.now() + verificationWindowMs;
252
+ await delay(pollMs);
253
+ continue;
254
+ }
255
+ throw new BrowserAutomationError("The selected ChatGPT tab is an existing Work conversation and cannot be resumed as an ordinary Chat conversation. Start a new Chat conversation instead.", { stage: "chat-mode-selection", details: { mode: "work-conversation" } });
256
+ }
257
+ if (probe?.status === "work-selected" && !clickedChat && probe.chatPoint) {
258
+ logger("ChatGPT mode: Work; switching to Chat");
259
+ const { x, y } = probe.chatPoint;
260
+ await Input.dispatchMouseEvent({ type: "mouseMoved", x, y });
261
+ await Input.dispatchMouseEvent({
262
+ type: "mousePressed",
263
+ x,
264
+ y,
265
+ button: "left",
266
+ clickCount: 1,
267
+ });
268
+ await Input.dispatchMouseEvent({
269
+ type: "mouseReleased",
270
+ x,
271
+ y,
272
+ button: "left",
273
+ clickCount: 1,
274
+ });
275
+ changedFromWork = true;
276
+ clickedChat = true;
277
+ deadline = Date.now() + verificationWindowMs;
278
+ await delay(pollMs);
279
+ continue;
280
+ }
281
+ if (Date.now() >= deadline)
282
+ break;
283
+ await delay(pollMs);
284
+ }
285
+ await logDomFailure(Runtime, logger, "chat-mode-selection");
286
+ throw new BrowserAutomationError(changedFromWork
287
+ ? "Oracle could not verify Chat mode after leaving Work."
288
+ : "ChatGPT exposed Chat/Work controls but Oracle could not verify the active mode.", { stage: "chat-mode-selection", details: { changedFromWork, clickedChat } });
289
+ }
290
+ export function buildChatModeProbeExpressionForTest() {
291
+ return buildChatModeProbeExpression();
292
+ }
127
293
  export async function ensureNotBlocked(Runtime, headless, logger) {
128
294
  if (await isCloudflareInterstitial(Runtime)) {
129
295
  const message = headless
@@ -456,22 +622,77 @@ async function waitForPrompt(Runtime, timeoutMs) {
456
622
  }
457
623
  return false;
458
624
  }
459
- async function isCloudflareInterstitial(Runtime) {
460
- const { result: titleResult } = await Runtime.evaluate({
461
- expression: "document.title",
462
- returnByValue: true,
463
- });
464
- const title = typeof titleResult.value === "string" ? titleResult.value : "";
465
- const challengeTitle = CLOUDFLARE_TITLE.toLowerCase();
466
- if (title.toLowerCase().includes(challengeTitle)) {
467
- return true;
625
+ // A single injected verdict. The old check flagged a challenge whenever the
626
+ // '/challenge-platform/' script was present, but Cloudflare bot-management injects that script on
627
+ // NORMAL ChatGPT pages too (including the new GPT-5.6 "Work" UI), so it false-flagged healthy pages
628
+ // as challenges (which then aborted the run). Classification by evidence strength:
629
+ // strong - the "Just a moment"/"Attention Required" title, a challenge widget, or
630
+ // verification copy on a short page: a real interstitial, classify immediately.
631
+ // shell - the ChatGPT app shell rendered: never a challenge, regardless of injected scripts.
632
+ // weak - only the bot-management script on a short, shell-less page. This is INFERENCE, and
633
+ // right after navigation it also matches a healthy SPA load mid-hydration (readyState
634
+ // fires before React renders the shell), so weak evidence must PERSIST through a
635
+ // hydration grace window before it counts (see isCloudflareInterstitial).
636
+ export function buildCloudflareVerdictExpression() {
637
+ return `(() => {
638
+ const title = String(document.title || '').toLowerCase();
639
+ const titleSaysChallenge =
640
+ title.includes(${JSON.stringify(CLOUDFLARE_TITLE.toLowerCase())}) ||
641
+ (title.includes('attention required') && title.includes('cloudflare'));
642
+ const hasAppShell = Boolean(document.querySelector(
643
+ '#prompt-textarea, [data-testid="prompt-textarea"], [data-testid^="conversation-turn"], [data-testid="profile-button"], main form[data-type], nav a[href*="/c/"]'
644
+ ));
645
+ const bodyText = String((document.body && document.body.innerText) || '')
646
+ .toLowerCase().replace(/\\s+/g, ' ').trim();
647
+ const isShortPage = bodyText.length < 600;
648
+ const hasChallengeWidget = Boolean(document.querySelector(
649
+ '#challenge-form, #challenge-running, #cf-challenge-running, [class*="cf-challenge"], iframe[src*="challenges.cloudflare.com"], iframe[src*="/cdn-cgi/challenge-platform/"]'
650
+ ));
651
+ const saysVerifying = /verify(ing)? you are human|checking your browser|needs to review the security of your connection|just a moment/.test(bodyText);
652
+ const hasChallengeScript = Boolean(document.querySelector(${JSON.stringify(CLOUDFLARE_SCRIPT_SELECTOR)}));
653
+ return {
654
+ strong:
655
+ !hasAppShell &&
656
+ (titleSaysChallenge || hasChallengeWidget || (isShortPage && saysVerifying)),
657
+ shell: hasAppShell,
658
+ // A genuine interstitial is a SHORT page; the content-rich app never is. So the script
659
+ // only counts on a short page with no app shell.
660
+ weak: !hasAppShell && hasChallengeScript && isShortPage,
661
+ };
662
+ })()`;
663
+ }
664
+ // Boolean composition kept for tests/back-compat: what an instantaneous classifier would say.
665
+ export function buildCloudflareInterstitialExpression() {
666
+ return `(() => { const v = ${buildCloudflareVerdictExpression()}; return v.strong || v.weak; })()`;
667
+ }
668
+ export const buildCloudflareInterstitialExpressionForTest = buildCloudflareInterstitialExpression;
669
+ export const buildCloudflareVerdictExpressionForTest = buildCloudflareVerdictExpression;
670
+ // Weak (script-only) evidence must persist through a hydration grace window: callers run this
671
+ // right after navigation, when a healthy SPA load can briefly look exactly like the weak case
672
+ // (script present, shell not yet rendered, short body). A real interstitial never grows an app
673
+ // shell, so waiting converts the race into a correct answer at the cost of a few seconds only
674
+ // on genuinely ambiguous pages. Strong evidence still classifies immediately.
675
+ const CLOUDFLARE_HYDRATION_GRACE_MS = 12_000;
676
+ async function isCloudflareInterstitial(Runtime, graceMs = CLOUDFLARE_HYDRATION_GRACE_MS) {
677
+ const deadline = Date.now() + Math.max(0, graceMs);
678
+ for (;;) {
679
+ const { result } = await Runtime.evaluate({
680
+ expression: buildCloudflareVerdictExpression(),
681
+ returnByValue: true,
682
+ });
683
+ const verdict = (result?.value ?? {});
684
+ if (verdict.strong)
685
+ return true;
686
+ if (verdict.shell)
687
+ return false;
688
+ if (!verdict.weak)
689
+ return false;
690
+ if (Date.now() >= deadline)
691
+ return true;
692
+ await delay(500);
468
693
  }
469
- const { result } = await Runtime.evaluate({
470
- expression: `Boolean(document.querySelector('${CLOUDFLARE_SCRIPT_SELECTOR}'))`,
471
- returnByValue: true,
472
- });
473
- return Boolean(result.value);
474
694
  }
695
+ export const isCloudflareInterstitialForTest = isCloudflareInterstitial;
475
696
  async function isChatGptAccountSecurityBlock(Runtime) {
476
697
  try {
477
698
  const outcome = await Runtime.evaluate({