@steipete/oracle 0.12.1 → 0.14.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 (97) hide show
  1. package/README.md +72 -61
  2. package/dist/bin/oracle-cli.js +77 -68
  3. package/dist/bin/oracle-mcp.js +0 -0
  4. package/dist/docs-site/.nojekyll +0 -0
  5. package/dist/docs-site/CNAME +1 -0
  6. package/dist/docs-site/RELEASING.html +410 -0
  7. package/dist/docs-site/agents.html +374 -0
  8. package/dist/docs-site/anthropic.html +368 -0
  9. package/dist/docs-site/bridge.html +400 -0
  10. package/dist/docs-site/browser-mode.html +593 -0
  11. package/dist/docs-site/chromium-forks.html +347 -0
  12. package/dist/docs-site/cli-reference.html +346 -0
  13. package/dist/docs-site/configuration.html +452 -0
  14. package/dist/docs-site/favicon.svg +14 -0
  15. package/dist/docs-site/followup.html +375 -0
  16. package/dist/docs-site/gemini.html +383 -0
  17. package/dist/docs-site/grok.html +325 -0
  18. package/dist/docs-site/index.html +360 -0
  19. package/dist/docs-site/install.html +335 -0
  20. package/dist/docs-site/linux.html +321 -0
  21. package/dist/docs-site/llms.txt +43 -0
  22. package/dist/docs-site/manual-tests.html +596 -0
  23. package/dist/docs-site/mcp.html +391 -0
  24. package/dist/docs-site/multimodel.html +364 -0
  25. package/dist/docs-site/mythical-pro-agents.html +360 -0
  26. package/dist/docs-site/notifier.html +338 -0
  27. package/dist/docs-site/openai-endpoints.html +387 -0
  28. package/dist/docs-site/openrouter.html +344 -0
  29. package/dist/docs-site/quickstart.html +369 -0
  30. package/dist/docs-site/refactor/ux.html +532 -0
  31. package/dist/docs-site/sessions.html +388 -0
  32. package/dist/docs-site/social-card.png +0 -0
  33. package/dist/docs-site/social-card.svg +79 -0
  34. package/dist/docs-site/spec.html +363 -0
  35. package/dist/docs-site/testing.html +320 -0
  36. package/dist/docs-site/tui-debug.html +326 -0
  37. package/dist/docs-site/windows-work.html +323 -0
  38. package/dist/docs-site/windows.html +320 -0
  39. package/dist/src/browser/actions/assistantResponse.js +91 -9
  40. package/dist/src/browser/actions/deepResearch.js +180 -52
  41. package/dist/src/browser/actions/modelSelection.js +111 -24
  42. package/dist/src/browser/actions/navigation.js +358 -67
  43. package/dist/src/browser/actions/promptComposer.js +241 -46
  44. package/dist/src/browser/actions/thinkingTime.js +308 -89
  45. package/dist/src/browser/artifacts.js +19 -0
  46. package/dist/src/browser/chatgptFiles.js +797 -0
  47. package/dist/src/browser/chatgptImages.js +130 -3
  48. package/dist/src/browser/chromeLifecycle.js +4 -0
  49. package/dist/src/browser/config.js +4 -0
  50. package/dist/src/browser/index.js +353 -41
  51. package/dist/src/browser/pageActions.js +1 -1
  52. package/dist/src/browser/policies.js +2 -6
  53. package/dist/src/browser/projectSourcesRunner.js +14 -2
  54. package/dist/src/browser/prompt.js +11 -15
  55. package/dist/src/browser/providers/chatgptDomProvider.js +1 -0
  56. package/dist/src/browser/reattach.js +6 -2
  57. package/dist/src/browser/reattachability.js +22 -10
  58. package/dist/src/browser/recoverConversation.js +73 -0
  59. package/dist/src/browser/sessionRunner.js +4 -1
  60. package/dist/src/cli/bridge/doctor.js +7 -2
  61. package/dist/src/cli/browserConfig.js +13 -2
  62. package/dist/src/cli/browserDefaults.js +3 -0
  63. package/dist/src/cli/browserTabs.js +129 -54
  64. package/dist/src/cli/engine.js +6 -2
  65. package/dist/src/cli/followup.js +72 -0
  66. package/dist/src/cli/help.js +1 -1
  67. package/dist/src/cli/options.js +20 -0
  68. package/dist/src/cli/reattachGuidance.js +8 -0
  69. package/dist/src/cli/runOptions.js +10 -31
  70. package/dist/src/cli/sessionCommand.js +4 -0
  71. package/dist/src/cli/sessionDisplay.js +17 -3
  72. package/dist/src/cli/sessionLineage.js +7 -4
  73. package/dist/src/cli/sessionRunner.js +16 -1
  74. package/dist/src/config.js +164 -9
  75. package/dist/src/gemini-web/client.js +5 -10
  76. package/dist/src/gemini-web/executor.js +1 -24
  77. package/dist/src/gemini-web/models.js +83 -0
  78. package/dist/src/mcp/server.js +2 -0
  79. package/dist/src/mcp/tools/chatgptImage.js +127 -0
  80. package/dist/src/mcp/tools/consult.js +261 -167
  81. package/dist/src/mcp/types.js +2 -0
  82. package/dist/src/mcp/utils.js +87 -1
  83. package/dist/src/oracle/config.js +27 -1
  84. package/dist/src/oracle/files.js +4 -6
  85. package/dist/src/oracle/geminiModels.js +2 -0
  86. package/dist/src/oracle/markdown.js +36 -3
  87. package/dist/src/oracle/modelResolver.js +21 -13
  88. package/dist/src/oracle/promptAssembly.js +2 -4
  89. package/dist/src/oracle/request.js +3 -5
  90. package/dist/src/oracle/tokenStats.js +5 -1
  91. package/dist/src/oracle.js +1 -1
  92. package/dist/src/sessionManager.js +1 -0
  93. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  94. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  95. package/package.json +20 -29
  96. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  97. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
@@ -5,12 +5,12 @@ import net from "node:net";
5
5
  import { resolveBrowserConfig } from "./config.js";
6
6
  import { launchChrome, registerTerminationHooks, hideChromeWindow, connectToRemoteChrome, connectWithNewTab, closeTab, closeRemoteChromeTarget, closeBlankChromeTabs, } from "./chromeLifecycle.js";
7
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";
8
+ import { navigateToChatGPT, navigateToPromptReadyWithFallback, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, waitForResumedConversationHydration, installJavaScriptDialogAutoDismissal, ensureModelSelection, clearPromptComposer, waitForAssistantResponse, captureAssistantMarkdown, clearComposerAttachments, uploadAttachmentFile, waitForAttachmentCompletion, waitForUserTurnAttachments, readAssistantSnapshot, } from "./pageActions.js";
9
9
  import { INPUT_SELECTORS } from "./constants.js";
10
10
  import { uploadAttachmentViaDataTransfer } from "./actions/remoteFileTransfer.js";
11
11
  import { ensureThinkingTime } from "./actions/thinkingTime.js";
12
12
  import { startThinkingStatusMonitor } from "./actions/thinkingStatus.js";
13
- import { activateDeepResearch, waitForDeepResearchCompletion, waitForResearchPlanAutoConfirm, } from "./actions/deepResearch.js";
13
+ import { activateDeepResearch, captureDeepResearchTargetKeys, waitForDeepResearchCompletion, waitForResearchPlanAutoConfirm, } from "./actions/deepResearch.js";
14
14
  import { estimateTokenCount, withRetries, delay } from "./utils.js";
15
15
  import { formatElapsed } from "../oracle/format.js";
16
16
  import { CHATGPT_URL, CONVERSATION_TURN_SELECTOR, DEFAULT_MODEL_STRATEGY } from "./constants.js";
@@ -20,6 +20,7 @@ import { cleanupStaleProfileState, acquireProfileRunLock, findRunningChromeDebug
20
20
  import { acquireBrowserTabLease, hasOtherActiveBrowserTabLeases, } from "./tabLeaseRegistry.js";
21
21
  import { appendArtifacts, saveBrowserTranscriptArtifact, saveDeepResearchReportArtifact, } from "./artifacts.js";
22
22
  import { collectGeneratedImageArtifacts } from "./chatgptImages.js";
23
+ import { collectChatGptFileArtifacts } from "./chatgptFiles.js";
23
24
  import { runProviderSubmissionFlow } from "./providerDomFlow.js";
24
25
  import { chatgptDomProvider } from "./providers/index.js";
25
26
  import { resolveAttachRunningConnection } from "./attachRunning.js";
@@ -71,12 +72,188 @@ export function shouldPreserveBrowserOnErrorForTest(error, headless) {
71
72
  export function classifyPreservedBrowserErrorForTest(error, headless) {
72
73
  return classifyPreservedBrowserError(error, headless);
73
74
  }
74
- // NOTE: Previously, shouldSkipThinkingTimeSelection() would skip the thinking
75
- // time UI step when desiredModel was gpt-5.5-pro and thinkingTime was "extended",
76
- // assuming that selecting "Pro Extended" in the old UI already implied Extended
77
- // effort. This is wrong for lower-tier plans ($100/mo Pro) where selecting "Pro"
78
- // defaults to Standard effort. ensureThinkingTime() already handles the
79
- // "already-selected" case as a no-op, so always attempting it is safe.
75
+ const MAX_CHATGPT_UI_WARNING_CHARS = 300;
76
+ const MAX_CHATGPT_UI_WARNINGS = 3;
77
+ function classifyChatGptUiWarningText(text) {
78
+ const normalized = text.toLowerCase();
79
+ if (/\btoo many requests\b/.test(normalized) ||
80
+ /\bsending too many requests\b/.test(normalized) ||
81
+ /\btoo quickly\b/.test(normalized) ||
82
+ /\btemporarily limited access\b/.test(normalized) ||
83
+ /\bplease wait a few minutes\b/.test(normalized) ||
84
+ /\brate limit(?:ed)?\b/.test(normalized) ||
85
+ /\bslow down\b/.test(normalized)) {
86
+ return "rate_limit";
87
+ }
88
+ if (/\btemporarily unavailable\b/.test(normalized) ||
89
+ /\bsomething went wrong\b/.test(normalized) ||
90
+ /\bfailed to generate\b/.test(normalized) ||
91
+ /\btry again later\b/.test(normalized)) {
92
+ return "temporary_unavailable";
93
+ }
94
+ if (/\bverify you are human\b/.test(normalized) ||
95
+ /\bunusual activity\b/.test(normalized) ||
96
+ /\bcloudflare\b/.test(normalized) ||
97
+ /\bchallenge\b/.test(normalized) ||
98
+ /\blogin required\b/.test(normalized) ||
99
+ /\bsign in\b/.test(normalized)) {
100
+ return "auth_or_challenge";
101
+ }
102
+ return null;
103
+ }
104
+ function sanitizeChatGptUiWarningText(text) {
105
+ return text
106
+ .replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, "[redacted-email]")
107
+ .replace(/\b((?:access|auth|session)[-_ ]?token|token)\s*[:=]\s*["']?[^\s"',;]+/gi, "$1=[redacted]")
108
+ .replace(/\b(?:sk-(?:ant-|or-)?|xai-)[A-Za-z0-9_-]{8,}\b/g, "[redacted-token]");
109
+ }
110
+ function normalizeUiWarningCandidate(value) {
111
+ if (!value || typeof value !== "object")
112
+ return null;
113
+ const candidate = value;
114
+ const text = typeof candidate.text === "string"
115
+ ? sanitizeChatGptUiWarningText(candidate.text.replace(/\s+/g, " ").trim())
116
+ : "";
117
+ if (!text)
118
+ return null;
119
+ return {
120
+ text: text.slice(0, MAX_CHATGPT_UI_WARNING_CHARS),
121
+ source: typeof candidate.source === "string" ? candidate.source : null,
122
+ role: typeof candidate.role === "string" ? candidate.role : null,
123
+ ariaLive: typeof candidate.ariaLive === "string" ? candidate.ariaLive : null,
124
+ selector: typeof candidate.selector === "string" ? candidate.selector : null,
125
+ };
126
+ }
127
+ async function collectChatGptUiWarnings(Runtime) {
128
+ try {
129
+ const { result } = await Runtime.evaluate({
130
+ awaitPromise: true,
131
+ returnByValue: true,
132
+ expression: `(() => {
133
+ const warningPattern = /too many requests|sending too many requests|too quickly|temporarily limited access|please wait a few minutes|rate limit|rate limited|slow down|try again later|temporarily unavailable|something went wrong|failed to generate|verify you are human|unusual activity|cloudflare|challenge|login required|sign in/i;
134
+ const selectors = [
135
+ '[role="alert"]',
136
+ '[role="status"]',
137
+ '[role="dialog"]',
138
+ '[aria-live]',
139
+ '[data-testid*="toast" i]',
140
+ '[data-testid*="banner" i]',
141
+ '[data-testid*="error" i]',
142
+ '[class*="toast" i]',
143
+ '[class*="banner" i]'
144
+ ];
145
+ const isVisible = (element) => {
146
+ if (!(element instanceof HTMLElement)) return false;
147
+ let current = element;
148
+ while (current) {
149
+ const currentStyle = window.getComputedStyle(current);
150
+ if (
151
+ !currentStyle ||
152
+ currentStyle.display === 'none' ||
153
+ currentStyle.visibility === 'hidden' ||
154
+ currentStyle.visibility === 'collapse' ||
155
+ Number.parseFloat(currentStyle.opacity || '1') === 0
156
+ ) {
157
+ return false;
158
+ }
159
+ current = current.parentElement;
160
+ }
161
+ const rect = element.getBoundingClientRect();
162
+ return rect.width > 0 && rect.height > 0;
163
+ };
164
+ const describe = (element, source, selector = null) => ({
165
+ text: (element.innerText || element.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 1000),
166
+ source,
167
+ selector,
168
+ role: element.getAttribute('role'),
169
+ ariaLive: element.getAttribute('aria-live')
170
+ });
171
+ const out = [];
172
+ const seen = new Set();
173
+ const warningContainers = [];
174
+ const overlapsWarningContainer = (element) => warningContainers.some((container) => (
175
+ container !== element && (container.contains(element) || element.contains(container))
176
+ ));
177
+ const add = (element, entry) => {
178
+ if (!entry.text || !warningPattern.test(entry.text)) return;
179
+ const key = entry.text + '|' + (entry.role || '') + '|' + (entry.ariaLive || '');
180
+ if (seen.has(key)) return;
181
+ seen.add(key);
182
+ warningContainers.push(element);
183
+ out.push(entry);
184
+ };
185
+ for (const selector of selectors) {
186
+ if (out.length >= 5) break;
187
+ let elements = [];
188
+ try {
189
+ elements = Array.from(document.querySelectorAll(selector));
190
+ } catch {
191
+ elements = [];
192
+ }
193
+ for (const element of elements) {
194
+ if (out.length >= 5) break;
195
+ if (overlapsWarningContainer(element)) continue;
196
+ if (isVisible(element)) add(element, describe(element, 'selector', selector));
197
+ }
198
+ }
199
+ return out.slice(0, 5);
200
+ })()`,
201
+ });
202
+ const rawWarnings = Array.isArray(result?.value) ? result.value : [];
203
+ const warnings = [];
204
+ const seen = new Set();
205
+ for (const raw of rawWarnings) {
206
+ const candidate = normalizeUiWarningCandidate(raw);
207
+ if (!candidate)
208
+ continue;
209
+ const type = classifyChatGptUiWarningText(candidate.text);
210
+ if (!type)
211
+ continue;
212
+ const key = `${type}:${candidate.text}`;
213
+ if (seen.has(key))
214
+ continue;
215
+ seen.add(key);
216
+ warnings.push({
217
+ type,
218
+ message: candidate.text,
219
+ source: candidate.source,
220
+ role: candidate.role,
221
+ ariaLive: candidate.ariaLive,
222
+ selector: candidate.selector,
223
+ });
224
+ if (warnings.length >= MAX_CHATGPT_UI_WARNINGS)
225
+ break;
226
+ }
227
+ return warnings;
228
+ }
229
+ catch {
230
+ return [];
231
+ }
232
+ }
233
+ function formatChatGptUiWarningType(type) {
234
+ switch (type) {
235
+ case "rate_limit":
236
+ return "rate-limit";
237
+ case "temporary_unavailable":
238
+ return "temporary-unavailable";
239
+ case "auth_or_challenge":
240
+ return "authentication/challenge";
241
+ }
242
+ }
243
+ async function createAssistantTimeoutError(params) {
244
+ const [uiWarning] = await collectChatGptUiWarnings(params.Runtime);
245
+ if (!uiWarning) {
246
+ return new BrowserAutomationError("Assistant response timed out before completion; reattach later to capture the answer.", { stage: "assistant-timeout", runtime: params.runtime, diagnostics: params.diagnostics }, params.cause);
247
+ }
248
+ params.logger(`[browser] ChatGPT UI warning detected (${uiWarning.type}): ${uiWarning.message}`);
249
+ return new BrowserAutomationError(`ChatGPT displayed a ${formatChatGptUiWarningType(uiWarning.type)} warning while waiting for the assistant: ${uiWarning.message}`, {
250
+ stage: "assistant-timeout",
251
+ code: "chatgpt-ui-warning",
252
+ uiWarning,
253
+ runtime: params.runtime,
254
+ diagnostics: params.diagnostics,
255
+ }, params.cause);
256
+ }
80
257
  function listIgnoredRemoteChromeFlags(config) {
81
258
  return [
82
259
  config.headless ? "--browser-headless" : null,
@@ -273,6 +450,19 @@ function resolveRemoteTabLeaseProfileDir(config) {
273
450
  export function resolveRemoteTabLeaseProfileDirForTest(config) {
274
451
  return resolveRemoteTabLeaseProfileDir(config);
275
452
  }
453
+ function isLocalChromeHost(host) {
454
+ const normalized = host
455
+ .trim()
456
+ .toLowerCase()
457
+ .replace(/^\[|\]$/g, "");
458
+ if (normalized === "localhost" || normalized === "::1") {
459
+ return true;
460
+ }
461
+ return net.isIPv4(normalized) && normalized.startsWith("127.");
462
+ }
463
+ export function isLocalChromeHostForTest(host) {
464
+ return isLocalChromeHost(host);
465
+ }
276
466
  async function closeRemoteConnectionAfterRun(options) {
277
467
  if (options.connectionClosedUnexpectedly) {
278
468
  return;
@@ -310,6 +500,7 @@ export async function runBrowserMode(options) {
310
500
  const attachments = options.attachments ?? [];
311
501
  const fallbackSubmission = options.fallbackSubmission;
312
502
  let config = resolveBrowserConfig(options.config);
503
+ const isResumingConversation = Boolean(config.resumeConversationUrl);
313
504
  const followUpPrompts = normalizeBrowserFollowUpPrompts(options.followUpPrompts);
314
505
  if (config.researchMode === "deep" && followUpPrompts.length > 0) {
315
506
  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.", {
@@ -596,9 +787,18 @@ export async function runBrowserMode(options) {
596
787
  });
597
788
  }
598
789
  if (config.browserTabRef) {
790
+ if (isResumingConversation) {
791
+ await raceWithDisconnect(navigateToChatGPT(Page, Runtime, config.resumeConversationUrl, logger));
792
+ }
599
793
  await raceWithDisconnect(ensureNotBlocked(Runtime, config.headless, logger));
600
794
  await raceWithDisconnect(ensureLoggedIn(Runtime, logger));
601
795
  await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
796
+ if (isResumingConversation) {
797
+ await raceWithDisconnect(waitForResumedConversationHydration(Runtime, config.inputTimeoutMs, logger, {
798
+ requirePriorTurns: true,
799
+ expectedConversationUrl: config.resumeConversationUrl,
800
+ }));
801
+ }
602
802
  }
603
803
  else {
604
804
  const baseUrl = CHATGPT_URL;
@@ -616,7 +816,12 @@ export async function runBrowserMode(options) {
616
816
  profileDir: userDataDir,
617
817
  keepBrowser: effectiveKeepBrowser,
618
818
  }));
619
- if (config.url !== baseUrl) {
819
+ if (isResumingConversation) {
820
+ await raceWithDisconnect(navigateToChatGPT(Page, Runtime, config.resumeConversationUrl, logger));
821
+ await raceWithDisconnect(ensureNotBlocked(Runtime, config.headless, logger));
822
+ await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
823
+ }
824
+ else if (config.url !== baseUrl) {
620
825
  await raceWithDisconnect(navigateToPromptReadyWithFallback(Page, Runtime, {
621
826
  url: config.url,
622
827
  fallbackUrl: baseUrl,
@@ -628,6 +833,17 @@ export async function runBrowserMode(options) {
628
833
  else {
629
834
  await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
630
835
  }
836
+ if (isResumingConversation) {
837
+ // A resumed thread loads its prior history after navigation; ChatGPT can reset the
838
+ // composer mid-hydration and wipe a freshly-typed prompt. Wait for hydration to settle
839
+ // and re-confirm the composer before the prompt is typed/submitted below. Wrapped in
840
+ // raceWithDisconnect so a dropped client aborts immediately instead of polling to the
841
+ // hydration deadline. Shared with the remote path via the same helper.
842
+ await raceWithDisconnect(waitForResumedConversationHydration(Runtime, config.inputTimeoutMs, logger, {
843
+ requirePriorTurns: true,
844
+ expectedConversationUrl: config.resumeConversationUrl,
845
+ }));
846
+ }
631
847
  }
632
848
  logger(`Prompt textarea ready (initial focus, ${promptText.length.toLocaleString()} chars queued)`);
633
849
  const captureRuntimeSnapshot = async () => {
@@ -707,7 +923,7 @@ export async function runBrowserMode(options) {
707
923
  };
708
924
  await captureRuntimeSnapshot();
709
925
  const modelStrategy = config.modelStrategy ?? DEFAULT_MODEL_STRATEGY;
710
- if (config.desiredModel && modelStrategy !== "ignore") {
926
+ if (config.desiredModel && modelStrategy !== "ignore" && !isResumingConversation) {
711
927
  modelSelectionEvidence = await raceWithDisconnect(withRetries(() => ensureModelSelection(Runtime, config.desiredModel, logger, modelStrategy), {
712
928
  retries: 2,
713
929
  delayMs: 300,
@@ -726,9 +942,11 @@ export async function runBrowserMode(options) {
726
942
  await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
727
943
  logger(`Prompt textarea ready (after model switch, ${promptText.length.toLocaleString()} chars queued)`);
728
944
  }
729
- else if (modelStrategy === "ignore") {
945
+ else if (modelStrategy === "ignore" || isResumingConversation) {
730
946
  modelSelectionEvidence = buildSkippedModelSelectionEvidence(config.desiredModel, modelStrategy);
731
- logger("Model picker: skipped (strategy=ignore)");
947
+ logger(isResumingConversation
948
+ ? "Model picker: skipped (resumed conversation)"
949
+ : "Model picker: skipped (strategy=ignore)");
732
950
  }
733
951
  const deepResearch = config.researchMode === "deep";
734
952
  // Handle thinking time selection if specified. Deep Research owns its own effort flow.
@@ -779,6 +997,10 @@ export async function runBrowserMode(options) {
779
997
  const baselineSnapshot = await readAssistantSnapshot(Runtime).catch(() => null);
780
998
  const baselineAssistantText = typeof baselineSnapshot?.text === "string" ? baselineSnapshot.text.trim() : "";
781
999
  const attachmentNames = submissionAttachments.map((a) => path.basename(a.path));
1000
+ const attachmentExpectations = submissionAttachments.map((a) => ({
1001
+ name: path.basename(a.path),
1002
+ generatedBundle: a.generatedBundle === true,
1003
+ }));
782
1004
  let inputOnlyAttachments = false;
783
1005
  await raceWithDisconnect(clearPromptComposer(Runtime, logger));
784
1006
  await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
@@ -800,7 +1022,8 @@ export async function runBrowserMode(options) {
800
1022
  const baseTimeout = config.inputTimeoutMs ?? 30_000;
801
1023
  const perFileTimeout = 20_000;
802
1024
  const waitBudget = Math.max(baseTimeout, 45_000) + (submissionAttachments.length - 1) * perFileTimeout;
803
- await waitForAttachmentCompletion(Runtime, waitBudget, attachmentNames, logger);
1025
+ const attachmentWaitBudget = Math.max(config.attachmentTimeoutMs ?? 0, waitBudget);
1026
+ await waitForAttachmentCompletion(Runtime, attachmentWaitBudget, attachmentNames, logger);
804
1027
  logger("All attachments uploaded");
805
1028
  }
806
1029
  let baselineTurns = await readConversationTurnCount(Runtime, logger);
@@ -811,10 +1034,14 @@ export async function runBrowserMode(options) {
811
1034
  logger,
812
1035
  timeoutMs: config.timeoutMs,
813
1036
  inputTimeoutMs: config.inputTimeoutMs ?? undefined,
1037
+ attachmentTimeoutMs: config.attachmentTimeoutMs ?? undefined,
814
1038
  baselineTurns: baselineTurns ?? undefined,
815
- attachmentNames,
1039
+ attachmentNames: attachmentExpectations,
816
1040
  onPromptSubmitted: markPromptSubmitted,
817
1041
  };
1042
+ const deepResearchTargetKeys = deepResearch && client
1043
+ ? await captureDeepResearchTargetKeys(client).catch(() => [])
1044
+ : undefined;
818
1045
  await runProviderSubmissionFlow(chatgptDomProvider, {
819
1046
  prompt,
820
1047
  evaluate: async () => undefined,
@@ -847,7 +1074,7 @@ export async function runBrowserMode(options) {
847
1074
  }
848
1075
  // Reattach needs a /c/ URL; ChatGPT can update it late, so poll in the background.
849
1076
  scheduleConversationHint("post-submit", config.timeoutMs ?? 120_000);
850
- return { baselineTurns, baselineAssistantText };
1077
+ return { baselineTurns, baselineAssistantText, deepResearchTargetKeys };
851
1078
  };
852
1079
  const reloadPromptComposer = async () => {
853
1080
  logger("[browser] Composer became unresponsive; reloading page and retrying once.");
@@ -856,6 +1083,7 @@ export async function runBrowserMode(options) {
856
1083
  };
857
1084
  let baselineTurns = null;
858
1085
  let baselineAssistantText = null;
1086
+ let deepResearchTargetKeys = [];
859
1087
  await acquireProfileLockIfNeeded();
860
1088
  try {
861
1089
  const submission = await runSubmissionWithRecovery({
@@ -872,6 +1100,7 @@ export async function runBrowserMode(options) {
872
1100
  });
873
1101
  baselineTurns = submission.baselineTurns;
874
1102
  baselineAssistantText = submission.baselineAssistantText;
1103
+ deepResearchTargetKeys = submission.deepResearchTargetKeys ?? [];
875
1104
  }
876
1105
  finally {
877
1106
  await releaseProfileLockIfHeld();
@@ -879,7 +1108,7 @@ export async function runBrowserMode(options) {
879
1108
  const imageArtifactMinTurnIndex = baselineTurns;
880
1109
  if (deepResearch) {
881
1110
  await raceWithDisconnect(waitForResearchPlanAutoConfirm(Runtime, logger));
882
- const researchResult = await raceWithDisconnect(waitForDeepResearchCompletion(Runtime, logger, config.timeoutMs, baselineTurns, Page, client));
1111
+ const researchResult = await raceWithDisconnect(waitForDeepResearchCompletion(Runtime, logger, config.timeoutMs, baselineTurns, Page, client, { ignoredTargetKeys: deepResearchTargetKeys }));
883
1112
  await updateConversationHint("post-deep-research", 15_000).catch(() => false);
884
1113
  runStatus = "complete";
885
1114
  const durationMs = Date.now() - startedAt;
@@ -1068,7 +1297,13 @@ export async function runBrowserMode(options) {
1068
1297
  promptSubmitted,
1069
1298
  controllerPid: process.pid,
1070
1299
  };
1071
- throw new BrowserAutomationError("Assistant response timed out before completion; reattach later to capture the answer.", { stage: "assistant-timeout", runtime, diagnostics }, error);
1300
+ throw await createAssistantTimeoutError({
1301
+ Runtime,
1302
+ logger,
1303
+ runtime,
1304
+ diagnostics,
1305
+ cause: error,
1306
+ });
1072
1307
  }
1073
1308
  }
1074
1309
  else {
@@ -1257,6 +1492,9 @@ export async function runBrowserMode(options) {
1257
1492
  throw new Error("Chrome disconnected before completion");
1258
1493
  }
1259
1494
  const imageArtifacts = await collectGeneratedImageArtifacts({
1495
+ Browser: client.Browser,
1496
+ Client: client,
1497
+ Page,
1260
1498
  Runtime,
1261
1499
  Network,
1262
1500
  logger,
@@ -1271,16 +1509,28 @@ export async function runBrowserMode(options) {
1271
1509
  if (imageArtifacts.markdownSuffix) {
1272
1510
  answerMarkdown += imageArtifacts.markdownSuffix;
1273
1511
  }
1512
+ const fileArtifacts = await collectChatGptFileArtifacts({
1513
+ Browser: client.Browser,
1514
+ Client: client,
1515
+ Page,
1516
+ Runtime,
1517
+ Network,
1518
+ answerText: [answerText, answerMarkdown, answerHtml].filter(Boolean).join("\n"),
1519
+ logger,
1520
+ minTurnIndex: imageArtifactMinTurnIndex,
1521
+ sessionId: options.sessionId,
1522
+ });
1274
1523
  const savedImageArtifacts = appendArtifacts(undefined, imageArtifacts.savedImages);
1524
+ const savedBrowserArtifacts = appendArtifacts(savedImageArtifacts, fileArtifacts.savedFiles);
1275
1525
  const transcriptArtifact = await saveOptionalArtifact(() => saveBrowserTranscriptArtifact({
1276
1526
  sessionId: options.sessionId,
1277
1527
  prompt: promptText,
1278
1528
  answerMarkdown,
1279
1529
  conversationUrl: lastUrl,
1280
- artifacts: savedImageArtifacts,
1530
+ artifacts: savedBrowserArtifacts,
1281
1531
  logger,
1282
1532
  }), logger);
1283
- const savedArtifacts = appendArtifacts(savedImageArtifacts, [transcriptArtifact]);
1533
+ const savedArtifacts = appendArtifacts(savedBrowserArtifacts, [transcriptArtifact]);
1284
1534
  const archive = await maybeArchiveCompletedConversation({
1285
1535
  Runtime,
1286
1536
  logger,
@@ -1288,7 +1538,8 @@ export async function runBrowserMode(options) {
1288
1538
  conversationUrl: lastUrl,
1289
1539
  followUpCount: followUpPrompts.length,
1290
1540
  requiredArtifactsSaved: Boolean(transcriptArtifact) &&
1291
- imageArtifacts.savedImages.length === imageArtifacts.imageCount,
1541
+ imageArtifacts.savedImages.length === imageArtifacts.imageCount &&
1542
+ fileArtifacts.savedFiles.length === fileArtifacts.fileCount,
1292
1543
  });
1293
1544
  runStatus = "complete";
1294
1545
  const durationMs = Date.now() - startedAt;
@@ -1301,6 +1552,8 @@ export async function runBrowserMode(options) {
1301
1552
  artifacts: savedArtifacts,
1302
1553
  generatedImages: imageArtifacts.generatedImages,
1303
1554
  savedImages: imageArtifacts.savedImages,
1555
+ downloadableFiles: fileArtifacts.files,
1556
+ savedFiles: fileArtifacts.savedFiles,
1304
1557
  archive,
1305
1558
  modelSelection: modelSelectionEvidence,
1306
1559
  tookMs: durationMs,
@@ -1674,13 +1927,23 @@ async function maybeReuseRunningChrome(userDataDir, logger, options = {}) {
1674
1927
  let pid = await readChromePid(userDataDir);
1675
1928
  if (!port) {
1676
1929
  const discovered = await findRunningChromeDebugTargetForProfile(userDataDir);
1677
- if (!discovered)
1930
+ if (!discovered) {
1931
+ if (pid) {
1932
+ logger(`No reachable Chrome DevTools target found for ${userDataDir}; clearing stale profile state before launching new Chrome.`);
1933
+ await cleanupStaleProfileState(userDataDir, logger, {
1934
+ lockRemovalMode: "if_oracle_pid_dead",
1935
+ });
1936
+ }
1678
1937
  return null;
1938
+ }
1679
1939
  const discoveredProbe = await (options.probe ?? verifyDevToolsReachable)({
1680
1940
  port: discovered.port,
1681
1941
  });
1682
1942
  if (!discoveredProbe.ok) {
1683
1943
  logger(`Discovered Chrome for ${userDataDir} on port ${discovered.port} but it was unreachable (${discoveredProbe.error}); launching new Chrome.`);
1944
+ await cleanupStaleProfileState(userDataDir, logger, {
1945
+ lockRemovalMode: "if_oracle_pid_dead",
1946
+ });
1684
1947
  return null;
1685
1948
  }
1686
1949
  await writeDevToolsActivePort(userDataDir, discovered.port);
@@ -1828,16 +2091,20 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
1828
2091
  removeDialogHandler = installJavaScriptDialogAutoDismissal(Page, logger);
1829
2092
  // Skip cookie sync for remote Chrome - it already has cookies
1830
2093
  logger("Skipping cookie sync for remote Chrome (using existing session)");
1831
- if (!attachedExistingTab) {
2094
+ if (config.resumeConversationUrl) {
2095
+ await navigateToChatGPT(Page, Runtime, config.resumeConversationUrl, logger);
2096
+ }
2097
+ else if (!attachedExistingTab) {
1832
2098
  await navigateToChatGPT(Page, Runtime, config.url, logger);
1833
- await ensureNotBlocked(Runtime, config.headless, logger);
1834
- await ensureLoggedIn(Runtime, logger, { remoteSession: true });
1835
- await ensurePromptReady(Runtime, config.inputTimeoutMs, logger);
1836
2099
  }
1837
- else {
1838
- await ensureNotBlocked(Runtime, config.headless, logger);
1839
- await ensureLoggedIn(Runtime, logger, { remoteSession: true });
1840
- await ensurePromptReady(Runtime, config.inputTimeoutMs, logger);
2100
+ await ensureNotBlocked(Runtime, config.headless, logger);
2101
+ await ensureLoggedIn(Runtime, logger, { remoteSession: true });
2102
+ await ensurePromptReady(Runtime, config.inputTimeoutMs, logger);
2103
+ if (config.resumeConversationUrl) {
2104
+ await waitForResumedConversationHydration(Runtime, config.inputTimeoutMs, logger, {
2105
+ requirePriorTurns: true,
2106
+ expectedConversationUrl: config.resumeConversationUrl,
2107
+ });
1841
2108
  }
1842
2109
  logger(`Prompt textarea ready (initial focus, ${promptText.length.toLocaleString()} chars queued)`);
1843
2110
  try {
@@ -1854,7 +2121,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
1854
2121
  // ignore
1855
2122
  }
1856
2123
  const modelStrategy = config.modelStrategy ?? DEFAULT_MODEL_STRATEGY;
1857
- if (config.desiredModel && modelStrategy !== "ignore") {
2124
+ if (config.desiredModel && modelStrategy !== "ignore" && !config.resumeConversationUrl) {
1858
2125
  modelSelectionEvidence = await withRetries(() => ensureModelSelection(Runtime, config.desiredModel, logger, modelStrategy), {
1859
2126
  retries: 2,
1860
2127
  delayMs: 300,
@@ -1867,9 +2134,11 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
1867
2134
  await ensurePromptReady(Runtime, config.inputTimeoutMs, logger);
1868
2135
  logger(`Prompt textarea ready (after model switch, ${promptText.length.toLocaleString()} chars queued)`);
1869
2136
  }
1870
- else if (modelStrategy === "ignore") {
2137
+ else if (modelStrategy === "ignore" || config.resumeConversationUrl) {
1871
2138
  modelSelectionEvidence = buildSkippedModelSelectionEvidence(config.desiredModel, modelStrategy);
1872
- logger("Model picker: skipped (strategy=ignore)");
2139
+ logger(config.resumeConversationUrl
2140
+ ? "Model picker: skipped (resumed conversation)"
2141
+ : "Model picker: skipped (strategy=ignore)");
1873
2142
  }
1874
2143
  const deepResearch = config.researchMode === "deep";
1875
2144
  // Handle thinking time selection if specified. Deep Research owns its own effort flow.
@@ -1903,6 +2172,10 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
1903
2172
  const baselineSnapshot = await readAssistantSnapshot(Runtime).catch(() => null);
1904
2173
  const baselineAssistantText = typeof baselineSnapshot?.text === "string" ? baselineSnapshot.text.trim() : "";
1905
2174
  const attachmentNames = submissionAttachments.map((a) => path.basename(a.path));
2175
+ const attachmentExpectations = submissionAttachments.map((a) => ({
2176
+ name: path.basename(a.path),
2177
+ generatedBundle: a.generatedBundle === true,
2178
+ }));
1906
2179
  await clearPromptComposer(Runtime, logger);
1907
2180
  await ensurePromptReady(Runtime, config.inputTimeoutMs, logger);
1908
2181
  if (submissionAttachments.length > 0) {
@@ -1920,7 +2193,8 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
1920
2193
  const baseTimeout = config.inputTimeoutMs ?? 30_000;
1921
2194
  const perFileTimeout = 15_000;
1922
2195
  const waitBudget = Math.max(baseTimeout, 30_000) + (submissionAttachments.length - 1) * perFileTimeout;
1923
- await waitForAttachmentCompletion(Runtime, waitBudget, attachmentNames, logger);
2196
+ const attachmentWaitBudget = Math.max(config.attachmentTimeoutMs ?? 0, waitBudget);
2197
+ await waitForAttachmentCompletion(Runtime, attachmentWaitBudget, attachmentNames, logger);
1924
2198
  logger("All attachments uploaded");
1925
2199
  }
1926
2200
  let baselineTurns = await readConversationTurnCount(Runtime, logger);
@@ -1930,10 +2204,14 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
1930
2204
  logger,
1931
2205
  timeoutMs: config.timeoutMs,
1932
2206
  inputTimeoutMs: config.inputTimeoutMs ?? undefined,
2207
+ attachmentTimeoutMs: config.attachmentTimeoutMs ?? undefined,
1933
2208
  baselineTurns: baselineTurns ?? undefined,
1934
- attachmentNames,
2209
+ attachmentNames: attachmentExpectations,
1935
2210
  onPromptSubmitted: markPromptSubmitted,
1936
2211
  };
2212
+ const deepResearchTargetKeys = deepResearch && client
2213
+ ? await captureDeepResearchTargetKeys(client).catch(() => [])
2214
+ : undefined;
1937
2215
  await runProviderSubmissionFlow(chatgptDomProvider, {
1938
2216
  prompt,
1939
2217
  evaluate: async () => undefined,
@@ -1946,7 +2224,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
1946
2224
  if (typeof providerBaselineTurns === "number" && Number.isFinite(providerBaselineTurns)) {
1947
2225
  baselineTurns = providerBaselineTurns;
1948
2226
  }
1949
- return { baselineTurns, baselineAssistantText };
2227
+ return { baselineTurns, baselineAssistantText, deepResearchTargetKeys };
1950
2228
  };
1951
2229
  const reloadPromptComposer = async () => {
1952
2230
  logger("[browser] Composer became unresponsive; reloading page and retrying once.");
@@ -1955,6 +2233,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
1955
2233
  };
1956
2234
  let baselineTurns = null;
1957
2235
  let baselineAssistantText = null;
2236
+ let deepResearchTargetKeys = [];
1958
2237
  const submission = await runSubmissionWithRecovery({
1959
2238
  prompt: promptText,
1960
2239
  attachments,
@@ -1969,10 +2248,11 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
1969
2248
  });
1970
2249
  baselineTurns = submission.baselineTurns;
1971
2250
  baselineAssistantText = submission.baselineAssistantText;
2251
+ deepResearchTargetKeys = submission.deepResearchTargetKeys ?? [];
1972
2252
  const imageArtifactMinTurnIndex = baselineTurns;
1973
2253
  if (deepResearch) {
1974
2254
  await waitForResearchPlanAutoConfirm(Runtime, logger);
1975
- const researchResult = await waitForDeepResearchCompletion(Runtime, logger, config.timeoutMs, baselineTurns, Page, client);
2255
+ const researchResult = await waitForDeepResearchCompletion(Runtime, logger, config.timeoutMs, baselineTurns, Page, client, { ignoredTargetKeys: deepResearchTargetKeys });
1976
2256
  await emitRuntimeHint();
1977
2257
  const durationMs = Date.now() - startedAt;
1978
2258
  const tokens = estimateTokenCount(researchResult.text);
@@ -2171,7 +2451,13 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2171
2451
  promptSubmitted,
2172
2452
  controllerPid: process.pid,
2173
2453
  };
2174
- throw new BrowserAutomationError("Assistant response timed out before completion; reattach later to capture the answer.", { stage: "assistant-timeout", runtime, diagnostics }, error);
2454
+ throw await createAssistantTimeoutError({
2455
+ Runtime,
2456
+ logger,
2457
+ runtime,
2458
+ diagnostics,
2459
+ cause: error,
2460
+ });
2175
2461
  }
2176
2462
  }
2177
2463
  else {
@@ -2316,7 +2602,11 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2316
2602
  answerMarkdown = formatted.answerMarkdown;
2317
2603
  answerHtml = "";
2318
2604
  }
2605
+ const canSaveBrowserDownloadsLocally = isLocalChromeHost(host);
2319
2606
  const imageArtifacts = await collectGeneratedImageArtifacts({
2607
+ Browser: canSaveBrowserDownloadsLocally ? client.Browser : undefined,
2608
+ Client: canSaveBrowserDownloadsLocally ? client : undefined,
2609
+ Page: canSaveBrowserDownloadsLocally ? Page : undefined,
2320
2610
  Runtime,
2321
2611
  Network,
2322
2612
  logger,
@@ -2331,16 +2621,28 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2331
2621
  if (imageArtifacts.markdownSuffix) {
2332
2622
  answerMarkdown += imageArtifacts.markdownSuffix;
2333
2623
  }
2624
+ const fileArtifacts = await collectChatGptFileArtifacts({
2625
+ Browser: client.Browser,
2626
+ Client: client,
2627
+ Page,
2628
+ Runtime,
2629
+ Network,
2630
+ answerText: [answerText, answerMarkdown, answerHtml].filter(Boolean).join("\n"),
2631
+ logger,
2632
+ minTurnIndex: imageArtifactMinTurnIndex,
2633
+ sessionId: options.sessionId,
2634
+ });
2334
2635
  const savedImageArtifacts = appendArtifacts(undefined, imageArtifacts.savedImages);
2636
+ const savedBrowserArtifacts = appendArtifacts(savedImageArtifacts, fileArtifacts.savedFiles);
2335
2637
  const transcriptArtifact = await saveOptionalArtifact(() => saveBrowserTranscriptArtifact({
2336
2638
  sessionId: options.sessionId,
2337
2639
  prompt: promptText,
2338
2640
  answerMarkdown,
2339
2641
  conversationUrl: lastUrl,
2340
- artifacts: savedImageArtifacts,
2642
+ artifacts: savedBrowserArtifacts,
2341
2643
  logger,
2342
2644
  }), logger);
2343
- const savedArtifacts = appendArtifacts(savedImageArtifacts, [transcriptArtifact]);
2645
+ const savedArtifacts = appendArtifacts(savedBrowserArtifacts, [transcriptArtifact]);
2344
2646
  const archive = await maybeArchiveCompletedConversation({
2345
2647
  Runtime,
2346
2648
  logger,
@@ -2348,7 +2650,8 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2348
2650
  conversationUrl: lastUrl,
2349
2651
  followUpCount: followUpPrompts.length,
2350
2652
  requiredArtifactsSaved: Boolean(transcriptArtifact) &&
2351
- imageArtifacts.savedImages.length === imageArtifacts.imageCount,
2653
+ imageArtifacts.savedImages.length === imageArtifacts.imageCount &&
2654
+ fileArtifacts.savedFiles.length === fileArtifacts.fileCount,
2352
2655
  });
2353
2656
  const durationMs = Date.now() - startedAt;
2354
2657
  const answerChars = answerText.length;
@@ -2373,6 +2676,10 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2373
2676
  conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
2374
2677
  promptSubmitted,
2375
2678
  artifacts: savedArtifacts,
2679
+ generatedImages: imageArtifacts.generatedImages,
2680
+ savedImages: imageArtifacts.savedImages,
2681
+ downloadableFiles: fileArtifacts.files,
2682
+ savedFiles: fileArtifacts.savedFiles,
2376
2683
  archive,
2377
2684
  modelSelection: modelSelectionEvidence,
2378
2685
  controllerPid: process.pid,
@@ -2439,8 +2746,12 @@ export { resolveBrowserConfig, DEFAULT_BROWSER_CONFIG } from "./config.js";
2439
2746
  export const __test__ = {
2440
2747
  assertManualLoginProfileReadyForRun,
2441
2748
  closeRemoteConnectionAfterRun,
2749
+ classifyChatGptUiWarningText,
2750
+ collectChatGptUiWarnings,
2751
+ createAssistantTimeoutError,
2442
2752
  detachKeptChromeProcess,
2443
2753
  formatManualLoginSetupCommand,
2754
+ isAssistantResponseTimeoutError,
2444
2755
  isManualLoginProfileInitialized,
2445
2756
  isImageOnlyUiChromeText,
2446
2757
  listIgnoredRemoteChromeFlags,
@@ -2496,7 +2807,8 @@ function isAssistantResponseTimeoutError(error) {
2496
2807
  const message = error.message.toLowerCase();
2497
2808
  if (!message)
2498
2809
  return false;
2499
- return (message.includes("assistant-response") ||
2810
+ return (message === "response timeout" ||
2811
+ message.includes("assistant-response") ||
2500
2812
  message.includes("assistant response") ||
2501
2813
  message.includes("watchdog") ||
2502
2814
  message.includes("capture assistant response"));