@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
@@ -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.`,
@@ -3,6 +3,7 @@ import { parseDuration } from "../duration.js";
3
3
  import path from "node:path";
4
4
  import fg from "fast-glob";
5
5
  import { DEFAULT_MODEL, MODEL_CONFIGS } from "../oracle/config.js";
6
+ import { normalizeThinkingTimeLevel } from "../oracle/thinkingTime.js";
6
7
  export function collectPaths(value, previous = []) {
7
8
  if (!value) {
8
9
  return previous;
@@ -127,6 +128,13 @@ export function parseSearchOption(value) {
127
128
  }
128
129
  throw new InvalidArgumentError('Search mode must be "on" or "off".');
129
130
  }
131
+ export function parseThinkingTimeOption(value) {
132
+ const normalized = normalizeThinkingTimeLevel(value);
133
+ if (normalized) {
134
+ return normalized;
135
+ }
136
+ throw new InvalidArgumentError('Thinking time must be one of "light", "standard", "extended", "heavy", or a ChatGPT UI alias like "instant", "medium", "high", or "extra-high".');
137
+ }
130
138
  export function normalizeModelOption(value) {
131
139
  return (value ?? "").trim();
132
140
  }
@@ -228,6 +236,14 @@ export function resolveApiModel(modelValue) {
228
236
  throw new InvalidArgumentError("Gemini Deep Think is browser-only today. Use --engine browser --model gemini-3-deep-think.");
229
237
  }
230
238
  if (normalized.includes("gemini")) {
239
+ if (normalized.includes("3.5") && normalized.includes("flash")) {
240
+ return "gemini-3.5-flash";
241
+ }
242
+ if ((normalized.includes("3.1") || normalized.includes("3_1")) &&
243
+ normalized.includes("flash") &&
244
+ normalized.includes("lite")) {
245
+ return "gemini-3.1-flash-lite";
246
+ }
231
247
  if (normalized.includes("3.1") || normalized.includes("3_1")) {
232
248
  return "gemini-3.1-pro";
233
249
  }
@@ -266,6 +282,14 @@ export function inferModelFromLabel(modelValue) {
266
282
  return "gemini-3-pro-deep-think";
267
283
  }
268
284
  if (normalized.includes("gemini")) {
285
+ if (normalized.includes("3.5") && normalized.includes("flash")) {
286
+ return "gemini-3.5-flash";
287
+ }
288
+ if ((normalized.includes("3.1") || normalized.includes("3_1")) &&
289
+ normalized.includes("flash") &&
290
+ normalized.includes("lite")) {
291
+ return "gemini-3.1-flash-lite";
292
+ }
269
293
  if (normalized.includes("3.1") || normalized.includes("3_1")) {
270
294
  return "gemini-3.1-pro";
271
295
  }
@@ -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
+ }
@@ -28,16 +28,11 @@ export function resolveRunOptionsFromConfig({ prompt, files = [], model, models,
28
28
  const isCodex = apiModel.startsWith("gpt-5.1-codex");
29
29
  const isClaude = apiModel.startsWith("claude");
30
30
  const isGrok = apiModel.startsWith("grok");
31
- const isGeminiApiOnly = apiModel === "gemini-3.1-pro";
32
31
  const engineWasBrowser = resolvedEngine === "browser";
33
32
  const allModels = normalizedRequestedModels.length > 0
34
33
  ? Array.from(new Set(normalizedRequestedModels.map((entry) => resolveApiModel(entry))))
35
34
  : [apiModel];
36
35
  const browserCompatibilityModels = normalizedRequestedModels.length > 0 ? allModels : [browserModel];
37
- const includesGeminiApiOnly = allModels.some((m) => m === "gemini-3.1-pro");
38
- if (browserEngineRequested && includesGeminiApiOnly) {
39
- throw new PromptValidationError("gemini-3.1-pro is API-only today. Use --engine api or switch to gemini-3-pro for Gemini web.", { engine: "browser", models: allModels });
40
- }
41
36
  const isBrowserCompatible = (m) => m.startsWith("gpt-") || m.startsWith("gemini");
42
37
  const hasNonBrowserCompatibleTarget = browserEngineRequested && browserCompatibilityModels.some((m) => !isBrowserCompatible(m));
43
38
  if (hasNonBrowserCompatibleTarget) {
@@ -47,13 +42,8 @@ export function resolveRunOptionsFromConfig({ prompt, files = [], model, models,
47
42
  const azureAutoApi = Boolean(azure?.endpoint) &&
48
43
  !browserEngineRequested &&
49
44
  allModels.some(isAzureOpenAICandidateModel);
50
- const engineCoercedToApi = engineWasBrowser && (isCodex || isClaude || isGrok || isGeminiApiOnly || azureAutoApi);
51
- const fixedEngine = isCodex ||
52
- isClaude ||
53
- isGrok ||
54
- isGeminiApiOnly ||
55
- azureAutoApi ||
56
- normalizedRequestedModels.length > 0
45
+ const engineCoercedToApi = engineWasBrowser && (isCodex || isClaude || isGrok || azureAutoApi);
46
+ const fixedEngine = isCodex || isClaude || isGrok || azureAutoApi || normalizedRequestedModels.length > 0
57
47
  ? "api"
58
48
  : resolvedEngine;
59
49
  // Browser runs use ChatGPT picker labels/aliases; API runs must keep API model ids intact.
@@ -109,16 +109,20 @@ export async function handleSessionCommand(sessionId, command, deps = defaultDep
109
109
  process.exitCode = 1;
110
110
  return;
111
111
  }
112
+ // Commander sets `recover: false` when --no-recover is passed; default is `true`.
113
+ const recoverIfMissing = sessionOptions.recover !== false;
112
114
  if (harvestRequested) {
113
115
  await deps.harvestSessionBrowserOutput(sessionId, {
114
116
  writeOutputPath,
115
117
  browserTabRef,
118
+ recoverIfMissing,
116
119
  });
117
120
  return;
118
121
  }
119
122
  await deps.liveTailSessionBrowserOutput(sessionId, {
120
123
  writeOutputPath,
121
124
  browserTabRef,
125
+ recoverIfMissing,
122
126
  });
123
127
  return;
124
128
  }
@@ -628,6 +628,14 @@ function matchesModel(entry, filter) {
628
628
  (entry.model ? [entry.model.toLowerCase()] : []);
629
629
  return models.includes(normalized);
630
630
  }
631
+ function formatLineageParentLabel(lineage) {
632
+ if (!lineage?.parentSessionId) {
633
+ return undefined;
634
+ }
635
+ return lineage.parentResponseId
636
+ ? `${lineage.parentSessionId} (${abbreviateResponseId(lineage.parentResponseId)})`
637
+ : lineage.parentSessionId;
638
+ }
631
639
  function buildStatusTreeRows(entries, responseOwners) {
632
640
  const entryById = new Map(entries.map((entry) => [entry.id, entry]));
633
641
  const orderIndex = new Map(entries.map((entry, index) => [entry.id, index]));
@@ -668,7 +676,7 @@ function buildStatusTreeRows(entries, responseOwners) {
668
676
  visited.add(entry.id);
669
677
  const lineage = lineageById.get(entry.id);
670
678
  const hiddenParent = lineage?.parentSessionId && !entryById.has(lineage.parentSessionId)
671
- ? `${lineage.parentSessionId} (${abbreviateResponseId(lineage.parentResponseId)})`
679
+ ? formatLineageParentLabel(lineage)
672
680
  : undefined;
673
681
  const children = childMap.get(entry.id) ?? [];
674
682
  rows.push({ entry, displaySlug: entry.id, detachedParentLabel: hiddenParent });
@@ -694,13 +702,19 @@ async function buildSessionChainLine(metadata) {
694
702
  return `root -> ${metadata.id}`;
695
703
  }
696
704
  if (lineageWithoutLookup.parentSessionId) {
697
- return `${lineageWithoutLookup.parentSessionId} (${abbreviateResponseId(lineageWithoutLookup.parentResponseId)}) -> ${metadata.id}`;
705
+ return `${formatLineageParentLabel(lineageWithoutLookup)} -> ${metadata.id}`;
706
+ }
707
+ if (!lineageWithoutLookup.parentResponseId) {
708
+ return `root -> ${metadata.id}`;
698
709
  }
699
710
  const sessions = await sessionStore.listSessions().catch(() => []);
700
711
  const responseOwners = buildResponseOwnerIndex(sessions);
701
712
  const lineage = resolveSessionLineage(metadata, responseOwners) ?? lineageWithoutLookup;
702
713
  if (lineage.parentSessionId) {
703
- return `${lineage.parentSessionId} (${abbreviateResponseId(lineage.parentResponseId)}) -> ${metadata.id}`;
714
+ return `${formatLineageParentLabel(lineage)} -> ${metadata.id}`;
715
+ }
716
+ if (!lineage.parentResponseId) {
717
+ return `root -> ${metadata.id}`;
704
718
  }
705
719
  return `${abbreviateResponseId(lineage.parentResponseId)} -> ${metadata.id}`;
706
720
  }
@@ -39,17 +39,20 @@ export function buildResponseOwnerIndex(sessions) {
39
39
  }
40
40
  export function resolveSessionLineage(meta, responseOwners) {
41
41
  const previous = meta.options?.previousResponseId?.trim();
42
- if (!previous) {
42
+ let parentSessionId = meta.options?.followupSessionId?.trim();
43
+ if (!previous && !parentSessionId) {
43
44
  return null;
44
45
  }
45
- let parentSessionId = meta.options?.followupSessionId?.trim();
46
- if (!parentSessionId && responseOwners) {
46
+ if (!parentSessionId && previous && responseOwners) {
47
47
  parentSessionId = responseOwners.get(previous);
48
48
  }
49
49
  if (parentSessionId === meta.id) {
50
50
  parentSessionId = undefined;
51
51
  }
52
- return { parentResponseId: previous, parentSessionId };
52
+ return {
53
+ parentResponseId: previous || undefined,
54
+ parentSessionId,
55
+ };
53
56
  }
54
57
  export function abbreviateResponseId(responseId, max = 18) {
55
58
  if (responseId.length <= max) {
@@ -24,6 +24,7 @@ import { resumeBrowserSession } from "../browser/reattach.js";
24
24
  import { hasRecoverableChatGptConversation } from "../browser/reattachability.js";
25
25
  import { estimateTokenCount } from "../browser/utils.js";
26
26
  import { formatElapsed } from "../oracle/format.js";
27
+ import { formatBrowserReattachGuidance } from "./reattachGuidance.js";
27
28
  const isTty = process.stdout.isTTY;
28
29
  const dim = (text) => (isTty ? kleur.dim(text) : text);
29
30
  export async function performSessionRun({ sessionMeta, runOptions, mode, browserConfig, cwd, log, write, version, notifications, browserDeps, muteStdout = false, }) {
@@ -388,6 +389,16 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
388
389
  userError.details?.stage === "assistant-timeout";
389
390
  const cloudflareChallenge = userError?.category === "browser-automation" &&
390
391
  userError.details?.stage === "cloudflare-challenge";
392
+ let reattachGuidanceLogged = false;
393
+ const logBrowserReattachGuidance = (runtime) => {
394
+ if (reattachGuidanceLogged || mode !== "browser")
395
+ return;
396
+ if (!hasRecoverableChatGptConversation(runtime) && runtime?.promptSubmitted !== true) {
397
+ return;
398
+ }
399
+ reattachGuidanceLogged = true;
400
+ log(formatBrowserReattachGuidance(sessionMeta.id));
401
+ };
391
402
  if (connectionLost && mode === "browser") {
392
403
  const runtime = userError.details
393
404
  ?.runtime;
@@ -442,6 +453,7 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
442
453
  },
443
454
  response: { status: "running", incompleteReason: "chrome-disconnected" },
444
455
  });
456
+ logBrowserReattachGuidance(recoverableRuntime);
445
457
  return;
446
458
  }
447
459
  if (assistantTimeout && mode === "browser") {
@@ -492,7 +504,7 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
492
504
  return;
493
505
  }
494
506
  }
495
- log(dim(`Reattach later with: oracle session ${sessionMeta.id}`));
507
+ logBrowserReattachGuidance(runtime ?? sessionMeta.browser?.runtime);
496
508
  return;
497
509
  }
498
510
  if (cloudflareChallenge && mode === "browser") {
@@ -518,6 +530,9 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
518
530
  const browserRuntime = mode === "browser"
519
531
  ? userError?.details?.runtime
520
532
  : undefined;
533
+ if (!cloudflareChallenge) {
534
+ logBrowserReattachGuidance(browserRuntime ?? sessionMeta.browser?.runtime);
535
+ }
521
536
  await sessionStore.updateSession(sessionMeta.id, {
522
537
  status: "error",
523
538
  completedAt: new Date().toISOString(),
@@ -1,13 +1,8 @@
1
1
  import { mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { buildGeminiWebModelHeader, FALLBACK_GEMINI_WEB_MODEL, } from "./models.js";
3
4
  const USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
4
5
  const MODEL_HEADER_NAME = "x-goog-ext-525001261-jspb";
5
- const MODEL_HEADERS = {
6
- "gemini-3-pro": '[1,null,null,null,"9d8ca3786ebdfbea",null,null,0,[4]]',
7
- "gemini-3-pro-deep-think": '[1,null,null,null,"e6fa609c3fa255c0",null,null,0,[4],null,null,3]',
8
- "gemini-2.5-pro": '[1,null,null,null,"4af6c7f5da75d65d",null,null,0,[4]]',
9
- "gemini-2.5-flash": '[1,null,null,null,"9ec249fc9ad08861",null,null,0,[4]]',
10
- };
11
6
  const GEMINI_APP_URL = "https://gemini.google.com/app";
12
7
  const GEMINI_STREAM_GENERATE_URL = "https://gemini.google.com/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate";
13
8
  const GEMINI_UPLOAD_URL = "https://content-push.googleapis.com/upload";
@@ -276,7 +271,7 @@ export async function runGeminiWebOnce(input) {
276
271
  "x-same-domain": "1",
277
272
  "user-agent": USER_AGENT,
278
273
  cookie: cookieHeader,
279
- [MODEL_HEADER_NAME]: MODEL_HEADERS[input.model],
274
+ [MODEL_HEADER_NAME]: buildGeminiWebModelHeader(input.model),
280
275
  },
281
276
  body: params.toString(),
282
277
  });
@@ -324,9 +319,9 @@ export async function runGeminiWebOnce(input) {
324
319
  }
325
320
  export async function runGeminiWebWithFallback(input) {
326
321
  const attempt = await runGeminiWebOnce(input);
327
- if (isGeminiModelUnavailable(attempt.errorCode) && input.model !== "gemini-2.5-flash") {
328
- const fallback = await runGeminiWebOnce({ ...input, model: "gemini-2.5-flash" });
329
- return { ...fallback, effectiveModel: "gemini-2.5-flash" };
322
+ if (isGeminiModelUnavailable(attempt.errorCode) && input.model !== FALLBACK_GEMINI_WEB_MODEL) {
323
+ const fallback = await runGeminiWebOnce({ ...input, model: FALLBACK_GEMINI_WEB_MODEL });
324
+ return { ...fallback, effectiveModel: FALLBACK_GEMINI_WEB_MODEL };
330
325
  }
331
326
  return { ...attempt, effectiveModel: input.model };
332
327
  }
@@ -4,6 +4,7 @@ import { runProviderDomFlow } from "../browser/providerDomFlow.js";
4
4
  import { delay } from "../browser/utils.js";
5
5
  import { runGeminiWebWithFallback, saveFirstGeminiImageFromOutput } from "./client.js";
6
6
  import { geminiDeepThinkDomProvider } from "../browser/providers/index.js";
7
+ import { resolveGeminiWebModel } from "./models.js";
7
8
  import { openGeminiBrowserSession } from "./browserSessionManager.js";
8
9
  import { selectGeminiExecutionMode } from "./executionMode.js";
9
10
  const GEMINI_COOKIE_NAMES = [
@@ -38,30 +39,6 @@ function resolveInvocationPath(value) {
38
39
  return undefined;
39
40
  return path.isAbsolute(trimmed) ? trimmed : path.resolve(process.cwd(), trimmed);
40
41
  }
41
- function resolveGeminiWebModel(desiredModel, log) {
42
- const desired = typeof desiredModel === "string" ? desiredModel.trim() : "";
43
- if (!desired)
44
- return "gemini-3-pro";
45
- const normalized = desired.toLowerCase().replace(/[_\s]+/g, "-");
46
- switch (normalized) {
47
- case "gemini-3-pro":
48
- case "gemini-3.0-pro":
49
- return "gemini-3-pro";
50
- case "gemini-3-deep-think":
51
- case "gemini-3-pro-deep-think":
52
- case "gemini-3-pro-deepthink":
53
- return "gemini-3-pro-deep-think";
54
- case "gemini-2.5-pro":
55
- return "gemini-2.5-pro";
56
- case "gemini-2.5-flash":
57
- return "gemini-2.5-flash";
58
- default:
59
- if (normalized.startsWith("gemini-") || normalized.includes("gemini")) {
60
- log?.(`[gemini-web] Unsupported Gemini web model "${desired}". Falling back to gemini-3-pro.`);
61
- }
62
- return "gemini-3-pro";
63
- }
64
- }
65
42
  function resolveCookieDomain(cookie) {
66
43
  const rawDomain = cookie.domain?.trim();
67
44
  if (rawDomain) {