@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
@@ -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);
@@ -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");
@@ -459,7 +451,7 @@ function isBlankPageTarget(target) {
459
451
  const url = (target.url ?? "").trim().toLowerCase();
460
452
  return url === "about:blank" || url === "chrome://newtab/" || url === "chrome://new-tab-page/";
461
453
  }
462
- function buildChromeFlags(headless, debugBindAddress) {
454
+ function buildChromeFlags(headless, debugBindAddress, hideWindow = false) {
463
455
  const flags = [
464
456
  "--disable-background-networking",
465
457
  "--disable-background-timer-throttling",
@@ -489,8 +481,17 @@ function buildChromeFlags(headless, debugBindAddress) {
489
481
  if (headless) {
490
482
  flags.push("--headless=new");
491
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
+ }
492
490
  return flags;
493
491
  }
492
+ export function buildChromeFlagsForTest(headless, debugBindAddress, hideWindow = false) {
493
+ return buildChromeFlags(headless, debugBindAddress, hideWindow);
494
+ }
494
495
  function resolveChromeLaunchOptions(chromeFlags, usingCopiedProfile) {
495
496
  if (!usingCopiedProfile) {
496
497
  return { chromeFlags, ignoreDefaultFlags: false };
@@ -61,6 +61,7 @@ export function resolveBrowserConfig(config) {
61
61
  const debugPortEnv = parseDebugPort(process.env.ORACLE_BROWSER_PORT ?? process.env.ORACLE_BROWSER_DEBUG_PORT);
62
62
  const envAllowCookieErrors = (process.env.ORACLE_BROWSER_ALLOW_COOKIE_ERRORS ?? "").trim().toLowerCase() === "true" ||
63
63
  (process.env.ORACLE_BROWSER_ALLOW_COOKIE_ERRORS ?? "").trim() === "1";
64
+ const envMaxConcurrentTabs = parseMaxConcurrentTabs(process.env.ORACLE_BROWSER_MAX_CONCURRENT_TABS);
64
65
  const rawUrl = config?.chatgptUrl ?? config?.url ?? DEFAULT_BROWSER_CONFIG.url;
65
66
  const normalizedUrl = normalizeChatgptUrl(rawUrl ?? DEFAULT_BROWSER_CONFIG.url, DEFAULT_BROWSER_CONFIG.url);
66
67
  const desiredModel = config?.desiredModel ?? DEFAULT_BROWSER_CONFIG.desiredModel ?? DEFAULT_MODEL_TARGET;
@@ -87,7 +88,7 @@ export function resolveBrowserConfig(config) {
87
88
  assistantRecheckTimeoutMs: config?.assistantRecheckTimeoutMs ?? DEFAULT_BROWSER_CONFIG.assistantRecheckTimeoutMs,
88
89
  reuseChromeWaitMs: config?.reuseChromeWaitMs ?? DEFAULT_BROWSER_CONFIG.reuseChromeWaitMs,
89
90
  profileLockTimeoutMs: config?.profileLockTimeoutMs ?? DEFAULT_BROWSER_CONFIG.profileLockTimeoutMs,
90
- maxConcurrentTabs: normalizeMaxConcurrentTabs(config?.maxConcurrentTabs ?? DEFAULT_BROWSER_CONFIG.maxConcurrentTabs),
91
+ maxConcurrentTabs: normalizeMaxConcurrentTabs(config?.maxConcurrentTabs ?? envMaxConcurrentTabs ?? DEFAULT_BROWSER_CONFIG.maxConcurrentTabs),
91
92
  autoReattachDelayMs: config?.autoReattachDelayMs ?? DEFAULT_BROWSER_CONFIG.autoReattachDelayMs,
92
93
  autoReattachIntervalMs: config?.autoReattachIntervalMs ?? DEFAULT_BROWSER_CONFIG.autoReattachIntervalMs,
93
94
  autoReattachTimeoutMs: config?.autoReattachTimeoutMs ?? DEFAULT_BROWSER_CONFIG.autoReattachTimeoutMs,
@@ -134,6 +135,18 @@ function parseDebugPort(raw) {
134
135
  }
135
136
  return value;
136
137
  }
138
+ function parseMaxConcurrentTabs(raw) {
139
+ if (!raw)
140
+ return null;
141
+ const trimmed = raw.trim();
142
+ if (!/^\d+$/.test(trimmed))
143
+ return null;
144
+ const value = Number(trimmed);
145
+ if (!Number.isInteger(value) || value <= 0) {
146
+ return null;
147
+ }
148
+ return value;
149
+ }
137
150
  function resolveManualLoginProfileDir(...candidates) {
138
151
  for (const candidate of candidates) {
139
152
  const profileDir = candidate?.trim();
@@ -64,10 +64,14 @@ export const UPLOAD_STATUS_SELECTORS = [
64
64
  '[aria-live="assertive"]',
65
65
  ];
66
66
  export const STOP_BUTTON_SELECTOR = '[data-testid="stop-button"]';
67
+ // The aria-label fallback exists for data-testid drift, but a document-wide match makes ANY
68
+ // visible "stop" control (read-aloud, voice/dictation) read as "still generating", which blocks
69
+ // completion until the response timeout. Scope it to the composer form and exclude the known
70
+ // non-generation stop controls that legitimately live there.
67
71
  export const STOP_BUTTON_SELECTORS = [
68
72
  STOP_BUTTON_SELECTOR,
69
73
  '[data-testid="composer-stop-button"]',
70
- 'button[aria-label*="stop" i]',
74
+ 'form button[aria-label*="stop" i]:not([aria-label*="dictat" i]):not([aria-label*="voice" i]):not([aria-label*="read" i])',
71
75
  ];
72
76
  export const SEND_BUTTON_SELECTORS = [
73
77
  'button[data-testid="send-button"]',
@@ -45,13 +45,13 @@ export function describeBrowserControlPlan(config = {}) {
45
45
  };
46
46
  }
47
47
  if (config.hideWindow) {
48
- guidance.push("Chrome may briefly focus while launching before Oracle hides it.");
48
+ guidance.push("On macOS, Oracle launches Chrome off-screen while keeping the page rendered.");
49
49
  guidance.push("For the calmest shared-desktop flow, prefer --browser-attach-running or --remote-chrome.");
50
50
  return {
51
51
  mode: "hidden-window",
52
52
  launchesChrome: true,
53
53
  mayFocusWindow: true,
54
- summary: "launch Chrome and hide the window after startup",
54
+ summary: "launch Chrome in hidden-window mode",
55
55
  guidance,
56
56
  };
57
57
  }