@steipete/oracle 0.8.5 → 0.9.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 (49) hide show
  1. package/README.md +99 -5
  2. package/dist/bin/oracle-cli.js +376 -13
  3. package/dist/src/browser/actions/assistantResponse.js +72 -37
  4. package/dist/src/browser/actions/modelSelection.js +60 -8
  5. package/dist/src/browser/actions/navigation.js +2 -1
  6. package/dist/src/browser/actions/promptComposer.js +141 -32
  7. package/dist/src/browser/chromeLifecycle.js +25 -9
  8. package/dist/src/browser/config.js +14 -0
  9. package/dist/src/browser/constants.js +1 -1
  10. package/dist/src/browser/index.js +414 -43
  11. package/dist/src/browser/profileState.js +93 -0
  12. package/dist/src/browser/providerDomFlow.js +17 -0
  13. package/dist/src/browser/providers/chatgptDomProvider.js +49 -0
  14. package/dist/src/browser/providers/geminiDeepThinkDomProvider.js +245 -0
  15. package/dist/src/browser/providers/index.js +2 -0
  16. package/dist/src/cli/browserConfig.js +33 -6
  17. package/dist/src/cli/browserDefaults.js +21 -0
  18. package/dist/src/cli/detach.js +5 -2
  19. package/dist/src/cli/fileSize.js +11 -0
  20. package/dist/src/cli/help.js +3 -3
  21. package/dist/src/cli/markdownBundle.js +5 -1
  22. package/dist/src/cli/options.js +40 -3
  23. package/dist/src/cli/runOptions.js +11 -3
  24. package/dist/src/cli/sessionDisplay.js +91 -2
  25. package/dist/src/cli/sessionLineage.js +56 -0
  26. package/dist/src/cli/sessionRunner.js +169 -2
  27. package/dist/src/cli/sessionTable.js +2 -1
  28. package/dist/src/cli/tui/index.js +3 -0
  29. package/dist/src/gemini-web/browserSessionManager.js +76 -0
  30. package/dist/src/gemini-web/client.js +16 -5
  31. package/dist/src/gemini-web/executionClients.js +1 -0
  32. package/dist/src/gemini-web/executionMode.js +18 -0
  33. package/dist/src/gemini-web/executor.js +273 -120
  34. package/dist/src/mcp/tools/consult.js +35 -21
  35. package/dist/src/oracle/client.js +42 -13
  36. package/dist/src/oracle/config.js +43 -7
  37. package/dist/src/oracle/errors.js +2 -2
  38. package/dist/src/oracle/files.js +20 -5
  39. package/dist/src/oracle/gemini.js +3 -0
  40. package/dist/src/oracle/modelResolver.js +33 -1
  41. package/dist/src/oracle/request.js +7 -2
  42. package/dist/src/oracle/run.js +22 -12
  43. package/dist/src/sessionManager.js +13 -2
  44. package/dist/src/sessionStore.js +2 -2
  45. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  46. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  47. package/package.json +24 -24
  48. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  49. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
@@ -12,6 +12,13 @@ export const DEFAULT_BROWSER_CONFIG = {
12
12
  timeoutMs: 1_200_000,
13
13
  debugPort: null,
14
14
  inputTimeoutMs: 60_000,
15
+ assistantRecheckDelayMs: 0,
16
+ assistantRecheckTimeoutMs: 120_000,
17
+ reuseChromeWaitMs: 10_000,
18
+ profileLockTimeoutMs: 300_000,
19
+ autoReattachDelayMs: 0,
20
+ autoReattachIntervalMs: 0,
21
+ autoReattachTimeoutMs: 120_000,
15
22
  cookieSync: true,
16
23
  cookieNames: null,
17
24
  cookieSyncWaitMs: 0,
@@ -57,6 +64,13 @@ export function resolveBrowserConfig(config) {
57
64
  timeoutMs: config?.timeoutMs ?? DEFAULT_BROWSER_CONFIG.timeoutMs,
58
65
  debugPort: config?.debugPort ?? debugPortEnv ?? DEFAULT_BROWSER_CONFIG.debugPort,
59
66
  inputTimeoutMs: config?.inputTimeoutMs ?? DEFAULT_BROWSER_CONFIG.inputTimeoutMs,
67
+ assistantRecheckDelayMs: config?.assistantRecheckDelayMs ?? DEFAULT_BROWSER_CONFIG.assistantRecheckDelayMs,
68
+ assistantRecheckTimeoutMs: config?.assistantRecheckTimeoutMs ?? DEFAULT_BROWSER_CONFIG.assistantRecheckTimeoutMs,
69
+ reuseChromeWaitMs: config?.reuseChromeWaitMs ?? DEFAULT_BROWSER_CONFIG.reuseChromeWaitMs,
70
+ profileLockTimeoutMs: config?.profileLockTimeoutMs ?? DEFAULT_BROWSER_CONFIG.profileLockTimeoutMs,
71
+ autoReattachDelayMs: config?.autoReattachDelayMs ?? DEFAULT_BROWSER_CONFIG.autoReattachDelayMs,
72
+ autoReattachIntervalMs: config?.autoReattachIntervalMs ?? DEFAULT_BROWSER_CONFIG.autoReattachIntervalMs,
73
+ autoReattachTimeoutMs: config?.autoReattachTimeoutMs ?? DEFAULT_BROWSER_CONFIG.autoReattachTimeoutMs,
60
74
  cookieSync: config?.cookieSync ?? cookieSyncDefault,
61
75
  cookieNames: config?.cookieNames ?? DEFAULT_BROWSER_CONFIG.cookieNames,
62
76
  cookieSyncWaitMs: config?.cookieSyncWaitMs ?? DEFAULT_BROWSER_CONFIG.cookieSyncWaitMs,
@@ -1,5 +1,5 @@
1
1
  export const CHATGPT_URL = 'https://chatgpt.com/';
2
- export const DEFAULT_MODEL_TARGET = 'GPT-5.2 Pro';
2
+ export const DEFAULT_MODEL_TARGET = 'GPT-5.4 Pro';
3
3
  export const DEFAULT_MODEL_STRATEGY = 'select';
4
4
  export const COOKIE_URLS = ['https://chatgpt.com', 'https://chat.openai.com', 'https://atlas.openai.com'];
5
5
  export const INPUT_SELECTORS = [
@@ -5,7 +5,8 @@ import net from 'node:net';
5
5
  import { resolveBrowserConfig } from './config.js';
6
6
  import { launchChrome, registerTerminationHooks, hideChromeWindow, connectToRemoteChrome, closeRemoteChromeTarget, connectWithNewTab, closeTab, } from './chromeLifecycle.js';
7
7
  import { syncCookies } from './cookies.js';
8
- import { navigateToChatGPT, navigateToPromptReadyWithFallback, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, installJavaScriptDialogAutoDismissal, ensureModelSelection, submitPrompt, clearPromptComposer, waitForAssistantResponse, captureAssistantMarkdown, clearComposerAttachments, uploadAttachmentFile, waitForAttachmentCompletion, waitForUserTurnAttachments, readAssistantSnapshot, } from './pageActions.js';
8
+ import { navigateToChatGPT, navigateToPromptReadyWithFallback, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, installJavaScriptDialogAutoDismissal, ensureModelSelection, clearPromptComposer, waitForAssistantResponse, captureAssistantMarkdown, clearComposerAttachments, uploadAttachmentFile, waitForAttachmentCompletion, waitForUserTurnAttachments, readAssistantSnapshot, } from './pageActions.js';
9
+ import { INPUT_SELECTORS } from './constants.js';
9
10
  import { uploadAttachmentViaDataTransfer } from './actions/remoteFileTransfer.js';
10
11
  import { ensureThinkingTime } from './actions/thinkingTime.js';
11
12
  import { estimateTokenCount, withRetries, delay } from './utils.js';
@@ -13,9 +14,22 @@ import { formatElapsed } from '../oracle/format.js';
13
14
  import { CHATGPT_URL, CONVERSATION_TURN_SELECTOR, DEFAULT_MODEL_STRATEGY } from './constants.js';
14
15
  import { BrowserAutomationError } from '../oracle/errors.js';
15
16
  import { alignPromptEchoPair, buildPromptEchoMatcher } from './reattachHelpers.js';
16
- import { cleanupStaleProfileState, readChromePid, readDevToolsPort, shouldCleanupManualLoginProfileState, verifyDevToolsReachable, writeChromePid, writeDevToolsActivePort, } from './profileState.js';
17
+ import { cleanupStaleProfileState, acquireProfileRunLock, readChromePid, readDevToolsPort, shouldCleanupManualLoginProfileState, verifyDevToolsReachable, writeChromePid, writeDevToolsActivePort, } from './profileState.js';
18
+ import { runProviderSubmissionFlow } from './providerDomFlow.js';
19
+ import { chatgptDomProvider } from './providers/index.js';
17
20
  export { CHATGPT_URL, DEFAULT_MODEL_STRATEGY, DEFAULT_MODEL_TARGET } from './constants.js';
18
21
  export { parseDuration, delay, normalizeChatgptUrl, isTemporaryChatUrl } from './utils.js';
22
+ function isCloudflareChallengeError(error) {
23
+ if (!(error instanceof BrowserAutomationError))
24
+ return false;
25
+ return error.details?.stage === 'cloudflare-challenge';
26
+ }
27
+ function shouldPreserveBrowserOnError(error, headless) {
28
+ return !headless && isCloudflareChallengeError(error);
29
+ }
30
+ export function shouldPreserveBrowserOnErrorForTest(error, headless) {
31
+ return shouldPreserveBrowserOnError(error, headless);
32
+ }
19
33
  export async function runBrowserMode(options) {
20
34
  const promptText = options.prompt?.trim();
21
35
  if (!promptText) {
@@ -96,7 +110,9 @@ export async function runBrowserMode(options) {
96
110
  logger(`Created temporary Chrome profile at ${userDataDir}`);
97
111
  }
98
112
  const effectiveKeepBrowser = Boolean(config.keepBrowser);
99
- const reusedChrome = manualLogin ? await maybeReuseRunningChrome(userDataDir, logger) : null;
113
+ const reusedChrome = manualLogin
114
+ ? await maybeReuseRunningChrome(userDataDir, logger, { waitForPortMs: config.reuseChromeWaitMs })
115
+ : null;
100
116
  const chrome = reusedChrome ??
101
117
  (await launchChrome({
102
118
  ...config,
@@ -132,9 +148,15 @@ export async function runBrowserMode(options) {
132
148
  let stopThinkingMonitor = null;
133
149
  let removeDialogHandler = null;
134
150
  let appliedCookies = 0;
151
+ let preserveBrowserOnError = false;
135
152
  try {
136
153
  try {
137
- const connection = await connectWithNewTab(chrome.port, logger, undefined, chromeHost);
154
+ const strictTabIsolation = Boolean(manualLogin && reusedChrome);
155
+ const connection = await connectWithNewTab(chrome.port, logger, undefined, chromeHost, {
156
+ fallbackToDefault: !strictTabIsolation,
157
+ retries: strictTabIsolation ? 3 : 0,
158
+ retryDelayMs: 500,
159
+ });
138
160
  client = connection.client;
139
161
  isolatedTargetId = connection.targetId ?? null;
140
162
  }
@@ -346,6 +368,23 @@ export async function runBrowserMode(options) {
346
368
  },
347
369
  }));
348
370
  }
371
+ const profileLockTimeoutMs = manualLogin ? (config.profileLockTimeoutMs ?? 0) : 0;
372
+ let profileLock = null;
373
+ const acquireProfileLockIfNeeded = async () => {
374
+ if (profileLockTimeoutMs <= 0)
375
+ return;
376
+ profileLock = await acquireProfileRunLock(userDataDir, {
377
+ timeoutMs: profileLockTimeoutMs,
378
+ logger,
379
+ });
380
+ };
381
+ const releaseProfileLockIfHeld = async () => {
382
+ if (!profileLock)
383
+ return;
384
+ const handle = profileLock;
385
+ profileLock = null;
386
+ await handle.release().catch(() => undefined);
387
+ };
349
388
  const submitOnce = async (prompt, submissionAttachments) => {
350
389
  const baselineSnapshot = await readAssistantSnapshot(Runtime).catch(() => null);
351
390
  const baselineAssistantText = typeof baselineSnapshot?.text === 'string' ? baselineSnapshot.text.trim() : '';
@@ -388,17 +427,25 @@ export async function runBrowserMode(options) {
388
427
  let baselineTurns = await readConversationTurnCount(Runtime, logger);
389
428
  // Learned: return baselineTurns so assistant polling can ignore earlier content.
390
429
  const sendAttachmentNames = attachmentWaitTimedOut ? [] : attachmentNames;
391
- const committedTurns = await submitPrompt({
430
+ const providerState = {
392
431
  runtime: Runtime,
393
432
  input: Input,
394
- attachmentNames: sendAttachmentNames,
395
- baselineTurns: baselineTurns ?? undefined,
433
+ logger,
434
+ timeoutMs: config.timeoutMs,
396
435
  inputTimeoutMs: config.inputTimeoutMs ?? undefined,
397
- }, prompt, logger);
398
- if (typeof committedTurns === 'number' && Number.isFinite(committedTurns)) {
399
- if (baselineTurns === null || committedTurns > baselineTurns) {
400
- baselineTurns = Math.max(0, committedTurns - 1);
401
- }
436
+ baselineTurns: baselineTurns ?? undefined,
437
+ attachmentNames: sendAttachmentNames,
438
+ };
439
+ await runProviderSubmissionFlow(chatgptDomProvider, {
440
+ prompt,
441
+ evaluate: async () => undefined,
442
+ delay,
443
+ log: logger,
444
+ state: providerState,
445
+ });
446
+ const providerBaselineTurns = providerState.baselineTurns;
447
+ if (typeof providerBaselineTurns === 'number' && Number.isFinite(providerBaselineTurns)) {
448
+ baselineTurns = providerBaselineTurns;
402
449
  }
403
450
  if (attachmentNames.length > 0) {
404
451
  if (attachmentWaitTimedOut) {
@@ -421,27 +468,33 @@ export async function runBrowserMode(options) {
421
468
  };
422
469
  let baselineTurns = null;
423
470
  let baselineAssistantText = null;
471
+ await acquireProfileLockIfNeeded();
424
472
  try {
425
- const submission = await raceWithDisconnect(submitOnce(promptText, attachments));
426
- baselineTurns = submission.baselineTurns;
427
- baselineAssistantText = submission.baselineAssistantText;
428
- }
429
- catch (error) {
430
- const isPromptTooLarge = error instanceof BrowserAutomationError &&
431
- error.details?.code === 'prompt-too-large';
432
- if (fallbackSubmission && isPromptTooLarge) {
433
- // Learned: when prompts truncate, retry with file uploads so the UI receives the full content.
434
- logger('[browser] Inline prompt too large; retrying with file uploads.');
435
- await raceWithDisconnect(clearPromptComposer(Runtime, logger));
436
- await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
437
- const submission = await raceWithDisconnect(submitOnce(fallbackSubmission.prompt, fallbackSubmission.attachments));
473
+ try {
474
+ const submission = await raceWithDisconnect(submitOnce(promptText, attachments));
438
475
  baselineTurns = submission.baselineTurns;
439
476
  baselineAssistantText = submission.baselineAssistantText;
440
477
  }
441
- else {
442
- throw error;
478
+ catch (error) {
479
+ const isPromptTooLarge = error instanceof BrowserAutomationError &&
480
+ error.details?.code === 'prompt-too-large';
481
+ if (fallbackSubmission && isPromptTooLarge) {
482
+ // Learned: when prompts truncate, retry with file uploads so the UI receives the full content.
483
+ logger('[browser] Inline prompt too large; retrying with file uploads.');
484
+ await raceWithDisconnect(clearPromptComposer(Runtime, logger));
485
+ await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
486
+ const submission = await raceWithDisconnect(submitOnce(fallbackSubmission.prompt, fallbackSubmission.attachments));
487
+ baselineTurns = submission.baselineTurns;
488
+ baselineAssistantText = submission.baselineAssistantText;
489
+ }
490
+ else {
491
+ throw error;
492
+ }
443
493
  }
444
494
  }
495
+ finally {
496
+ await releaseProfileLockIfHeld();
497
+ }
445
498
  stopThinkingMonitor = startThinkingStatusMonitor(Runtime, logger, options.verbose ?? false);
446
499
  // Helper to normalize text for echo detection (collapse whitespace, lowercase)
447
500
  const normalizeForComparison = (text) => text.toLowerCase().replace(/\s+/g, ' ').trim();
@@ -468,7 +521,83 @@ export async function runBrowserMode(options) {
468
521
  }
469
522
  return null;
470
523
  };
471
- let answer = await raceWithDisconnect(waitForAssistantResponseWithReload(Runtime, Page, config.timeoutMs, logger, baselineTurns ?? undefined));
524
+ let answer;
525
+ const recheckDelayMs = Math.max(0, config.assistantRecheckDelayMs ?? 0);
526
+ const recheckTimeoutMs = Math.max(0, config.assistantRecheckTimeoutMs ?? 0);
527
+ const attemptAssistantRecheck = async () => {
528
+ if (!recheckDelayMs)
529
+ return null;
530
+ logger(`[browser] Assistant response timed out; waiting ${formatElapsed(recheckDelayMs)} before rechecking conversation.`);
531
+ await raceWithDisconnect(delay(recheckDelayMs));
532
+ await updateConversationHint('assistant-recheck', 15_000).catch(() => false);
533
+ await captureRuntimeSnapshot().catch(() => undefined);
534
+ const conversationUrl = await readConversationUrl(Runtime);
535
+ if (conversationUrl && isConversationUrl(conversationUrl)) {
536
+ logger(`[browser] Rechecking assistant response at ${conversationUrl}`);
537
+ await raceWithDisconnect(Page.navigate({ url: conversationUrl }));
538
+ await raceWithDisconnect(delay(1000));
539
+ }
540
+ // Validate session before attempting recheck - sessions can expire during the delay
541
+ const sessionValid = await validateChatGPTSession(Runtime, logger);
542
+ if (!sessionValid.valid) {
543
+ logger(`[browser] Session validation failed: ${sessionValid.reason}`);
544
+ // Update session metadata to indicate login is needed
545
+ await emitRuntimeHint();
546
+ throw new BrowserAutomationError(`ChatGPT session expired during recheck: ${sessionValid.reason}. ` +
547
+ `Conversation URL: ${conversationUrl || lastUrl || 'unknown'}. ` +
548
+ `Please sign in and retry.`, {
549
+ stage: 'assistant-recheck',
550
+ details: {
551
+ conversationUrl: conversationUrl || lastUrl || null,
552
+ sessionStatus: 'needs_login',
553
+ validationReason: sessionValid.reason,
554
+ },
555
+ runtime: {
556
+ chromePid: chrome.pid,
557
+ chromePort: chrome.port,
558
+ chromeHost,
559
+ userDataDir,
560
+ chromeTargetId: lastTargetId,
561
+ tabUrl: lastUrl,
562
+ conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
563
+ controllerPid: process.pid,
564
+ },
565
+ });
566
+ }
567
+ const timeoutMs = recheckTimeoutMs > 0 ? recheckTimeoutMs : config.timeoutMs;
568
+ const rechecked = await raceWithDisconnect(waitForAssistantResponseWithReload(Runtime, Page, timeoutMs, logger, baselineTurns ?? undefined));
569
+ logger('Recovered assistant response after delayed recheck');
570
+ return rechecked;
571
+ };
572
+ try {
573
+ answer = await raceWithDisconnect(waitForAssistantResponseWithReload(Runtime, Page, config.timeoutMs, logger, baselineTurns ?? undefined));
574
+ }
575
+ catch (error) {
576
+ if (isAssistantResponseTimeoutError(error)) {
577
+ const rechecked = await attemptAssistantRecheck().catch(() => null);
578
+ if (rechecked) {
579
+ answer = rechecked;
580
+ }
581
+ else {
582
+ await updateConversationHint('assistant-timeout', 15_000).catch(() => false);
583
+ await captureRuntimeSnapshot().catch(() => undefined);
584
+ const runtime = {
585
+ chromePid: chrome.pid,
586
+ chromePort: chrome.port,
587
+ chromeHost,
588
+ userDataDir,
589
+ chromeTargetId: lastTargetId,
590
+ tabUrl: lastUrl,
591
+ conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
592
+ controllerPid: process.pid,
593
+ };
594
+ throw new BrowserAutomationError('Assistant response timed out before completion; reattach later to capture the answer.', { stage: 'assistant-timeout', runtime }, error);
595
+ }
596
+ }
597
+ else {
598
+ throw error;
599
+ }
600
+ }
472
601
  // Ensure we store the final conversation URL even if the UI updated late.
473
602
  await updateConversationHint('post-response', 15_000);
474
603
  const baselineNormalized = baselineAssistantText ? normalizeForComparison(baselineAssistantText) : '';
@@ -624,6 +753,28 @@ export async function runBrowserMode(options) {
624
753
  stopThinkingMonitor?.();
625
754
  const socketClosed = connectionClosedUnexpectedly || isWebSocketClosureError(normalizedError);
626
755
  connectionClosedUnexpectedly = connectionClosedUnexpectedly || socketClosed;
756
+ if (shouldPreserveBrowserOnError(normalizedError, config.headless)) {
757
+ preserveBrowserOnError = true;
758
+ const runtime = {
759
+ chromePid: chrome.pid,
760
+ chromePort: chrome.port,
761
+ chromeHost,
762
+ userDataDir,
763
+ chromeTargetId: lastTargetId,
764
+ tabUrl: lastUrl,
765
+ controllerPid: process.pid,
766
+ };
767
+ const reuseProfileHint = `oracle --engine browser --browser-manual-login ` +
768
+ `--browser-manual-login-profile-dir ${JSON.stringify(userDataDir)}`;
769
+ await emitRuntimeHint();
770
+ logger('Cloudflare challenge detected; leaving browser open so you can complete the check.');
771
+ logger(`Reuse this browser profile with: ${reuseProfileHint}`);
772
+ throw new BrowserAutomationError('Cloudflare challenge detected. Complete the “Just a moment…” check in the open browser, then rerun.', {
773
+ stage: 'cloudflare-challenge',
774
+ runtime,
775
+ reuseProfileHint,
776
+ }, normalizedError);
777
+ }
627
778
  if (!socketClosed) {
628
779
  logger(`Failed to complete ChatGPT run: ${normalizedError.message}`);
629
780
  if ((config.debug || process.env.CHATGPT_DEVTOOLS_TRACE === '1') && normalizedError.stack) {
@@ -658,12 +809,16 @@ export async function runBrowserMode(options) {
658
809
  catch {
659
810
  // ignore
660
811
  }
661
- if (!effectiveKeepBrowser && isolatedTargetId && chrome?.port) {
812
+ // Close the isolated tab once the response has been fully captured to prevent
813
+ // tab accumulation across repeated runs. Keep the tab open on incomplete runs
814
+ // so reattach can recover the response.
815
+ if (runStatus === 'complete' && isolatedTargetId && chrome?.port) {
662
816
  await closeTab(chrome.port, isolatedTargetId, logger, chromeHost).catch(() => undefined);
663
817
  }
664
818
  removeDialogHandler?.();
665
819
  removeTerminationHooks?.();
666
- if (!effectiveKeepBrowser) {
820
+ const keepBrowserOpen = effectiveKeepBrowser || preserveBrowserOnError;
821
+ if (!keepBrowserOpen) {
667
822
  if (!connectionClosedUnexpectedly) {
668
823
  try {
669
824
  await chrome.kill();
@@ -817,11 +972,20 @@ async function _assertNavigatedToHttp(runtime, _logger, timeoutMs = 10_000) {
817
972
  details: { url: lastUrl || '(empty)' },
818
973
  });
819
974
  }
820
- async function maybeReuseRunningChrome(userDataDir, logger) {
821
- const port = await readDevToolsPort(userDataDir);
975
+ async function maybeReuseRunningChrome(userDataDir, logger, options = {}) {
976
+ const waitForPortMs = Math.max(0, options.waitForPortMs ?? 0);
977
+ let port = await readDevToolsPort(userDataDir);
978
+ if (!port && waitForPortMs > 0) {
979
+ const deadline = Date.now() + waitForPortMs;
980
+ logger(`Waiting up to ${formatElapsed(waitForPortMs)} for shared Chrome to appear...`);
981
+ while (!port && Date.now() < deadline) {
982
+ await delay(250);
983
+ port = await readDevToolsPort(userDataDir);
984
+ }
985
+ }
822
986
  if (!port)
823
987
  return null;
824
- const probe = await verifyDevToolsReachable({ port });
988
+ const probe = await (options.probe ?? verifyDevToolsReachable)({ port });
825
989
  if (!probe.ok) {
826
990
  logger(`DevToolsActivePort found for ${userDataDir} but unreachable (${probe.error}); launching new Chrome.`);
827
991
  // Safe cleanup: remove stale DevToolsActivePort; only remove lock files if this was an Oracle-owned pid that died.
@@ -961,17 +1125,25 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
961
1125
  logger('All attachments uploaded');
962
1126
  }
963
1127
  let baselineTurns = await readConversationTurnCount(Runtime, logger);
964
- const committedTurns = await submitPrompt({
1128
+ const providerState = {
965
1129
  runtime: Runtime,
966
1130
  input: Input,
967
- attachmentNames,
968
- baselineTurns: baselineTurns ?? undefined,
1131
+ logger,
1132
+ timeoutMs: config.timeoutMs,
969
1133
  inputTimeoutMs: config.inputTimeoutMs ?? undefined,
970
- }, prompt, logger);
971
- if (typeof committedTurns === 'number' && Number.isFinite(committedTurns)) {
972
- if (baselineTurns === null || committedTurns > baselineTurns) {
973
- baselineTurns = Math.max(0, committedTurns - 1);
974
- }
1134
+ baselineTurns: baselineTurns ?? undefined,
1135
+ attachmentNames,
1136
+ };
1137
+ await runProviderSubmissionFlow(chatgptDomProvider, {
1138
+ prompt,
1139
+ evaluate: async () => undefined,
1140
+ delay,
1141
+ log: logger,
1142
+ state: providerState,
1143
+ });
1144
+ const providerBaselineTurns = providerState.baselineTurns;
1145
+ if (typeof providerBaselineTurns === 'number' && Number.isFinite(providerBaselineTurns)) {
1146
+ baselineTurns = providerBaselineTurns;
975
1147
  }
976
1148
  return { baselineTurns, baselineAssistantText };
977
1149
  };
@@ -1023,7 +1195,87 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
1023
1195
  }
1024
1196
  return null;
1025
1197
  };
1026
- let answer = await waitForAssistantResponseWithReload(Runtime, Page, config.timeoutMs, logger, baselineTurns ?? undefined);
1198
+ let answer;
1199
+ const recheckDelayMs = Math.max(0, config.assistantRecheckDelayMs ?? 0);
1200
+ const recheckTimeoutMs = Math.max(0, config.assistantRecheckTimeoutMs ?? 0);
1201
+ const attemptAssistantRecheck = async () => {
1202
+ if (!recheckDelayMs)
1203
+ return null;
1204
+ logger(`[browser] Assistant response timed out; waiting ${formatElapsed(recheckDelayMs)} before rechecking conversation.`);
1205
+ await delay(recheckDelayMs);
1206
+ const conversationUrl = await readConversationUrl(Runtime);
1207
+ if (conversationUrl && isConversationUrl(conversationUrl)) {
1208
+ lastUrl = conversationUrl;
1209
+ logger(`[browser] Rechecking assistant response at ${conversationUrl}`);
1210
+ await Page.navigate({ url: conversationUrl });
1211
+ await delay(1000);
1212
+ }
1213
+ // Validate session before attempting recheck - sessions can expire during the delay
1214
+ const sessionValid = await validateChatGPTSession(Runtime, logger);
1215
+ if (!sessionValid.valid) {
1216
+ logger(`[browser] Session validation failed: ${sessionValid.reason}`);
1217
+ // Update session metadata to indicate login is needed
1218
+ await emitRuntimeHint();
1219
+ throw new BrowserAutomationError(`ChatGPT session expired during recheck: ${sessionValid.reason}. ` +
1220
+ `Conversation URL: ${conversationUrl || lastUrl || 'unknown'}. ` +
1221
+ `Please sign in and retry.`, {
1222
+ stage: 'assistant-recheck',
1223
+ details: {
1224
+ conversationUrl: conversationUrl || lastUrl || null,
1225
+ sessionStatus: 'needs_login',
1226
+ validationReason: sessionValid.reason,
1227
+ },
1228
+ runtime: {
1229
+ chromeHost: host,
1230
+ chromePort: port,
1231
+ chromeTargetId: remoteTargetId ?? undefined,
1232
+ tabUrl: lastUrl,
1233
+ conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
1234
+ controllerPid: process.pid,
1235
+ },
1236
+ });
1237
+ }
1238
+ await emitRuntimeHint();
1239
+ const timeoutMs = recheckTimeoutMs > 0 ? recheckTimeoutMs : config.timeoutMs;
1240
+ const rechecked = await waitForAssistantResponseWithReload(Runtime, Page, timeoutMs, logger, baselineTurns ?? undefined);
1241
+ logger('Recovered assistant response after delayed recheck');
1242
+ return rechecked;
1243
+ };
1244
+ try {
1245
+ answer = await waitForAssistantResponseWithReload(Runtime, Page, config.timeoutMs, logger, baselineTurns ?? undefined);
1246
+ }
1247
+ catch (error) {
1248
+ if (isAssistantResponseTimeoutError(error)) {
1249
+ const rechecked = await attemptAssistantRecheck().catch(() => null);
1250
+ if (rechecked) {
1251
+ answer = rechecked;
1252
+ }
1253
+ else {
1254
+ try {
1255
+ const conversationUrl = await readConversationUrl(Runtime);
1256
+ if (conversationUrl) {
1257
+ lastUrl = conversationUrl;
1258
+ }
1259
+ }
1260
+ catch {
1261
+ // ignore
1262
+ }
1263
+ await emitRuntimeHint();
1264
+ const runtime = {
1265
+ chromePort: port,
1266
+ chromeHost: host,
1267
+ chromeTargetId: remoteTargetId ?? undefined,
1268
+ tabUrl: lastUrl,
1269
+ conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
1270
+ controllerPid: process.pid,
1271
+ };
1272
+ throw new BrowserAutomationError('Assistant response timed out before completion; reattach later to capture the answer.', { stage: 'assistant-timeout', runtime }, error);
1273
+ }
1274
+ }
1275
+ else {
1276
+ throw error;
1277
+ }
1278
+ }
1027
1279
  const baselineNormalized = baselineAssistantText ? normalizeForComparison(baselineAssistantText) : '';
1028
1280
  if (baselineNormalized) {
1029
1281
  const normalizedAnswer = normalizeForComparison(answer.text ?? '');
@@ -1178,11 +1430,15 @@ export { estimateTokenCount } from './utils.js';
1178
1430
  export { resolveBrowserConfig, DEFAULT_BROWSER_CONFIG } from './config.js';
1179
1431
  export { syncCookies } from './cookies.js';
1180
1432
  export { navigateToChatGPT, ensureNotBlocked, ensurePromptReady, ensureModelSelection, submitPrompt, waitForAssistantResponse, captureAssistantMarkdown, uploadAttachmentFile, waitForAttachmentCompletion, } from './pageActions.js';
1181
- function isWebSocketClosureError(error) {
1433
+ export async function maybeReuseRunningChromeForTest(userDataDir, logger, options = {}) {
1434
+ return maybeReuseRunningChrome(userDataDir, logger, options);
1435
+ }
1436
+ export function isWebSocketClosureError(error) {
1182
1437
  const message = error.message.toLowerCase();
1183
1438
  return (message.includes('websocket connection closed') ||
1184
1439
  message.includes('websocket is closed') ||
1185
1440
  message.includes('websocket error') ||
1441
+ message.includes('inspected target navigated or closed') ||
1186
1442
  message.includes('target closed'));
1187
1443
  }
1188
1444
  export function formatThinkingLog(startedAt, now, message, locatorSuffix) {
@@ -1222,6 +1478,17 @@ function shouldReloadAfterAssistantError(error) {
1222
1478
  message.includes('timeout') ||
1223
1479
  message.includes('capture assistant response'));
1224
1480
  }
1481
+ function isAssistantResponseTimeoutError(error) {
1482
+ if (!(error instanceof Error))
1483
+ return false;
1484
+ const message = error.message.toLowerCase();
1485
+ if (!message)
1486
+ return false;
1487
+ return (message.includes('assistant-response') ||
1488
+ message.includes('assistant response') ||
1489
+ message.includes('watchdog') ||
1490
+ message.includes('capture assistant response'));
1491
+ }
1225
1492
  async function readConversationUrl(Runtime) {
1226
1493
  try {
1227
1494
  const currentUrl = await Runtime.evaluate({ expression: 'location.href', returnByValue: true });
@@ -1231,6 +1498,110 @@ async function readConversationUrl(Runtime) {
1231
1498
  return null;
1232
1499
  }
1233
1500
  }
1501
+ /**
1502
+ * Validates that the ChatGPT session is still active by checking for login CTAs
1503
+ * and textarea availability. Sessions can expire during long delays (e.g., recheck).
1504
+ *
1505
+ * @param Runtime - Chrome Runtime client
1506
+ * @param logger - Browser logger for diagnostics
1507
+ * @returns SessionValidationResult indicating if session is valid and reason if not
1508
+ */
1509
+ async function validateChatGPTSession(Runtime, logger) {
1510
+ try {
1511
+ const outcome = await Runtime.evaluate({
1512
+ expression: buildSessionValidationExpression(),
1513
+ awaitPromise: true,
1514
+ returnByValue: true,
1515
+ });
1516
+ const result = outcome.result?.value;
1517
+ if (!result) {
1518
+ return { valid: false, reason: 'Failed to evaluate session state' };
1519
+ }
1520
+ if (result.onAuthPage) {
1521
+ return { valid: false, reason: 'Redirected to auth page' };
1522
+ }
1523
+ if (result.hasLoginCta) {
1524
+ return { valid: false, reason: 'Login button detected on page' };
1525
+ }
1526
+ if (!result.hasTextarea) {
1527
+ return { valid: false, reason: 'Prompt textarea not available' };
1528
+ }
1529
+ return { valid: true };
1530
+ }
1531
+ catch (error) {
1532
+ const message = error instanceof Error ? error.message : String(error);
1533
+ logger(`[browser] Session validation error: ${message}`);
1534
+ return { valid: false, reason: `Validation error: ${message}` };
1535
+ }
1536
+ }
1537
+ function buildSessionValidationExpression() {
1538
+ const selectorLiteral = JSON.stringify(INPUT_SELECTORS);
1539
+ return `(async () => {
1540
+ const pageUrl = typeof location === 'object' && location?.href ? location.href : null;
1541
+ const onAuthPage =
1542
+ typeof location === 'object' &&
1543
+ typeof location.pathname === 'string' &&
1544
+ /^\\/(auth|login|signin)/i.test(location.pathname);
1545
+
1546
+ // Check for login CTAs (similar to ensureLoggedIn logic)
1547
+ const hasLoginCta = (() => {
1548
+ const candidates = Array.from(
1549
+ document.querySelectorAll(
1550
+ [
1551
+ 'a[href*="/auth/login"]',
1552
+ 'a[href*="/auth/signin"]',
1553
+ 'button[type="submit"]',
1554
+ 'button[data-testid*="login"]',
1555
+ 'button[data-testid*="log-in"]',
1556
+ 'button[data-testid*="sign-in"]',
1557
+ 'button[data-testid*="signin"]',
1558
+ 'button',
1559
+ 'a',
1560
+ ].join(','),
1561
+ ),
1562
+ );
1563
+ const textMatches = (text) => {
1564
+ if (!text) return false;
1565
+ const normalized = text.toLowerCase().trim();
1566
+ return ['log in', 'login', 'sign in', 'signin', 'continue with'].some((needle) =>
1567
+ normalized.startsWith(needle),
1568
+ );
1569
+ };
1570
+ for (const node of candidates) {
1571
+ if (!(node instanceof HTMLElement)) continue;
1572
+ const label =
1573
+ node.textContent?.trim() ||
1574
+ node.getAttribute('aria-label') ||
1575
+ node.getAttribute('title') ||
1576
+ '';
1577
+ if (textMatches(label)) {
1578
+ return true;
1579
+ }
1580
+ }
1581
+ return false;
1582
+ })();
1583
+
1584
+ // Check for textarea availability
1585
+ const hasTextarea = (() => {
1586
+ const selectors = ${selectorLiteral};
1587
+ for (const selector of selectors) {
1588
+ const node = document.querySelector(selector);
1589
+ if (node) {
1590
+ return true;
1591
+ }
1592
+ }
1593
+ return false;
1594
+ })();
1595
+
1596
+ return {
1597
+ valid: !onAuthPage && !hasLoginCta && hasTextarea,
1598
+ hasLoginCta,
1599
+ hasTextarea,
1600
+ onAuthPage,
1601
+ pageUrl,
1602
+ };
1603
+ })()`;
1604
+ }
1234
1605
  async function readConversationTurnCount(Runtime, logger) {
1235
1606
  const selectorLiteral = JSON.stringify(CONVERSATION_TURN_SELECTOR);
1236
1607
  const attempts = 4;