@steipete/oracle 0.19.0 → 0.20.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 (35) hide show
  1. package/dist/bin/oracle-cli.js +11 -13
  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 +38 -1
  6. package/dist/src/browser/actions/webSearch.js +96 -0
  7. package/dist/src/browser/chromeLifecycle.js +5 -4
  8. package/dist/src/browser/config.js +1 -1
  9. package/dist/src/browser/index.js +111 -43
  10. package/dist/src/browser/liveTabs.js +3 -4
  11. package/dist/src/browser/providers/chatgptDomProvider.js +1 -0
  12. package/dist/src/browser/reattach.js +21 -1
  13. package/dist/src/browser/recoveryTarget.js +131 -0
  14. package/dist/src/browser/sessionRunner.js +3 -1
  15. package/dist/src/browser/tabLeaseRegistry.js +20 -0
  16. package/dist/src/browser/targetClaim.js +54 -0
  17. package/dist/src/cli/browserConfig.js +33 -3
  18. package/dist/src/cli/browserTabs.js +3 -0
  19. package/dist/src/cli/options.js +15 -0
  20. package/dist/src/cli/recoveredBrowserHarvest.js +50 -0
  21. package/dist/src/cli/runOptions.js +19 -4
  22. package/dist/src/cli/sessionDisplay.js +4 -5
  23. package/dist/src/cli/sessionRunner.js +4 -5
  24. package/dist/src/mcp/tools/consult.js +2 -2
  25. package/dist/src/mcp/types.js +1 -1
  26. package/dist/src/oracle/config.js +14 -0
  27. package/dist/src/oracle/geminiModels.js +1 -0
  28. package/dist/src/oracle/run.js +27 -4
  29. package/dist/src/remote/client.js +3 -0
  30. package/dist/src/remote/server.js +1 -0
  31. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  32. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  33. package/package.json +3 -3
  34. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  35. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
@@ -0,0 +1,131 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { sessionStore } from "../sessionStore.js";
3
+ import { connectToRemoteChromeTarget } from "./chromeLifecycle.js";
4
+ import { resolveBrowserApprovalWait } from "./config.js";
5
+ import { extractConversationIdFromUrl } from "./reattachHelpers.js";
6
+ import { STOP_BUTTON_SELECTORS } from "./constants.js";
7
+ import { hasActiveBrowserTargetLease } from "./tabLeaseRegistry.js";
8
+ import { buildTargetRetirementExpression, buildTargetRetirementRollbackExpression, normalizeChromeHost, } from "./targetClaim.js";
9
+ function processMayBeAlive(pid) {
10
+ try {
11
+ process.kill(pid, 0);
12
+ return true;
13
+ }
14
+ catch (error) {
15
+ return error.code !== "ESRCH";
16
+ }
17
+ }
18
+ export function hasOtherLiveBrowserController(metadata) {
19
+ for (const pid of [metadata.browser?.runtime?.controllerPid, metadata.lifecycle?.workerPid]) {
20
+ if (!pid || pid === process.pid)
21
+ continue;
22
+ if (processMayBeAlive(pid))
23
+ return true;
24
+ }
25
+ return false;
26
+ }
27
+ async function targetHasActiveController(metadata, capture) {
28
+ for (const other of await sessionStore.listSessions()) {
29
+ if (other.id === metadata.id)
30
+ continue;
31
+ const runtime = other.browser?.runtime;
32
+ if (runtime?.chromeTargetId !== capture.targetId ||
33
+ runtime.chromePort !== capture.port ||
34
+ normalizeChromeHost(runtime.chromeHost ?? "127.0.0.1") !== normalizeChromeHost(capture.host))
35
+ continue;
36
+ if ([runtime.controllerPid, other.lifecycle?.workerPid].some((pid) => pid && processMayBeAlive(pid)))
37
+ return true;
38
+ }
39
+ const runtime = metadata.browser?.runtime;
40
+ const profileDirs = new Set([
41
+ runtime?.userDataDir,
42
+ runtime?.chromeProfileRoot,
43
+ metadata.browser?.config?.manualLoginProfileDir,
44
+ ].filter((dir) => Boolean(dir)));
45
+ for (const dir of profileDirs) {
46
+ if (await hasActiveBrowserTargetLease(dir, capture))
47
+ return true;
48
+ }
49
+ return false;
50
+ }
51
+ export function matchesOwnedRecoveryTarget(metadata, capture) {
52
+ const runtime = metadata.browser?.runtime;
53
+ const owned = runtime?.ownedRecoveryTarget;
54
+ const conversationId = runtime?.conversationId ?? extractConversationIdFromUrl(runtime?.tabUrl ?? "");
55
+ return Boolean(owned &&
56
+ capture &&
57
+ conversationId &&
58
+ capture.conversationId === conversationId &&
59
+ owned.targetId === capture.targetId &&
60
+ normalizeChromeHost(owned.host) === normalizeChromeHost(capture.host) &&
61
+ owned.port === capture.port &&
62
+ owned.browserWSEndpoint === capture.browserWSEndpoint);
63
+ }
64
+ /** Call only after the full recovered answer and completed session have been saved. */
65
+ export async function retireRecoveredBrowserTarget(sessionId, capture, logger, deps = {}) {
66
+ if (!capture)
67
+ return;
68
+ let connection;
69
+ let lockedClaim;
70
+ const reservationId = randomUUID();
71
+ let retired = false;
72
+ try {
73
+ const metadata = await sessionStore.readSession(sessionId);
74
+ if (!metadata ||
75
+ metadata.status !== "completed" ||
76
+ hasOtherLiveBrowserController(metadata) ||
77
+ !matchesOwnedRecoveryTarget(metadata, capture))
78
+ return;
79
+ const claimId = metadata.browser?.runtime?.ownedRecoveryTarget?.claimId;
80
+ if (!claimId)
81
+ return;
82
+ const connect = deps.connect ?? connectToRemoteChromeTarget;
83
+ connection = await connect(capture.host, capture.port, logger, {
84
+ targetId: capture.targetId,
85
+ browserWSEndpoint: capture.browserWSEndpoint,
86
+ closeTargetOnDispose: false,
87
+ approvalWaitMs: resolveBrowserApprovalWait(metadata.browser?.config?.approvalWaitMs),
88
+ });
89
+ const { Runtime, Target } = connection.client;
90
+ const state = await Runtime.evaluate({
91
+ expression: `(() => ({ url: location.href, generating: Array.from(document.querySelectorAll(${JSON.stringify(STOP_BUTTON_SELECTORS.join(","))})).some(node => node.getBoundingClientRect().width > 0 && node.getBoundingClientRect().height > 0) }))()`,
92
+ returnByValue: true,
93
+ });
94
+ const value = state.result?.value;
95
+ if (!value ||
96
+ value.generating !== false ||
97
+ extractConversationIdFromUrl(value.url ?? "") !== capture.conversationId)
98
+ return;
99
+ const { targetInfos } = await Target.getTargets();
100
+ if (!targetInfos.some((target) => target.type === "page" && target.targetId !== capture.targetId)) {
101
+ const replacement = await Target.createTarget({ url: "about:blank" });
102
+ if (!replacement.targetId)
103
+ return;
104
+ }
105
+ if (await targetHasActiveController(metadata, capture))
106
+ return;
107
+ lockedClaim = claimId;
108
+ const locked = await Runtime.evaluate({
109
+ expression: buildTargetRetirementExpression(claimId, capture.conversationId, reservationId),
110
+ returnByValue: true,
111
+ });
112
+ if (locked.exceptionDetails || locked.result?.value !== true)
113
+ return;
114
+ const result = await Target.closeTarget({ targetId: capture.targetId });
115
+ if (!result.success)
116
+ throw new Error("Chrome refused target retirement");
117
+ retired = true;
118
+ logger("Retired Oracle-owned browser tab after saving the recovered answer.");
119
+ }
120
+ catch (error) {
121
+ logger(`Recovered answer is saved; browser tab cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
122
+ }
123
+ finally {
124
+ if (connection && lockedClaim && !retired) {
125
+ await connection.client.Runtime.evaluate({
126
+ expression: buildTargetRetirementRollbackExpression(lockedClaim, reservationId),
127
+ }).catch(() => undefined);
128
+ }
129
+ await connection?.close().catch(() => undefined);
130
+ }
131
+ }
@@ -135,7 +135,7 @@ async function executeAssembledBrowserSession({ runOptions, browserConfig, log,
135
135
  if (typeof message !== "string")
136
136
  return;
137
137
  const shouldAlwaysPrint = message.startsWith("[browser] ") &&
138
- /archive|fallback|follow-up|retry|thinking|waiting for chatgpt|remote debugging approval|browser slot|browser control|browser guidance|model selection|model picker/i.test(message);
138
+ /archive|fallback|follow-up|retry|thinking|research|waiting for chatgpt|remote debugging approval|browser slot|browser control|browser guidance|model selection|model picker/i.test(message);
139
139
  if (!runOptions.verbose && !shouldAlwaysPrint)
140
140
  return;
141
141
  log(message);
@@ -278,9 +278,11 @@ async function executeAssembledBrowserSession({ runOptions, browserConfig, log,
278
278
  chromeProfileRoot: browserResult.chromeProfileRoot,
279
279
  userDataDir: browserResult.userDataDir,
280
280
  chromeTargetId: browserResult.chromeTargetId,
281
+ ownedRecoveryTarget: browserResult.ownedRecoveryTarget,
281
282
  tabUrl: browserResult.tabUrl,
282
283
  conversationId: browserResult.conversationId,
283
284
  promptSubmitted: browserResult.promptSubmitted,
285
+ researchPlan: browserResult.researchPlan,
284
286
  controllerPid: browserResult.controllerPid ?? process.pid,
285
287
  },
286
288
  archive: browserResult.archive,
@@ -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) {
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) 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;
@@ -6,6 +6,7 @@ import { collectChatGptTabs, DEFAULT_REMOTE_CHROME_HOST, DEFAULT_REMOTE_CHROME_P
6
6
  import { isRecoveredConversationHarvestReady, recoverConversationTab, } from "../browser/recoverConversation.js";
7
7
  import { resolveOutputPath } from "./writeOutputPath.js";
8
8
  import { persistBrowserHarvest } from "./harvestIntegrity.js";
9
+ import { completeOwnedBrowserHarvest } from "./recoveredBrowserHarvest.js";
9
10
  const LIVE_POLL_MS = 2000;
10
11
  const DEFAULT_STALL_THRESHOLD_MS = 60_000;
11
12
  function isRecoverableMissingTabError(message) {
@@ -199,6 +200,7 @@ export async function harvestSessionBrowserOutput(sessionId, options = {}) {
199
200
  if (!options.quietOutput && output) {
200
201
  process.stdout.write(`${output}${output.endsWith("\n") ? "" : "\n"}`);
201
202
  }
203
+ await completeOwnedBrowserHarvest(sessionId, harvested, integrity, (line) => console.log(line));
202
204
  return harvested;
203
205
  }
204
206
  finally {
@@ -296,6 +298,7 @@ export async function liveTailSessionBrowserOutput(sessionId, options = {}) {
296
298
  if (output) {
297
299
  process.stdout.write(`${output}${output.endsWith("\n") ? "" : "\n"}`);
298
300
  }
301
+ await completeOwnedBrowserHarvest(sessionId, finalHarvest, integrity, (line) => console.log(line));
299
302
  return finalHarvest;
300
303
  }
301
304
  await new Promise((resolve) => setTimeout(resolve, LIVE_POLL_MS));
@@ -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) {
@@ -22,6 +22,7 @@ import { sanitizeOscProgress } from "./oscUtils.js";
22
22
  import { readFiles } from "../oracle/files.js";
23
23
  import { cwd as getCwd } from "node:process";
24
24
  import { resumeBrowserSession } from "../browser/reattach.js";
25
+ import { retireRecoveredBrowserTarget } from "../browser/recoveryTarget.js";
25
26
  import { hasRecoverableChatGptConversation } from "../browser/reattachability.js";
26
27
  import { estimateTokenCount } from "../browser/utils.js";
27
28
  import { computeFileSha256, sanitizeArtifactFilename } from "../browser/artifacts.js";
@@ -1039,11 +1040,8 @@ async function autoReattachUntilComplete({ sessionMeta, runtime, browserConfig,
1039
1040
  existingArtifacts: sessionMeta.artifacts,
1040
1041
  logger,
1041
1042
  });
1042
- const logWriter = sessionStore.createLogWriter(sessionMeta.id);
1043
- logWriter.logLine(`[auto-reattach] captured assistant response on attempt ${attempt}`);
1044
- logWriter.logLine("Answer:");
1045
- logWriter.logLine(answerText);
1046
- logWriter.stream.end();
1043
+ const paths = await sessionStore.getPaths(sessionMeta.id);
1044
+ await fs.appendFile(paths.log, `[auto-reattach] captured assistant response on attempt ${attempt}\nAnswer:\n${answerText}\n`, "utf8");
1047
1045
  if (modelForStatus) {
1048
1046
  await sessionStore.updateModelRun(sessionMeta.id, modelForStatus, {
1049
1047
  status: "completed",
@@ -1089,6 +1087,7 @@ async function autoReattachUntilComplete({ sessionMeta, runtime, browserConfig,
1089
1087
  transport: undefined,
1090
1088
  });
1091
1089
  log(kleur.green("Auto-reattach succeeded; session marked completed."));
1090
+ await retireRecoveredBrowserTarget(sessionMeta.id, result.captureTarget, logger);
1092
1091
  return true;
1093
1092
  }
1094
1093
  catch (error) {
@@ -74,9 +74,9 @@ const consultInputShape = {
74
74
  .optional()
75
75
  .describe("Browser-only: model picker strategy. Mirrors the CLI --browser-model-strategy flag."),
76
76
  browserResearchMode: z
77
- .enum(["deep"])
77
+ .enum(["search", "deep"])
78
78
  .optional()
79
- .describe("Browser-only: activate ChatGPT Deep Research mode for broad web research."),
79
+ .describe("Browser-only: explicitly select ChatGPT Web Search or Deep Research."),
80
80
  browserArchive: z
81
81
  .enum(["auto", "always", "never"])
82
82
  .optional()
@@ -23,7 +23,7 @@ export const consultInputSchema = z
23
23
  browserBundleFormat: z.enum(["auto", "text", "zip"]).optional(),
24
24
  browserThinkingTime: browserThinkingTimeInputSchema.optional(),
25
25
  browserModelStrategy: z.enum(["select", "current", "ignore"]).optional(),
26
- browserResearchMode: z.enum(["deep"]).optional(),
26
+ browserResearchMode: z.enum(["search", "deep"]).optional(),
27
27
  browserArchive: z.enum(["auto", "always", "never"]).optional(),
28
28
  browserFollowUps: z.array(z.string()).optional(),
29
29
  browserKeepBrowser: z.boolean().optional(),
@@ -30,6 +30,20 @@ const countTokensAnthropic = (input) => {
30
30
  // supported limit at the base-rate boundary until cost estimation supports tiers.
31
31
  const GPT_5_6_BASE_RATE_INPUT_LIMIT = 272_000;
32
32
  export const MODEL_CONFIGS = {
33
+ "gpt-6-astra": {
34
+ model: "gpt-6-astra",
35
+ provider: "openai",
36
+ tokenizer: countTokensGpt5,
37
+ // Conservative base-rate budget, not Astra's maximum context window.
38
+ // https://developers.openai.com/api/docs/models/gpt-6-astra
39
+ inputLimit: 272_000,
40
+ pricing: {
41
+ inputPerToken: 10 / 1_000_000,
42
+ outputPerToken: 50 / 1_000_000,
43
+ },
44
+ reasoning: { effort: "xhigh" },
45
+ searchToolType: "web_search",
46
+ },
33
47
  "gpt-5.6": {
34
48
  model: "gpt-5.6",
35
49
  provider: "openai",
@@ -1,4 +1,5 @@
1
1
  const MODEL_ID_MAP = {
2
+ "gpt-6-astra": "gpt-6-astra",
2
3
  "gemini-3.1-flash-lite": "gemini-3.1-flash-lite",
3
4
  "gemini-3.1-pro": "gemini-3.1-pro-preview",
4
5
  "gemini-3.5-flash": "gemini-3.5-flash",