@steipete/oracle 0.20.1 → 0.20.2

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.
@@ -25,7 +25,7 @@ import { warnIfOversizeBundle } from "../src/cli/bundleWarnings.js";
25
25
  import { formatRenderedMarkdown } from "../src/cli/renderOutput.js";
26
26
  import { resolveRenderFlag, resolveRenderPlain } from "../src/cli/renderFlags.js";
27
27
  import { resolveGeminiModelId } from "../src/oracle/geminiModels.js";
28
- import { isErrorLogged } from "../src/cli/errorUtils.js";
28
+ import { formatCliError, isErrorLogged } from "../src/cli/errorUtils.js";
29
29
  import { resolveOutputPath } from "../src/cli/writeOutputPath.js";
30
30
  import { getCliVersion } from "../src/version.js";
31
31
  import { resolveNotificationSettings, deriveNotificationSettingsFromMetadata, } from "../src/cli/notifier.js";
@@ -1358,6 +1358,8 @@ async function runRootCommand(options) {
1358
1358
  browserRequestedModel: cliModelArg,
1359
1359
  browserModelLabel: resolveBrowserModelLabel(cliModelArg, activeModel),
1360
1360
  });
1361
+ config.modelIsImplicitDefault =
1362
+ optionUsesDefault("model") && !userConfig.model && !options.browserModelLabel;
1361
1363
  return resolvedOptions.browserResumeConversationUrl
1362
1364
  ? { ...config, resumeConversationUrl: resolvedOptions.browserResumeConversationUrl }
1363
1365
  : config;
@@ -2106,13 +2108,7 @@ async function main() {
2106
2108
  }
2107
2109
  }
2108
2110
  void main().catch((error) => {
2109
- if (error instanceof Error) {
2110
- if (!isErrorLogged(error)) {
2111
- console.error(chalk.red("✖"), error.message);
2112
- }
2113
- }
2114
- else {
2115
- console.error(chalk.red("✖"), error);
2116
- }
2111
+ if (!isErrorLogged(error))
2112
+ console.error(chalk.red("✖"), formatCliError(error));
2117
2113
  process.exitCode = 1;
2118
2114
  });
@@ -15,10 +15,28 @@ const MODEL_BUTTON_POLL_MS = 250;
15
15
  export async function ensureModelSelection(Runtime, desiredModel, logger, strategy = "select", options = {}) {
16
16
  const buttonWaitMs = options.buttonWaitMs ?? MODEL_BUTTON_WAIT_MS;
17
17
  const buttonPollMs = options.buttonPollMs ?? MODEL_BUTTON_POLL_MS;
18
- const deadline = Date.now() + Math.max(0, buttonWaitMs);
18
+ const probeDeadline = Date.now() + Math.max(0, buttonWaitMs);
19
+ let deadline;
19
20
  let result;
20
21
  let announcedWait = false;
21
22
  for (;;) {
23
+ if (options.implicitDefault && strategy === "select") {
24
+ // Wait for observable selection before a default-driven switch, just as selection waits for the picker.
25
+ const observed = await Runtime.evaluate({
26
+ expression: buildModelSelectionExpression(desiredModel, "current"),
27
+ awaitPromise: true,
28
+ returnByValue: true,
29
+ }).catch(() => null);
30
+ const label = observed?.result?.value?.label;
31
+ if ((typeof label !== "string" || !label.trim()) && Date.now() < probeDeadline) {
32
+ await delay(buttonPollMs);
33
+ continue;
34
+ }
35
+ if (typeof label === "string" && isNewerModelLabel(label, desiredModel)) {
36
+ logger(`[browser] Model selection warning: no model was specified, so Oracle's default will switch ChatGPT from "${label}" to "${desiredModel}" before submission. Pass --model explicitly or --browser-model-strategy current to keep the selected model.`);
37
+ }
38
+ }
39
+ deadline ??= Date.now() + Math.max(0, buttonWaitMs);
22
40
  const outcome = await Runtime.evaluate({
23
41
  expression: buildModelSelectionExpression(desiredModel, strategy),
24
42
  awaitPromise: true,
@@ -76,6 +94,18 @@ export async function ensureModelSelection(Runtime, desiredModel, logger, strate
76
94
  }
77
95
  }
78
96
  }
97
+ function isNewerModelLabel(current, target) {
98
+ const latest = /^(?:Latest|最新|최신)$/i;
99
+ if (latest.test(current.trim()))
100
+ return !latest.test(target.trim());
101
+ const version = (label) => {
102
+ const match = label.match(/(?:^|gpt[- ]*|thinking\s+)(\d+)(?:\.(\d+))?/i);
103
+ return match ? [Number(match[1]), Number(match[2] ?? 0)] : null;
104
+ };
105
+ const from = version(current);
106
+ const to = version(target);
107
+ return Boolean(from && to && (from[0] > to[0] || (from[0] === to[0] && from[1] > to[1])));
108
+ }
79
109
  function assertResolvedModelSelection(desiredModel, resolvedLabel) {
80
110
  const desired = desiredModel.toLowerCase();
81
111
  const resolved = resolvedLabel.toLowerCase();
@@ -85,7 +115,8 @@ function assertResolvedModelSelection(desiredModel, resolvedLabel) {
85
115
  // The advanced radio is localized, but only the documented exact labels are
86
116
  // evidence of GPT-6 Astra. Do not let a generic picker result verify Latest.
87
117
  if (resolvedLabel.normalize("NFC").trim() === "Latest" ||
88
- resolvedLabel.normalize("NFC").trim() === "最新") {
118
+ resolvedLabel.normalize("NFC").trim() === "最新" ||
119
+ resolvedLabel.normalize("NFC").trim() === "최신") {
89
120
  return;
90
121
  }
91
122
  throw new Error(`Model picker selected "${resolvedLabel}" while "${desiredModel}" requires GPT-6 Astra (Latest).`);
@@ -187,7 +218,7 @@ function buildModelSelectionExpression(targetModel, strategy) {
187
218
  // Sol or an arbitrary localized menu row can never satisfy a Latest request.
188
219
  const isLatestModelLabel = (value) => {
189
220
  const label = String(value ?? '').normalize('NFC').trim();
190
- return label === 'Latest' || label === '最新';
221
+ return label === 'Latest' || label === '最新' || label === '최신';
191
222
  };
192
223
  const normalizedTokens = Array.from(new Set([normalizedTarget, ...LABEL_TOKENS]))
193
224
  .map((token) => normalizeText(token))
@@ -1442,8 +1473,9 @@ function buildModelMatchersLiteral(targetModel) {
1442
1473
  testIdTokens.add("gpt56");
1443
1474
  }
1444
1475
  if (base === "latest") {
1445
- // ChatGPT's Japanese advanced-model radio is named exactly "最新".
1476
+ // Exact Japanese and Korean labels for the advanced-model Latest radio.
1446
1477
  push("最新", labelTokens);
1478
+ push("최신", labelTokens);
1447
1479
  }
1448
1480
  // Numeric variations (5.5 <-> 55 <-> gpt-5-5)
1449
1481
  if (base.includes("5.5") || base.includes("5-5") || base.includes("55")) {
@@ -0,0 +1,59 @@
1
+ import { withoutBrowserCancellation } from "./cancellation.js";
2
+ const connections = new Map();
3
+ // CRI exposes no ref API. Retain approval while idle without keeping a CLI alive.
4
+ function refTransport(client, active) {
5
+ const socket = client._ws?._socket;
6
+ if (active)
7
+ socket?.ref();
8
+ else
9
+ socket?.unref();
10
+ }
11
+ export async function acquireBrowserConnection(endpoint, connect) {
12
+ let pending = connections.get(endpoint);
13
+ if (!pending) {
14
+ pending = Promise.resolve().then(async () => {
15
+ const client = await withoutBrowserCancellation(connect);
16
+ const connection = { client, users: 0 };
17
+ client.on?.("disconnect", () => {
18
+ if (connections.get(endpoint) === pending)
19
+ connections.delete(endpoint);
20
+ });
21
+ return connection;
22
+ });
23
+ connections.set(endpoint, pending);
24
+ }
25
+ let connection;
26
+ try {
27
+ connection = await pending;
28
+ }
29
+ catch (error) {
30
+ if (connections.get(endpoint) === pending)
31
+ connections.delete(endpoint);
32
+ throw error;
33
+ }
34
+ connection.users += 1;
35
+ refTransport(connection.client, true);
36
+ let released = false;
37
+ return new Proxy({}, {
38
+ ownKeys: () => Reflect.ownKeys(connection.client),
39
+ getOwnPropertyDescriptor: (_target, key) => {
40
+ const descriptor = Object.getOwnPropertyDescriptor(connection.client, key);
41
+ return descriptor ? { ...descriptor, configurable: true } : undefined;
42
+ },
43
+ get(_target, key) {
44
+ const target = connection.client;
45
+ if (key === "close") {
46
+ return async () => {
47
+ if (released)
48
+ return;
49
+ released = true;
50
+ connection.users -= 1;
51
+ if (connection.users === 0)
52
+ refTransport(target, false);
53
+ };
54
+ }
55
+ const value = Reflect.get(target, key, target);
56
+ return typeof value === "function" ? value.bind(target) : value;
57
+ },
58
+ });
59
+ }
@@ -209,7 +209,7 @@ function detectImageFile(buffer) {
209
209
  }
210
210
  return null;
211
211
  }
212
- function resolveSiblingImagePath(basePath, index, extension) {
212
+ export function resolveSiblingImagePath(basePath, index, extension) {
213
213
  const ext = path.extname(basePath);
214
214
  const dir = path.dirname(basePath);
215
215
  const stem = ext ? path.basename(basePath, ext) : path.basename(basePath);
@@ -1,4 +1,5 @@
1
1
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { EventEmitter } from "node:events";
2
3
  import * as childProcess from "node:child_process";
3
4
  import net from "node:net";
4
5
  import path from "node:path";
@@ -8,6 +9,7 @@ import { cleanupStaleProfileState } from "./profileState.js";
8
9
  import { delay } from "./utils.js";
9
10
  import { isWsl, resolveWslChromeLaunchRoute } from "./wslHost.js";
10
11
  import { BrowserCancellation } from "./cancellation.js";
12
+ import { acquireBrowserConnection } from "./browserConnection.js";
11
13
  export async function launchChrome(config, userDataDir, logger) {
12
14
  const { connectHost, debugBindAddress, usePatchedLauncher } = resolveWslChromeLaunchRoute();
13
15
  const debugPort = config.debugPort ?? parseDebugPortEnv();
@@ -405,6 +407,7 @@ export async function listRemoteChromeTargets(options) {
405
407
  targetId: target.targetId,
406
408
  type: target.type,
407
409
  url: target.url,
410
+ title: target.title,
408
411
  }));
409
412
  }
410
413
  finally {
@@ -429,37 +432,43 @@ export async function connectToRemoteChromeTarget(host, port, logger, options) {
429
432
  }
430
433
  const browser = await connectToBrowserWebSocket(host, port, options.browserWSEndpoint, logger, options.approvalWaitMs);
431
434
  let targetId = options.targetId;
435
+ let createdTargetId;
432
436
  try {
433
437
  if (!targetId) {
434
438
  const created = await browser.Target.createTarget({
435
439
  url: options.targetUrl ?? "about:blank",
436
440
  });
437
441
  targetId = created.targetId;
442
+ createdTargetId = targetId;
438
443
  logger(`Opened dedicated remote Chrome tab targeting ${options.targetUrl ?? "about:blank"}`);
439
444
  }
440
445
  const attached = await browser.Target.attachToTarget({ targetId, flatten: true });
441
446
  const client = createSessionBoundChromeClient(browser, attached.sessionId);
447
+ let closing;
442
448
  return {
443
449
  client,
444
450
  targetId,
445
451
  browserWSEndpoint: options.browserWSEndpoint,
446
- close: async (closeOptions) => {
447
- await browser.Target.detachFromTarget({ sessionId: attached.sessionId }).catch(() => undefined);
452
+ close: (closeOptions) => (closing ??= (async () => {
448
453
  if (options.closeTargetOnDispose && targetId && !closeOptions?.preserveTarget) {
449
454
  await browser.Target.closeTarget({ targetId }).catch(() => undefined);
450
455
  }
451
- await browser.close().catch(() => undefined);
452
- },
456
+ await client.close();
457
+ })()),
453
458
  };
454
459
  }
455
460
  catch (error) {
461
+ if (createdTargetId) {
462
+ await browser.Target.closeTarget({ targetId: createdTargetId }).catch(() => undefined);
463
+ }
456
464
  await browser.close().catch(() => undefined);
457
465
  throw error;
458
466
  }
459
467
  }
460
468
  async function connectToBrowserWebSocket(host, port, browserWSEndpoint, logger, approvalWaitMs) {
469
+ const acquire = () => acquireBrowserConnection(browserWSEndpoint, async () => (await CDP({ target: browserWSEndpoint, local: true })));
461
470
  if (!approvalWaitMs || approvalWaitMs <= 0) {
462
- return (await CDP({ target: browserWSEndpoint, local: true }));
471
+ return acquire();
463
472
  }
464
473
  logger(`[browser] Waiting for Chrome remote debugging approval for ${host}:${port}...`);
465
474
  const startedAt = Date.now();
@@ -474,8 +483,8 @@ async function connectToBrowserWebSocket(host, port, browserWSEndpoint, logger,
474
483
  let timeout;
475
484
  let expired = false;
476
485
  try {
477
- const connecting = CDP({ target: browserWSEndpoint, local: true }).then(async (client) => {
478
- // An approval arriving after our deadline must not leak a connection.
486
+ const connecting = acquire().then(async (client) => {
487
+ // Release this waiter; another request may still be awaiting the same approval.
479
488
  if (expired)
480
489
  await client.close().catch(() => undefined);
481
490
  return client;
@@ -554,6 +563,37 @@ async function connectToNewTarget(host, port, url, logger, messages) {
554
563
  }
555
564
  function createSessionBoundChromeClient(browser, sessionId) {
556
565
  const browserWithEvents = browser;
566
+ const events = new EventEmitter();
567
+ const bridges = new Map();
568
+ let closing;
569
+ const remove = (name, listener) => {
570
+ events.removeListener(name, listener);
571
+ if (events.listenerCount(name) === 0) {
572
+ const bridge = bridges.get(name);
573
+ if (bridge)
574
+ browserWithEvents.removeListener(name, bridge);
575
+ bridges.delete(name);
576
+ }
577
+ };
578
+ const listen = (name, listener, once = false) => {
579
+ if (closing)
580
+ return () => { };
581
+ if (!bridges.has(name)) {
582
+ const bridge = (...args) => events.emit(name, ...args);
583
+ bridges.set(name, bridge);
584
+ browserWithEvents.on(name, bridge);
585
+ }
586
+ if (once)
587
+ events.once(name, listener);
588
+ else
589
+ events.on(name, listener);
590
+ return () => remove(name, listener);
591
+ };
592
+ const onDetached = (event) => {
593
+ if (event.sessionId === sessionId)
594
+ events.emit("disconnect");
595
+ };
596
+ browserWithEvents.on("Target.detachedFromTarget", onDetached);
557
597
  const bindDomain = (domainName) => {
558
598
  const domain = browser[domainName];
559
599
  const eventName = (name) => `${domainName}.${name}.${sessionId}`;
@@ -561,25 +601,34 @@ function createSessionBoundChromeClient(browser, sessionId) {
561
601
  get(target, prop, receiver) {
562
602
  if (prop === "on") {
563
603
  return (name, listener) => {
564
- const domainEvent = target[name];
565
- if (typeof domainEvent === "function") {
566
- return domainEvent(sessionId, listener);
567
- }
568
- browserWithEvents.on(eventName(name), listener);
569
- return () => browserWithEvents.removeListener(eventName(name), listener);
604
+ return listen(eventName(name), listener);
570
605
  };
571
606
  }
572
607
  if (prop === "off" || prop === "removeListener") {
573
608
  return (name, listener) => {
574
- const off = browserWithEvents.off ?? browserWithEvents.removeListener.bind(browserWithEvents);
575
- off(eventName(name), listener);
609
+ remove(eventName(name), listener);
576
610
  };
577
611
  }
578
612
  const value = Reflect.get(target, prop, receiver);
579
613
  if (typeof value !== "function") {
580
614
  return value;
581
615
  }
582
- return (...args) => value(...args, sessionId);
616
+ if (value.category === "event") {
617
+ return (listener) => listener
618
+ ? listen(eventName(String(prop)), listener)
619
+ : new Promise((resolve) => listen(eventName(String(prop)), resolve, true));
620
+ }
621
+ return (...args) => {
622
+ if (closing)
623
+ return Promise.reject(new Error("Chrome page session is closed."));
624
+ if (typeof args[0] === "function") {
625
+ return value({}, sessionId, args[0]);
626
+ }
627
+ if (typeof args[1] === "function") {
628
+ return value(args[0], sessionId, args[1]);
629
+ }
630
+ return value(...args, sessionId);
631
+ };
583
632
  },
584
633
  });
585
634
  };
@@ -598,14 +647,23 @@ function createSessionBoundChromeClient(browser, sessionId) {
598
647
  Input: bindDomain("Input"),
599
648
  DOM: bindDomain("DOM"),
600
649
  Emulation: bindDomain("Emulation"),
601
- on: browserWithEvents.on.bind(browserWithEvents),
602
- once: browserWithEvents.once.bind(browserWithEvents),
603
- off: browserWithEvents.off?.bind(browserWithEvents) ??
604
- browserWithEvents.removeListener.bind(browserWithEvents),
605
- removeListener: browserWithEvents.removeListener.bind(browserWithEvents),
606
- close: async () => {
607
- await browser.Target.detachFromTarget({ sessionId }).catch(() => undefined);
608
- },
650
+ on: (name, listener) => listen(name, listener),
651
+ once: (name, listener) => listen(name, listener, true),
652
+ off: remove,
653
+ removeListener: remove,
654
+ close: () => (closing ??= (async () => {
655
+ for (const [name, bridge] of bridges)
656
+ browserWithEvents.removeListener(name, bridge);
657
+ bridges.clear();
658
+ events.removeAllListeners();
659
+ browserWithEvents.removeListener("Target.detachedFromTarget", onDetached);
660
+ try {
661
+ await browser.Target.detachFromTarget({ sessionId }).catch(() => undefined);
662
+ }
663
+ finally {
664
+ await browser.close();
665
+ }
666
+ })()),
609
667
  };
610
668
  }
611
669
  export async function connectWithNewTab(port, logger, initialUrl, host, options) {
@@ -1210,7 +1210,9 @@ async function runBrowserModeInternal(options, cancellation) {
1210
1210
  await captureRuntimeSnapshot();
1211
1211
  const modelStrategy = config.modelStrategy ?? DEFAULT_MODEL_STRATEGY;
1212
1212
  if (config.desiredModel && modelStrategy !== "ignore" && !isResumingConversation) {
1213
- modelSelectionEvidence = await raceWithDisconnect(withRetries(() => ensureModelSelection(Runtime, config.desiredModel, logger, modelStrategy), {
1213
+ modelSelectionEvidence = await raceWithDisconnect(withRetries(() => ensureModelSelection(Runtime, config.desiredModel, logger, modelStrategy, {
1214
+ implicitDefault: config.modelIsImplicitDefault,
1215
+ }), {
1214
1216
  retries: 2,
1215
1217
  delayMs: 300,
1216
1218
  onRetry: (attempt, error) => {
@@ -2490,6 +2492,8 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2490
2492
  const attached = await cancellation.acquire(() => connectToExistingChatGptTab({
2491
2493
  host,
2492
2494
  port,
2495
+ browserWSEndpoint,
2496
+ approvalWaitMs: config.approvalWaitMs,
2493
2497
  ref: tabRef,
2494
2498
  }), (attached) => attached.client.close());
2495
2499
  client = cancellation.client(attached.client);
@@ -2605,7 +2609,9 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2605
2609
  }
2606
2610
  const modelStrategy = config.modelStrategy ?? DEFAULT_MODEL_STRATEGY;
2607
2611
  if (config.desiredModel && modelStrategy !== "ignore" && !config.resumeConversationUrl) {
2608
- modelSelectionEvidence = await withRetries(() => ensureModelSelection(Runtime, config.desiredModel, logger, modelStrategy), {
2612
+ modelSelectionEvidence = await withRetries(() => ensureModelSelection(Runtime, config.desiredModel, logger, modelStrategy, {
2613
+ implicitDefault: config.modelIsImplicitDefault,
2614
+ }), {
2609
2615
  retries: 2,
2610
2616
  delayMs: 300,
2611
2617
  onRetry: (attempt, error) => {
@@ -5,6 +5,7 @@ import { captureAssistantMarkdown, readAssistantSnapshot } from "./actions/assis
5
5
  import { buildConversationTurnListExpression } from "./conversationTurns.js";
6
6
  import { extractStableConversationIdFromUrl } from "./conversationUrl.js";
7
7
  import { delay } from "./utils.js";
8
+ import { connectToRemoteChromeTarget, listRemoteChromeTargets } from "./chromeLifecycle.js";
8
9
  export const DEFAULT_REMOTE_CHROME_HOST = "127.0.0.1";
9
10
  export const DEFAULT_REMOTE_CHROME_PORT = 9222;
10
11
  const LOGIN_CTA_PATTERN = /\b(log in|login|sign up|sign in|continue with google|continue with microsoft)\b/i;
@@ -22,8 +23,15 @@ function normalizeHostPort(input = {}) {
22
23
  return {
23
24
  host: input.host ?? DEFAULT_REMOTE_CHROME_HOST,
24
25
  port: input.port ?? DEFAULT_REMOTE_CHROME_PORT,
26
+ ...(input.browserWSEndpoint
27
+ ? { browserWSEndpoint: input.browserWSEndpoint, approvalWaitMs: input.approvalWaitMs }
28
+ : {}),
25
29
  };
26
30
  }
31
+ function chromeTransportError(operation, endpoint, error) {
32
+ const detail = error instanceof Error ? error.message.trim() : String(error ?? "").trim();
33
+ return new Error(`Unable to ${operation} on Chrome at ${endpoint.host}:${endpoint.port}. ${detail || "Chrome returned no error details."} Check that Chrome is running and remote debugging is enabled, then retry and allow the connection prompt.`, { cause: error });
34
+ }
27
35
  function normalizeUrl(value) {
28
36
  return String(value ?? "").trim();
29
37
  }
@@ -164,6 +172,7 @@ function buildTabInspectionExpression() {
164
172
  const lastUserText = normalize(lastUserTurn?.textContent);
165
173
  const lastUserMessage = lastUserTurn?.matches?.('[data-message-author-role="user"]')
166
174
  ? lastUserTurn : lastUserTurn?.querySelector?.('[data-message-author-role="user"]');
175
+ const userContent = lastUserMessage?.querySelectorAll?.('[class~="whitespace-pre-wrap"]');
167
176
  const authenticated = !loginButtonExists && (promptReady || sendExists || stopExists || assistantCount > 0);
168
177
  return {
169
178
  title: normalize(document.title),
@@ -181,6 +190,7 @@ function buildTabInspectionExpression() {
181
190
  lastUserTurnIndex,
182
191
  lastUserText,
183
192
  lastUserTextRaw: lastUserMessage?.textContent,
193
+ lastUserContentText: userContent?.length === 1 ? userContent[0].textContent : undefined,
184
194
  lastUserMessageId: lastUserMessage?.getAttribute?.('data-message-id'),
185
195
  visibilityState: document.visibilityState,
186
196
  focused: Boolean(document.hasFocus?.()),
@@ -191,26 +201,67 @@ export function buildTabInspectionExpressionForTest() {
191
201
  return buildTabInspectionExpression();
192
202
  }
193
203
  export async function listChatGptTargets(options = {}) {
194
- const { host, port } = normalizeHostPort(options);
195
- const targets = (await CDP.List({ host, port }));
196
- return targets.filter(isChatGptTarget);
204
+ const endpoint = normalizeHostPort(options);
205
+ try {
206
+ const targets = await listRemoteChromeTargets(endpoint);
207
+ return targets.filter(isChatGptTarget);
208
+ }
209
+ catch (error) {
210
+ throw chromeTransportError("list ChatGPT tabs", endpoint, error);
211
+ }
197
212
  }
198
213
  export async function openChatGptTarget(options = {}) {
199
- const { host, port } = normalizeHostPort(options);
214
+ const endpoint = normalizeHostPort(options);
215
+ const { host, port } = endpoint;
200
216
  const url = options.url ?? "https://chatgpt.com/";
201
- const target = await CDP.New({ host, port, url });
202
- return target.id;
217
+ try {
218
+ if (endpoint.browserWSEndpoint) {
219
+ const connection = await connectToRemoteChromeTarget(host, port, noopLogger, {
220
+ ...endpoint,
221
+ targetUrl: url,
222
+ });
223
+ try {
224
+ if (!connection.targetId)
225
+ throw new Error("Chrome did not return a target ID.");
226
+ return connection.targetId;
227
+ }
228
+ finally {
229
+ await connection.close();
230
+ }
231
+ }
232
+ const target = await CDP.New({ host, port, url });
233
+ return target.id;
234
+ }
235
+ catch (error) {
236
+ throw chromeTransportError("open saved ChatGPT conversation", endpoint, error);
237
+ }
203
238
  }
204
- async function connectToTarget(host, port, targetId) {
205
- const client = await CDP({ host, port, target: targetId });
239
+ async function connectToTarget(options, targetId) {
240
+ const endpoint = normalizeHostPort(options);
241
+ const { host, port } = endpoint;
242
+ const connection = await connectToRemoteChromeTarget(host, port, noopLogger, {
243
+ ...endpoint,
244
+ targetId,
245
+ }).catch((error) => {
246
+ throw chromeTransportError("inspect ChatGPT tab", endpoint, error);
247
+ });
248
+ const client = Object.create(connection.client);
249
+ // Session-bound clients detach only; the owning connection also closes its browser socket.
250
+ client.close = connection.close;
206
251
  const { Runtime, DOM } = client;
207
- if (Runtime?.enable) {
208
- await Runtime.enable();
252
+ try {
253
+ if (Runtime?.enable) {
254
+ await Runtime.enable();
255
+ }
256
+ if (DOM?.enable) {
257
+ await DOM.enable();
258
+ }
259
+ return client;
209
260
  }
210
- if (DOM?.enable) {
211
- await DOM.enable();
261
+ catch (error) {
262
+ await connection.close().catch(() => undefined);
263
+ throw error;
212
264
  }
213
- return client;
214
265
  }
215
266
  export async function inspectChatGptTab(options) {
216
267
  const { host, port } = normalizeHostPort(options);
@@ -219,7 +270,7 @@ export async function inspectChatGptTab(options) {
219
270
  if (!targetId) {
220
271
  throw new Error("inspectChatGptTab requires a target with targetId.");
221
272
  }
222
- const client = await connectToTarget(host, port, targetId);
273
+ const client = await connectToTarget(options, targetId);
223
274
  try {
224
275
  const { Runtime } = client;
225
276
  const evaluation = await Runtime.evaluate({
@@ -268,6 +319,7 @@ export async function inspectChatGptTab(options) {
268
319
  lastAssistantSnippet: trimToSnippet(lastAssistantText),
269
320
  lastUserText,
270
321
  lastUserTextRaw: info.lastUserTextRaw,
322
+ lastUserContentText: info.lastUserContentText,
271
323
  lastUserMessageId: info.lastUserMessageId,
272
324
  lastUserSnippet: trimToSnippet(lastUserText),
273
325
  focused: Boolean(info.focused),
@@ -305,11 +357,11 @@ export function classifyTabState(summary) {
305
357
  }
306
358
  export async function collectChatGptTabs(options = {}) {
307
359
  const { host, port } = normalizeHostPort(options);
308
- const targets = await listChatGptTargets({ host, port });
360
+ const targets = await listChatGptTargets(options);
309
361
  const summaries = [];
310
362
  for (const target of targets) {
311
363
  try {
312
- const summary = await inspectChatGptTab({ host, port, target });
364
+ const summary = await inspectChatGptTab({ ...options, target });
313
365
  summaries.push(summary);
314
366
  }
315
367
  catch (error) {
@@ -383,28 +435,33 @@ export function resolveChatGptTabFromSummariesForTest(summaries, ref) {
383
435
  return resolveChatGptTabFromSummaries(summaries, ref);
384
436
  }
385
437
  export async function resolveChatGptTab(options = {}) {
386
- const { host, port } = normalizeHostPort(options);
387
- const summaries = await collectChatGptTabs({ host, port });
438
+ const ref = options.ref?.trim();
439
+ if (ref && ref.toLowerCase() !== "current") {
440
+ const targets = await listChatGptTargets(options);
441
+ const exact = targets.find((target) => extractTargetId(target) === ref ||
442
+ target.url === ref ||
443
+ extractConversationIdFromUrl(target.url ?? "") === ref);
444
+ if (exact)
445
+ return inspectChatGptTab({ ...options, target: exact });
446
+ }
447
+ const summaries = await collectChatGptTabs(options);
388
448
  return resolveChatGptTabFromSummaries(summaries, options.ref);
389
449
  }
390
450
  export async function connectToExistingChatGptTab(options = {}) {
391
- const { host, port } = normalizeHostPort(options);
392
- const tab = await resolveChatGptTab({ host, port, ref: options.ref });
393
- const client = await connectToTarget(host, port, tab.targetId);
451
+ const tab = await resolveChatGptTab(options);
452
+ const client = await connectToTarget(options, tab.targetId);
394
453
  return { client, targetId: tab.targetId, tab };
395
454
  }
396
455
  export async function harvestChatGptTab(options = {}) {
397
- const { host, port } = normalizeHostPort(options);
398
456
  const resolved = options.target
399
- ? await inspectChatGptTab({ host, port, target: options.target })
400
- : await resolveChatGptTab({ host, port, ref: options.ref });
401
- const client = await connectToTarget(host, port, resolved.targetId);
457
+ ? await inspectChatGptTab({ ...options, target: options.target })
458
+ : await resolveChatGptTab(options);
459
+ const client = await connectToTarget(options, resolved.targetId);
402
460
  try {
403
461
  const { Runtime } = client;
404
462
  const snapshot = await readAssistantSnapshot(Runtime).catch(() => null);
405
463
  const nowSummary = await inspectChatGptTab({
406
- host,
407
- port,
464
+ ...options,
408
465
  target: {
409
466
  targetId: resolved.targetId,
410
467
  title: resolved.title,
@@ -448,8 +505,7 @@ export async function harvestChatGptTab(options = {}) {
448
505
  const firstFingerprint = harvested.fingerprint;
449
506
  await delay(options.stallWindowMs);
450
507
  const followup = await inspectChatGptTab({
451
- host,
452
- port,
508
+ ...options,
453
509
  target: {
454
510
  targetId: harvested.targetId,
455
511
  title: harvested.title,
@@ -468,6 +524,7 @@ export async function harvestChatGptTab(options = {}) {
468
524
  harvested.loginButtonExists = followup.loginButtonExists;
469
525
  harvested.lastUserText = followup.lastUserText;
470
526
  harvested.lastUserTextRaw = followup.lastUserTextRaw;
527
+ harvested.lastUserContentText = followup.lastUserContentText;
471
528
  harvested.lastUserMessageId = followup.lastUserMessageId;
472
529
  harvested.lastUserSnippet = followup.lastUserSnippet;
473
530
  harvested.assistantFollowsLatestUser = followup.assistantFollowsLatestUser;
@@ -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 { resolveBrowserConfig } from "../browser/config.js";
6
+ import { formatWebSocketHost, readDevToolsActivePortInfo } from "../browser/detect.js";
6
7
  import { browserPromptFingerprint } from "../browser/promptFingerprint.js";
7
8
  import { collectChatGptTabs, DEFAULT_REMOTE_CHROME_HOST, DEFAULT_REMOTE_CHROME_PORT, formatBrowserTabState, harvestChatGptTab, sessionMatchesTab, } from "../browser/liveTabs.js";
8
9
  import { isRecoveredConversationHarvestReady, recoverConversationTab, } from "../browser/recoverConversation.js";
@@ -41,7 +42,10 @@ function harvestMatchesSessionPrompt(harvested, fingerprint) {
41
42
  return (fingerprint === undefined ||
42
43
  (typeof harvested.lastUserMessageId === "string" &&
43
44
  harvested.lastUserMessageId.trim().length > 0 &&
44
- browserPromptFingerprint(harvested.lastUserTextRaw ?? harvested.lastUserText, harvested.lastUserMessageId) === fingerprint));
45
+ // ChatGPT can append transient status text outside the user's content after submission.
46
+ // Keep legacy full-container hashes valid and require an exact match for either form.
47
+ [harvested.lastUserTextRaw ?? harvested.lastUserText, harvested.lastUserContentText].some((text) => typeof text === "string" &&
48
+ browserPromptFingerprint(text, harvested.lastUserMessageId) === fingerprint)));
45
49
  }
46
50
  async function harvestSessionPrompt(meta, options, requireSessionPrompt = true) {
47
51
  const fingerprint = requireSessionPrompt ? meta.browser?.runtime?.submittedPromptHash : undefined;
@@ -63,7 +67,7 @@ async function harvestSessionPrompt(meta, options, requireSessionPrompt = true)
63
67
  }
64
68
  return harvested;
65
69
  }
66
- function sessionBrowserEndpoint(meta) {
70
+ async function sessionBrowserEndpoint(meta) {
67
71
  const runtime = meta?.browser?.runtime ?? {};
68
72
  const remote = meta?.browser?.config?.remoteChrome ?? {};
69
73
  const host = runtime.chromeHost ?? remote.host;
@@ -71,20 +75,65 @@ function sessionBrowserEndpoint(meta) {
71
75
  if (!host || !port) {
72
76
  return null;
73
77
  }
74
- return { host, port };
78
+ let browserWSEndpoint = runtime.chromeBrowserWSEndpoint;
79
+ let livePort = port;
80
+ if (browserWSEndpoint) {
81
+ const active = runtime.chromeProfileRoot
82
+ ? await readDevToolsActivePortInfo(runtime.chromeProfileRoot, { host }).catch(() => null)
83
+ : null;
84
+ if (active) {
85
+ browserWSEndpoint = active.browserWSEndpoint;
86
+ livePort = active.port;
87
+ }
88
+ else {
89
+ // A restarted Chrome can keep its port while changing its browser socket ID.
90
+ const controller = new AbortController();
91
+ const timeout = setTimeout(() => controller.abort(), 1000);
92
+ try {
93
+ const response = await fetch(`http://${formatWebSocketHost(host)}:${port}/json/version`, {
94
+ signal: controller.signal,
95
+ });
96
+ if (response.ok) {
97
+ const version = (await response.json());
98
+ const advertised = new URL(version.webSocketDebuggerUrl ?? "");
99
+ if (advertised.pathname.startsWith("/devtools/browser/")) {
100
+ const refreshed = new URL(browserWSEndpoint);
101
+ refreshed.pathname = advertised.pathname;
102
+ browserWSEndpoint = refreshed.toString();
103
+ }
104
+ }
105
+ }
106
+ catch {
107
+ // Attach-running Chrome may disable HTTP discovery; keep its saved socket.
108
+ }
109
+ finally {
110
+ clearTimeout(timeout);
111
+ controller.abort();
112
+ }
113
+ }
114
+ }
115
+ return {
116
+ host,
117
+ port: livePort,
118
+ ...(browserWSEndpoint
119
+ ? {
120
+ browserWSEndpoint,
121
+ approvalWaitMs: resolveBrowserConfig(meta?.browser?.config).approvalWaitMs,
122
+ }
123
+ : {}),
124
+ };
75
125
  }
76
- function collectUniqueEndpoints(metas) {
126
+ async function collectUniqueEndpoints(metas) {
77
127
  const entries = new Map();
78
- entries.set(`${DEFAULT_REMOTE_CHROME_HOST}:${DEFAULT_REMOTE_CHROME_PORT}`, {
128
+ entries.set(`${DEFAULT_REMOTE_CHROME_HOST}:${DEFAULT_REMOTE_CHROME_PORT}:http`, {
79
129
  host: DEFAULT_REMOTE_CHROME_HOST,
80
130
  port: DEFAULT_REMOTE_CHROME_PORT,
81
131
  });
82
- for (const meta of metas) {
83
- const endpoint = sessionBrowserEndpoint(meta);
132
+ for (const endpoint of await Promise.all(metas.map(sessionBrowserEndpoint))) {
84
133
  if (!endpoint) {
85
134
  continue;
86
135
  }
87
- entries.set(`${endpoint.host}:${endpoint.port}`, endpoint);
136
+ entries.set(`${endpoint.host}:${endpoint.port}:${endpoint.browserWSEndpoint ?? "http"}`, endpoint);
88
137
  }
89
138
  return Array.from(entries.values());
90
139
  }
@@ -151,7 +200,7 @@ async function maybeWriteHarvestOutput(pathInput, cwd, content) {
151
200
  }
152
201
  export async function showBrowserTabsStatus() {
153
202
  const metas = await sessionStore.listSessions().catch(() => []);
154
- const endpoints = collectUniqueEndpoints(metas);
203
+ const endpoints = await collectUniqueEndpoints(metas);
155
204
  let printedAny = false;
156
205
  for (const endpoint of endpoints) {
157
206
  let tabs;
@@ -188,7 +237,7 @@ export async function harvestSessionBrowserOutput(sessionId, options = {}) {
188
237
  if (!meta) {
189
238
  throw new Error(`No session found with ID ${sessionId}.`);
190
239
  }
191
- const recordedEndpoint = sessionBrowserEndpoint(meta);
240
+ const recordedEndpoint = await sessionBrowserEndpoint(meta);
192
241
  const initialEndpoint = recordedEndpoint ?? {
193
242
  host: DEFAULT_REMOTE_CHROME_HOST,
194
243
  port: DEFAULT_REMOTE_CHROME_PORT,
@@ -200,8 +249,7 @@ export async function harvestSessionBrowserOutput(sessionId, options = {}) {
200
249
  let harvested;
201
250
  try {
202
251
  harvested = await harvestSessionPrompt(meta, {
203
- host: initialEndpoint.host,
204
- port: initialEndpoint.port,
252
+ ...initialEndpoint,
205
253
  ref,
206
254
  stallWindowMs: options.stallWindowMs,
207
255
  }, !options.browserTabRef);
@@ -219,6 +267,8 @@ export async function harvestSessionBrowserOutput(sessionId, options = {}) {
219
267
  harvested = await harvestSessionPrompt(meta, {
220
268
  host: recovered.host,
221
269
  port: recovered.port,
270
+ browserWSEndpoint: recovered.browserWSEndpoint,
271
+ approvalWaitMs: recovered.approvalWaitMs,
222
272
  ref: recovered.ref,
223
273
  stallWindowMs: options.stallWindowMs,
224
274
  });
@@ -244,7 +294,7 @@ export async function liveTailSessionBrowserOutput(sessionId, options = {}) {
244
294
  if (!meta) {
245
295
  throw new Error(`No session found with ID ${sessionId}.`);
246
296
  }
247
- const recordedEndpoint = sessionBrowserEndpoint(meta);
297
+ const recordedEndpoint = await sessionBrowserEndpoint(meta);
248
298
  let endpoint = recordedEndpoint ?? {
249
299
  host: DEFAULT_REMOTE_CHROME_HOST,
250
300
  port: DEFAULT_REMOTE_CHROME_PORT,
@@ -261,8 +311,7 @@ export async function liveTailSessionBrowserOutput(sessionId, options = {}) {
261
311
  // Probe once to see if the live tab is still alive; recover if not.
262
312
  try {
263
313
  await harvestChatGptTab({
264
- host: endpoint.host,
265
- port: endpoint.port,
314
+ ...endpoint,
266
315
  ref: browserTabRef,
267
316
  });
268
317
  }
@@ -277,15 +326,19 @@ export async function liveTailSessionBrowserOutput(sessionId, options = {}) {
277
326
  waitForReady: false,
278
327
  });
279
328
  recoveredChrome = recovered.chrome;
280
- endpoint = { host: recovered.host, port: recovered.port };
329
+ endpoint = {
330
+ host: recovered.host,
331
+ port: recovered.port,
332
+ browserWSEndpoint: recovered.browserWSEndpoint,
333
+ approvalWaitMs: recovered.approvalWaitMs,
334
+ };
281
335
  browserTabRef = recovered.ref;
282
336
  requireRecoveredContent = true;
283
337
  recoveredContentDeadlineMs = Date.now() + stallThresholdMs;
284
338
  }
285
339
  while (true) {
286
340
  const harvested = await harvestChatGptTab({
287
- host: endpoint.host,
288
- port: endpoint.port,
341
+ ...endpoint,
289
342
  ref: browserTabRef,
290
343
  });
291
344
  const fullText = harvested.lastAssistantMarkdown ?? harvested.lastAssistantText ?? "";
@@ -1,4 +1,13 @@
1
1
  const LOGGED_SYMBOL = Symbol("oracle.alreadyLogged");
2
+ export function formatCliError(error) {
3
+ const message = error instanceof Error ? error.message : typeof error === "string" ? error : "";
4
+ if (message.trim())
5
+ return message;
6
+ const code = error && typeof error === "object" && "code" in error ? error.code : undefined;
7
+ if (typeof code === "string" && code.trim())
8
+ return `Operation failed (${code}).`;
9
+ return "An unexpected error occurred. Retry with --verbose for more details.";
10
+ }
2
11
  export function markErrorLogged(error) {
3
12
  if (error instanceof Error) {
4
13
  error[LOGGED_SYMBOL] = true;
@@ -299,6 +299,7 @@ export function buildConsultBrowserConfig({ userConfig, env, runModel, inputMode
299
299
  researchMode: browserResearchMode ?? configuredBrowser.researchMode,
300
300
  archiveConversations: browserArchive ?? configuredBrowser.archiveConversations,
301
301
  desiredModel: desiredModelLabel || mapModelToBrowserLabel(runModel),
302
+ modelIsImplicitDefault: !inputModel && !userConfig.model && !browserModelLabel,
302
303
  };
303
304
  }
304
305
  export function buildConsultDryRunResolved({ resolvedEngine, runOptions, browserConfig, }) {
@@ -436,13 +437,6 @@ export async function runConsultTool(input, { log: requestLog, launchDetached =
436
437
  const cwd = process.cwd();
437
438
  const sendLog = (text, level = "info") => requestLog(level, { text, bytes: Buffer.byteLength(text, "utf8") }).catch(() => { });
438
439
  const resolvedRemote = resolveRemoteServiceConfig({ userConfig, env: process.env });
439
- const imageOutputPath = runOptions.generateImage ?? runOptions.outputPath;
440
- if (resolvedEngine === "browser" && resolvedRemote.host && imageOutputPath) {
441
- return {
442
- isError: true,
443
- content: textContent("ChatGPT image output is not supported with a remote browser service: generated files are not transferred back to the MCP caller. Unset ORACLE_REMOTE_HOST to generate images locally, or omit generateImage/outputPath."),
444
- };
445
- }
446
440
  let browserConfig;
447
441
  if (resolvedEngine === "browser") {
448
442
  browserConfig = buildConsultBrowserConfig({
@@ -5,11 +5,12 @@ import { pipeline } from "node:stream/promises";
5
5
  import path from "node:path";
6
6
  import { mkdir, readFile, rename, rm, stat } from "node:fs/promises";
7
7
  import { appendArtifacts, computeFileSha256, resolveSessionArtifactsDir, resolveUniqueArtifactPath, sanitizeArtifactFilename, sanitizeArtifactMimeType, validateArtifactFile, } from "../browser/artifacts.js";
8
- import { MAX_REMOTE_ARTIFACT_BYTES, } from "./types.js";
8
+ import { MAX_REMOTE_ARTIFACT_BYTES, pickRemoteImageMetadata, } from "./types.js";
9
9
  import { materializeStagedFallbackBundle } from "../browser/prompt.js";
10
10
  import { checkRemoteHealth } from "./health.js";
11
11
  import { parseHostPort } from "../bridge/connection.js";
12
12
  import { BrowserRunCancelledError } from "../oracle/errors.js";
13
+ import { resolveSiblingImagePath } from "../browser/chatgptImages.js";
13
14
  export function createRemoteBrowserExecutor({ host, token }) {
14
15
  // Return a drop-in replacement for runBrowserMode so the browser session runner can stay unchanged.
15
16
  return async function remoteBrowserExecutor(options) {
@@ -17,13 +18,20 @@ export function createRemoteBrowserExecutor({ host, token }) {
17
18
  throw new Error("Web Search is a local browser pilot; --remote-host does not negotiate this capability yet. Use local Chrome or --browser-attach-running.");
18
19
  }
19
20
  const callerSignal = options.signal;
21
+ const imageOutputRequested = Boolean(options.generateImagePath || options.outputPath);
20
22
  if (callerSignal?.aborted)
21
23
  throw new BrowserRunCancelledError("Browser run cancelled before the request was sent.");
22
- if (callerSignal) {
24
+ if (callerSignal || imageOutputRequested) {
23
25
  const health = await checkRemoteHealth({ host, token, signal: callerSignal });
24
- if (callerSignal.aborted)
26
+ if (callerSignal?.aborted)
25
27
  throw new BrowserRunCancelledError();
26
- if (health.capabilities?.runCancellation !== true)
28
+ if (imageOutputRequested &&
29
+ (!health.ok ||
30
+ health.capabilities?.generatedImages !== true ||
31
+ health.capabilities.artifactProtocolVersion !== 1)) {
32
+ throw new Error("Remote host cannot capture and transfer generated images; upgrade Oracle on the host and retry. The image request was not sent.");
33
+ }
34
+ if (callerSignal && health.capabilities?.runCancellation !== true)
27
35
  throw new Error("Remote host does not support run cancellation; upgrade the host before using an AbortSignal.");
28
36
  }
29
37
  const payload = {
@@ -37,6 +45,7 @@ export function createRemoteBrowserExecutor({ host, token }) {
37
45
  sessionId: options.sessionId,
38
46
  followUpPrompts: options.followUpPrompts,
39
47
  cancelOnDisconnect: callerSignal ? true : undefined,
48
+ imageOutputRequested,
40
49
  },
41
50
  };
42
51
  const body = Buffer.from(JSON.stringify(payload));
@@ -46,10 +55,12 @@ export function createRemoteBrowserExecutor({ host, token }) {
46
55
  reject(new Error("Browser run cancelled before the request was sent."));
47
56
  return;
48
57
  }
49
- const transferredFiles = [];
58
+ const transferredArtifacts = [];
50
59
  const transferFailures = [];
51
60
  const transferPromises = [];
52
61
  let artifactTransferQueue = Promise.resolve();
62
+ const preferredImagePath = options.generateImagePath ?? options.outputPath;
63
+ let preferredImageIndex = 0;
53
64
  let settled = false;
54
65
  let resolved = null;
55
66
  const fail = (error) => {
@@ -95,7 +106,7 @@ export function createRemoteBrowserExecutor({ host, token }) {
95
106
  resolved = result;
96
107
  },
97
108
  onArtifact: (artifact) => {
98
- transferredFiles.push(artifact);
109
+ transferredArtifacts.push(artifact);
99
110
  },
100
111
  onArtifactFailure: (message) => {
101
112
  transferFailures.push(message);
@@ -105,6 +116,12 @@ export function createRemoteBrowserExecutor({ host, token }) {
105
116
  artifactTransferQueue = queued.catch(() => undefined);
106
117
  return queued;
107
118
  },
119
+ resolvePreferredImagePath: (descriptor) => {
120
+ if (!preferredImagePath)
121
+ return undefined;
122
+ const extension = path.extname(descriptor.filename).slice(1) || "png";
123
+ return resolveSiblingImagePath(path.resolve(preferredImagePath), preferredImageIndex++, extension);
124
+ },
108
125
  onError: fail,
109
126
  });
110
127
  if (transferPromise) {
@@ -123,9 +140,18 @@ export function createRemoteBrowserExecutor({ host, token }) {
123
140
  fail(new Error("Remote browser run completed without a result."));
124
141
  return;
125
142
  }
143
+ if (preferredImagePath) {
144
+ const images = transferredArtifacts.filter((artifact) => artifact.kind === "image");
145
+ if (images.length === 0 ||
146
+ images.length !== preferredImageIndex ||
147
+ resolved.warnings?.some((warning) => warning.code === "remote-image-registration-failed")) {
148
+ fail(new Error("Remote image output was not fully delivered. Inspect the bridge host's generated images and the artifact transfer diagnostics before retrying."));
149
+ return;
150
+ }
151
+ }
126
152
  settled = true;
127
153
  callerSignal?.removeEventListener("abort", onCallerAbort);
128
- resolve(mergeTransferredArtifacts(resolved, transferredFiles, transferFailures));
154
+ resolve(mergeTransferredArtifacts(resolved, transferredArtifacts, transferFailures));
129
155
  })().catch(fail);
130
156
  });
131
157
  res.on("error", fail);
@@ -222,12 +248,16 @@ function handleEvent(params) {
222
248
  }
223
249
  if (event.type === "artifact-ready") {
224
250
  const displayFilename = sanitizeArtifactFilename(String(event.artifact?.filename ?? ""), "artifact.bin");
251
+ const preferredPath = event.artifact.kind === "image"
252
+ ? params.resolvePreferredImagePath(event.artifact)
253
+ : undefined;
225
254
  const transfer = params.enqueueArtifactTransfer(() => transferRemoteArtifact({
226
255
  hostname: params.hostname,
227
256
  port: params.port,
228
257
  token: params.token,
229
258
  descriptor: event.artifact,
230
259
  sessionId: params.options.sessionId,
260
+ preferredPath,
231
261
  signal: params.options.signal,
232
262
  log: params.options.log,
233
263
  })
@@ -256,9 +286,11 @@ async function transferRemoteArtifact(params) {
256
286
  validateRemoteArtifactDescriptor(params.descriptor);
257
287
  const sessionId = params.sessionId ?? params.descriptor.runId;
258
288
  const artifactsDir = resolveSessionArtifactsDir(sessionId);
259
- await mkdir(artifactsDir, { recursive: true });
260
289
  const filename = sanitizeArtifactFilename(params.descriptor.filename, `artifact-${params.descriptor.artifactId}.bin`);
261
- const finalPath = await resolveUniqueArtifactPath(path.join(artifactsDir, filename));
290
+ const finalPath = params.preferredPath
291
+ ? path.resolve(params.preferredPath)
292
+ : await resolveUniqueArtifactPath(path.join(artifactsDir, filename));
293
+ await mkdir(path.dirname(finalPath), { recursive: true });
262
294
  const partPath = `${finalPath}.part-${params.descriptor.artifactId}`;
263
295
  const artifactPath = `/runs/${encodeURIComponent(params.descriptor.runId)}/artifacts/${encodeURIComponent(params.descriptor.artifactId)}`;
264
296
  params.log?.(`[browser] Transferring artifact ${filename} from bridge host...`);
@@ -300,8 +332,7 @@ async function transferRemoteArtifact(params) {
300
332
  await rename(partPath, finalPath);
301
333
  params.log?.(`[browser] Transferred artifact to ${finalPath}`);
302
334
  const publishedFilename = path.basename(finalPath);
303
- return {
304
- kind: "file",
335
+ const baseArtifact = {
305
336
  path: finalPath,
306
337
  label: publishedFilename,
307
338
  mimeType: sanitizeArtifactMimeType(params.descriptor.mimeType),
@@ -313,6 +344,17 @@ async function transferRemoteArtifact(params) {
313
344
  origin: { mode: "bridge" },
314
345
  url: "bridge-artifact",
315
346
  finalUrl: "bridge-artifact",
347
+ };
348
+ if (params.descriptor.kind === "image") {
349
+ return {
350
+ ...baseArtifact,
351
+ kind: "image",
352
+ ...pickRemoteImageMetadata(params.descriptor.image),
353
+ };
354
+ }
355
+ return {
356
+ ...baseArtifact,
357
+ kind: "file",
316
358
  filename: publishedFilename,
317
359
  };
318
360
  }
@@ -371,7 +413,7 @@ async function downloadArtifactToFile(params) {
371
413
  function validateRemoteArtifactDescriptor(descriptor) {
372
414
  if (!descriptor ||
373
415
  typeof descriptor !== "object" ||
374
- descriptor.kind !== "file" ||
416
+ (descriptor.kind !== "file" && descriptor.kind !== "image") ||
375
417
  typeof descriptor.runId !== "string" ||
376
418
  !/^[a-zA-Z0-9_-]{1,128}$/.test(descriptor.runId) ||
377
419
  typeof descriptor.artifactId !== "string" ||
@@ -385,9 +427,12 @@ function validateRemoteArtifactDescriptor(descriptor) {
385
427
  throw new Error("invalid bridge artifact descriptor");
386
428
  }
387
429
  }
388
- function mergeTransferredArtifacts(result, transferredFiles, transferFailures) {
389
- const artifacts = appendArtifacts(result.artifacts, transferredFiles);
430
+ function mergeTransferredArtifacts(result, transferredArtifacts, transferFailures) {
431
+ const transferredFiles = transferredArtifacts.filter((artifact) => artifact.kind === "file");
432
+ const transferredImages = transferredArtifacts.filter((artifact) => artifact.kind === "image");
433
+ const artifacts = appendArtifacts(result.artifacts, transferredArtifacts);
390
434
  const savedFiles = appendSavedFiles(result.savedFiles, transferredFiles);
435
+ const savedImages = appendSavedImages(result.savedImages, transferredImages);
391
436
  const warnings = [
392
437
  ...(result.warnings ?? []),
393
438
  ...transferFailures.map((message) => ({
@@ -400,9 +445,21 @@ function mergeTransferredArtifacts(result, transferredFiles, transferFailures) {
400
445
  ...result,
401
446
  artifacts,
402
447
  savedFiles,
448
+ savedImages,
403
449
  warnings: warnings.length > 0 ? warnings : undefined,
404
450
  };
405
451
  }
452
+ function appendSavedImages(existing, additions) {
453
+ const merged = new Map();
454
+ for (const artifact of existing ?? []) {
455
+ merged.set(artifact.path, artifact);
456
+ }
457
+ for (const artifact of additions) {
458
+ merged.set(artifact.path, artifact);
459
+ }
460
+ const values = Array.from(merged.values());
461
+ return values.length > 0 ? values : undefined;
462
+ }
406
463
  function appendSavedFiles(existing, additions) {
407
464
  const merged = new Map();
408
465
  for (const artifact of existing ?? []) {
@@ -93,6 +93,7 @@ function parseCapabilities(value) {
93
93
  return {
94
94
  ...(raw.deferredFallbackBundling === true ? { deferredFallbackBundling: true } : {}),
95
95
  ...(raw.runCancellation === true ? { runCancellation: true } : {}),
96
+ ...(raw.generatedImages === true ? { generatedImages: true } : {}),
96
97
  artifactTransfer: true,
97
98
  artifactProtocolVersion,
98
99
  maxArtifactBytes: Math.min(maxArtifactBytes, MAX_REMOTE_ARTIFACT_BYTES),
@@ -14,20 +14,21 @@ import { resolveBrowserConfig } from "../browser/config.js";
14
14
  import { RunSlots } from "./runSlots.js";
15
15
  export { RunSlots } from "./runSlots.js";
16
16
  import { loadUserConfig } from "../config.js";
17
- import { MAX_REMOTE_ARTIFACT_BYTES } from "./types.js";
17
+ import { MAX_REMOTE_ARTIFACT_BYTES, pickRemoteImageMetadata } from "./types.js";
18
18
  import { getCookies } from "@steipete/sweet-cookie";
19
19
  import { CHATGPT_URL } from "../browser/constants.js";
20
20
  import { getCliVersion } from "../version.js";
21
21
  import { getOracleHomeDir } from "../oracleHome.js";
22
22
  import { cleanupStaleProfileState, readDevToolsPort, verifyDevToolsReachable, writeChromePid, writeDevToolsActivePort, } from "../browser/profileState.js";
23
23
  import { normalizeChatgptUrl } from "../browser/utils.js";
24
- import { computeFileSha256, sanitizeArtifactFilename, sanitizeArtifactMimeType, validateArtifactFile, } from "../browser/artifacts.js";
24
+ import { computeFileSha256, resolveSessionArtifactsDir, sanitizeArtifactFilename, sanitizeArtifactMimeType, validateArtifactFile, } from "../browser/artifacts.js";
25
25
  const ARTIFACT_PROTOCOL_VERSION = 1;
26
26
  const REMOTE_ARTIFACT_TTL_MS = 30 * 60 * 1000;
27
27
  const ARTIFACT_CAPABILITIES = {
28
28
  runCancellation: true,
29
29
  deferredFallbackBundling: true,
30
30
  artifactTransfer: true,
31
+ generatedImages: true,
31
32
  artifactProtocolVersion: ARTIFACT_PROTOCOL_VERSION,
32
33
  maxArtifactBytes: MAX_REMOTE_ARTIFACT_BYTES,
33
34
  };
@@ -334,6 +335,9 @@ export async function createRemoteServer(options = {}, deps = {}) {
334
335
  ? payload.options.sessionId.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 32)
335
336
  : "remote";
336
337
  payload.options.sessionId = `${clientSession || "remote"}-${runId}`;
338
+ const hostImageOutputPath = payload.options.imageOutputRequested === true
339
+ ? path.join(resolveSessionArtifactsDir(payload.options.sessionId), "generated.png")
340
+ : undefined;
337
341
  if (browserTabCap !== undefined)
338
342
  payload.browserConfig.maxConcurrentTabs = browserTabCap;
339
343
  signal?.throwIfAborted();
@@ -362,6 +366,7 @@ export async function createRemoteServer(options = {}, deps = {}) {
362
366
  heartbeatIntervalMs: payload.options.heartbeatIntervalMs,
363
367
  verbose: payload.options.verbose,
364
368
  sessionId: payload.options.sessionId,
369
+ generateImagePath: hostImageOutputPath,
365
370
  followUpPrompts: payload.options.followUpPrompts,
366
371
  });
367
372
  signal?.throwIfAborted();
@@ -615,13 +620,14 @@ function remoteArtifactKey(runId, artifactId) {
615
620
  async function registerRemoteArtifacts(params) {
616
621
  pruneExpiredArtifacts(params.artifactRegistry);
617
622
  const seen = new Set();
618
- const fileArtifacts = [
623
+ const transferableArtifacts = [
619
624
  ...(params.result.savedFiles ?? []),
620
- ...(params.result.artifacts ?? []).filter((artifact) => artifact.kind === "file"),
625
+ ...(params.result.savedImages ?? []),
626
+ ...(params.result.artifacts ?? []).filter((artifact) => artifact.kind === "file" || artifact.kind === "image"),
621
627
  ];
622
628
  const descriptors = [];
623
629
  const warnings = [];
624
- for (const artifact of fileArtifacts) {
630
+ for (const artifact of transferableArtifacts) {
625
631
  if (!artifact?.path || seen.has(artifact.path)) {
626
632
  continue;
627
633
  }
@@ -630,7 +636,9 @@ async function registerRemoteArtifacts(params) {
630
636
  const filename = sanitizeArtifactFilename(path.basename(artifact.path), "artifact.bin");
631
637
  params.logger(`[serve] Skipping remote artifact descriptor: ${error instanceof Error ? error.message : String(error)}`);
632
638
  warnings.push({
633
- code: "remote-artifact-registration-failed",
639
+ code: artifact.kind === "image"
640
+ ? "remote-image-registration-failed"
641
+ : "remote-artifact-registration-failed",
634
642
  severity: "warning",
635
643
  message: `Oracle captured the browser text response, but the bridge host could not prepare ${filename} for transfer. ` +
636
644
  "Open the ChatGPT browser on the bridge host, download the ZIP/file shown in the current response, and copy it to a cloud-readable path.",
@@ -675,7 +683,12 @@ async function buildRemoteArtifactRegistration(runId, artifact) {
675
683
  descriptor: {
676
684
  artifactId: randomUUID(),
677
685
  runId,
678
- kind: "file",
686
+ kind: artifact.kind === "image" ? "image" : "file",
687
+ ...(artifact.kind === "image"
688
+ ? {
689
+ image: pickRemoteImageMetadata(artifact),
690
+ }
691
+ : {}),
679
692
  filename,
680
693
  mimeType,
681
694
  byteSize: fileStat.size,
@@ -740,6 +753,7 @@ const CLIENT_BROWSER_CONFIG_FIELDS = [
740
753
  "chatgptUrl",
741
754
  "url",
742
755
  "desiredModel",
756
+ "modelIsImplicitDefault",
743
757
  "modelStrategy",
744
758
  "thinkingTime",
745
759
  "researchMode",
@@ -805,10 +819,37 @@ async function stageRemoteAttachments(payload, directory, defaultPrefix) {
805
819
  }
806
820
  // Return conversation identity and observed selection; keep process/profile details on the host.
807
821
  function sanitizeResult(result, warnings = []) {
822
+ const hostArtifactPaths = [
823
+ ...(result.savedFiles ?? []),
824
+ ...(result.savedImages ?? []),
825
+ ...(result.artifacts ?? []),
826
+ ]
827
+ .map((artifact) => artifact.path)
828
+ .filter((artifactPath) => Boolean(artifactPath));
829
+ const savedImagePaths = [
830
+ ...(result.savedImages ?? []),
831
+ ...(result.artifacts ?? []).filter((artifact) => artifact.kind === "image"),
832
+ ].map((artifact) => artifact.path);
833
+ const imageCount = new Set(savedImagePaths).size;
834
+ const sanitizeAnswer = (value) => {
835
+ let sanitized = value;
836
+ // Local save notices describe the host filesystem, not the client's transferred files.
837
+ for (const imagePath of savedImagePaths) {
838
+ sanitized = sanitized
839
+ ?.split(` Saved to: ${imagePath}`)
840
+ .join("")
841
+ .split(` Saved ${imageCount} file(s) starting at: ${imagePath}`)
842
+ .join("");
843
+ }
844
+ for (const artifactPath of hostArtifactPaths) {
845
+ sanitized = sanitized?.split(artifactPath).join(path.basename(artifactPath));
846
+ }
847
+ return sanitized;
848
+ };
808
849
  return {
809
- answerText: result.answerText,
810
- answerMarkdown: result.answerMarkdown,
811
- answerHtml: result.answerHtml,
850
+ answerText: sanitizeAnswer(result.answerText) ?? "",
851
+ answerMarkdown: sanitizeAnswer(result.answerMarkdown) ?? "",
852
+ answerHtml: sanitizeAnswer(result.answerHtml),
812
853
  tookMs: result.tookMs,
813
854
  answerTokens: result.answerTokens,
814
855
  answerChars: result.answerChars,
@@ -1 +1,14 @@
1
1
  export const MAX_REMOTE_ARTIFACT_BYTES = 512 * 1024 * 1024;
2
+ export function pickRemoteImageMetadata(image = {}) {
3
+ return {
4
+ ...(Number.isSafeInteger(image.width) && Number(image.width) > 0
5
+ ? { width: Number(image.width) }
6
+ : {}),
7
+ ...(Number.isSafeInteger(image.height) && Number(image.height) > 0
8
+ ? { height: Number(image.height) }
9
+ : {}),
10
+ ...(typeof image.fileId === "string" && /^file[-_][a-z0-9_-]{1,200}$/i.test(image.fileId)
11
+ ? { fileId: image.fileId }
12
+ : {}),
13
+ };
14
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steipete/oracle",
3
- "version": "0.20.1",
3
+ "version": "0.20.2",
4
4
  "description": "CLI wrapper around OpenAI Responses API with GPT-5.6 Sol, GPT-5.6, GPT-5.5 Pro, GPT-5.5, GPT-5.4, GPT-5.2, GPT-5.1, and GPT-5.1 Codex high reasoning modes.",
5
5
  "keywords": [],
6
6
  "homepage": "https://askoracle.sh",
@@ -80,7 +80,7 @@
80
80
  "shiki": "^4.4.3",
81
81
  "toasted-notifier": "^10.1.0",
82
82
  "tokentally": "^0.1.6",
83
- "zod": "^4.6.0"
83
+ "zod": "^4.6.1"
84
84
  },
85
85
  "devDependencies": {
86
86
  "@anthropic-ai/tokenizer": "^0.0.4",