@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.
- package/README.md +99 -5
- package/dist/bin/oracle-cli.js +376 -13
- package/dist/src/browser/actions/assistantResponse.js +72 -37
- package/dist/src/browser/actions/modelSelection.js +60 -8
- package/dist/src/browser/actions/navigation.js +2 -1
- package/dist/src/browser/actions/promptComposer.js +141 -32
- package/dist/src/browser/chromeLifecycle.js +25 -9
- package/dist/src/browser/config.js +14 -0
- package/dist/src/browser/constants.js +1 -1
- package/dist/src/browser/index.js +414 -43
- package/dist/src/browser/profileState.js +93 -0
- package/dist/src/browser/providerDomFlow.js +17 -0
- package/dist/src/browser/providers/chatgptDomProvider.js +49 -0
- package/dist/src/browser/providers/geminiDeepThinkDomProvider.js +245 -0
- package/dist/src/browser/providers/index.js +2 -0
- package/dist/src/cli/browserConfig.js +33 -6
- package/dist/src/cli/browserDefaults.js +21 -0
- package/dist/src/cli/detach.js +5 -2
- package/dist/src/cli/fileSize.js +11 -0
- package/dist/src/cli/help.js +3 -3
- package/dist/src/cli/markdownBundle.js +5 -1
- package/dist/src/cli/options.js +40 -3
- package/dist/src/cli/runOptions.js +11 -3
- package/dist/src/cli/sessionDisplay.js +91 -2
- package/dist/src/cli/sessionLineage.js +56 -0
- package/dist/src/cli/sessionRunner.js +169 -2
- package/dist/src/cli/sessionTable.js +2 -1
- package/dist/src/cli/tui/index.js +3 -0
- package/dist/src/gemini-web/browserSessionManager.js +76 -0
- package/dist/src/gemini-web/client.js +16 -5
- package/dist/src/gemini-web/executionClients.js +1 -0
- package/dist/src/gemini-web/executionMode.js +18 -0
- package/dist/src/gemini-web/executor.js +273 -120
- package/dist/src/mcp/tools/consult.js +35 -21
- package/dist/src/oracle/client.js +42 -13
- package/dist/src/oracle/config.js +43 -7
- package/dist/src/oracle/errors.js +2 -2
- package/dist/src/oracle/files.js +20 -5
- package/dist/src/oracle/gemini.js +3 -0
- package/dist/src/oracle/modelResolver.js +33 -1
- package/dist/src/oracle/request.js +7 -2
- package/dist/src/oracle/run.js +22 -12
- package/dist/src/sessionManager.js +13 -2
- package/dist/src/sessionStore.js +2 -2
- package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
- package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
- package/package.json +24 -24
- package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
- 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
|
+
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,
|
|
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
|
|
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
|
|
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
|
|
430
|
+
const providerState = {
|
|
392
431
|
runtime: Runtime,
|
|
393
432
|
input: Input,
|
|
394
|
-
|
|
395
|
-
|
|
433
|
+
logger,
|
|
434
|
+
timeoutMs: config.timeoutMs,
|
|
396
435
|
inputTimeoutMs: config.inputTimeoutMs ?? undefined,
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
-
|
|
426
|
-
|
|
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
|
-
|
|
442
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1128
|
+
const providerState = {
|
|
965
1129
|
runtime: Runtime,
|
|
966
1130
|
input: Input,
|
|
967
|
-
|
|
968
|
-
|
|
1131
|
+
logger,
|
|
1132
|
+
timeoutMs: config.timeoutMs,
|
|
969
1133
|
inputTimeoutMs: config.inputTimeoutMs ?? undefined,
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
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
|
|
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
|
|
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;
|