@steipete/oracle 0.9.0 → 0.11.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/LICENSE +1 -1
- package/README.md +107 -49
- package/dist/bin/oracle-cli.js +551 -410
- package/dist/bin/oracle-mcp.js +2 -2
- package/dist/bin/oracle.js +165 -279
- package/dist/scripts/agent-send.js +31 -31
- package/dist/scripts/check.js +6 -6
- package/dist/scripts/debug/extract-chatgpt-response.js +10 -10
- package/dist/scripts/docs-list.js +30 -30
- package/dist/scripts/git-policy.js +25 -23
- package/dist/scripts/run-cli.js +8 -8
- package/dist/scripts/runner.js +203 -195
- package/dist/scripts/test-browser.js +21 -18
- package/dist/scripts/test-remote-chrome.js +20 -20
- package/dist/src/bridge/connection.js +18 -18
- package/dist/src/bridge/userConfigFile.js +7 -7
- package/dist/src/browser/actions/archiveConversation.js +224 -0
- package/dist/src/browser/actions/assistantResponse.js +175 -101
- package/dist/src/browser/actions/attachmentDataTransfer.js +49 -47
- package/dist/src/browser/actions/attachments.js +246 -150
- package/dist/src/browser/actions/deepResearch.js +662 -0
- package/dist/src/browser/actions/domEvents.js +2 -2
- package/dist/src/browser/actions/modelSelection.js +342 -119
- package/dist/src/browser/actions/navigation.js +183 -137
- package/dist/src/browser/actions/projectSources.js +491 -0
- package/dist/src/browser/actions/promptComposer.js +152 -91
- package/dist/src/browser/actions/remoteFileTransfer.js +10 -10
- package/dist/src/browser/actions/thinkingStatus.js +391 -0
- package/dist/src/browser/actions/thinkingTime.js +207 -110
- package/dist/src/browser/artifacts.js +150 -0
- package/dist/src/browser/attachRunning.js +31 -0
- package/dist/src/browser/chatgptImages.js +315 -0
- package/dist/src/browser/chromeLifecycle.js +276 -63
- package/dist/src/browser/config.js +59 -16
- package/dist/src/browser/constants.js +25 -12
- package/dist/src/browser/controlPlan.js +81 -0
- package/dist/src/browser/cookies.js +19 -19
- package/dist/src/browser/detect.js +250 -77
- package/dist/src/browser/domDebug.js +50 -1
- package/dist/src/browser/index.js +1559 -692
- package/dist/src/browser/liveTabs.js +434 -0
- package/dist/src/browser/modelStrategy.js +1 -1
- package/dist/src/browser/pageActions.js +5 -5
- package/dist/src/browser/policies.js +16 -13
- package/dist/src/browser/profileState.js +127 -42
- package/dist/src/browser/projectSourcesRunner.js +366 -0
- package/dist/src/browser/prompt.js +72 -42
- package/dist/src/browser/promptSummary.js +5 -5
- package/dist/src/browser/providerDomFlow.js +1 -1
- package/dist/src/browser/providers/chatgptDomProvider.js +9 -9
- package/dist/src/browser/providers/geminiDeepThinkDomProvider.js +51 -42
- package/dist/src/browser/providers/index.js +2 -2
- package/dist/src/browser/reattach.js +178 -73
- package/dist/src/browser/reattachHelpers.js +32 -27
- package/dist/src/browser/sessionRunner.js +89 -25
- package/dist/src/browser/tabLeaseRegistry.js +182 -0
- package/dist/src/browser/utils.js +9 -9
- package/dist/src/browserMode.js +1 -1
- package/dist/src/cli/bridge/claudeConfig.js +24 -20
- package/dist/src/cli/bridge/client.js +28 -20
- package/dist/src/cli/bridge/codexConfig.js +16 -16
- package/dist/src/cli/bridge/doctor.js +47 -39
- package/dist/src/cli/bridge/host.js +58 -56
- package/dist/src/cli/browserConfig.js +102 -48
- package/dist/src/cli/browserDefaults.js +51 -26
- package/dist/src/cli/browserTabs.js +228 -0
- package/dist/src/cli/bundleWarnings.js +1 -1
- package/dist/src/cli/clipboard.js +11 -2
- package/dist/src/cli/detach.js +2 -2
- package/dist/src/cli/dryRun.js +62 -26
- package/dist/src/cli/duplicatePromptGuard.js +12 -4
- package/dist/src/cli/engine.js +9 -9
- package/dist/src/cli/errorUtils.js +1 -1
- package/dist/src/cli/fileSize.js +3 -3
- package/dist/src/cli/format.js +2 -2
- package/dist/src/cli/help.js +28 -28
- package/dist/src/cli/hiddenAliases.js +3 -3
- package/dist/src/cli/markdownBundle.js +7 -7
- package/dist/src/cli/markdownRenderer.js +15 -15
- package/dist/src/cli/notifier.js +77 -67
- package/dist/src/cli/options.js +131 -106
- package/dist/src/cli/oscUtils.js +1 -1
- package/dist/src/cli/projectSources.js +116 -0
- package/dist/src/cli/promptRequirement.js +2 -2
- package/dist/src/cli/renderOutput.js +1 -1
- package/dist/src/cli/rootAlias.js +1 -1
- package/dist/src/cli/runOptions.js +32 -28
- package/dist/src/cli/sessionCommand.js +82 -21
- package/dist/src/cli/sessionDisplay.js +213 -87
- package/dist/src/cli/sessionLineage.js +6 -2
- package/dist/src/cli/sessionRunner.js +149 -95
- package/dist/src/cli/sessionTable.js +26 -23
- package/dist/src/cli/stdin.js +22 -0
- package/dist/src/cli/tagline.js +121 -124
- package/dist/src/cli/tui/index.js +139 -128
- package/dist/src/cli/writeOutputPath.js +5 -5
- package/dist/src/config.js +7 -7
- package/dist/src/gemini-web/browserSessionManager.js +19 -15
- package/dist/src/gemini-web/client.js +76 -70
- package/dist/src/gemini-web/executionMode.js +6 -8
- package/dist/src/gemini-web/executor.js +98 -93
- package/dist/src/gemini-web/index.js +1 -1
- package/dist/src/mcp/consultPresets.js +19 -0
- package/dist/src/mcp/server.js +18 -12
- package/dist/src/mcp/tools/consult.js +246 -67
- package/dist/src/mcp/tools/projectSources.js +123 -0
- package/dist/src/mcp/tools/sessionResources.js +12 -12
- package/dist/src/mcp/tools/sessions.js +26 -17
- package/dist/src/mcp/types.js +12 -5
- package/dist/src/mcp/utils.js +21 -8
- package/dist/src/oracle/background.js +15 -15
- package/dist/src/oracle/claude.js +53 -25
- package/dist/src/oracle/client.js +50 -41
- package/dist/src/oracle/config.js +96 -66
- package/dist/src/oracle/errors.js +38 -38
- package/dist/src/oracle/files.js +55 -46
- package/dist/src/oracle/finishLine.js +10 -8
- package/dist/src/oracle/format.js +3 -3
- package/dist/src/oracle/gemini.js +37 -33
- package/dist/src/oracle/logging.js +7 -7
- package/dist/src/oracle/markdown.js +28 -28
- package/dist/src/oracle/modelResolver.js +16 -16
- package/dist/src/oracle/multiModelRunner.js +12 -12
- package/dist/src/oracle/oscProgress.js +8 -8
- package/dist/src/oracle/promptAssembly.js +6 -3
- package/dist/src/oracle/request.js +16 -13
- package/dist/src/oracle/run.js +160 -135
- package/dist/src/oracle/runUtils.js +8 -5
- package/dist/src/oracle/tokenEstimate.js +6 -6
- package/dist/src/oracle/tokenStats.js +5 -5
- package/dist/src/oracle/tokenStringifier.js +5 -5
- package/dist/src/oracle.js +12 -12
- package/dist/src/oracleHome.js +3 -3
- package/dist/src/projectSources/plan.js +27 -0
- package/dist/src/projectSources/url.js +23 -0
- package/dist/src/remote/client.js +25 -25
- package/dist/src/remote/health.js +20 -20
- package/dist/src/remote/remoteServiceConfig.js +9 -9
- package/dist/src/remote/server.js +129 -118
- package/dist/src/sessionManager.js +78 -75
- package/dist/src/sessionStore.js +3 -3
- package/dist/src/version.js +10 -10
- package/dist/vendor/oracle-notifier/README.md +2 -0
- package/package.json +67 -62
- package/vendor/oracle-notifier/README.md +2 -0
- package/dist/markdansi/types/index.js +0 -4
- package/dist/oracle/bin/oracle-cli.js +0 -472
- package/dist/oracle/src/browser/actions/assistantResponse.js +0 -471
- package/dist/oracle/src/browser/actions/attachments.js +0 -82
- package/dist/oracle/src/browser/actions/modelSelection.js +0 -190
- package/dist/oracle/src/browser/actions/navigation.js +0 -75
- package/dist/oracle/src/browser/actions/promptComposer.js +0 -167
- package/dist/oracle/src/browser/chromeLifecycle.js +0 -104
- package/dist/oracle/src/browser/config.js +0 -33
- package/dist/oracle/src/browser/constants.js +0 -40
- package/dist/oracle/src/browser/cookies.js +0 -210
- package/dist/oracle/src/browser/domDebug.js +0 -36
- package/dist/oracle/src/browser/index.js +0 -331
- package/dist/oracle/src/browser/pageActions.js +0 -5
- package/dist/oracle/src/browser/prompt.js +0 -88
- package/dist/oracle/src/browser/promptSummary.js +0 -20
- package/dist/oracle/src/browser/sessionRunner.js +0 -80
- package/dist/oracle/src/browser/utils.js +0 -62
- package/dist/oracle/src/browserMode.js +0 -1
- package/dist/oracle/src/cli/browserConfig.js +0 -44
- package/dist/oracle/src/cli/dryRun.js +0 -59
- package/dist/oracle/src/cli/engine.js +0 -17
- package/dist/oracle/src/cli/errorUtils.js +0 -9
- package/dist/oracle/src/cli/help.js +0 -70
- package/dist/oracle/src/cli/markdownRenderer.js +0 -15
- package/dist/oracle/src/cli/options.js +0 -103
- package/dist/oracle/src/cli/promptRequirement.js +0 -14
- package/dist/oracle/src/cli/rootAlias.js +0 -30
- package/dist/oracle/src/cli/sessionCommand.js +0 -77
- package/dist/oracle/src/cli/sessionDisplay.js +0 -270
- package/dist/oracle/src/cli/sessionRunner.js +0 -94
- package/dist/oracle/src/heartbeat.js +0 -43
- package/dist/oracle/src/oracle/client.js +0 -48
- package/dist/oracle/src/oracle/config.js +0 -29
- package/dist/oracle/src/oracle/errors.js +0 -101
- package/dist/oracle/src/oracle/files.js +0 -220
- package/dist/oracle/src/oracle/format.js +0 -33
- package/dist/oracle/src/oracle/fsAdapter.js +0 -7
- package/dist/oracle/src/oracle/oscProgress.js +0 -60
- package/dist/oracle/src/oracle/request.js +0 -48
- package/dist/oracle/src/oracle/run.js +0 -444
- package/dist/oracle/src/oracle/tokenStats.js +0 -39
- package/dist/oracle/src/oracle/types.js +0 -1
- package/dist/oracle/src/oracle.js +0 -9
- package/dist/oracle/src/sessionManager.js +0 -205
- package/dist/oracle/src/version.js +0 -39
- package/dist/scripts/chrome/browser-tools.js +0 -295
- package/dist/src/browser/profileSync.js +0 -141
- /package/dist/{oracle/src/browser → src/projectSources}/types.js +0 -0
|
@@ -1,43 +1,317 @@
|
|
|
1
|
-
import { mkdtemp, rm, mkdir } from
|
|
2
|
-
import path from
|
|
3
|
-
import os from
|
|
4
|
-
import net from
|
|
5
|
-
import { resolveBrowserConfig } from
|
|
6
|
-
import { launchChrome, registerTerminationHooks, hideChromeWindow, connectToRemoteChrome,
|
|
7
|
-
import { syncCookies } from
|
|
8
|
-
import { navigateToChatGPT, navigateToPromptReadyWithFallback, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, installJavaScriptDialogAutoDismissal, ensureModelSelection, clearPromptComposer, waitForAssistantResponse, captureAssistantMarkdown, clearComposerAttachments, uploadAttachmentFile, waitForAttachmentCompletion, waitForUserTurnAttachments, readAssistantSnapshot, } from
|
|
9
|
-
import { INPUT_SELECTORS } from
|
|
10
|
-
import { uploadAttachmentViaDataTransfer } from
|
|
11
|
-
import { ensureThinkingTime } from
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
|
|
21
|
-
|
|
1
|
+
import { mkdtemp, rm, mkdir } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import net from "node:net";
|
|
5
|
+
import { resolveBrowserConfig } from "./config.js";
|
|
6
|
+
import { launchChrome, registerTerminationHooks, hideChromeWindow, connectToRemoteChrome, connectWithNewTab, closeTab, closeRemoteChromeTarget, closeBlankChromeTabs, } from "./chromeLifecycle.js";
|
|
7
|
+
import { syncCookies } from "./cookies.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";
|
|
10
|
+
import { uploadAttachmentViaDataTransfer } from "./actions/remoteFileTransfer.js";
|
|
11
|
+
import { ensureThinkingTime } from "./actions/thinkingTime.js";
|
|
12
|
+
import { startThinkingStatusMonitor } from "./actions/thinkingStatus.js";
|
|
13
|
+
import { activateDeepResearch, waitForDeepResearchCompletion, waitForResearchPlanAutoConfirm, } from "./actions/deepResearch.js";
|
|
14
|
+
import { estimateTokenCount, withRetries, delay } from "./utils.js";
|
|
15
|
+
import { formatElapsed } from "../oracle/format.js";
|
|
16
|
+
import { CHATGPT_URL, CONVERSATION_TURN_SELECTOR, DEFAULT_MODEL_STRATEGY } from "./constants.js";
|
|
17
|
+
import { BrowserAutomationError } from "../oracle/errors.js";
|
|
18
|
+
import { alignPromptEchoPair, buildPromptEchoMatcher } from "./reattachHelpers.js";
|
|
19
|
+
import { cleanupStaleProfileState, acquireProfileRunLock, findRunningChromeDebugTargetForProfile, readChromePid, readDevToolsPort, shouldCleanupManualLoginProfileState, terminateRecordedChromeForProfile, verifyDevToolsReachable, writeChromePid, writeDevToolsActivePort, } from "./profileState.js";
|
|
20
|
+
import { acquireBrowserTabLease, hasOtherActiveBrowserTabLeases, } from "./tabLeaseRegistry.js";
|
|
21
|
+
import { appendArtifacts, saveBrowserTranscriptArtifact, saveDeepResearchReportArtifact, } from "./artifacts.js";
|
|
22
|
+
import { collectGeneratedImageArtifacts } from "./chatgptImages.js";
|
|
23
|
+
import { runProviderSubmissionFlow } from "./providerDomFlow.js";
|
|
24
|
+
import { chatgptDomProvider } from "./providers/index.js";
|
|
25
|
+
import { resolveAttachRunningConnection } from "./attachRunning.js";
|
|
26
|
+
import { connectToExistingChatGptTab } from "./liveTabs.js";
|
|
27
|
+
import { captureBrowserDiagnostics } from "./domDebug.js";
|
|
28
|
+
import { archiveChatGptConversation, resolveBrowserArchiveDecision, } from "./actions/archiveConversation.js";
|
|
29
|
+
import { describeBrowserControlPlan, formatBrowserControlPlan } from "./controlPlan.js";
|
|
30
|
+
export { CHATGPT_URL, DEFAULT_MODEL_STRATEGY, DEFAULT_MODEL_TARGET } from "./constants.js";
|
|
31
|
+
export { parseDuration, delay, normalizeChatgptUrl, isTemporaryChatUrl } from "./utils.js";
|
|
32
|
+
export { formatThinkingLog, formatThinkingWaitingLog, buildThinkingStatusExpressionForTest, readThinkingStatusForTest, sanitizeThinkingText, startThinkingStatusMonitorForTest, } from "./actions/thinkingStatus.js";
|
|
33
|
+
function redactBrowserConfigForDebugLog(config) {
|
|
34
|
+
const redacted = { ...config };
|
|
35
|
+
if (Array.isArray(config.inlineCookies)) {
|
|
36
|
+
redacted.inlineCookies = `[redacted:${config.inlineCookies.length} cookies]`;
|
|
37
|
+
redacted.inlineCookieCount = config.inlineCookies.length;
|
|
38
|
+
}
|
|
39
|
+
return redacted;
|
|
40
|
+
}
|
|
41
|
+
export function redactBrowserConfigForDebugLogForTest(config) {
|
|
42
|
+
return redactBrowserConfigForDebugLog(config);
|
|
43
|
+
}
|
|
22
44
|
function isCloudflareChallengeError(error) {
|
|
23
45
|
if (!(error instanceof BrowserAutomationError))
|
|
24
46
|
return false;
|
|
25
|
-
return error.details?.stage ===
|
|
47
|
+
return error.details?.stage === "cloudflare-challenge";
|
|
48
|
+
}
|
|
49
|
+
function isReattachableCaptureError(error) {
|
|
50
|
+
if (!(error instanceof BrowserAutomationError))
|
|
51
|
+
return false;
|
|
52
|
+
const stage = error.details?.stage;
|
|
53
|
+
return stage === "assistant-timeout" || stage === "assistant-recheck";
|
|
54
|
+
}
|
|
55
|
+
function classifyPreservedBrowserError(error, headless) {
|
|
56
|
+
if (headless)
|
|
57
|
+
return null;
|
|
58
|
+
if (isCloudflareChallengeError(error))
|
|
59
|
+
return "cloudflare-challenge";
|
|
60
|
+
if (isReattachableCaptureError(error))
|
|
61
|
+
return "reattachable-capture";
|
|
62
|
+
return null;
|
|
26
63
|
}
|
|
27
64
|
function shouldPreserveBrowserOnError(error, headless) {
|
|
28
|
-
return
|
|
65
|
+
return classifyPreservedBrowserError(error, headless) !== null;
|
|
29
66
|
}
|
|
30
67
|
export function shouldPreserveBrowserOnErrorForTest(error, headless) {
|
|
31
68
|
return shouldPreserveBrowserOnError(error, headless);
|
|
32
69
|
}
|
|
70
|
+
export function classifyPreservedBrowserErrorForTest(error, headless) {
|
|
71
|
+
return classifyPreservedBrowserError(error, headless);
|
|
72
|
+
}
|
|
73
|
+
function shouldSkipThinkingTimeSelection(desiredModel, thinkingTime) {
|
|
74
|
+
if (thinkingTime !== "extended" || !desiredModel) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
const normalized = desiredModel.toLowerCase();
|
|
78
|
+
return (normalized === "gpt-5.5-pro" ||
|
|
79
|
+
normalized.includes("gpt-5.5 pro") ||
|
|
80
|
+
normalized.includes("gpt 5.5 pro") ||
|
|
81
|
+
normalized.includes("gpt 5 5 pro"));
|
|
82
|
+
}
|
|
83
|
+
export function shouldSkipThinkingTimeSelectionForTest(desiredModel, thinkingTime) {
|
|
84
|
+
return shouldSkipThinkingTimeSelection(desiredModel, thinkingTime);
|
|
85
|
+
}
|
|
86
|
+
function listIgnoredRemoteChromeFlags(config) {
|
|
87
|
+
return [
|
|
88
|
+
config.headless ? "--browser-headless" : null,
|
|
89
|
+
config.hideWindow ? "--browser-hide-window" : null,
|
|
90
|
+
config.keepBrowser ? "--browser-keep-browser" : null,
|
|
91
|
+
!config.attachRunning && config.chromePath ? "--browser-chrome-path" : null,
|
|
92
|
+
].filter((value) => Boolean(value));
|
|
93
|
+
}
|
|
94
|
+
function hasBrowserErrorCode(error, code) {
|
|
95
|
+
return (error instanceof BrowserAutomationError &&
|
|
96
|
+
error.details?.code === code);
|
|
97
|
+
}
|
|
98
|
+
async function saveOptionalArtifact(operation, logger) {
|
|
99
|
+
try {
|
|
100
|
+
return await operation();
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
104
|
+
logger(`[browser] Failed to save session artifact: ${message}`);
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
async function waitForAssistantOrGeneratedImageResponse(params) {
|
|
109
|
+
if (!params.imageOutputRequested) {
|
|
110
|
+
return params.waitForText();
|
|
111
|
+
}
|
|
112
|
+
params.logger("[browser] Waiting for ChatGPT generated image response.");
|
|
113
|
+
const response = await pollGeneratedImageOrTextAssistantResponse(params.Runtime, params.timeoutMs, params.minTurnIndex, params.expectedConversationId);
|
|
114
|
+
if (response) {
|
|
115
|
+
if (response.html?.includes("/backend-api/estuary/content?id=file_")) {
|
|
116
|
+
params.logger("[browser] Captured generated image response before text appeared.");
|
|
117
|
+
}
|
|
118
|
+
return response;
|
|
119
|
+
}
|
|
120
|
+
throw new Error("assistant response timeout while waiting for generated image or text");
|
|
121
|
+
}
|
|
122
|
+
async function attemptAssistantRecheckOrRethrow(operation) {
|
|
123
|
+
try {
|
|
124
|
+
return await operation();
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
if (error instanceof BrowserAutomationError) {
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
async function pollGeneratedImageOrTextAssistantResponse(Runtime, timeoutMs, minTurnIndex, expectedConversationId) {
|
|
134
|
+
const deadline = Date.now() + timeoutMs;
|
|
135
|
+
while (Date.now() < deadline) {
|
|
136
|
+
let snapshot = await readAssistantSnapshot(Runtime, minTurnIndex, expectedConversationId).catch(() => null);
|
|
137
|
+
if (!snapshot && typeof minTurnIndex === "number" && Number.isFinite(minTurnIndex)) {
|
|
138
|
+
const relaxedSnapshot = await readAssistantSnapshot(Runtime, undefined, expectedConversationId).catch(() => null);
|
|
139
|
+
const relaxedHtml = typeof relaxedSnapshot?.html === "string" ? relaxedSnapshot.html : "";
|
|
140
|
+
if (relaxedHtml.includes("/backend-api/estuary/content?id=file_")) {
|
|
141
|
+
snapshot = relaxedSnapshot;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const text = typeof snapshot?.text === "string" ? snapshot.text.trim() : "";
|
|
145
|
+
const html = typeof snapshot?.html === "string" ? snapshot.html : "";
|
|
146
|
+
const hasGeneratedImage = html.includes("/backend-api/estuary/content?id=file_");
|
|
147
|
+
if (text && (hasGeneratedImage || !isImageOnlyUiChromeText(text))) {
|
|
148
|
+
return {
|
|
149
|
+
text,
|
|
150
|
+
html,
|
|
151
|
+
meta: {
|
|
152
|
+
turnId: snapshot?.turnId ?? undefined,
|
|
153
|
+
messageId: snapshot?.messageId ?? undefined,
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
await delay(750);
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
function isImageOnlyUiChromeText(text) {
|
|
162
|
+
const normalized = text.toLowerCase().replace(/\s+/g, " ").trim();
|
|
163
|
+
return (normalized.length === 0 ||
|
|
164
|
+
normalized === "edit" ||
|
|
165
|
+
normalized === "stopped thinking" ||
|
|
166
|
+
normalized === "stopped thinking edit");
|
|
167
|
+
}
|
|
168
|
+
function normalizeBrowserFollowUpPrompts(values) {
|
|
169
|
+
return (values ?? []).map((entry) => entry.trim()).filter(Boolean);
|
|
170
|
+
}
|
|
171
|
+
export function formatBrowserTurnTranscript(turns) {
|
|
172
|
+
if (turns.length <= 1) {
|
|
173
|
+
const turn = turns[0];
|
|
174
|
+
return {
|
|
175
|
+
answerText: turn?.answerText ?? "",
|
|
176
|
+
answerMarkdown: turn?.answerMarkdown ?? turn?.answerText ?? "",
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
const answerMarkdown = turns
|
|
180
|
+
.map((turn, index) => {
|
|
181
|
+
const label = turn.label.trim() || `Turn ${index + 1}`;
|
|
182
|
+
const prompt = turn.prompt?.trim();
|
|
183
|
+
const promptBlock = prompt ? `\n\n### Prompt\n\n${prompt}` : "";
|
|
184
|
+
const answer = (turn.answerMarkdown || turn.answerText).trim() || "_No text captured._";
|
|
185
|
+
return `## ${label}${promptBlock}\n\n### Answer\n\n${answer}`;
|
|
186
|
+
})
|
|
187
|
+
.join("\n\n")
|
|
188
|
+
.trim();
|
|
189
|
+
return {
|
|
190
|
+
answerText: answerMarkdown,
|
|
191
|
+
answerMarkdown,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
async function maybeArchiveCompletedConversation({ Runtime, logger, config, conversationUrl, followUpCount, requiredArtifactsSaved, }) {
|
|
195
|
+
const decision = resolveBrowserArchiveDecision({
|
|
196
|
+
mode: config.archiveConversations,
|
|
197
|
+
chatgptUrl: config.chatgptUrl ?? config.url,
|
|
198
|
+
conversationUrl,
|
|
199
|
+
researchMode: config.researchMode,
|
|
200
|
+
followUpCount,
|
|
201
|
+
});
|
|
202
|
+
if (!decision.shouldArchive) {
|
|
203
|
+
logger(`[browser] ChatGPT archive skipped (${decision.reason}).`);
|
|
204
|
+
return {
|
|
205
|
+
mode: decision.mode,
|
|
206
|
+
attempted: false,
|
|
207
|
+
archived: false,
|
|
208
|
+
reason: decision.reason,
|
|
209
|
+
conversationUrl: conversationUrl ?? undefined,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
if (!requiredArtifactsSaved) {
|
|
213
|
+
logger("[browser] ChatGPT archive skipped (artifact-save-failed).");
|
|
214
|
+
return {
|
|
215
|
+
mode: decision.mode,
|
|
216
|
+
attempted: false,
|
|
217
|
+
archived: false,
|
|
218
|
+
reason: "artifact-save-failed",
|
|
219
|
+
conversationUrl: conversationUrl ?? undefined,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
return archiveChatGptConversation(Runtime, logger, {
|
|
223
|
+
mode: decision.mode,
|
|
224
|
+
conversationUrl,
|
|
225
|
+
}).catch((error) => {
|
|
226
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
227
|
+
logger(`[browser] ChatGPT archive failed (${message}).`);
|
|
228
|
+
return {
|
|
229
|
+
mode: decision.mode,
|
|
230
|
+
attempted: true,
|
|
231
|
+
archived: false,
|
|
232
|
+
reason: "archive-failed",
|
|
233
|
+
conversationUrl: conversationUrl ?? undefined,
|
|
234
|
+
error: message,
|
|
235
|
+
};
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
export function maybeArchiveCompletedConversationForTest(args) {
|
|
239
|
+
return maybeArchiveCompletedConversation(args);
|
|
240
|
+
}
|
|
241
|
+
async function runSubmissionWithRecovery({ prompt, attachments, fallbackSubmission, submit, reloadPromptComposer, prepareFallbackSubmission, logger, }) {
|
|
242
|
+
let currentPrompt = prompt;
|
|
243
|
+
let currentAttachments = attachments;
|
|
244
|
+
let retriedDeadComposer = false;
|
|
245
|
+
let usedFallbackSubmission = false;
|
|
246
|
+
while (true) {
|
|
247
|
+
try {
|
|
248
|
+
return await submit(currentPrompt, currentAttachments);
|
|
249
|
+
}
|
|
250
|
+
catch (error) {
|
|
251
|
+
const isDeadComposer = hasBrowserErrorCode(error, "dead-composer");
|
|
252
|
+
if (isDeadComposer && !retriedDeadComposer) {
|
|
253
|
+
retriedDeadComposer = true;
|
|
254
|
+
await reloadPromptComposer();
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
const isPromptTooLarge = hasBrowserErrorCode(error, "prompt-too-large");
|
|
258
|
+
if (fallbackSubmission && isPromptTooLarge && !usedFallbackSubmission) {
|
|
259
|
+
usedFallbackSubmission = true;
|
|
260
|
+
logger("[browser] Inline prompt too large; retrying with file uploads.");
|
|
261
|
+
await prepareFallbackSubmission();
|
|
262
|
+
currentPrompt = fallbackSubmission.prompt;
|
|
263
|
+
currentAttachments = fallbackSubmission.attachments;
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
throw error;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
export async function runSubmissionWithRecoveryForTest(args) {
|
|
271
|
+
return runSubmissionWithRecovery(args);
|
|
272
|
+
}
|
|
273
|
+
function resolveRemoteTabLeaseProfileDir(config) {
|
|
274
|
+
if (!config.remoteChrome || !config.manualLogin || !config.manualLoginProfileDir) {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
return path.resolve(config.manualLoginProfileDir);
|
|
278
|
+
}
|
|
279
|
+
export function resolveRemoteTabLeaseProfileDirForTest(config) {
|
|
280
|
+
return resolveRemoteTabLeaseProfileDir(config);
|
|
281
|
+
}
|
|
282
|
+
async function closeRemoteConnectionAfterRun(options) {
|
|
283
|
+
if (options.connectionClosedUnexpectedly) {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
if (!options.connection) {
|
|
287
|
+
await options.client?.close();
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (options.runStatus === "complete") {
|
|
291
|
+
await options.connection.close();
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
await options.client?.close();
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
function shouldCloseOwnedRunTargetAfterRun(options) {
|
|
298
|
+
return options.runStatus === "complete" && options.ownsTarget && !options.keepBrowser;
|
|
299
|
+
}
|
|
33
300
|
export async function runBrowserMode(options) {
|
|
34
301
|
const promptText = options.prompt?.trim();
|
|
35
302
|
if (!promptText) {
|
|
36
|
-
throw new Error(
|
|
303
|
+
throw new Error("Prompt text is required when using browser mode.");
|
|
37
304
|
}
|
|
38
305
|
const attachments = options.attachments ?? [];
|
|
39
306
|
const fallbackSubmission = options.fallbackSubmission;
|
|
40
307
|
let config = resolveBrowserConfig(options.config);
|
|
308
|
+
const followUpPrompts = normalizeBrowserFollowUpPrompts(options.followUpPrompts);
|
|
309
|
+
if (config.researchMode === "deep" && followUpPrompts.length > 0) {
|
|
310
|
+
throw new BrowserAutomationError("Browser follow-ups are not supported with Deep Research mode. Put the full research plan into the initial prompt or run a normal browser consult for multi-turn review.", {
|
|
311
|
+
stage: "browser-follow-ups",
|
|
312
|
+
details: { researchMode: "deep", followUps: followUpPrompts.length },
|
|
313
|
+
});
|
|
314
|
+
}
|
|
41
315
|
const logger = options.log ?? ((_message) => { });
|
|
42
316
|
if (logger.verbose === undefined) {
|
|
43
317
|
logger.verbose = Boolean(config.debug);
|
|
@@ -48,8 +322,9 @@ export async function runBrowserMode(options) {
|
|
|
48
322
|
const runtimeHintCb = options.runtimeHintCb;
|
|
49
323
|
let lastTargetId;
|
|
50
324
|
let lastUrl;
|
|
325
|
+
let tabLease = null;
|
|
51
326
|
const emitRuntimeHint = async () => {
|
|
52
|
-
if (!
|
|
327
|
+
if (!chrome?.port) {
|
|
53
328
|
return;
|
|
54
329
|
}
|
|
55
330
|
const conversationId = lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined;
|
|
@@ -64,19 +339,37 @@ export async function runBrowserMode(options) {
|
|
|
64
339
|
controllerPid: process.pid,
|
|
65
340
|
};
|
|
66
341
|
try {
|
|
67
|
-
await runtimeHintCb(hint);
|
|
342
|
+
await runtimeHintCb?.(hint);
|
|
343
|
+
await tabLease?.update({
|
|
344
|
+
chromeHost,
|
|
345
|
+
chromePort: chrome.port,
|
|
346
|
+
chromeTargetId: lastTargetId,
|
|
347
|
+
tabUrl: lastUrl,
|
|
348
|
+
});
|
|
68
349
|
}
|
|
69
350
|
catch (error) {
|
|
70
351
|
const message = error instanceof Error ? error.message : String(error);
|
|
71
352
|
logger(`Failed to persist runtime hint: ${message}`);
|
|
72
353
|
}
|
|
73
354
|
};
|
|
74
|
-
if (config.debug || process.env.CHATGPT_DEVTOOLS_TRACE ===
|
|
355
|
+
if (config.debug || process.env.CHATGPT_DEVTOOLS_TRACE === "1") {
|
|
75
356
|
logger(`[browser-mode] config: ${JSON.stringify({
|
|
76
|
-
...config,
|
|
357
|
+
...redactBrowserConfigForDebugLog(config),
|
|
77
358
|
promptLength: promptText.length,
|
|
78
359
|
})}`);
|
|
79
360
|
}
|
|
361
|
+
for (const line of formatBrowserControlPlan(describeBrowserControlPlan(config), "browser")) {
|
|
362
|
+
logger(line);
|
|
363
|
+
}
|
|
364
|
+
if (config.attachRunning) {
|
|
365
|
+
const attached = await resolveAttachRunningConnection(config, logger);
|
|
366
|
+
config = {
|
|
367
|
+
...config,
|
|
368
|
+
remoteChrome: { host: attached.host, port: attached.port },
|
|
369
|
+
remoteChromeBrowserWSEndpoint: attached.browserWSEndpoint,
|
|
370
|
+
remoteChromeProfileRoot: attached.profileRoot,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
80
373
|
if (!config.remoteChrome && !config.manualLogin) {
|
|
81
374
|
const preferredPort = config.debugPort ?? DEFAULT_DEBUG_PORT;
|
|
82
375
|
const availablePort = await pickAvailableDebugPort(preferredPort, logger);
|
|
@@ -88,19 +381,19 @@ export async function runBrowserMode(options) {
|
|
|
88
381
|
// Remote Chrome mode - connect to existing browser
|
|
89
382
|
if (config.remoteChrome) {
|
|
90
383
|
// Warn about ignored local-only options
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
384
|
+
const ignoredFlags = listIgnoredRemoteChromeFlags(config);
|
|
385
|
+
if (ignoredFlags.length > 0) {
|
|
386
|
+
logger(`Note: --remote-chrome ignores local Chrome flags (${ignoredFlags.join(", ")}).`);
|
|
94
387
|
}
|
|
95
388
|
return runRemoteBrowserMode(promptText, attachments, config, logger, options);
|
|
96
389
|
}
|
|
97
390
|
const manualLogin = Boolean(config.manualLogin);
|
|
98
391
|
const manualProfileDir = config.manualLoginProfileDir
|
|
99
392
|
? path.resolve(config.manualLoginProfileDir)
|
|
100
|
-
: path.join(os.homedir(),
|
|
393
|
+
: path.join(os.homedir(), ".oracle", "browser-profile");
|
|
101
394
|
const userDataDir = manualLogin
|
|
102
395
|
? manualProfileDir
|
|
103
|
-
: await mkdtemp(path.join(await resolveUserDataBaseDir(),
|
|
396
|
+
: await mkdtemp(path.join(await resolveUserDataBaseDir(), "oracle-browser-"));
|
|
104
397
|
if (manualLogin) {
|
|
105
398
|
// Learned: manual login reuses a persistent profile so cookies/SSO survive.
|
|
106
399
|
await mkdir(userDataDir, { recursive: true });
|
|
@@ -109,27 +402,47 @@ export async function runBrowserMode(options) {
|
|
|
109
402
|
else {
|
|
110
403
|
logger(`Created temporary Chrome profile at ${userDataDir}`);
|
|
111
404
|
}
|
|
405
|
+
if (manualLogin) {
|
|
406
|
+
tabLease = await acquireBrowserTabLease(userDataDir, {
|
|
407
|
+
maxConcurrentTabs: config.maxConcurrentTabs,
|
|
408
|
+
timeoutMs: config.timeoutMs,
|
|
409
|
+
logger,
|
|
410
|
+
sessionId: options.sessionId,
|
|
411
|
+
});
|
|
412
|
+
}
|
|
112
413
|
const effectiveKeepBrowser = Boolean(config.keepBrowser);
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
414
|
+
let acquiredChrome;
|
|
415
|
+
try {
|
|
416
|
+
acquiredChrome = manualLogin
|
|
417
|
+
? await acquireManualLoginChromeForRun(userDataDir, config, logger, options.sessionId)
|
|
418
|
+
: {
|
|
419
|
+
chrome: await launchChrome({
|
|
420
|
+
...config,
|
|
421
|
+
remoteChrome: config.remoteChrome,
|
|
422
|
+
}, userDataDir, logger),
|
|
423
|
+
reusedChrome: null,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
catch (error) {
|
|
427
|
+
if (tabLease) {
|
|
428
|
+
const handle = tabLease;
|
|
429
|
+
tabLease = null;
|
|
430
|
+
await handle.release().catch(() => undefined);
|
|
127
431
|
}
|
|
432
|
+
throw error;
|
|
433
|
+
}
|
|
434
|
+
const { chrome, reusedChrome } = acquiredChrome;
|
|
435
|
+
const chromeHost = chrome.host ?? "127.0.0.1";
|
|
436
|
+
if (tabLease) {
|
|
437
|
+
await tabLease.update({
|
|
438
|
+
chromeHost,
|
|
439
|
+
chromePort: chrome.port,
|
|
440
|
+
});
|
|
128
441
|
}
|
|
129
442
|
let removeTerminationHooks = null;
|
|
130
443
|
try {
|
|
131
444
|
removeTerminationHooks = registerTerminationHooks(chrome, userDataDir, effectiveKeepBrowser, logger, {
|
|
132
|
-
isInFlight: () => runStatus !==
|
|
445
|
+
isInFlight: () => runStatus !== "complete",
|
|
133
446
|
emitRuntimeHint,
|
|
134
447
|
preserveUserDataDir: manualLogin,
|
|
135
448
|
});
|
|
@@ -139,11 +452,12 @@ export async function runBrowserMode(options) {
|
|
|
139
452
|
}
|
|
140
453
|
let client = null;
|
|
141
454
|
let isolatedTargetId = null;
|
|
455
|
+
let ownsTarget = true;
|
|
142
456
|
const startedAt = Date.now();
|
|
143
|
-
let answerText =
|
|
144
|
-
let answerMarkdown =
|
|
145
|
-
let answerHtml =
|
|
146
|
-
let runStatus =
|
|
457
|
+
let answerText = "";
|
|
458
|
+
let answerMarkdown = "";
|
|
459
|
+
let answerHtml = "";
|
|
460
|
+
let runStatus = "attempted";
|
|
147
461
|
let connectionClosedUnexpectedly = false;
|
|
148
462
|
let stopThinkingMonitor = null;
|
|
149
463
|
let removeDialogHandler = null;
|
|
@@ -151,14 +465,37 @@ export async function runBrowserMode(options) {
|
|
|
151
465
|
let preserveBrowserOnError = false;
|
|
152
466
|
try {
|
|
153
467
|
try {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
468
|
+
if (config.browserTabRef) {
|
|
469
|
+
const attached = await connectToExistingChatGptTab({
|
|
470
|
+
host: chromeHost,
|
|
471
|
+
port: chrome.port,
|
|
472
|
+
ref: config.browserTabRef,
|
|
473
|
+
});
|
|
474
|
+
client = attached.client;
|
|
475
|
+
isolatedTargetId = attached.targetId ?? null;
|
|
476
|
+
lastTargetId = attached.targetId ?? undefined;
|
|
477
|
+
lastUrl = attached.tab.url || lastUrl;
|
|
478
|
+
ownsTarget = false;
|
|
479
|
+
logger(`Attached to existing ChatGPT tab ${attached.targetId}${attached.tab.url ? ` (${attached.tab.url})` : ""}`);
|
|
480
|
+
}
|
|
481
|
+
else {
|
|
482
|
+
const strictTabIsolation = Boolean(manualLogin && reusedChrome);
|
|
483
|
+
const connection = await connectWithNewTab(chrome.port, logger, config.url, chromeHost, {
|
|
484
|
+
fallbackToDefault: !strictTabIsolation,
|
|
485
|
+
retries: strictTabIsolation ? 3 : 0,
|
|
486
|
+
retryDelayMs: 500,
|
|
487
|
+
});
|
|
488
|
+
client = connection.client;
|
|
489
|
+
isolatedTargetId = connection.targetId ?? null;
|
|
490
|
+
ownsTarget = true;
|
|
491
|
+
}
|
|
492
|
+
if (tabLease && isolatedTargetId) {
|
|
493
|
+
await tabLease.update({
|
|
494
|
+
chromeHost,
|
|
495
|
+
chromePort: chrome.port,
|
|
496
|
+
chromeTargetId: isolatedTargetId,
|
|
497
|
+
});
|
|
498
|
+
}
|
|
162
499
|
}
|
|
163
500
|
catch (error) {
|
|
164
501
|
const hint = describeDevtoolsFirewallHint(chromeHost, chrome.port);
|
|
@@ -168,10 +505,10 @@ export async function runBrowserMode(options) {
|
|
|
168
505
|
throw error;
|
|
169
506
|
}
|
|
170
507
|
const disconnectPromise = new Promise((_, reject) => {
|
|
171
|
-
client?.on(
|
|
508
|
+
client?.on("disconnect", () => {
|
|
172
509
|
connectionClosedUnexpectedly = true;
|
|
173
|
-
logger(
|
|
174
|
-
reject(new Error(
|
|
510
|
+
logger("Chrome window closed; attempting to abort run.");
|
|
511
|
+
reject(new Error("Chrome window closed before oracle finished. Please keep it open until completion."));
|
|
175
512
|
});
|
|
176
513
|
});
|
|
177
514
|
const raceWithDisconnect = (promise) => Promise.race([promise, disconnectPromise]);
|
|
@@ -180,7 +517,7 @@ export async function runBrowserMode(options) {
|
|
|
180
517
|
await hideChromeWindow(chrome, logger);
|
|
181
518
|
}
|
|
182
519
|
const domainEnablers = [Network.enable({}), Page.enable(), Runtime.enable()];
|
|
183
|
-
if (DOM && typeof DOM.enable ===
|
|
520
|
+
if (DOM && typeof DOM.enable === "function") {
|
|
184
521
|
domainEnablers.push(DOM.enable());
|
|
185
522
|
}
|
|
186
523
|
await Promise.all(domainEnablers);
|
|
@@ -192,13 +529,13 @@ export async function runBrowserMode(options) {
|
|
|
192
529
|
const cookieSyncEnabled = config.cookieSync && (!manualLogin || manualLoginCookieSync);
|
|
193
530
|
if (cookieSyncEnabled) {
|
|
194
531
|
if (manualLoginCookieSync) {
|
|
195
|
-
logger(
|
|
532
|
+
logger("Manual login mode: seeding persistent profile with cookies from your Chrome profile.");
|
|
196
533
|
}
|
|
197
534
|
if (!config.inlineCookies) {
|
|
198
|
-
logger(
|
|
535
|
+
logger("Heads-up: macOS may prompt for your Keychain password to read Chrome cookies; use --copy or --render for manual flow.");
|
|
199
536
|
}
|
|
200
537
|
else {
|
|
201
|
-
logger(
|
|
538
|
+
logger("Applying inline cookies (skipping Chrome profile read and Keychain prompt)");
|
|
202
539
|
}
|
|
203
540
|
// Learned: always sync cookies before the first navigation so /backend-api/me succeeds.
|
|
204
541
|
const cookieCount = await syncCookies(Network, config.url, config.chromeProfile, logger, {
|
|
@@ -210,53 +547,66 @@ export async function runBrowserMode(options) {
|
|
|
210
547
|
});
|
|
211
548
|
appliedCookies = cookieCount;
|
|
212
549
|
if (config.inlineCookies && cookieCount === 0) {
|
|
213
|
-
throw new Error(
|
|
550
|
+
throw new Error("No inline cookies were applied; aborting before navigation.");
|
|
214
551
|
}
|
|
215
552
|
logger(cookieCount > 0
|
|
216
553
|
? config.inlineCookies
|
|
217
554
|
? `Applied ${cookieCount} inline cookies`
|
|
218
|
-
: `Copied ${cookieCount} cookies from Chrome profile ${config.chromeProfile ??
|
|
555
|
+
: `Copied ${cookieCount} cookies from Chrome profile ${config.chromeProfile ?? "Default"}`
|
|
219
556
|
: config.inlineCookies
|
|
220
|
-
?
|
|
221
|
-
:
|
|
557
|
+
? "No inline cookies applied; continuing without session reuse"
|
|
558
|
+
: "No Chrome cookies found; continuing without session reuse");
|
|
222
559
|
}
|
|
223
560
|
else {
|
|
224
561
|
logger(manualLogin
|
|
225
|
-
?
|
|
226
|
-
:
|
|
562
|
+
? "Skipping Chrome cookie sync (--browser-manual-login enabled); reuse the opened profile after signing in."
|
|
563
|
+
: "Skipping Chrome cookie sync (--browser-no-cookie-sync)");
|
|
227
564
|
}
|
|
228
565
|
if (cookieSyncEnabled && !manualLogin && (appliedCookies ?? 0) === 0 && !config.inlineCookies) {
|
|
229
566
|
// Learned: if the profile has no ChatGPT cookies, browser mode will just bounce to login.
|
|
230
567
|
// Fail early so the user knows to sign in.
|
|
231
|
-
throw new BrowserAutomationError(
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
stage:
|
|
568
|
+
throw new BrowserAutomationError("No ChatGPT cookies were applied from your Chrome profile; cannot proceed in browser mode. " +
|
|
569
|
+
"Make sure ChatGPT is signed in in the selected profile, use --browser-manual-login / inline cookies, " +
|
|
570
|
+
"or retry with --browser-cookie-wait 5s if Keychain prompts are slow.", {
|
|
571
|
+
stage: "execute-browser",
|
|
235
572
|
details: {
|
|
236
|
-
profile: config.chromeProfile ??
|
|
573
|
+
profile: config.chromeProfile ?? "Default",
|
|
237
574
|
cookiePath: config.chromeCookiePath ?? null,
|
|
238
|
-
hint:
|
|
575
|
+
hint: "If macOS Keychain prompts or denies access, run oracle from a GUI session or use --copy/--render for the manual flow.",
|
|
239
576
|
},
|
|
240
577
|
});
|
|
241
578
|
}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
await raceWithDisconnect(ensureNotBlocked(Runtime, config.headless, logger));
|
|
247
|
-
// Learned: login checks must happen on the base domain before jumping into project URLs.
|
|
248
|
-
await raceWithDisconnect(waitForLogin({ runtime: Runtime, logger, appliedCookies, manualLogin, timeoutMs: config.timeoutMs }));
|
|
249
|
-
if (config.url !== baseUrl) {
|
|
250
|
-
await raceWithDisconnect(navigateToPromptReadyWithFallback(Page, Runtime, {
|
|
251
|
-
url: config.url,
|
|
252
|
-
fallbackUrl: baseUrl,
|
|
253
|
-
timeoutMs: config.inputTimeoutMs,
|
|
254
|
-
headless: config.headless,
|
|
255
|
-
logger,
|
|
256
|
-
}));
|
|
579
|
+
if (config.browserTabRef) {
|
|
580
|
+
await raceWithDisconnect(ensureNotBlocked(Runtime, config.headless, logger));
|
|
581
|
+
await raceWithDisconnect(ensureLoggedIn(Runtime, logger));
|
|
582
|
+
await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
|
|
257
583
|
}
|
|
258
584
|
else {
|
|
259
|
-
|
|
585
|
+
const baseUrl = CHATGPT_URL;
|
|
586
|
+
// First load the base ChatGPT homepage to satisfy potential interstitials,
|
|
587
|
+
// then hop to the requested URL if it differs.
|
|
588
|
+
await raceWithDisconnect(navigateToChatGPT(Page, Runtime, baseUrl, logger));
|
|
589
|
+
await raceWithDisconnect(ensureNotBlocked(Runtime, config.headless, logger));
|
|
590
|
+
// Learned: login checks must happen on the base domain before jumping into project URLs.
|
|
591
|
+
await raceWithDisconnect(waitForLogin({
|
|
592
|
+
runtime: Runtime,
|
|
593
|
+
logger,
|
|
594
|
+
appliedCookies,
|
|
595
|
+
manualLogin,
|
|
596
|
+
timeoutMs: config.timeoutMs,
|
|
597
|
+
}));
|
|
598
|
+
if (config.url !== baseUrl) {
|
|
599
|
+
await raceWithDisconnect(navigateToPromptReadyWithFallback(Page, Runtime, {
|
|
600
|
+
url: config.url,
|
|
601
|
+
fallbackUrl: baseUrl,
|
|
602
|
+
timeoutMs: config.inputTimeoutMs,
|
|
603
|
+
headless: config.headless,
|
|
604
|
+
logger,
|
|
605
|
+
}));
|
|
606
|
+
}
|
|
607
|
+
else {
|
|
608
|
+
await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
|
|
609
|
+
}
|
|
260
610
|
}
|
|
261
611
|
logger(`Prompt textarea ready (initial focus, ${promptText.length.toLocaleString()} chars queued)`);
|
|
262
612
|
const captureRuntimeSnapshot = async () => {
|
|
@@ -272,10 +622,10 @@ export async function runBrowserMode(options) {
|
|
|
272
622
|
}
|
|
273
623
|
try {
|
|
274
624
|
const { result } = await Runtime.evaluate({
|
|
275
|
-
expression:
|
|
625
|
+
expression: "location.href",
|
|
276
626
|
returnByValue: true,
|
|
277
627
|
});
|
|
278
|
-
if (typeof result?.value ===
|
|
628
|
+
if (typeof result?.value === "string") {
|
|
279
629
|
lastUrl = result.value;
|
|
280
630
|
}
|
|
281
631
|
}
|
|
@@ -286,7 +636,7 @@ export async function runBrowserMode(options) {
|
|
|
286
636
|
logger(`[browser] url = ${lastUrl}`);
|
|
287
637
|
}
|
|
288
638
|
if (chrome?.port) {
|
|
289
|
-
const suffix = lastTargetId ? ` target=${lastTargetId}` :
|
|
639
|
+
const suffix = lastTargetId ? ` target=${lastTargetId}` : "";
|
|
290
640
|
if (lastUrl) {
|
|
291
641
|
logger(`[reattach] chrome port=${chrome.port} host=${chromeHost} url=${lastUrl}${suffix}`);
|
|
292
642
|
}
|
|
@@ -304,8 +654,11 @@ export async function runBrowserMode(options) {
|
|
|
304
654
|
const start = Date.now();
|
|
305
655
|
while (Date.now() - start < timeoutMs) {
|
|
306
656
|
try {
|
|
307
|
-
const { result } = await Runtime.evaluate({
|
|
308
|
-
|
|
657
|
+
const { result } = await Runtime.evaluate({
|
|
658
|
+
expression: "location.href",
|
|
659
|
+
returnByValue: true,
|
|
660
|
+
});
|
|
661
|
+
if (typeof result?.value === "string" && result.value.includes("/c/")) {
|
|
309
662
|
lastUrl = result.value;
|
|
310
663
|
logger(`[browser] conversation url (${label}) = ${lastUrl}`);
|
|
311
664
|
await emitRuntimeHint();
|
|
@@ -333,7 +686,7 @@ export async function runBrowserMode(options) {
|
|
|
333
686
|
};
|
|
334
687
|
await captureRuntimeSnapshot();
|
|
335
688
|
const modelStrategy = config.modelStrategy ?? DEFAULT_MODEL_STRATEGY;
|
|
336
|
-
if (config.desiredModel && modelStrategy !==
|
|
689
|
+
if (config.desiredModel && modelStrategy !== "ignore") {
|
|
337
690
|
await raceWithDisconnect(withRetries(() => ensureModelSelection(Runtime, config.desiredModel, logger, modelStrategy), {
|
|
338
691
|
retries: 2,
|
|
339
692
|
delayMs: 300,
|
|
@@ -345,28 +698,47 @@ export async function runBrowserMode(options) {
|
|
|
345
698
|
})).catch((error) => {
|
|
346
699
|
const base = error instanceof Error ? error.message : String(error);
|
|
347
700
|
const hint = appliedCookies === 0
|
|
348
|
-
?
|
|
349
|
-
:
|
|
701
|
+
? " No cookies were applied; log in to ChatGPT in Chrome or provide inline cookies (--browser-inline-cookies[(-file)] or ORACLE_BROWSER_COOKIES_JSON)."
|
|
702
|
+
: "";
|
|
350
703
|
throw new Error(`${base}${hint}`);
|
|
351
704
|
});
|
|
352
705
|
await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
|
|
353
706
|
logger(`Prompt textarea ready (after model switch, ${promptText.length.toLocaleString()} chars queued)`);
|
|
354
707
|
}
|
|
355
|
-
else if (modelStrategy ===
|
|
356
|
-
logger(
|
|
708
|
+
else if (modelStrategy === "ignore") {
|
|
709
|
+
logger("Model picker: skipped (strategy=ignore)");
|
|
357
710
|
}
|
|
358
|
-
|
|
711
|
+
const deepResearch = config.researchMode === "deep";
|
|
712
|
+
// Handle thinking time selection if specified. Deep Research owns its own effort flow.
|
|
359
713
|
const thinkingTime = config.thinkingTime;
|
|
360
|
-
if (thinkingTime) {
|
|
361
|
-
|
|
714
|
+
if (thinkingTime && !deepResearch) {
|
|
715
|
+
if (shouldSkipThinkingTimeSelection(config.desiredModel, thinkingTime)) {
|
|
716
|
+
logger("Thinking time: Pro Extended (via model selection)");
|
|
717
|
+
}
|
|
718
|
+
else {
|
|
719
|
+
await raceWithDisconnect(withRetries(() => ensureThinkingTime(Runtime, thinkingTime, logger), {
|
|
720
|
+
retries: 2,
|
|
721
|
+
delayMs: 300,
|
|
722
|
+
onRetry: (attempt, error) => {
|
|
723
|
+
if (options.verbose) {
|
|
724
|
+
logger(`[retry] Thinking time (${thinkingTime}) attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`);
|
|
725
|
+
}
|
|
726
|
+
},
|
|
727
|
+
}));
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
if (deepResearch) {
|
|
731
|
+
await raceWithDisconnect(withRetries(() => activateDeepResearch(Runtime, Input, logger), {
|
|
362
732
|
retries: 2,
|
|
363
|
-
delayMs:
|
|
733
|
+
delayMs: 500,
|
|
364
734
|
onRetry: (attempt, error) => {
|
|
365
735
|
if (options.verbose) {
|
|
366
|
-
logger(`[retry]
|
|
736
|
+
logger(`[retry] Deep Research activation attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`);
|
|
367
737
|
}
|
|
368
738
|
},
|
|
369
739
|
}));
|
|
740
|
+
await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
|
|
741
|
+
logger(`Prompt textarea ready (after Deep Research activation, ${promptText.length.toLocaleString()} chars queued)`);
|
|
370
742
|
}
|
|
371
743
|
const profileLockTimeoutMs = manualLogin ? (config.profileLockTimeoutMs ?? 0) : 0;
|
|
372
744
|
let profileLock = null;
|
|
@@ -387,13 +759,14 @@ export async function runBrowserMode(options) {
|
|
|
387
759
|
};
|
|
388
760
|
const submitOnce = async (prompt, submissionAttachments) => {
|
|
389
761
|
const baselineSnapshot = await readAssistantSnapshot(Runtime).catch(() => null);
|
|
390
|
-
const baselineAssistantText = typeof baselineSnapshot?.text ===
|
|
762
|
+
const baselineAssistantText = typeof baselineSnapshot?.text === "string" ? baselineSnapshot.text.trim() : "";
|
|
391
763
|
const attachmentNames = submissionAttachments.map((a) => path.basename(a.path));
|
|
392
|
-
let attachmentWaitTimedOut = false;
|
|
393
764
|
let inputOnlyAttachments = false;
|
|
765
|
+
await raceWithDisconnect(clearPromptComposer(Runtime, logger));
|
|
766
|
+
await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
|
|
394
767
|
if (submissionAttachments.length > 0) {
|
|
395
768
|
if (!DOM) {
|
|
396
|
-
throw new Error(
|
|
769
|
+
throw new Error("Chrome DOM domain unavailable while uploading attachments.");
|
|
397
770
|
}
|
|
398
771
|
await clearComposerAttachments(Runtime, 5_000, logger);
|
|
399
772
|
for (let attachmentIndex = 0; attachmentIndex < submissionAttachments.length; attachmentIndex += 1) {
|
|
@@ -409,24 +782,11 @@ export async function runBrowserMode(options) {
|
|
|
409
782
|
const baseTimeout = config.inputTimeoutMs ?? 30_000;
|
|
410
783
|
const perFileTimeout = 20_000;
|
|
411
784
|
const waitBudget = Math.max(baseTimeout, 45_000) + (submissionAttachments.length - 1) * perFileTimeout;
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
logger('All attachments uploaded');
|
|
415
|
-
}
|
|
416
|
-
catch (error) {
|
|
417
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
418
|
-
if (/Attachments did not finish uploading before timeout/i.test(message)) {
|
|
419
|
-
attachmentWaitTimedOut = true;
|
|
420
|
-
logger(`[browser] Attachment upload timed out after ${Math.round(waitBudget / 1000)}s; continuing without confirmation.`);
|
|
421
|
-
}
|
|
422
|
-
else {
|
|
423
|
-
throw error;
|
|
424
|
-
}
|
|
425
|
-
}
|
|
785
|
+
await waitForAttachmentCompletion(Runtime, waitBudget, attachmentNames, logger);
|
|
786
|
+
logger("All attachments uploaded");
|
|
426
787
|
}
|
|
427
788
|
let baselineTurns = await readConversationTurnCount(Runtime, logger);
|
|
428
789
|
// Learned: return baselineTurns so assistant polling can ignore earlier content.
|
|
429
|
-
const sendAttachmentNames = attachmentWaitTimedOut ? [] : attachmentNames;
|
|
430
790
|
const providerState = {
|
|
431
791
|
runtime: Runtime,
|
|
432
792
|
input: Input,
|
|
@@ -434,7 +794,7 @@ export async function runBrowserMode(options) {
|
|
|
434
794
|
timeoutMs: config.timeoutMs,
|
|
435
795
|
inputTimeoutMs: config.inputTimeoutMs ?? undefined,
|
|
436
796
|
baselineTurns: baselineTurns ?? undefined,
|
|
437
|
-
attachmentNames
|
|
797
|
+
attachmentNames,
|
|
438
798
|
};
|
|
439
799
|
await runProviderSubmissionFlow(chatgptDomProvider, {
|
|
440
800
|
prompt,
|
|
@@ -444,76 +804,131 @@ export async function runBrowserMode(options) {
|
|
|
444
804
|
state: providerState,
|
|
445
805
|
});
|
|
446
806
|
const providerBaselineTurns = providerState.baselineTurns;
|
|
447
|
-
if (typeof providerBaselineTurns ===
|
|
807
|
+
if (typeof providerBaselineTurns === "number" && Number.isFinite(providerBaselineTurns)) {
|
|
448
808
|
baselineTurns = providerBaselineTurns;
|
|
449
809
|
}
|
|
450
810
|
if (attachmentNames.length > 0) {
|
|
451
|
-
if (
|
|
452
|
-
logger(
|
|
453
|
-
}
|
|
454
|
-
else if (inputOnlyAttachments) {
|
|
455
|
-
logger('Attachment UI did not render before send; skipping user-turn attachment verification.');
|
|
811
|
+
if (inputOnlyAttachments) {
|
|
812
|
+
logger("Attachment UI did not render before send; skipping user-turn attachment verification.");
|
|
456
813
|
}
|
|
457
814
|
else {
|
|
458
|
-
const verified = await waitForUserTurnAttachments(Runtime, attachmentNames, 20_000, logger
|
|
815
|
+
const verified = await waitForUserTurnAttachments(Runtime, attachmentNames, 20_000, logger, {
|
|
816
|
+
minTurnIndex: baselineTurns ?? undefined,
|
|
817
|
+
expectedPrompt: prompt,
|
|
818
|
+
expectedConversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
|
|
819
|
+
});
|
|
459
820
|
if (!verified) {
|
|
460
|
-
|
|
821
|
+
logger("Sent user message did not expose attachment UI; continuing after upload check.");
|
|
822
|
+
}
|
|
823
|
+
else {
|
|
824
|
+
logger("Verified attachments present on sent user message");
|
|
461
825
|
}
|
|
462
|
-
logger('Verified attachments present on sent user message');
|
|
463
826
|
}
|
|
464
827
|
}
|
|
465
828
|
// Reattach needs a /c/ URL; ChatGPT can update it late, so poll in the background.
|
|
466
|
-
scheduleConversationHint(
|
|
829
|
+
scheduleConversationHint("post-submit", config.timeoutMs ?? 120_000);
|
|
467
830
|
return { baselineTurns, baselineAssistantText };
|
|
468
831
|
};
|
|
832
|
+
const reloadPromptComposer = async () => {
|
|
833
|
+
logger("[browser] Composer became unresponsive; reloading page and retrying once.");
|
|
834
|
+
await raceWithDisconnect(Page.reload({ ignoreCache: true }));
|
|
835
|
+
await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
|
|
836
|
+
};
|
|
469
837
|
let baselineTurns = null;
|
|
470
838
|
let baselineAssistantText = null;
|
|
471
839
|
await acquireProfileLockIfNeeded();
|
|
472
840
|
try {
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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.');
|
|
841
|
+
const submission = await runSubmissionWithRecovery({
|
|
842
|
+
prompt: promptText,
|
|
843
|
+
attachments,
|
|
844
|
+
fallbackSubmission,
|
|
845
|
+
submit: (submissionPrompt, submissionAttachments) => raceWithDisconnect(submitOnce(submissionPrompt, submissionAttachments)),
|
|
846
|
+
reloadPromptComposer,
|
|
847
|
+
prepareFallbackSubmission: async () => {
|
|
484
848
|
await raceWithDisconnect(clearPromptComposer(Runtime, logger));
|
|
485
849
|
await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
throw error;
|
|
492
|
-
}
|
|
493
|
-
}
|
|
850
|
+
},
|
|
851
|
+
logger,
|
|
852
|
+
});
|
|
853
|
+
baselineTurns = submission.baselineTurns;
|
|
854
|
+
baselineAssistantText = submission.baselineAssistantText;
|
|
494
855
|
}
|
|
495
856
|
finally {
|
|
496
857
|
await releaseProfileLockIfHeld();
|
|
497
858
|
}
|
|
498
|
-
|
|
859
|
+
const imageArtifactMinTurnIndex = baselineTurns;
|
|
860
|
+
if (deepResearch) {
|
|
861
|
+
await raceWithDisconnect(waitForResearchPlanAutoConfirm(Runtime, logger));
|
|
862
|
+
const researchResult = await raceWithDisconnect(waitForDeepResearchCompletion(Runtime, logger, config.timeoutMs, baselineTurns, Page, client));
|
|
863
|
+
await updateConversationHint("post-deep-research", 15_000).catch(() => false);
|
|
864
|
+
runStatus = "complete";
|
|
865
|
+
const durationMs = Date.now() - startedAt;
|
|
866
|
+
const tokens = estimateTokenCount(researchResult.text);
|
|
867
|
+
const reportArtifact = await saveOptionalArtifact(() => saveDeepResearchReportArtifact({
|
|
868
|
+
sessionId: options.sessionId,
|
|
869
|
+
reportMarkdown: researchResult.text,
|
|
870
|
+
conversationUrl: lastUrl,
|
|
871
|
+
logger,
|
|
872
|
+
}), logger);
|
|
873
|
+
const transcriptArtifact = await saveOptionalArtifact(() => saveBrowserTranscriptArtifact({
|
|
874
|
+
sessionId: options.sessionId,
|
|
875
|
+
prompt: promptText,
|
|
876
|
+
answerMarkdown: researchResult.text,
|
|
877
|
+
conversationUrl: lastUrl,
|
|
878
|
+
artifacts: appendArtifacts(undefined, [reportArtifact]),
|
|
879
|
+
logger,
|
|
880
|
+
}), logger);
|
|
881
|
+
const savedArtifacts = appendArtifacts(undefined, [reportArtifact, transcriptArtifact]);
|
|
882
|
+
const archive = await maybeArchiveCompletedConversation({
|
|
883
|
+
Runtime,
|
|
884
|
+
logger,
|
|
885
|
+
config,
|
|
886
|
+
conversationUrl: lastUrl,
|
|
887
|
+
followUpCount: 0,
|
|
888
|
+
requiredArtifactsSaved: Boolean(reportArtifact && transcriptArtifact),
|
|
889
|
+
});
|
|
890
|
+
return {
|
|
891
|
+
answerText: researchResult.text,
|
|
892
|
+
answerMarkdown: researchResult.text,
|
|
893
|
+
answerHtml: researchResult.html,
|
|
894
|
+
artifacts: savedArtifacts,
|
|
895
|
+
archive,
|
|
896
|
+
tookMs: durationMs,
|
|
897
|
+
answerTokens: tokens,
|
|
898
|
+
answerChars: researchResult.text.length,
|
|
899
|
+
chromePid: chrome.pid,
|
|
900
|
+
chromePort: chrome.port,
|
|
901
|
+
chromeHost,
|
|
902
|
+
userDataDir,
|
|
903
|
+
chromeTargetId: lastTargetId,
|
|
904
|
+
tabUrl: lastUrl,
|
|
905
|
+
conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
|
|
906
|
+
controllerPid: process.pid,
|
|
907
|
+
};
|
|
908
|
+
}
|
|
499
909
|
// Helper to normalize text for echo detection (collapse whitespace, lowercase)
|
|
500
|
-
const normalizeForComparison = (text) => text.toLowerCase().replace(/\s+/g,
|
|
910
|
+
const normalizeForComparison = (text) => text.toLowerCase().replace(/\s+/g, " ").trim();
|
|
911
|
+
const expectedConversationId = () => lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined;
|
|
501
912
|
const waitForFreshAssistantResponse = async (baselineNormalized, timeoutMs) => {
|
|
502
913
|
const baselinePrefix = baselineNormalized.length >= 80
|
|
503
914
|
? baselineNormalized.slice(0, Math.min(200, baselineNormalized.length))
|
|
504
|
-
:
|
|
915
|
+
: "";
|
|
505
916
|
const deadline = Date.now() + timeoutMs;
|
|
506
917
|
while (Date.now() < deadline) {
|
|
507
|
-
const snapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined).catch(() => null);
|
|
508
|
-
const text = typeof snapshot?.text ===
|
|
918
|
+
const snapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined, expectedConversationId()).catch(() => null);
|
|
919
|
+
const text = typeof snapshot?.text === "string" ? snapshot.text.trim() : "";
|
|
509
920
|
if (text) {
|
|
510
921
|
const normalized = normalizeForComparison(text);
|
|
511
|
-
const isBaseline = normalized === baselineNormalized ||
|
|
922
|
+
const isBaseline = normalized === baselineNormalized ||
|
|
923
|
+
(baselinePrefix.length > 0 && normalized.startsWith(baselinePrefix));
|
|
512
924
|
if (!isBaseline) {
|
|
513
925
|
return {
|
|
514
926
|
text,
|
|
515
927
|
html: snapshot?.html ?? undefined,
|
|
516
|
-
meta: {
|
|
928
|
+
meta: {
|
|
929
|
+
turnId: snapshot?.turnId ?? undefined,
|
|
930
|
+
messageId: snapshot?.messageId ?? undefined,
|
|
931
|
+
},
|
|
517
932
|
};
|
|
518
933
|
}
|
|
519
934
|
}
|
|
@@ -521,7 +936,19 @@ export async function runBrowserMode(options) {
|
|
|
521
936
|
}
|
|
522
937
|
return null;
|
|
523
938
|
};
|
|
524
|
-
|
|
939
|
+
const waitWithThinkingMonitor = async (operation) => {
|
|
940
|
+
stopThinkingMonitor?.();
|
|
941
|
+
stopThinkingMonitor = startThinkingStatusMonitor(Runtime, logger, {
|
|
942
|
+
intervalMs: options.heartbeatIntervalMs,
|
|
943
|
+
});
|
|
944
|
+
try {
|
|
945
|
+
return await operation();
|
|
946
|
+
}
|
|
947
|
+
finally {
|
|
948
|
+
stopThinkingMonitor?.();
|
|
949
|
+
stopThinkingMonitor = null;
|
|
950
|
+
}
|
|
951
|
+
};
|
|
525
952
|
const recheckDelayMs = Math.max(0, config.assistantRecheckDelayMs ?? 0);
|
|
526
953
|
const recheckTimeoutMs = Math.max(0, config.assistantRecheckTimeoutMs ?? 0);
|
|
527
954
|
const attemptAssistantRecheck = async () => {
|
|
@@ -529,7 +956,7 @@ export async function runBrowserMode(options) {
|
|
|
529
956
|
return null;
|
|
530
957
|
logger(`[browser] Assistant response timed out; waiting ${formatElapsed(recheckDelayMs)} before rechecking conversation.`);
|
|
531
958
|
await raceWithDisconnect(delay(recheckDelayMs));
|
|
532
|
-
await updateConversationHint(
|
|
959
|
+
await updateConversationHint("assistant-recheck", 15_000).catch(() => false);
|
|
533
960
|
await captureRuntimeSnapshot().catch(() => undefined);
|
|
534
961
|
const conversationUrl = await readConversationUrl(Runtime);
|
|
535
962
|
if (conversationUrl && isConversationUrl(conversationUrl)) {
|
|
@@ -544,12 +971,12 @@ export async function runBrowserMode(options) {
|
|
|
544
971
|
// Update session metadata to indicate login is needed
|
|
545
972
|
await emitRuntimeHint();
|
|
546
973
|
throw new BrowserAutomationError(`ChatGPT session expired during recheck: ${sessionValid.reason}. ` +
|
|
547
|
-
`Conversation URL: ${conversationUrl || lastUrl ||
|
|
974
|
+
`Conversation URL: ${conversationUrl || lastUrl || "unknown"}. ` +
|
|
548
975
|
`Please sign in and retry.`, {
|
|
549
|
-
stage:
|
|
976
|
+
stage: "assistant-recheck",
|
|
550
977
|
details: {
|
|
551
978
|
conversationUrl: conversationUrl || lastUrl || null,
|
|
552
|
-
sessionStatus:
|
|
979
|
+
sessionStatus: "needs_login",
|
|
553
980
|
validationReason: sessionValid.reason,
|
|
554
981
|
},
|
|
555
982
|
runtime: {
|
|
@@ -565,170 +992,281 @@ export async function runBrowserMode(options) {
|
|
|
565
992
|
});
|
|
566
993
|
}
|
|
567
994
|
const timeoutMs = recheckTimeoutMs > 0 ? recheckTimeoutMs : config.timeoutMs;
|
|
568
|
-
const rechecked = await
|
|
569
|
-
|
|
995
|
+
const rechecked = await waitWithThinkingMonitor(() => raceWithDisconnect(waitForAssistantOrGeneratedImageResponse({
|
|
996
|
+
Runtime,
|
|
997
|
+
waitForText: () => waitForAssistantResponseWithReload(Runtime, Page, timeoutMs, logger, baselineTurns ?? undefined, expectedConversationId()),
|
|
998
|
+
timeoutMs,
|
|
999
|
+
logger,
|
|
1000
|
+
minTurnIndex: baselineTurns ?? undefined,
|
|
1001
|
+
expectedConversationId: expectedConversationId(),
|
|
1002
|
+
imageOutputRequested,
|
|
1003
|
+
})));
|
|
1004
|
+
logger("Recovered assistant response after delayed recheck");
|
|
570
1005
|
return rechecked;
|
|
571
1006
|
};
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
1007
|
+
const imageOutputRequested = Boolean(options.generateImagePath ||
|
|
1008
|
+
options.outputPath ||
|
|
1009
|
+
options.generateImage);
|
|
1010
|
+
const captureAssistantTurn = async (turnPrompt, label) => {
|
|
1011
|
+
let turnAnswer;
|
|
1012
|
+
try {
|
|
1013
|
+
await updateConversationHint("assistant-wait", 15_000).catch(() => false);
|
|
1014
|
+
turnAnswer = await waitWithThinkingMonitor(() => raceWithDisconnect(waitForAssistantOrGeneratedImageResponse({
|
|
1015
|
+
Runtime,
|
|
1016
|
+
waitForText: () => waitForAssistantResponseWithReload(Runtime, Page, config.timeoutMs, logger, baselineTurns ?? undefined, expectedConversationId()),
|
|
1017
|
+
timeoutMs: config.timeoutMs,
|
|
1018
|
+
logger,
|
|
1019
|
+
minTurnIndex: baselineTurns ?? undefined,
|
|
1020
|
+
expectedConversationId: expectedConversationId(),
|
|
1021
|
+
imageOutputRequested,
|
|
1022
|
+
})));
|
|
1023
|
+
}
|
|
1024
|
+
catch (error) {
|
|
1025
|
+
if (isAssistantResponseTimeoutError(error)) {
|
|
1026
|
+
const rechecked = await attemptAssistantRecheckOrRethrow(attemptAssistantRecheck);
|
|
1027
|
+
if (rechecked) {
|
|
1028
|
+
turnAnswer = rechecked;
|
|
1029
|
+
}
|
|
1030
|
+
else {
|
|
1031
|
+
await updateConversationHint("assistant-timeout", 15_000).catch(() => false);
|
|
1032
|
+
await captureRuntimeSnapshot().catch(() => undefined);
|
|
1033
|
+
const diagnostics = await captureBrowserDiagnostics(Runtime, logger, "assistant-timeout", {
|
|
1034
|
+
Page,
|
|
1035
|
+
sessionId: options.sessionId,
|
|
1036
|
+
}).catch(() => undefined);
|
|
1037
|
+
const runtime = {
|
|
1038
|
+
chromePid: chrome.pid,
|
|
1039
|
+
chromePort: chrome.port,
|
|
1040
|
+
chromeHost,
|
|
1041
|
+
userDataDir,
|
|
1042
|
+
chromeTargetId: lastTargetId,
|
|
1043
|
+
tabUrl: lastUrl,
|
|
1044
|
+
conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
|
|
1045
|
+
controllerPid: process.pid,
|
|
1046
|
+
};
|
|
1047
|
+
throw new BrowserAutomationError("Assistant response timed out before completion; reattach later to capture the answer.", { stage: "assistant-timeout", runtime, diagnostics }, error);
|
|
1048
|
+
}
|
|
580
1049
|
}
|
|
581
1050
|
else {
|
|
582
|
-
|
|
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);
|
|
1051
|
+
throw error;
|
|
595
1052
|
}
|
|
596
1053
|
}
|
|
597
|
-
|
|
598
|
-
|
|
1054
|
+
// Ensure we store the final conversation URL even if the UI updated late.
|
|
1055
|
+
await updateConversationHint("post-response", 15_000);
|
|
1056
|
+
const baselineNormalized = baselineAssistantText
|
|
1057
|
+
? normalizeForComparison(baselineAssistantText)
|
|
1058
|
+
: "";
|
|
1059
|
+
if (baselineNormalized) {
|
|
1060
|
+
const normalizedAnswer = normalizeForComparison(turnAnswer.text ?? "");
|
|
1061
|
+
const baselinePrefix = baselineNormalized.length >= 80
|
|
1062
|
+
? baselineNormalized.slice(0, Math.min(200, baselineNormalized.length))
|
|
1063
|
+
: "";
|
|
1064
|
+
const isBaseline = normalizedAnswer === baselineNormalized ||
|
|
1065
|
+
(baselinePrefix.length > 0 && normalizedAnswer.startsWith(baselinePrefix));
|
|
1066
|
+
if (isBaseline) {
|
|
1067
|
+
logger("Detected stale assistant response; waiting for new response...");
|
|
1068
|
+
const refreshed = await waitForFreshAssistantResponse(baselineNormalized, 15_000);
|
|
1069
|
+
if (refreshed) {
|
|
1070
|
+
turnAnswer = refreshed;
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
599
1073
|
}
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
1074
|
+
let turnAnswerText = turnAnswer.text;
|
|
1075
|
+
const turnAnswerHtml = turnAnswer.html ?? "";
|
|
1076
|
+
const copiedMarkdown = await raceWithDisconnect(withRetries(async () => {
|
|
1077
|
+
const attempt = await captureAssistantMarkdown(Runtime, turnAnswer.meta, logger);
|
|
1078
|
+
if (!attempt) {
|
|
1079
|
+
throw new Error("copy-missing");
|
|
1080
|
+
}
|
|
1081
|
+
return attempt;
|
|
1082
|
+
}, {
|
|
1083
|
+
retries: 2,
|
|
1084
|
+
delayMs: 350,
|
|
1085
|
+
onRetry: (attempt, error) => {
|
|
1086
|
+
if (options.verbose) {
|
|
1087
|
+
logger(`[retry] Markdown capture attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`);
|
|
1088
|
+
}
|
|
1089
|
+
},
|
|
1090
|
+
})).catch(() => null);
|
|
1091
|
+
let turnAnswerMarkdown = copiedMarkdown ?? turnAnswerText;
|
|
1092
|
+
const promptEchoMatcher = buildPromptEchoMatcher(turnPrompt);
|
|
1093
|
+
({ answerText: turnAnswerText, answerMarkdown: turnAnswerMarkdown } =
|
|
1094
|
+
await maybeRecoverLongAssistantResponse({
|
|
1095
|
+
runtime: Runtime,
|
|
1096
|
+
baselineTurns,
|
|
1097
|
+
answerText: turnAnswerText,
|
|
1098
|
+
answerMarkdown: turnAnswerMarkdown,
|
|
1099
|
+
logger,
|
|
1100
|
+
allowMarkdownUpdate: !copiedMarkdown,
|
|
1101
|
+
}));
|
|
1102
|
+
// Final sanity check: ensure we didn't accidentally capture the user prompt instead of the assistant turn.
|
|
1103
|
+
const finalSnapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined, expectedConversationId()).catch(() => null);
|
|
1104
|
+
const finalText = typeof finalSnapshot?.text === "string" ? finalSnapshot.text.trim() : "";
|
|
1105
|
+
if (finalText && finalText !== turnPrompt.trim()) {
|
|
1106
|
+
const trimmedMarkdown = turnAnswerMarkdown.trim();
|
|
1107
|
+
const finalIsEcho = promptEchoMatcher ? promptEchoMatcher.isEcho(finalText) : false;
|
|
1108
|
+
const lengthDelta = finalText.length - trimmedMarkdown.length;
|
|
1109
|
+
const missingCopy = !copiedMarkdown && lengthDelta >= 0;
|
|
1110
|
+
const likelyTruncatedCopy = copiedMarkdown &&
|
|
1111
|
+
trimmedMarkdown.length > 0 &&
|
|
1112
|
+
lengthDelta >= Math.max(12, Math.floor(trimmedMarkdown.length * 0.75));
|
|
1113
|
+
if ((missingCopy || likelyTruncatedCopy) && !finalIsEcho && finalText !== trimmedMarkdown) {
|
|
1114
|
+
logger("Refreshed assistant response via final DOM snapshot");
|
|
1115
|
+
turnAnswerText = finalText;
|
|
1116
|
+
turnAnswerMarkdown = finalText;
|
|
616
1117
|
}
|
|
617
1118
|
}
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
1119
|
+
// Detect prompt echo using normalized comparison (whitespace-insensitive).
|
|
1120
|
+
const alignedEcho = alignPromptEchoPair(turnAnswerText, turnAnswerMarkdown, promptEchoMatcher, copiedMarkdown ? logger : undefined, {
|
|
1121
|
+
text: "Aligned assistant response text to copied markdown after prompt echo",
|
|
1122
|
+
markdown: "Aligned assistant markdown to response text after prompt echo",
|
|
1123
|
+
});
|
|
1124
|
+
turnAnswerText = alignedEcho.answerText;
|
|
1125
|
+
turnAnswerMarkdown = alignedEcho.answerMarkdown;
|
|
1126
|
+
const isPromptEcho = alignedEcho.isEcho;
|
|
1127
|
+
if (isPromptEcho) {
|
|
1128
|
+
logger("Detected prompt echo in response; waiting for actual assistant response...");
|
|
1129
|
+
const deadline = Date.now() + 15_000;
|
|
1130
|
+
let bestText = null;
|
|
1131
|
+
let stableCount = 0;
|
|
1132
|
+
while (Date.now() < deadline) {
|
|
1133
|
+
const snapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined, expectedConversationId()).catch(() => null);
|
|
1134
|
+
const text = typeof snapshot?.text === "string" ? snapshot.text.trim() : "";
|
|
1135
|
+
const isStillEcho = !text || Boolean(promptEchoMatcher?.isEcho(text));
|
|
1136
|
+
if (!isStillEcho) {
|
|
1137
|
+
if (!bestText || text.length > bestText.length) {
|
|
1138
|
+
bestText = text;
|
|
1139
|
+
stableCount = 0;
|
|
1140
|
+
}
|
|
1141
|
+
else if (text === bestText) {
|
|
1142
|
+
stableCount += 1;
|
|
1143
|
+
}
|
|
1144
|
+
if (stableCount >= 2) {
|
|
1145
|
+
break;
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
633
1149
|
}
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
if (finalText && finalText !== promptText.trim()) {
|
|
650
|
-
const trimmedMarkdown = answerMarkdown.trim();
|
|
651
|
-
const finalIsEcho = promptEchoMatcher ? promptEchoMatcher.isEcho(finalText) : false;
|
|
652
|
-
const lengthDelta = finalText.length - trimmedMarkdown.length;
|
|
653
|
-
const missingCopy = !copiedMarkdown && lengthDelta >= 0;
|
|
654
|
-
const likelyTruncatedCopy = copiedMarkdown &&
|
|
655
|
-
trimmedMarkdown.length > 0 &&
|
|
656
|
-
lengthDelta >= Math.max(12, Math.floor(trimmedMarkdown.length * 0.75));
|
|
657
|
-
if ((missingCopy || likelyTruncatedCopy) && !finalIsEcho && finalText !== trimmedMarkdown) {
|
|
658
|
-
logger('Refreshed assistant response via final DOM snapshot');
|
|
659
|
-
answerText = finalText;
|
|
660
|
-
answerMarkdown = finalText;
|
|
661
|
-
}
|
|
662
|
-
}
|
|
663
|
-
// Detect prompt echo using normalized comparison (whitespace-insensitive).
|
|
664
|
-
const alignedEcho = alignPromptEchoPair(answerText, answerMarkdown, promptEchoMatcher, copiedMarkdown ? logger : undefined, {
|
|
665
|
-
text: 'Aligned assistant response text to copied markdown after prompt echo',
|
|
666
|
-
markdown: 'Aligned assistant markdown to response text after prompt echo',
|
|
667
|
-
});
|
|
668
|
-
answerText = alignedEcho.answerText;
|
|
669
|
-
answerMarkdown = alignedEcho.answerMarkdown;
|
|
670
|
-
const isPromptEcho = alignedEcho.isEcho;
|
|
671
|
-
if (isPromptEcho) {
|
|
672
|
-
logger('Detected prompt echo in response; waiting for actual assistant response...');
|
|
673
|
-
const deadline = Date.now() + 15_000;
|
|
674
|
-
let bestText = null;
|
|
675
|
-
let stableCount = 0;
|
|
676
|
-
while (Date.now() < deadline) {
|
|
677
|
-
const snapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined).catch(() => null);
|
|
678
|
-
const text = typeof snapshot?.text === 'string' ? snapshot.text.trim() : '';
|
|
679
|
-
const isStillEcho = !text || Boolean(promptEchoMatcher?.isEcho(text));
|
|
680
|
-
if (!isStillEcho) {
|
|
681
|
-
if (!bestText || text.length > bestText.length) {
|
|
1150
|
+
if (bestText) {
|
|
1151
|
+
logger("Recovered assistant response after detecting prompt echo");
|
|
1152
|
+
turnAnswerText = bestText;
|
|
1153
|
+
turnAnswerMarkdown = bestText;
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
const minAnswerChars = 16;
|
|
1157
|
+
if (turnAnswerText.trim().length > 0 && turnAnswerText.trim().length < minAnswerChars) {
|
|
1158
|
+
const deadline = Date.now() + 12_000;
|
|
1159
|
+
let bestText = turnAnswerText.trim();
|
|
1160
|
+
let stableCycles = 0;
|
|
1161
|
+
while (Date.now() < deadline) {
|
|
1162
|
+
const snapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined, expectedConversationId()).catch(() => null);
|
|
1163
|
+
const text = typeof snapshot?.text === "string" ? snapshot.text.trim() : "";
|
|
1164
|
+
if (text && text.length > bestText.length) {
|
|
682
1165
|
bestText = text;
|
|
683
|
-
|
|
1166
|
+
stableCycles = 0;
|
|
684
1167
|
}
|
|
685
|
-
else
|
|
686
|
-
|
|
1168
|
+
else {
|
|
1169
|
+
stableCycles += 1;
|
|
687
1170
|
}
|
|
688
|
-
if (
|
|
1171
|
+
if (stableCycles >= 3 && bestText.length >= minAnswerChars) {
|
|
689
1172
|
break;
|
|
690
1173
|
}
|
|
1174
|
+
await delay(400);
|
|
691
1175
|
}
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
answerText = bestText;
|
|
697
|
-
answerMarkdown = bestText;
|
|
698
|
-
}
|
|
699
|
-
}
|
|
700
|
-
const minAnswerChars = 16;
|
|
701
|
-
if (answerText.trim().length > 0 && answerText.trim().length < minAnswerChars) {
|
|
702
|
-
const deadline = Date.now() + 12_000;
|
|
703
|
-
let bestText = answerText.trim();
|
|
704
|
-
let stableCycles = 0;
|
|
705
|
-
while (Date.now() < deadline) {
|
|
706
|
-
const snapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined).catch(() => null);
|
|
707
|
-
const text = typeof snapshot?.text === 'string' ? snapshot.text.trim() : '';
|
|
708
|
-
if (text && text.length > bestText.length) {
|
|
709
|
-
bestText = text;
|
|
710
|
-
stableCycles = 0;
|
|
711
|
-
}
|
|
712
|
-
else {
|
|
713
|
-
stableCycles += 1;
|
|
714
|
-
}
|
|
715
|
-
if (stableCycles >= 3 && bestText.length >= minAnswerChars) {
|
|
716
|
-
break;
|
|
1176
|
+
if (bestText.length > turnAnswerText.trim().length) {
|
|
1177
|
+
logger("Refreshed short assistant response from latest DOM snapshot");
|
|
1178
|
+
turnAnswerText = bestText;
|
|
1179
|
+
turnAnswerMarkdown = bestText;
|
|
717
1180
|
}
|
|
718
|
-
await delay(400);
|
|
719
1181
|
}
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
answerText
|
|
723
|
-
answerMarkdown
|
|
1182
|
+
return {
|
|
1183
|
+
label,
|
|
1184
|
+
answerText: turnAnswerText,
|
|
1185
|
+
answerMarkdown: turnAnswerMarkdown,
|
|
1186
|
+
answerHtml: turnAnswerHtml,
|
|
1187
|
+
};
|
|
1188
|
+
};
|
|
1189
|
+
const turns = [];
|
|
1190
|
+
const initialTurn = await captureAssistantTurn(promptText, "Initial response");
|
|
1191
|
+
turns.push(initialTurn);
|
|
1192
|
+
answerText = initialTurn.answerText;
|
|
1193
|
+
answerMarkdown = initialTurn.answerMarkdown;
|
|
1194
|
+
answerHtml = initialTurn.answerHtml;
|
|
1195
|
+
for (let index = 0; index < followUpPrompts.length; index += 1) {
|
|
1196
|
+
const followUpPrompt = followUpPrompts[index];
|
|
1197
|
+
logger(`[browser] Sending follow-up ${index + 1}/${followUpPrompts.length}`);
|
|
1198
|
+
await acquireProfileLockIfNeeded();
|
|
1199
|
+
try {
|
|
1200
|
+
await raceWithDisconnect(clearPromptComposer(Runtime, logger));
|
|
1201
|
+
await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
|
|
1202
|
+
const submission = await runSubmissionWithRecovery({
|
|
1203
|
+
prompt: followUpPrompt,
|
|
1204
|
+
attachments: [],
|
|
1205
|
+
submit: (submissionPrompt, submissionAttachments) => raceWithDisconnect(submitOnce(submissionPrompt, submissionAttachments)),
|
|
1206
|
+
reloadPromptComposer,
|
|
1207
|
+
prepareFallbackSubmission: async () => {
|
|
1208
|
+
await raceWithDisconnect(clearPromptComposer(Runtime, logger));
|
|
1209
|
+
await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
|
|
1210
|
+
},
|
|
1211
|
+
logger,
|
|
1212
|
+
});
|
|
1213
|
+
baselineTurns = submission.baselineTurns;
|
|
1214
|
+
baselineAssistantText = submission.baselineAssistantText;
|
|
1215
|
+
}
|
|
1216
|
+
finally {
|
|
1217
|
+
await releaseProfileLockIfHeld();
|
|
724
1218
|
}
|
|
1219
|
+
const turn = await captureAssistantTurn(followUpPrompt, `Follow-up ${index + 1}`);
|
|
1220
|
+
turns.push({ ...turn, prompt: followUpPrompt });
|
|
1221
|
+
answerText = turn.answerText;
|
|
1222
|
+
answerMarkdown = turn.answerMarkdown;
|
|
1223
|
+
answerHtml = turn.answerHtml;
|
|
1224
|
+
}
|
|
1225
|
+
if (turns.length > 1) {
|
|
1226
|
+
const formatted = formatBrowserTurnTranscript(turns);
|
|
1227
|
+
answerText = formatted.answerText;
|
|
1228
|
+
answerMarkdown = formatted.answerMarkdown;
|
|
1229
|
+
answerHtml = "";
|
|
725
1230
|
}
|
|
726
1231
|
if (connectionClosedUnexpectedly) {
|
|
727
1232
|
// Bail out on mid-run disconnects so the session stays reattachable.
|
|
728
|
-
throw new Error(
|
|
1233
|
+
throw new Error("Chrome disconnected before completion");
|
|
729
1234
|
}
|
|
730
|
-
|
|
731
|
-
|
|
1235
|
+
const imageArtifacts = await collectGeneratedImageArtifacts({
|
|
1236
|
+
Runtime,
|
|
1237
|
+
Network,
|
|
1238
|
+
logger,
|
|
1239
|
+
minTurnIndex: imageArtifactMinTurnIndex,
|
|
1240
|
+
sessionId: options.sessionId,
|
|
1241
|
+
generateImagePath: options.generateImagePath,
|
|
1242
|
+
outputPath: options.outputPath,
|
|
1243
|
+
answerText,
|
|
1244
|
+
waitTimeoutMs: options.config?.timeoutMs,
|
|
1245
|
+
});
|
|
1246
|
+
answerText = imageArtifacts.answerText || answerText;
|
|
1247
|
+
if (imageArtifacts.markdownSuffix) {
|
|
1248
|
+
answerMarkdown += imageArtifacts.markdownSuffix;
|
|
1249
|
+
}
|
|
1250
|
+
const savedImageArtifacts = appendArtifacts(undefined, imageArtifacts.savedImages);
|
|
1251
|
+
const transcriptArtifact = await saveOptionalArtifact(() => saveBrowserTranscriptArtifact({
|
|
1252
|
+
sessionId: options.sessionId,
|
|
1253
|
+
prompt: promptText,
|
|
1254
|
+
answerMarkdown,
|
|
1255
|
+
conversationUrl: lastUrl,
|
|
1256
|
+
artifacts: savedImageArtifacts,
|
|
1257
|
+
logger,
|
|
1258
|
+
}), logger);
|
|
1259
|
+
const savedArtifacts = appendArtifacts(savedImageArtifacts, [transcriptArtifact]);
|
|
1260
|
+
const archive = await maybeArchiveCompletedConversation({
|
|
1261
|
+
Runtime,
|
|
1262
|
+
logger,
|
|
1263
|
+
config,
|
|
1264
|
+
conversationUrl: lastUrl,
|
|
1265
|
+
followUpCount: followUpPrompts.length,
|
|
1266
|
+
requiredArtifactsSaved: Boolean(transcriptArtifact) &&
|
|
1267
|
+
imageArtifacts.savedImages.length === imageArtifacts.imageCount,
|
|
1268
|
+
});
|
|
1269
|
+
runStatus = "complete";
|
|
732
1270
|
const durationMs = Date.now() - startedAt;
|
|
733
1271
|
const answerChars = answerText.length;
|
|
734
1272
|
const answerTokens = estimateTokenCount(answerMarkdown);
|
|
@@ -736,6 +1274,10 @@ export async function runBrowserMode(options) {
|
|
|
736
1274
|
answerText,
|
|
737
1275
|
answerMarkdown,
|
|
738
1276
|
answerHtml: answerHtml.length > 0 ? answerHtml : undefined,
|
|
1277
|
+
artifacts: savedArtifacts,
|
|
1278
|
+
generatedImages: imageArtifacts.generatedImages,
|
|
1279
|
+
savedImages: imageArtifacts.savedImages,
|
|
1280
|
+
archive,
|
|
739
1281
|
tookMs: durationMs,
|
|
740
1282
|
answerTokens,
|
|
741
1283
|
answerChars,
|
|
@@ -745,15 +1287,16 @@ export async function runBrowserMode(options) {
|
|
|
745
1287
|
userDataDir,
|
|
746
1288
|
chromeTargetId: lastTargetId,
|
|
747
1289
|
tabUrl: lastUrl,
|
|
1290
|
+
conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
|
|
748
1291
|
controllerPid: process.pid,
|
|
749
1292
|
};
|
|
750
1293
|
}
|
|
751
1294
|
catch (error) {
|
|
752
1295
|
const normalizedError = error instanceof Error ? error : new Error(String(error));
|
|
753
|
-
stopThinkingMonitor?.();
|
|
754
1296
|
const socketClosed = connectionClosedUnexpectedly || isWebSocketClosureError(normalizedError);
|
|
755
1297
|
connectionClosedUnexpectedly = connectionClosedUnexpectedly || socketClosed;
|
|
756
|
-
|
|
1298
|
+
const preservedErrorKind = classifyPreservedBrowserError(normalizedError, config.headless);
|
|
1299
|
+
if (preservedErrorKind === "cloudflare-challenge") {
|
|
757
1300
|
preserveBrowserOnError = true;
|
|
758
1301
|
const runtime = {
|
|
759
1302
|
chromePid: chrome.pid,
|
|
@@ -767,28 +1310,34 @@ export async function runBrowserMode(options) {
|
|
|
767
1310
|
const reuseProfileHint = `oracle --engine browser --browser-manual-login ` +
|
|
768
1311
|
`--browser-manual-login-profile-dir ${JSON.stringify(userDataDir)}`;
|
|
769
1312
|
await emitRuntimeHint();
|
|
770
|
-
logger(
|
|
1313
|
+
logger("Cloudflare challenge detected; leaving browser open so you can complete the check.");
|
|
771
1314
|
logger(`Reuse this browser profile with: ${reuseProfileHint}`);
|
|
772
|
-
throw new BrowserAutomationError(
|
|
773
|
-
stage:
|
|
1315
|
+
throw new BrowserAutomationError("Cloudflare challenge detected. Complete the “Just a moment…” check in the open browser, then rerun.", {
|
|
1316
|
+
stage: "cloudflare-challenge",
|
|
774
1317
|
runtime,
|
|
775
1318
|
reuseProfileHint,
|
|
776
1319
|
}, normalizedError);
|
|
777
1320
|
}
|
|
1321
|
+
if (preservedErrorKind === "reattachable-capture") {
|
|
1322
|
+
preserveBrowserOnError = true;
|
|
1323
|
+
await emitRuntimeHint();
|
|
1324
|
+
logger("Assistant capture incomplete; leaving browser open for reattach.");
|
|
1325
|
+
throw normalizedError;
|
|
1326
|
+
}
|
|
778
1327
|
if (!socketClosed) {
|
|
779
1328
|
logger(`Failed to complete ChatGPT run: ${normalizedError.message}`);
|
|
780
|
-
if ((config.debug || process.env.CHATGPT_DEVTOOLS_TRACE ===
|
|
1329
|
+
if ((config.debug || process.env.CHATGPT_DEVTOOLS_TRACE === "1") && normalizedError.stack) {
|
|
781
1330
|
logger(normalizedError.stack);
|
|
782
1331
|
}
|
|
783
1332
|
throw normalizedError;
|
|
784
1333
|
}
|
|
785
|
-
if ((config.debug || process.env.CHATGPT_DEVTOOLS_TRACE ===
|
|
1334
|
+
if ((config.debug || process.env.CHATGPT_DEVTOOLS_TRACE === "1") && normalizedError.stack) {
|
|
786
1335
|
logger(`Chrome window closed before completion: ${normalizedError.message}`);
|
|
787
1336
|
logger(normalizedError.stack);
|
|
788
1337
|
}
|
|
789
1338
|
await emitRuntimeHint();
|
|
790
|
-
throw new BrowserAutomationError(
|
|
791
|
-
stage:
|
|
1339
|
+
throw new BrowserAutomationError("Chrome window closed before oracle finished. Please keep it open until completion.", {
|
|
1340
|
+
stage: "connection-lost",
|
|
792
1341
|
runtime: {
|
|
793
1342
|
chromePid: chrome.pid,
|
|
794
1343
|
chromePort: chrome.port,
|
|
@@ -812,16 +1361,70 @@ export async function runBrowserMode(options) {
|
|
|
812
1361
|
// Close the isolated tab once the response has been fully captured to prevent
|
|
813
1362
|
// tab accumulation across repeated runs. Keep the tab open on incomplete runs
|
|
814
1363
|
// so reattach can recover the response.
|
|
815
|
-
if (
|
|
1364
|
+
if (shouldCloseOwnedRunTargetAfterRun({
|
|
1365
|
+
runStatus,
|
|
1366
|
+
ownsTarget,
|
|
1367
|
+
keepBrowser: effectiveKeepBrowser,
|
|
1368
|
+
}) &&
|
|
1369
|
+
isolatedTargetId &&
|
|
1370
|
+
chrome?.port) {
|
|
816
1371
|
await closeTab(chrome.port, isolatedTargetId, logger, chromeHost).catch(() => undefined);
|
|
817
1372
|
}
|
|
1373
|
+
let keepBrowserOpen = effectiveKeepBrowser || preserveBrowserOnError;
|
|
1374
|
+
let cleanupProfileLock = null;
|
|
1375
|
+
let terminatedRecordedChrome = false;
|
|
1376
|
+
let otherActiveBrowserTabLeases = null;
|
|
1377
|
+
const hasOtherActiveLeases = async () => {
|
|
1378
|
+
if (!manualLogin || !tabLease) {
|
|
1379
|
+
return false;
|
|
1380
|
+
}
|
|
1381
|
+
if (otherActiveBrowserTabLeases === null) {
|
|
1382
|
+
otherActiveBrowserTabLeases = await hasOtherActiveBrowserTabLeases(userDataDir, tabLease.id);
|
|
1383
|
+
}
|
|
1384
|
+
return otherActiveBrowserTabLeases;
|
|
1385
|
+
};
|
|
1386
|
+
if (runStatus === "complete" &&
|
|
1387
|
+
manualLogin &&
|
|
1388
|
+
!connectionClosedUnexpectedly &&
|
|
1389
|
+
chrome?.port &&
|
|
1390
|
+
ownsTarget) {
|
|
1391
|
+
const otherLeasesActive = await hasOtherActiveLeases().catch(() => true);
|
|
1392
|
+
if (!otherLeasesActive) {
|
|
1393
|
+
await closeBlankChromeTabs(chrome.port, logger, chromeHost, {
|
|
1394
|
+
excludeTargetIds: [isolatedTargetId, lastTargetId],
|
|
1395
|
+
}).catch(() => undefined);
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
if (!keepBrowserOpen && manualLogin && tabLease) {
|
|
1399
|
+
const cleanupLockTimeoutMs = Math.max(0, config.profileLockTimeoutMs ?? 0);
|
|
1400
|
+
if (cleanupLockTimeoutMs > 0) {
|
|
1401
|
+
cleanupProfileLock = await acquireProfileRunLock(userDataDir, {
|
|
1402
|
+
timeoutMs: cleanupLockTimeoutMs,
|
|
1403
|
+
logger,
|
|
1404
|
+
sessionId: options.sessionId,
|
|
1405
|
+
}).catch(() => null);
|
|
1406
|
+
}
|
|
1407
|
+
keepBrowserOpen = await hasOtherActiveLeases().catch(() => false);
|
|
1408
|
+
if (keepBrowserOpen) {
|
|
1409
|
+
logger("[browser] Other ChatGPT tab leases still active; leaving shared Chrome running.");
|
|
1410
|
+
}
|
|
1411
|
+
else if (reusedChrome && !connectionClosedUnexpectedly) {
|
|
1412
|
+
terminatedRecordedChrome = await terminateRecordedChromeForProfile(userDataDir, logger).catch(() => false);
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
if (tabLease) {
|
|
1416
|
+
const handle = tabLease;
|
|
1417
|
+
tabLease = null;
|
|
1418
|
+
await handle.release().catch(() => undefined);
|
|
1419
|
+
}
|
|
818
1420
|
removeDialogHandler?.();
|
|
819
1421
|
removeTerminationHooks?.();
|
|
820
|
-
const keepBrowserOpen = effectiveKeepBrowser || preserveBrowserOnError;
|
|
821
1422
|
if (!keepBrowserOpen) {
|
|
822
1423
|
if (!connectionClosedUnexpectedly) {
|
|
823
1424
|
try {
|
|
824
|
-
|
|
1425
|
+
if (!terminatedRecordedChrome) {
|
|
1426
|
+
await chrome.kill();
|
|
1427
|
+
}
|
|
825
1428
|
}
|
|
826
1429
|
catch {
|
|
827
1430
|
// ignore kill failures
|
|
@@ -834,7 +1437,7 @@ export async function runBrowserMode(options) {
|
|
|
834
1437
|
});
|
|
835
1438
|
if (shouldCleanup) {
|
|
836
1439
|
// Preserve the persistent manual-login profile, but clear stale reattach hints.
|
|
837
|
-
await cleanupStaleProfileState(userDataDir, logger, { lockRemovalMode:
|
|
1440
|
+
await cleanupStaleProfileState(userDataDir, logger, { lockRemovalMode: "never" }).catch(() => undefined);
|
|
838
1441
|
}
|
|
839
1442
|
}
|
|
840
1443
|
else {
|
|
@@ -845,8 +1448,16 @@ export async function runBrowserMode(options) {
|
|
|
845
1448
|
logger(`Cleanup ${runStatus} • ${totalSeconds.toFixed(1)}s total`);
|
|
846
1449
|
}
|
|
847
1450
|
}
|
|
848
|
-
else
|
|
849
|
-
|
|
1451
|
+
else {
|
|
1452
|
+
detachKeptChromeProcess(chrome);
|
|
1453
|
+
if (!connectionClosedUnexpectedly) {
|
|
1454
|
+
logger(`Chrome left running on port ${chrome.port} with profile ${userDataDir}`);
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
if (cleanupProfileLock) {
|
|
1458
|
+
const handle = cleanupProfileLock;
|
|
1459
|
+
cleanupProfileLock = null;
|
|
1460
|
+
await handle.release().catch(() => undefined);
|
|
850
1461
|
}
|
|
851
1462
|
}
|
|
852
1463
|
}
|
|
@@ -866,28 +1477,28 @@ async function pickAvailableDebugPort(preferredPort, logger) {
|
|
|
866
1477
|
async function isPortAvailable(port) {
|
|
867
1478
|
return new Promise((resolve) => {
|
|
868
1479
|
const server = net.createServer();
|
|
869
|
-
server.once(
|
|
870
|
-
server.once(
|
|
1480
|
+
server.once("error", () => resolve(false));
|
|
1481
|
+
server.once("listening", () => {
|
|
871
1482
|
server.close(() => resolve(true));
|
|
872
1483
|
});
|
|
873
|
-
server.listen(port,
|
|
1484
|
+
server.listen(port, "127.0.0.1");
|
|
874
1485
|
});
|
|
875
1486
|
}
|
|
876
1487
|
async function findEphemeralPort() {
|
|
877
1488
|
return new Promise((resolve, reject) => {
|
|
878
1489
|
const server = net.createServer();
|
|
879
|
-
server.once(
|
|
1490
|
+
server.once("error", (error) => {
|
|
880
1491
|
server.close();
|
|
881
1492
|
reject(error);
|
|
882
1493
|
});
|
|
883
|
-
server.listen(0,
|
|
1494
|
+
server.listen(0, "127.0.0.1", () => {
|
|
884
1495
|
const address = server.address();
|
|
885
|
-
if (address && typeof address ===
|
|
1496
|
+
if (address && typeof address === "object") {
|
|
886
1497
|
const port = address.port;
|
|
887
1498
|
server.close(() => resolve(port));
|
|
888
1499
|
}
|
|
889
1500
|
else {
|
|
890
|
-
server.close(() => reject(new Error(
|
|
1501
|
+
server.close(() => reject(new Error("Failed to acquire ephemeral port")));
|
|
891
1502
|
}
|
|
892
1503
|
});
|
|
893
1504
|
});
|
|
@@ -906,20 +1517,20 @@ async function waitForLogin({ runtime, logger, appliedCookies, manualLogin, time
|
|
|
906
1517
|
}
|
|
907
1518
|
catch (error) {
|
|
908
1519
|
const message = error instanceof Error ? error.message : String(error);
|
|
909
|
-
const loginDetected = message?.toLowerCase().includes(
|
|
910
|
-
const sessionMissing = message?.toLowerCase().includes(
|
|
1520
|
+
const loginDetected = message?.toLowerCase().includes("login button");
|
|
1521
|
+
const sessionMissing = message?.toLowerCase().includes("session not detected");
|
|
911
1522
|
if (!loginDetected && !sessionMissing) {
|
|
912
1523
|
throw error;
|
|
913
1524
|
}
|
|
914
1525
|
const now = Date.now();
|
|
915
1526
|
if (now - lastNotice > 5000) {
|
|
916
|
-
logger(
|
|
1527
|
+
logger("Manual login mode: please sign into chatgpt.com in the opened Chrome window; waiting for session to appear...");
|
|
917
1528
|
lastNotice = now;
|
|
918
1529
|
}
|
|
919
1530
|
await delay(1000);
|
|
920
1531
|
}
|
|
921
1532
|
}
|
|
922
|
-
throw new Error(
|
|
1533
|
+
throw new Error("Manual login mode timed out waiting for ChatGPT session; please sign in and retry.");
|
|
923
1534
|
}
|
|
924
1535
|
async function maybeRecoverLongAssistantResponse({ runtime, baselineTurns, answerText, answerMarkdown, logger, allowMarkdownUpdate, }) {
|
|
925
1536
|
// Learned: long streaming responses can still be rendering after initial capture.
|
|
@@ -933,7 +1544,7 @@ async function maybeRecoverLongAssistantResponse({ runtime, baselineTurns, answe
|
|
|
933
1544
|
let bestText = answerText;
|
|
934
1545
|
for (let i = 0; i < 5; i++) {
|
|
935
1546
|
const laterSnapshot = await readAssistantSnapshot(runtime, baselineTurns ?? undefined).catch(() => null);
|
|
936
|
-
const laterText = typeof laterSnapshot?.text ===
|
|
1547
|
+
const laterText = typeof laterSnapshot?.text === "string" ? laterSnapshot.text.trim() : "";
|
|
937
1548
|
if (laterText.length > bestLength) {
|
|
938
1549
|
bestLength = laterText.length;
|
|
939
1550
|
bestText = laterText;
|
|
@@ -954,24 +1565,69 @@ async function maybeRecoverLongAssistantResponse({ runtime, baselineTurns, answe
|
|
|
954
1565
|
}
|
|
955
1566
|
async function _assertNavigatedToHttp(runtime, _logger, timeoutMs = 10_000) {
|
|
956
1567
|
const deadline = Date.now() + timeoutMs;
|
|
957
|
-
let lastUrl =
|
|
1568
|
+
let lastUrl = "";
|
|
958
1569
|
while (Date.now() < deadline) {
|
|
959
1570
|
const { result } = await runtime.evaluate({
|
|
960
1571
|
expression: 'typeof location === "object" && location.href ? location.href : ""',
|
|
961
1572
|
returnByValue: true,
|
|
962
1573
|
});
|
|
963
|
-
const url = typeof result?.value ===
|
|
1574
|
+
const url = typeof result?.value === "string" ? result.value : "";
|
|
964
1575
|
lastUrl = url;
|
|
965
1576
|
if (/^https?:\/\//i.test(url)) {
|
|
966
1577
|
return url;
|
|
967
1578
|
}
|
|
968
1579
|
await delay(250);
|
|
969
1580
|
}
|
|
970
|
-
throw new BrowserAutomationError(
|
|
971
|
-
stage:
|
|
972
|
-
details: { url: lastUrl ||
|
|
1581
|
+
throw new BrowserAutomationError("ChatGPT session not detected; page never left new tab.", {
|
|
1582
|
+
stage: "execute-browser",
|
|
1583
|
+
details: { url: lastUrl || "(empty)" },
|
|
973
1584
|
});
|
|
974
1585
|
}
|
|
1586
|
+
function detachKeptChromeProcess(chrome) {
|
|
1587
|
+
try {
|
|
1588
|
+
chrome.process?.unref();
|
|
1589
|
+
}
|
|
1590
|
+
catch {
|
|
1591
|
+
// Best-effort only; cleanup should not mask the original browser result.
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
async function acquireManualLoginChromeForRun(userDataDir, config, logger, sessionId, deps = {}) {
|
|
1595
|
+
const maybeReuse = deps.maybeReuse ?? maybeReuseRunningChrome;
|
|
1596
|
+
const launch = deps.launch ?? launchChrome;
|
|
1597
|
+
const lockTimeoutMs = Math.max(0, config.profileLockTimeoutMs ?? 0);
|
|
1598
|
+
let launchLock = null;
|
|
1599
|
+
if (lockTimeoutMs > 0) {
|
|
1600
|
+
launchLock = await acquireProfileRunLock(userDataDir, {
|
|
1601
|
+
timeoutMs: lockTimeoutMs,
|
|
1602
|
+
logger,
|
|
1603
|
+
sessionId,
|
|
1604
|
+
});
|
|
1605
|
+
}
|
|
1606
|
+
try {
|
|
1607
|
+
const reusedChrome = await maybeReuse(userDataDir, logger, {
|
|
1608
|
+
waitForPortMs: config.reuseChromeWaitMs,
|
|
1609
|
+
});
|
|
1610
|
+
const chrome = reusedChrome ??
|
|
1611
|
+
(await launch({
|
|
1612
|
+
...config,
|
|
1613
|
+
remoteChrome: config.remoteChrome,
|
|
1614
|
+
}, userDataDir, logger));
|
|
1615
|
+
// Persist while the launch lock is still held so parallel callers reuse
|
|
1616
|
+
// this Chrome instead of racing to start another one on the same profile.
|
|
1617
|
+
if (chrome.port) {
|
|
1618
|
+
await writeDevToolsActivePort(userDataDir, chrome.port);
|
|
1619
|
+
if (!reusedChrome && chrome.pid) {
|
|
1620
|
+
await writeChromePid(userDataDir, chrome.pid);
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
return { chrome, reusedChrome };
|
|
1624
|
+
}
|
|
1625
|
+
finally {
|
|
1626
|
+
if (launchLock) {
|
|
1627
|
+
await launchLock.release().catch(() => undefined);
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
975
1631
|
async function maybeReuseRunningChrome(userDataDir, logger, options = {}) {
|
|
976
1632
|
const waitForPortMs = Math.max(0, options.waitForPortMs ?? 0);
|
|
977
1633
|
let port = await readDevToolsPort(userDataDir);
|
|
@@ -983,17 +1639,38 @@ async function maybeReuseRunningChrome(userDataDir, logger, options = {}) {
|
|
|
983
1639
|
port = await readDevToolsPort(userDataDir);
|
|
984
1640
|
}
|
|
985
1641
|
}
|
|
986
|
-
|
|
987
|
-
|
|
1642
|
+
let pid = await readChromePid(userDataDir);
|
|
1643
|
+
if (!port) {
|
|
1644
|
+
const discovered = await findRunningChromeDebugTargetForProfile(userDataDir);
|
|
1645
|
+
if (!discovered)
|
|
1646
|
+
return null;
|
|
1647
|
+
const discoveredProbe = await (options.probe ?? verifyDevToolsReachable)({
|
|
1648
|
+
port: discovered.port,
|
|
1649
|
+
});
|
|
1650
|
+
if (!discoveredProbe.ok) {
|
|
1651
|
+
logger(`Discovered Chrome for ${userDataDir} on port ${discovered.port} but it was unreachable (${discoveredProbe.error}); launching new Chrome.`);
|
|
1652
|
+
return null;
|
|
1653
|
+
}
|
|
1654
|
+
await writeDevToolsActivePort(userDataDir, discovered.port);
|
|
1655
|
+
await writeChromePid(userDataDir, discovered.pid);
|
|
1656
|
+
port = discovered.port;
|
|
1657
|
+
pid = discovered.pid;
|
|
1658
|
+
logger(`Discovered running Chrome for ${userDataDir}; reusing (DevTools port ${port}, pid ${pid})`);
|
|
1659
|
+
return {
|
|
1660
|
+
port,
|
|
1661
|
+
pid,
|
|
1662
|
+
kill: async () => { },
|
|
1663
|
+
process: undefined,
|
|
1664
|
+
};
|
|
1665
|
+
}
|
|
988
1666
|
const probe = await (options.probe ?? verifyDevToolsReachable)({ port });
|
|
989
1667
|
if (!probe.ok) {
|
|
990
1668
|
logger(`DevToolsActivePort found for ${userDataDir} but unreachable (${probe.error}); launching new Chrome.`);
|
|
991
1669
|
// Safe cleanup: remove stale DevToolsActivePort; only remove lock files if this was an Oracle-owned pid that died.
|
|
992
|
-
await cleanupStaleProfileState(userDataDir, logger, { lockRemovalMode:
|
|
1670
|
+
await cleanupStaleProfileState(userDataDir, logger, { lockRemovalMode: "if_oracle_pid_dead" });
|
|
993
1671
|
return null;
|
|
994
1672
|
}
|
|
995
|
-
|
|
996
|
-
logger(`Found running Chrome for ${userDataDir}; reusing (DevTools port ${port}${pid ? `, pid ${pid}` : ''})`);
|
|
1673
|
+
logger(`Found running Chrome for ${userDataDir}; reusing (DevTools port ${port}${pid ? `, pid ${pid}` : ""})`);
|
|
997
1674
|
return {
|
|
998
1675
|
port,
|
|
999
1676
|
pid: pid ?? undefined,
|
|
@@ -1004,13 +1681,16 @@ async function maybeReuseRunningChrome(userDataDir, logger, options = {}) {
|
|
|
1004
1681
|
async function runRemoteBrowserMode(promptText, attachments, config, logger, options) {
|
|
1005
1682
|
const remoteChromeConfig = config.remoteChrome;
|
|
1006
1683
|
if (!remoteChromeConfig) {
|
|
1007
|
-
throw new Error(
|
|
1684
|
+
throw new Error("Remote Chrome configuration missing. Pass --remote-chrome <host:port> to use this mode.");
|
|
1008
1685
|
}
|
|
1009
1686
|
const { host, port } = remoteChromeConfig;
|
|
1010
1687
|
logger(`Connecting to remote Chrome at ${host}:${port}`);
|
|
1011
1688
|
let client = null;
|
|
1012
1689
|
let remoteTargetId = null;
|
|
1690
|
+
let tabLease = null;
|
|
1013
1691
|
let lastUrl;
|
|
1692
|
+
let attachedExistingTab = false;
|
|
1693
|
+
let ownsTarget = true;
|
|
1014
1694
|
const runtimeHintCb = options.runtimeHintCb;
|
|
1015
1695
|
const emitRuntimeHint = async () => {
|
|
1016
1696
|
if (!runtimeHintCb)
|
|
@@ -1019,10 +1699,19 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
|
|
|
1019
1699
|
await runtimeHintCb({
|
|
1020
1700
|
chromePort: port,
|
|
1021
1701
|
chromeHost: host,
|
|
1702
|
+
chromeBrowserWSEndpoint: browserWSEndpoint,
|
|
1703
|
+
chromeProfileRoot,
|
|
1022
1704
|
chromeTargetId: remoteTargetId ?? undefined,
|
|
1023
1705
|
tabUrl: lastUrl,
|
|
1706
|
+
conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
|
|
1024
1707
|
controllerPid: process.pid,
|
|
1025
1708
|
});
|
|
1709
|
+
await tabLease?.update({
|
|
1710
|
+
chromeHost: host,
|
|
1711
|
+
chromePort: port,
|
|
1712
|
+
chromeTargetId: remoteTargetId ?? undefined,
|
|
1713
|
+
tabUrl: lastUrl,
|
|
1714
|
+
});
|
|
1026
1715
|
}
|
|
1027
1716
|
catch (error) {
|
|
1028
1717
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -1030,41 +1719,91 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
|
|
|
1030
1719
|
}
|
|
1031
1720
|
};
|
|
1032
1721
|
const startedAt = Date.now();
|
|
1033
|
-
let answerText =
|
|
1034
|
-
let answerMarkdown =
|
|
1035
|
-
let answerHtml =
|
|
1722
|
+
let answerText = "";
|
|
1723
|
+
let answerMarkdown = "";
|
|
1724
|
+
let answerHtml = "";
|
|
1036
1725
|
let connectionClosedUnexpectedly = false;
|
|
1726
|
+
let runStatus = "attempted";
|
|
1037
1727
|
let stopThinkingMonitor = null;
|
|
1038
1728
|
let removeDialogHandler = null;
|
|
1729
|
+
let connection = null;
|
|
1730
|
+
const browserWSEndpoint = config.remoteChromeBrowserWSEndpoint ?? undefined;
|
|
1731
|
+
const chromeProfileRoot = config.remoteChromeProfileRoot ?? undefined;
|
|
1039
1732
|
try {
|
|
1040
|
-
const
|
|
1041
|
-
|
|
1042
|
-
|
|
1733
|
+
const remoteLeaseProfileDir = config.browserTabRef
|
|
1734
|
+
? null
|
|
1735
|
+
: resolveRemoteTabLeaseProfileDir(config);
|
|
1736
|
+
if (remoteLeaseProfileDir) {
|
|
1737
|
+
await mkdir(remoteLeaseProfileDir, { recursive: true });
|
|
1738
|
+
tabLease = await acquireBrowserTabLease(remoteLeaseProfileDir, {
|
|
1739
|
+
maxConcurrentTabs: config.maxConcurrentTabs,
|
|
1740
|
+
timeoutMs: config.timeoutMs,
|
|
1741
|
+
logger,
|
|
1742
|
+
sessionId: options.sessionId,
|
|
1743
|
+
chromeHost: host,
|
|
1744
|
+
chromePort: port,
|
|
1745
|
+
});
|
|
1746
|
+
}
|
|
1747
|
+
if (config.browserTabRef) {
|
|
1748
|
+
const attached = await connectToExistingChatGptTab({
|
|
1749
|
+
host,
|
|
1750
|
+
port,
|
|
1751
|
+
ref: config.browserTabRef,
|
|
1752
|
+
});
|
|
1753
|
+
client = attached.client;
|
|
1754
|
+
remoteTargetId = attached.targetId ?? null;
|
|
1755
|
+
lastUrl = attached.tab.url || lastUrl;
|
|
1756
|
+
attachedExistingTab = true;
|
|
1757
|
+
ownsTarget = false;
|
|
1758
|
+
logger(`Attached to existing remote ChatGPT tab ${attached.targetId}${attached.tab.url ? ` (${attached.tab.url})` : ""}`);
|
|
1759
|
+
}
|
|
1760
|
+
else {
|
|
1761
|
+
connection = await connectToRemoteChrome(host, port, logger, config.url, browserWSEndpoint, {
|
|
1762
|
+
approvalWaitMs: config.attachRunning && browserWSEndpoint ? 20_000 : undefined,
|
|
1763
|
+
});
|
|
1764
|
+
client = connection.client;
|
|
1765
|
+
remoteTargetId = connection.targetId ?? null;
|
|
1766
|
+
ownsTarget = true;
|
|
1767
|
+
}
|
|
1768
|
+
if (tabLease && remoteTargetId) {
|
|
1769
|
+
await tabLease.update({
|
|
1770
|
+
chromeHost: host,
|
|
1771
|
+
chromePort: port,
|
|
1772
|
+
chromeTargetId: remoteTargetId,
|
|
1773
|
+
});
|
|
1774
|
+
}
|
|
1043
1775
|
await emitRuntimeHint();
|
|
1044
1776
|
const markConnectionLost = () => {
|
|
1045
1777
|
connectionClosedUnexpectedly = true;
|
|
1046
1778
|
};
|
|
1047
|
-
client.on(
|
|
1779
|
+
client.on("disconnect", markConnectionLost);
|
|
1048
1780
|
const { Network, Page, Runtime, Input, DOM } = client;
|
|
1049
1781
|
const domainEnablers = [Network.enable({}), Page.enable(), Runtime.enable()];
|
|
1050
|
-
if (DOM && typeof DOM.enable ===
|
|
1782
|
+
if (DOM && typeof DOM.enable === "function") {
|
|
1051
1783
|
domainEnablers.push(DOM.enable());
|
|
1052
1784
|
}
|
|
1053
1785
|
await Promise.all(domainEnablers);
|
|
1054
1786
|
removeDialogHandler = installJavaScriptDialogAutoDismissal(Page, logger);
|
|
1055
1787
|
// Skip cookie sync for remote Chrome - it already has cookies
|
|
1056
|
-
logger(
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1788
|
+
logger("Skipping cookie sync for remote Chrome (using existing session)");
|
|
1789
|
+
if (!attachedExistingTab) {
|
|
1790
|
+
await navigateToChatGPT(Page, Runtime, config.url, logger);
|
|
1791
|
+
await ensureNotBlocked(Runtime, config.headless, logger);
|
|
1792
|
+
await ensureLoggedIn(Runtime, logger, { remoteSession: true });
|
|
1793
|
+
await ensurePromptReady(Runtime, config.inputTimeoutMs, logger);
|
|
1794
|
+
}
|
|
1795
|
+
else {
|
|
1796
|
+
await ensureNotBlocked(Runtime, config.headless, logger);
|
|
1797
|
+
await ensureLoggedIn(Runtime, logger, { remoteSession: true });
|
|
1798
|
+
await ensurePromptReady(Runtime, config.inputTimeoutMs, logger);
|
|
1799
|
+
}
|
|
1061
1800
|
logger(`Prompt textarea ready (initial focus, ${promptText.length.toLocaleString()} chars queued)`);
|
|
1062
1801
|
try {
|
|
1063
1802
|
const { result } = await Runtime.evaluate({
|
|
1064
|
-
expression:
|
|
1803
|
+
expression: "location.href",
|
|
1065
1804
|
returnByValue: true,
|
|
1066
1805
|
});
|
|
1067
|
-
if (typeof result?.value ===
|
|
1806
|
+
if (typeof result?.value === "string") {
|
|
1068
1807
|
lastUrl = result.value;
|
|
1069
1808
|
}
|
|
1070
1809
|
await emitRuntimeHint();
|
|
@@ -1073,7 +1812,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
|
|
|
1073
1812
|
// ignore
|
|
1074
1813
|
}
|
|
1075
1814
|
const modelStrategy = config.modelStrategy ?? DEFAULT_MODEL_STRATEGY;
|
|
1076
|
-
if (config.desiredModel && modelStrategy !==
|
|
1815
|
+
if (config.desiredModel && modelStrategy !== "ignore") {
|
|
1077
1816
|
await withRetries(() => ensureModelSelection(Runtime, config.desiredModel, logger, modelStrategy), {
|
|
1078
1817
|
retries: 2,
|
|
1079
1818
|
delayMs: 300,
|
|
@@ -1086,29 +1825,50 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
|
|
|
1086
1825
|
await ensurePromptReady(Runtime, config.inputTimeoutMs, logger);
|
|
1087
1826
|
logger(`Prompt textarea ready (after model switch, ${promptText.length.toLocaleString()} chars queued)`);
|
|
1088
1827
|
}
|
|
1089
|
-
else if (modelStrategy ===
|
|
1090
|
-
logger(
|
|
1828
|
+
else if (modelStrategy === "ignore") {
|
|
1829
|
+
logger("Model picker: skipped (strategy=ignore)");
|
|
1091
1830
|
}
|
|
1092
|
-
|
|
1831
|
+
const deepResearch = config.researchMode === "deep";
|
|
1832
|
+
// Handle thinking time selection if specified. Deep Research owns its own effort flow.
|
|
1093
1833
|
const thinkingTime = config.thinkingTime;
|
|
1094
|
-
if (thinkingTime) {
|
|
1095
|
-
|
|
1834
|
+
if (thinkingTime && !deepResearch) {
|
|
1835
|
+
if (shouldSkipThinkingTimeSelection(config.desiredModel, thinkingTime)) {
|
|
1836
|
+
logger("Thinking time: Pro Extended (via model selection)");
|
|
1837
|
+
}
|
|
1838
|
+
else {
|
|
1839
|
+
await withRetries(() => ensureThinkingTime(Runtime, thinkingTime, logger), {
|
|
1840
|
+
retries: 2,
|
|
1841
|
+
delayMs: 300,
|
|
1842
|
+
onRetry: (attempt, error) => {
|
|
1843
|
+
if (options.verbose) {
|
|
1844
|
+
logger(`[retry] Thinking time (${thinkingTime}) attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`);
|
|
1845
|
+
}
|
|
1846
|
+
},
|
|
1847
|
+
});
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
if (deepResearch) {
|
|
1851
|
+
await withRetries(() => activateDeepResearch(Runtime, Input, logger), {
|
|
1096
1852
|
retries: 2,
|
|
1097
|
-
delayMs:
|
|
1853
|
+
delayMs: 500,
|
|
1098
1854
|
onRetry: (attempt, error) => {
|
|
1099
1855
|
if (options.verbose) {
|
|
1100
|
-
logger(`[retry]
|
|
1856
|
+
logger(`[retry] Deep Research activation attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`);
|
|
1101
1857
|
}
|
|
1102
1858
|
},
|
|
1103
1859
|
});
|
|
1860
|
+
await ensurePromptReady(Runtime, config.inputTimeoutMs, logger);
|
|
1861
|
+
logger(`Prompt textarea ready (after Deep Research activation, ${promptText.length.toLocaleString()} chars queued)`);
|
|
1104
1862
|
}
|
|
1105
1863
|
const submitOnce = async (prompt, submissionAttachments) => {
|
|
1106
1864
|
const baselineSnapshot = await readAssistantSnapshot(Runtime).catch(() => null);
|
|
1107
|
-
const baselineAssistantText = typeof baselineSnapshot?.text ===
|
|
1865
|
+
const baselineAssistantText = typeof baselineSnapshot?.text === "string" ? baselineSnapshot.text.trim() : "";
|
|
1108
1866
|
const attachmentNames = submissionAttachments.map((a) => path.basename(a.path));
|
|
1867
|
+
await clearPromptComposer(Runtime, logger);
|
|
1868
|
+
await ensurePromptReady(Runtime, config.inputTimeoutMs, logger);
|
|
1109
1869
|
if (submissionAttachments.length > 0) {
|
|
1110
1870
|
if (!DOM) {
|
|
1111
|
-
throw new Error(
|
|
1871
|
+
throw new Error("Chrome DOM domain unavailable while uploading attachments.");
|
|
1112
1872
|
}
|
|
1113
1873
|
await clearComposerAttachments(Runtime, 5_000, logger);
|
|
1114
1874
|
// Use remote file transfer for remote Chrome (reads local files and injects via CDP)
|
|
@@ -1122,7 +1882,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
|
|
|
1122
1882
|
const perFileTimeout = 15_000;
|
|
1123
1883
|
const waitBudget = Math.max(baseTimeout, 30_000) + (submissionAttachments.length - 1) * perFileTimeout;
|
|
1124
1884
|
await waitForAttachmentCompletion(Runtime, waitBudget, attachmentNames, logger);
|
|
1125
|
-
logger(
|
|
1885
|
+
logger("All attachments uploaded");
|
|
1126
1886
|
}
|
|
1127
1887
|
let baselineTurns = await readConversationTurnCount(Runtime, logger);
|
|
1128
1888
|
const providerState = {
|
|
@@ -1142,52 +1902,103 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
|
|
|
1142
1902
|
state: providerState,
|
|
1143
1903
|
});
|
|
1144
1904
|
const providerBaselineTurns = providerState.baselineTurns;
|
|
1145
|
-
if (typeof providerBaselineTurns ===
|
|
1905
|
+
if (typeof providerBaselineTurns === "number" && Number.isFinite(providerBaselineTurns)) {
|
|
1146
1906
|
baselineTurns = providerBaselineTurns;
|
|
1147
1907
|
}
|
|
1148
1908
|
return { baselineTurns, baselineAssistantText };
|
|
1149
1909
|
};
|
|
1910
|
+
const reloadPromptComposer = async () => {
|
|
1911
|
+
logger("[browser] Composer became unresponsive; reloading page and retrying once.");
|
|
1912
|
+
await Page.reload({ ignoreCache: true });
|
|
1913
|
+
await ensurePromptReady(Runtime, config.inputTimeoutMs, logger);
|
|
1914
|
+
};
|
|
1150
1915
|
let baselineTurns = null;
|
|
1151
1916
|
let baselineAssistantText = null;
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
error.details?.code === 'prompt-too-large';
|
|
1160
|
-
if (options.fallbackSubmission && isPromptTooLarge) {
|
|
1161
|
-
logger('[browser] Inline prompt too large; retrying with file uploads.');
|
|
1917
|
+
const submission = await runSubmissionWithRecovery({
|
|
1918
|
+
prompt: promptText,
|
|
1919
|
+
attachments,
|
|
1920
|
+
fallbackSubmission: options.fallbackSubmission,
|
|
1921
|
+
submit: submitOnce,
|
|
1922
|
+
reloadPromptComposer,
|
|
1923
|
+
prepareFallbackSubmission: async () => {
|
|
1162
1924
|
await clearPromptComposer(Runtime, logger);
|
|
1163
1925
|
await ensurePromptReady(Runtime, config.inputTimeoutMs, logger);
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1926
|
+
},
|
|
1927
|
+
logger,
|
|
1928
|
+
});
|
|
1929
|
+
baselineTurns = submission.baselineTurns;
|
|
1930
|
+
baselineAssistantText = submission.baselineAssistantText;
|
|
1931
|
+
const imageArtifactMinTurnIndex = baselineTurns;
|
|
1932
|
+
if (deepResearch) {
|
|
1933
|
+
await waitForResearchPlanAutoConfirm(Runtime, logger);
|
|
1934
|
+
const researchResult = await waitForDeepResearchCompletion(Runtime, logger, config.timeoutMs, baselineTurns, Page, client);
|
|
1935
|
+
await emitRuntimeHint();
|
|
1936
|
+
const durationMs = Date.now() - startedAt;
|
|
1937
|
+
const tokens = estimateTokenCount(researchResult.text);
|
|
1938
|
+
const reportArtifact = await saveOptionalArtifact(() => saveDeepResearchReportArtifact({
|
|
1939
|
+
sessionId: options.sessionId,
|
|
1940
|
+
reportMarkdown: researchResult.text,
|
|
1941
|
+
conversationUrl: lastUrl,
|
|
1942
|
+
logger,
|
|
1943
|
+
}), logger);
|
|
1944
|
+
const transcriptArtifact = await saveOptionalArtifact(() => saveBrowserTranscriptArtifact({
|
|
1945
|
+
sessionId: options.sessionId,
|
|
1946
|
+
prompt: promptText,
|
|
1947
|
+
answerMarkdown: researchResult.text,
|
|
1948
|
+
conversationUrl: lastUrl,
|
|
1949
|
+
artifacts: appendArtifacts(undefined, [reportArtifact]),
|
|
1950
|
+
logger,
|
|
1951
|
+
}), logger);
|
|
1952
|
+
const savedArtifacts = appendArtifacts(undefined, [reportArtifact, transcriptArtifact]);
|
|
1953
|
+
const archive = await maybeArchiveCompletedConversation({
|
|
1954
|
+
Runtime,
|
|
1955
|
+
logger,
|
|
1956
|
+
config,
|
|
1957
|
+
conversationUrl: lastUrl,
|
|
1958
|
+
followUpCount: 0,
|
|
1959
|
+
requiredArtifactsSaved: Boolean(reportArtifact && transcriptArtifact),
|
|
1960
|
+
});
|
|
1961
|
+
runStatus = "complete";
|
|
1962
|
+
return {
|
|
1963
|
+
answerText: researchResult.text,
|
|
1964
|
+
answerMarkdown: researchResult.text,
|
|
1965
|
+
answerHtml: researchResult.html,
|
|
1966
|
+
artifacts: savedArtifacts,
|
|
1967
|
+
archive,
|
|
1968
|
+
tookMs: durationMs,
|
|
1969
|
+
answerTokens: tokens,
|
|
1970
|
+
answerChars: researchResult.text.length,
|
|
1971
|
+
chromePort: port,
|
|
1972
|
+
chromeHost: host,
|
|
1973
|
+
chromeTargetId: remoteTargetId ?? undefined,
|
|
1974
|
+
tabUrl: lastUrl,
|
|
1975
|
+
conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
|
|
1976
|
+
controllerPid: process.pid,
|
|
1977
|
+
};
|
|
1171
1978
|
}
|
|
1172
|
-
stopThinkingMonitor = startThinkingStatusMonitor(Runtime, logger, options.verbose ?? false);
|
|
1173
1979
|
// Helper to normalize text for echo detection (collapse whitespace, lowercase)
|
|
1174
|
-
const normalizeForComparison = (text) => text.toLowerCase().replace(/\s+/g,
|
|
1980
|
+
const normalizeForComparison = (text) => text.toLowerCase().replace(/\s+/g, " ").trim();
|
|
1981
|
+
const expectedConversationId = () => lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined;
|
|
1175
1982
|
const waitForFreshAssistantResponse = async (baselineNormalized, timeoutMs) => {
|
|
1176
1983
|
const baselinePrefix = baselineNormalized.length >= 80
|
|
1177
1984
|
? baselineNormalized.slice(0, Math.min(200, baselineNormalized.length))
|
|
1178
|
-
:
|
|
1985
|
+
: "";
|
|
1179
1986
|
const deadline = Date.now() + timeoutMs;
|
|
1180
1987
|
while (Date.now() < deadline) {
|
|
1181
|
-
const snapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined).catch(() => null);
|
|
1182
|
-
const text = typeof snapshot?.text ===
|
|
1988
|
+
const snapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined, expectedConversationId()).catch(() => null);
|
|
1989
|
+
const text = typeof snapshot?.text === "string" ? snapshot.text.trim() : "";
|
|
1183
1990
|
if (text) {
|
|
1184
1991
|
const normalized = normalizeForComparison(text);
|
|
1185
|
-
const isBaseline = normalized === baselineNormalized ||
|
|
1992
|
+
const isBaseline = normalized === baselineNormalized ||
|
|
1993
|
+
(baselinePrefix.length > 0 && normalized.startsWith(baselinePrefix));
|
|
1186
1994
|
if (!isBaseline) {
|
|
1187
1995
|
return {
|
|
1188
1996
|
text,
|
|
1189
1997
|
html: snapshot?.html ?? undefined,
|
|
1190
|
-
meta: {
|
|
1998
|
+
meta: {
|
|
1999
|
+
turnId: snapshot?.turnId ?? undefined,
|
|
2000
|
+
messageId: snapshot?.messageId ?? undefined,
|
|
2001
|
+
},
|
|
1191
2002
|
};
|
|
1192
2003
|
}
|
|
1193
2004
|
}
|
|
@@ -1195,7 +2006,19 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
|
|
|
1195
2006
|
}
|
|
1196
2007
|
return null;
|
|
1197
2008
|
};
|
|
1198
|
-
|
|
2009
|
+
const waitWithThinkingMonitor = async (operation) => {
|
|
2010
|
+
stopThinkingMonitor?.();
|
|
2011
|
+
stopThinkingMonitor = startThinkingStatusMonitor(Runtime, logger, {
|
|
2012
|
+
intervalMs: options.heartbeatIntervalMs,
|
|
2013
|
+
});
|
|
2014
|
+
try {
|
|
2015
|
+
return await operation();
|
|
2016
|
+
}
|
|
2017
|
+
finally {
|
|
2018
|
+
stopThinkingMonitor?.();
|
|
2019
|
+
stopThinkingMonitor = null;
|
|
2020
|
+
}
|
|
2021
|
+
};
|
|
1199
2022
|
const recheckDelayMs = Math.max(0, config.assistantRecheckDelayMs ?? 0);
|
|
1200
2023
|
const recheckTimeoutMs = Math.max(0, config.assistantRecheckTimeoutMs ?? 0);
|
|
1201
2024
|
const attemptAssistantRecheck = async () => {
|
|
@@ -1217,17 +2040,19 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
|
|
|
1217
2040
|
// Update session metadata to indicate login is needed
|
|
1218
2041
|
await emitRuntimeHint();
|
|
1219
2042
|
throw new BrowserAutomationError(`ChatGPT session expired during recheck: ${sessionValid.reason}. ` +
|
|
1220
|
-
`Conversation URL: ${conversationUrl || lastUrl ||
|
|
2043
|
+
`Conversation URL: ${conversationUrl || lastUrl || "unknown"}. ` +
|
|
1221
2044
|
`Please sign in and retry.`, {
|
|
1222
|
-
stage:
|
|
2045
|
+
stage: "assistant-recheck",
|
|
1223
2046
|
details: {
|
|
1224
2047
|
conversationUrl: conversationUrl || lastUrl || null,
|
|
1225
|
-
sessionStatus:
|
|
2048
|
+
sessionStatus: "needs_login",
|
|
1226
2049
|
validationReason: sessionValid.reason,
|
|
1227
2050
|
},
|
|
1228
2051
|
runtime: {
|
|
1229
2052
|
chromeHost: host,
|
|
1230
2053
|
chromePort: port,
|
|
2054
|
+
chromeBrowserWSEndpoint: browserWSEndpoint,
|
|
2055
|
+
chromeProfileRoot,
|
|
1231
2056
|
chromeTargetId: remoteTargetId ?? undefined,
|
|
1232
2057
|
tabUrl: lastUrl,
|
|
1233
2058
|
conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
|
|
@@ -1237,140 +2062,253 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
|
|
|
1237
2062
|
}
|
|
1238
2063
|
await emitRuntimeHint();
|
|
1239
2064
|
const timeoutMs = recheckTimeoutMs > 0 ? recheckTimeoutMs : config.timeoutMs;
|
|
1240
|
-
const rechecked = await
|
|
1241
|
-
|
|
2065
|
+
const rechecked = await waitWithThinkingMonitor(() => waitForAssistantOrGeneratedImageResponse({
|
|
2066
|
+
Runtime,
|
|
2067
|
+
waitForText: () => waitForAssistantResponseWithReload(Runtime, Page, timeoutMs, logger, baselineTurns ?? undefined, expectedConversationId()),
|
|
2068
|
+
timeoutMs,
|
|
2069
|
+
logger,
|
|
2070
|
+
minTurnIndex: baselineTurns ?? undefined,
|
|
2071
|
+
expectedConversationId: expectedConversationId(),
|
|
2072
|
+
imageOutputRequested,
|
|
2073
|
+
}));
|
|
2074
|
+
logger("Recovered assistant response after delayed recheck");
|
|
1242
2075
|
return rechecked;
|
|
1243
2076
|
};
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
2077
|
+
const imageOutputRequested = Boolean(options.generateImagePath ||
|
|
2078
|
+
options.outputPath ||
|
|
2079
|
+
options.generateImage);
|
|
2080
|
+
const captureAssistantTurn = async (turnPrompt, label) => {
|
|
2081
|
+
let turnAnswer;
|
|
2082
|
+
try {
|
|
2083
|
+
const conversationUrl = await readConversationUrl(Runtime).catch(() => null);
|
|
2084
|
+
if (conversationUrl && isConversationUrl(conversationUrl)) {
|
|
2085
|
+
lastUrl = conversationUrl;
|
|
2086
|
+
await emitRuntimeHint();
|
|
1252
2087
|
}
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
2088
|
+
turnAnswer = await waitWithThinkingMonitor(() => waitForAssistantOrGeneratedImageResponse({
|
|
2089
|
+
Runtime,
|
|
2090
|
+
waitForText: () => waitForAssistantResponseWithReload(Runtime, Page, config.timeoutMs, logger, baselineTurns ?? undefined, expectedConversationId()),
|
|
2091
|
+
timeoutMs: config.timeoutMs,
|
|
2092
|
+
logger,
|
|
2093
|
+
minTurnIndex: baselineTurns ?? undefined,
|
|
2094
|
+
expectedConversationId: expectedConversationId(),
|
|
2095
|
+
imageOutputRequested,
|
|
2096
|
+
}));
|
|
2097
|
+
}
|
|
2098
|
+
catch (error) {
|
|
2099
|
+
if (isAssistantResponseTimeoutError(error)) {
|
|
2100
|
+
const rechecked = await attemptAssistantRecheckOrRethrow(attemptAssistantRecheck);
|
|
2101
|
+
if (rechecked) {
|
|
2102
|
+
turnAnswer = rechecked;
|
|
1259
2103
|
}
|
|
1260
|
-
|
|
1261
|
-
|
|
2104
|
+
else {
|
|
2105
|
+
try {
|
|
2106
|
+
const conversationUrl = await readConversationUrl(Runtime);
|
|
2107
|
+
if (conversationUrl) {
|
|
2108
|
+
lastUrl = conversationUrl;
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
catch {
|
|
2112
|
+
// ignore
|
|
2113
|
+
}
|
|
2114
|
+
await emitRuntimeHint();
|
|
2115
|
+
const diagnostics = await captureBrowserDiagnostics(Runtime, logger, "assistant-timeout", {
|
|
2116
|
+
Page,
|
|
2117
|
+
sessionId: options.sessionId,
|
|
2118
|
+
}).catch(() => undefined);
|
|
2119
|
+
const runtime = {
|
|
2120
|
+
chromePort: port,
|
|
2121
|
+
chromeHost: host,
|
|
2122
|
+
chromeBrowserWSEndpoint: browserWSEndpoint,
|
|
2123
|
+
chromeProfileRoot,
|
|
2124
|
+
chromeTargetId: remoteTargetId ?? undefined,
|
|
2125
|
+
tabUrl: lastUrl,
|
|
2126
|
+
conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
|
|
2127
|
+
controllerPid: process.pid,
|
|
2128
|
+
};
|
|
2129
|
+
throw new BrowserAutomationError("Assistant response timed out before completion; reattach later to capture the answer.", { stage: "assistant-timeout", runtime, diagnostics }, error);
|
|
1262
2130
|
}
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
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);
|
|
2131
|
+
}
|
|
2132
|
+
else {
|
|
2133
|
+
throw error;
|
|
1273
2134
|
}
|
|
1274
2135
|
}
|
|
1275
|
-
|
|
1276
|
-
|
|
2136
|
+
const baselineNormalized = baselineAssistantText
|
|
2137
|
+
? normalizeForComparison(baselineAssistantText)
|
|
2138
|
+
: "";
|
|
2139
|
+
if (baselineNormalized) {
|
|
2140
|
+
const normalizedAnswer = normalizeForComparison(turnAnswer.text ?? "");
|
|
2141
|
+
const baselinePrefix = baselineNormalized.length >= 80
|
|
2142
|
+
? baselineNormalized.slice(0, Math.min(200, baselineNormalized.length))
|
|
2143
|
+
: "";
|
|
2144
|
+
const isBaseline = normalizedAnswer === baselineNormalized ||
|
|
2145
|
+
(baselinePrefix.length > 0 && normalizedAnswer.startsWith(baselinePrefix));
|
|
2146
|
+
if (isBaseline) {
|
|
2147
|
+
logger("Detected stale assistant response; waiting for new response...");
|
|
2148
|
+
const refreshed = await waitForFreshAssistantResponse(baselineNormalized, 15_000);
|
|
2149
|
+
if (refreshed) {
|
|
2150
|
+
turnAnswer = refreshed;
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
1277
2153
|
}
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
: '';
|
|
1285
|
-
const isBaseline = normalizedAnswer === baselineNormalized ||
|
|
1286
|
-
(baselinePrefix.length > 0 && normalizedAnswer.startsWith(baselinePrefix));
|
|
1287
|
-
if (isBaseline) {
|
|
1288
|
-
logger('Detected stale assistant response; waiting for new response...');
|
|
1289
|
-
const refreshed = await waitForFreshAssistantResponse(baselineNormalized, 15_000);
|
|
1290
|
-
if (refreshed) {
|
|
1291
|
-
answer = refreshed;
|
|
2154
|
+
let turnAnswerText = turnAnswer.text;
|
|
2155
|
+
const turnAnswerHtml = turnAnswer.html ?? "";
|
|
2156
|
+
const copiedMarkdown = await withRetries(async () => {
|
|
2157
|
+
const attempt = await captureAssistantMarkdown(Runtime, turnAnswer.meta, logger);
|
|
2158
|
+
if (!attempt) {
|
|
2159
|
+
throw new Error("copy-missing");
|
|
1292
2160
|
}
|
|
2161
|
+
return attempt;
|
|
2162
|
+
}, {
|
|
2163
|
+
retries: 2,
|
|
2164
|
+
delayMs: 350,
|
|
2165
|
+
onRetry: (attempt, error) => {
|
|
2166
|
+
if (options.verbose) {
|
|
2167
|
+
logger(`[retry] Markdown capture attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`);
|
|
2168
|
+
}
|
|
2169
|
+
},
|
|
2170
|
+
}).catch(() => null);
|
|
2171
|
+
let turnAnswerMarkdown = copiedMarkdown ?? turnAnswerText;
|
|
2172
|
+
({ answerText: turnAnswerText, answerMarkdown: turnAnswerMarkdown } =
|
|
2173
|
+
await maybeRecoverLongAssistantResponse({
|
|
2174
|
+
runtime: Runtime,
|
|
2175
|
+
baselineTurns,
|
|
2176
|
+
answerText: turnAnswerText,
|
|
2177
|
+
answerMarkdown: turnAnswerMarkdown,
|
|
2178
|
+
logger,
|
|
2179
|
+
allowMarkdownUpdate: !copiedMarkdown,
|
|
2180
|
+
}));
|
|
2181
|
+
// Final sanity check: ensure we didn't accidentally capture the user prompt instead of the assistant turn.
|
|
2182
|
+
const finalSnapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined, expectedConversationId()).catch(() => null);
|
|
2183
|
+
const finalText = typeof finalSnapshot?.text === "string" ? finalSnapshot.text.trim() : "";
|
|
2184
|
+
if (finalText &&
|
|
2185
|
+
finalText !== turnAnswerMarkdown.trim() &&
|
|
2186
|
+
finalText !== turnPrompt.trim() &&
|
|
2187
|
+
finalText.length >= turnAnswerMarkdown.trim().length) {
|
|
2188
|
+
logger("Refreshed assistant response via final DOM snapshot");
|
|
2189
|
+
turnAnswerText = finalText;
|
|
2190
|
+
turnAnswerMarkdown = finalText;
|
|
1293
2191
|
}
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
2192
|
+
// Detect prompt echo using normalized comparison (whitespace-insensitive).
|
|
2193
|
+
const promptEchoMatcher = buildPromptEchoMatcher(turnPrompt);
|
|
2194
|
+
const alignedEcho = alignPromptEchoPair(turnAnswerText, turnAnswerMarkdown, promptEchoMatcher, copiedMarkdown ? logger : undefined, {
|
|
2195
|
+
text: "Aligned assistant response text to copied markdown after prompt echo",
|
|
2196
|
+
markdown: "Aligned assistant markdown to response text after prompt echo",
|
|
2197
|
+
});
|
|
2198
|
+
turnAnswerText = alignedEcho.answerText;
|
|
2199
|
+
turnAnswerMarkdown = alignedEcho.answerMarkdown;
|
|
2200
|
+
const isPromptEcho = alignedEcho.isEcho;
|
|
2201
|
+
if (isPromptEcho) {
|
|
2202
|
+
logger("Detected prompt echo in response; waiting for actual assistant response...");
|
|
2203
|
+
const deadline = Date.now() + 15_000;
|
|
2204
|
+
let bestText = null;
|
|
2205
|
+
let stableCount = 0;
|
|
2206
|
+
while (Date.now() < deadline) {
|
|
2207
|
+
const snapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined, expectedConversationId()).catch(() => null);
|
|
2208
|
+
const text = typeof snapshot?.text === "string" ? snapshot.text.trim() : "";
|
|
2209
|
+
const isStillEcho = !text || Boolean(promptEchoMatcher?.isEcho(text));
|
|
2210
|
+
if (!isStillEcho) {
|
|
2211
|
+
if (!bestText || text.length > bestText.length) {
|
|
2212
|
+
bestText = text;
|
|
2213
|
+
stableCount = 0;
|
|
2214
|
+
}
|
|
2215
|
+
else if (text === bestText) {
|
|
2216
|
+
stableCount += 1;
|
|
2217
|
+
}
|
|
2218
|
+
if (stableCount >= 2) {
|
|
2219
|
+
break;
|
|
2220
|
+
}
|
|
2221
|
+
}
|
|
2222
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
1309
2223
|
}
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
2224
|
+
if (bestText) {
|
|
2225
|
+
logger("Recovered assistant response after detecting prompt echo");
|
|
2226
|
+
turnAnswerText = bestText;
|
|
2227
|
+
turnAnswerMarkdown = bestText;
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
return {
|
|
2231
|
+
label,
|
|
2232
|
+
answerText: turnAnswerText,
|
|
2233
|
+
answerMarkdown: turnAnswerMarkdown,
|
|
2234
|
+
answerHtml: turnAnswerHtml,
|
|
2235
|
+
};
|
|
2236
|
+
};
|
|
2237
|
+
const followUpPrompts = normalizeBrowserFollowUpPrompts(options.followUpPrompts);
|
|
2238
|
+
const turns = [];
|
|
2239
|
+
const initialTurn = await captureAssistantTurn(promptText, "Initial response");
|
|
2240
|
+
turns.push(initialTurn);
|
|
2241
|
+
answerText = initialTurn.answerText;
|
|
2242
|
+
answerMarkdown = initialTurn.answerMarkdown;
|
|
2243
|
+
answerHtml = initialTurn.answerHtml;
|
|
2244
|
+
for (let index = 0; index < followUpPrompts.length; index += 1) {
|
|
2245
|
+
const followUpPrompt = followUpPrompts[index];
|
|
2246
|
+
logger(`[browser] Sending follow-up ${index + 1}/${followUpPrompts.length}`);
|
|
2247
|
+
await clearPromptComposer(Runtime, logger);
|
|
2248
|
+
await ensurePromptReady(Runtime, config.inputTimeoutMs, logger);
|
|
2249
|
+
const submission = await runSubmissionWithRecovery({
|
|
2250
|
+
prompt: followUpPrompt,
|
|
2251
|
+
attachments: [],
|
|
2252
|
+
submit: submitOnce,
|
|
2253
|
+
reloadPromptComposer,
|
|
2254
|
+
prepareFallbackSubmission: async () => {
|
|
2255
|
+
await clearPromptComposer(Runtime, logger);
|
|
2256
|
+
await ensurePromptReady(Runtime, config.inputTimeoutMs, logger);
|
|
2257
|
+
},
|
|
2258
|
+
logger,
|
|
2259
|
+
});
|
|
2260
|
+
baselineTurns = submission.baselineTurns;
|
|
2261
|
+
baselineAssistantText = submission.baselineAssistantText;
|
|
2262
|
+
const turn = await captureAssistantTurn(followUpPrompt, `Follow-up ${index + 1}`);
|
|
2263
|
+
turns.push({ ...turn, prompt: followUpPrompt });
|
|
2264
|
+
answerText = turn.answerText;
|
|
2265
|
+
answerMarkdown = turn.answerMarkdown;
|
|
2266
|
+
answerHtml = turn.answerHtml;
|
|
2267
|
+
}
|
|
2268
|
+
if (turns.length > 1) {
|
|
2269
|
+
const formatted = formatBrowserTurnTranscript(turns);
|
|
2270
|
+
answerText = formatted.answerText;
|
|
2271
|
+
answerMarkdown = formatted.answerMarkdown;
|
|
2272
|
+
answerHtml = "";
|
|
2273
|
+
}
|
|
2274
|
+
const imageArtifacts = await collectGeneratedImageArtifacts({
|
|
2275
|
+
Runtime,
|
|
2276
|
+
Network,
|
|
2277
|
+
logger,
|
|
2278
|
+
minTurnIndex: imageArtifactMinTurnIndex,
|
|
2279
|
+
sessionId: options.sessionId,
|
|
2280
|
+
generateImagePath: options.generateImagePath,
|
|
2281
|
+
outputPath: options.outputPath,
|
|
1316
2282
|
answerText,
|
|
2283
|
+
waitTimeoutMs: options.config?.timeoutMs,
|
|
2284
|
+
});
|
|
2285
|
+
answerText = imageArtifacts.answerText || answerText;
|
|
2286
|
+
if (imageArtifacts.markdownSuffix) {
|
|
2287
|
+
answerMarkdown += imageArtifacts.markdownSuffix;
|
|
2288
|
+
}
|
|
2289
|
+
const savedImageArtifacts = appendArtifacts(undefined, imageArtifacts.savedImages);
|
|
2290
|
+
const transcriptArtifact = await saveOptionalArtifact(() => saveBrowserTranscriptArtifact({
|
|
2291
|
+
sessionId: options.sessionId,
|
|
2292
|
+
prompt: promptText,
|
|
1317
2293
|
answerMarkdown,
|
|
2294
|
+
conversationUrl: lastUrl,
|
|
2295
|
+
artifacts: savedImageArtifacts,
|
|
1318
2296
|
logger,
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
answerText = finalText;
|
|
1330
|
-
answerMarkdown = finalText;
|
|
1331
|
-
}
|
|
1332
|
-
// Detect prompt echo using normalized comparison (whitespace-insensitive).
|
|
1333
|
-
const promptEchoMatcher = buildPromptEchoMatcher(promptText);
|
|
1334
|
-
const alignedEcho = alignPromptEchoPair(answerText, answerMarkdown, promptEchoMatcher, copiedMarkdown ? logger : undefined, {
|
|
1335
|
-
text: 'Aligned assistant response text to copied markdown after prompt echo',
|
|
1336
|
-
markdown: 'Aligned assistant markdown to response text after prompt echo',
|
|
2297
|
+
}), logger);
|
|
2298
|
+
const savedArtifacts = appendArtifacts(savedImageArtifacts, [transcriptArtifact]);
|
|
2299
|
+
const archive = await maybeArchiveCompletedConversation({
|
|
2300
|
+
Runtime,
|
|
2301
|
+
logger,
|
|
2302
|
+
config,
|
|
2303
|
+
conversationUrl: lastUrl,
|
|
2304
|
+
followUpCount: followUpPrompts.length,
|
|
2305
|
+
requiredArtifactsSaved: Boolean(transcriptArtifact) &&
|
|
2306
|
+
imageArtifacts.savedImages.length === imageArtifacts.imageCount,
|
|
1337
2307
|
});
|
|
1338
|
-
answerText = alignedEcho.answerText;
|
|
1339
|
-
answerMarkdown = alignedEcho.answerMarkdown;
|
|
1340
|
-
const isPromptEcho = alignedEcho.isEcho;
|
|
1341
|
-
if (isPromptEcho) {
|
|
1342
|
-
logger('Detected prompt echo in response; waiting for actual assistant response...');
|
|
1343
|
-
const deadline = Date.now() + 15_000;
|
|
1344
|
-
let bestText = null;
|
|
1345
|
-
let stableCount = 0;
|
|
1346
|
-
while (Date.now() < deadline) {
|
|
1347
|
-
const snapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined).catch(() => null);
|
|
1348
|
-
const text = typeof snapshot?.text === 'string' ? snapshot.text.trim() : '';
|
|
1349
|
-
const isStillEcho = !text || Boolean(promptEchoMatcher?.isEcho(text));
|
|
1350
|
-
if (!isStillEcho) {
|
|
1351
|
-
if (!bestText || text.length > bestText.length) {
|
|
1352
|
-
bestText = text;
|
|
1353
|
-
stableCount = 0;
|
|
1354
|
-
}
|
|
1355
|
-
else if (text === bestText) {
|
|
1356
|
-
stableCount += 1;
|
|
1357
|
-
}
|
|
1358
|
-
if (stableCount >= 2) {
|
|
1359
|
-
break;
|
|
1360
|
-
}
|
|
1361
|
-
}
|
|
1362
|
-
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
1363
|
-
}
|
|
1364
|
-
if (bestText) {
|
|
1365
|
-
logger('Recovered assistant response after detecting prompt echo');
|
|
1366
|
-
answerText = bestText;
|
|
1367
|
-
answerMarkdown = bestText;
|
|
1368
|
-
}
|
|
1369
|
-
}
|
|
1370
|
-
stopThinkingMonitor?.();
|
|
1371
2308
|
const durationMs = Date.now() - startedAt;
|
|
1372
2309
|
const answerChars = answerText.length;
|
|
1373
2310
|
const answerTokens = estimateTokenCount(answerMarkdown);
|
|
2311
|
+
runStatus = "complete";
|
|
1374
2312
|
return {
|
|
1375
2313
|
answerText,
|
|
1376
2314
|
answerMarkdown,
|
|
@@ -1378,32 +2316,39 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
|
|
|
1378
2316
|
tookMs: durationMs,
|
|
1379
2317
|
answerTokens,
|
|
1380
2318
|
answerChars,
|
|
2319
|
+
browserTransport: "cdp",
|
|
1381
2320
|
chromePid: undefined,
|
|
1382
2321
|
chromePort: port,
|
|
1383
2322
|
chromeHost: host,
|
|
2323
|
+
chromeBrowserWSEndpoint: browserWSEndpoint,
|
|
2324
|
+
chromeProfileRoot,
|
|
1384
2325
|
userDataDir: undefined,
|
|
1385
2326
|
chromeTargetId: remoteTargetId ?? undefined,
|
|
1386
2327
|
tabUrl: lastUrl,
|
|
2328
|
+
conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
|
|
2329
|
+
artifacts: savedArtifacts,
|
|
2330
|
+
archive,
|
|
1387
2331
|
controllerPid: process.pid,
|
|
1388
2332
|
};
|
|
1389
2333
|
}
|
|
1390
2334
|
catch (error) {
|
|
1391
2335
|
const normalizedError = error instanceof Error ? error : new Error(String(error));
|
|
1392
|
-
stopThinkingMonitor?.();
|
|
1393
2336
|
const socketClosed = connectionClosedUnexpectedly || isWebSocketClosureError(normalizedError);
|
|
1394
2337
|
connectionClosedUnexpectedly = connectionClosedUnexpectedly || socketClosed;
|
|
1395
2338
|
if (!socketClosed) {
|
|
1396
2339
|
logger(`Failed to complete ChatGPT run: ${normalizedError.message}`);
|
|
1397
|
-
if ((config.debug || process.env.CHATGPT_DEVTOOLS_TRACE ===
|
|
2340
|
+
if ((config.debug || process.env.CHATGPT_DEVTOOLS_TRACE === "1") && normalizedError.stack) {
|
|
1398
2341
|
logger(normalizedError.stack);
|
|
1399
2342
|
}
|
|
1400
2343
|
throw normalizedError;
|
|
1401
2344
|
}
|
|
1402
|
-
throw new BrowserAutomationError(
|
|
1403
|
-
stage:
|
|
2345
|
+
throw new BrowserAutomationError("Remote Chrome connection lost before Oracle finished.", {
|
|
2346
|
+
stage: "connection-lost",
|
|
1404
2347
|
runtime: {
|
|
1405
2348
|
chromeHost: host,
|
|
1406
2349
|
chromePort: port,
|
|
2350
|
+
chromeBrowserWSEndpoint: browserWSEndpoint,
|
|
2351
|
+
chromeProfileRoot,
|
|
1407
2352
|
chromeTargetId: remoteTargetId ?? undefined,
|
|
1408
2353
|
tabUrl: lastUrl,
|
|
1409
2354
|
controllerPid: process.pid,
|
|
@@ -1412,48 +2357,63 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
|
|
|
1412
2357
|
}
|
|
1413
2358
|
finally {
|
|
1414
2359
|
try {
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
2360
|
+
await closeRemoteConnectionAfterRun({
|
|
2361
|
+
connectionClosedUnexpectedly,
|
|
2362
|
+
connection,
|
|
2363
|
+
client,
|
|
2364
|
+
runStatus,
|
|
2365
|
+
});
|
|
1418
2366
|
}
|
|
1419
2367
|
catch {
|
|
1420
2368
|
// ignore
|
|
1421
2369
|
}
|
|
1422
2370
|
removeDialogHandler?.();
|
|
1423
|
-
|
|
2371
|
+
if (tabLease) {
|
|
2372
|
+
const handle = tabLease;
|
|
2373
|
+
tabLease = null;
|
|
2374
|
+
await handle.release().catch(() => undefined);
|
|
2375
|
+
}
|
|
2376
|
+
if (shouldCloseOwnedRunTargetAfterRun({
|
|
2377
|
+
runStatus,
|
|
2378
|
+
ownsTarget,
|
|
2379
|
+
keepBrowser: Boolean(config.keepBrowser),
|
|
2380
|
+
})) {
|
|
2381
|
+
await closeRemoteChromeTarget(host, port, remoteTargetId ?? undefined, logger);
|
|
2382
|
+
}
|
|
1424
2383
|
// Don't kill remote Chrome - it's not ours to manage
|
|
1425
2384
|
const totalSeconds = (Date.now() - startedAt) / 1000;
|
|
1426
2385
|
logger(`Remote session complete • ${totalSeconds.toFixed(1)}s total`);
|
|
1427
2386
|
}
|
|
1428
2387
|
}
|
|
1429
|
-
export { estimateTokenCount } from
|
|
1430
|
-
export { resolveBrowserConfig, DEFAULT_BROWSER_CONFIG } from
|
|
1431
|
-
export
|
|
1432
|
-
export
|
|
2388
|
+
export { estimateTokenCount } from "./utils.js";
|
|
2389
|
+
export { resolveBrowserConfig, DEFAULT_BROWSER_CONFIG } from "./config.js";
|
|
2390
|
+
// biome-ignore lint/style/useNamingConvention: test-only export used in vitest suite
|
|
2391
|
+
export const __test__ = {
|
|
2392
|
+
closeRemoteConnectionAfterRun,
|
|
2393
|
+
detachKeptChromeProcess,
|
|
2394
|
+
isImageOnlyUiChromeText,
|
|
2395
|
+
listIgnoredRemoteChromeFlags,
|
|
2396
|
+
shouldCloseOwnedRunTargetAfterRun,
|
|
2397
|
+
};
|
|
2398
|
+
export { syncCookies } from "./cookies.js";
|
|
2399
|
+
export { navigateToChatGPT, ensureNotBlocked, ensurePromptReady, ensureModelSelection, submitPrompt, waitForAssistantResponse, captureAssistantMarkdown, uploadAttachmentFile, waitForAttachmentCompletion, } from "./pageActions.js";
|
|
1433
2400
|
export async function maybeReuseRunningChromeForTest(userDataDir, logger, options = {}) {
|
|
1434
2401
|
return maybeReuseRunningChrome(userDataDir, logger, options);
|
|
1435
2402
|
}
|
|
2403
|
+
export async function acquireManualLoginChromeForRunForTest(userDataDir, config, logger, sessionId, deps) {
|
|
2404
|
+
return acquireManualLoginChromeForRun(userDataDir, config, logger, sessionId, deps);
|
|
2405
|
+
}
|
|
1436
2406
|
export function isWebSocketClosureError(error) {
|
|
1437
2407
|
const message = error.message.toLowerCase();
|
|
1438
|
-
return (message.includes(
|
|
1439
|
-
message.includes(
|
|
1440
|
-
message.includes(
|
|
1441
|
-
message.includes(
|
|
1442
|
-
message.includes(
|
|
1443
|
-
}
|
|
1444
|
-
export function formatThinkingLog(startedAt, now, message, locatorSuffix) {
|
|
1445
|
-
const elapsedMs = now - startedAt;
|
|
1446
|
-
const elapsedText = formatElapsed(elapsedMs);
|
|
1447
|
-
const progress = Math.min(1, elapsedMs / 600_000); // soft target: 10 minutes
|
|
1448
|
-
const pct = Math.round(progress * 100)
|
|
1449
|
-
.toString()
|
|
1450
|
-
.padStart(3, ' ');
|
|
1451
|
-
const statusLabel = message ? ` — ${message}` : '';
|
|
1452
|
-
return `${pct}% [${elapsedText} / ~10m]${statusLabel}${locatorSuffix}`;
|
|
2408
|
+
return (message.includes("websocket connection closed") ||
|
|
2409
|
+
message.includes("websocket is closed") ||
|
|
2410
|
+
message.includes("websocket error") ||
|
|
2411
|
+
message.includes("inspected target navigated or closed") ||
|
|
2412
|
+
message.includes("target closed"));
|
|
1453
2413
|
}
|
|
1454
|
-
async function waitForAssistantResponseWithReload(Runtime, Page, timeoutMs, logger, minTurnIndex) {
|
|
2414
|
+
async function waitForAssistantResponseWithReload(Runtime, Page, timeoutMs, logger, minTurnIndex, expectedConversationId) {
|
|
1455
2415
|
try {
|
|
1456
|
-
return await waitForAssistantResponse(Runtime, timeoutMs, logger, minTurnIndex);
|
|
2416
|
+
return await waitForAssistantResponse(Runtime, timeoutMs, logger, minTurnIndex, expectedConversationId);
|
|
1457
2417
|
}
|
|
1458
2418
|
catch (error) {
|
|
1459
2419
|
if (!shouldReloadAfterAssistantError(error)) {
|
|
@@ -1463,20 +2423,20 @@ async function waitForAssistantResponseWithReload(Runtime, Page, timeoutMs, logg
|
|
|
1463
2423
|
if (!conversationUrl || !isConversationUrl(conversationUrl)) {
|
|
1464
2424
|
throw error;
|
|
1465
2425
|
}
|
|
1466
|
-
logger(
|
|
2426
|
+
logger("Assistant response stalled; reloading conversation and retrying once");
|
|
1467
2427
|
await Page.navigate({ url: conversationUrl });
|
|
1468
2428
|
await delay(1000);
|
|
1469
|
-
return await waitForAssistantResponse(Runtime, timeoutMs, logger, minTurnIndex);
|
|
2429
|
+
return await waitForAssistantResponse(Runtime, timeoutMs, logger, minTurnIndex, expectedConversationId);
|
|
1470
2430
|
}
|
|
1471
2431
|
}
|
|
1472
2432
|
function shouldReloadAfterAssistantError(error) {
|
|
1473
2433
|
if (!(error instanceof Error))
|
|
1474
2434
|
return false;
|
|
1475
2435
|
const message = error.message.toLowerCase();
|
|
1476
|
-
return (message.includes(
|
|
1477
|
-
message.includes(
|
|
1478
|
-
message.includes(
|
|
1479
|
-
message.includes(
|
|
2436
|
+
return (message.includes("assistant-response") ||
|
|
2437
|
+
message.includes("watchdog") ||
|
|
2438
|
+
message.includes("timeout") ||
|
|
2439
|
+
message.includes("capture assistant response"));
|
|
1480
2440
|
}
|
|
1481
2441
|
function isAssistantResponseTimeoutError(error) {
|
|
1482
2442
|
if (!(error instanceof Error))
|
|
@@ -1484,15 +2444,15 @@ function isAssistantResponseTimeoutError(error) {
|
|
|
1484
2444
|
const message = error.message.toLowerCase();
|
|
1485
2445
|
if (!message)
|
|
1486
2446
|
return false;
|
|
1487
|
-
return (message.includes(
|
|
1488
|
-
message.includes(
|
|
1489
|
-
message.includes(
|
|
1490
|
-
message.includes(
|
|
2447
|
+
return (message.includes("assistant-response") ||
|
|
2448
|
+
message.includes("assistant response") ||
|
|
2449
|
+
message.includes("watchdog") ||
|
|
2450
|
+
message.includes("capture assistant response"));
|
|
1491
2451
|
}
|
|
1492
2452
|
async function readConversationUrl(Runtime) {
|
|
1493
2453
|
try {
|
|
1494
|
-
const currentUrl = await Runtime.evaluate({ expression:
|
|
1495
|
-
return typeof currentUrl.result?.value ===
|
|
2454
|
+
const currentUrl = await Runtime.evaluate({ expression: "location.href", returnByValue: true });
|
|
2455
|
+
return typeof currentUrl.result?.value === "string" ? currentUrl.result.value : null;
|
|
1496
2456
|
}
|
|
1497
2457
|
catch {
|
|
1498
2458
|
return null;
|
|
@@ -1515,16 +2475,16 @@ async function validateChatGPTSession(Runtime, logger) {
|
|
|
1515
2475
|
});
|
|
1516
2476
|
const result = outcome.result?.value;
|
|
1517
2477
|
if (!result) {
|
|
1518
|
-
return { valid: false, reason:
|
|
2478
|
+
return { valid: false, reason: "Failed to evaluate session state" };
|
|
1519
2479
|
}
|
|
1520
2480
|
if (result.onAuthPage) {
|
|
1521
|
-
return { valid: false, reason:
|
|
2481
|
+
return { valid: false, reason: "Redirected to auth page" };
|
|
1522
2482
|
}
|
|
1523
2483
|
if (result.hasLoginCta) {
|
|
1524
|
-
return { valid: false, reason:
|
|
2484
|
+
return { valid: false, reason: "Login button detected on page" };
|
|
1525
2485
|
}
|
|
1526
2486
|
if (!result.hasTextarea) {
|
|
1527
|
-
return { valid: false, reason:
|
|
2487
|
+
return { valid: false, reason: "Prompt textarea not available" };
|
|
1528
2488
|
}
|
|
1529
2489
|
return { valid: true };
|
|
1530
2490
|
}
|
|
@@ -1611,9 +2571,9 @@ async function readConversationTurnCount(Runtime, logger) {
|
|
|
1611
2571
|
expression: `document.querySelectorAll(${selectorLiteral}).length`,
|
|
1612
2572
|
returnByValue: true,
|
|
1613
2573
|
});
|
|
1614
|
-
const raw = typeof result?.value ===
|
|
2574
|
+
const raw = typeof result?.value === "number" ? result.value : Number(result?.value);
|
|
1615
2575
|
if (!Number.isFinite(raw)) {
|
|
1616
|
-
throw new Error(
|
|
2576
|
+
throw new Error("Turn count not numeric");
|
|
1617
2577
|
}
|
|
1618
2578
|
return Math.max(0, Math.floor(raw));
|
|
1619
2579
|
}
|
|
@@ -1633,93 +2593,25 @@ async function readConversationTurnCount(Runtime, logger) {
|
|
|
1633
2593
|
function isConversationUrl(url) {
|
|
1634
2594
|
return /\/c\/[a-z0-9-]+/i.test(url);
|
|
1635
2595
|
}
|
|
1636
|
-
function startThinkingStatusMonitor(Runtime, logger, includeDiagnostics = false) {
|
|
1637
|
-
let stopped = false;
|
|
1638
|
-
let pending = false;
|
|
1639
|
-
let lastMessage = null;
|
|
1640
|
-
const startedAt = Date.now();
|
|
1641
|
-
const interval = setInterval(async () => {
|
|
1642
|
-
// stop flag flips asynchronously
|
|
1643
|
-
if (stopped || pending) {
|
|
1644
|
-
return;
|
|
1645
|
-
}
|
|
1646
|
-
pending = true;
|
|
1647
|
-
try {
|
|
1648
|
-
const nextMessage = await readThinkingStatus(Runtime);
|
|
1649
|
-
if (nextMessage && nextMessage !== lastMessage) {
|
|
1650
|
-
lastMessage = nextMessage;
|
|
1651
|
-
let locatorSuffix = '';
|
|
1652
|
-
if (includeDiagnostics) {
|
|
1653
|
-
try {
|
|
1654
|
-
const snapshot = await readAssistantSnapshot(Runtime);
|
|
1655
|
-
locatorSuffix = ` | assistant-turn=${snapshot ? 'present' : 'missing'}`;
|
|
1656
|
-
}
|
|
1657
|
-
catch {
|
|
1658
|
-
locatorSuffix = ' | assistant-turn=error';
|
|
1659
|
-
}
|
|
1660
|
-
}
|
|
1661
|
-
logger(formatThinkingLog(startedAt, Date.now(), nextMessage, locatorSuffix));
|
|
1662
|
-
}
|
|
1663
|
-
}
|
|
1664
|
-
catch {
|
|
1665
|
-
// ignore DOM polling errors
|
|
1666
|
-
}
|
|
1667
|
-
finally {
|
|
1668
|
-
pending = false;
|
|
1669
|
-
}
|
|
1670
|
-
}, 1500);
|
|
1671
|
-
interval.unref?.();
|
|
1672
|
-
return () => {
|
|
1673
|
-
// multiple callers may race to stop
|
|
1674
|
-
if (stopped) {
|
|
1675
|
-
return;
|
|
1676
|
-
}
|
|
1677
|
-
stopped = true;
|
|
1678
|
-
clearInterval(interval);
|
|
1679
|
-
};
|
|
1680
|
-
}
|
|
1681
|
-
async function readThinkingStatus(Runtime) {
|
|
1682
|
-
const expression = buildThinkingStatusExpression();
|
|
1683
|
-
try {
|
|
1684
|
-
const { result } = await Runtime.evaluate({ expression, returnByValue: true });
|
|
1685
|
-
const value = typeof result.value === 'string' ? result.value.trim() : '';
|
|
1686
|
-
const sanitized = sanitizeThinkingText(value);
|
|
1687
|
-
return sanitized || null;
|
|
1688
|
-
}
|
|
1689
|
-
catch {
|
|
1690
|
-
return null;
|
|
1691
|
-
}
|
|
1692
|
-
}
|
|
1693
|
-
function sanitizeThinkingText(raw) {
|
|
1694
|
-
if (!raw) {
|
|
1695
|
-
return '';
|
|
1696
|
-
}
|
|
1697
|
-
const trimmed = raw.trim();
|
|
1698
|
-
const prefixPattern = /^(pro thinking)\s*[•:\-–—]*\s*/i;
|
|
1699
|
-
if (prefixPattern.test(trimmed)) {
|
|
1700
|
-
return trimmed.replace(prefixPattern, '').trim();
|
|
1701
|
-
}
|
|
1702
|
-
return trimmed;
|
|
1703
|
-
}
|
|
1704
2596
|
function describeDevtoolsFirewallHint(host, port) {
|
|
1705
2597
|
if (!isWsl())
|
|
1706
2598
|
return null;
|
|
1707
2599
|
return [
|
|
1708
2600
|
`DevTools port ${host}:${port} is blocked from WSL.`,
|
|
1709
|
-
|
|
1710
|
-
|
|
2601
|
+
"",
|
|
2602
|
+
"PowerShell (admin):",
|
|
1711
2603
|
`New-NetFirewallRule -DisplayName 'Chrome DevTools ${port}' -Direction Inbound -Action Allow -Protocol TCP -LocalPort ${port}`,
|
|
1712
2604
|
"New-NetFirewallRule -DisplayName 'Chrome DevTools (chrome.exe)' -Direction Inbound -Action Allow -Program 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' -Protocol TCP",
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
].join(
|
|
2605
|
+
"",
|
|
2606
|
+
"Re-run the same oracle command after adding the rule.",
|
|
2607
|
+
].join("\n");
|
|
1716
2608
|
}
|
|
1717
2609
|
function isWsl() {
|
|
1718
|
-
if (process.platform !==
|
|
2610
|
+
if (process.platform !== "linux")
|
|
1719
2611
|
return false;
|
|
1720
2612
|
if (process.env.WSL_DISTRO_NAME)
|
|
1721
2613
|
return true;
|
|
1722
|
-
return os.release().toLowerCase().includes(
|
|
2614
|
+
return os.release().toLowerCase().includes("microsoft");
|
|
1723
2615
|
}
|
|
1724
2616
|
function extractConversationIdFromUrl(url) {
|
|
1725
2617
|
const match = url.match(/\/c\/([a-zA-Z0-9-]+)/);
|
|
@@ -1729,9 +2621,9 @@ async function resolveUserDataBaseDir() {
|
|
|
1729
2621
|
// On WSL, Chrome launched via Windows can choke on UNC paths; prefer a Windows-backed temp folder.
|
|
1730
2622
|
if (isWsl()) {
|
|
1731
2623
|
const candidates = [
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
2624
|
+
"/mnt/c/Users/Public/AppData/Local/Temp",
|
|
2625
|
+
"/mnt/c/Temp",
|
|
2626
|
+
"/mnt/c/Windows/Temp",
|
|
1735
2627
|
];
|
|
1736
2628
|
for (const candidate of candidates) {
|
|
1737
2629
|
try {
|
|
@@ -1743,53 +2635,28 @@ async function resolveUserDataBaseDir() {
|
|
|
1743
2635
|
}
|
|
1744
2636
|
}
|
|
1745
2637
|
}
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
'[role="status"]',
|
|
1755
|
-
'[aria-live="polite"]',
|
|
1756
|
-
];
|
|
1757
|
-
const keywords = ['pro thinking', 'thinking', 'reasoning', 'clarifying', 'planning', 'drafting', 'summarizing'];
|
|
1758
|
-
const selectorLiteral = JSON.stringify(selectors);
|
|
1759
|
-
const keywordsLiteral = JSON.stringify(keywords);
|
|
1760
|
-
return `(() => {
|
|
1761
|
-
const selectors = ${selectorLiteral};
|
|
1762
|
-
const keywords = ${keywordsLiteral};
|
|
1763
|
-
const nodes = new Set();
|
|
1764
|
-
for (const selector of selectors) {
|
|
1765
|
-
document.querySelectorAll(selector).forEach((node) => nodes.add(node));
|
|
1766
|
-
}
|
|
1767
|
-
document.querySelectorAll('[data-testid]').forEach((node) => nodes.add(node));
|
|
1768
|
-
for (const node of nodes) {
|
|
1769
|
-
if (!(node instanceof HTMLElement)) {
|
|
1770
|
-
continue;
|
|
1771
|
-
}
|
|
1772
|
-
const text = node.textContent?.trim();
|
|
1773
|
-
if (!text) {
|
|
1774
|
-
continue;
|
|
1775
|
-
}
|
|
1776
|
-
const classLabel = (node.className || '').toLowerCase();
|
|
1777
|
-
const dataLabel = ((node.getAttribute('data-testid') || '') + ' ' + (node.getAttribute('aria-label') || ''))
|
|
1778
|
-
.toLowerCase();
|
|
1779
|
-
const normalizedText = text.toLowerCase();
|
|
1780
|
-
const matches = keywords.some((keyword) =>
|
|
1781
|
-
normalizedText.includes(keyword) || classLabel.includes(keyword) || dataLabel.includes(keyword)
|
|
1782
|
-
);
|
|
1783
|
-
if (matches) {
|
|
1784
|
-
const shimmerChild = node.querySelector(
|
|
1785
|
-
'span.flex.items-center.gap-1.truncate.text-start.align-middle.text-token-text-tertiary',
|
|
1786
|
-
);
|
|
1787
|
-
if (shimmerChild?.textContent?.trim()) {
|
|
1788
|
-
return shimmerChild.textContent.trim();
|
|
2638
|
+
const tmpDir = os.tmpdir();
|
|
2639
|
+
if (shouldPreferSystemTmpDir(process.platform, tmpDir, os.homedir())) {
|
|
2640
|
+
try {
|
|
2641
|
+
await mkdir("/tmp", { recursive: true });
|
|
2642
|
+
return "/tmp";
|
|
2643
|
+
}
|
|
2644
|
+
catch {
|
|
2645
|
+
// Fall back to the inherited tmpdir if /tmp is unavailable.
|
|
1789
2646
|
}
|
|
1790
|
-
return text.trim();
|
|
1791
|
-
}
|
|
1792
2647
|
}
|
|
1793
|
-
return
|
|
1794
|
-
|
|
2648
|
+
return tmpDir;
|
|
2649
|
+
}
|
|
2650
|
+
function shouldPreferSystemTmpDir(platform, tmpDir, homeDir) {
|
|
2651
|
+
if (platform !== "linux" || !tmpDir || !homeDir)
|
|
2652
|
+
return false;
|
|
2653
|
+
const relativeToHome = path.relative(homeDir, tmpDir);
|
|
2654
|
+
if (!relativeToHome || relativeToHome.startsWith("..") || path.isAbsolute(relativeToHome)) {
|
|
2655
|
+
return false;
|
|
2656
|
+
}
|
|
2657
|
+
const firstSegment = relativeToHome.split(path.sep, 1)[0];
|
|
2658
|
+
return Boolean(firstSegment?.startsWith("."));
|
|
2659
|
+
}
|
|
2660
|
+
export function shouldPreferSystemTmpDirForTest(platform, tmpDir, homeDir) {
|
|
2661
|
+
return shouldPreferSystemTmpDir(platform, tmpDir, homeDir);
|
|
1795
2662
|
}
|