@steipete/oracle 0.15.1 → 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 (78) hide show
  1. package/dist/bin/oracle-cli.js +1 -1
  2. package/dist/scripts/test-browser.js +13 -2
  3. package/dist/src/browser/actions/assistantResponse.js +235 -121
  4. package/dist/src/browser/actions/attachments.js +3 -4
  5. package/dist/src/browser/actions/deepResearch.js +6 -6
  6. package/dist/src/browser/actions/modelSelection.js +133 -22
  7. package/dist/src/browser/actions/navigation.js +235 -14
  8. package/dist/src/browser/actions/promptComposer.js +4 -5
  9. package/dist/src/browser/actions/thinkingStatus.js +228 -0
  10. package/dist/src/browser/actions/thinkingTime.js +68 -11
  11. package/dist/src/browser/chatgptFiles.js +4 -7
  12. package/dist/src/browser/chatgptImages.js +3 -4
  13. package/dist/src/browser/chromeLifecycle.js +30 -28
  14. package/dist/src/browser/config.js +14 -1
  15. package/dist/src/browser/constants.js +6 -1
  16. package/dist/src/browser/controlPlan.js +2 -2
  17. package/dist/src/browser/conversationTurns.js +16 -0
  18. package/dist/src/browser/conversationUrlMonitor.js +64 -0
  19. package/dist/src/browser/cookies.js +72 -0
  20. package/dist/src/browser/index.js +121 -72
  21. package/dist/src/browser/liveTabs.js +113 -31
  22. package/dist/src/browser/pageActions.js +1 -1
  23. package/dist/src/browser/projectSourcesRunner.js +7 -6
  24. package/dist/src/browser/reattach.js +29 -13
  25. package/dist/src/browser/reattachHelpers.js +14 -5
  26. package/dist/src/browser/recoverConversation.js +90 -29
  27. package/dist/src/cli/browserConfig.js +5 -1
  28. package/dist/src/cli/browserTabs.js +54 -33
  29. package/dist/src/cli/options.js +24 -0
  30. package/dist/src/cli/runOptions.js +6 -1
  31. package/dist/src/cli/sessionDisplay.js +38 -14
  32. package/dist/src/oracle/config.js +25 -0
  33. package/dist/src/oracle/geminiModels.js +2 -0
  34. package/dist/src/oracle/oscProgress.js +3 -2
  35. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  36. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  37. package/package.json +21 -22
  38. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  39. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  40. package/dist/bin/oracle.js +0 -569
  41. package/dist/docs-site/.nojekyll +0 -0
  42. package/dist/docs-site/CNAME +0 -1
  43. package/dist/docs-site/RELEASING.html +0 -410
  44. package/dist/docs-site/agents.html +0 -374
  45. package/dist/docs-site/anthropic.html +0 -368
  46. package/dist/docs-site/bridge.html +0 -416
  47. package/dist/docs-site/browser-mode.html +0 -594
  48. package/dist/docs-site/chromium-forks.html +0 -347
  49. package/dist/docs-site/cli-reference.html +0 -346
  50. package/dist/docs-site/configuration.html +0 -462
  51. package/dist/docs-site/favicon.svg +0 -14
  52. package/dist/docs-site/followup.html +0 -375
  53. package/dist/docs-site/gemini.html +0 -383
  54. package/dist/docs-site/grok.html +0 -325
  55. package/dist/docs-site/index.html +0 -360
  56. package/dist/docs-site/install.html +0 -335
  57. package/dist/docs-site/linux.html +0 -321
  58. package/dist/docs-site/llms.txt +0 -43
  59. package/dist/docs-site/manual-tests.html +0 -596
  60. package/dist/docs-site/mcp.html +0 -391
  61. package/dist/docs-site/multimodel.html +0 -364
  62. package/dist/docs-site/mythical-pro-agents.html +0 -360
  63. package/dist/docs-site/notifier.html +0 -338
  64. package/dist/docs-site/openai-endpoints.html +0 -399
  65. package/dist/docs-site/openrouter.html +0 -344
  66. package/dist/docs-site/quickstart.html +0 -369
  67. package/dist/docs-site/refactor/ux.html +0 -532
  68. package/dist/docs-site/sessions.html +0 -388
  69. package/dist/docs-site/social-card.png +0 -0
  70. package/dist/docs-site/social-card.svg +0 -79
  71. package/dist/docs-site/spec.html +0 -363
  72. package/dist/docs-site/testing.html +0 -320
  73. package/dist/docs-site/tui-debug.html +0 -326
  74. package/dist/docs-site/windows-work.html +0 -323
  75. package/dist/docs-site/windows.html +0 -320
  76. package/dist/src/browser/chromeCookies.js +0 -312
  77. package/dist/src/browser/keytarShim.js +0 -56
  78. package/dist/src/browser/windowsCookies.js +0 -219
@@ -1,4 +1,5 @@
1
- import { INPUT_SELECTORS, PROMPT_PRIMARY_SELECTOR, PROMPT_FALLBACK_SELECTOR, SEND_BUTTON_SELECTORS, CONVERSATION_TURN_SELECTOR, STOP_BUTTON_SELECTOR, ASSISTANT_ROLE_SELECTOR, } from "../constants.js";
1
+ import { INPUT_SELECTORS, PROMPT_PRIMARY_SELECTOR, PROMPT_FALLBACK_SELECTOR, SEND_BUTTON_SELECTORS, STOP_BUTTON_SELECTOR, ASSISTANT_ROLE_SELECTOR, } from "../constants.js";
2
+ import { buildConversationTurnCountExpression, buildConversationTurnListExpression, } from "../conversationTurns.js";
2
3
  import { delay } from "../utils.js";
3
4
  import { logDomFailure } from "../domDebug.js";
4
5
  import { buildClickDispatcher } from "./domEvents.js";
@@ -691,14 +692,13 @@ async function verifyPromptCommitted(Runtime, prompt, timeoutMs, logger, baselin
691
692
  const inputSelectorsLiteral = JSON.stringify(INPUT_SELECTORS);
692
693
  const stopSelectorLiteral = JSON.stringify(STOP_BUTTON_SELECTOR);
693
694
  const assistantSelectorLiteral = JSON.stringify(ASSISTANT_ROLE_SELECTOR);
694
- const turnSelectorLiteral = JSON.stringify(CONVERSATION_TURN_SELECTOR);
695
695
  let baseline = typeof baselineTurns === "number" && Number.isFinite(baselineTurns) && baselineTurns >= 0
696
696
  ? Math.floor(baselineTurns)
697
697
  : null;
698
698
  if (baseline === null) {
699
699
  try {
700
700
  const { result } = await Runtime.evaluate({
701
- expression: `document.querySelectorAll(${turnSelectorLiteral}).length`,
701
+ expression: buildConversationTurnCountExpression(),
702
702
  returnByValue: true,
703
703
  });
704
704
  const raw = typeof result?.value === "number" ? result.value : Number(result?.value);
@@ -726,8 +726,7 @@ async function verifyPromptCommitted(Runtime, prompt, timeoutMs, logger, baselin
726
726
  };
727
727
  const normalizedPrompt = normalize(${encodedPrompt});
728
728
  const normalizedPromptPrefix = normalizedPrompt.slice(0, 120);
729
- const CONVERSATION_SELECTOR = ${JSON.stringify(CONVERSATION_TURN_SELECTOR)};
730
- const articles = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
729
+ const articles = ${buildConversationTurnListExpression()};
731
730
  const normalizedTurns = articles.map((node) => normalize(node?.innerText));
732
731
  const readValue = (node) => {
733
732
  if (!node) return '';
@@ -404,6 +404,234 @@ function buildThinkingStatusExpression() {
404
404
  return null;
405
405
  })()`;
406
406
  }
407
+ // Present-tense/gerund status labels that mean the model is ACTIVELY working. The
408
+ // past-tense "thought for Xs" summary is deliberately excluded: it persists in the DOM
409
+ // on every completed reasoning turn (and on reattach), so treating its mere presence as
410
+ // "still thinking" would veto completion forever and hang the call. Kept in sync with
411
+ // THINKING_STATUS_LABELS in assistantResponse.ts (the connector phases "searching the
412
+ // web"/"reading"/"finalizing answer" are exactly the GPT-5.5 Pro gaps that produce the
413
+ // preamble->answer window this predicate must cover).
414
+ const ACTIVE_THINKING_LABELS = [
415
+ "thinking",
416
+ "pro thinking",
417
+ "thinking longer for a better answer",
418
+ "reasoning",
419
+ "finalizing answer",
420
+ "finalizing",
421
+ "analyzing",
422
+ "researching",
423
+ "working on it",
424
+ "working",
425
+ "planning",
426
+ "searching the web",
427
+ "searching",
428
+ "reading",
429
+ ];
430
+ // buildThinkingActivePredicateJs: a SIDE-EFFECT-FREE injected predicate `${fnName}()` that
431
+ // returns true iff the model is ACTIVELY generating/thinking right now. Unlike
432
+ // buildThinkingStatusExpression it never clicks a disclosure and never keys on the mere
433
+ // PRESENCE of a reasoning container ([data-testid*="reasoning"] persists after completion);
434
+ // it keys only on ACTIVITY signals: a visible stop/interrupt control, a visible animated
435
+ // loading-shimmer skeleton, aria-busy, a visible thinking sidecar panel with live progress,
436
+ // or a visible ACTIVE (present-tense) thinking status label. Used as a completion VETO so a
437
+ // settled preamble is never finalized while the reasoning/tool phase is still running.
438
+ function buildThinkingActivityPredicateJs(fnName, detailed) {
439
+ const stopLiteral = JSON.stringify(STOP_BUTTON_SELECTORS.join(", "));
440
+ const activeLabelsLiteral = JSON.stringify(ACTIVE_THINKING_LABELS);
441
+ const conversationLiteral = JSON.stringify(CONVERSATION_TURN_SELECTOR);
442
+ const assistantLiteral = JSON.stringify(ASSISTANT_ROLE_SELECTOR);
443
+ const strong = detailed ? "{ active: true, strong: true }" : "true";
444
+ const weak = detailed ? "{ active: true, strong: false }" : "true";
445
+ const idle = detailed ? "{ active: false, strong: false }" : "false";
446
+ return `const ${fnName} = () => {
447
+ const STOP_SELECTOR = ${stopLiteral};
448
+ const ACTIVE_LABELS = ${activeLabelsLiteral};
449
+ const CONVERSATION_SELECTOR = ${conversationLiteral};
450
+ const ASSISTANT_SELECTOR = ${assistantLiteral};
451
+ const isVisible = (node) => {
452
+ if (!(node instanceof HTMLElement)) return false;
453
+ const rect = node.getBoundingClientRect();
454
+ if (!rect || rect.width <= 0 || rect.height <= 0) return false;
455
+ const style = window.getComputedStyle(node);
456
+ if (
457
+ style.display === 'none' ||
458
+ style.visibility === 'hidden' ||
459
+ (style.opacity !== '' && Number(style.opacity) === 0)
460
+ ) {
461
+ return false;
462
+ }
463
+ return true;
464
+ };
465
+ const some = (selector, pred) => {
466
+ let nodes;
467
+ try { nodes = document.querySelectorAll(selector); } catch { return false; }
468
+ return Array.from(nodes).some((node) => pred(node));
469
+ };
470
+ // 1) Stop/interrupt control visible -> generation is active (language-independent).
471
+ if (some(STOP_SELECTOR, isVisible)) return ${strong};
472
+ // 2-3) Shimmer/aria-busy are checked only inside the current turn or verified thinking
473
+ // panel below. Page-global busy UI (history/sidebar/upload) is not model activity.
474
+ const hasBusyIndicator = (scope) => {
475
+ let nodes;
476
+ try {
477
+ nodes = scope.querySelectorAll(
478
+ 'span.loading-shimmer, .loading-shimmer, [class*="loading-shimmer"], [aria-busy="true"]',
479
+ );
480
+ } catch { return false; }
481
+ return Array.from(nodes).some((node) => isVisible(node));
482
+ };
483
+ // 4) Active (present-tense) thinking status label near a status/reasoning node.
484
+ const norm = (value) =>
485
+ String(value || '')
486
+ .normalize('NFD')
487
+ .replace(/[\\u0300-\\u036f]/g, '')
488
+ .toLowerCase()
489
+ .replace(/\\s+/g, ' ')
490
+ .trim();
491
+ // Completed reasoning summary: the whole visible label must be a duration summary. Anchoring
492
+ // prevents an early live trace such as "Thought for 2s: Searching the web" from being
493
+ // mistaken for completion before the trace grows beyond an arbitrary length threshold.
494
+ const isCompletedSummary = (text) =>
495
+ /^(?:(?:reasoning|pro thinking)\\s*)?thought for (?:\\d+(?:\\.\\d+)?\\s*(?:s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours)(?:\\s+\\d+(?:\\.\\d+)?\\s*(?:s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours))*|(?:a|an) [a-z]+(?: [a-z]+){0,2})(?: edit)?$/.test(text);
496
+ const isActiveLabel = (raw) => {
497
+ const text = norm(raw);
498
+ if (!text || text.length > 60) return false;
499
+ if (isCompletedSummary(text)) return false;
500
+ return ACTIVE_LABELS.some((label) => text === label || text.startsWith(label + ' '));
501
+ };
502
+ const statusNodes = (() => {
503
+ try {
504
+ return Array.from(
505
+ document.querySelectorAll(
506
+ '[data-testid*="thinking"], [data-testid*="reasoning"]',
507
+ ),
508
+ );
509
+ } catch {
510
+ return [];
511
+ }
512
+ })();
513
+ for (const node of statusNodes) {
514
+ if (!(node instanceof HTMLElement) || !isVisible(node)) continue;
515
+ const testId = norm(node.getAttribute('data-testid'));
516
+ const verifiedThinkingChrome = testId.includes('thinking') || testId.includes('reasoning');
517
+ const matches =
518
+ verifiedThinkingChrome &&
519
+ (isActiveLabel(node.textContent) || isActiveLabel(node.getAttribute('aria-label')));
520
+ if (matches) return ${strong};
521
+ }
522
+ // 5) A live progress bar (determinate or indeterminate) is active generation, even when no
523
+ // label or shimmer is present (some connector/tool phases surface only a progress bar).
524
+ const hasLiveProgress = (scope) => {
525
+ let nodes;
526
+ // Only genuine progress indicators — NOT generic [aria-valuenow] range widgets (sliders,
527
+ // spinbuttons), which are not liveness signals and would falsely veto completion forever.
528
+ try {
529
+ nodes = scope.querySelectorAll('progress, [role="progressbar"]');
530
+ } catch { return false; }
531
+ return Array.from(nodes).some((n) => {
532
+ if (!(n instanceof HTMLElement) || !isVisible(n)) return false;
533
+ if (n instanceof HTMLProgressElement) {
534
+ // Determinate <progress> is active only while it has not reached its max.
535
+ if (Number.isFinite(n.value) && Number.isFinite(n.max) && n.max > 0) return n.value < n.max;
536
+ return true; // indeterminate
537
+ }
538
+ const rawNow = n.getAttribute('aria-valuenow');
539
+ if (rawNow != null) {
540
+ const now = Number(rawNow);
541
+ const rawMax = n.getAttribute('aria-valuemax');
542
+ const max = rawMax != null && Number.isFinite(Number(rawMax)) ? Number(rawMax) : 100;
543
+ return Number.isFinite(now) ? now < max : true;
544
+ }
545
+ return true; // role=progressbar with no value -> indeterminate -> active
546
+ });
547
+ };
548
+ // Scoped to the CURRENT assistant turn, not the whole document: unrelated page UI can keep
549
+ // a visible progress bar mounted indefinitely (review P1), and a document-wide veto would
550
+ // then hold thinkingActive true until the watchdog timeout on a completed response. The
551
+ // sidecar check below covers verified reasoning panels; here only the latest turn counts.
552
+ const turns = (() => {
553
+ try { return document.querySelectorAll(CONVERSATION_SELECTOR); } catch { return []; }
554
+ })();
555
+ const lastTurn = turns.length ? turns[turns.length - 1] : null;
556
+ if (
557
+ lastTurn instanceof HTMLElement &&
558
+ (hasBusyIndicator(lastTurn) || hasLiveProgress(lastTurn))
559
+ ) return ${strong};
560
+ // 6) A visible thinking/reasoning sidecar panel (the connector/reasoning phase is often
561
+ // exposed ONLY through a right-side panel with no inline label). Match the existing
562
+ // thinking-monitor heuristic: a right-side panel that looks like thinking, or any such
563
+ // container that carries a live progress bar. Presence alone is NOT enough (a collapsed
564
+ // reasoning summary persists post-completion); require the thinking cue or live progress.
565
+ const looksLikeThinking = (node) => {
566
+ // Judge completion on the panel's VISIBLE text only: data-testid values like
567
+ // "reasoning-panel" would otherwise taint the completed-summary check, and a live trace
568
+ // is judged by its full rendered length, not by fragments of it.
569
+ const visible = norm(node.textContent) || norm(node.getAttribute?.('aria-label'));
570
+ if (isCompletedSummary(visible)) return false;
571
+ if (visible.includes('thought for ')) return true;
572
+ const label = norm([
573
+ node.textContent,
574
+ node.getAttribute?.('aria-label'),
575
+ node.getAttribute?.('data-testid'),
576
+ ].filter(Boolean).join(' '));
577
+ return label.includes('thinking') || label.includes('reasoning') || label.includes('pro thinking');
578
+ };
579
+ let panels;
580
+ try {
581
+ panels = document.querySelectorAll(
582
+ 'aside, [role="complementary"], [role="dialog"], [data-testid*="thinking"], [data-testid*="reasoning"], [class*="sidecar"], [class*="sidebar"]',
583
+ );
584
+ } catch { panels = []; }
585
+ for (const node of Array.from(panels)) {
586
+ if (!(node instanceof HTMLElement) || !isVisible(node)) continue;
587
+ const rect = node.getBoundingClientRect();
588
+ const rightSide = rect.left >= window.innerWidth * 0.35 && rect.width >= 180 && rect.height >= 120;
589
+ const panelLabel = norm([
590
+ node.getAttribute?.('aria-label'),
591
+ node.getAttribute?.('data-testid'),
592
+ node.className,
593
+ ].filter(Boolean).join(' '));
594
+ const verifiedThinkingPanel =
595
+ panelLabel.includes('thinking') ||
596
+ panelLabel.includes('reasoning') ||
597
+ panelLabel.includes('sidecar');
598
+ if ((hasBusyIndicator(node) || hasLiveProgress(node)) && verifiedThinkingPanel) return ${strong};
599
+ // A text-only sidecar match is intentionally weak: completed turns can retain a mounted
600
+ // reasoning panel whose shape/text heuristics still look active. The terminal gate may
601
+ // override only this weak evidence after a stable, debounced finished-action bar.
602
+ if (rightSide && looksLikeThinking(node)) return ${weak};
603
+ }
604
+ return ${idle};
605
+ };`;
606
+ }
607
+ export function buildThinkingActivePredicateJs(fnName) {
608
+ return buildThinkingActivityPredicateJs(fnName, false);
609
+ }
610
+ export function buildThinkingActivityDetailsPredicateJs(fnName) {
611
+ return buildThinkingActivityPredicateJs(fnName, true);
612
+ }
613
+ export async function readThinkingActivity(Runtime) {
614
+ try {
615
+ const { result } = await Runtime.evaluate({
616
+ expression: `(() => {
617
+ ${buildThinkingActivityDetailsPredicateJs("readThinkingActivity")}
618
+ return readThinkingActivity();
619
+ })()`,
620
+ returnByValue: true,
621
+ });
622
+ const value = result?.value;
623
+ return { active: Boolean(value?.active), strong: Boolean(value?.strong) };
624
+ }
625
+ catch {
626
+ return { active: false, strong: false };
627
+ }
628
+ }
629
+ export async function isThinkingActive(Runtime) {
630
+ return (await readThinkingActivity(Runtime)).active;
631
+ }
407
632
  export const startThinkingStatusMonitorForTest = startThinkingStatusMonitor;
408
633
  export const readThinkingStatusForTest = readThinkingStatus;
409
634
  export const buildThinkingStatusExpressionForTest = buildThinkingStatusExpression;
635
+ export const buildThinkingActivePredicateJsForTest = buildThinkingActivePredicateJs;
636
+ export const buildThinkingActivityDetailsPredicateJsForTest = buildThinkingActivityDetailsPredicateJs;
637
+ export { ACTIVE_THINKING_LABELS };
@@ -124,6 +124,7 @@ function buildThinkingTimeExpression(level, desiredModel) {
124
124
  const modelButtonLiteral = JSON.stringify(MODEL_BUTTON_SELECTOR);
125
125
  const targetLevelLiteral = JSON.stringify(level.toLowerCase());
126
126
  const targetModelKindLiteral = JSON.stringify(inferThinkingTargetModelKind(desiredModel));
127
+ const targetIsGpt56ModelLiteral = JSON.stringify(/(?:^|[^0-9])5[._ -]6(?:[^0-9]|$)/i.test(desiredModel ?? ""));
127
128
  return `(async () => {
128
129
  ${buildClickDispatcher()}
129
130
 
@@ -132,13 +133,14 @@ function buildThinkingTimeExpression(level, desiredModel) {
132
133
  const MODEL_BUTTON_SELECTOR = ${modelButtonLiteral};
133
134
  const TARGET_LEVEL = ${targetLevelLiteral};
134
135
  const TARGET_MODEL_KIND = ${targetModelKindLiteral};
136
+ const TARGET_IS_GPT56_MODEL = ${targetIsGpt56ModelLiteral};
135
137
 
136
138
  // Bilingual matchers: English level token + observed Chinese variants.
137
139
  const LEVEL_TOKENS = {
138
- light: ['light', 'instant', '轻'],
139
- standard: ['standard', 'medium', '标准'],
140
- extended: ['extended', 'high', '扩展', '深度', '加强'],
141
- heavy: ['heavy', 'extra high', '重度', '加重', ''],
140
+ light: ['light', 'instant', '轻', '极速'],
141
+ standard: ['standard', 'medium', '标准', '中'],
142
+ extended: ['extended', 'high', '扩展', '深度', '加强', '高'],
143
+ heavy: ['heavy', 'extra high', '重度', '加重', '极高'],
142
144
  };
143
145
  const targetTokens = LEVEL_TOKENS[TARGET_LEVEL] || [TARGET_LEVEL];
144
146
 
@@ -166,6 +168,13 @@ function buildThinkingTimeExpression(level, desiredModel) {
166
168
  if (!token) return false;
167
169
  if (token === 'high') return hasToken(t, 'high') && !hasToken(t, 'extra');
168
170
  if (token === 'extra high') return hasToken(t, 'extra') && hasToken(t, 'high');
171
+ if (token === '极速') {
172
+ const suffix = t.slice(token.length);
173
+ return t === token || hasToken(t, token) || /^[0-9]/.test(suffix);
174
+ }
175
+ if (['中', '高', '极高'].includes(token)) {
176
+ return t === token || hasToken(t, token);
177
+ }
169
178
  return t === token || hasToken(t, token) || t.includes(token);
170
179
  });
171
180
  };
@@ -334,6 +343,9 @@ function buildThinkingTimeExpression(level, desiredModel) {
334
343
  if (menu?.getAttribute?.('data-testid') === 'composer-intelligence-picker-content') {
335
344
  return true;
336
345
  }
346
+ if (menu?.querySelector?.(INTELLIGENCE_MENU_SELECTOR)) {
347
+ return true;
348
+ }
337
349
  const label = menu?.querySelector?.('.__menu-label, [class*="menu-label"]');
338
350
  return normalize(label?.textContent ?? '').includes('intelligence');
339
351
  };
@@ -347,6 +359,21 @@ function buildThinkingTimeExpression(level, desiredModel) {
347
359
  const items = Array.from(menu.querySelectorAll(MENU_ITEM_SELECTOR));
348
360
  const modelKind = modelKindOverride || effectiveTargetModelKind();
349
361
  if (modelKind === 'pro') {
362
+ // GPT-5.6's unified Intelligence picker exposes Pro as the highest
363
+ // effort radio directly. It no longer has a nested "Pro Extended"
364
+ // row, so preserve the legacy request semantics by selecting Pro.
365
+ if (
366
+ TARGET_LEVEL === 'extended' &&
367
+ isIntelligenceEffortMenu(menu) &&
368
+ !document.querySelector(PRO_EFFORT_TRIGGER_SELECTOR)
369
+ ) {
370
+ for (const item of items) {
371
+ const itemText = normalize(
372
+ (item.textContent ?? '') + ' ' + (item.getAttribute?.('aria-label') ?? ''),
373
+ );
374
+ if (itemText === 'pro') return item;
375
+ }
376
+ }
350
377
  for (const item of items) {
351
378
  const itemText = normalize(
352
379
  (item.textContent ?? '') + ' ' + (item.getAttribute?.('aria-label') ?? ''),
@@ -367,7 +394,7 @@ function buildThinkingTimeExpression(level, desiredModel) {
367
394
  const itemText = normalize(
368
395
  (item.textContent ?? '') + ' ' + (item.getAttribute?.('aria-label') ?? ''),
369
396
  );
370
- if (modelKind && modelKind !== 'pro' && hasToken(itemText, 'pro')) {
397
+ if (modelKind !== 'pro' && hasToken(itemText, 'pro')) {
371
398
  continue;
372
399
  }
373
400
  if (
@@ -377,6 +404,16 @@ function buildThinkingTimeExpression(level, desiredModel) {
377
404
  return item;
378
405
  }
379
406
  }
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.
411
+ for (const item of items) {
412
+ const itemText = normalize(item.textContent ?? '');
413
+ const ariaLabel = normalize(item.getAttribute?.('aria-label') ?? '');
414
+ if (itemText === '高' || ariaLabel === '高') return item;
415
+ }
416
+ }
380
417
  return null;
381
418
  };
382
419
  const countEffortLevels = (menu) => {
@@ -390,6 +427,7 @@ function buildThinkingTimeExpression(level, desiredModel) {
390
427
  const isEffortMenu = (menu) => {
391
428
  if (!isVisible(menu)) return false;
392
429
  if (menu.getAttribute?.('data-testid') === 'composer-intelligence-picker-content') return true;
430
+ if (menu.querySelector?.(INTELLIGENCE_MENU_SELECTOR)) return true;
393
431
  const label = menu.querySelector?.('.__menu-label, [class*="menu-label"]');
394
432
  const labelText = normalize(label?.textContent ?? '');
395
433
  return (
@@ -450,8 +488,16 @@ function buildThinkingTimeExpression(level, desiredModel) {
450
488
  }
451
489
  return null;
452
490
  };
491
+ const freshComposerTrigger = (trigger) => {
492
+ if (!trigger?.matches?.('button.__composer-pill')) return null;
493
+ // React can replace the composer pill after an effort click. Keep using
494
+ // the captured node while it is live, but re-query once it is detached so
495
+ // verification does not read its stale pre-click label.
496
+ if (trigger.isConnected !== false) return trigger;
497
+ return findComposerEffortPill() || findModelButton() || trigger;
498
+ };
453
499
  const currentProEffortPillMatchesTarget = (trigger, modelKindOverride = null) => {
454
- const button = trigger?.matches?.('button.__composer-pill') ? trigger : findModelButton();
500
+ const button = freshComposerTrigger(trigger) || findModelButton();
455
501
  if ((modelKindOverride || TARGET_MODEL_KIND || modelKindFromNode(button)) !== 'pro') {
456
502
  return false;
457
503
  }
@@ -466,7 +512,7 @@ function buildThinkingTimeExpression(level, desiredModel) {
466
512
  };
467
513
  const currentEffortPillMatchesTarget = (trigger, modelKindOverride = null) => {
468
514
  if (currentProEffortPillMatchesTarget(trigger, modelKindOverride)) return true;
469
- const button = trigger?.matches?.('button.__composer-pill') ? trigger : findModelButton();
515
+ const button = freshComposerTrigger(trigger) || findModelButton();
470
516
  if ((modelKindOverride || TARGET_MODEL_KIND || modelKindFromNode(button)) === 'pro') {
471
517
  return false;
472
518
  }
@@ -499,8 +545,9 @@ function buildThinkingTimeExpression(level, desiredModel) {
499
545
  return { status: 'switched', label };
500
546
  }
501
547
 
502
- if (!refreshed && trigger && trigger.getAttribute?.('aria-expanded') !== 'true') {
503
- dispatchClickSequence(trigger);
548
+ const reopenTrigger = freshComposerTrigger(trigger) || trigger;
549
+ if (!refreshed && reopenTrigger?.getAttribute?.('aria-expanded') !== 'true') {
550
+ dispatchClickSequence(reopenTrigger);
504
551
  await sleep(INITIAL_WAIT_MS);
505
552
  }
506
553
  const deadline = performance.now() + 2000;
@@ -556,6 +603,7 @@ function buildThinkingTimeExpression(level, desiredModel) {
556
603
  ];
557
604
  const findComposerEffortPill = () => {
558
605
  const seen = new Set();
606
+ let gpt56Fallback = null;
559
607
  for (const selector of COMPOSER_EFFORT_PILL_SELECTORS) {
560
608
  for (const button of document.querySelectorAll(selector)) {
561
609
  if (seen.has(button) || !isVisible(button)) continue;
@@ -574,9 +622,16 @@ function buildThinkingTimeExpression(level, desiredModel) {
574
622
  ) {
575
623
  return button;
576
624
  }
625
+ if (
626
+ TARGET_IS_GPT56_MODEL &&
627
+ button.matches?.('button.__composer-pill') &&
628
+ normalize(button.textContent ?? '') === 'pro'
629
+ ) {
630
+ gpt56Fallback ||= button;
631
+ }
577
632
  }
578
633
  }
579
- return null;
634
+ return gpt56Fallback;
580
635
  };
581
636
  let composerEffortPill = findComposerEffortPill();
582
637
  let modelBtn = findModelButton();
@@ -634,7 +689,9 @@ function buildThinkingTimeExpression(level, desiredModel) {
634
689
  }
635
690
  if (composerEffortPill) {
636
691
  if (attemptedModelButton && attemptedModelButton !== composerEffortPill) closeOpenMenus();
637
- const composerModelKind = TARGET_MODEL_KIND || modelKindFromNode(composerEffortPill);
692
+ const composerModelKind =
693
+ TARGET_MODEL_KIND ||
694
+ (TARGET_IS_GPT56_MODEL ? 'versioned' : modelKindFromNode(composerEffortPill));
638
695
  if (composerEffortPill.getAttribute?.('aria-expanded') !== 'true') {
639
696
  dispatchClickSequence(composerEffortPill);
640
697
  await sleep(INITIAL_WAIT_MS);
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { ASSISTANT_ROLE_SELECTOR, CONVERSATION_TURN_SELECTOR } from "./constants.js";
3
+ import { ASSISTANT_ROLE_SELECTOR } from "./constants.js";
4
+ import { buildConversationTurnListExpression } from "./conversationTurns.js";
4
5
  import { computeFileSha256, resolveSessionArtifactsDir, sanitizeArtifactFilename, validateArtifactFile, writeBinaryBrowserArtifact, } from "./artifacts.js";
5
6
  const CHATGPT_DOWNLOAD_BASE_URL = "https://chatgpt.com/";
6
7
  const DOWNLOAD_BUTTON_WAIT_MS = 15_000;
@@ -272,11 +273,9 @@ function buildAssistantDownloadableFilesExpression(minTurnIndex) {
272
273
  const minTurnLiteral = typeof minTurnIndex === "number" && Number.isFinite(minTurnIndex) && minTurnIndex >= 0
273
274
  ? Math.floor(minTurnIndex)
274
275
  : -1;
275
- const conversationLiteral = JSON.stringify(CONVERSATION_TURN_SELECTOR);
276
276
  const assistantLiteral = JSON.stringify(ASSISTANT_ROLE_SELECTOR);
277
277
  return `(() => {
278
278
  const MIN_TURN_INDEX = ${minTurnLiteral};
279
- const CONVERSATION_SELECTOR = ${conversationLiteral};
280
279
  const ASSISTANT_SELECTOR = ${assistantLiteral};
281
280
  const isAssistantTurn = (node) => {
282
281
  if (!(node instanceof HTMLElement)) return false;
@@ -385,7 +384,7 @@ function buildAssistantDownloadableFilesExpression(minTurnIndex) {
385
384
  ].join(',')))
386
385
  .map(serializeCandidate)
387
386
  .filter(Boolean);
388
- const turns = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
387
+ const turns = ${buildConversationTurnListExpression()};
389
388
  const files = [];
390
389
  for (let index = turns.length - 1; index >= 0; index -= 1) {
391
390
  const turn = turns[index];
@@ -626,7 +625,6 @@ function buildClickAssistantDownloadButtonsExpression(minTurnIndex, expectedLabe
626
625
  const minTurnLiteral = typeof minTurnIndex === "number" && Number.isFinite(minTurnIndex) && minTurnIndex >= 0
627
626
  ? Math.floor(minTurnIndex)
628
627
  : -1;
629
- const conversationLiteral = JSON.stringify(CONVERSATION_TURN_SELECTOR);
630
628
  const assistantLiteral = JSON.stringify(ASSISTANT_ROLE_SELECTOR);
631
629
  const expectedLabelsLiteral = JSON.stringify(expectedLabels);
632
630
  const allowGenericDownloadLabelsLiteral = JSON.stringify(allowGenericDownloadLabels);
@@ -639,7 +637,6 @@ function buildClickAssistantDownloadButtonsExpression(minTurnIndex, expectedLabe
639
637
  : 0;
640
638
  return `(() => {
641
639
  const MIN_TURN_INDEX = ${minTurnLiteral};
642
- const CONVERSATION_SELECTOR = ${conversationLiteral};
643
640
  const ASSISTANT_SELECTOR = ${assistantLiteral};
644
641
  const EXPECTED_LABELS = ${expectedLabelsLiteral};
645
642
  const ALLOW_GENERIC_DOWNLOAD_LABELS = ${allowGenericDownloadLabelsLiteral};
@@ -762,7 +759,7 @@ function buildClickAssistantDownloadButtonsExpression(minTurnIndex, expectedLabe
762
759
  const genericBehaviorButton = (info) =>
763
760
  ALLOW_GENERIC_DOWNLOAD_LABELS && info.className.includes('behavior-btn') && hasDownloadIntent(info);
764
761
  const genericFallbackButton = (info) => ALLOW_GENERIC_DOWNLOAD_LABELS && hasDownloadIntent(info);
765
- const turns = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
762
+ const turns = ${buildConversationTurnListExpression()};
766
763
  const expectedMatches = new Set();
767
764
  const genericBehaviorMatches = new Set();
768
765
  const genericFallbackMatches = new Set();
@@ -1,7 +1,8 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
- import { ASSISTANT_ROLE_SELECTOR, CONVERSATION_TURN_SELECTOR } from "./constants.js";
4
+ import { ASSISTANT_ROLE_SELECTOR } from "./constants.js";
5
+ import { buildConversationTurnListExpression } from "./conversationTurns.js";
5
6
  import { delay } from "./utils.js";
6
7
  import { readAssistantSnapshot } from "./pageActions.js";
7
8
  import { getOracleHomeDir } from "../oracleHome.js";
@@ -61,11 +62,9 @@ function buildAssistantImageExpression(minTurnIndex) {
61
62
  const minTurnLiteral = typeof minTurnIndex === "number" && Number.isFinite(minTurnIndex) && minTurnIndex >= 0
62
63
  ? Math.floor(minTurnIndex)
63
64
  : -1;
64
- const conversationLiteral = JSON.stringify(CONVERSATION_TURN_SELECTOR);
65
65
  const assistantLiteral = JSON.stringify(ASSISTANT_ROLE_SELECTOR);
66
66
  return `(() => {
67
67
  const MIN_TURN_INDEX = ${minTurnLiteral};
68
- const CONVERSATION_SELECTOR = ${conversationLiteral};
69
68
  const ASSISTANT_SELECTOR = ${assistantLiteral};
70
69
  const isGeneratedImage = (img) => {
71
70
  const url = new URL(img?.src || '', location.origin || 'https://chatgpt.com');
@@ -101,7 +100,7 @@ function buildAssistantImageExpression(minTurnIndex) {
101
100
  if (testId.includes('assistant')) return true;
102
101
  return Boolean(node.querySelector(ASSISTANT_SELECTOR) || node.querySelector('[data-testid*="assistant"]'));
103
102
  };
104
- const turns = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
103
+ const turns = ${buildConversationTurnListExpression()};
105
104
  for (let index = turns.length - 1; index >= 0; index -= 1) {
106
105
  const turn = turns[index];
107
106
  if (!isAssistantTurn(turn)) continue;
@@ -2,18 +2,15 @@ import { rm } from "node:fs/promises";
2
2
  import { readFileSync } from "node:fs";
3
3
  import os from "node:os";
4
4
  import net from "node:net";
5
- import { execFile } from "node:child_process";
6
- import { promisify } from "node:util";
7
5
  import CDP from "chrome-remote-interface";
8
6
  import { launch, Launcher } from "chrome-launcher";
9
7
  import { cleanupStaleProfileState } from "./profileState.js";
10
8
  import { delay } from "./utils.js";
11
- const execFileAsync = promisify(execFile);
12
9
  export async function launchChrome(config, userDataDir, logger) {
13
10
  const connectHost = resolveRemoteDebugHost();
14
11
  const debugBindAddress = connectHost && connectHost !== "127.0.0.1" ? "0.0.0.0" : connectHost;
15
12
  const debugPort = config.debugPort ?? parseDebugPortEnv();
16
- const chromeFlags = buildChromeFlags(config.headless ?? false, debugBindAddress);
13
+ const chromeFlags = buildChromeFlags(config.headless ?? false, debugBindAddress, config.hideWindow ?? false);
17
14
  const usePatchedLauncher = Boolean(connectHost && connectHost !== "127.0.0.1");
18
15
  // copy-profile reuses a copied signed-in profile whose cookies are
19
16
  // Keychain-encrypted, so it must launch with the real Keychain (not mocked):
@@ -46,6 +43,24 @@ export async function launchChrome(config, userDataDir, logger) {
46
43
  logger(`Launched Chrome${pidLabel} on port ${launcher.port}${hostLabel}`);
47
44
  return Object.assign(launcher, { host: connectHost ?? "127.0.0.1" });
48
45
  }
46
+ export async function positionChromeWindowOffscreen(client, logger) {
47
+ if (process.platform !== "darwin") {
48
+ logger("Window hiding is only supported on macOS");
49
+ return;
50
+ }
51
+ try {
52
+ const { windowId } = await client.Browser.getWindowForTarget();
53
+ await client.Browser.setWindowBounds({
54
+ windowId,
55
+ bounds: { left: -32_000, top: -32_000, windowState: "normal" },
56
+ });
57
+ logger("Chrome window positioned off-screen");
58
+ }
59
+ catch (error) {
60
+ const message = error instanceof Error ? error.message : String(error);
61
+ logger(`Failed to position Chrome window off-screen: ${message}`);
62
+ }
63
+ }
49
64
  export function registerTerminationHooks(chrome, userDataDir, keepBrowser, logger, opts) {
50
65
  const signals = ["SIGINT", "SIGTERM", "SIGQUIT"];
51
66
  let handling;
@@ -110,29 +125,6 @@ export function registerTerminationHooks(chrome, userDataDir, keepBrowser, logge
110
125
  }
111
126
  };
112
127
  }
113
- export async function hideChromeWindow(chrome, logger) {
114
- if (process.platform !== "darwin") {
115
- logger("Window hiding is only supported on macOS");
116
- return;
117
- }
118
- if (!chrome.pid) {
119
- logger("Unable to hide window: missing Chrome PID");
120
- return;
121
- }
122
- const script = `tell application "System Events"
123
- try
124
- set visible of (first process whose unix id is ${chrome.pid}) to false
125
- end try
126
- end tell`;
127
- try {
128
- await execFileAsync("osascript", ["-e", script]);
129
- logger("Chrome window hidden (Cmd-H)");
130
- }
131
- catch (error) {
132
- const message = error instanceof Error ? error.message : String(error);
133
- logger(`Failed to hide Chrome window: ${message}`);
134
- }
135
- }
136
128
  export async function connectToChrome(port, logger, host) {
137
129
  const client = await CDP({ port, host });
138
130
  logger("Connected to Chrome DevTools protocol");
@@ -367,6 +359,7 @@ function createSessionBoundChromeClient(browser, sessionId) {
367
359
  Runtime: bindDomain("Runtime"),
368
360
  Input: bindDomain("Input"),
369
361
  DOM: bindDomain("DOM"),
362
+ Emulation: bindDomain("Emulation"),
370
363
  on: browserWithEvents.on.bind(browserWithEvents),
371
364
  once: browserWithEvents.once.bind(browserWithEvents),
372
365
  off: browserWithEvents.off?.bind(browserWithEvents) ??
@@ -458,7 +451,7 @@ function isBlankPageTarget(target) {
458
451
  const url = (target.url ?? "").trim().toLowerCase();
459
452
  return url === "about:blank" || url === "chrome://newtab/" || url === "chrome://new-tab-page/";
460
453
  }
461
- function buildChromeFlags(headless, debugBindAddress) {
454
+ function buildChromeFlags(headless, debugBindAddress, hideWindow = false) {
462
455
  const flags = [
463
456
  "--disable-background-networking",
464
457
  "--disable-background-timer-throttling",
@@ -488,8 +481,17 @@ function buildChromeFlags(headless, debugBindAddress) {
488
481
  if (headless) {
489
482
  flags.push("--headless=new");
490
483
  }
484
+ else if (hideWindow && process.platform === "darwin") {
485
+ // Cmd-H stops macOS Chrome from compositing the page, which can swallow
486
+ // trusted CDP clicks and retain the prompt as a draft. Keeping the window
487
+ // off-screen avoids desktop disruption while preserving normal rendering.
488
+ flags.push("--window-position=-32000,-32000");
489
+ }
491
490
  return flags;
492
491
  }
492
+ export function buildChromeFlagsForTest(headless, debugBindAddress, hideWindow = false) {
493
+ return buildChromeFlags(headless, debugBindAddress, hideWindow);
494
+ }
493
495
  function resolveChromeLaunchOptions(chromeFlags, usingCopiedProfile) {
494
496
  if (!usingCopiedProfile) {
495
497
  return { chromeFlags, ignoreDefaultFlags: false };