@steipete/oracle 0.14.1 → 0.15.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 (85) hide show
  1. package/dist/bin/oracle-cli.js +16 -6
  2. package/dist/bin/oracle.js +569 -0
  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 +399 -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 +388 -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 +323 -0
  37. package/dist/docs-site/windows.html +320 -0
  38. package/dist/src/browser/actions/assistantResponse.js +40 -31
  39. package/dist/src/browser/actions/attachments.js +28 -1
  40. package/dist/src/browser/actions/deepResearch.js +344 -128
  41. package/dist/src/browser/actions/modelSelection.js +75 -9
  42. package/dist/src/browser/actions/promptComposer.js +71 -14
  43. package/dist/src/browser/actions/thinkingStatus.js +19 -1
  44. package/dist/src/browser/actions/thinkingTime.js +65 -20
  45. package/dist/src/browser/artifacts.js +193 -14
  46. package/dist/src/browser/chatgptFiles.js +674 -91
  47. package/dist/src/browser/chromeCookies.js +312 -0
  48. package/dist/src/browser/chromeLifecycle.js +35 -4
  49. package/dist/src/browser/constants.js +5 -0
  50. package/dist/src/browser/deepResearchResult.js +23 -0
  51. package/dist/src/browser/index.js +112 -41
  52. package/dist/src/browser/keytarShim.js +56 -0
  53. package/dist/src/browser/profileCopy.js +93 -0
  54. package/dist/src/browser/sessionRunner.js +9 -3
  55. package/dist/src/browser/windowsCookies.js +219 -0
  56. package/dist/src/cli/bridge/client.js +4 -1
  57. package/dist/src/cli/bridge/doctor.js +19 -0
  58. package/dist/src/cli/browserConfig.js +17 -1
  59. package/dist/src/cli/runOptions.js +11 -2
  60. package/dist/src/cli/sessionDisplay.js +6 -1
  61. package/dist/src/cli/sessionRunner.js +41 -17
  62. package/dist/src/config.js +3 -0
  63. package/dist/src/oracle/client.js +2 -0
  64. package/dist/src/oracle/modelResolver.js +85 -0
  65. package/dist/src/oracle/multiModelRunner.js +4 -1
  66. package/dist/src/oracle/run.js +4 -1
  67. package/dist/src/remote/client.js +253 -22
  68. package/dist/src/remote/health.js +27 -0
  69. package/dist/src/remote/server.js +239 -4
  70. package/dist/src/remote/types.js +1 -1
  71. package/dist/src/sessionManager.js +1 -0
  72. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  73. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/Info.plist +20 -0
  74. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  75. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
  76. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +128 -0
  77. package/dist/vendor/oracle-notifier/build-notifier.sh +0 -0
  78. package/package.json +45 -43
  79. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  80. package/vendor/oracle-notifier/OracleNotifier.app/Contents/Info.plist +20 -0
  81. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  82. package/vendor/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
  83. package/vendor/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +128 -0
  84. package/vendor/oracle-notifier/README.md +26 -0
  85. package/vendor/oracle-notifier/build-notifier.sh +0 -0
@@ -1,15 +1,38 @@
1
1
  import { COMPOSER_MODEL_SIGNAL_SELECTOR, MENU_CONTAINER_SELECTOR, MENU_ITEM_SELECTOR, MODEL_BUTTON_SELECTOR, } from "../constants.js";
2
2
  import { logDomFailure } from "../domDebug.js";
3
3
  import { buildClickDispatcher } from "./domEvents.js";
4
+ import { delay } from "../utils.js";
4
5
  const LEGACY_PRO_VERSION_WORD_TOKENS = ["5 4", "5 2", "5 1", "5 0", "gpt 5 pro"];
5
6
  const LEGACY_PRO_VERSION_COMPACT_TOKENS = ["gpt54", "gpt52", "gpt51", "gpt50"];
6
- export async function ensureModelSelection(Runtime, desiredModel, logger, strategy = "select") {
7
- const outcome = await Runtime.evaluate({
8
- expression: buildModelSelectionExpression(desiredModel, strategy),
9
- awaitPromise: true,
10
- returnByValue: true,
11
- });
12
- const result = outcome.result?.value;
7
+ // The model/effort picker is a composer pill that React mounts a beat after the page
8
+ // becomes interactive (~1-4s on a cold profile, e.g. cookie-sync's throwaway Chrome).
9
+ // Re-evaluate while it is still missing, up to a bounded deadline, so selection does not
10
+ // give up before the pill renders. Only "button-missing" waits; a genuine
11
+ // "option-not-found" surfaces immediately.
12
+ const MODEL_BUTTON_WAIT_MS = 8000;
13
+ const MODEL_BUTTON_POLL_MS = 250;
14
+ export async function ensureModelSelection(Runtime, desiredModel, logger, strategy = "select", options = {}) {
15
+ const buttonWaitMs = options.buttonWaitMs ?? MODEL_BUTTON_WAIT_MS;
16
+ const buttonPollMs = options.buttonPollMs ?? MODEL_BUTTON_POLL_MS;
17
+ const deadline = Date.now() + Math.max(0, buttonWaitMs);
18
+ let result;
19
+ let announcedWait = false;
20
+ for (;;) {
21
+ const outcome = await Runtime.evaluate({
22
+ expression: buildModelSelectionExpression(desiredModel, strategy),
23
+ awaitPromise: true,
24
+ returnByValue: true,
25
+ });
26
+ result = outcome.result?.value;
27
+ if (result?.status !== "button-missing" || Date.now() >= deadline) {
28
+ break;
29
+ }
30
+ if (!announcedWait) {
31
+ announcedWait = true;
32
+ logger(`Model picker button not mounted yet; waiting up to ${Math.round(buttonWaitMs / 1000)}s for the composer pill to render.`);
33
+ }
34
+ await delay(buttonPollMs);
35
+ }
13
36
  switch (result?.status) {
14
37
  case "already-selected":
15
38
  case "switched": {
@@ -659,10 +682,27 @@ function buildModelSelectionExpression(targetModel, strategy) {
659
682
  normalizedTestId.includes('pro');
660
683
  const candidateHasInstant =
661
684
  normalizedText.includes('instant') || normalizedTestId.includes('instant');
685
+ const candidateSelectsConfiguredVersion =
686
+ Boolean(getConfigurationDialog()) &&
687
+ candidateSelectsDesiredVersion &&
688
+ (node?.getAttribute?.('role') === 'option' ||
689
+ node?.getAttribute?.('role') === 'menuitemradio');
690
+ const candidateOpensInstantSubmenu =
691
+ wantsInstant &&
692
+ candidateSelectsDesiredVersion &&
693
+ !candidateHasInstant &&
694
+ (normalizedTestId.includes('submenu') ||
695
+ node?.getAttribute?.('aria-haspopup') === 'menu' ||
696
+ node?.getAttribute?.('data-has-submenu') !== null);
662
697
  if (wantsPro && candidateHasThinking) return 0;
663
698
  if (wantsPro && candidateHasLegacyProVersion && !candidateSelectsDesiredVersion) return 0;
664
699
  if (wantsPro && !candidateHasPro && !candidateSelectsDesiredVersion) return 0;
665
- if (wantsInstant && !candidateHasInstant && !candidateSelectsDesiredVersion) return 0;
700
+ if (
701
+ wantsInstant &&
702
+ !candidateHasInstant &&
703
+ !candidateOpensInstantSubmenu &&
704
+ !candidateSelectsConfiguredVersion
705
+ ) return 0;
666
706
  if (wantsThinking && candidateHasPro) return 0;
667
707
  if (wantsThinking && !candidateHasThinking && !candidateSelectsDesiredVersion) return 0;
668
708
  if (desiredVersion === '5-5' && normalizedText && !candidateGpt55VisibleAlias) {
@@ -701,10 +741,14 @@ function buildModelSelectionExpression(targetModel, strategy) {
701
741
  if (
702
742
  desiredVersion &&
703
743
  candidateTextVersion === desiredVersion &&
744
+ !candidateOpensInstantSubmenu &&
704
745
  !(wantsThinking && desiredVersion === '5-5' && normalizedText === 'gpt 5 5')
705
746
  ) {
706
747
  score += 1200;
707
748
  }
749
+ if (candidateOpensInstantSubmenu) {
750
+ score += 300;
751
+ }
708
752
  if (candidateOpensVersionSubmenu) {
709
753
  score += 500;
710
754
  }
@@ -809,6 +853,9 @@ function buildModelSelectionExpression(targetModel, strategy) {
809
853
  const currentButtonLabel = normalizeText(getButtonLabel());
810
854
  return !labelHasProWord(currentButtonLabel) && !hasProComposerPill();
811
855
  };
856
+ const openedSubmenuKeys = new Set();
857
+ const submenuKey = (normalizedText, testid) =>
858
+ normalizeText(testid ?? '') + '|' + normalizedText;
812
859
 
813
860
  const findBestOption = () => {
814
861
  // Walk through every menu item and keep whichever earns the highest score.
@@ -823,6 +870,10 @@ function buildModelSelectionExpression(targetModel, strategy) {
823
870
  const text = option.textContent ?? '';
824
871
  const normalizedText = normalizeText(text);
825
872
  const testid = option.getAttribute('data-testid') ?? '';
873
+ const optionSubmenuKey = submenuKey(normalizedText, testid);
874
+ if (isSubmenuOption(option, testid) && openedSubmenuKeys.has(optionSubmenuKey)) {
875
+ continue;
876
+ }
826
877
  let score = scoreOption(normalizedText, testid, option);
827
878
  if (score <= 0) {
828
879
  continue;
@@ -832,7 +883,14 @@ function buildModelSelectionExpression(targetModel, strategy) {
832
883
  }
833
884
  const label = getOptionLabel(option);
834
885
  if (!bestMatch || score > bestMatch.score) {
835
- bestMatch = { node: option, label, score, testid, normalizedText };
886
+ bestMatch = {
887
+ node: option,
888
+ label,
889
+ score,
890
+ testid,
891
+ normalizedText,
892
+ submenuKey: optionSubmenuKey,
893
+ };
836
894
  }
837
895
  }
838
896
  }
@@ -924,6 +982,13 @@ function buildModelSelectionExpression(targetModel, strategy) {
924
982
  const openDelay = () => new Promise((r) => setTimeout(r, INITIAL_WAIT_MS));
925
983
  let initialized = false;
926
984
  const attempt = async () => {
985
+ if (performance.now() - start > MAX_WAIT_MS) {
986
+ resolve({
987
+ status: 'option-not-found',
988
+ hint: { temporaryChat: detectTemporaryChat(), availableOptions: collectAvailableOptions() },
989
+ });
990
+ return;
991
+ }
927
992
  if (!initialized) {
928
993
  initialized = true;
929
994
  await openDelay();
@@ -946,6 +1011,7 @@ function buildModelSelectionExpression(targetModel, strategy) {
946
1011
  // Keep scanning once the submenu opens instead of treating the submenu click as a final switch.
947
1012
  const isSubmenu = isSubmenuOption(match.node, match.testid);
948
1013
  if (isSubmenu) {
1014
+ openedSubmenuKeys.add(match.submenuKey);
949
1015
  openSubmenuOption(match.node);
950
1016
  setTimeout(attempt, REOPEN_INTERVAL_MS / 2);
951
1017
  return;
@@ -166,7 +166,7 @@ export async function submitPrompt(deps, prompt, logger) {
166
166
  observedLength,
167
167
  });
168
168
  }
169
- const clicked = await attemptSendButton(runtime, logger, deps?.attachmentNames, deps?.attachmentTimeoutMs);
169
+ const clicked = await attemptSendButton(runtime, input, logger, deps?.attachmentNames, deps?.attachmentTimeoutMs);
170
170
  if (!clicked) {
171
171
  await input.dispatchKeyEvent({
172
172
  type: "keyDown",
@@ -575,7 +575,7 @@ function buildAttachmentReadyExpression(attachmentNames) {
575
575
  export function buildAttachmentReadyExpressionForTest(attachmentNames) {
576
576
  return buildAttachmentReadyExpression(attachmentNames);
577
577
  }
578
- async function attemptSendButton(Runtime, _logger, attachmentNames, attachmentTimeoutMs) {
578
+ async function attemptSendButton(Runtime, Input, _logger, attachmentNames, attachmentTimeoutMs) {
579
579
  const needAttachment = Array.isArray(attachmentNames) && attachmentNames.length > 0;
580
580
  const script = `(() => {
581
581
  ${buildClickDispatcher()}
@@ -604,10 +604,15 @@ async function attemptSendButton(Runtime, _logger, attachmentNames, attachmentTi
604
604
  candidates.push(...Array.from(document.querySelectorAll(selector)));
605
605
  }
606
606
  const button = candidates.find((node) => isVisible(node) && isEnabled(node)) || null;
607
- if (!button) return 'missing';
608
- // Use unified pointer/mouse sequence to satisfy React handlers.
607
+ if (!button) return { status: 'missing' };
608
+ button.scrollIntoView({ block: 'center', inline: 'center' });
609
+ const rect = button.getBoundingClientRect();
610
+ if (rect.width > 0 && rect.height > 0) {
611
+ return { status: 'point', x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
612
+ }
613
+ // Last-resort fallback for unusual DOMs where the button is visible but has no useful rect.
609
614
  dispatchClickSequence(button);
610
- return 'clicked';
615
+ return { status: 'clicked' };
611
616
  })()`;
612
617
  // Give attachment-bearing submissions more headroom. ChatGPT's chip render can
613
618
  // settle slowly for multi-file uploads, but plain text sends should keep the
@@ -626,10 +631,19 @@ async function attemptSendButton(Runtime, _logger, attachmentNames, attachmentTi
626
631
  }
627
632
  }
628
633
  const { result } = await Runtime.evaluate({ expression: script, returnByValue: true });
629
- if (result.value === "clicked") {
634
+ const value = result.value;
635
+ const status = typeof value === "string" ? value : value?.status;
636
+ if (status === "point" &&
637
+ typeof value === "object" &&
638
+ typeof value.x === "number" &&
639
+ typeof value.y === "number") {
640
+ await clickTrustedPoint(Runtime, Input, value.x, value.y);
641
+ return true;
642
+ }
643
+ if (status === "clicked") {
630
644
  return true;
631
645
  }
632
- if (result.value === "missing") {
646
+ if (status === "missing") {
633
647
  break;
634
648
  }
635
649
  await delay(100);
@@ -644,6 +658,23 @@ async function attemptSendButton(Runtime, _logger, attachmentNames, attachmentTi
644
658
  }
645
659
  return false;
646
660
  }
661
+ async function clickTrustedPoint(Runtime, Input, x, y) {
662
+ if (Input && typeof Input.dispatchMouseEvent === "function") {
663
+ await Input.dispatchMouseEvent({ type: "mouseMoved", x, y });
664
+ await Input.dispatchMouseEvent({ type: "mousePressed", x, y, button: "left", clickCount: 1 });
665
+ await Input.dispatchMouseEvent({ type: "mouseReleased", x, y, button: "left", clickCount: 1 });
666
+ return;
667
+ }
668
+ await Runtime.evaluate({
669
+ expression: `(() => {
670
+ const el = document.elementFromPoint(${JSON.stringify(x)}, ${JSON.stringify(y)});
671
+ if (!(el instanceof HTMLElement)) return false;
672
+ el.click();
673
+ return true;
674
+ })()`,
675
+ returnByValue: true,
676
+ });
677
+ }
647
678
  function sendButtonTimeoutMs(attachmentNames, attachmentTimeoutMs) {
648
679
  if (!Array.isArray(attachmentNames) || attachmentNames.length === 0) {
649
680
  return 20_000;
@@ -755,9 +786,13 @@ async function verifyPromptCommitted(Runtime, prompt, timeoutMs, logger, baselin
755
786
  turnsCount: normalizedTurns.length,
756
787
  };
757
788
  })()`;
789
+ let lastProbe;
758
790
  while (Date.now() < deadline) {
759
791
  const { result } = await Runtime.evaluate({ expression: script, returnByValue: true });
760
792
  const info = result.value;
793
+ if (info && typeof info === "object") {
794
+ lastProbe = info;
795
+ }
761
796
  const turnsCount = result.value?.turnsCount;
762
797
  const matchesPrompt = Boolean(info?.lastMatched || info?.userMatched || info?.prefixMatched);
763
798
  const baselineUnknown = typeof info?.baseline === "number" ? info.baseline < 0 : baselineLiteral < 0;
@@ -772,13 +807,12 @@ async function verifyPromptCommitted(Runtime, prompt, timeoutMs, logger, baselin
772
807
  }
773
808
  await delay(100);
774
809
  }
810
+ const finalProbe = await Runtime.evaluate({ expression: script, returnByValue: true })
811
+ .then((res) => res?.result?.value)
812
+ .catch(() => undefined);
813
+ const probe = finalProbe && typeof finalProbe === "object" ? finalProbe : lastProbe;
775
814
  if (logger) {
776
- logger(`Prompt commit check failed; latest state: ${await Runtime.evaluate({
777
- expression: script,
778
- returnByValue: true,
779
- })
780
- .then((res) => JSON.stringify(res?.result?.value))
781
- .catch(() => "unavailable")}`);
815
+ logger(`Prompt commit check failed; latest state: ${probe ? JSON.stringify(probe) : "unavailable"}`);
782
816
  await logDomFailure(Runtime, logger, "prompt-commit");
783
817
  }
784
818
  if (prompt.trim().length >= 50_000) {
@@ -789,7 +823,30 @@ async function verifyPromptCommitted(Runtime, prompt, timeoutMs, logger, baselin
789
823
  timeoutMs,
790
824
  });
791
825
  }
792
- throw new Error("Prompt did not appear in conversation before timeout (send may have failed)");
826
+ throw new BrowserAutomationError("Prompt did not appear in conversation before timeout (send may have failed)", {
827
+ stage: "submit-prompt",
828
+ code: "prompt-commit-timeout",
829
+ promptLength: prompt.trim().length,
830
+ timeoutMs,
831
+ commitProbe: probe ? summarizeCommitProbe(probe) : undefined,
832
+ });
833
+ }
834
+ // Keep booleans/counts but replace free text with lengths so session metadata stays lean.
835
+ function summarizeCommitProbe(probe) {
836
+ return {
837
+ baseline: probe.baseline,
838
+ turnsCount: probe.turnsCount,
839
+ userMatched: probe.userMatched,
840
+ prefixMatched: probe.prefixMatched,
841
+ lastMatched: probe.lastMatched,
842
+ hasNewTurn: probe.hasNewTurn,
843
+ stopVisible: probe.stopVisible,
844
+ assistantVisible: probe.assistantVisible,
845
+ composerCleared: probe.composerCleared,
846
+ inConversation: probe.inConversation,
847
+ editorLength: typeof probe.editorValue === "string" ? probe.editorValue.length : undefined,
848
+ lastTurnLength: typeof probe.lastTurn === "string" ? probe.lastTurn.length : undefined,
849
+ };
793
850
  }
794
851
  // biome-ignore lint/style/useNamingConvention: test-only export used in vitest suite
795
852
  export const __test__ = {
@@ -1,5 +1,5 @@
1
1
  import { formatElapsed } from "../../oracle/format.js";
2
- import { ASSISTANT_ROLE_SELECTOR, CONVERSATION_TURN_SELECTOR } from "../constants.js";
2
+ import { ASSISTANT_ROLE_SELECTOR, CONVERSATION_TURN_SELECTOR, STOP_BUTTON_SELECTORS, } from "../constants.js";
3
3
  const THINKING_STALE_HINT_MS = 10 * 60_000;
4
4
  export function startThinkingStatusMonitor(Runtime, logger, options = {}) {
5
5
  const intervalMs = resolveThinkingStatusInterval(options.intervalMs);
@@ -127,6 +127,7 @@ async function readThinkingStatus(Runtime) {
127
127
  }
128
128
  const SAFE_THINKING_STATUS_MESSAGES = new Set([
129
129
  "active",
130
+ "response streaming",
130
131
  "thinking sidecar active",
131
132
  "thinking sidecar opened",
132
133
  ]);
@@ -157,13 +158,16 @@ function buildThinkingStatusExpression() {
157
158
  '[aria-live="polite"]',
158
159
  ];
159
160
  const keywords = ["pro thinking", "thinking", "reasoning"];
161
+ const stopSelector = STOP_BUTTON_SELECTORS.join(", ");
160
162
  const selectorLiteral = JSON.stringify(selectors);
161
163
  const keywordsLiteral = JSON.stringify(keywords);
164
+ const stopSelectorLiteral = JSON.stringify(stopSelector);
162
165
  return `(async () => {
163
166
  const CONVERSATION_SELECTOR = ${conversationLiteral};
164
167
  const ASSISTANT_SELECTOR = ${assistantLiteral};
165
168
  const selectors = ${selectorLiteral};
166
169
  const keywords = ${keywordsLiteral};
170
+ const stopSelector = ${stopSelectorLiteral};
167
171
  const normalize = (value) =>
168
172
  String(value || '')
169
173
  .normalize('NFD')
@@ -383,6 +387,20 @@ function buildThinkingStatusExpression() {
383
387
  };
384
388
  }
385
389
  }
390
+ // Last-resort liveness fallback: selector drift can hide every thinking
391
+ // indicator while a response is still generating, and returning null here
392
+ // reads as "dead" downstream. The stop/interrupt control is a stable,
393
+ // language-independent signal that generation is active; it lives in the
394
+ // composer, so isComposerAdjacent must not filter it.
395
+ const stopVisible = Array.from(document.querySelectorAll(stopSelector)).some((node) =>
396
+ isVisible(node),
397
+ );
398
+ if (stopVisible) {
399
+ return {
400
+ message: 'response streaming',
401
+ source: 'inline',
402
+ };
403
+ }
386
404
  return null;
387
405
  })()`;
388
406
  }
@@ -578,9 +578,62 @@ function buildThinkingTimeExpression(level, desiredModel) {
578
578
  }
579
579
  return null;
580
580
  };
581
-
582
- const composerEffortPill = findComposerEffortPill();
581
+ let composerEffortPill = findComposerEffortPill();
582
+ let modelBtn = findModelButton();
583
+ const modelKindFromLegacyTrailing = (trailing) => {
584
+ const row = trailing.closest?.(
585
+ '[role="menuitem"], [role="menuitemradio"], [data-radix-collection-item]',
586
+ );
587
+ const idText = normalize(
588
+ (row?.getAttribute?.('data-testid') ?? '') + ' ' +
589
+ (trailing.getAttribute?.('data-testid') ?? '')
590
+ );
591
+ if (!idText.includes('model switcher')) return null;
592
+ const modelPart = normalize(idText.replace(/\\bthinking effort\\b.*$/, ''));
593
+ if (hasToken(modelPart, 'pro')) return 'pro';
594
+ if (hasToken(modelPart, 'thinking')) return 'thinking';
595
+ if (hasToken(modelPart, 'instant')) return 'instant';
596
+ return null;
597
+ };
598
+ const legacyEffortOwnerIsReady = () => {
599
+ if (
600
+ TARGET_MODEL_KIND === 'pro' &&
601
+ TARGET_LEVEL === 'extended' &&
602
+ isVisible(document.querySelector(INTELLIGENCE_MENU_SELECTOR))
603
+ ) {
604
+ return true;
605
+ }
606
+ const expectedKind = TARGET_MODEL_KIND || modelKindFromNode(modelBtn);
607
+ return Boolean(
608
+ expectedKind &&
609
+ findTrailingButtons().some(
610
+ (button) => isVisible(button) && modelKindFromLegacyTrailing(button) === expectedKind,
611
+ ),
612
+ );
613
+ };
614
+ let attemptedModelButton =
615
+ modelBtn?.getAttribute?.('aria-expanded') === 'true' ? modelBtn : null;
616
+ const effortOwnerDeadline = performance.now() + MAX_WAIT_MS;
617
+ while (!composerEffortPill && performance.now() < effortOwnerDeadline) {
618
+ if (
619
+ modelBtn &&
620
+ attemptedModelButton !== modelBtn &&
621
+ modelBtn.getAttribute?.('aria-expanded') !== 'true'
622
+ ) {
623
+ dispatchClickSequence(modelBtn);
624
+ attemptedModelButton = modelBtn;
625
+ await sleep(INITIAL_WAIT_MS);
626
+ }
627
+ if (modelBtn && legacyEffortOwnerIsReady()) break;
628
+ await sleep(100);
629
+ composerEffortPill = findComposerEffortPill();
630
+ modelBtn = findModelButton();
631
+ if (modelBtn?.getAttribute?.('aria-expanded') === 'true') {
632
+ attemptedModelButton = modelBtn;
633
+ }
634
+ }
583
635
  if (composerEffortPill) {
636
+ if (attemptedModelButton && attemptedModelButton !== composerEffortPill) closeOpenMenus();
584
637
  const composerModelKind = TARGET_MODEL_KIND || modelKindFromNode(composerEffortPill);
585
638
  if (composerEffortPill.getAttribute?.('aria-expanded') !== 'true') {
586
639
  dispatchClickSequence(composerEffortPill);
@@ -646,22 +699,7 @@ function buildThinkingTimeExpression(level, desiredModel) {
646
699
  (trailing.getAttribute?.('data-testid') ?? '')
647
700
  );
648
701
  };
649
- const testIdTextForTrailing = (trailing) => {
650
- const row = rowForTrailing(trailing) || findEffortRow(trailing);
651
- return normalize(
652
- (row?.getAttribute?.('data-testid') ?? '') + ' ' +
653
- (trailing.getAttribute?.('data-testid') ?? '')
654
- );
655
- };
656
- const modelKindFromTrailing = (trailing) => {
657
- const idText = testIdTextForTrailing(trailing);
658
- if (!idText.includes('model switcher')) return null;
659
- const modelPart = normalize(idText.replace(/\\bthinking effort\\b.*$/, ''));
660
- if (hasToken(modelPart, 'pro')) return 'pro';
661
- if (hasToken(modelPart, 'thinking')) return 'thinking';
662
- if (hasToken(modelPart, 'instant')) return 'instant';
663
- return null;
664
- };
702
+ const modelKindFromTrailing = modelKindFromLegacyTrailing;
665
703
  const trailingMatchesTargetModelKind = (trailing) => {
666
704
  if (!TARGET_MODEL_KIND) return false;
667
705
  const idKind = modelKindFromTrailing(trailing);
@@ -698,12 +736,19 @@ function buildThinkingTimeExpression(level, desiredModel) {
698
736
  return null;
699
737
  };
700
738
 
701
- const modelBtn = findModelButton();
739
+ const modelButtonDeadline = performance.now() + MAX_WAIT_MS;
740
+ while (!modelBtn && performance.now() < modelButtonDeadline) {
741
+ await sleep(100);
742
+ modelBtn = findModelButton();
743
+ }
702
744
  if (!modelBtn) {
703
745
  return failure('chip-not-found');
704
746
  }
705
747
  // Open model menu (idempotent — leaves it open if already open).
706
- if (modelBtn.getAttribute('aria-expanded') !== 'true') {
748
+ if (
749
+ modelBtn.getAttribute('aria-expanded') !== 'true' &&
750
+ !legacyEffortOwnerIsReady()
751
+ ) {
707
752
  dispatchClickSequence(modelBtn);
708
753
  await sleep(INITIAL_WAIT_MS);
709
754
  }