@steipete/oracle 0.13.0 → 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 (91) hide show
  1. package/README.md +20 -9
  2. package/dist/bin/oracle-cli.js +63 -63
  3. package/dist/docs-site/.nojekyll +0 -0
  4. package/dist/docs-site/CNAME +1 -0
  5. package/dist/docs-site/RELEASING.html +410 -0
  6. package/dist/docs-site/agents.html +374 -0
  7. package/dist/docs-site/anthropic.html +368 -0
  8. package/dist/docs-site/bridge.html +400 -0
  9. package/dist/docs-site/browser-mode.html +593 -0
  10. package/dist/docs-site/chromium-forks.html +347 -0
  11. package/dist/docs-site/cli-reference.html +346 -0
  12. package/dist/docs-site/configuration.html +452 -0
  13. package/dist/docs-site/favicon.svg +14 -0
  14. package/dist/docs-site/followup.html +375 -0
  15. package/dist/docs-site/gemini.html +383 -0
  16. package/dist/docs-site/grok.html +325 -0
  17. package/dist/docs-site/index.html +360 -0
  18. package/dist/docs-site/install.html +335 -0
  19. package/dist/docs-site/linux.html +321 -0
  20. package/dist/docs-site/llms.txt +43 -0
  21. package/dist/docs-site/manual-tests.html +596 -0
  22. package/dist/docs-site/mcp.html +391 -0
  23. package/dist/docs-site/multimodel.html +364 -0
  24. package/dist/docs-site/mythical-pro-agents.html +360 -0
  25. package/dist/docs-site/notifier.html +338 -0
  26. package/dist/docs-site/openai-endpoints.html +387 -0
  27. package/dist/docs-site/openrouter.html +344 -0
  28. package/dist/docs-site/quickstart.html +369 -0
  29. package/dist/docs-site/refactor/ux.html +532 -0
  30. package/dist/docs-site/sessions.html +388 -0
  31. package/dist/docs-site/social-card.png +0 -0
  32. package/dist/docs-site/social-card.svg +79 -0
  33. package/dist/docs-site/spec.html +363 -0
  34. package/dist/docs-site/testing.html +320 -0
  35. package/dist/docs-site/tui-debug.html +326 -0
  36. package/dist/docs-site/windows-work.html +323 -0
  37. package/dist/docs-site/windows.html +320 -0
  38. package/dist/src/browser/actions/assistantResponse.js +91 -9
  39. package/dist/src/browser/actions/deepResearch.js +180 -52
  40. package/dist/src/browser/actions/modelSelection.js +13 -11
  41. package/dist/src/browser/actions/navigation.js +294 -65
  42. package/dist/src/browser/actions/promptComposer.js +50 -4
  43. package/dist/src/browser/actions/thinkingTime.js +308 -89
  44. package/dist/src/browser/artifacts.js +19 -0
  45. package/dist/src/browser/chatgptFiles.js +797 -0
  46. package/dist/src/browser/chatgptImages.js +130 -3
  47. package/dist/src/browser/chromeLifecycle.js +4 -0
  48. package/dist/src/browser/config.js +2 -0
  49. package/dist/src/browser/index.js +347 -39
  50. package/dist/src/browser/pageActions.js +1 -1
  51. package/dist/src/browser/policies.js +2 -6
  52. package/dist/src/browser/projectSourcesRunner.js +14 -2
  53. package/dist/src/browser/prompt.js +11 -15
  54. package/dist/src/browser/reattach.js +6 -2
  55. package/dist/src/browser/reattachability.js +22 -10
  56. package/dist/src/browser/recoverConversation.js +73 -0
  57. package/dist/src/browser/sessionRunner.js +4 -1
  58. package/dist/src/cli/browserConfig.js +4 -1
  59. package/dist/src/cli/browserTabs.js +129 -54
  60. package/dist/src/cli/followup.js +72 -0
  61. package/dist/src/cli/help.js +1 -1
  62. package/dist/src/cli/options.js +16 -0
  63. package/dist/src/cli/reattachGuidance.js +8 -0
  64. package/dist/src/cli/runOptions.js +2 -12
  65. package/dist/src/cli/sessionCommand.js +4 -0
  66. package/dist/src/cli/sessionDisplay.js +17 -3
  67. package/dist/src/cli/sessionLineage.js +7 -4
  68. package/dist/src/cli/sessionRunner.js +16 -1
  69. package/dist/src/gemini-web/client.js +5 -10
  70. package/dist/src/gemini-web/executor.js +1 -24
  71. package/dist/src/gemini-web/models.js +83 -0
  72. package/dist/src/mcp/server.js +2 -0
  73. package/dist/src/mcp/tools/chatgptImage.js +127 -0
  74. package/dist/src/mcp/tools/consult.js +261 -167
  75. package/dist/src/mcp/types.js +2 -0
  76. package/dist/src/mcp/utils.js +87 -1
  77. package/dist/src/oracle/config.js +27 -1
  78. package/dist/src/oracle/files.js +4 -6
  79. package/dist/src/oracle/geminiModels.js +2 -0
  80. package/dist/src/oracle/markdown.js +36 -3
  81. package/dist/src/oracle/modelResolver.js +21 -13
  82. package/dist/src/oracle/promptAssembly.js +2 -4
  83. package/dist/src/oracle/request.js +3 -5
  84. package/dist/src/oracle/tokenStats.js +5 -1
  85. package/dist/src/oracle.js +1 -1
  86. package/dist/src/sessionManager.js +1 -0
  87. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  88. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  89. package/package.json +19 -28
  90. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  91. 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));
@@ -814,9 +1036,12 @@ export async function runBrowserMode(options) {
814
1036
  inputTimeoutMs: config.inputTimeoutMs ?? undefined,
815
1037
  attachmentTimeoutMs: config.attachmentTimeoutMs ?? undefined,
816
1038
  baselineTurns: baselineTurns ?? undefined,
817
- attachmentNames,
1039
+ attachmentNames: attachmentExpectations,
818
1040
  onPromptSubmitted: markPromptSubmitted,
819
1041
  };
1042
+ const deepResearchTargetKeys = deepResearch && client
1043
+ ? await captureDeepResearchTargetKeys(client).catch(() => [])
1044
+ : undefined;
820
1045
  await runProviderSubmissionFlow(chatgptDomProvider, {
821
1046
  prompt,
822
1047
  evaluate: async () => undefined,
@@ -849,7 +1074,7 @@ export async function runBrowserMode(options) {
849
1074
  }
850
1075
  // Reattach needs a /c/ URL; ChatGPT can update it late, so poll in the background.
851
1076
  scheduleConversationHint("post-submit", config.timeoutMs ?? 120_000);
852
- return { baselineTurns, baselineAssistantText };
1077
+ return { baselineTurns, baselineAssistantText, deepResearchTargetKeys };
853
1078
  };
854
1079
  const reloadPromptComposer = async () => {
855
1080
  logger("[browser] Composer became unresponsive; reloading page and retrying once.");
@@ -858,6 +1083,7 @@ export async function runBrowserMode(options) {
858
1083
  };
859
1084
  let baselineTurns = null;
860
1085
  let baselineAssistantText = null;
1086
+ let deepResearchTargetKeys = [];
861
1087
  await acquireProfileLockIfNeeded();
862
1088
  try {
863
1089
  const submission = await runSubmissionWithRecovery({
@@ -874,6 +1100,7 @@ export async function runBrowserMode(options) {
874
1100
  });
875
1101
  baselineTurns = submission.baselineTurns;
876
1102
  baselineAssistantText = submission.baselineAssistantText;
1103
+ deepResearchTargetKeys = submission.deepResearchTargetKeys ?? [];
877
1104
  }
878
1105
  finally {
879
1106
  await releaseProfileLockIfHeld();
@@ -881,7 +1108,7 @@ export async function runBrowserMode(options) {
881
1108
  const imageArtifactMinTurnIndex = baselineTurns;
882
1109
  if (deepResearch) {
883
1110
  await raceWithDisconnect(waitForResearchPlanAutoConfirm(Runtime, logger));
884
- 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 }));
885
1112
  await updateConversationHint("post-deep-research", 15_000).catch(() => false);
886
1113
  runStatus = "complete";
887
1114
  const durationMs = Date.now() - startedAt;
@@ -1070,7 +1297,13 @@ export async function runBrowserMode(options) {
1070
1297
  promptSubmitted,
1071
1298
  controllerPid: process.pid,
1072
1299
  };
1073
- 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
+ });
1074
1307
  }
1075
1308
  }
1076
1309
  else {
@@ -1259,6 +1492,9 @@ export async function runBrowserMode(options) {
1259
1492
  throw new Error("Chrome disconnected before completion");
1260
1493
  }
1261
1494
  const imageArtifacts = await collectGeneratedImageArtifacts({
1495
+ Browser: client.Browser,
1496
+ Client: client,
1497
+ Page,
1262
1498
  Runtime,
1263
1499
  Network,
1264
1500
  logger,
@@ -1273,16 +1509,28 @@ export async function runBrowserMode(options) {
1273
1509
  if (imageArtifacts.markdownSuffix) {
1274
1510
  answerMarkdown += imageArtifacts.markdownSuffix;
1275
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
+ });
1276
1523
  const savedImageArtifacts = appendArtifacts(undefined, imageArtifacts.savedImages);
1524
+ const savedBrowserArtifacts = appendArtifacts(savedImageArtifacts, fileArtifacts.savedFiles);
1277
1525
  const transcriptArtifact = await saveOptionalArtifact(() => saveBrowserTranscriptArtifact({
1278
1526
  sessionId: options.sessionId,
1279
1527
  prompt: promptText,
1280
1528
  answerMarkdown,
1281
1529
  conversationUrl: lastUrl,
1282
- artifacts: savedImageArtifacts,
1530
+ artifacts: savedBrowserArtifacts,
1283
1531
  logger,
1284
1532
  }), logger);
1285
- const savedArtifacts = appendArtifacts(savedImageArtifacts, [transcriptArtifact]);
1533
+ const savedArtifacts = appendArtifacts(savedBrowserArtifacts, [transcriptArtifact]);
1286
1534
  const archive = await maybeArchiveCompletedConversation({
1287
1535
  Runtime,
1288
1536
  logger,
@@ -1290,7 +1538,8 @@ export async function runBrowserMode(options) {
1290
1538
  conversationUrl: lastUrl,
1291
1539
  followUpCount: followUpPrompts.length,
1292
1540
  requiredArtifactsSaved: Boolean(transcriptArtifact) &&
1293
- imageArtifacts.savedImages.length === imageArtifacts.imageCount,
1541
+ imageArtifacts.savedImages.length === imageArtifacts.imageCount &&
1542
+ fileArtifacts.savedFiles.length === fileArtifacts.fileCount,
1294
1543
  });
1295
1544
  runStatus = "complete";
1296
1545
  const durationMs = Date.now() - startedAt;
@@ -1303,6 +1552,8 @@ export async function runBrowserMode(options) {
1303
1552
  artifacts: savedArtifacts,
1304
1553
  generatedImages: imageArtifacts.generatedImages,
1305
1554
  savedImages: imageArtifacts.savedImages,
1555
+ downloadableFiles: fileArtifacts.files,
1556
+ savedFiles: fileArtifacts.savedFiles,
1306
1557
  archive,
1307
1558
  modelSelection: modelSelectionEvidence,
1308
1559
  tookMs: durationMs,
@@ -1676,13 +1927,23 @@ async function maybeReuseRunningChrome(userDataDir, logger, options = {}) {
1676
1927
  let pid = await readChromePid(userDataDir);
1677
1928
  if (!port) {
1678
1929
  const discovered = await findRunningChromeDebugTargetForProfile(userDataDir);
1679
- 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
+ }
1680
1937
  return null;
1938
+ }
1681
1939
  const discoveredProbe = await (options.probe ?? verifyDevToolsReachable)({
1682
1940
  port: discovered.port,
1683
1941
  });
1684
1942
  if (!discoveredProbe.ok) {
1685
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
+ });
1686
1947
  return null;
1687
1948
  }
1688
1949
  await writeDevToolsActivePort(userDataDir, discovered.port);
@@ -1830,16 +2091,20 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
1830
2091
  removeDialogHandler = installJavaScriptDialogAutoDismissal(Page, logger);
1831
2092
  // Skip cookie sync for remote Chrome - it already has cookies
1832
2093
  logger("Skipping cookie sync for remote Chrome (using existing session)");
1833
- if (!attachedExistingTab) {
2094
+ if (config.resumeConversationUrl) {
2095
+ await navigateToChatGPT(Page, Runtime, config.resumeConversationUrl, logger);
2096
+ }
2097
+ else if (!attachedExistingTab) {
1834
2098
  await navigateToChatGPT(Page, Runtime, config.url, logger);
1835
- await ensureNotBlocked(Runtime, config.headless, logger);
1836
- await ensureLoggedIn(Runtime, logger, { remoteSession: true });
1837
- await ensurePromptReady(Runtime, config.inputTimeoutMs, logger);
1838
2099
  }
1839
- else {
1840
- await ensureNotBlocked(Runtime, config.headless, logger);
1841
- await ensureLoggedIn(Runtime, logger, { remoteSession: true });
1842
- 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
+ });
1843
2108
  }
1844
2109
  logger(`Prompt textarea ready (initial focus, ${promptText.length.toLocaleString()} chars queued)`);
1845
2110
  try {
@@ -1856,7 +2121,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
1856
2121
  // ignore
1857
2122
  }
1858
2123
  const modelStrategy = config.modelStrategy ?? DEFAULT_MODEL_STRATEGY;
1859
- if (config.desiredModel && modelStrategy !== "ignore") {
2124
+ if (config.desiredModel && modelStrategy !== "ignore" && !config.resumeConversationUrl) {
1860
2125
  modelSelectionEvidence = await withRetries(() => ensureModelSelection(Runtime, config.desiredModel, logger, modelStrategy), {
1861
2126
  retries: 2,
1862
2127
  delayMs: 300,
@@ -1869,9 +2134,11 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
1869
2134
  await ensurePromptReady(Runtime, config.inputTimeoutMs, logger);
1870
2135
  logger(`Prompt textarea ready (after model switch, ${promptText.length.toLocaleString()} chars queued)`);
1871
2136
  }
1872
- else if (modelStrategy === "ignore") {
2137
+ else if (modelStrategy === "ignore" || config.resumeConversationUrl) {
1873
2138
  modelSelectionEvidence = buildSkippedModelSelectionEvidence(config.desiredModel, modelStrategy);
1874
- logger("Model picker: skipped (strategy=ignore)");
2139
+ logger(config.resumeConversationUrl
2140
+ ? "Model picker: skipped (resumed conversation)"
2141
+ : "Model picker: skipped (strategy=ignore)");
1875
2142
  }
1876
2143
  const deepResearch = config.researchMode === "deep";
1877
2144
  // Handle thinking time selection if specified. Deep Research owns its own effort flow.
@@ -1905,6 +2172,10 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
1905
2172
  const baselineSnapshot = await readAssistantSnapshot(Runtime).catch(() => null);
1906
2173
  const baselineAssistantText = typeof baselineSnapshot?.text === "string" ? baselineSnapshot.text.trim() : "";
1907
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
+ }));
1908
2179
  await clearPromptComposer(Runtime, logger);
1909
2180
  await ensurePromptReady(Runtime, config.inputTimeoutMs, logger);
1910
2181
  if (submissionAttachments.length > 0) {
@@ -1935,9 +2206,12 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
1935
2206
  inputTimeoutMs: config.inputTimeoutMs ?? undefined,
1936
2207
  attachmentTimeoutMs: config.attachmentTimeoutMs ?? undefined,
1937
2208
  baselineTurns: baselineTurns ?? undefined,
1938
- attachmentNames,
2209
+ attachmentNames: attachmentExpectations,
1939
2210
  onPromptSubmitted: markPromptSubmitted,
1940
2211
  };
2212
+ const deepResearchTargetKeys = deepResearch && client
2213
+ ? await captureDeepResearchTargetKeys(client).catch(() => [])
2214
+ : undefined;
1941
2215
  await runProviderSubmissionFlow(chatgptDomProvider, {
1942
2216
  prompt,
1943
2217
  evaluate: async () => undefined,
@@ -1950,7 +2224,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
1950
2224
  if (typeof providerBaselineTurns === "number" && Number.isFinite(providerBaselineTurns)) {
1951
2225
  baselineTurns = providerBaselineTurns;
1952
2226
  }
1953
- return { baselineTurns, baselineAssistantText };
2227
+ return { baselineTurns, baselineAssistantText, deepResearchTargetKeys };
1954
2228
  };
1955
2229
  const reloadPromptComposer = async () => {
1956
2230
  logger("[browser] Composer became unresponsive; reloading page and retrying once.");
@@ -1959,6 +2233,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
1959
2233
  };
1960
2234
  let baselineTurns = null;
1961
2235
  let baselineAssistantText = null;
2236
+ let deepResearchTargetKeys = [];
1962
2237
  const submission = await runSubmissionWithRecovery({
1963
2238
  prompt: promptText,
1964
2239
  attachments,
@@ -1973,10 +2248,11 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
1973
2248
  });
1974
2249
  baselineTurns = submission.baselineTurns;
1975
2250
  baselineAssistantText = submission.baselineAssistantText;
2251
+ deepResearchTargetKeys = submission.deepResearchTargetKeys ?? [];
1976
2252
  const imageArtifactMinTurnIndex = baselineTurns;
1977
2253
  if (deepResearch) {
1978
2254
  await waitForResearchPlanAutoConfirm(Runtime, logger);
1979
- 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 });
1980
2256
  await emitRuntimeHint();
1981
2257
  const durationMs = Date.now() - startedAt;
1982
2258
  const tokens = estimateTokenCount(researchResult.text);
@@ -2175,7 +2451,13 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2175
2451
  promptSubmitted,
2176
2452
  controllerPid: process.pid,
2177
2453
  };
2178
- 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
+ });
2179
2461
  }
2180
2462
  }
2181
2463
  else {
@@ -2320,7 +2602,11 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2320
2602
  answerMarkdown = formatted.answerMarkdown;
2321
2603
  answerHtml = "";
2322
2604
  }
2605
+ const canSaveBrowserDownloadsLocally = isLocalChromeHost(host);
2323
2606
  const imageArtifacts = await collectGeneratedImageArtifacts({
2607
+ Browser: canSaveBrowserDownloadsLocally ? client.Browser : undefined,
2608
+ Client: canSaveBrowserDownloadsLocally ? client : undefined,
2609
+ Page: canSaveBrowserDownloadsLocally ? Page : undefined,
2324
2610
  Runtime,
2325
2611
  Network,
2326
2612
  logger,
@@ -2335,16 +2621,28 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2335
2621
  if (imageArtifacts.markdownSuffix) {
2336
2622
  answerMarkdown += imageArtifacts.markdownSuffix;
2337
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
+ });
2338
2635
  const savedImageArtifacts = appendArtifacts(undefined, imageArtifacts.savedImages);
2636
+ const savedBrowserArtifacts = appendArtifacts(savedImageArtifacts, fileArtifacts.savedFiles);
2339
2637
  const transcriptArtifact = await saveOptionalArtifact(() => saveBrowserTranscriptArtifact({
2340
2638
  sessionId: options.sessionId,
2341
2639
  prompt: promptText,
2342
2640
  answerMarkdown,
2343
2641
  conversationUrl: lastUrl,
2344
- artifacts: savedImageArtifacts,
2642
+ artifacts: savedBrowserArtifacts,
2345
2643
  logger,
2346
2644
  }), logger);
2347
- const savedArtifacts = appendArtifacts(savedImageArtifacts, [transcriptArtifact]);
2645
+ const savedArtifacts = appendArtifacts(savedBrowserArtifacts, [transcriptArtifact]);
2348
2646
  const archive = await maybeArchiveCompletedConversation({
2349
2647
  Runtime,
2350
2648
  logger,
@@ -2352,7 +2650,8 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2352
2650
  conversationUrl: lastUrl,
2353
2651
  followUpCount: followUpPrompts.length,
2354
2652
  requiredArtifactsSaved: Boolean(transcriptArtifact) &&
2355
- imageArtifacts.savedImages.length === imageArtifacts.imageCount,
2653
+ imageArtifacts.savedImages.length === imageArtifacts.imageCount &&
2654
+ fileArtifacts.savedFiles.length === fileArtifacts.fileCount,
2356
2655
  });
2357
2656
  const durationMs = Date.now() - startedAt;
2358
2657
  const answerChars = answerText.length;
@@ -2377,6 +2676,10 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2377
2676
  conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
2378
2677
  promptSubmitted,
2379
2678
  artifacts: savedArtifacts,
2679
+ generatedImages: imageArtifacts.generatedImages,
2680
+ savedImages: imageArtifacts.savedImages,
2681
+ downloadableFiles: fileArtifacts.files,
2682
+ savedFiles: fileArtifacts.savedFiles,
2380
2683
  archive,
2381
2684
  modelSelection: modelSelectionEvidence,
2382
2685
  controllerPid: process.pid,
@@ -2443,8 +2746,12 @@ export { resolveBrowserConfig, DEFAULT_BROWSER_CONFIG } from "./config.js";
2443
2746
  export const __test__ = {
2444
2747
  assertManualLoginProfileReadyForRun,
2445
2748
  closeRemoteConnectionAfterRun,
2749
+ classifyChatGptUiWarningText,
2750
+ collectChatGptUiWarnings,
2751
+ createAssistantTimeoutError,
2446
2752
  detachKeptChromeProcess,
2447
2753
  formatManualLoginSetupCommand,
2754
+ isAssistantResponseTimeoutError,
2448
2755
  isManualLoginProfileInitialized,
2449
2756
  isImageOnlyUiChromeText,
2450
2757
  listIgnoredRemoteChromeFlags,
@@ -2500,7 +2807,8 @@ function isAssistantResponseTimeoutError(error) {
2500
2807
  const message = error.message.toLowerCase();
2501
2808
  if (!message)
2502
2809
  return false;
2503
- return (message.includes("assistant-response") ||
2810
+ return (message === "response timeout" ||
2811
+ message.includes("assistant-response") ||
2504
2812
  message.includes("assistant response") ||
2505
2813
  message.includes("watchdog") ||
2506
2814
  message.includes("capture assistant response"));