@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
@@ -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
  }
@@ -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,
@@ -28,7 +28,10 @@ const BROWSER_MODEL_LABELS = [
28
28
  ["gpt-5.4", "Thinking 5.4"],
29
29
  ["gpt-5.2", "GPT-5.2"], // Selects "Auto" in ChatGPT UI
30
30
  ["gpt-5.1", "GPT-5.2"], // Legacy alias → Auto
31
- ["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"],
32
35
  ["gemini-3-pro-deep-think", "gemini-3-deep-think"],
33
36
  ];
34
37
  export function normalizeChatGptModelForBrowser(model) {
@@ -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
  }
@@ -0,0 +1,72 @@
1
+ import { CHATGPT_URL } from "../browser/constants.js";
2
+ import { buildConversationUrl } from "../browser/reattachHelpers.js";
3
+ import { resolveRecoveryUrl } from "../browser/recoverConversation.js";
4
+ import { isRecoverableChatGptConversationUrl } from "../browser/reattachability.js";
5
+ import { DEFAULT_MODEL } from "../oracle/config.js";
6
+ /**
7
+ * Resolve the ChatGPT conversation URL to reopen for a browser follow-up.
8
+ *
9
+ * Reuses the same recoverable-URL gate as conversation recovery
10
+ * (`resolveRecoveryUrl`): prefer the post-harvest URL, fall back to the
11
+ * runtime tab URL, and reject home / project-shell / external URLs via
12
+ * `isRecoverableChatGptConversationUrl`. Only when neither candidate is a
13
+ * recoverable `chatgpt.com/c/<id>` URL do we rebuild from a stored
14
+ * `conversationId` against the session's ChatGPT base — and that rebuilt URL is
15
+ * gated too. This prevents a stale or attacker-controlled URL in session
16
+ * metadata from navigating the signed-in browser profile somewhere unintended.
17
+ */
18
+ export function resolveBrowserResumeConversationUrl(metadata, fallbackBaseUrl = CHATGPT_URL) {
19
+ const gatedUrl = resolveRecoveryUrl(metadata);
20
+ if (gatedUrl) {
21
+ return gatedUrl;
22
+ }
23
+ const conversationId = metadata.browser?.runtime?.conversationId?.trim();
24
+ if (!conversationId) {
25
+ return null;
26
+ }
27
+ const baseUrl = metadata.browser?.config?.url ?? fallbackBaseUrl;
28
+ const built = buildConversationUrl({ conversationId }, baseUrl);
29
+ if (built && isRecoverableChatGptConversationUrl(built)) {
30
+ return built;
31
+ }
32
+ return null;
33
+ }
34
+ export async function resolveBrowserFollowupReference(value, store) {
35
+ const trimmed = value.trim();
36
+ if (!trimmed || trimmed.startsWith("resp_")) {
37
+ return null;
38
+ }
39
+ const metadata = await store.readSession(trimmed);
40
+ if (!metadata) {
41
+ return null;
42
+ }
43
+ const mode = metadata.mode ?? metadata.options?.mode;
44
+ const hasBrowserMetadata = Boolean(metadata.browser?.runtime || metadata.browser?.config || metadata.options?.browserConfig);
45
+ if (mode !== "browser" && !hasBrowserMetadata) {
46
+ return null;
47
+ }
48
+ const resumeConversationUrl = resolveBrowserResumeConversationUrl(metadata);
49
+ if (!resumeConversationUrl) {
50
+ throw new Error(`Session ${trimmed} is a browser session but does not contain a ChatGPT conversation URL. Run "oracle status --hours 72 --limit 20" to list recent sessions.`);
51
+ }
52
+ const parentBrowserConfig = metadata.options?.browserConfig ?? metadata.browser?.config;
53
+ if (!parentBrowserConfig) {
54
+ throw new Error(`Session ${trimmed} is missing its stored browser configuration.`);
55
+ }
56
+ const storedModel = metadata.options?.model ?? metadata.model;
57
+ const model = typeof storedModel === "string" && storedModel.startsWith("gpt-")
58
+ ? storedModel
59
+ : DEFAULT_MODEL;
60
+ return {
61
+ sessionId: metadata.id,
62
+ resumeConversationUrl,
63
+ model,
64
+ browserConfig: {
65
+ ...parentBrowserConfig,
66
+ browserTabRef: null,
67
+ resumeConversationUrl,
68
+ researchMode: "off",
69
+ archiveConversations: "never",
70
+ },
71
+ };
72
+ }
@@ -49,7 +49,7 @@ function renderHelpFooter(program, colors) {
49
49
  `${colors.bullet("•")} Spell out the project + platform + version requirements (repo name, target OS/toolchain versions, API dependencies) so Oracle doesn’t guess defaults.`,
50
50
  `${colors.bullet("•")} When comparing multiple repos/files, spell out each repo + path + role (e.g., “Project A SettingsView → apps/project-a/Sources/SettingsView.swift; Project B SettingsView → ../project-b/mac/...”) so the model knows exactly which file is which.`,
51
51
  `${colors.bullet("•")} Best results: 6–30 sentences plus key source files; very short prompts often yield generic answers.`,
52
- `${colors.bullet("•")} Oracle is one-shot by default. API runs can continue with ${colors.accent("--followup <sessionId|responseId>")}; browser runs can ask sequential ChatGPT turns with repeated ${colors.accent("--browser-follow-up")}.`,
52
+ `${colors.bullet("•")} Oracle is one-shot by default. Continue saved API or ChatGPT browser sessions with ${colors.accent("--followup <sessionId|responseId>")}; use repeated ${colors.accent("--browser-follow-up")} for planned same-run ChatGPT turns.`,
53
53
  `${colors.bullet("•")} Run ${colors.accent("--files-report")} to inspect token spend before hitting the API.`,
54
54
  `${colors.bullet("•")} Non-preview runs spawn detached sessions (especially gpt-5.5-pro API). If the CLI times out, do not re-run — reattach with ${colors.accent("oracle session <slug>")} to resume/inspect the existing run.`,
55
55
  `${colors.bullet("•")} Set a memorable 3–5 word slug via ${colors.accent('--slug "<words>"')} to keep session IDs tidy.`,
@@ -228,6 +228,14 @@ export function resolveApiModel(modelValue) {
228
228
  throw new InvalidArgumentError("Gemini Deep Think is browser-only today. Use --engine browser --model gemini-3-deep-think.");
229
229
  }
230
230
  if (normalized.includes("gemini")) {
231
+ if (normalized.includes("3.5") && normalized.includes("flash")) {
232
+ return "gemini-3.5-flash";
233
+ }
234
+ if ((normalized.includes("3.1") || normalized.includes("3_1")) &&
235
+ normalized.includes("flash") &&
236
+ normalized.includes("lite")) {
237
+ return "gemini-3.1-flash-lite";
238
+ }
231
239
  if (normalized.includes("3.1") || normalized.includes("3_1")) {
232
240
  return "gemini-3.1-pro";
233
241
  }
@@ -266,6 +274,14 @@ export function inferModelFromLabel(modelValue) {
266
274
  return "gemini-3-pro-deep-think";
267
275
  }
268
276
  if (normalized.includes("gemini")) {
277
+ if (normalized.includes("3.5") && normalized.includes("flash")) {
278
+ return "gemini-3.5-flash";
279
+ }
280
+ if ((normalized.includes("3.1") || normalized.includes("3_1")) &&
281
+ normalized.includes("flash") &&
282
+ normalized.includes("lite")) {
283
+ return "gemini-3.1-flash-lite";
284
+ }
269
285
  if (normalized.includes("3.1") || normalized.includes("3_1")) {
270
286
  return "gemini-3.1-pro";
271
287
  }
@@ -0,0 +1,8 @@
1
+ export function formatBrowserReattachGuidance(sessionId) {
2
+ return [
3
+ "This run did not return cleanly, but it may still be alive. Reattach:",
4
+ ` oracle session ${sessionId} --render # final markdown when complete`,
5
+ ` oracle session ${sessionId} --live # tail until done`,
6
+ ` oracle session ${sessionId} --harvest # snapshot the current answer now`,
7
+ ].join("\n");
8
+ }