@steipete/oracle 0.19.0 → 0.20.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 (41) hide show
  1. package/dist/bin/oracle-cli.js +72 -29
  2. package/dist/src/browser/actions/deepResearch.js +168 -44
  3. package/dist/src/browser/actions/modelSelection.js +78 -1
  4. package/dist/src/browser/actions/promptComposer.js +3 -0
  5. package/dist/src/browser/actions/thinkingTime.js +49 -6
  6. package/dist/src/browser/actions/webSearch.js +96 -0
  7. package/dist/src/browser/chromeLifecycle.js +28 -16
  8. package/dist/src/browser/config.js +1 -1
  9. package/dist/src/browser/index.js +144 -49
  10. package/dist/src/browser/liveTabs.js +11 -4
  11. package/dist/src/browser/profileState.js +6 -1
  12. package/dist/src/browser/promptFingerprint.js +54 -0
  13. package/dist/src/browser/providers/chatgptDomProvider.js +1 -0
  14. package/dist/src/browser/reattach.js +178 -109
  15. package/dist/src/browser/recoveryTarget.js +156 -0
  16. package/dist/src/browser/sessionRunner.js +33 -8
  17. package/dist/src/browser/tabLeaseRegistry.js +20 -0
  18. package/dist/src/browser/targetClaim.js +54 -0
  19. package/dist/src/cli/browserConfig.js +33 -3
  20. package/dist/src/cli/browserDefaults.js +3 -0
  21. package/dist/src/cli/browserTabs.js +38 -3
  22. package/dist/src/cli/detach.js +10 -1
  23. package/dist/src/cli/detachedSession.js +36 -0
  24. package/dist/src/cli/options.js +15 -0
  25. package/dist/src/cli/recoveredBrowserHarvest.js +50 -0
  26. package/dist/src/cli/runOptions.js +19 -4
  27. package/dist/src/cli/sessionDisplay.js +15 -8
  28. package/dist/src/cli/sessionRunner.js +153 -41
  29. package/dist/src/mcp/tools/consult.js +2 -2
  30. package/dist/src/mcp/types.js +1 -1
  31. package/dist/src/oracle/config.js +14 -0
  32. package/dist/src/oracle/geminiModels.js +1 -0
  33. package/dist/src/oracle/run.js +27 -4
  34. package/dist/src/remote/client.js +3 -0
  35. package/dist/src/remote/server.js +2 -0
  36. package/dist/src/sessionManager.js +8 -1
  37. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  38. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  39. package/package.json +10 -10
  40. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  41. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
@@ -4,6 +4,7 @@ import { createHash, randomUUID } from "node:crypto";
4
4
  import { lstat, mkdir, readFile, realpath, rename, rm, rmdir, stat, writeFile, } from "node:fs/promises";
5
5
  import { isProcessAlive, readProcessStartTimeMs } from "./profileState.js";
6
6
  import { delay } from "./utils.js";
7
+ import { normalizeChromeHost } from "./targetClaim.js";
7
8
  export const DEFAULT_MAX_CONCURRENT_CHATGPT_TABS = 3;
8
9
  const REGISTRY_FILENAME = "oracle-tab-leases.json";
9
10
  const REGISTRY_LOCK_DIRNAME = "oracle-tab-leases.lock";
@@ -187,6 +188,25 @@ export async function hasOtherActiveBrowserTabLeases(profileDir, leaseId, option
187
188
  return active.some((lease) => lease.id !== leaseId);
188
189
  });
189
190
  }
191
+ export async function hasActiveBrowserTargetLease(profileDir, target) {
192
+ let raw;
193
+ try {
194
+ raw = await readFile(path.join(profileDir, REGISTRY_FILENAME), "utf8");
195
+ }
196
+ catch (error) {
197
+ if (error.code === "ENOENT")
198
+ return false;
199
+ throw error;
200
+ }
201
+ const registry = JSON.parse(raw);
202
+ if (registry.version !== 1 || !Array.isArray(registry.leases))
203
+ throw new Error("Unknown browser lease registry");
204
+ return registry.leases.some((lease) => lease.chromeTargetId === target.targetId &&
205
+ (!lease.chromeHost ||
206
+ normalizeChromeHost(lease.chromeHost) === normalizeChromeHost(target.host)) &&
207
+ (!lease.chromePort || lease.chromePort === target.port) &&
208
+ isProcessAlive(lease.pid));
209
+ }
190
210
  async function withRegistryLock(profileDir, callback, signal) {
191
211
  const lockDir = path.join(profileDir, REGISTRY_LOCK_DIRNAME);
192
212
  const lockId = randomUUID();
@@ -0,0 +1,54 @@
1
+ import { isIP } from "node:net";
2
+ import { STOP_BUTTON_SELECTORS } from "./constants.js";
3
+ export function normalizeChromeHost(host) {
4
+ const value = host
5
+ .trim()
6
+ .toLowerCase()
7
+ .replace(/^\[|\]$/g, "");
8
+ return value === "localhost" || value === "::1" || (isIP(value) === 4 && value.startsWith("127."))
9
+ ? "loopback"
10
+ : value;
11
+ }
12
+ export function buildTargetClaimExpression(claimId) {
13
+ return `(() => {
14
+ if (window !== window.top) return true;
15
+ try {
16
+ const claim = JSON.parse(sessionStorage.getItem('oracle:target-claim') || 'null');
17
+ if (claim?.retiring) return false;
18
+ sessionStorage.setItem('oracle:target-claim', JSON.stringify({ id: ${JSON.stringify(claimId)}, retiring: false }));
19
+ return true;
20
+ } catch { return false; }
21
+ })()`;
22
+ }
23
+ export function buildTargetRetirementExpression(claimId, conversationId, reservationId, options = {}) {
24
+ return `(() => {
25
+ let claim;
26
+ try { claim = JSON.parse(sessionStorage.getItem('oracle:target-claim') || 'null'); } catch { return false; }
27
+ if (!claim || claim.id !== ${JSON.stringify(claimId)} || claim.retiring) return false;
28
+ const id = location.pathname.match(/\\/c\\/([^/]+)/)?.[1];
29
+ if (id !== ${JSON.stringify(conversationId)}) return false;
30
+ const generating = Array.from(document.querySelectorAll(${JSON.stringify(STOP_BUTTON_SELECTORS.join(","))})).some(node => node.getBoundingClientRect().width > 0 && node.getBoundingClientRect().height > 0);
31
+ if (generating && !${JSON.stringify(options.allowGenerating === true)}) return false;
32
+ claim.retiring = true;
33
+ claim.retirementId = ${JSON.stringify(reservationId)};
34
+ try { sessionStorage.setItem('oracle:target-claim', JSON.stringify(claim)); } catch { return false; }
35
+ return true;
36
+ })()`;
37
+ }
38
+ export function buildTargetRetirementRollbackExpression(claimId, reservationId) {
39
+ return `(() => { try {
40
+ const claim = JSON.parse(sessionStorage.getItem('oracle:target-claim') || 'null');
41
+ if (claim?.id === ${JSON.stringify(claimId)} && claim.retirementId === ${JSON.stringify(reservationId)}) {
42
+ claim.retiring = false;
43
+ delete claim.retirementId;
44
+ sessionStorage.setItem('oracle:target-claim', JSON.stringify(claim));
45
+ }
46
+ } catch {} })()`;
47
+ }
48
+ /** Per-tab storage survives document replacement and serializes acquisition with retirement. */
49
+ export async function claimBrowserTarget(runtime, claimId) {
50
+ const source = buildTargetClaimExpression(claimId);
51
+ const claim = await runtime.evaluate({ expression: source, returnByValue: true });
52
+ if (claim.exceptionDetails || claim.result?.value !== true)
53
+ throw new Error("This Oracle tab is being retired after recovery; choose another browser tab.");
54
+ }
@@ -24,6 +24,10 @@ const CURRENT_CHATGPT_PRO_ALIASES = new Set([
24
24
  // The browser label is passed to the model picker which fuzzy-matches against ChatGPT's UI.
25
25
  const BROWSER_MODEL_LABELS = [
26
26
  // Most specific first (e.g., "gpt-5.2-thinking" before "gpt-5.2")
27
+ // GPT-6 (Astra) has no entry of its own in the ChatGPT picker: it is the "Latest" radio of the
28
+ // advanced view, and "GPT-6 Pro" is that radio with the power slider at Pro (composer pill "6 Pro").
29
+ ["gpt-6-pro", "Latest"],
30
+ ["gpt-6-astra", "Latest"],
27
31
  ["gpt-5.6-sol", "GPT-5.6 Sol"],
28
32
  ["gpt-5.6", "GPT-5.6 Sol"],
29
33
  ["gpt-5.5-pro", "GPT-5.5"],
@@ -47,6 +51,13 @@ const BROWSER_MODEL_LABELS = [
47
51
  ];
48
52
  export function normalizeChatGptModelForBrowser(model) {
49
53
  const normalized = model.toLowerCase();
54
+ // Browser-only alias: gpt-6-pro keeps its name so the Pro tier default survives (label "Latest").
55
+ if (isGpt6ProAlias(normalized)) {
56
+ return "gpt-6-pro";
57
+ }
58
+ if (isGpt6Alias(normalized)) {
59
+ return "gpt-6-astra";
60
+ }
50
61
  if (!normalized.startsWith("gpt-") || normalized.includes("codex")) {
51
62
  return model;
52
63
  }
@@ -72,6 +83,18 @@ export function normalizeChatGptModelForBrowser(model) {
72
83
  }
73
84
  return model;
74
85
  }
86
+ // Documented spellings only: gpt-6, gpt-6-astra, gpt-6-pro (plus their label forms such as
87
+ // "GPT-6 Pro") and "latest" map to ChatGPT's "Latest" model. Any other gpt-6-* id (gpt-6-codex,
88
+ // gpt-6-custom, ...) is not an alias and must pass through unchanged for custom/OpenRouter use.
89
+ const GPT6_ALIAS_PATTERN = /^gpt[-_ ]?6(?:[-_ ](?:astra|pro))?$/;
90
+ const GPT6_PRO_ALIAS_PATTERN = /^gpt[-_ ]?6[-_ ]pro$/;
91
+ export function isGpt6Alias(model) {
92
+ const normalized = model?.trim().toLowerCase() ?? "";
93
+ return normalized === "latest" || GPT6_ALIAS_PATTERN.test(normalized);
94
+ }
95
+ export function isGpt6ProAlias(model) {
96
+ return GPT6_PRO_ALIAS_PATTERN.test(model?.trim().toLowerCase() ?? "");
97
+ }
75
98
  export function isCurrentChatGptProAlias(model) {
76
99
  return CURRENT_CHATGPT_PRO_ALIASES.has(model?.trim().toLowerCase() ?? "");
77
100
  }
@@ -80,7 +103,10 @@ export function resolveDefaultBrowserThinkingTime({ model, requestedModel, model
80
103
  if (strategy !== "select")
81
104
  return undefined;
82
105
  const normalizedModel = normalizeChatGptModelForBrowser(model);
83
- return isCurrentChatGptProAlias(requestedModel ?? model) || normalizedModel === "gpt-5.5-pro"
106
+ return isCurrentChatGptProAlias(requestedModel ?? model) ||
107
+ isGpt6ProAlias(requestedModel ?? model) ||
108
+ normalizedModel === "gpt-6-pro" ||
109
+ normalizedModel === "gpt-5.5-pro"
84
110
  ? "pro"
85
111
  : undefined;
86
112
  }
@@ -208,7 +234,9 @@ export async function buildBrowserConfig(options) {
208
234
  remoteChrome,
209
235
  browserTabRef: options.browserTab ?? undefined,
210
236
  thinkingTime,
211
- researchMode: options.browserResearch === "deep" ? "deep" : "off",
237
+ researchMode: options.browserResearch === "deep" || options.browserResearch === "search"
238
+ ? options.browserResearch
239
+ : "off",
212
240
  archiveConversations: options.browserArchive,
213
241
  };
214
242
  }
@@ -288,7 +316,9 @@ export function resolveBrowserModelLabel(input, model) {
288
316
  return mapModelToBrowserLabel(model);
289
317
  }
290
318
  const normalizedInput = trimmed.toLowerCase();
291
- if (normalizedInput === model.toLowerCase()) {
319
+ if (normalizedInput === model.toLowerCase() ||
320
+ (isGpt6Alias(normalizedInput) && (model === "gpt-6-astra" || model === "gpt-6-pro")) ||
321
+ (isGpt6ProAlias(normalizedInput) && model === "gpt-6-pro")) {
292
322
  return mapModelToBrowserLabel(model);
293
323
  }
294
324
  return trimmed;
@@ -1,6 +1,7 @@
1
1
  import { CHATGPT_URL } from "../browser/constants.js";
2
2
  import { normalizeChatgptUrl } from "../browser/utils.js";
3
3
  import { normalizeThinkingTimeLevel } from "../oracle/thinkingTime.js";
4
+ import { isGpt6ProAlias } from "./browserConfig.js";
4
5
  export function applyBrowserDefaultsFromConfig(options, config, getSource) {
5
6
  const browser = config.browser;
6
7
  if (!browser)
@@ -12,6 +13,7 @@ export function applyBrowserDefaultsFromConfig(options, config, getSource) {
12
13
  const attachRunningRequested = options.browserAttachRunning === true ||
13
14
  (isUnset("browserAttachRunning") && browser.attachRunning === true);
14
15
  const currentModelRequestedByCli = options.browserModelStrategy === "current" && getSource("browserModelStrategy") === "cli";
16
+ const gpt6ProRequestedByCli = getSource("model") === "cli" && isGpt6ProAlias(options.model);
15
17
  if (!options.copyProfile &&
16
18
  isUnset("remoteChrome") &&
17
19
  options.remoteChrome === undefined &&
@@ -104,6 +106,7 @@ export function applyBrowserDefaultsFromConfig(options, config, getSource) {
104
106
  options.browserModelStrategy = browser.modelStrategy;
105
107
  }
106
108
  if (!currentModelRequestedByCli &&
109
+ !gpt6ProRequestedByCli &&
107
110
  isUnset("browserThinkingTime") &&
108
111
  browser.thinkingTime !== undefined) {
109
112
  options.browserThinkingTime = normalizeThinkingTimeLevel(browser.thinkingTime) ?? undefined;
@@ -2,12 +2,16 @@ import fs from "node:fs/promises";
2
2
  import { createHash } from "node:crypto";
3
3
  import chalk from "chalk";
4
4
  import { sessionStore } from "../sessionStore.js";
5
+ import { resolveBrowserConfig } from "../browser/config.js";
6
+ import { browserPromptFingerprint } from "../browser/promptFingerprint.js";
5
7
  import { collectChatGptTabs, DEFAULT_REMOTE_CHROME_HOST, DEFAULT_REMOTE_CHROME_PORT, formatBrowserTabState, harvestChatGptTab, sessionMatchesTab, } from "../browser/liveTabs.js";
6
8
  import { isRecoveredConversationHarvestReady, recoverConversationTab, } from "../browser/recoverConversation.js";
7
9
  import { resolveOutputPath } from "./writeOutputPath.js";
8
10
  import { persistBrowserHarvest } from "./harvestIntegrity.js";
11
+ import { completeOwnedBrowserHarvest } from "./recoveredBrowserHarvest.js";
9
12
  const LIVE_POLL_MS = 2000;
10
13
  const DEFAULT_STALL_THRESHOLD_MS = 60_000;
14
+ const HARVEST_FRESHNESS_POLL_MS = 250;
11
15
  function isRecoverableMissingTabError(message) {
12
16
  return (message.includes("No ChatGPT tab matched") ||
13
17
  message.includes("No live ChatGPT tabs found") ||
@@ -30,6 +34,35 @@ function finishRecoveredChrome(recoveredChrome, closeAfterRecover) {
30
34
  // best-effort cleanup
31
35
  }
32
36
  }
37
+ function harvestMatchesSessionPrompt(harvested, fingerprint) {
38
+ const answer = harvested.lastAssistantMarkdown ?? harvested.lastAssistantText;
39
+ if (harvested.assistantFollowsLatestUser !== true || !answer?.trim())
40
+ return false;
41
+ return (fingerprint === undefined ||
42
+ (typeof harvested.lastUserMessageId === "string" &&
43
+ harvested.lastUserMessageId.trim().length > 0 &&
44
+ browserPromptFingerprint(harvested.lastUserTextRaw ?? harvested.lastUserText, harvested.lastUserMessageId) === fingerprint));
45
+ }
46
+ async function harvestSessionPrompt(meta, options, requireSessionPrompt = true) {
47
+ const fingerprint = requireSessionPrompt ? meta.browser?.runtime?.submittedPromptHash : undefined;
48
+ if (fingerprint === null) {
49
+ throw new Error("This browser session has no confirmed submitted user turn; retry after submission or use --browser-tab to inspect a specific tab.");
50
+ }
51
+ if (requireSessionPrompt && fingerprint === undefined) {
52
+ console.warn("Legacy browser session: submitted-turn identity is unavailable; verifying only latest user/assistant pairing.");
53
+ }
54
+ const freshnessTimeoutMs = resolveBrowserConfig(meta.browser?.config).inputTimeoutMs;
55
+ const deadline = Date.now() + freshnessTimeoutMs;
56
+ let harvested = await harvestChatGptTab(options);
57
+ while (!harvestMatchesSessionPrompt(harvested, fingerprint) && Date.now() < deadline) {
58
+ await new Promise((resolve) => setTimeout(resolve, HARVEST_FRESHNESS_POLL_MS));
59
+ harvested = await harvestChatGptTab(options);
60
+ }
61
+ if (!harvestMatchesSessionPrompt(harvested, fingerprint)) {
62
+ throw new Error(`Latest ChatGPT turn did not contain an assistant answer paired with this session prompt after ${Math.ceil(freshnessTimeoutMs / 1000)}s; refusing to harvest stale output.`);
63
+ }
64
+ return harvested;
65
+ }
33
66
  function sessionBrowserEndpoint(meta) {
34
67
  const runtime = meta?.browser?.runtime ?? {};
35
68
  const remote = meta?.browser?.config?.remoteChrome ?? {};
@@ -166,12 +199,12 @@ export async function harvestSessionBrowserOutput(sessionId, options = {}) {
166
199
  try {
167
200
  let harvested;
168
201
  try {
169
- harvested = await harvestChatGptTab({
202
+ harvested = await harvestSessionPrompt(meta, {
170
203
  host: initialEndpoint.host,
171
204
  port: initialEndpoint.port,
172
205
  ref,
173
206
  stallWindowMs: options.stallWindowMs,
174
- });
207
+ }, !options.browserTabRef);
175
208
  }
176
209
  catch (error) {
177
210
  const message = error instanceof Error ? error.message : String(error);
@@ -183,7 +216,7 @@ export async function harvestSessionBrowserOutput(sessionId, options = {}) {
183
216
  existingEndpoint: recordedEndpoint ?? undefined,
184
217
  });
185
218
  recoveredChrome = recovered.chrome;
186
- harvested = await harvestChatGptTab({
219
+ harvested = await harvestSessionPrompt(meta, {
187
220
  host: recovered.host,
188
221
  port: recovered.port,
189
222
  ref: recovered.ref,
@@ -199,6 +232,7 @@ export async function harvestSessionBrowserOutput(sessionId, options = {}) {
199
232
  if (!options.quietOutput && output) {
200
233
  process.stdout.write(`${output}${output.endsWith("\n") ? "" : "\n"}`);
201
234
  }
235
+ await completeOwnedBrowserHarvest(sessionId, harvested, integrity, (line) => console.log(line));
202
236
  return harvested;
203
237
  }
204
238
  finally {
@@ -296,6 +330,7 @@ export async function liveTailSessionBrowserOutput(sessionId, options = {}) {
296
330
  if (output) {
297
331
  process.stdout.write(`${output}${output.endsWith("\n") ? "" : "\n"}`);
298
332
  }
333
+ await completeOwnedBrowserHarvest(sessionId, finalHarvest, integrity, (line) => console.log(line));
299
334
  return finalHarvest;
300
335
  }
301
336
  await new Promise((resolve) => setTimeout(resolve, LIVE_POLL_MS));
@@ -1,4 +1,5 @@
1
1
  import { isProModel } from "../oracle/modelResolver.js";
2
+ import { isGpt6ProAlias } from "./browserConfig.js";
2
3
  export function shouldDetachSession({
3
4
  // Params kept for policy tweaks.
4
5
  engine, model, reasoningMode, waitPreference, disableDetachEnv, }) {
@@ -7,7 +8,7 @@ engine, model, reasoningMode, waitPreference, disableDetachEnv, }) {
7
8
  // Keep long local browser Pro work in a separate process even while the CLI
8
9
  // stays attached to its session log. If the foreground stream is interrupted,
9
10
  // the worker can still finish the browser run and persist the answer.
10
- if (engine === "browser" && isProModel(model))
11
+ if (engine === "browser" && (isProModel(model) || isGpt6ProAlias(model)))
11
12
  return true;
12
13
  // For API runs, explicit --wait keeps execution in the foreground.
13
14
  if (waitPreference)
@@ -17,6 +18,14 @@ engine, model, reasoningMode, waitPreference, disableDetachEnv, }) {
17
18
  return true;
18
19
  return false;
19
20
  }
21
+ export function shouldExitAfterTopLevelSigint(remainingListenerCount) {
22
+ return remainingListenerCount === 0;
23
+ }
24
+ export function detachedCancellationExitCode(cancelled, finalStatus, currentExitCode) {
25
+ if (!cancelled)
26
+ return currentExitCode;
27
+ return finalStatus === "completed" || finalStatus === "partial" ? 0 : 130;
28
+ }
20
29
  export function stopDetachedWorker(workerPid, kill = process.kill) {
21
30
  try {
22
31
  kill(workerPid, "SIGTERM");
@@ -1,5 +1,41 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { access, rm, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { setTimeout as delay } from "node:timers/promises";
2
5
  import { fileURLToPath } from "node:url";
6
+ const CANCELLATION_MARKER = ".cancel-requested";
7
+ export function detachedSessionCancellationPath(sessionDir, workerPid) {
8
+ return path.join(sessionDir, `${CANCELLATION_MARKER}-${workerPid}`);
9
+ }
10
+ export async function requestDetachedSessionCancellation(markerPath) {
11
+ await writeFile(markerPath, "cancel\n", "utf8");
12
+ }
13
+ export async function clearDetachedSessionCancellation(markerPath) {
14
+ await rm(markerPath, { force: true });
15
+ }
16
+ export async function waitForDetachedSessionCancellation({ markerPath, signal, pollIntervalMs = 100, }) {
17
+ while (!signal.aborted) {
18
+ try {
19
+ await access(markerPath);
20
+ return true;
21
+ }
22
+ catch (error) {
23
+ if (!(error instanceof Error) || error.code !== "ENOENT") {
24
+ throw error;
25
+ }
26
+ }
27
+ try {
28
+ await delay(pollIntervalMs, undefined, { signal });
29
+ }
30
+ catch (error) {
31
+ if (signal.aborted && error instanceof Error && error.name === "AbortError") {
32
+ return false;
33
+ }
34
+ throw error;
35
+ }
36
+ }
37
+ return false;
38
+ }
3
39
  export function resolveOracleCliEntrypoint(moduleUrl = import.meta.url) {
4
40
  const extension = fileURLToPath(moduleUrl).endsWith(".ts") ? "ts" : "js";
5
41
  return fileURLToPath(new URL(`../../bin/oracle-cli.${extension}`, moduleUrl));
@@ -1,3 +1,4 @@
1
+ import { isGpt6Alias, isGpt6ProAlias } from "./browserConfig.js";
1
2
  import { InvalidArgumentError } from "commander";
2
3
  import { parseDuration } from "../duration.js";
3
4
  import path from "node:path";
@@ -187,6 +188,12 @@ export function resolveApiModel(modelValue) {
187
188
  if (normalized.includes("/")) {
188
189
  return normalized;
189
190
  }
191
+ if (isGpt6ProAlias(normalized)) {
192
+ throw new InvalidArgumentError("GPT-6 Pro is an API reasoning mode, not a model slug. Use --model gpt-6-astra --reasoning-mode pro (or --engine browser --model gpt-6-pro).");
193
+ }
194
+ if (isGpt6Alias(normalized)) {
195
+ return "gpt-6-astra";
196
+ }
190
197
  const gpt56Label = parseBrowserGpt56Label(normalized);
191
198
  if (gpt56Label?.variant.split(" ").includes("pro")) {
192
199
  throw new InvalidArgumentError("GPT-5.6 Pro is an API reasoning mode, not a model slug. Use --model gpt-5.6-sol --reasoning-mode pro.");
@@ -284,6 +291,14 @@ export function inferModelFromLabel(modelValue) {
284
291
  if (normalized.includes("/")) {
285
292
  return normalized;
286
293
  }
294
+ // gpt-6 / gpt-6-pro / latest: ChatGPT's "Latest" model (GPT-6 Astra). The browser-only -pro alias
295
+ // is passed through so its Pro tier default survives (resolveDefaultBrowserThinkingTime).
296
+ if (isGpt6ProAlias(normalized)) {
297
+ return "gpt-6-pro";
298
+ }
299
+ if (isGpt6Alias(normalized)) {
300
+ return "gpt-6-astra";
301
+ }
287
302
  if (normalized.includes("grok")) {
288
303
  return "grok-4.1";
289
304
  }
@@ -0,0 +1,50 @@
1
+ import fs from "node:fs/promises";
2
+ import { sessionStore } from "../sessionStore.js";
3
+ import { isRecoveredConversationHarvestReady } from "../browser/recoverConversation.js";
4
+ import { hasOtherLiveBrowserController, matchesOwnedRecoveryTarget, retireRecoveredBrowserTarget, } from "../browser/recoveryTarget.js";
5
+ import { extractConversationIdFromUrl } from "../browser/reattachHelpers.js";
6
+ import { estimateTokenCount } from "../browser/utils.js";
7
+ export async function completeOwnedBrowserHarvest(sessionId, harvested, integrity, logger) {
8
+ if (harvested.state !== "completed" ||
9
+ harvested.stopExists ||
10
+ integrity.status !== "matched" ||
11
+ integrity.explicitTarget)
12
+ return;
13
+ const metadata = await sessionStore.readSession(sessionId);
14
+ if (!metadata?.browser?.runtime?.ownedRecoveryTarget ||
15
+ !isRecoveredConversationHarvestReady(harvested))
16
+ return;
17
+ const runtime = metadata.browser?.runtime;
18
+ const capture = {
19
+ host: harvested.host ?? "",
20
+ port: harvested.port ?? 0,
21
+ targetId: harvested.targetId,
22
+ browserWSEndpoint: runtime?.chromeBrowserWSEndpoint,
23
+ conversationId: harvested.conversationId ?? extractConversationIdFromUrl(harvested.url),
24
+ };
25
+ if (hasOtherLiveBrowserController(metadata) || !matchesOwnedRecoveryTarget(metadata, capture))
26
+ return;
27
+ // Harvest previously persisted only a snippet/hash. Save the full answer before retirement.
28
+ const answer = harvested.lastAssistantMarkdown || harvested.lastAssistantText;
29
+ const paths = await sessionStore.getPaths(sessionId);
30
+ await fs.appendFile(paths.log, `[reattach] harvested assistant response from existing Chrome tab\nAnswer:\n${answer}\n`, "utf8");
31
+ const outputTokens = estimateTokenCount(answer);
32
+ const usage = { inputTokens: 0, outputTokens, reasoningTokens: 0, totalTokens: outputTokens };
33
+ if (metadata.model) {
34
+ await sessionStore.updateModelRun(sessionId, metadata.model, {
35
+ status: "completed",
36
+ completedAt: new Date().toISOString(),
37
+ usage,
38
+ });
39
+ }
40
+ await sessionStore.updateSession(sessionId, {
41
+ status: "completed",
42
+ completedAt: new Date().toISOString(),
43
+ usage,
44
+ errorMessage: undefined,
45
+ error: undefined,
46
+ transport: undefined,
47
+ response: { status: "completed" },
48
+ });
49
+ await retireRecoveredBrowserTarget(sessionId, capture, logger);
50
+ }
@@ -4,7 +4,7 @@ import { normalizeModelOption, inferModelFromLabel, resolveApiModel, normalizeBa
4
4
  import { resolveGeminiModelId } from "../oracle/gemini.js";
5
5
  import { resolveOverriddenApiModel } from "../oracle/modelResolver.js";
6
6
  import { PromptValidationError } from "../oracle/errors.js";
7
- import { normalizeChatGptModelForBrowser } from "./browserConfig.js";
7
+ import { normalizeChatGptModelForBrowser, isGpt6ProAlias } from "./browserConfig.js";
8
8
  import { resolveConfiguredMaxFileSizeBytes } from "./fileSize.js";
9
9
  import { isAzureOpenAICandidateModel } from "../oracle/providerRouting.js";
10
10
  export function resolveRunOptionsFromConfig({ prompt, files = [], model, models, engine, userConfig, env = process.env, }) {
@@ -24,7 +24,10 @@ export function resolveRunOptionsFromConfig({ prompt, files = [], model, models,
24
24
  .map((entry) => normalizeModelOption(entry))
25
25
  .filter(Boolean);
26
26
  const cliModelArg = normalizeModelOption(model ?? userConfig?.model) || DEFAULT_MODEL;
27
- const apiModel = resolveApiModel(cliModelArg);
27
+ const isGpt6Pro = isGpt6ProAlias(cliModelArg);
28
+ const apiModel = isGpt6Pro && (resolvedEngine === "browser" || browserEngineRequested)
29
+ ? "gpt-6-pro"
30
+ : resolveApiModel(cliModelArg);
28
31
  // Browser label inference is intentionally engine-scoped: API model ids such as
29
32
  // gpt-5.6-luna must remain provider values even though browser mode rejects
30
33
  // unrecognized GPT-5.6 picker variants.
@@ -36,9 +39,11 @@ export function resolveRunOptionsFromConfig({ prompt, files = [], model, models,
36
39
  const isGrok = apiModel.startsWith("grok");
37
40
  const engineWasBrowser = resolvedEngine === "browser";
38
41
  const allModels = normalizedRequestedModels.length > 0
39
- ? Array.from(new Set(normalizedRequestedModels.map((entry) => resolveApiModel(entry))))
42
+ ? Array.from(new Set(normalizedRequestedModels.map((entry) => isGpt6ProAlias(entry) && (resolvedEngine === "browser" || browserEngineRequested)
43
+ ? "gpt-6-pro"
44
+ : resolveApiModel(entry))))
40
45
  : [apiModel];
41
- const browserCompatibilityModels = normalizedRequestedModels.length > 0 ? allModels : [browserModel];
46
+ const browserCompatibilityModels = normalizedRequestedModels.length > 0 ? allModels : [browserModel ?? apiModel];
42
47
  const isBrowserCompatible = (m) => m.startsWith("gpt-") || m.startsWith("gemini");
43
48
  const hasNonBrowserCompatibleTarget = browserEngineRequested && browserCompatibilityModels.some((m) => !isBrowserCompatible(m));
44
49
  if (hasNonBrowserCompatibleTarget) {
@@ -52,6 +57,16 @@ export function resolveRunOptionsFromConfig({ prompt, files = [], model, models,
52
57
  const fixedEngine = isCodex || isClaude || isGrok || azureAutoApi || normalizedRequestedModels.length > 0
53
58
  ? "api"
54
59
  : resolvedEngine;
60
+ if (fixedEngine === "api") {
61
+ if (isGpt6ProAlias(cliModelArg)) {
62
+ resolveApiModel(cliModelArg);
63
+ }
64
+ for (const entry of normalizedRequestedModels) {
65
+ if (isGpt6ProAlias(entry)) {
66
+ resolveApiModel(entry);
67
+ }
68
+ }
69
+ }
55
70
  // Browser runs use ChatGPT picker labels/aliases; API runs must keep API model ids intact.
56
71
  const resolvedModel = fixedEngine === "browser" ? browserModel : apiModel;
57
72
  const promptWithSuffix = userConfig?.promptSuffix && userConfig.promptSuffix.trim().length > 0
@@ -6,6 +6,7 @@ import { formatFinishLine } from "../oracle/finishLine.js";
6
6
  import { sessionStore, wait } from "../sessionStore.js";
7
7
  import { formatTokenCount, formatTokenValue } from "../oracle/runUtils.js";
8
8
  import { resumeBrowserSession } from "../browser/reattach.js";
9
+ import { retireRecoveredBrowserTarget } from "../browser/recoveryTarget.js";
9
10
  import { hasRecoverableChatGptConversation } from "../browser/reattachability.js";
10
11
  import { appendArtifacts, saveBrowserTranscriptArtifact, saveDeepResearchReportArtifact, } from "../browser/artifacts.js";
11
12
  import { estimateTokenCount } from "../browser/utils.js";
@@ -88,11 +89,8 @@ async function writeReattachAnswer(sessionId, result, replaceExistingLog) {
88
89
  await fs.writeFile(paths.log, `[reattach] replaced incomplete Deep Research capture from existing Chrome tab\nAnswer:\n${body}\n`, "utf8");
89
90
  return;
90
91
  }
91
- const logWriter = sessionStore.createLogWriter(sessionId);
92
- logWriter.logLine("[reattach] captured assistant response from existing Chrome tab");
93
- logWriter.logLine("Answer:");
94
- logWriter.logLine(body);
95
- logWriter.stream.end();
92
+ const paths = await sessionStore.getPaths(sessionId);
93
+ await fs.appendFile(paths.log, `[reattach] captured assistant response from existing Chrome tab\nAnswer:\n${body}\n`, "utf8");
96
94
  }
97
95
  async function saveReattachBrowserArtifacts(sessionId, metadata, result) {
98
96
  const body = result.answerMarkdown || result.answerText;
@@ -264,6 +262,7 @@ export async function attachSession(sessionId, options) {
264
262
  transport: undefined,
265
263
  });
266
264
  console.log(chalk.green("Reattach succeeded; session marked completed."));
265
+ await retireRecoveredBrowserTarget(sessionId, result.captureTarget, (line) => console.log(dim(line)));
267
266
  metadata = (await sessionStore.readSession(sessionId)) ?? metadata;
268
267
  }
269
268
  catch (error) {
@@ -360,7 +359,10 @@ export async function attachSession(sessionId, options) {
360
359
  console.log(dim(`User error: ${userErrorSummary}`));
361
360
  }
362
361
  }
363
- const shouldTrimIntro = initialStatus === "completed" || initialStatus === "partial" || initialStatus === "error";
362
+ const shouldTrimIntro = initialStatus === "completed" ||
363
+ initialStatus === "partial" ||
364
+ initialStatus === "error" ||
365
+ initialStatus === "cancelled";
364
366
  if (options?.renderPrompt !== false) {
365
367
  const prompt = await readStoredPrompt(sessionId);
366
368
  if (prompt) {
@@ -486,7 +488,10 @@ export async function attachSession(sessionId, options) {
486
488
  if (!latest) {
487
489
  break;
488
490
  }
489
- if (latest.status === "completed" || latest.status === "partial" || latest.status === "error") {
491
+ if (latest.status === "completed" ||
492
+ latest.status === "partial" ||
493
+ latest.status === "error" ||
494
+ latest.status === "cancelled") {
490
495
  await printNew();
491
496
  flushRemainder();
492
497
  if (!options?.suppressMetadata) {
@@ -517,7 +522,9 @@ export async function attachSession(sessionId, options) {
517
522
  if (!settled) {
518
523
  break;
519
524
  }
520
- if (settled.status === "completed" || settled.status === "partial") {
525
+ if (settled.status === "completed" ||
526
+ settled.status === "partial" ||
527
+ settled.status === "cancelled") {
521
528
  continue;
522
529
  }
523
530
  await printNew();