@steipete/oracle 0.20.0 → 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.
Files changed (32) hide show
  1. package/dist/bin/oracle-cli.js +66 -25
  2. package/dist/src/browser/actions/modelSelection.js +36 -4
  3. package/dist/src/browser/actions/thinkingTime.js +11 -5
  4. package/dist/src/browser/browserConnection.js +59 -0
  5. package/dist/src/browser/chatgptImages.js +1 -1
  6. package/dist/src/browser/chromeLifecycle.js +105 -36
  7. package/dist/src/browser/index.js +41 -8
  8. package/dist/src/browser/liveTabs.js +94 -29
  9. package/dist/src/browser/profileState.js +6 -1
  10. package/dist/src/browser/promptFingerprint.js +54 -0
  11. package/dist/src/browser/reattach.js +157 -108
  12. package/dist/src/browser/recoveryTarget.js +31 -6
  13. package/dist/src/browser/sessionRunner.js +30 -7
  14. package/dist/src/browser/targetClaim.js +2 -2
  15. package/dist/src/cli/browserDefaults.js +3 -0
  16. package/dist/src/cli/browserTabs.js +105 -20
  17. package/dist/src/cli/detach.js +10 -1
  18. package/dist/src/cli/detachedSession.js +36 -0
  19. package/dist/src/cli/errorUtils.js +9 -0
  20. package/dist/src/cli/sessionDisplay.js +11 -3
  21. package/dist/src/cli/sessionRunner.js +150 -37
  22. package/dist/src/mcp/tools/consult.js +1 -7
  23. package/dist/src/remote/client.js +71 -14
  24. package/dist/src/remote/health.js +1 -0
  25. package/dist/src/remote/server.js +52 -10
  26. package/dist/src/remote/types.js +13 -0
  27. package/dist/src/sessionManager.js +8 -1
  28. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  29. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  30. package/package.json +9 -9
  31. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  32. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
@@ -9,7 +9,6 @@ import { formatResponseMetadata, formatTransportMetadata } from "./sessionDispla
9
9
  import { markErrorLogged } from "./errorUtils.js";
10
10
  import { sendSessionNotification, deriveNotificationSettingsFromMetadata, } from "./notifier.js";
11
11
  import { sessionStore } from "../sessionStore.js";
12
- import { wait } from "../sessionManager.js";
13
12
  import { runMultiModelApiSession } from "../oracle/multiModelRunner.js";
14
13
  import { MODEL_CONFIGS, DEFAULT_SYSTEM_PROMPT } from "../oracle/config.js";
15
14
  import { isKnownModel } from "../oracle/modelResolver.js";
@@ -22,31 +21,71 @@ import { sanitizeOscProgress } from "./oscUtils.js";
22
21
  import { readFiles } from "../oracle/files.js";
23
22
  import { cwd as getCwd } from "node:process";
24
23
  import { resumeBrowserSession } from "../browser/reattach.js";
25
- import { retireRecoveredBrowserTarget } from "../browser/recoveryTarget.js";
24
+ import { recoveryCaptureFromRuntime, retireCancelledBrowserTarget, retireRecoveredBrowserTarget, } from "../browser/recoveryTarget.js";
26
25
  import { hasRecoverableChatGptConversation } from "../browser/reattachability.js";
27
- import { estimateTokenCount } from "../browser/utils.js";
26
+ import { delay, estimateTokenCount } from "../browser/utils.js";
28
27
  import { computeFileSha256, sanitizeArtifactFilename } from "../browser/artifacts.js";
29
28
  import { formatElapsed } from "../oracle/format.js";
30
29
  import { formatBrowserReattachGuidance } from "./reattachGuidance.js";
30
+ import { BrowserRunCancelledError } from "../oracle/errors.js";
31
31
  const isTty = process.stdout.isTTY;
32
32
  const dim = (text) => (isTty ? kleur.dim(text) : text);
33
- export async function performSessionRun({ sessionMeta, runOptions, mode, browserConfig, cwd, log, write, version, notifications, browserDeps, muteStdout = false, }) {
33
+ export async function performSessionRun({ sessionMeta, runOptions, mode, browserConfig, cwd, log, write, version, notifications, browserDeps, muteStdout = false, signal, }) {
34
34
  const writeInline = (chunk) => {
35
35
  // Keep session logs intact while still echoing inline output to the user.
36
36
  write(chunk);
37
37
  return muteStdout ? true : process.stdout.write(chunk);
38
38
  };
39
39
  let currentBrowser = browserConfig
40
- ? { config: browserConfig }
40
+ ? {
41
+ config: browserConfig,
42
+ ...(mode === "browser" && sessionMeta.browser?.runtime?.submittedPromptHash !== undefined
43
+ ? { runtime: { submittedPromptHash: null } }
44
+ : {}),
45
+ }
41
46
  : sessionMeta.browser;
42
47
  await sessionStore.updateSession(sessionMeta.id, {
43
48
  status: "running",
44
49
  startedAt: new Date().toISOString(),
45
50
  mode,
46
- ...(browserConfig ? { browser: { config: browserConfig } } : {}),
51
+ ...(browserConfig ? { browser: currentBrowser } : {}),
47
52
  });
48
53
  const notificationSettings = notifications ?? deriveNotificationSettingsFromMetadata(sessionMeta, process.env);
49
54
  const modelForStatus = runOptions.model ?? sessionMeta.model;
55
+ const checkBrowserCancellation = () => {
56
+ if (mode === "browser" && signal?.aborted)
57
+ throw new BrowserRunCancelledError();
58
+ };
59
+ const finalizeBrowserCancellation = async (cancellationError) => {
60
+ const completedAt = new Date().toISOString();
61
+ log("Browser run cancelled.");
62
+ if (modelForStatus) {
63
+ await sessionStore.updateModelRun(sessionMeta.id, modelForStatus, {
64
+ status: "cancelled",
65
+ completedAt,
66
+ response: { status: "cancelled" },
67
+ error: undefined,
68
+ transport: undefined,
69
+ });
70
+ }
71
+ await sessionStore.updateSession(sessionMeta.id, {
72
+ status: "cancelled",
73
+ completedAt,
74
+ errorMessage: undefined,
75
+ mode,
76
+ browser: browserConfig
77
+ ? {
78
+ ...currentBrowser,
79
+ config: browserConfig,
80
+ }
81
+ : undefined,
82
+ response: { status: "cancelled" },
83
+ error: undefined,
84
+ transport: undefined,
85
+ });
86
+ await retireCancelledBrowserTarget(sessionMeta.id, recoveryCaptureFromRuntime(currentBrowser?.runtime), log);
87
+ throw cancellationError;
88
+ };
50
89
  try {
51
90
  if (mode === "browser") {
52
91
  if (!browserConfig) {
@@ -80,13 +119,17 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
80
119
  browserConfig,
81
120
  cwd,
82
121
  log,
122
+ signal,
83
123
  }, runnerDeps);
124
+ checkBrowserCancellation();
84
125
  const writtenOutputPath = await writeAssistantOutput(runOptions.writeOutputPath, result.answerText ?? "", log);
126
+ checkBrowserCancellation();
85
127
  const outputArtifacts = await copyBrowserOutputArtifacts({
86
128
  outputPath: writtenOutputPath,
87
129
  savedFiles: runOptions.writeArtifacts ? result.savedFiles : undefined,
88
130
  log,
89
131
  });
132
+ checkBrowserCancellation();
90
133
  const browserWarnings = [...(result.warnings ?? []), ...outputArtifacts.warnings];
91
134
  await sendSessionNotification({
92
135
  sessionId: sessionMeta.id,
@@ -96,13 +139,16 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
96
139
  usage: result.usage,
97
140
  characters: result.answerText?.length,
98
141
  }, notificationSettings, log, result.answerText?.slice(0, 140));
142
+ checkBrowserCancellation();
99
143
  if (modelForStatus) {
100
144
  await sessionStore.updateModelRun(sessionMeta.id, modelForStatus, {
101
145
  status: "completed",
102
146
  completedAt: new Date().toISOString(),
103
147
  usage: result.usage,
104
148
  });
149
+ checkBrowserCancellation();
105
150
  }
151
+ checkBrowserCancellation();
106
152
  await sessionStore.updateSession(sessionMeta.id, {
107
153
  status: "completed",
108
154
  completedAt: new Date().toISOString(),
@@ -401,6 +447,9 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
401
447
  });
402
448
  }
403
449
  catch (error) {
450
+ if (error instanceof BrowserRunCancelledError) {
451
+ return await finalizeBrowserCancellation(error);
452
+ }
404
453
  const message = formatError(error);
405
454
  log(`ERROR: ${message}`);
406
455
  markErrorLogged(error);
@@ -426,6 +475,11 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
426
475
  const runtime = userError.details
427
476
  ?.runtime;
428
477
  const recoverableRuntime = runtime ?? currentBrowser?.runtime;
478
+ currentBrowser = {
479
+ ...currentBrowser,
480
+ config: browserConfig,
481
+ runtime: recoverableRuntime,
482
+ };
429
483
  if (!hasRecoverableChatGptConversation(recoverableRuntime) &&
430
484
  recoverableRuntime?.promptSubmitted !== true) {
431
485
  log(dim("Chrome disconnected before a ChatGPT conversation was created; marking session error."));
@@ -495,22 +549,32 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
495
549
  const connectionLostIntervalMs = configuredIntervalMs > 0
496
550
  ? configuredIntervalMs
497
551
  : Math.max(1_000, Math.min(browserConfig?.timeoutMs ?? 30_000, 30_000));
498
- const success = await autoReattachUntilComplete({
499
- sessionMeta,
500
- runtime: recoverableRuntime ?? undefined,
501
- browserConfig: {
502
- ...browserConfig,
503
- autoReattachIntervalMs: connectionLostIntervalMs,
504
- autoReattachDelayMs: browserConfig?.autoReattachDelayMs ?? 0,
505
- autoReattachTimeoutMs: browserConfig?.autoReattachTimeoutMs ?? browserConfig?.timeoutMs ?? 120_000,
506
- },
507
- browserMetadata: currentBrowser,
508
- runOptions,
509
- modelForStatus,
510
- notificationSettings,
511
- log,
512
- maxAttempts: configuredIntervalMs > 0 ? undefined : 1,
513
- });
552
+ let success;
553
+ try {
554
+ success = await autoReattachUntilComplete({
555
+ sessionMeta,
556
+ runtime: recoverableRuntime ?? undefined,
557
+ browserConfig: {
558
+ ...browserConfig,
559
+ autoReattachIntervalMs: connectionLostIntervalMs,
560
+ autoReattachDelayMs: browserConfig?.autoReattachDelayMs ?? 0,
561
+ autoReattachTimeoutMs: browserConfig?.autoReattachTimeoutMs ?? browserConfig?.timeoutMs ?? 120_000,
562
+ },
563
+ browserMetadata: currentBrowser,
564
+ runOptions,
565
+ modelForStatus,
566
+ notificationSettings,
567
+ log,
568
+ maxAttempts: configuredIntervalMs > 0 ? undefined : 1,
569
+ signal,
570
+ });
571
+ }
572
+ catch (recoveryError) {
573
+ if (signal?.aborted || recoveryError instanceof BrowserRunCancelledError) {
574
+ return await finalizeBrowserCancellation(new BrowserRunCancelledError());
575
+ }
576
+ throw recoveryError;
577
+ }
514
578
  if (success) {
515
579
  return;
516
580
  }
@@ -531,6 +595,11 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
531
595
  };
532
596
  const autoReattachIntervalMs = browserConfig?.autoReattachIntervalMs ?? 0;
533
597
  const autoRuntime = runtime ?? currentBrowser?.runtime;
598
+ currentBrowser = {
599
+ ...currentBrowser,
600
+ config: browserConfig,
601
+ runtime: autoRuntime,
602
+ };
534
603
  const willAutoReattach = autoReattachIntervalMs > 0 && Boolean(autoRuntime);
535
604
  if (willAutoReattach) {
536
605
  if (modelForStatus) {
@@ -554,16 +623,26 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
554
623
  response: timeoutResponse,
555
624
  error: timeoutError,
556
625
  });
557
- const success = await autoReattachUntilComplete({
558
- sessionMeta,
559
- runtime: autoRuntime,
560
- browserConfig,
561
- browserMetadata: currentBrowser,
562
- runOptions,
563
- modelForStatus,
564
- notificationSettings,
565
- log,
566
- });
626
+ let success;
627
+ try {
628
+ success = await autoReattachUntilComplete({
629
+ sessionMeta,
630
+ runtime: autoRuntime,
631
+ browserConfig,
632
+ browserMetadata: currentBrowser,
633
+ runOptions,
634
+ modelForStatus,
635
+ notificationSettings,
636
+ log,
637
+ signal,
638
+ });
639
+ }
640
+ catch (recoveryError) {
641
+ if (signal?.aborted || recoveryError instanceof BrowserRunCancelledError) {
642
+ return await finalizeBrowserCancellation(new BrowserRunCancelledError());
643
+ }
644
+ throw recoveryError;
645
+ }
567
646
  if (success) {
568
647
  return;
569
648
  }
@@ -976,7 +1055,7 @@ async function writeAssistantOutput(targetPath, content, log) {
976
1055
  log(dim(`write-output failed (${reason}); session completed anyway.`));
977
1056
  }
978
1057
  }
979
- async function autoReattachUntilComplete({ sessionMeta, runtime, browserConfig, browserMetadata, runOptions, modelForStatus, notificationSettings, log, maxAttempts, }) {
1058
+ async function autoReattachUntilComplete({ sessionMeta, runtime, browserConfig, browserMetadata, runOptions, modelForStatus, notificationSettings, log, maxAttempts, signal, }) {
980
1059
  if (!runtime || !browserConfig) {
981
1060
  log(dim("Auto-reattach disabled: missing runtime or browser config."));
982
1061
  return false;
@@ -991,12 +1070,16 @@ async function autoReattachUntilComplete({ sessionMeta, runtime, browserConfig,
991
1070
  120_000;
992
1071
  const maxTotalMs = 2 * 60 * 60 * 1000; // 2h hard cap; avoid infinite polling by default.
993
1072
  const maxDeadline = Date.now() + maxTotalMs;
1073
+ const checkCancellation = () => {
1074
+ if (signal?.aborted)
1075
+ throw new BrowserRunCancelledError();
1076
+ };
994
1077
  const attemptLimit = typeof maxAttempts === "number" && maxAttempts > 0
995
1078
  ? Math.floor(maxAttempts)
996
1079
  : Number.POSITIVE_INFINITY;
997
1080
  if (delayMs > 0) {
998
1081
  log(dim(`Auto-reattach starting in ${formatElapsed(delayMs)}...`));
999
- await wait(delayMs);
1082
+ await delay(delayMs, signal);
1000
1083
  }
1001
1084
  if (Number.isFinite(attemptLimit)) {
1002
1085
  log(dim(`Auto-reattach will try up to ${attemptLimit} attempt(s).`));
@@ -1012,6 +1095,7 @@ async function autoReattachUntilComplete({ sessionMeta, runtime, browserConfig,
1012
1095
  logger.verbose = true;
1013
1096
  let attempt = 0;
1014
1097
  for (;;) {
1098
+ checkCancellation();
1015
1099
  const remainingBudgetMs = maxDeadline - Date.now();
1016
1100
  if (remainingBudgetMs <= 0) {
1017
1101
  log(dim(`Auto-reattach stopped after ${formatElapsed(maxTotalMs)} without capturing an answer.`));
@@ -1025,9 +1109,11 @@ async function autoReattachUntilComplete({ sessionMeta, runtime, browserConfig,
1025
1109
  ...browserConfig,
1026
1110
  timeoutMs,
1027
1111
  };
1028
- const result = await resumeBrowserSession(runtime, reattachConfig, logger, {
1112
+ const result = await awaitBrowserCancellation(resumeBrowserSession(runtime, reattachConfig, logger, {
1029
1113
  promptPreview: sessionMeta.promptPreview,
1030
- });
1114
+ signal,
1115
+ }), signal);
1116
+ checkCancellation();
1031
1117
  captureSucceeded = true;
1032
1118
  const answerText = result.answerMarkdown || result.answerText || "";
1033
1119
  const outputTokens = estimateTokenCount(answerText);
@@ -1040,8 +1126,11 @@ async function autoReattachUntilComplete({ sessionMeta, runtime, browserConfig,
1040
1126
  existingArtifacts: sessionMeta.artifacts,
1041
1127
  logger,
1042
1128
  });
1129
+ checkCancellation();
1043
1130
  const paths = await sessionStore.getPaths(sessionMeta.id);
1131
+ checkCancellation();
1044
1132
  await fs.appendFile(paths.log, `[auto-reattach] captured assistant response on attempt ${attempt}\nAnswer:\n${answerText}\n`, "utf8");
1133
+ checkCancellation();
1045
1134
  if (modelForStatus) {
1046
1135
  await sessionStore.updateModelRun(sessionMeta.id, modelForStatus, {
1047
1136
  status: "completed",
@@ -1053,8 +1142,10 @@ async function autoReattachUntilComplete({ sessionMeta, runtime, browserConfig,
1053
1142
  totalTokens: outputTokens,
1054
1143
  },
1055
1144
  });
1145
+ checkCancellation();
1056
1146
  }
1057
1147
  await writeAssistantOutput(runOptions.writeOutputPath, answerText, log);
1148
+ checkCancellation();
1058
1149
  await sendSessionNotification({
1059
1150
  sessionId: sessionMeta.id,
1060
1151
  sessionName: sessionMeta.options?.slug ?? sessionMeta.id,
@@ -1066,6 +1157,7 @@ async function autoReattachUntilComplete({ sessionMeta, runtime, browserConfig,
1066
1157
  },
1067
1158
  characters: answerText.length,
1068
1159
  }, notificationSettings, log, answerText.slice(0, 140));
1160
+ checkCancellation();
1069
1161
  await sessionStore.updateSession(sessionMeta.id, {
1070
1162
  status: "completed",
1071
1163
  completedAt: new Date().toISOString(),
@@ -1091,6 +1183,9 @@ async function autoReattachUntilComplete({ sessionMeta, runtime, browserConfig,
1091
1183
  return true;
1092
1184
  }
1093
1185
  catch (error) {
1186
+ if (signal?.aborted || error instanceof BrowserRunCancelledError) {
1187
+ throw new BrowserRunCancelledError();
1188
+ }
1094
1189
  if (captureSucceeded) {
1095
1190
  const message = formatError(error);
1096
1191
  if (modelForStatus) {
@@ -1128,7 +1223,25 @@ async function autoReattachUntilComplete({ sessionMeta, runtime, browserConfig,
1128
1223
  log(dim(`Auto-reattach stopped after ${formatElapsed(maxTotalMs)} without capturing an answer.`));
1129
1224
  return false;
1130
1225
  }
1131
- await wait(Math.min(intervalMs, remainingAfterAttemptMs));
1226
+ await delay(Math.min(intervalMs, remainingAfterAttemptMs), signal);
1227
+ }
1228
+ }
1229
+ async function awaitBrowserCancellation(task, signal) {
1230
+ if (!signal)
1231
+ return task;
1232
+ if (signal.aborted)
1233
+ throw new BrowserRunCancelledError();
1234
+ let abort;
1235
+ const cancelled = new Promise((_, reject) => {
1236
+ abort = () => reject(new BrowserRunCancelledError());
1237
+ signal.addEventListener("abort", abort, { once: true });
1238
+ });
1239
+ try {
1240
+ return await Promise.race([task, cancelled]);
1241
+ }
1242
+ finally {
1243
+ if (abort)
1244
+ signal.removeEventListener("abort", abort);
1132
1245
  }
1133
1246
  }
1134
1247
  export function deriveModelOutputPath(basePath, model) {
@@ -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),