@steipete/oracle 0.11.1 → 0.12.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 (47) hide show
  1. package/README.md +55 -10
  2. package/dist/bin/oracle-cli.js +440 -98
  3. package/dist/src/browser/actions/modelSelection.js +53 -15
  4. package/dist/src/browser/actions/navigation.js +5 -3
  5. package/dist/src/browser/actions/promptComposer.js +75 -18
  6. package/dist/src/browser/actions/thinkingTime.js +23 -8
  7. package/dist/src/browser/constants.js +1 -1
  8. package/dist/src/browser/index.js +41 -7
  9. package/dist/src/browser/manualLoginProfile.js +54 -0
  10. package/dist/src/browser/projectSourcesRunner.js +16 -5
  11. package/dist/src/browser/prompt.js +56 -37
  12. package/dist/src/browser/sessionRunner.js +72 -1
  13. package/dist/src/browser/utils.js +1 -47
  14. package/dist/src/browser/zipBundle.js +152 -0
  15. package/dist/src/cli/browserConfig.js +13 -11
  16. package/dist/src/cli/browserDefaults.js +2 -1
  17. package/dist/src/cli/docsCheck.js +186 -0
  18. package/dist/src/cli/engine.js +11 -4
  19. package/dist/src/cli/options.js +12 -6
  20. package/dist/src/cli/perfTrace.js +242 -0
  21. package/dist/src/cli/promptRequirement.js +2 -0
  22. package/dist/src/cli/providerDoctor.js +85 -0
  23. package/dist/src/cli/runOptions.js +46 -16
  24. package/dist/src/cli/sessionDisplay.js +39 -4
  25. package/dist/src/cli/sessionLifecycle.js +38 -0
  26. package/dist/src/cli/sessionRunner.js +228 -3
  27. package/dist/src/cli/sessionTable.js +2 -1
  28. package/dist/src/duration.js +47 -0
  29. package/dist/src/mcp/tools/consult.js +19 -3
  30. package/dist/src/mcp/types.js +1 -0
  31. package/dist/src/mcp/utils.js +4 -1
  32. package/dist/src/oracle/baseUrl.js +17 -0
  33. package/dist/src/oracle/client.js +1 -22
  34. package/dist/src/oracle/config.js +17 -4
  35. package/dist/src/oracle/gemini.js +2 -22
  36. package/dist/src/oracle/geminiModels.js +21 -0
  37. package/dist/src/oracle/modelResolver.js +7 -1
  38. package/dist/src/oracle/multiModelRunner.js +20 -2
  39. package/dist/src/oracle/providerFailures.js +204 -0
  40. package/dist/src/oracle/providerRoutePlan.js +281 -0
  41. package/dist/src/oracle/providerRouting.js +92 -0
  42. package/dist/src/oracle/run.js +157 -54
  43. package/dist/src/oracle.js +1 -0
  44. package/dist/src/remote/client.js +8 -0
  45. package/dist/src/remote/server.js +26 -0
  46. package/dist/src/sessionManager.js +5 -1
  47. package/package.json +3 -1
@@ -1,6 +1,8 @@
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
+ const LEGACY_PRO_VERSION_WORD_TOKENS = ["5 4", "5 2", "5 1", "5 0", "gpt 5 pro"];
5
+ const LEGACY_PRO_VERSION_COMPACT_TOKENS = ["gpt54", "gpt52", "gpt51", "gpt50"];
4
6
  export async function ensureModelSelection(Runtime, desiredModel, logger, strategy = "select") {
5
7
  const outcome = await Runtime.evaluate({
6
8
  expression: buildModelSelectionExpression(desiredModel, strategy),
@@ -17,7 +19,15 @@ export async function ensureModelSelection(Runtime, desiredModel, logger, strate
17
19
  assertResolvedModelSelection(desiredModel, label);
18
20
  }
19
21
  logger(`Model picker: ${label}`);
20
- return;
22
+ return {
23
+ requestedModel: desiredModel,
24
+ resolvedLabel: label,
25
+ strategy,
26
+ status: result.status,
27
+ verified: strategy !== "current",
28
+ source: "chatgpt-model-picker",
29
+ capturedAt: new Date().toISOString(),
30
+ };
21
31
  }
22
32
  case "option-not-found": {
23
33
  await logDomFailure(Runtime, logger, "model-switcher-option");
@@ -38,22 +48,39 @@ export async function ensureModelSelection(Runtime, desiredModel, logger, strate
38
48
  function assertResolvedModelSelection(desiredModel, resolvedLabel) {
39
49
  const desired = desiredModel.toLowerCase();
40
50
  const resolved = resolvedLabel.toLowerCase();
41
- const wantsGpt55Pro = desired === "gpt-5.5-pro" ||
51
+ const wantsGpt55Pro = desired === "pro" ||
52
+ desired === "chatgpt pro" ||
53
+ desired === "gpt-5.5-pro" ||
42
54
  desired.includes("5.5 pro") ||
43
55
  desired.includes("5-5 pro") ||
44
56
  (desired.includes("pro") && desired.includes("extended"));
45
57
  if (!wantsGpt55Pro || !resolved) {
46
58
  return;
47
59
  }
48
- const hasProSignal = resolved.includes(" pro") ||
60
+ if (!hasCurrentProSignal(resolved) ||
61
+ hasLegacyProVersionLabel(resolved) ||
62
+ (resolved.includes("thinking") && !resolved.includes("pro"))) {
63
+ throw new Error(`Model picker selected "${resolvedLabel}" while "${desiredModel}" requires GPT-5.5 Pro. Use model "gpt-5.5" with browser thinking time for the Thinking variant.`);
64
+ }
65
+ }
66
+ function normalizeResolvedModelLabel(value) {
67
+ return value
68
+ .replace(/[^a-z0-9]+/g, " ")
69
+ .replace(/\s+/g, " ")
70
+ .trim();
71
+ }
72
+ function hasCurrentProSignal(resolved) {
73
+ return (resolved.includes(" pro") ||
49
74
  resolved.endsWith("pro") ||
50
75
  resolved.includes("pro ") ||
51
76
  resolved.includes("extended") ||
52
77
  resolved.includes("gpt-5.5-pro") ||
53
- resolved.includes("gpt 5 5 pro");
54
- if (!hasProSignal || (resolved.includes("thinking") && !resolved.includes("pro"))) {
55
- throw new Error(`Model picker selected "${resolvedLabel}" while "${desiredModel}" requires GPT-5.5 Pro. Use model "gpt-5.5" with browser thinking time for the Thinking variant.`);
56
- }
78
+ resolved.includes("gpt 5 5 pro"));
79
+ }
80
+ function hasLegacyProVersionLabel(resolved) {
81
+ const normalized = normalizeResolvedModelLabel(resolved);
82
+ return (LEGACY_PRO_VERSION_WORD_TOKENS.some((token) => normalized.includes(token)) ||
83
+ LEGACY_PRO_VERSION_COMPACT_TOKENS.some((token) => resolved.includes(token)));
57
84
  }
58
85
  export function assertResolvedModelSelectionForTest(desiredModel, resolvedLabel) {
59
86
  assertResolvedModelSelection(desiredModel, resolvedLabel);
@@ -121,8 +148,16 @@ function buildModelSelectionExpression(targetModel, strategy) {
121
148
  const wantsPro = normalizedTarget.includes(' pro') || normalizedTarget.endsWith(' pro') || normalizedTokens.includes('pro');
122
149
  const wantsInstant = normalizedTarget.includes('instant');
123
150
  const wantsThinking = normalizedTarget.includes('thinking');
151
+ const targetUsesCurrentGpt55Alias =
152
+ desiredVersion === '5-5' || normalizedTarget === 'pro' || normalizedTarget === 'chatgpt pro';
153
+ const labelHasProWord = (label) => label === 'pro' || label.startsWith('pro ') || label.includes(' pro ') || label.endsWith(' pro');
154
+ const legacyProVersionTokens = ['5 4', '5 2', '5 1', '5 0', 'gpt54', 'gpt52', 'gpt51', 'gpt50', 'gpt 5 pro'];
155
+ const labelHasLegacyProVersion = (value) => {
156
+ const label = normalizeText(value);
157
+ return legacyProVersionTokens.some((token) => label.includes(token));
158
+ };
124
159
  const isTargetGpt55VisibleAlias = (value) => {
125
- if (desiredVersion !== '5-5') return false;
160
+ if (!targetUsesCurrentGpt55Alias) return false;
126
161
  const label = normalizeText(value);
127
162
  if (wantsPro) {
128
163
  // ChatGPT UI as of 2026-05: the picker shows just "Pro" (no longer "Pro Extended").
@@ -200,7 +235,8 @@ function buildModelSelectionExpression(targetModel, strategy) {
200
235
  if (desiredVersion === '5-1' && !normalizedLabel.includes('5 1')) return false;
201
236
  if (desiredVersion === '5-0' && !normalizedLabel.includes('5 0')) return false;
202
237
  }
203
- if (wantsPro && !normalizedLabel.includes(' pro')) return false;
238
+ if (wantsPro && labelHasLegacyProVersion(normalizedLabel)) return false;
239
+ if (wantsPro && !labelHasProWord(normalizedLabel)) return false;
204
240
  if (wantsInstant && !normalizedLabel.includes('instant')) return false;
205
241
  if (wantsThinking && !normalizedLabel.includes('thinking')) return false;
206
242
  // Also reject if button has variants we DON'T want
@@ -218,6 +254,9 @@ function buildModelSelectionExpression(targetModel, strategy) {
218
254
  if (!signal) {
219
255
  return COMPOSER_SIGNAL_ALLOW_BLANK;
220
256
  }
257
+ if (wantsPro && labelHasLegacyProVersion(signal)) {
258
+ return false;
259
+ }
221
260
  if (COMPOSER_SIGNAL_EXCLUDES.some((token) => token && signal.includes(token))) {
222
261
  return false;
223
262
  }
@@ -356,15 +395,14 @@ function buildModelSelectionExpression(targetModel, strategy) {
356
395
  const candidateGpt55VisibleAlias = isTargetGpt55VisibleAlias(normalizedText);
357
396
  const candidateHasThinking =
358
397
  normalizedText.includes('thinking') || normalizedTestId.includes('thinking');
398
+ const candidateHasLegacyProVersion = labelHasLegacyProVersion(normalizedText);
359
399
  const candidateHasPro =
360
400
  candidateGpt55VisibleAlias ||
361
- normalizedText === 'pro' ||
362
- normalizedText.startsWith('pro ') ||
363
- normalizedText.includes(' pro ') ||
364
- normalizedText.endsWith(' pro') ||
401
+ labelHasProWord(normalizedText) ||
365
402
  normalizedText.includes('proresearch') ||
366
403
  normalizedTestId.includes('pro');
367
404
  if (wantsPro && candidateHasThinking) return 0;
405
+ if (wantsPro && candidateHasLegacyProVersion) return 0;
368
406
  if (wantsPro && !candidateHasPro) return 0;
369
407
  if (wantsThinking && candidateHasPro) return 0;
370
408
  if (desiredVersion === '5-5' && normalizedText && !candidateGpt55VisibleAlias) {
@@ -407,10 +445,10 @@ function buildModelSelectionExpression(targetModel, strategy) {
407
445
  }
408
446
  // If the caller didn't explicitly ask for Pro, prefer non-Pro options when both exist.
409
447
  if (wantsPro) {
410
- if (!normalizedText.includes(' pro')) {
448
+ if (!labelHasProWord(normalizedText)) {
411
449
  score -= 80;
412
450
  }
413
- } else if (normalizedText.includes(' pro')) {
451
+ } else if (labelHasProWord(normalizedText)) {
414
452
  score -= 40;
415
453
  }
416
454
  // Similarly for Thinking variant
@@ -388,9 +388,8 @@ function buildLoginProbeExpression(timeoutMs) {
388
388
  if (!text) return false;
389
389
  const normalized = text.toLowerCase().trim();
390
390
  return (
391
- ['log in', 'login', 'sign in', 'signin', 'continue with', 'sign up for free'].some(
392
- (needle) => normalized.startsWith(needle),
393
- ) ||
391
+ ['log in', 'login', 'sign in', 'signin', 'sign up for free'].includes(normalized) ||
392
+ normalized.startsWith('continue with') ||
394
393
  normalized.includes('get responses tailored to you') ||
395
394
  normalized.includes('log in to get answers')
396
395
  );
@@ -490,3 +489,6 @@ function normalizeLoginProbe(raw) {
490
489
  onAuthPage: Boolean(value.onAuthPage),
491
490
  };
492
491
  }
492
+ export function buildLoginProbeExpressionForTest(timeoutMs = LOGIN_CHECK_TIMEOUT_MS) {
493
+ return buildLoginProbeExpression(timeoutMs);
494
+ }
@@ -290,17 +290,35 @@ function buildAttachmentReadyExpression(attachmentNames) {
290
290
  document.querySelector('form') ||
291
291
  document.body ||
292
292
  document;
293
- const labelText = (node) =>
294
- [
295
- node?.textContent,
296
- node?.getAttribute?.('aria-label'),
297
- node?.getAttribute?.('title'),
298
- node?.getAttribute?.('data-testid'),
299
- ]
300
- .filter(Boolean)
301
- .join(' ')
302
- .toLowerCase();
303
- const match = (node, name) => labelText(node).includes(name);
293
+ // Walk node + ancestors (up to grandparent) + descendants to gather every textual hint.
294
+ // ChatGPT's current chip DOM nests the filename inside truncated child spans, so checking
295
+ // only the node's own textContent/aria/title misses the match.
296
+ const collectLabelHaystack = (node) => {
297
+ if (!node) return '';
298
+ const pieces = [];
299
+ const pushAttrs = (el) => {
300
+ if (!el || typeof el.getAttribute !== 'function') return;
301
+ for (const attr of ['aria-label', 'title', 'data-testid', 'data-tooltip', 'data-tooltip-content']) {
302
+ const v = el.getAttribute(attr);
303
+ if (v) pieces.push(v);
304
+ }
305
+ };
306
+ const pushText = (el) => {
307
+ if (!el) return;
308
+ const text = (el.innerText ?? el.textContent ?? '').trim();
309
+ if (text) pieces.push(text);
310
+ };
311
+ pushAttrs(node);
312
+ pushText(node);
313
+ const parent = node.parentElement;
314
+ pushAttrs(parent);
315
+ pushText(parent);
316
+ const grandparent = parent?.parentElement;
317
+ pushAttrs(grandparent);
318
+ pushText(grandparent);
319
+ return pieces.join(' ').toLowerCase();
320
+ };
321
+ const match = (node, name) => collectLabelHaystack(node).includes(name);
304
322
 
305
323
  // Restrict to attachment affordances; never scan generic div/span nodes (prompt text can contain the file name).
306
324
  const attachmentSelectors = [
@@ -312,13 +330,32 @@ function buildAttachmentReadyExpression(attachmentNames) {
312
330
  'button[aria-label*="Remove file"]',
313
331
  '[aria-label*="remove file"]',
314
332
  'button[aria-label*="remove file"]',
333
+ '[aria-label*="Remove attachment"]',
334
+ 'button[aria-label*="Remove attachment"]',
335
+ '[aria-label*="remove attachment"]',
336
+ 'button[aria-label*="remove attachment"]',
315
337
  ];
316
- const attachmentRoots = Array.from(new Set([composer, document])).filter(Boolean);
338
+ const attachmentRoots = Array.from(new Set([composer])).filter(Boolean);
339
+ const collectChipNodes = () => {
340
+ const seen = new Set();
341
+ const collected = [];
342
+ for (const root of attachmentRoots) {
343
+ for (const node of Array.from(root.querySelectorAll(attachmentSelectors.join(',')))) {
344
+ if (!(node instanceof HTMLElement)) continue;
345
+ // Skip elements clearly inside the editable input (composer textarea may contain
346
+ // filename text in the user's prompt — avoid mistaking that for a chip).
347
+ if (node.closest('textarea,[contenteditable="true"]')) continue;
348
+ if (seen.has(node)) continue;
349
+ seen.add(node);
350
+ collected.push(node);
351
+ }
352
+ }
353
+ return collected;
354
+ };
355
+ const chipNodes = collectChipNodes();
317
356
 
318
357
  const chipsReady = names.every((name) =>
319
- attachmentRoots.some((root) =>
320
- Array.from(root.querySelectorAll(attachmentSelectors.join(','))).some((node) => match(node, name)),
321
- ),
358
+ chipNodes.some((node) => match(node, name)),
322
359
  );
323
360
  const inputsReady = names.every((name) =>
324
361
  attachmentRoots.some((root) =>
@@ -329,14 +366,28 @@ function buildAttachmentReadyExpression(attachmentNames) {
329
366
  ),
330
367
  ),
331
368
  );
369
+ // Count-based fallback: if we cannot match names individually (ChatGPT may strip
370
+ // the filename out of attribute-readable text into a deeply nested span), but we
371
+ // do see at least as many distinct chip-shaped nodes as attachments we uploaded,
372
+ // and a sibling "Remove" affordance exists per chip, trust the upload.
373
+ const removeAffordanceCount = chipNodes.filter((node) => {
374
+ const aria = (node.getAttribute?.('aria-label') ?? '').toLowerCase();
375
+ if (aria.includes('remove')) return true;
376
+ const removeSibling = node.querySelector?.(
377
+ '[aria-label*="Remove" i], [aria-label*="remove" i], button[aria-label*="Remove" i], button[aria-label*="remove" i]',
378
+ );
379
+ return Boolean(removeSibling);
380
+ }).length;
381
+ const countReady = chipNodes.length >= names.length && removeAffordanceCount >= names.length;
332
382
 
333
- return chipsReady || inputsReady;
383
+ return chipsReady || inputsReady || countReady;
334
384
  })()`;
335
385
  }
336
386
  export function buildAttachmentReadyExpressionForTest(attachmentNames) {
337
387
  return buildAttachmentReadyExpression(attachmentNames);
338
388
  }
339
389
  async function attemptSendButton(Runtime, _logger, attachmentNames) {
390
+ const needAttachment = Array.isArray(attachmentNames) && attachmentNames.length > 0;
340
391
  const script = `(() => {
341
392
  ${buildClickDispatcher()}
342
393
  const selectors = ${JSON.stringify(SEND_BUTTON_SELECTORS)};
@@ -369,9 +420,11 @@ async function attemptSendButton(Runtime, _logger, attachmentNames) {
369
420
  dispatchClickSequence(button);
370
421
  return 'clicked';
371
422
  })()`;
372
- const deadline = Date.now() + 20_000;
423
+ // Give attachment-bearing submissions more headroom. ChatGPT's chip render can
424
+ // settle slowly for multi-file uploads, but plain text sends should keep the
425
+ // shorter historical deadline.
426
+ const deadline = Date.now() + sendButtonTimeoutMs(attachmentNames);
373
427
  while (Date.now() < deadline) {
374
- const needAttachment = Array.isArray(attachmentNames) && attachmentNames.length > 0;
375
428
  if (needAttachment) {
376
429
  const ready = await Runtime.evaluate({
377
430
  expression: buildAttachmentReadyExpression(attachmentNames),
@@ -400,6 +453,9 @@ async function attemptSendButton(Runtime, _logger, attachmentNames) {
400
453
  }
401
454
  return false;
402
455
  }
456
+ function sendButtonTimeoutMs(attachmentNames) {
457
+ return Array.isArray(attachmentNames) && attachmentNames.length > 0 ? 45_000 : 20_000;
458
+ }
403
459
  async function verifyPromptCommitted(Runtime, prompt, timeoutMs, logger, baselineTurns) {
404
460
  const deadline = Date.now() + timeoutMs;
405
461
  const encodedPrompt = JSON.stringify(prompt.trim());
@@ -542,5 +598,6 @@ async function verifyPromptCommitted(Runtime, prompt, timeoutMs, logger, baselin
542
598
  // biome-ignore lint/style/useNamingConvention: test-only export used in vitest suite
543
599
  export const __test__ = {
544
600
  attemptSendButton,
601
+ sendButtonTimeoutMs,
545
602
  verifyPromptCommitted,
546
603
  };
@@ -207,21 +207,36 @@ function buildThinkingTimeExpression(level) {
207
207
 
208
208
  const findModelButton = () => document.querySelector(MODEL_BUTTON_SELECTOR);
209
209
  const findTrailingButtons = () => Array.from(document.querySelectorAll(TRAILING_SELECTOR));
210
+ const findEffortRow = (node) => {
211
+ let current = node instanceof HTMLElement ? node.parentElement : null;
212
+ while (current && current !== document.body) {
213
+ if (current.getAttribute?.('data-model-picker-thinking-effort-row') === 'true') {
214
+ return current;
215
+ }
216
+ current = current.parentElement;
217
+ }
218
+ return null;
219
+ };
220
+ const rowIsSelected = (row) => {
221
+ if (!(row instanceof HTMLElement)) return false;
222
+ const modelItem = row.querySelector('[data-model-picker-thinking-effort-menu-item="true"], [role="menuitemradio"]');
223
+ if (optionIsSelected(modelItem)) return true;
224
+ return Boolean(
225
+ row.querySelector(
226
+ '[aria-checked="true"], [aria-selected="true"], [aria-current="true"], [data-selected="true"], [data-state="checked"], [data-state="selected"], [data-state="on"]',
227
+ ),
228
+ );
229
+ };
210
230
  const pickTrailingForCurrentModel = () => {
211
231
  const trailings = findTrailingButtons();
212
232
  if (trailings.length === 0) return null;
213
233
  if (trailings.length === 1) return trailings[0];
214
234
  // Prefer the trailing button whose model row is currently selected.
215
235
  for (const t of trailings) {
216
- const row = t.closest('[role="menuitem"], [role="menuitemradio"], [data-radix-collection-item]');
217
- if (row && (optionIsSelected(row) || row.querySelector('[aria-checked="true"]'))) return t;
218
- }
219
- // Fallback: first one with non-zero box.
220
- for (const t of trailings) {
221
- const r = t.getBoundingClientRect?.();
222
- if (r && r.width > 0 && r.height > 0) return t;
236
+ const row = findEffortRow(t);
237
+ if (rowIsSelected(row)) return t;
223
238
  }
224
- return trailings[0];
239
+ return null;
225
240
  };
226
241
 
227
242
  const modelBtn = findModelButton();
@@ -1,5 +1,5 @@
1
1
  export const CHATGPT_URL = "https://chatgpt.com/";
2
- export const DEFAULT_MODEL_TARGET = "GPT-5.5 Pro";
2
+ export const DEFAULT_MODEL_TARGET = "Pro";
3
3
  export const DEFAULT_MODEL_STRATEGY = "select";
4
4
  export const COOKIE_URLS = [
5
5
  "https://chatgpt.com",
@@ -26,6 +26,7 @@ import { resolveAttachRunningConnection } from "./attachRunning.js";
26
26
  import { connectToExistingChatGptTab } from "./liveTabs.js";
27
27
  import { captureBrowserDiagnostics } from "./domDebug.js";
28
28
  import { archiveChatGptConversation, resolveBrowserArchiveDecision, } from "./actions/archiveConversation.js";
29
+ import { assertManualLoginProfileReadyForRun, defaultManualLoginProfileDir, formatManualLoginSetupCommand, isManualLoginProfileInitialized, resolveManualLoginWaitMs, } from "./manualLoginProfile.js";
29
30
  import { describeBrowserControlPlan, formatBrowserControlPlan } from "./controlPlan.js";
30
31
  export { CHATGPT_URL, DEFAULT_MODEL_STRATEGY, DEFAULT_MODEL_TARGET } from "./constants.js";
31
32
  export { parseDuration, delay, normalizeChatgptUrl, isTemporaryChatUrl } from "./utils.js";
@@ -290,6 +291,17 @@ async function closeRemoteConnectionAfterRun(options) {
290
291
  function shouldCloseOwnedRunTargetAfterRun(options) {
291
292
  return options.runStatus === "complete" && options.ownsTarget && !options.keepBrowser;
292
293
  }
294
+ function buildSkippedModelSelectionEvidence(desiredModel, strategy) {
295
+ return {
296
+ requestedModel: desiredModel ?? null,
297
+ resolvedLabel: null,
298
+ strategy,
299
+ status: "skipped",
300
+ verified: false,
301
+ source: "config",
302
+ capturedAt: new Date().toISOString(),
303
+ };
304
+ }
293
305
  export async function runBrowserMode(options) {
294
306
  const promptText = options.prompt?.trim();
295
307
  if (!promptText) {
@@ -383,14 +395,19 @@ export async function runBrowserMode(options) {
383
395
  const manualLogin = Boolean(config.manualLogin);
384
396
  const manualProfileDir = config.manualLoginProfileDir
385
397
  ? path.resolve(config.manualLoginProfileDir)
386
- : path.join(os.homedir(), ".oracle", "browser-profile");
398
+ : defaultManualLoginProfileDir();
387
399
  const userDataDir = manualLogin
388
400
  ? manualProfileDir
389
401
  : await mkdtemp(path.join(await resolveUserDataBaseDir(), "oracle-browser-"));
402
+ const effectiveKeepBrowser = Boolean(config.keepBrowser);
390
403
  if (manualLogin) {
391
404
  // Learned: manual login reuses a persistent profile so cookies/SSO survive.
392
405
  await mkdir(userDataDir, { recursive: true });
393
406
  logger(`Manual login mode enabled; reusing persistent profile at ${userDataDir}`);
407
+ await assertManualLoginProfileReadyForRun({
408
+ userDataDir,
409
+ keepBrowser: effectiveKeepBrowser,
410
+ });
394
411
  }
395
412
  else {
396
413
  logger(`Created temporary Chrome profile at ${userDataDir}`);
@@ -403,7 +420,6 @@ export async function runBrowserMode(options) {
403
420
  sessionId: options.sessionId,
404
421
  });
405
422
  }
406
- const effectiveKeepBrowser = Boolean(config.keepBrowser);
407
423
  let acquiredChrome;
408
424
  try {
409
425
  acquiredChrome = manualLogin
@@ -451,6 +467,7 @@ export async function runBrowserMode(options) {
451
467
  let answerMarkdown = "";
452
468
  let answerHtml = "";
453
469
  let runStatus = "attempted";
470
+ let modelSelectionEvidence;
454
471
  let connectionClosedUnexpectedly = false;
455
472
  let stopThinkingMonitor = null;
456
473
  let removeDialogHandler = null;
@@ -587,6 +604,8 @@ export async function runBrowserMode(options) {
587
604
  appliedCookies,
588
605
  manualLogin,
589
606
  timeoutMs: config.timeoutMs,
607
+ profileDir: userDataDir,
608
+ keepBrowser: effectiveKeepBrowser,
590
609
  }));
591
610
  if (config.url !== baseUrl) {
592
611
  await raceWithDisconnect(navigateToPromptReadyWithFallback(Page, Runtime, {
@@ -680,7 +699,7 @@ export async function runBrowserMode(options) {
680
699
  await captureRuntimeSnapshot();
681
700
  const modelStrategy = config.modelStrategy ?? DEFAULT_MODEL_STRATEGY;
682
701
  if (config.desiredModel && modelStrategy !== "ignore") {
683
- await raceWithDisconnect(withRetries(() => ensureModelSelection(Runtime, config.desiredModel, logger, modelStrategy), {
702
+ modelSelectionEvidence = await raceWithDisconnect(withRetries(() => ensureModelSelection(Runtime, config.desiredModel, logger, modelStrategy), {
684
703
  retries: 2,
685
704
  delayMs: 300,
686
705
  onRetry: (attempt, error) => {
@@ -699,6 +718,7 @@ export async function runBrowserMode(options) {
699
718
  logger(`Prompt textarea ready (after model switch, ${promptText.length.toLocaleString()} chars queued)`);
700
719
  }
701
720
  else if (modelStrategy === "ignore") {
721
+ modelSelectionEvidence = buildSkippedModelSelectionEvidence(config.desiredModel, modelStrategy);
702
722
  logger("Model picker: skipped (strategy=ignore)");
703
723
  }
704
724
  const deepResearch = config.researchMode === "deep";
@@ -881,6 +901,7 @@ export async function runBrowserMode(options) {
881
901
  answerHtml: researchResult.html,
882
902
  artifacts: savedArtifacts,
883
903
  archive,
904
+ modelSelection: modelSelectionEvidence,
884
905
  tookMs: durationMs,
885
906
  answerTokens: tokens,
886
907
  answerChars: researchResult.text.length,
@@ -1266,6 +1287,7 @@ export async function runBrowserMode(options) {
1266
1287
  generatedImages: imageArtifacts.generatedImages,
1267
1288
  savedImages: imageArtifacts.savedImages,
1268
1289
  archive,
1290
+ modelSelection: modelSelectionEvidence,
1269
1291
  tookMs: durationMs,
1270
1292
  answerTokens,
1271
1293
  answerChars,
@@ -1491,12 +1513,13 @@ async function findEphemeralPort() {
1491
1513
  });
1492
1514
  });
1493
1515
  }
1494
- async function waitForLogin({ runtime, logger, appliedCookies, manualLogin, timeoutMs, }) {
1516
+ async function waitForLogin({ runtime, logger, appliedCookies, manualLogin, timeoutMs, profileDir, keepBrowser, }) {
1495
1517
  if (!manualLogin) {
1496
1518
  await ensureLoggedIn(runtime, logger, { appliedCookies });
1497
1519
  return;
1498
1520
  }
1499
- const deadline = Date.now() + Math.min(timeoutMs ?? 1_200_000, 20 * 60_000);
1521
+ const waitMs = resolveManualLoginWaitMs(timeoutMs, Boolean(keepBrowser));
1522
+ const deadline = Date.now() + waitMs;
1500
1523
  let lastNotice = 0;
1501
1524
  while (Date.now() < deadline) {
1502
1525
  try {
@@ -1518,7 +1541,10 @@ async function waitForLogin({ runtime, logger, appliedCookies, manualLogin, time
1518
1541
  await delay(1000);
1519
1542
  }
1520
1543
  }
1521
- throw new Error("Manual login mode timed out waiting for ChatGPT session; please sign in and retry.");
1544
+ const setupCommand = formatManualLoginSetupCommand(profileDir ?? defaultManualLoginProfileDir());
1545
+ throw new Error("Manual login mode timed out waiting for ChatGPT session. " +
1546
+ `Browser mode is using Oracle's private Chrome profile at ${profileDir ?? "(default profile)"}, not your normal Chrome profile. ` +
1547
+ `Run first-time setup, sign in there, then retry: ${setupCommand}`);
1522
1548
  }
1523
1549
  async function maybeRecoverLongAssistantResponse({ runtime, baselineTurns, answerText, answerMarkdown, logger, allowMarkdownUpdate, }) {
1524
1550
  // Learned: long streaming responses can still be rendering after initial capture.
@@ -1712,6 +1738,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
1712
1738
  let answerHtml = "";
1713
1739
  let connectionClosedUnexpectedly = false;
1714
1740
  let runStatus = "attempted";
1741
+ let modelSelectionEvidence;
1715
1742
  let stopThinkingMonitor = null;
1716
1743
  let removeDialogHandler = null;
1717
1744
  let connection = null;
@@ -1801,7 +1828,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
1801
1828
  }
1802
1829
  const modelStrategy = config.modelStrategy ?? DEFAULT_MODEL_STRATEGY;
1803
1830
  if (config.desiredModel && modelStrategy !== "ignore") {
1804
- await withRetries(() => ensureModelSelection(Runtime, config.desiredModel, logger, modelStrategy), {
1831
+ modelSelectionEvidence = await withRetries(() => ensureModelSelection(Runtime, config.desiredModel, logger, modelStrategy), {
1805
1832
  retries: 2,
1806
1833
  delayMs: 300,
1807
1834
  onRetry: (attempt, error) => {
@@ -1814,6 +1841,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
1814
1841
  logger(`Prompt textarea ready (after model switch, ${promptText.length.toLocaleString()} chars queued)`);
1815
1842
  }
1816
1843
  else if (modelStrategy === "ignore") {
1844
+ modelSelectionEvidence = buildSkippedModelSelectionEvidence(config.desiredModel, modelStrategy);
1817
1845
  logger("Model picker: skipped (strategy=ignore)");
1818
1846
  }
1819
1847
  const deepResearch = config.researchMode === "deep";
@@ -1948,6 +1976,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
1948
1976
  answerHtml: researchResult.html,
1949
1977
  artifacts: savedArtifacts,
1950
1978
  archive,
1979
+ modelSelection: modelSelectionEvidence,
1951
1980
  tookMs: durationMs,
1952
1981
  answerTokens: tokens,
1953
1982
  answerChars: researchResult.text.length,
@@ -2311,6 +2340,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2311
2340
  conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
2312
2341
  artifacts: savedArtifacts,
2313
2342
  archive,
2343
+ modelSelection: modelSelectionEvidence,
2314
2344
  controllerPid: process.pid,
2315
2345
  };
2316
2346
  }
@@ -2372,10 +2402,14 @@ export { estimateTokenCount } from "./utils.js";
2372
2402
  export { resolveBrowserConfig, DEFAULT_BROWSER_CONFIG } from "./config.js";
2373
2403
  // biome-ignore lint/style/useNamingConvention: test-only export used in vitest suite
2374
2404
  export const __test__ = {
2405
+ assertManualLoginProfileReadyForRun,
2375
2406
  closeRemoteConnectionAfterRun,
2376
2407
  detachKeptChromeProcess,
2408
+ formatManualLoginSetupCommand,
2409
+ isManualLoginProfileInitialized,
2377
2410
  isImageOnlyUiChromeText,
2378
2411
  listIgnoredRemoteChromeFlags,
2412
+ resolveManualLoginWaitMs,
2379
2413
  shouldCloseOwnedRunTargetAfterRun,
2380
2414
  };
2381
2415
  export { syncCookies } from "./cookies.js";
@@ -0,0 +1,54 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { BrowserAutomationError } from "../oracle/errors.js";
5
+ export function resolveManualLoginWaitMs(timeoutMs, keepBrowser) {
6
+ const configured = Math.min(timeoutMs ?? 1_200_000, 20 * 60_000);
7
+ if (keepBrowser) {
8
+ return configured;
9
+ }
10
+ return Math.min(configured, 30_000);
11
+ }
12
+ export async function assertManualLoginProfileReadyForRun({ userDataDir, keepBrowser, }) {
13
+ if (keepBrowser) {
14
+ return;
15
+ }
16
+ if (await isManualLoginProfileInitialized(userDataDir)) {
17
+ return;
18
+ }
19
+ const setupCommand = formatManualLoginSetupCommand(userDataDir);
20
+ throw new BrowserAutomationError("ChatGPT browser manual-login profile is not initialized. " +
21
+ `Browser mode is using Oracle's private Chrome profile at ${userDataDir}, separate from your normal Chrome profile. ` +
22
+ `Run first-time setup, sign in there, then retry: ${setupCommand}. ` +
23
+ "If you want to reuse an already signed-in Chrome instead, use --browser-attach-running.", {
24
+ stage: "browser-login-setup",
25
+ details: {
26
+ profileDir: userDataDir,
27
+ setupCommand,
28
+ sessionStatus: "needs_login",
29
+ },
30
+ reuseProfileHint: setupCommand,
31
+ });
32
+ }
33
+ export async function isManualLoginProfileInitialized(profileDir) {
34
+ const entries = await readdir(profileDir, { withFileTypes: true }).catch(() => []);
35
+ return entries.some((entry) => {
36
+ if (!entry.name)
37
+ return false;
38
+ if (entry.name === "Default" || entry.name === "Local State")
39
+ return true;
40
+ if (entry.name.startsWith("Profile "))
41
+ return true;
42
+ return false;
43
+ });
44
+ }
45
+ export function formatManualLoginSetupCommand(profileDir) {
46
+ return [
47
+ "oracle --engine browser --browser-manual-login --browser-keep-browser",
48
+ `--browser-manual-login-profile-dir ${JSON.stringify(profileDir)}`,
49
+ '-p "HI"',
50
+ ].join(" ");
51
+ }
52
+ export function defaultManualLoginProfileDir() {
53
+ return path.join(os.homedir(), ".oracle", "browser-profile");
54
+ }