@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.
Files changed (194) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +107 -49
  3. package/dist/bin/oracle-cli.js +551 -410
  4. package/dist/bin/oracle-mcp.js +2 -2
  5. package/dist/bin/oracle.js +165 -279
  6. package/dist/scripts/agent-send.js +31 -31
  7. package/dist/scripts/check.js +6 -6
  8. package/dist/scripts/debug/extract-chatgpt-response.js +10 -10
  9. package/dist/scripts/docs-list.js +30 -30
  10. package/dist/scripts/git-policy.js +25 -23
  11. package/dist/scripts/run-cli.js +8 -8
  12. package/dist/scripts/runner.js +203 -195
  13. package/dist/scripts/test-browser.js +21 -18
  14. package/dist/scripts/test-remote-chrome.js +20 -20
  15. package/dist/src/bridge/connection.js +18 -18
  16. package/dist/src/bridge/userConfigFile.js +7 -7
  17. package/dist/src/browser/actions/archiveConversation.js +224 -0
  18. package/dist/src/browser/actions/assistantResponse.js +175 -101
  19. package/dist/src/browser/actions/attachmentDataTransfer.js +49 -47
  20. package/dist/src/browser/actions/attachments.js +246 -150
  21. package/dist/src/browser/actions/deepResearch.js +662 -0
  22. package/dist/src/browser/actions/domEvents.js +2 -2
  23. package/dist/src/browser/actions/modelSelection.js +342 -119
  24. package/dist/src/browser/actions/navigation.js +183 -137
  25. package/dist/src/browser/actions/projectSources.js +491 -0
  26. package/dist/src/browser/actions/promptComposer.js +152 -91
  27. package/dist/src/browser/actions/remoteFileTransfer.js +10 -10
  28. package/dist/src/browser/actions/thinkingStatus.js +391 -0
  29. package/dist/src/browser/actions/thinkingTime.js +207 -110
  30. package/dist/src/browser/artifacts.js +150 -0
  31. package/dist/src/browser/attachRunning.js +31 -0
  32. package/dist/src/browser/chatgptImages.js +315 -0
  33. package/dist/src/browser/chromeLifecycle.js +276 -63
  34. package/dist/src/browser/config.js +59 -16
  35. package/dist/src/browser/constants.js +25 -12
  36. package/dist/src/browser/controlPlan.js +81 -0
  37. package/dist/src/browser/cookies.js +19 -19
  38. package/dist/src/browser/detect.js +250 -77
  39. package/dist/src/browser/domDebug.js +50 -1
  40. package/dist/src/browser/index.js +1559 -692
  41. package/dist/src/browser/liveTabs.js +434 -0
  42. package/dist/src/browser/modelStrategy.js +1 -1
  43. package/dist/src/browser/pageActions.js +5 -5
  44. package/dist/src/browser/policies.js +16 -13
  45. package/dist/src/browser/profileState.js +127 -42
  46. package/dist/src/browser/projectSourcesRunner.js +366 -0
  47. package/dist/src/browser/prompt.js +72 -42
  48. package/dist/src/browser/promptSummary.js +5 -5
  49. package/dist/src/browser/providerDomFlow.js +1 -1
  50. package/dist/src/browser/providers/chatgptDomProvider.js +9 -9
  51. package/dist/src/browser/providers/geminiDeepThinkDomProvider.js +51 -42
  52. package/dist/src/browser/providers/index.js +2 -2
  53. package/dist/src/browser/reattach.js +178 -73
  54. package/dist/src/browser/reattachHelpers.js +32 -27
  55. package/dist/src/browser/sessionRunner.js +89 -25
  56. package/dist/src/browser/tabLeaseRegistry.js +182 -0
  57. package/dist/src/browser/utils.js +9 -9
  58. package/dist/src/browserMode.js +1 -1
  59. package/dist/src/cli/bridge/claudeConfig.js +24 -20
  60. package/dist/src/cli/bridge/client.js +28 -20
  61. package/dist/src/cli/bridge/codexConfig.js +16 -16
  62. package/dist/src/cli/bridge/doctor.js +47 -39
  63. package/dist/src/cli/bridge/host.js +58 -56
  64. package/dist/src/cli/browserConfig.js +102 -48
  65. package/dist/src/cli/browserDefaults.js +51 -26
  66. package/dist/src/cli/browserTabs.js +228 -0
  67. package/dist/src/cli/bundleWarnings.js +1 -1
  68. package/dist/src/cli/clipboard.js +11 -2
  69. package/dist/src/cli/detach.js +2 -2
  70. package/dist/src/cli/dryRun.js +62 -26
  71. package/dist/src/cli/duplicatePromptGuard.js +12 -4
  72. package/dist/src/cli/engine.js +9 -9
  73. package/dist/src/cli/errorUtils.js +1 -1
  74. package/dist/src/cli/fileSize.js +3 -3
  75. package/dist/src/cli/format.js +2 -2
  76. package/dist/src/cli/help.js +28 -28
  77. package/dist/src/cli/hiddenAliases.js +3 -3
  78. package/dist/src/cli/markdownBundle.js +7 -7
  79. package/dist/src/cli/markdownRenderer.js +15 -15
  80. package/dist/src/cli/notifier.js +77 -67
  81. package/dist/src/cli/options.js +131 -106
  82. package/dist/src/cli/oscUtils.js +1 -1
  83. package/dist/src/cli/projectSources.js +116 -0
  84. package/dist/src/cli/promptRequirement.js +2 -2
  85. package/dist/src/cli/renderOutput.js +1 -1
  86. package/dist/src/cli/rootAlias.js +1 -1
  87. package/dist/src/cli/runOptions.js +32 -28
  88. package/dist/src/cli/sessionCommand.js +82 -21
  89. package/dist/src/cli/sessionDisplay.js +213 -87
  90. package/dist/src/cli/sessionLineage.js +6 -2
  91. package/dist/src/cli/sessionRunner.js +149 -95
  92. package/dist/src/cli/sessionTable.js +26 -23
  93. package/dist/src/cli/stdin.js +22 -0
  94. package/dist/src/cli/tagline.js +121 -124
  95. package/dist/src/cli/tui/index.js +139 -128
  96. package/dist/src/cli/writeOutputPath.js +5 -5
  97. package/dist/src/config.js +7 -7
  98. package/dist/src/gemini-web/browserSessionManager.js +19 -15
  99. package/dist/src/gemini-web/client.js +76 -70
  100. package/dist/src/gemini-web/executionMode.js +6 -8
  101. package/dist/src/gemini-web/executor.js +98 -93
  102. package/dist/src/gemini-web/index.js +1 -1
  103. package/dist/src/mcp/consultPresets.js +19 -0
  104. package/dist/src/mcp/server.js +18 -12
  105. package/dist/src/mcp/tools/consult.js +246 -67
  106. package/dist/src/mcp/tools/projectSources.js +123 -0
  107. package/dist/src/mcp/tools/sessionResources.js +12 -12
  108. package/dist/src/mcp/tools/sessions.js +26 -17
  109. package/dist/src/mcp/types.js +12 -5
  110. package/dist/src/mcp/utils.js +21 -8
  111. package/dist/src/oracle/background.js +15 -15
  112. package/dist/src/oracle/claude.js +53 -25
  113. package/dist/src/oracle/client.js +50 -41
  114. package/dist/src/oracle/config.js +96 -66
  115. package/dist/src/oracle/errors.js +38 -38
  116. package/dist/src/oracle/files.js +55 -46
  117. package/dist/src/oracle/finishLine.js +10 -8
  118. package/dist/src/oracle/format.js +3 -3
  119. package/dist/src/oracle/gemini.js +37 -33
  120. package/dist/src/oracle/logging.js +7 -7
  121. package/dist/src/oracle/markdown.js +28 -28
  122. package/dist/src/oracle/modelResolver.js +16 -16
  123. package/dist/src/oracle/multiModelRunner.js +12 -12
  124. package/dist/src/oracle/oscProgress.js +8 -8
  125. package/dist/src/oracle/promptAssembly.js +6 -3
  126. package/dist/src/oracle/request.js +16 -13
  127. package/dist/src/oracle/run.js +160 -135
  128. package/dist/src/oracle/runUtils.js +8 -5
  129. package/dist/src/oracle/tokenEstimate.js +6 -6
  130. package/dist/src/oracle/tokenStats.js +5 -5
  131. package/dist/src/oracle/tokenStringifier.js +5 -5
  132. package/dist/src/oracle.js +12 -12
  133. package/dist/src/oracleHome.js +3 -3
  134. package/dist/src/projectSources/plan.js +27 -0
  135. package/dist/src/projectSources/url.js +23 -0
  136. package/dist/src/remote/client.js +25 -25
  137. package/dist/src/remote/health.js +20 -20
  138. package/dist/src/remote/remoteServiceConfig.js +9 -9
  139. package/dist/src/remote/server.js +129 -118
  140. package/dist/src/sessionManager.js +78 -75
  141. package/dist/src/sessionStore.js +3 -3
  142. package/dist/src/version.js +10 -10
  143. package/dist/vendor/oracle-notifier/README.md +2 -0
  144. package/package.json +67 -62
  145. package/vendor/oracle-notifier/README.md +2 -0
  146. package/dist/markdansi/types/index.js +0 -4
  147. package/dist/oracle/bin/oracle-cli.js +0 -472
  148. package/dist/oracle/src/browser/actions/assistantResponse.js +0 -471
  149. package/dist/oracle/src/browser/actions/attachments.js +0 -82
  150. package/dist/oracle/src/browser/actions/modelSelection.js +0 -190
  151. package/dist/oracle/src/browser/actions/navigation.js +0 -75
  152. package/dist/oracle/src/browser/actions/promptComposer.js +0 -167
  153. package/dist/oracle/src/browser/chromeLifecycle.js +0 -104
  154. package/dist/oracle/src/browser/config.js +0 -33
  155. package/dist/oracle/src/browser/constants.js +0 -40
  156. package/dist/oracle/src/browser/cookies.js +0 -210
  157. package/dist/oracle/src/browser/domDebug.js +0 -36
  158. package/dist/oracle/src/browser/index.js +0 -331
  159. package/dist/oracle/src/browser/pageActions.js +0 -5
  160. package/dist/oracle/src/browser/prompt.js +0 -88
  161. package/dist/oracle/src/browser/promptSummary.js +0 -20
  162. package/dist/oracle/src/browser/sessionRunner.js +0 -80
  163. package/dist/oracle/src/browser/utils.js +0 -62
  164. package/dist/oracle/src/browserMode.js +0 -1
  165. package/dist/oracle/src/cli/browserConfig.js +0 -44
  166. package/dist/oracle/src/cli/dryRun.js +0 -59
  167. package/dist/oracle/src/cli/engine.js +0 -17
  168. package/dist/oracle/src/cli/errorUtils.js +0 -9
  169. package/dist/oracle/src/cli/help.js +0 -70
  170. package/dist/oracle/src/cli/markdownRenderer.js +0 -15
  171. package/dist/oracle/src/cli/options.js +0 -103
  172. package/dist/oracle/src/cli/promptRequirement.js +0 -14
  173. package/dist/oracle/src/cli/rootAlias.js +0 -30
  174. package/dist/oracle/src/cli/sessionCommand.js +0 -77
  175. package/dist/oracle/src/cli/sessionDisplay.js +0 -270
  176. package/dist/oracle/src/cli/sessionRunner.js +0 -94
  177. package/dist/oracle/src/heartbeat.js +0 -43
  178. package/dist/oracle/src/oracle/client.js +0 -48
  179. package/dist/oracle/src/oracle/config.js +0 -29
  180. package/dist/oracle/src/oracle/errors.js +0 -101
  181. package/dist/oracle/src/oracle/files.js +0 -220
  182. package/dist/oracle/src/oracle/format.js +0 -33
  183. package/dist/oracle/src/oracle/fsAdapter.js +0 -7
  184. package/dist/oracle/src/oracle/oscProgress.js +0 -60
  185. package/dist/oracle/src/oracle/request.js +0 -48
  186. package/dist/oracle/src/oracle/run.js +0 -444
  187. package/dist/oracle/src/oracle/tokenStats.js +0 -39
  188. package/dist/oracle/src/oracle/types.js +0 -1
  189. package/dist/oracle/src/oracle.js +0 -9
  190. package/dist/oracle/src/sessionManager.js +0 -205
  191. package/dist/oracle/src/version.js +0 -39
  192. package/dist/scripts/chrome/browser-tools.js +0 -295
  193. package/dist/src/browser/profileSync.js +0 -141
  194. /package/dist/{oracle/src/browser → src/projectSources}/types.js +0 -0
@@ -1,43 +1,317 @@
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, closeRemoteChromeTarget, connectWithNewTab, closeTab, } 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 { estimateTokenCount, withRetries, delay } from './utils.js';
13
- import { formatElapsed } from '../oracle/format.js';
14
- import { CHATGPT_URL, CONVERSATION_TURN_SELECTOR, DEFAULT_MODEL_STRATEGY } from './constants.js';
15
- import { BrowserAutomationError } from '../oracle/errors.js';
16
- import { alignPromptEchoPair, buildPromptEchoMatcher } from './reattachHelpers.js';
17
- import { cleanupStaleProfileState, acquireProfileRunLock, readChromePid, readDevToolsPort, shouldCleanupManualLoginProfileState, verifyDevToolsReachable, writeChromePid, writeDevToolsActivePort, } from './profileState.js';
18
- import { runProviderSubmissionFlow } from './providerDomFlow.js';
19
- import { chatgptDomProvider } from './providers/index.js';
20
- export { CHATGPT_URL, DEFAULT_MODEL_STRATEGY, DEFAULT_MODEL_TARGET } from './constants.js';
21
- export { parseDuration, delay, normalizeChatgptUrl, isTemporaryChatUrl } from './utils.js';
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 === 'cloudflare-challenge';
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 !headless && isCloudflareChallengeError(error);
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('Prompt text is required when using browser mode.');
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 (!runtimeHintCb || !chrome?.port) {
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 === '1') {
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
- if (config.headless || config.hideWindow || config.keepBrowser || config.chromePath) {
92
- logger('Note: --remote-chrome ignores local Chrome flags ' +
93
- '(--browser-headless, --browser-hide-window, --browser-keep-browser, --browser-chrome-path).');
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(), '.oracle', 'browser-profile');
393
+ : path.join(os.homedir(), ".oracle", "browser-profile");
101
394
  const userDataDir = manualLogin
102
395
  ? manualProfileDir
103
- : await mkdtemp(path.join(await resolveUserDataBaseDir(), 'oracle-browser-'));
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
- const reusedChrome = manualLogin
114
- ? await maybeReuseRunningChrome(userDataDir, logger, { waitForPortMs: config.reuseChromeWaitMs })
115
- : null;
116
- const chrome = reusedChrome ??
117
- (await launchChrome({
118
- ...config,
119
- remoteChrome: config.remoteChrome,
120
- }, userDataDir, logger));
121
- const chromeHost = chrome.host ?? '127.0.0.1';
122
- // Persist profile state so future manual-login runs can reuse this Chrome.
123
- if (manualLogin && chrome.port) {
124
- await writeDevToolsActivePort(userDataDir, chrome.port);
125
- if (!reusedChrome && chrome.pid) {
126
- await writeChromePid(userDataDir, chrome.pid);
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 !== 'complete',
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 = 'attempted';
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
- const strictTabIsolation = Boolean(manualLogin && reusedChrome);
155
- const connection = await connectWithNewTab(chrome.port, logger, undefined, chromeHost, {
156
- fallbackToDefault: !strictTabIsolation,
157
- retries: strictTabIsolation ? 3 : 0,
158
- retryDelayMs: 500,
159
- });
160
- client = connection.client;
161
- isolatedTargetId = connection.targetId ?? null;
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('disconnect', () => {
508
+ client?.on("disconnect", () => {
172
509
  connectionClosedUnexpectedly = true;
173
- logger('Chrome window closed; attempting to abort run.');
174
- reject(new Error('Chrome window closed before oracle finished. Please keep it open until completion.'));
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 === 'function') {
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('Manual login mode: seeding persistent profile with cookies from your Chrome profile.');
532
+ logger("Manual login mode: seeding persistent profile with cookies from your Chrome profile.");
196
533
  }
197
534
  if (!config.inlineCookies) {
198
- logger('Heads-up: macOS may prompt for your Keychain password to read Chrome cookies; use --copy or --render for manual flow.');
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('Applying inline cookies (skipping Chrome profile read and Keychain prompt)');
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('No inline cookies were applied; aborting before navigation.');
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 ?? 'Default'}`
555
+ : `Copied ${cookieCount} cookies from Chrome profile ${config.chromeProfile ?? "Default"}`
219
556
  : config.inlineCookies
220
- ? 'No inline cookies applied; continuing without session reuse'
221
- : 'No Chrome cookies found; continuing without session reuse');
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
- ? 'Skipping Chrome cookie sync (--browser-manual-login enabled); reuse the opened profile after signing in.'
226
- : 'Skipping Chrome cookie sync (--browser-no-cookie-sync)');
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('No ChatGPT cookies were applied from your Chrome profile; cannot proceed in browser mode. ' +
232
- 'Make sure ChatGPT is signed in in the selected profile, use --browser-manual-login / inline cookies, ' +
233
- 'or retry with --browser-cookie-wait 5s if Keychain prompts are slow.', {
234
- stage: 'execute-browser',
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 ?? 'Default',
573
+ profile: config.chromeProfile ?? "Default",
237
574
  cookiePath: config.chromeCookiePath ?? null,
238
- hint: 'If macOS Keychain prompts or denies access, run oracle from a GUI session or use --copy/--render for the manual flow.',
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
- const baseUrl = CHATGPT_URL;
243
- // First load the base ChatGPT homepage to satisfy potential interstitials,
244
- // then hop to the requested URL if it differs.
245
- await raceWithDisconnect(navigateToChatGPT(Page, Runtime, baseUrl, logger));
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
- await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
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: 'location.href',
625
+ expression: "location.href",
276
626
  returnByValue: true,
277
627
  });
278
- if (typeof result?.value === 'string') {
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({ expression: 'location.href', returnByValue: true });
308
- if (typeof result?.value === 'string' && result.value.includes('/c/')) {
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 !== 'ignore') {
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
- ? ' No cookies were applied; log in to ChatGPT in Chrome or provide inline cookies (--browser-inline-cookies[(-file)] or ORACLE_BROWSER_COOKIES_JSON).'
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 === 'ignore') {
356
- logger('Model picker: skipped (strategy=ignore)');
708
+ else if (modelStrategy === "ignore") {
709
+ logger("Model picker: skipped (strategy=ignore)");
357
710
  }
358
- // Handle thinking time selection if specified
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
- await raceWithDisconnect(withRetries(() => ensureThinkingTime(Runtime, thinkingTime, logger), {
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: 300,
733
+ delayMs: 500,
364
734
  onRetry: (attempt, error) => {
365
735
  if (options.verbose) {
366
- logger(`[retry] Thinking time (${thinkingTime}) attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`);
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 === 'string' ? baselineSnapshot.text.trim() : '';
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('Chrome DOM domain unavailable while uploading attachments.');
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
- try {
413
- await waitForAttachmentCompletion(Runtime, waitBudget, attachmentNames, logger);
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: sendAttachmentNames,
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 === 'number' && Number.isFinite(providerBaselineTurns)) {
807
+ if (typeof providerBaselineTurns === "number" && Number.isFinite(providerBaselineTurns)) {
448
808
  baselineTurns = providerBaselineTurns;
449
809
  }
450
810
  if (attachmentNames.length > 0) {
451
- if (attachmentWaitTimedOut) {
452
- logger('Attachment confirmation timed out; skipping user-turn attachment verification.');
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
- throw new Error('Sent user message did not expose attachment UI after upload.');
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('post-submit', config.timeoutMs ?? 120_000);
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
- try {
474
- const submission = await raceWithDisconnect(submitOnce(promptText, attachments));
475
- baselineTurns = submission.baselineTurns;
476
- baselineAssistantText = submission.baselineAssistantText;
477
- }
478
- catch (error) {
479
- const isPromptTooLarge = error instanceof BrowserAutomationError &&
480
- error.details?.code === 'prompt-too-large';
481
- if (fallbackSubmission && isPromptTooLarge) {
482
- // Learned: when prompts truncate, retry with file uploads so the UI receives the full content.
483
- logger('[browser] Inline prompt too large; retrying with file uploads.');
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
- const submission = await raceWithDisconnect(submitOnce(fallbackSubmission.prompt, fallbackSubmission.attachments));
487
- baselineTurns = submission.baselineTurns;
488
- baselineAssistantText = submission.baselineAssistantText;
489
- }
490
- else {
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
- stopThinkingMonitor = startThinkingStatusMonitor(Runtime, logger, options.verbose ?? false);
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, ' ').trim();
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 === 'string' ? snapshot.text.trim() : '';
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 || (baselinePrefix.length > 0 && normalized.startsWith(baselinePrefix));
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: { turnId: snapshot?.turnId ?? undefined, messageId: snapshot?.messageId ?? undefined },
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
- let answer;
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('assistant-recheck', 15_000).catch(() => false);
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 || 'unknown'}. ` +
974
+ `Conversation URL: ${conversationUrl || lastUrl || "unknown"}. ` +
548
975
  `Please sign in and retry.`, {
549
- stage: 'assistant-recheck',
976
+ stage: "assistant-recheck",
550
977
  details: {
551
978
  conversationUrl: conversationUrl || lastUrl || null,
552
- sessionStatus: 'needs_login',
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 raceWithDisconnect(waitForAssistantResponseWithReload(Runtime, Page, timeoutMs, logger, baselineTurns ?? undefined));
569
- logger('Recovered assistant response after delayed recheck');
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
- try {
573
- answer = await raceWithDisconnect(waitForAssistantResponseWithReload(Runtime, Page, config.timeoutMs, logger, baselineTurns ?? undefined));
574
- }
575
- catch (error) {
576
- if (isAssistantResponseTimeoutError(error)) {
577
- const rechecked = await attemptAssistantRecheck().catch(() => null);
578
- if (rechecked) {
579
- answer = rechecked;
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
- await updateConversationHint('assistant-timeout', 15_000).catch(() => false);
583
- await captureRuntimeSnapshot().catch(() => undefined);
584
- const runtime = {
585
- chromePid: chrome.pid,
586
- chromePort: chrome.port,
587
- chromeHost,
588
- userDataDir,
589
- chromeTargetId: lastTargetId,
590
- tabUrl: lastUrl,
591
- conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
592
- controllerPid: process.pid,
593
- };
594
- throw new BrowserAutomationError('Assistant response timed out before completion; reattach later to capture the answer.', { stage: 'assistant-timeout', runtime }, error);
1051
+ throw error;
595
1052
  }
596
1053
  }
597
- else {
598
- throw error;
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
- // Ensure we store the final conversation URL even if the UI updated late.
602
- await updateConversationHint('post-response', 15_000);
603
- const baselineNormalized = baselineAssistantText ? normalizeForComparison(baselineAssistantText) : '';
604
- if (baselineNormalized) {
605
- const normalizedAnswer = normalizeForComparison(answer.text ?? '');
606
- const baselinePrefix = baselineNormalized.length >= 80
607
- ? baselineNormalized.slice(0, Math.min(200, baselineNormalized.length))
608
- : '';
609
- const isBaseline = normalizedAnswer === baselineNormalized ||
610
- (baselinePrefix.length > 0 && normalizedAnswer.startsWith(baselinePrefix));
611
- if (isBaseline) {
612
- logger('Detected stale assistant response; waiting for new response...');
613
- const refreshed = await waitForFreshAssistantResponse(baselineNormalized, 15_000);
614
- if (refreshed) {
615
- answer = refreshed;
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
- answerText = answer.text;
620
- answerHtml = answer.html ?? '';
621
- const copiedMarkdown = await raceWithDisconnect(withRetries(async () => {
622
- const attempt = await captureAssistantMarkdown(Runtime, answer.meta, logger);
623
- if (!attempt) {
624
- throw new Error('copy-missing');
625
- }
626
- return attempt;
627
- }, {
628
- retries: 2,
629
- delayMs: 350,
630
- onRetry: (attempt, error) => {
631
- if (options.verbose) {
632
- logger(`[retry] Markdown capture attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`);
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
- })).catch(() => null);
636
- answerMarkdown = copiedMarkdown ?? answerText;
637
- const promptEchoMatcher = buildPromptEchoMatcher(promptText);
638
- ({ answerText, answerMarkdown } = await maybeRecoverLongAssistantResponse({
639
- runtime: Runtime,
640
- baselineTurns,
641
- answerText,
642
- answerMarkdown,
643
- logger,
644
- allowMarkdownUpdate: !copiedMarkdown,
645
- }));
646
- // Final sanity check: ensure we didn't accidentally capture the user prompt instead of the assistant turn.
647
- const finalSnapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined).catch(() => null);
648
- const finalText = typeof finalSnapshot?.text === 'string' ? finalSnapshot.text.trim() : '';
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
- stableCount = 0;
1166
+ stableCycles = 0;
684
1167
  }
685
- else if (text === bestText) {
686
- stableCount += 1;
1168
+ else {
1169
+ stableCycles += 1;
687
1170
  }
688
- if (stableCount >= 2) {
1171
+ if (stableCycles >= 3 && bestText.length >= minAnswerChars) {
689
1172
  break;
690
1173
  }
1174
+ await delay(400);
691
1175
  }
692
- await new Promise((resolve) => setTimeout(resolve, 300));
693
- }
694
- if (bestText) {
695
- logger('Recovered assistant response after detecting prompt echo');
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
- if (bestText.length > answerText.trim().length) {
721
- logger('Refreshed short assistant response from latest DOM snapshot');
722
- answerText = bestText;
723
- answerMarkdown = bestText;
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('Chrome disconnected before completion');
1233
+ throw new Error("Chrome disconnected before completion");
729
1234
  }
730
- stopThinkingMonitor?.();
731
- runStatus = 'complete';
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
- if (shouldPreserveBrowserOnError(normalizedError, config.headless)) {
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('Cloudflare challenge detected; leaving browser open so you can complete the check.');
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('Cloudflare challenge detected. Complete the “Just a moment…” check in the open browser, then rerun.', {
773
- stage: 'cloudflare-challenge',
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 === '1') && normalizedError.stack) {
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 === '1') && normalizedError.stack) {
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('Chrome window closed before oracle finished. Please keep it open until completion.', {
791
- stage: 'connection-lost',
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 (runStatus === 'complete' && isolatedTargetId && chrome?.port) {
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
- await chrome.kill();
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: 'never' }).catch(() => undefined);
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 if (!connectionClosedUnexpectedly) {
849
- logger(`Chrome left running on port ${chrome.port} with profile ${userDataDir}`);
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('error', () => resolve(false));
870
- server.once('listening', () => {
1480
+ server.once("error", () => resolve(false));
1481
+ server.once("listening", () => {
871
1482
  server.close(() => resolve(true));
872
1483
  });
873
- server.listen(port, '127.0.0.1');
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('error', (error) => {
1490
+ server.once("error", (error) => {
880
1491
  server.close();
881
1492
  reject(error);
882
1493
  });
883
- server.listen(0, '127.0.0.1', () => {
1494
+ server.listen(0, "127.0.0.1", () => {
884
1495
  const address = server.address();
885
- if (address && typeof address === 'object') {
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('Failed to acquire ephemeral port')));
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('login button');
910
- const sessionMissing = message?.toLowerCase().includes('session not detected');
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('Manual login mode: please sign into chatgpt.com in the opened Chrome window; waiting for session to appear...');
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('Manual login mode timed out waiting for ChatGPT session; please sign in and retry.');
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 === 'string' ? laterSnapshot.text.trim() : '';
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 === 'string' ? 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('ChatGPT session not detected; page never left new tab.', {
971
- stage: 'execute-browser',
972
- details: { url: lastUrl || '(empty)' },
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
- if (!port)
987
- return null;
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: 'if_oracle_pid_dead' });
1670
+ await cleanupStaleProfileState(userDataDir, logger, { lockRemovalMode: "if_oracle_pid_dead" });
993
1671
  return null;
994
1672
  }
995
- const pid = await readChromePid(userDataDir);
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('Remote Chrome configuration missing. Pass --remote-chrome <host:port> to use this mode.');
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 connection = await connectToRemoteChrome(host, port, logger, config.url);
1041
- client = connection.client;
1042
- remoteTargetId = connection.targetId ?? null;
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('disconnect', markConnectionLost);
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 === 'function') {
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('Skipping cookie sync for remote Chrome (using existing session)');
1057
- await navigateToChatGPT(Page, Runtime, config.url, logger);
1058
- await ensureNotBlocked(Runtime, config.headless, logger);
1059
- await ensureLoggedIn(Runtime, logger, { remoteSession: true });
1060
- await ensurePromptReady(Runtime, config.inputTimeoutMs, logger);
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: 'location.href',
1803
+ expression: "location.href",
1065
1804
  returnByValue: true,
1066
1805
  });
1067
- if (typeof result?.value === 'string') {
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 !== 'ignore') {
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 === 'ignore') {
1090
- logger('Model picker: skipped (strategy=ignore)');
1828
+ else if (modelStrategy === "ignore") {
1829
+ logger("Model picker: skipped (strategy=ignore)");
1091
1830
  }
1092
- // Handle thinking time selection if specified
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
- await withRetries(() => ensureThinkingTime(Runtime, thinkingTime, logger), {
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: 300,
1853
+ delayMs: 500,
1098
1854
  onRetry: (attempt, error) => {
1099
1855
  if (options.verbose) {
1100
- logger(`[retry] Thinking time (${thinkingTime}) attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`);
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 === 'string' ? baselineSnapshot.text.trim() : '';
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('Chrome DOM domain unavailable while uploading attachments.');
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('All attachments uploaded');
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 === 'number' && Number.isFinite(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
- try {
1153
- const submission = await submitOnce(promptText, attachments);
1154
- baselineTurns = submission.baselineTurns;
1155
- baselineAssistantText = submission.baselineAssistantText;
1156
- }
1157
- catch (error) {
1158
- const isPromptTooLarge = error instanceof BrowserAutomationError &&
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
- const submission = await submitOnce(options.fallbackSubmission.prompt, options.fallbackSubmission.attachments);
1165
- baselineTurns = submission.baselineTurns;
1166
- baselineAssistantText = submission.baselineAssistantText;
1167
- }
1168
- else {
1169
- throw error;
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, ' ').trim();
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 === 'string' ? snapshot.text.trim() : '';
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 || (baselinePrefix.length > 0 && normalized.startsWith(baselinePrefix));
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: { turnId: snapshot?.turnId ?? undefined, messageId: snapshot?.messageId ?? undefined },
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
- let answer;
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 || 'unknown'}. ` +
2043
+ `Conversation URL: ${conversationUrl || lastUrl || "unknown"}. ` +
1221
2044
  `Please sign in and retry.`, {
1222
- stage: 'assistant-recheck',
2045
+ stage: "assistant-recheck",
1223
2046
  details: {
1224
2047
  conversationUrl: conversationUrl || lastUrl || null,
1225
- sessionStatus: 'needs_login',
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 waitForAssistantResponseWithReload(Runtime, Page, timeoutMs, logger, baselineTurns ?? undefined);
1241
- logger('Recovered assistant response after delayed recheck');
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
- try {
1245
- answer = await waitForAssistantResponseWithReload(Runtime, Page, config.timeoutMs, logger, baselineTurns ?? undefined);
1246
- }
1247
- catch (error) {
1248
- if (isAssistantResponseTimeoutError(error)) {
1249
- const rechecked = await attemptAssistantRecheck().catch(() => null);
1250
- if (rechecked) {
1251
- answer = rechecked;
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
- else {
1254
- try {
1255
- const conversationUrl = await readConversationUrl(Runtime);
1256
- if (conversationUrl) {
1257
- lastUrl = conversationUrl;
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
- catch {
1261
- // ignore
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
- await emitRuntimeHint();
1264
- const runtime = {
1265
- chromePort: port,
1266
- chromeHost: host,
1267
- chromeTargetId: remoteTargetId ?? undefined,
1268
- tabUrl: lastUrl,
1269
- conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
1270
- controllerPid: process.pid,
1271
- };
1272
- throw new BrowserAutomationError('Assistant response timed out before completion; reattach later to capture the answer.', { stage: 'assistant-timeout', runtime }, error);
2131
+ }
2132
+ else {
2133
+ throw error;
1273
2134
  }
1274
2135
  }
1275
- else {
1276
- throw error;
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
- const baselineNormalized = baselineAssistantText ? normalizeForComparison(baselineAssistantText) : '';
1280
- if (baselineNormalized) {
1281
- const normalizedAnswer = normalizeForComparison(answer.text ?? '');
1282
- const baselinePrefix = baselineNormalized.length >= 80
1283
- ? baselineNormalized.slice(0, Math.min(200, baselineNormalized.length))
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
- answerText = answer.text;
1296
- answerHtml = answer.html ?? '';
1297
- const copiedMarkdown = await withRetries(async () => {
1298
- const attempt = await captureAssistantMarkdown(Runtime, answer.meta, logger);
1299
- if (!attempt) {
1300
- throw new Error('copy-missing');
1301
- }
1302
- return attempt;
1303
- }, {
1304
- retries: 2,
1305
- delayMs: 350,
1306
- onRetry: (attempt, error) => {
1307
- if (options.verbose) {
1308
- logger(`[retry] Markdown capture attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`);
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
- }).catch(() => null);
1312
- answerMarkdown = copiedMarkdown ?? answerText;
1313
- ({ answerText, answerMarkdown } = await maybeRecoverLongAssistantResponse({
1314
- runtime: Runtime,
1315
- baselineTurns,
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
- allowMarkdownUpdate: !copiedMarkdown,
1320
- }));
1321
- // Final sanity check: ensure we didn't accidentally capture the user prompt instead of the assistant turn.
1322
- const finalSnapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined).catch(() => null);
1323
- const finalText = typeof finalSnapshot?.text === 'string' ? finalSnapshot.text.trim() : '';
1324
- if (finalText &&
1325
- finalText !== answerMarkdown.trim() &&
1326
- finalText !== promptText.trim() &&
1327
- finalText.length >= answerMarkdown.trim().length) {
1328
- logger('Refreshed assistant response via final DOM snapshot');
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 === '1') && normalizedError.stack) {
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('Remote Chrome connection lost before Oracle finished.', {
1403
- stage: 'connection-lost',
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
- if (!connectionClosedUnexpectedly && client) {
1416
- await client.close();
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
- await closeRemoteChromeTarget(host, port, remoteTargetId ?? undefined, logger);
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 './utils.js';
1430
- export { resolveBrowserConfig, DEFAULT_BROWSER_CONFIG } from './config.js';
1431
- export { syncCookies } from './cookies.js';
1432
- export { navigateToChatGPT, ensureNotBlocked, ensurePromptReady, ensureModelSelection, submitPrompt, waitForAssistantResponse, captureAssistantMarkdown, uploadAttachmentFile, waitForAttachmentCompletion, } from './pageActions.js';
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('websocket connection closed') ||
1439
- message.includes('websocket is closed') ||
1440
- message.includes('websocket error') ||
1441
- message.includes('inspected target navigated or closed') ||
1442
- message.includes('target closed'));
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('Assistant response stalled; reloading conversation and retrying once');
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('assistant-response') ||
1477
- message.includes('watchdog') ||
1478
- message.includes('timeout') ||
1479
- message.includes('capture assistant response'));
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('assistant-response') ||
1488
- message.includes('assistant response') ||
1489
- message.includes('watchdog') ||
1490
- message.includes('capture assistant response'));
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: 'location.href', returnByValue: true });
1495
- return typeof currentUrl.result?.value === 'string' ? currentUrl.result.value : null;
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: 'Failed to evaluate session state' };
2478
+ return { valid: false, reason: "Failed to evaluate session state" };
1519
2479
  }
1520
2480
  if (result.onAuthPage) {
1521
- return { valid: false, reason: 'Redirected to auth page' };
2481
+ return { valid: false, reason: "Redirected to auth page" };
1522
2482
  }
1523
2483
  if (result.hasLoginCta) {
1524
- return { valid: false, reason: 'Login button detected on page' };
2484
+ return { valid: false, reason: "Login button detected on page" };
1525
2485
  }
1526
2486
  if (!result.hasTextarea) {
1527
- return { valid: false, reason: 'Prompt textarea not available' };
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 === 'number' ? result.value : Number(result?.value);
2574
+ const raw = typeof result?.value === "number" ? result.value : Number(result?.value);
1615
2575
  if (!Number.isFinite(raw)) {
1616
- throw new Error('Turn count not numeric');
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
- 'PowerShell (admin):',
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
- 'Re-run the same oracle command after adding the rule.',
1715
- ].join('\n');
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 !== 'linux')
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('microsoft');
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
- '/mnt/c/Users/Public/AppData/Local/Temp',
1733
- '/mnt/c/Temp',
1734
- '/mnt/c/Windows/Temp',
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
- return os.tmpdir();
1747
- }
1748
- function buildThinkingStatusExpression() {
1749
- const selectors = [
1750
- 'span.loading-shimmer',
1751
- 'span.flex.items-center.gap-1.truncate.text-start.align-middle.text-token-text-tertiary',
1752
- '[data-testid*="thinking"]',
1753
- '[data-testid*="reasoning"]',
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 null;
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
  }