@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
@@ -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,
@@ -347,11 +347,20 @@ async function maybeReuseProjectSourcesChrome(userDataDir, logger, options = {})
347
347
  if (!port) {
348
348
  const discovered = await findRunningChromeDebugTargetForProfile(userDataDir);
349
349
  if (!discovered) {
350
+ if (pid) {
351
+ logger(`No reachable Chrome DevTools target found for ${userDataDir}; clearing stale profile state before launching new Chrome.`);
352
+ await cleanupStaleProfileState(userDataDir, logger, {
353
+ lockRemovalMode: "if_oracle_pid_dead",
354
+ });
355
+ }
350
356
  return null;
351
357
  }
352
- const probe = await verifyDevToolsReachable({ port: discovered.port });
358
+ const probe = await (options.probe ?? verifyDevToolsReachable)({ port: discovered.port });
353
359
  if (!probe.ok) {
354
360
  logger(`Discovered Chrome for ${userDataDir} on port ${discovered.port} but it was unreachable (${probe.error}); launching new Chrome.`);
361
+ await cleanupStaleProfileState(userDataDir, logger, {
362
+ lockRemovalMode: "if_oracle_pid_dead",
363
+ });
355
364
  return null;
356
365
  }
357
366
  await writeDevToolsActivePort(userDataDir, discovered.port);
@@ -361,7 +370,7 @@ async function maybeReuseProjectSourcesChrome(userDataDir, logger, options = {})
361
370
  logger(`Discovered running Chrome for ${userDataDir}; reusing (DevTools port ${port}, pid ${pid})`);
362
371
  return { port, pid, kill: async () => { }, process: undefined };
363
372
  }
364
- const probe = await verifyDevToolsReachable({ port });
373
+ const probe = await (options.probe ?? verifyDevToolsReachable)({ port });
365
374
  if (!probe.ok) {
366
375
  logger(`Recorded Chrome DevTools port ${port} is stale (${probe.error}); launching new Chrome.`);
367
376
  await cleanupStaleProfileState(userDataDir, logger, { lockRemovalMode: "if_oracle_pid_dead" });
@@ -375,3 +384,6 @@ async function maybeReuseProjectSourcesChrome(userDataDir, logger, options = {})
375
384
  process: undefined,
376
385
  };
377
386
  }
387
+ export async function maybeReuseProjectSourcesChromeForTest(userDataDir, logger, options = {}) {
388
+ return maybeReuseProjectSourcesChrome(userDataDir, logger, options);
389
+ }
@@ -1,7 +1,7 @@
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, 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";
@@ -35,20 +35,17 @@ export function isMediaFile(filePath) {
35
35
  const ext = path.extname(filePath).toLowerCase();
36
36
  return MEDIA_EXTENSIONS.has(ext);
37
37
  }
38
- function formatSectionsForBundle(sections) {
39
- const bundleLines = [];
40
- sections.forEach((section) => {
41
- bundleLines.push(formatFileSection(section.displayPath, section.content).trimEnd());
42
- bundleLines.push("");
38
+ function formatSectionsForBundle(sections, options = {}) {
39
+ return formatFileSections(sections, {
40
+ lineNumbers: options.lineNumbers ?? true,
41
+ trailingNewline: true,
43
42
  });
44
- return `${bundleLines
45
- .join("\n")
46
- .replace(/\n{3,}/g, "\n\n")
47
- .trimEnd()}\n`;
48
43
  }
49
44
  async function writeBrowserBundle(sections, format) {
50
45
  const bundleDir = await fs.mkdtemp(path.join(os.tmpdir(), "oracle-browser-bundle-"));
51
- const tokenEstimateText = formatSectionsForBundle(sections);
46
+ const tokenEstimateText = formatSectionsForBundle(sections, {
47
+ lineNumbers: format === "text",
48
+ });
52
49
  if (format === "zip") {
53
50
  const bundlePath = path.join(bundleDir, "attachments-bundle.zip");
54
51
  const buffer = createStoredZip(sections.map((section) => ({
@@ -61,6 +58,7 @@ async function writeBrowserBundle(sections, format) {
61
58
  path: bundlePath,
62
59
  displayPath: bundlePath,
63
60
  sizeBytes: buffer.length,
61
+ generatedBundle: true,
64
62
  },
65
63
  metadata: { originalCount: sections.length, bundlePath, format },
66
64
  tokenEstimateText,
@@ -73,6 +71,7 @@ async function writeBrowserBundle(sections, format) {
73
71
  path: bundlePath,
74
72
  displayPath: bundlePath,
75
73
  sizeBytes: Buffer.byteLength(tokenEstimateText, "utf8"),
74
+ generatedBundle: true,
76
75
  },
77
76
  metadata: { originalCount: sections.length, bundlePath, format },
78
77
  tokenEstimateText,
@@ -161,10 +160,7 @@ export async function assembleBrowserPrompt(runOptions, deps = {}) {
161
160
  let estimatedInputTokens = tokenizer(tokenizerMessages.length > 0 ? tokenizerMessages : [{ role: "user", content: "" }], TOKENIZER_OPTIONS);
162
161
  const tokenEstimateIncludesInlineFiles = inlineFileCount > 0 && Boolean(selectedPlan.inlineBlock);
163
162
  if (!tokenEstimateIncludesInlineFiles && sections.length > 0) {
164
- const attachmentText = bundleText ??
165
- sections
166
- .map((section) => formatFileSection(section.displayPath, section.content).trimEnd())
167
- .join("\n\n");
163
+ const attachmentText = bundleText ?? formatFileSections(sections, { lineNumbers: false });
168
164
  const attachmentTokens = tokenizer([{ role: "user", content: attachmentText }], TOKENIZER_OPTIONS);
169
165
  estimatedInputTokens += attachmentTokens;
170
166
  }
@@ -23,6 +23,7 @@ async function submitPromptViaAdapter(ctx) {
23
23
  attachmentNames: state.attachmentNames ?? [],
24
24
  baselineTurns: state.baselineTurns ?? undefined,
25
25
  inputTimeoutMs: state.inputTimeoutMs ?? undefined,
26
+ attachmentTimeoutMs: state.attachmentTimeoutMs ?? undefined,
26
27
  onPromptSubmitted: state.onPromptSubmitted,
27
28
  }, ctx.prompt, state.logger);
28
29
  state.committedTurns =
@@ -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,
@@ -7,8 +7,9 @@ import { checkTcpConnection, checkRemoteHealth } from "../../remote/health.js";
7
7
  import { detectChromeBinary, detectChromeCookieDb } from "../../browser/detect.js";
8
8
  import { formatCodexMcpSnippet } from "./codexConfig.js";
9
9
  export async function runBridgeDoctor(_options) {
10
- const { config: userConfig, path: configPath, loaded } = await loadUserConfig();
10
+ const { config: userConfig, path: configPath, paths: configPaths, loaded: userConfigLoaded, } = await loadUserConfig();
11
11
  const version = getCliVersion();
12
+ const projectConfigPaths = configPaths.filter((entry) => entry !== configPath);
12
13
  const resolvedRemote = resolveRemoteServiceConfig({
13
14
  cliHost: undefined,
14
15
  cliToken: undefined,
@@ -22,7 +23,11 @@ export async function runBridgeDoctor(_options) {
22
23
  lines.push(chalk.dim(`OS: ${process.platform} ${os.release()} (${process.arch})`));
23
24
  lines.push(chalk.dim(`Node: ${process.version}`));
24
25
  lines.push(chalk.dim(`Oracle: ${version}`));
25
- lines.push(chalk.dim(`Config: ${loaded ? configPath : "(missing)"}`));
26
+ lines.push(chalk.dim(`Config: ${userConfigLoaded ? configPath : `${configPath} (missing)`}`));
27
+ if (projectConfigPaths.length > 0) {
28
+ const label = projectConfigPaths.length === 1 ? "Project config" : "Project configs";
29
+ lines.push(chalk.dim(`${label}: ${projectConfigPaths.join(", ")}`));
30
+ }
26
31
  if (userConfig.engine) {
27
32
  lines.push(chalk.dim(`Default engine: ${userConfig.engine}`));
28
33
  }
@@ -7,6 +7,7 @@ import { normalizeBrowserModelStrategy } from "../browser/modelStrategy.js";
7
7
  import { getOracleHomeDir } from "../oracleHome.js";
8
8
  const DEFAULT_BROWSER_TIMEOUT_MS = 1_200_000;
9
9
  const DEFAULT_BROWSER_INPUT_TIMEOUT_MS = 60_000;
10
+ const DEFAULT_BROWSER_ATTACHMENT_TIMEOUT_MS = 45_000;
10
11
  const DEFAULT_BROWSER_RECHECK_TIMEOUT_MS = 120_000;
11
12
  const DEFAULT_BROWSER_AUTO_REATTACH_TIMEOUT_MS = 120_000;
12
13
  const DEFAULT_CHROME_PROFILE = "Default";
@@ -15,6 +16,7 @@ const DEFAULT_CHROME_PROFILE = "Default";
15
16
  const BROWSER_MODEL_LABELS = [
16
17
  // Most specific first (e.g., "gpt-5.2-thinking" before "gpt-5.2")
17
18
  ["gpt-5.5-pro", "Pro"],
19
+ ["gpt-5.5-instant", "GPT-5.5 Instant"],
18
20
  ["gpt-5.5", "Thinking 5.5"],
19
21
  ["gpt-5.4-pro", "Pro"],
20
22
  ["gpt-5.2-thinking", "GPT-5.2 Thinking"],
@@ -26,7 +28,10 @@ const BROWSER_MODEL_LABELS = [
26
28
  ["gpt-5.4", "Thinking 5.4"],
27
29
  ["gpt-5.2", "GPT-5.2"], // Selects "Auto" in ChatGPT UI
28
30
  ["gpt-5.1", "GPT-5.2"], // Legacy alias → Auto
29
- ["gemini-3-pro", "Gemini 3 Pro"],
31
+ ["gemini-3.1-flash-lite", "Gemini 3.1 Flash-Lite"],
32
+ ["gemini-3.5-flash", "Gemini 3.5 Flash"],
33
+ ["gemini-3.1-pro", "Gemini 3.1 Pro"],
34
+ ["gemini-3-pro", "Gemini 3.1 Pro"],
30
35
  ["gemini-3-pro-deep-think", "gemini-3-deep-think"],
31
36
  ];
32
37
  export function normalizeChatGptModelForBrowser(model) {
@@ -34,7 +39,10 @@ export function normalizeChatGptModelForBrowser(model) {
34
39
  if (!normalized.startsWith("gpt-") || normalized.includes("codex")) {
35
40
  return model;
36
41
  }
37
- if (normalized === "gpt-5.5-pro" || normalized === "gpt-5.5" || normalized === "gpt-5.4") {
42
+ if (normalized === "gpt-5.5-pro" ||
43
+ normalized === "gpt-5.5-instant" ||
44
+ normalized === "gpt-5.5" ||
45
+ normalized === "gpt-5.4") {
38
46
  return normalized;
39
47
  }
40
48
  // Pro variants: resolve to the latest Pro model in ChatGPT.
@@ -101,6 +109,9 @@ export async function buildBrowserConfig(options) {
101
109
  inputTimeoutMs: options.browserInputTimeout
102
110
  ? parseDuration(options.browserInputTimeout, DEFAULT_BROWSER_INPUT_TIMEOUT_MS)
103
111
  : undefined,
112
+ attachmentTimeoutMs: options.browserAttachmentTimeout
113
+ ? parseDuration(options.browserAttachmentTimeout, DEFAULT_BROWSER_ATTACHMENT_TIMEOUT_MS)
114
+ : undefined,
104
115
  assistantRecheckDelayMs: options.browserRecheckDelay
105
116
  ? parseDuration(options.browserRecheckDelay, 0)
106
117
  : undefined,
@@ -43,6 +43,9 @@ export function applyBrowserDefaultsFromConfig(options, config, getSource) {
43
43
  if (isUnset("browserInputTimeout") && typeof browser.inputTimeoutMs === "number") {
44
44
  options.browserInputTimeout = String(browser.inputTimeoutMs);
45
45
  }
46
+ if (isUnset("browserAttachmentTimeout") && typeof browser.attachmentTimeoutMs === "number") {
47
+ options.browserAttachmentTimeout = String(browser.attachmentTimeoutMs);
48
+ }
46
49
  if (isUnset("browserRecheckDelay") && typeof browser.assistantRecheckDelayMs === "number") {
47
50
  options.browserRecheckDelay = String(browser.assistantRecheckDelayMs);
48
51
  }
@@ -3,6 +3,7 @@ import { createHash } from "node:crypto";
3
3
  import chalk from "chalk";
4
4
  import { sessionStore } from "../sessionStore.js";
5
5
  import { collectChatGptTabs, DEFAULT_REMOTE_CHROME_HOST, DEFAULT_REMOTE_CHROME_PORT, extractConversationIdFromUrl, formatBrowserTabState, harvestChatGptTab, sessionMatchesTab, } from "../browser/liveTabs.js";
6
+ import { recoverConversationTab } from "../browser/recoverConversation.js";
6
7
  import { resolveOutputPath } from "./writeOutputPath.js";
7
8
  const LIVE_POLL_MS = 2000;
8
9
  const DEFAULT_STALL_THRESHOLD_MS = 60_000;
@@ -149,80 +150,154 @@ export async function harvestSessionBrowserOutput(sessionId, options = {}) {
149
150
  if (!meta) {
150
151
  throw new Error(`No session found with ID ${sessionId}.`);
151
152
  }
152
- const endpoint = sessionBrowserEndpoint(meta) ?? {
153
+ const initialEndpoint = sessionBrowserEndpoint(meta) ?? {
153
154
  host: DEFAULT_REMOTE_CHROME_HOST,
154
155
  port: DEFAULT_REMOTE_CHROME_PORT,
155
156
  };
156
- const harvested = await harvestChatGptTab({
157
- host: endpoint.host,
158
- port: endpoint.port,
159
- ref: options.browserTabRef ?? resolveSessionTabRef(meta),
160
- stallWindowMs: options.stallWindowMs,
161
- });
162
- await persistHarvest(sessionId, meta, harvested);
163
- printHarvestSummary(sessionId, harvested);
164
- const output = harvested.lastAssistantMarkdown ?? harvested.lastAssistantText ?? "";
165
- if (options.writeOutputPath) {
166
- await maybeWriteHarvestOutput(options.writeOutputPath, meta.cwd ?? process.cwd(), output);
157
+ const ref = options.browserTabRef ?? resolveSessionTabRef(meta);
158
+ const recoverIfMissing = options.recoverIfMissing !== false;
159
+ let recoveredChrome = null;
160
+ try {
161
+ let harvested;
162
+ try {
163
+ harvested = await harvestChatGptTab({
164
+ host: initialEndpoint.host,
165
+ port: initialEndpoint.port,
166
+ ref,
167
+ stallWindowMs: options.stallWindowMs,
168
+ });
169
+ }
170
+ catch (error) {
171
+ const message = error instanceof Error ? error.message : String(error);
172
+ const isMissingTabError = message.includes("No ChatGPT tab matched") ||
173
+ message.includes("ECONNREFUSED") ||
174
+ message.includes("Could not connect");
175
+ if (!isMissingTabError || !recoverIfMissing) {
176
+ throw error;
177
+ }
178
+ console.log(chalk.yellow(`No live ChatGPT tab matched session "${sessionId}". Attempting recovery by reopening the saved conversation URL.`));
179
+ const recovered = await recoverConversationTab(meta, (line) => console.log(line));
180
+ recoveredChrome = recovered.chrome;
181
+ harvested = await harvestChatGptTab({
182
+ host: recovered.host,
183
+ port: recovered.port,
184
+ ref: recovered.url,
185
+ stallWindowMs: options.stallWindowMs,
186
+ });
187
+ }
188
+ await persistHarvest(sessionId, meta, harvested);
189
+ printHarvestSummary(sessionId, harvested);
190
+ const output = harvested.lastAssistantMarkdown ?? harvested.lastAssistantText ?? "";
191
+ if (options.writeOutputPath) {
192
+ await maybeWriteHarvestOutput(options.writeOutputPath, meta.cwd ?? process.cwd(), output);
193
+ }
194
+ if (!options.quietOutput && output) {
195
+ process.stdout.write(`${output}${output.endsWith("\n") ? "" : "\n"}`);
196
+ }
197
+ return harvested;
167
198
  }
168
- if (!options.quietOutput && output) {
169
- process.stdout.write(`${output}${output.endsWith("\n") ? "" : "\n"}`);
199
+ finally {
200
+ if (recoveredChrome && options.closeAfterRecover) {
201
+ try {
202
+ recoveredChrome.kill();
203
+ }
204
+ catch {
205
+ // best-effort cleanup
206
+ }
207
+ }
170
208
  }
171
- return harvested;
172
209
  }
173
210
  export async function liveTailSessionBrowserOutput(sessionId, options = {}) {
174
211
  const meta = await sessionStore.readSession(sessionId);
175
212
  if (!meta) {
176
213
  throw new Error(`No session found with ID ${sessionId}.`);
177
214
  }
178
- const endpoint = sessionBrowserEndpoint(meta) ?? {
215
+ let endpoint = sessionBrowserEndpoint(meta) ?? {
179
216
  host: DEFAULT_REMOTE_CHROME_HOST,
180
217
  port: DEFAULT_REMOTE_CHROME_PORT,
181
218
  };
182
- const browserTabRef = options.browserTabRef ?? resolveSessionTabRef(meta);
219
+ let browserTabRef = options.browserTabRef ?? resolveSessionTabRef(meta);
220
+ const recoverIfMissing = options.recoverIfMissing !== false;
221
+ let recoveredChrome = null;
183
222
  const stallThresholdMs = options.stallThresholdMs ?? DEFAULT_STALL_THRESHOLD_MS;
184
223
  let lastHash = null;
185
224
  let unchangedSince = Date.now();
186
- while (true) {
187
- const harvested = await harvestChatGptTab({
188
- host: endpoint.host,
189
- port: endpoint.port,
190
- ref: browserTabRef,
191
- });
192
- const fullText = harvested.lastAssistantMarkdown ?? harvested.lastAssistantText ?? "";
193
- const hash = createHash("sha1").update(fullText).digest("hex");
194
- if (hash !== lastHash) {
195
- lastHash = hash;
196
- unchangedSince = Date.now();
197
- const statusLine = `[${new Date().toISOString()}] state=${harvested.state} stop=${harvested.stopExists ? "yes" : "no"} ` +
198
- `send=${harvested.sendExists ? "yes" : "no"} model=${harvested.currentModelLabel || "(unknown)"} ` +
199
- `snippet=${snippet(harvested.lastAssistantSnippet || fullText, 160)}`;
200
- console.log(statusLine);
201
- await persistHarvest(sessionId, meta, harvested);
225
+ try {
226
+ // Probe once to see if the live tab is still alive; recover if not.
227
+ try {
228
+ await harvestChatGptTab({
229
+ host: endpoint.host,
230
+ port: endpoint.port,
231
+ ref: browserTabRef,
232
+ });
233
+ }
234
+ catch (error) {
235
+ const message = error instanceof Error ? error.message : String(error);
236
+ const isMissingTabError = message.includes("No ChatGPT tab matched") ||
237
+ message.includes("ECONNREFUSED") ||
238
+ message.includes("Could not connect");
239
+ if (!isMissingTabError || !recoverIfMissing) {
240
+ throw error;
241
+ }
242
+ console.log(chalk.yellow(`No live ChatGPT tab matched session "${sessionId}". Attempting recovery by reopening the saved conversation URL.`));
243
+ const recovered = await recoverConversationTab(meta, (line) => console.log(line));
244
+ recoveredChrome = recovered.chrome;
245
+ endpoint = { host: recovered.host, port: recovered.port };
246
+ browserTabRef = recovered.url;
202
247
  }
203
- const derivedState = harvested.stopExists
204
- ? Date.now() - unchangedSince >= stallThresholdMs
205
- ? "stalled"
206
- : "running"
207
- : harvested.authenticated
208
- ? "completed"
209
- : "detached";
210
- if (derivedState === "completed" || derivedState === "stalled" || derivedState === "detached") {
211
- const finalHarvest = {
212
- ...harvested,
213
- state: derivedState,
214
- };
215
- await persistHarvest(sessionId, meta, finalHarvest);
216
- printHarvestSummary(sessionId, finalHarvest);
217
- const output = finalHarvest.lastAssistantMarkdown ?? finalHarvest.lastAssistantText ?? "";
218
- if (options.writeOutputPath) {
219
- await maybeWriteHarvestOutput(options.writeOutputPath, meta.cwd ?? process.cwd(), output);
248
+ while (true) {
249
+ const harvested = await harvestChatGptTab({
250
+ host: endpoint.host,
251
+ port: endpoint.port,
252
+ ref: browserTabRef,
253
+ });
254
+ const fullText = harvested.lastAssistantMarkdown ?? harvested.lastAssistantText ?? "";
255
+ const hash = createHash("sha1").update(fullText).digest("hex");
256
+ if (hash !== lastHash) {
257
+ lastHash = hash;
258
+ unchangedSince = Date.now();
259
+ const statusLine = `[${new Date().toISOString()}] state=${harvested.state} stop=${harvested.stopExists ? "yes" : "no"} ` +
260
+ `send=${harvested.sendExists ? "yes" : "no"} model=${harvested.currentModelLabel || "(unknown)"} ` +
261
+ `snippet=${snippet(harvested.lastAssistantSnippet || fullText, 160)}`;
262
+ console.log(statusLine);
263
+ await persistHarvest(sessionId, meta, harvested);
264
+ }
265
+ const derivedState = harvested.stopExists
266
+ ? Date.now() - unchangedSince >= stallThresholdMs
267
+ ? "stalled"
268
+ : "running"
269
+ : harvested.authenticated
270
+ ? "completed"
271
+ : "detached";
272
+ if (derivedState === "completed" ||
273
+ derivedState === "stalled" ||
274
+ derivedState === "detached") {
275
+ const finalHarvest = {
276
+ ...harvested,
277
+ state: derivedState,
278
+ };
279
+ await persistHarvest(sessionId, meta, finalHarvest);
280
+ printHarvestSummary(sessionId, finalHarvest);
281
+ const output = finalHarvest.lastAssistantMarkdown ?? finalHarvest.lastAssistantText ?? "";
282
+ if (options.writeOutputPath) {
283
+ await maybeWriteHarvestOutput(options.writeOutputPath, meta.cwd ?? process.cwd(), output);
284
+ }
285
+ if (output) {
286
+ process.stdout.write(`${output}${output.endsWith("\n") ? "" : "\n"}`);
287
+ }
288
+ return finalHarvest;
289
+ }
290
+ await new Promise((resolve) => setTimeout(resolve, LIVE_POLL_MS));
291
+ }
292
+ }
293
+ finally {
294
+ if (recoveredChrome && options.closeAfterRecover) {
295
+ try {
296
+ recoveredChrome.kill();
220
297
  }
221
- if (output) {
222
- process.stdout.write(`${output}${output.endsWith("\n") ? "" : "\n"}`);
298
+ catch {
299
+ // best-effort cleanup
223
300
  }
224
- return finalHarvest;
225
301
  }
226
- await new Promise((resolve) => setTimeout(resolve, LIVE_POLL_MS));
227
302
  }
228
303
  }
@@ -14,9 +14,10 @@ export function defaultWaitPreference(model, engine) {
14
14
  * 2) Explicit --engine value.
15
15
  * 3) Explicit API provider routing flags force API.
16
16
  * 4) ORACLE_ENGINE environment override (api|browser).
17
- * 5) API environment decides: api when set, otherwise browser.
17
+ * 5) Config engine value.
18
+ * 6) API environment decides: api when set, otherwise browser.
18
19
  */
19
- export function resolveEngine({ engine, browserFlag, apiProviderRequested, env, }) {
20
+ export function resolveEngine({ engine, configEngine, browserFlag, apiProviderRequested, env, }) {
20
21
  if (browserFlag) {
21
22
  return "browser";
22
23
  }
@@ -30,6 +31,9 @@ export function resolveEngine({ engine, browserFlag, apiProviderRequested, env,
30
31
  if (envEngine) {
31
32
  return envEngine;
32
33
  }
34
+ if (configEngine) {
35
+ return configEngine;
36
+ }
33
37
  return hasApiEnvironment(env) ? "api" : "browser";
34
38
  }
35
39
  function hasApiEnvironment(env) {