@steipete/oracle 0.13.0 → 0.14.1

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 (71) hide show
  1. package/README.md +22 -11
  2. package/dist/bin/oracle-cli.js +71 -71
  3. package/dist/src/browser/actions/assistantResponse.js +93 -10
  4. package/dist/src/browser/actions/deepResearch.js +180 -52
  5. package/dist/src/browser/actions/modelSelection.js +357 -40
  6. package/dist/src/browser/actions/navigation.js +294 -65
  7. package/dist/src/browser/actions/promptComposer.js +50 -4
  8. package/dist/src/browser/actions/thinkingTime.js +517 -105
  9. package/dist/src/browser/artifacts.js +19 -0
  10. package/dist/src/browser/chatgptFiles.js +797 -0
  11. package/dist/src/browser/chatgptImages.js +256 -27
  12. package/dist/src/browser/chromeLifecycle.js +4 -0
  13. package/dist/src/browser/config.js +2 -0
  14. package/dist/src/browser/index.js +404 -41
  15. package/dist/src/browser/pageActions.js +1 -1
  16. package/dist/src/browser/policies.js +2 -6
  17. package/dist/src/browser/projectSourcesRunner.js +16 -3
  18. package/dist/src/browser/prompt.js +161 -36
  19. package/dist/src/browser/reattach.js +6 -2
  20. package/dist/src/browser/reattachability.js +22 -10
  21. package/dist/src/browser/recoverConversation.js +73 -0
  22. package/dist/src/browser/sessionRunner.js +4 -1
  23. package/dist/src/cli/browserConfig.js +6 -2
  24. package/dist/src/cli/browserDefaults.js +2 -1
  25. package/dist/src/cli/browserTabs.js +129 -54
  26. package/dist/src/cli/followup.js +72 -0
  27. package/dist/src/cli/help.js +1 -1
  28. package/dist/src/cli/options.js +24 -0
  29. package/dist/src/cli/reattachGuidance.js +8 -0
  30. package/dist/src/cli/runOptions.js +2 -12
  31. package/dist/src/cli/sessionCommand.js +4 -0
  32. package/dist/src/cli/sessionDisplay.js +17 -3
  33. package/dist/src/cli/sessionLineage.js +7 -4
  34. package/dist/src/cli/sessionRunner.js +16 -1
  35. package/dist/src/gemini-web/client.js +5 -10
  36. package/dist/src/gemini-web/executor.js +1 -24
  37. package/dist/src/gemini-web/models.js +83 -0
  38. package/dist/src/mcp/server.js +2 -0
  39. package/dist/src/mcp/tools/chatgptImage.js +132 -0
  40. package/dist/src/mcp/tools/consult.js +270 -175
  41. package/dist/src/mcp/types.js +13 -2
  42. package/dist/src/mcp/utils.js +87 -1
  43. package/dist/src/oracle/config.js +27 -1
  44. package/dist/src/oracle/files.js +4 -6
  45. package/dist/src/oracle/geminiModels.js +2 -0
  46. package/dist/src/oracle/markdown.js +36 -3
  47. package/dist/src/oracle/modelResolver.js +21 -13
  48. package/dist/src/oracle/promptAssembly.js +2 -4
  49. package/dist/src/oracle/request.js +3 -5
  50. package/dist/src/oracle/thinkingTime.js +40 -0
  51. package/dist/src/oracle/tokenStats.js +5 -1
  52. package/dist/src/oracle.js +1 -1
  53. package/dist/src/sessionManager.js +1 -0
  54. package/dist/vendor/oracle-notifier/build-notifier.sh +0 -0
  55. package/package.json +47 -58
  56. package/vendor/oracle-notifier/build-notifier.sh +0 -0
  57. package/dist/bin/oracle.js +0 -569
  58. package/dist/src/browser/chromeCookies.js +0 -312
  59. package/dist/src/browser/keytarShim.js +0 -56
  60. package/dist/src/browser/windowsCookies.js +0 -219
  61. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  62. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/Info.plist +0 -20
  63. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  64. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
  65. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +0 -128
  66. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  67. package/vendor/oracle-notifier/OracleNotifier.app/Contents/Info.plist +0 -20
  68. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  69. package/vendor/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
  70. package/vendor/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +0 -128
  71. package/vendor/oracle-notifier/README.md +0 -26
@@ -1,4 +1,4 @@
1
- export { navigateToChatGPT, navigateToPromptReadyWithFallback, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, installJavaScriptDialogAutoDismissal, } from "./actions/navigation.js";
1
+ export { navigateToChatGPT, navigateToPromptReadyWithFallback, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, waitForResumedConversationHydration, installJavaScriptDialogAutoDismissal, } from "./actions/navigation.js";
2
2
  export { ensureModelSelection } from "./actions/modelSelection.js";
3
3
  export { submitPrompt, clearPromptComposer } from "./actions/promptComposer.js";
4
4
  export { clearComposerAttachments, uploadAttachmentFile, waitForAttachmentCompletion, waitForUserTurnAttachments, buildUserTurnAttachmentExpressionForTest, } from "./actions/attachments.js";
@@ -1,11 +1,7 @@
1
- import { formatFileSection } from "../oracle/markdown.js";
1
+ import { formatFileSections } from "../oracle/markdown.js";
2
2
  export function buildAttachmentPlan(sections, { inlineFiles, bundleRequested, maxAttachments = 10, }) {
3
3
  if (inlineFiles) {
4
- const inlineLines = [];
5
- sections.forEach((section) => {
6
- inlineLines.push(formatFileSection(section.displayPath, section.content).trimEnd(), "");
7
- });
8
- const inlineBlock = inlineLines.join("\n").trim();
4
+ const inlineBlock = formatFileSections(sections);
9
5
  return {
10
6
  mode: "inline",
11
7
  inlineBlock,
@@ -97,9 +97,10 @@ export async function runBrowserProjectSources(request) {
97
97
  preserveUserDataDir: manualLogin,
98
98
  });
99
99
  const strictTabIsolation = Boolean(manualLogin && reusedChrome);
100
+ const devtoolsRetries = manualLogin ? 6 : 0;
100
101
  const connection = await connectWithNewTab(chrome.port, logger, "about:blank", chromeHost, {
101
102
  fallbackToDefault: !strictTabIsolation,
102
- retries: strictTabIsolation ? 3 : 0,
103
+ retries: devtoolsRetries,
103
104
  retryDelayMs: 500,
104
105
  });
105
106
  client = connection.client;
@@ -347,11 +348,20 @@ async function maybeReuseProjectSourcesChrome(userDataDir, logger, options = {})
347
348
  if (!port) {
348
349
  const discovered = await findRunningChromeDebugTargetForProfile(userDataDir);
349
350
  if (!discovered) {
351
+ if (pid) {
352
+ logger(`No reachable Chrome DevTools target found for ${userDataDir}; clearing stale profile state before launching new Chrome.`);
353
+ await cleanupStaleProfileState(userDataDir, logger, {
354
+ lockRemovalMode: "if_oracle_pid_dead",
355
+ });
356
+ }
350
357
  return null;
351
358
  }
352
- const probe = await verifyDevToolsReachable({ port: discovered.port });
359
+ const probe = await (options.probe ?? verifyDevToolsReachable)({ port: discovered.port });
353
360
  if (!probe.ok) {
354
361
  logger(`Discovered Chrome for ${userDataDir} on port ${discovered.port} but it was unreachable (${probe.error}); launching new Chrome.`);
362
+ await cleanupStaleProfileState(userDataDir, logger, {
363
+ lockRemovalMode: "if_oracle_pid_dead",
364
+ });
355
365
  return null;
356
366
  }
357
367
  await writeDevToolsActivePort(userDataDir, discovered.port);
@@ -361,7 +371,7 @@ async function maybeReuseProjectSourcesChrome(userDataDir, logger, options = {})
361
371
  logger(`Discovered running Chrome for ${userDataDir}; reusing (DevTools port ${port}, pid ${pid})`);
362
372
  return { port, pid, kill: async () => { }, process: undefined };
363
373
  }
364
- const probe = await verifyDevToolsReachable({ port });
374
+ const probe = await (options.probe ?? verifyDevToolsReachable)({ port });
365
375
  if (!probe.ok) {
366
376
  logger(`Recorded Chrome DevTools port ${port} is stale (${probe.error}); launching new Chrome.`);
367
377
  await cleanupStaleProfileState(userDataDir, logger, { lockRemovalMode: "if_oracle_pid_dead" });
@@ -375,3 +385,6 @@ async function maybeReuseProjectSourcesChrome(userDataDir, logger, options = {})
375
385
  process: undefined,
376
386
  };
377
387
  }
388
+ export async function maybeReuseProjectSourcesChromeForTest(userDataDir, logger, options = {}) {
389
+ return maybeReuseProjectSourcesChrome(userDataDir, logger, options);
390
+ }
@@ -1,12 +1,14 @@
1
1
  import fs from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
- import { readFiles, createFileSections, MODEL_CONFIGS, TOKENIZER_OPTIONS, formatFileSection, } from "../oracle.js";
4
+ import { readFiles, createFileSections, FileValidationError, MODEL_CONFIGS, TOKENIZER_OPTIONS, formatFileSections, } from "../oracle.js";
5
5
  import { isKnownModel } from "../oracle/modelResolver.js";
6
6
  import { buildPromptMarkdown } from "../oracle/promptAssembly.js";
7
7
  import { buildAttachmentPlan } from "./policies.js";
8
8
  import { createStoredZip } from "./zipBundle.js";
9
9
  const DEFAULT_BROWSER_INLINE_CHAR_BUDGET = 60_000;
10
+ const MAX_BROWSER_ATTACHMENTS = 10;
11
+ const MAX_BROWSER_ZIP_BUNDLE_BYTES = 128 * 1024 * 1024;
10
12
  const MEDIA_EXTENSIONS = new Set([
11
13
  ".mp4",
12
14
  ".mov",
@@ -31,38 +33,104 @@ const MEDIA_EXTENSIONS = new Set([
31
33
  ".heif",
32
34
  ".pdf",
33
35
  ]);
36
+ const ARCHIVE_EXTENSIONS = new Set([
37
+ ".7z",
38
+ ".aab",
39
+ ".apk",
40
+ ".br",
41
+ ".bz2",
42
+ ".cab",
43
+ ".crx",
44
+ ".deb",
45
+ ".dmg",
46
+ ".doc",
47
+ ".docx",
48
+ ".ear",
49
+ ".epub",
50
+ ".gz",
51
+ ".ipa",
52
+ ".iso",
53
+ ".jar",
54
+ ".lz",
55
+ ".lz4",
56
+ ".msi",
57
+ ".odp",
58
+ ".ods",
59
+ ".odt",
60
+ ".pkg",
61
+ ".ppt",
62
+ ".pptx",
63
+ ".rar",
64
+ ".rpm",
65
+ ".tar",
66
+ ".tgz",
67
+ ".war",
68
+ ".whl",
69
+ ".xls",
70
+ ".xlsx",
71
+ ".xz",
72
+ ".xpi",
73
+ ".zip",
74
+ ".zipx",
75
+ ".zst",
76
+ ]);
34
77
  export function isMediaFile(filePath) {
35
78
  const ext = path.extname(filePath).toLowerCase();
36
79
  return MEDIA_EXTENSIONS.has(ext);
37
80
  }
38
- function formatSectionsForBundle(sections) {
39
- const bundleLines = [];
40
- sections.forEach((section) => {
41
- bundleLines.push(formatFileSection(section.displayPath, section.content).trimEnd());
42
- bundleLines.push("");
81
+ export function isRawUploadFile(filePath) {
82
+ const ext = path.extname(filePath).toLowerCase();
83
+ return MEDIA_EXTENSIONS.has(ext) || ARCHIVE_EXTENSIONS.has(ext);
84
+ }
85
+ function formatSectionsForBundle(sections, options = {}) {
86
+ return formatFileSections(sections, {
87
+ lineNumbers: options.lineNumbers ?? true,
88
+ trailingNewline: true,
43
89
  });
44
- return `${bundleLines
45
- .join("\n")
46
- .replace(/\n{3,}/g, "\n\n")
47
- .trimEnd()}\n`;
48
90
  }
49
- async function writeBrowserBundle(sections, format) {
91
+ function resolveBrowserBundleFormat(format, sources) {
92
+ if (format !== "auto") {
93
+ return format;
94
+ }
95
+ return sources.hasRawUploadFiles ? "zip" : "text";
96
+ }
97
+ function shouldWriteBrowserBundle(format, { attachmentCount, bundleRequested, textSourceCount, textPlanShouldBundle, }) {
98
+ if (format === "zip") {
99
+ return (textPlanShouldBundle ||
100
+ (bundleRequested && attachmentCount > 0) ||
101
+ attachmentCount > MAX_BROWSER_ATTACHMENTS);
102
+ }
103
+ return textSourceCount > 0 && (textPlanShouldBundle || attachmentCount > MAX_BROWSER_ATTACHMENTS);
104
+ }
105
+ function assertAttachmentCount(attachments, format) {
106
+ if (attachments.length <= MAX_BROWSER_ATTACHMENTS)
107
+ return;
108
+ throw new Error(`Browser upload has ${attachments.length} attachments after applying bundle format "${format}". Use --browser-bundle-format auto or zip to stay within the ${MAX_BROWSER_ATTACHMENTS}-attachment limit.`);
109
+ }
110
+ async function writeBrowserBundle(sections, sources, format) {
50
111
  const bundleDir = await fs.mkdtemp(path.join(os.tmpdir(), "oracle-browser-bundle-"));
51
- const tokenEstimateText = formatSectionsForBundle(sections);
112
+ const tokenEstimateText = formatSectionsForBundle(sections, {
113
+ lineNumbers: format === "text",
114
+ });
52
115
  if (format === "zip") {
116
+ const totalSourceBytes = sources.reduce((total, source) => total + source.sizeBytes, 0);
117
+ if (totalSourceBytes > MAX_BROWSER_ZIP_BUNDLE_BYTES) {
118
+ throw new Error(`Browser ZIP bundle inputs exceed the ${MAX_BROWSER_ZIP_BUNDLE_BYTES}-byte in-memory limit.`);
119
+ }
53
120
  const bundlePath = path.join(bundleDir, "attachments-bundle.zip");
54
- const buffer = createStoredZip(sections.map((section) => ({
55
- path: section.displayPath,
56
- content: section.content,
57
- })));
121
+ const buffer = createStoredZip(await Promise.all(sources.map(async (source) => ({
122
+ path: source.displayPath,
123
+ content: await fs.readFile(source.absolutePath),
124
+ }))));
58
125
  await fs.writeFile(bundlePath, buffer);
59
126
  return {
60
127
  attachment: {
61
128
  path: bundlePath,
62
129
  displayPath: bundlePath,
63
130
  sizeBytes: buffer.length,
131
+ generatedBundle: true,
64
132
  },
65
- metadata: { originalCount: sections.length, bundlePath, format },
133
+ metadata: { originalCount: sources.length, bundlePath, format },
66
134
  tokenEstimateText,
67
135
  };
68
136
  }
@@ -73,6 +141,7 @@ async function writeBrowserBundle(sections, format) {
73
141
  path: bundlePath,
74
142
  displayPath: bundlePath,
75
143
  sizeBytes: Buffer.byteLength(tokenEstimateText, "utf8"),
144
+ generatedBundle: true,
76
145
  },
77
146
  metadata: { originalCount: sections.length, bundlePath, format },
78
147
  tokenEstimateText,
@@ -82,11 +151,27 @@ export async function assembleBrowserPrompt(runOptions, deps = {}) {
82
151
  const cwd = deps.cwd ?? process.cwd();
83
152
  const readFilesFn = deps.readFilesImpl ?? readFiles;
84
153
  const allFilePaths = runOptions.file ?? [];
85
- const textFilePaths = allFilePaths.filter((f) => !isMediaFile(f));
86
- const mediaFilePaths = allFilePaths.filter((f) => isMediaFile(f));
87
- const mediaAttachments = await Promise.all(mediaFilePaths.map(async (filePath) => {
154
+ const discoveredFiles = allFilePaths.length > 0
155
+ ? await readFilesFn(allFilePaths, {
156
+ cwd,
157
+ maxFileSizeBytes: 0,
158
+ readContents: false,
159
+ })
160
+ : [];
161
+ const textFilePaths = discoveredFiles
162
+ .filter((file) => !isRawUploadFile(file.path))
163
+ .map((file) => file.path);
164
+ const rawUploadFiles = discoveredFiles.filter((file) => isRawUploadFile(file.path));
165
+ const maxFileSizeBytes = runOptions.maxFileSizeBytes;
166
+ const rawUploadAttachments = await Promise.all(rawUploadFiles.map(async ({ path: filePath }) => {
88
167
  const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
89
168
  const stats = await fs.stat(resolvedPath);
169
+ if (maxFileSizeBytes && stats.size > maxFileSizeBytes) {
170
+ throw new FileValidationError(`The following file exceeds the ${maxFileSizeBytes}-byte limit:\n- ${path.relative(cwd, resolvedPath) || resolvedPath} (${stats.size} bytes)`, {
171
+ files: [resolvedPath],
172
+ limitBytes: maxFileSizeBytes,
173
+ });
174
+ }
90
175
  return {
91
176
  path: resolvedPath,
92
177
  displayPath: path.relative(cwd, resolvedPath) || path.basename(resolvedPath),
@@ -106,7 +191,10 @@ export async function assembleBrowserPrompt(runOptions, deps = {}) {
106
191
  ? "never"
107
192
  : (runOptions.browserAttachments ?? "auto");
108
193
  const bundleRequested = Boolean(runOptions.browserBundleFiles);
109
- const bundleFormat = runOptions.browserBundleFormat ?? "text";
194
+ const bundleFormat = runOptions.browserBundleFormat ?? "auto";
195
+ if (attachmentsPolicy === "never" && rawUploadAttachments.length > 0) {
196
+ throw new FileValidationError("Raw or binary files cannot be pasted inline when browser attachments are disabled. Use --browser-attachments auto or always.", { files: rawUploadAttachments.map((attachment) => attachment.displayPath) });
197
+ }
110
198
  const inlinePlan = buildAttachmentPlan(sections, { inlineFiles: true, bundleRequested });
111
199
  const uploadPlan = buildAttachmentPlan(sections, { inlineFiles: false, bundleRequested });
112
200
  const baseComposerSections = [];
@@ -125,25 +213,47 @@ export async function assembleBrowserPrompt(runOptions, deps = {}) {
125
213
  : inlineComposerText.length <= DEFAULT_BROWSER_INLINE_CHAR_BUDGET || sections.length === 0
126
214
  ? inlinePlan
127
215
  : uploadPlan;
128
- const composerText = (selectedPlan.inlineBlock
216
+ const textBundleSources = sections.map((section) => ({
217
+ absolutePath: section.absolutePath,
218
+ displayPath: section.displayPath,
219
+ sizeBytes: Buffer.byteLength(section.content, "utf8"),
220
+ }));
221
+ const rawUploadBundleSources = rawUploadAttachments.map((attachment) => ({
222
+ absolutePath: attachment.path,
223
+ displayPath: attachment.displayPath,
224
+ sizeBytes: attachment.sizeBytes ?? 0,
225
+ }));
226
+ const allBundleSources = [...textBundleSources, ...rawUploadBundleSources];
227
+ const attachments = [...selectedPlan.attachments, ...rawUploadAttachments];
228
+ const resolvedBundleFormat = resolveBrowserBundleFormat(bundleFormat, {
229
+ hasRawUploadFiles: rawUploadAttachments.length > 0,
230
+ });
231
+ const shouldBundle = shouldWriteBrowserBundle(resolvedBundleFormat, {
232
+ attachmentCount: attachments.length,
233
+ bundleRequested,
234
+ textSourceCount: textBundleSources.length,
235
+ textPlanShouldBundle: selectedPlan.shouldBundle,
236
+ });
237
+ const composerText = (!shouldBundle && selectedPlan.inlineBlock
129
238
  ? [...baseComposerSections, selectedPlan.inlineBlock]
130
239
  : baseComposerSections)
131
240
  .filter(Boolean)
132
241
  .join("\n\n")
133
242
  .trim();
134
- const attachments = [...selectedPlan.attachments, ...mediaAttachments];
135
- const shouldBundle = selectedPlan.shouldBundle;
136
243
  let bundleText = null;
137
244
  let bundled = null;
138
245
  if (shouldBundle) {
139
- const writtenBundle = await writeBrowserBundle(sections, bundleFormat);
246
+ const writtenBundle = await writeBrowserBundle(sections, resolvedBundleFormat === "zip" ? allBundleSources : textBundleSources, resolvedBundleFormat);
140
247
  bundleText = writtenBundle.tokenEstimateText;
141
248
  attachments.length = 0;
142
249
  attachments.push(writtenBundle.attachment);
143
- attachments.push(...mediaAttachments);
250
+ if (resolvedBundleFormat === "text") {
251
+ attachments.push(...rawUploadAttachments);
252
+ }
144
253
  bundled = writtenBundle.metadata;
145
254
  }
146
- const inlineFileCount = selectedPlan.inlineFileCount;
255
+ assertAttachmentCount(attachments, resolvedBundleFormat);
256
+ const inlineFileCount = shouldBundle ? 0 : selectedPlan.inlineFileCount;
147
257
  const modelConfig = isKnownModel(runOptions.model)
148
258
  ? MODEL_CONFIGS[runOptions.model]
149
259
  : MODEL_CONFIGS["gpt-5.1"];
@@ -161,25 +271,34 @@ export async function assembleBrowserPrompt(runOptions, deps = {}) {
161
271
  let estimatedInputTokens = tokenizer(tokenizerMessages.length > 0 ? tokenizerMessages : [{ role: "user", content: "" }], TOKENIZER_OPTIONS);
162
272
  const tokenEstimateIncludesInlineFiles = inlineFileCount > 0 && Boolean(selectedPlan.inlineBlock);
163
273
  if (!tokenEstimateIncludesInlineFiles && sections.length > 0) {
164
- const attachmentText = bundleText ??
165
- sections
166
- .map((section) => formatFileSection(section.displayPath, section.content).trimEnd())
167
- .join("\n\n");
274
+ const attachmentText = bundleText ?? formatFileSections(sections, { lineNumbers: false });
168
275
  const attachmentTokens = tokenizer([{ role: "user", content: attachmentText }], TOKENIZER_OPTIONS);
169
276
  estimatedInputTokens += attachmentTokens;
170
277
  }
171
278
  let fallback = null;
172
279
  if (attachmentsPolicy === "auto" && selectedPlan.mode === "inline" && sections.length > 0) {
173
280
  const fallbackComposerText = baseComposerSections.join("\n\n").trim();
174
- const fallbackAttachments = [...uploadPlan.attachments, ...mediaAttachments];
281
+ const fallbackAttachments = [...uploadPlan.attachments, ...rawUploadAttachments];
175
282
  let fallbackBundled = null;
176
- if (uploadPlan.shouldBundle) {
177
- const writtenBundle = await writeBrowserBundle(sections, bundleFormat);
283
+ const fallbackBundleFormat = resolveBrowserBundleFormat(bundleFormat, {
284
+ hasRawUploadFiles: rawUploadAttachments.length > 0,
285
+ });
286
+ const fallbackShouldBundle = shouldWriteBrowserBundle(fallbackBundleFormat, {
287
+ attachmentCount: fallbackAttachments.length,
288
+ bundleRequested,
289
+ textSourceCount: textBundleSources.length,
290
+ textPlanShouldBundle: uploadPlan.shouldBundle,
291
+ });
292
+ if (fallbackShouldBundle) {
293
+ const writtenBundle = await writeBrowserBundle(sections, fallbackBundleFormat === "zip" ? allBundleSources : textBundleSources, fallbackBundleFormat);
178
294
  fallbackAttachments.length = 0;
179
295
  fallbackAttachments.push(writtenBundle.attachment);
180
- fallbackAttachments.push(...mediaAttachments);
296
+ if (fallbackBundleFormat === "text") {
297
+ fallbackAttachments.push(...rawUploadAttachments);
298
+ }
181
299
  fallbackBundled = writtenBundle.metadata;
182
300
  }
301
+ assertAttachmentCount(fallbackAttachments, fallbackBundleFormat);
183
302
  fallback = {
184
303
  composerText: fallbackComposerText,
185
304
  attachments: fallbackAttachments,
@@ -194,7 +313,13 @@ export async function assembleBrowserPrompt(runOptions, deps = {}) {
194
313
  inlineFileCount,
195
314
  tokenEstimateIncludesInlineFiles,
196
315
  attachmentsPolicy,
197
- attachmentMode: selectedPlan.mode,
316
+ attachmentMode: shouldBundle
317
+ ? "bundle"
318
+ : attachments.length > 0
319
+ ? "upload"
320
+ : selectedPlan.mode === "bundle"
321
+ ? "inline"
322
+ : selectedPlan.mode,
198
323
  fallback,
199
324
  bundled,
200
325
  };
@@ -94,7 +94,9 @@ export async function resumeBrowserSession(runtime, config, logger, deps = {}) {
94
94
  (deps.promptPreview ? null : await readConversationTurnIndex(Runtime, logger));
95
95
  if (config?.researchMode === "deep") {
96
96
  const waitForDeepResearch = deps.waitForDeepResearchCompletion ?? waitForDeepResearchCompletion;
97
- const researchResult = await withTimeout(waitForDeepResearch(Runtime, logger, timeoutMs, minTurnIndex ?? undefined, Page, client), timeoutMs + 5_000, "Reattach Deep Research response timed out");
97
+ const researchResult = await withTimeout(waitForDeepResearch(Runtime, logger, timeoutMs, minTurnIndex ?? undefined, Page, client, {
98
+ requireScopedTargetOwner: true,
99
+ }), timeoutMs + 5_000, "Reattach Deep Research response timed out");
98
100
  await connection.close().catch(() => undefined);
99
101
  return {
100
102
  answerText: researchResult.text,
@@ -239,7 +241,9 @@ async function resumeBrowserSessionViaNewChrome(runtime, config, logger, deps) {
239
241
  (deps.promptPreview ? null : await readConversationTurnIndex(Runtime, logger));
240
242
  if (resolved.researchMode === "deep") {
241
243
  const waitForDeepResearch = deps.waitForDeepResearchCompletion ?? waitForDeepResearchCompletion;
242
- const researchResult = await waitForDeepResearch(Runtime, logger, timeoutMs, minTurnIndex ?? undefined, Page, client);
244
+ const researchResult = await waitForDeepResearch(Runtime, logger, timeoutMs, minTurnIndex ?? undefined, Page, client, {
245
+ requireScopedTargetOwner: true,
246
+ });
243
247
  await cleanup();
244
248
  return {
245
249
  answerText: researchResult.text,
@@ -1,16 +1,19 @@
1
- export function hasRecoverableChatGptConversation(runtime) {
2
- if (!runtime) {
3
- return false;
4
- }
5
- if (runtime.conversationId?.trim()) {
6
- return true;
7
- }
8
- const tabUrl = runtime.tabUrl?.trim();
9
- if (!tabUrl) {
1
+ /**
2
+ * True when the URL points at a specific ChatGPT conversation (`/c/<id>`) on
3
+ * chatgpt.com or chat.openai.com. Rejects home, project shell, and external
4
+ * URLs — anything else would be unsafe to auto-reopen in a persistent
5
+ * signed-in browser profile.
6
+ */
7
+ export function isRecoverableChatGptConversationUrl(candidate) {
8
+ const trimmed = candidate?.trim();
9
+ if (!trimmed) {
10
10
  return false;
11
11
  }
12
12
  try {
13
- const url = new URL(tabUrl);
13
+ const url = new URL(trimmed);
14
+ if (url.protocol !== "https:" || url.port) {
15
+ return false;
16
+ }
14
17
  if (url.hostname !== "chatgpt.com" && url.hostname !== "chat.openai.com") {
15
18
  return false;
16
19
  }
@@ -20,3 +23,12 @@ export function hasRecoverableChatGptConversation(runtime) {
20
23
  return false;
21
24
  }
22
25
  }
26
+ export function hasRecoverableChatGptConversation(runtime) {
27
+ if (!runtime) {
28
+ return false;
29
+ }
30
+ if (runtime.conversationId?.trim()) {
31
+ return true;
32
+ }
33
+ return isRecoverableChatGptConversationUrl(runtime.tabUrl);
34
+ }
@@ -0,0 +1,73 @@
1
+ import { launch } from "chrome-launcher";
2
+ import { defaultManualLoginProfileDir } from "./manualLoginProfile.js";
3
+ import { isRecoverableChatGptConversationUrl } from "./reattachability.js";
4
+ const DEFAULT_HYDRATION_DELAY_MS = 3_000;
5
+ /**
6
+ * Picks the URL to navigate the recovered Chrome tab to.
7
+ *
8
+ * Preference order matches `resolveSessionTabRef`: `harvest.url` (post-harvest,
9
+ * always a ChatGPT conversation URL when present) wins over `runtime.tabUrl`
10
+ * (the URL the original run last navigated to, which can be stale).
11
+ *
12
+ * Both candidates are gated by `isRecoverableChatGptConversationUrl` so a stale
13
+ * home / project shell URL or an unrelated external URL stored in metadata
14
+ * cannot navigate the persistent signed-in profile to the wrong page.
15
+ */
16
+ export function resolveRecoveryUrl(meta) {
17
+ const harvest = meta?.browser?.harvest ?? {};
18
+ const runtime = meta?.browser?.runtime ?? {};
19
+ for (const candidate of [harvest.url, runtime.tabUrl]) {
20
+ if (isRecoverableChatGptConversationUrl(candidate)) {
21
+ return candidate;
22
+ }
23
+ }
24
+ return null;
25
+ }
26
+ function resolveProfileDir(meta) {
27
+ const fromMeta = meta?.browser?.config?.manualLoginProfileDir;
28
+ if (typeof fromMeta === "string" && fromMeta.length > 0) {
29
+ return fromMeta;
30
+ }
31
+ return defaultManualLoginProfileDir();
32
+ }
33
+ /**
34
+ * Re-open a previously-harvested ChatGPT conversation by relaunching Chrome
35
+ * with the session's persistent profile and navigating to the saved tab URL.
36
+ *
37
+ * Used as a fallback when `harvestChatGptTab` can find no live tab matching the
38
+ * stored target (common after the original CLI run exits and closes its
39
+ * browser). ChatGPT preserves attachments + history at the conversation URL,
40
+ * so harvesting against the relaunched tab returns the original message + any
41
+ * assistant response that completed after the original run gave up.
42
+ */
43
+ export async function recoverConversationTab(meta, logger, options = {}) {
44
+ const url = resolveRecoveryUrl(meta);
45
+ if (!url) {
46
+ throw new Error("Cannot recover conversation: session metadata has no recoverable ChatGPT conversation URL " +
47
+ "(expected browser.harvest.url or browser.runtime.tabUrl to be a chatgpt.com/c/<id> URL).");
48
+ }
49
+ const userDataDir = resolveProfileDir(meta);
50
+ logger(`[browser] Recovery: relaunching Chrome with profile ${userDataDir} and navigating to ${url}`);
51
+ const chrome = await launch({
52
+ chromeFlags: [
53
+ "--no-first-run",
54
+ "--no-default-browser-check",
55
+ "--disable-features=AutomationControlled,TranslateUI",
56
+ "--disable-sync",
57
+ "--password-store=basic",
58
+ "--use-mock-keychain",
59
+ "--lang=en-US",
60
+ url,
61
+ ],
62
+ userDataDir,
63
+ handleSIGINT: false,
64
+ });
65
+ const host = "127.0.0.1";
66
+ const port = chrome.port;
67
+ const hydrationDelayMs = options.hydrationDelayMs ?? DEFAULT_HYDRATION_DELAY_MS;
68
+ if (hydrationDelayMs > 0) {
69
+ await new Promise((resolve) => setTimeout(resolve, hydrationDelayMs));
70
+ }
71
+ logger(`[browser] Recovery: Chrome listening on ${host}:${port}; tab loaded.`);
72
+ return { host, port, url, chrome };
73
+ }
@@ -105,6 +105,9 @@ export async function runBrowserSessionExecution({ runOptions, browserConfig, cw
105
105
  log(chalk.dim("Chrome automation does not stream output; this may take a minute..."));
106
106
  }
107
107
  const persistRuntimeHint = deps.persistRuntimeHint ?? (() => { });
108
+ const executionBrowserConfig = runOptions.browserResumeConversationUrl
109
+ ? { ...browserConfig, resumeConversationUrl: runOptions.browserResumeConversationUrl }
110
+ : browserConfig;
108
111
  let browserResult;
109
112
  try {
110
113
  browserResult = await executeBrowser({
@@ -116,7 +119,7 @@ export async function runBrowserSessionExecution({ runOptions, browserConfig, cw
116
119
  attachments: promptArtifacts.fallback.attachments,
117
120
  }
118
121
  : undefined,
119
- config: browserConfig,
122
+ config: executionBrowserConfig,
120
123
  log: automationLogger,
121
124
  heartbeatIntervalMs: runOptions.heartbeatIntervalMs,
122
125
  verbose: runOptions.verbose,
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { normalizeThinkingTimeLevel } from "../oracle/thinkingTime.js";
3
4
  import { CHATGPT_URL, DEFAULT_MODEL_STRATEGY, DEFAULT_MODEL_TARGET } from "../browser/constants.js";
4
5
  import { normalizeChatgptUrl } from "../browser/utils.js";
5
6
  import { parseDuration } from "../duration.js";
@@ -28,7 +29,10 @@ const BROWSER_MODEL_LABELS = [
28
29
  ["gpt-5.4", "Thinking 5.4"],
29
30
  ["gpt-5.2", "GPT-5.2"], // Selects "Auto" in ChatGPT UI
30
31
  ["gpt-5.1", "GPT-5.2"], // Legacy alias → Auto
31
- ["gemini-3-pro", "Gemini 3 Pro"],
32
+ ["gemini-3.1-flash-lite", "Gemini 3.1 Flash-Lite"],
33
+ ["gemini-3.5-flash", "Gemini 3.5 Flash"],
34
+ ["gemini-3.1-pro", "Gemini 3.1 Pro"],
35
+ ["gemini-3-pro", "Gemini 3.1 Pro"],
32
36
  ["gemini-3-pro-deep-think", "gemini-3-deep-think"],
33
37
  ];
34
38
  export function normalizeChatGptModelForBrowser(model) {
@@ -150,7 +154,7 @@ export async function buildBrowserConfig(options) {
150
154
  allowCookieErrors: options.browserAllowCookieErrors ?? true,
151
155
  remoteChrome,
152
156
  browserTabRef: options.browserTab ?? undefined,
153
- thinkingTime: options.browserThinkingTime,
157
+ thinkingTime: normalizeThinkingTimeLevel(options.browserThinkingTime) ?? undefined,
154
158
  researchMode: options.browserResearch === "deep" ? "deep" : "off",
155
159
  archiveConversations: options.browserArchive,
156
160
  };
@@ -1,5 +1,6 @@
1
1
  import { CHATGPT_URL } from "../browser/constants.js";
2
2
  import { normalizeChatgptUrl } from "../browser/utils.js";
3
+ import { normalizeThinkingTimeLevel } from "../oracle/thinkingTime.js";
3
4
  export function applyBrowserDefaultsFromConfig(options, config, getSource) {
4
5
  const browser = config.browser;
5
6
  if (!browser)
@@ -89,7 +90,7 @@ export function applyBrowserDefaultsFromConfig(options, config, getSource) {
89
90
  options.browserModelStrategy = browser.modelStrategy;
90
91
  }
91
92
  if (isUnset("browserThinkingTime") && browser.thinkingTime !== undefined) {
92
- options.browserThinkingTime = browser.thinkingTime;
93
+ options.browserThinkingTime = normalizeThinkingTimeLevel(browser.thinkingTime) ?? undefined;
93
94
  }
94
95
  if (isUnset("browserResearch") && browser.researchMode !== undefined) {
95
96
  options.browserResearch = browser.researchMode;