@steipete/oracle 0.18.0 → 0.19.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 (159) hide show
  1. package/README.md +1 -1
  2. package/dist/bin/oracle-cli.js +65 -63
  3. package/dist/bin/oracle-mcp.js +0 -0
  4. package/dist/src/browser/actions/archiveConversation.js +31 -7
  5. package/dist/src/browser/actions/assistantResponse.js +43 -1
  6. package/dist/src/browser/actions/attachmentContext.js +239 -0
  7. package/dist/src/browser/actions/attachmentDataTransfer.js +19 -1
  8. package/dist/src/browser/actions/attachmentEvidence.js +78 -0
  9. package/dist/src/browser/actions/attachmentProgress.js +41 -0
  10. package/dist/src/browser/actions/attachmentPrompt.js +130 -0
  11. package/dist/src/browser/actions/attachments.js +306 -355
  12. package/dist/src/browser/actions/deepResearch.js +2 -1
  13. package/dist/src/browser/actions/modelSelection.js +13 -3
  14. package/dist/src/browser/actions/navigation.js +1 -1
  15. package/dist/src/browser/actions/promptComposer.js +361 -186
  16. package/dist/src/browser/actions/remoteFileTransfer.js +4 -2
  17. package/dist/src/browser/actions/thinkingStatus.js +2 -5
  18. package/dist/src/browser/actions/thinkingTime.js +119 -16
  19. package/dist/src/browser/attachRunning.js +46 -2
  20. package/dist/src/browser/attachmentValidation.js +53 -0
  21. package/dist/src/browser/cancellation.js +105 -0
  22. package/dist/src/browser/chatgptImages.js +2 -0
  23. package/dist/src/browser/chatgptThrottle.js +104 -0
  24. package/dist/src/browser/chromeLifecycle.js +267 -43
  25. package/dist/src/browser/config.js +13 -0
  26. package/dist/src/browser/detect.js +1 -1
  27. package/dist/src/browser/index.js +444 -261
  28. package/dist/src/browser/modelDisplay.js +9 -0
  29. package/dist/src/browser/pageActions.js +2 -2
  30. package/dist/src/browser/profileState.js +114 -9
  31. package/dist/src/browser/projectSourcesRunner.js +83 -15
  32. package/dist/src/browser/prompt.js +272 -113
  33. package/dist/src/browser/providers/chatgptDomProvider.js +2 -0
  34. package/dist/src/browser/reattach.js +10 -3
  35. package/dist/src/browser/sessionRunner.js +71 -11
  36. package/dist/src/browser/tabLeaseRegistry.js +414 -38
  37. package/dist/src/browser/utils.js +20 -2
  38. package/dist/src/cli/browserConfig.js +3 -1
  39. package/dist/src/cli/browserDefaults.js +10 -0
  40. package/dist/src/cli/browserTabs.js +12 -29
  41. package/dist/src/cli/detachedSession.js +69 -0
  42. package/dist/src/cli/dryRun.js +58 -48
  43. package/dist/src/cli/harvestIntegrity.js +140 -0
  44. package/dist/src/cli/serveBrowserConfig.js +24 -0
  45. package/dist/src/cli/sessionDisplay.js +9 -2
  46. package/dist/src/cli/sessionRunner.js +91 -7
  47. package/dist/src/config.js +1 -0
  48. package/dist/src/gemini-web/client.js +67 -21
  49. package/dist/src/gemini-web/executor.js +4 -0
  50. package/dist/src/gemini-web/http.js +15 -0
  51. package/dist/src/gemini-web/models.js +15 -11
  52. package/dist/src/mcp/server.js +11 -14
  53. package/dist/src/mcp/tools/chatgptImage.js +4 -4
  54. package/dist/src/mcp/tools/consult.js +84 -22
  55. package/dist/src/mcp/tools/projectSources.js +4 -6
  56. package/dist/src/mcp/tools/sessionResources.js +1 -1
  57. package/dist/src/mcp/tools/sessions.js +2 -2
  58. package/dist/src/mcp/tools/wait.js +182 -0
  59. package/dist/src/mcp/types.js +7 -0
  60. package/dist/src/oracle/errors.js +11 -0
  61. package/dist/src/oracle/providerRoutePlan.js +3 -2
  62. package/dist/src/remote/client.js +70 -6
  63. package/dist/src/remote/health.js +6 -2
  64. package/dist/src/remote/runSlots.js +83 -0
  65. package/dist/src/remote/server.js +345 -71
  66. package/dist/src/sessionManager.js +36 -1
  67. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  68. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  69. package/package.json +25 -23
  70. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  71. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  72. package/dist/bin/oracle.js +0 -683
  73. package/dist/docs-site/.nojekyll +0 -0
  74. package/dist/docs-site/CNAME +0 -1
  75. package/dist/docs-site/RELEASING.html +0 -410
  76. package/dist/docs-site/agents.html +0 -374
  77. package/dist/docs-site/anthropic.html +0 -368
  78. package/dist/docs-site/bridge.html +0 -400
  79. package/dist/docs-site/browser-mode.html +0 -588
  80. package/dist/docs-site/chromium-forks.html +0 -347
  81. package/dist/docs-site/cli-reference.html +0 -346
  82. package/dist/docs-site/configuration.html +0 -436
  83. package/dist/docs-site/favicon.svg +0 -14
  84. package/dist/docs-site/followup.html +0 -368
  85. package/dist/docs-site/gemini.html +0 -379
  86. package/dist/docs-site/grok.html +0 -325
  87. package/dist/docs-site/index.html +0 -360
  88. package/dist/docs-site/install.html +0 -335
  89. package/dist/docs-site/linux.html +0 -321
  90. package/dist/docs-site/llms.txt +0 -43
  91. package/dist/docs-site/manual-tests.html +0 -595
  92. package/dist/docs-site/mcp.html +0 -368
  93. package/dist/docs-site/multimodel.html +0 -364
  94. package/dist/docs-site/mythical-pro-agents.html +0 -360
  95. package/dist/docs-site/notifier.html +0 -338
  96. package/dist/docs-site/openai-endpoints.html +0 -387
  97. package/dist/docs-site/openrouter.html +0 -344
  98. package/dist/docs-site/quickstart.html +0 -369
  99. package/dist/docs-site/refactor/ux.html +0 -532
  100. package/dist/docs-site/sessions.html +0 -388
  101. package/dist/docs-site/social-card.png +0 -0
  102. package/dist/docs-site/social-card.svg +0 -79
  103. package/dist/docs-site/spec.html +0 -363
  104. package/dist/docs-site/testing.html +0 -320
  105. package/dist/docs-site/tui-debug.html +0 -326
  106. package/dist/docs-site/windows-work.html +0 -323
  107. package/dist/docs-site/windows.html +0 -320
  108. package/dist/markdansi/types/index.js +0 -4
  109. package/dist/oracle/bin/oracle-cli.js +0 -472
  110. package/dist/oracle/src/browser/actions/assistantResponse.js +0 -471
  111. package/dist/oracle/src/browser/actions/attachments.js +0 -82
  112. package/dist/oracle/src/browser/actions/modelSelection.js +0 -190
  113. package/dist/oracle/src/browser/actions/navigation.js +0 -75
  114. package/dist/oracle/src/browser/actions/promptComposer.js +0 -167
  115. package/dist/oracle/src/browser/chromeLifecycle.js +0 -104
  116. package/dist/oracle/src/browser/config.js +0 -33
  117. package/dist/oracle/src/browser/constants.js +0 -40
  118. package/dist/oracle/src/browser/cookies.js +0 -210
  119. package/dist/oracle/src/browser/domDebug.js +0 -36
  120. package/dist/oracle/src/browser/index.js +0 -331
  121. package/dist/oracle/src/browser/pageActions.js +0 -5
  122. package/dist/oracle/src/browser/prompt.js +0 -88
  123. package/dist/oracle/src/browser/promptSummary.js +0 -20
  124. package/dist/oracle/src/browser/sessionRunner.js +0 -80
  125. package/dist/oracle/src/browser/types.js +0 -1
  126. package/dist/oracle/src/browser/utils.js +0 -62
  127. package/dist/oracle/src/browserMode.js +0 -1
  128. package/dist/oracle/src/cli/browserConfig.js +0 -44
  129. package/dist/oracle/src/cli/dryRun.js +0 -59
  130. package/dist/oracle/src/cli/engine.js +0 -17
  131. package/dist/oracle/src/cli/errorUtils.js +0 -9
  132. package/dist/oracle/src/cli/help.js +0 -70
  133. package/dist/oracle/src/cli/markdownRenderer.js +0 -15
  134. package/dist/oracle/src/cli/options.js +0 -103
  135. package/dist/oracle/src/cli/promptRequirement.js +0 -14
  136. package/dist/oracle/src/cli/rootAlias.js +0 -30
  137. package/dist/oracle/src/cli/sessionCommand.js +0 -77
  138. package/dist/oracle/src/cli/sessionDisplay.js +0 -270
  139. package/dist/oracle/src/cli/sessionRunner.js +0 -94
  140. package/dist/oracle/src/heartbeat.js +0 -43
  141. package/dist/oracle/src/oracle/client.js +0 -48
  142. package/dist/oracle/src/oracle/config.js +0 -29
  143. package/dist/oracle/src/oracle/errors.js +0 -101
  144. package/dist/oracle/src/oracle/files.js +0 -220
  145. package/dist/oracle/src/oracle/format.js +0 -33
  146. package/dist/oracle/src/oracle/fsAdapter.js +0 -7
  147. package/dist/oracle/src/oracle/oscProgress.js +0 -60
  148. package/dist/oracle/src/oracle/request.js +0 -48
  149. package/dist/oracle/src/oracle/run.js +0 -444
  150. package/dist/oracle/src/oracle/tokenStats.js +0 -39
  151. package/dist/oracle/src/oracle/types.js +0 -1
  152. package/dist/oracle/src/oracle.js +0 -9
  153. package/dist/oracle/src/sessionManager.js +0 -205
  154. package/dist/oracle/src/version.js +0 -39
  155. package/dist/scripts/chrome/browser-tools.js +0 -295
  156. package/dist/src/browser/chromeCookies.js +0 -312
  157. package/dist/src/browser/keytarShim.js +0 -56
  158. package/dist/src/browser/profileSync.js +0 -141
  159. package/dist/src/browser/windowsCookies.js +0 -219
@@ -0,0 +1,182 @@
1
+ import { realpathSync, watch } from "node:fs";
2
+ import { z } from "zod";
3
+ import { sessionStore } from "../../sessionStore.js";
4
+ import { waitInputSchema } from "../types.js";
5
+ import { consultArtifactSummaryShape, consultImageSummaryShape, consultModelSummaryShape, readSessionLogTail, summarizeArtifactsForConsult, summarizeImageArtifactsForConsult, summarizeModelRunsForConsult, } from "./consult.js";
6
+ const TERMINAL_SESSION_STATUSES = new Set(["completed", "partial", "error", "cancelled"]);
7
+ const DEFAULT_FALLBACK_INTERVAL_MS = 1_000;
8
+ const waitInputShape = {
9
+ id: z.string().min(1, "Session id is required.").describe("Oracle session id or slug."),
10
+ timeoutMs: z
11
+ .number()
12
+ .int()
13
+ .nonnegative()
14
+ .optional()
15
+ .describe("How long to wait for a terminal session state. Omit to wait indefinitely; 0 returns an immediate snapshot. A wait timeout never cancels the Oracle session."),
16
+ };
17
+ const waitOutputShape = {
18
+ sessionId: z.string(),
19
+ status: z.string(),
20
+ waitStatus: z.enum(["terminal", "timed_out"]),
21
+ timedOut: z.boolean(),
22
+ cancelled: z.boolean(),
23
+ output: z.string(),
24
+ models: z.array(consultModelSummaryShape).optional(),
25
+ artifacts: z.array(consultArtifactSummaryShape).optional(),
26
+ images: z.array(consultImageSummaryShape).optional(),
27
+ };
28
+ function abortReason(signal) {
29
+ return signal.reason instanceof Error
30
+ ? signal.reason
31
+ : new DOMException("Oracle session wait was cancelled.", "AbortError");
32
+ }
33
+ function throwIfAborted(signal) {
34
+ if (signal?.aborted) {
35
+ throw abortReason(signal);
36
+ }
37
+ }
38
+ export function isTerminalSessionStatus(status) {
39
+ return TERMINAL_SESSION_STATUSES.has(status);
40
+ }
41
+ export function createSessionChangeSource(directory) {
42
+ let watcher;
43
+ let notified = false;
44
+ let pendingWake;
45
+ const onWake = () => {
46
+ if (!pendingWake) {
47
+ notified = true;
48
+ return;
49
+ }
50
+ const wake = pendingWake;
51
+ pendingWake = undefined;
52
+ wake();
53
+ };
54
+ const onError = () => {
55
+ const failedWatcher = watcher;
56
+ watcher = undefined;
57
+ failedWatcher?.off("change", onWake);
58
+ failedWatcher?.off("error", onError);
59
+ failedWatcher?.close();
60
+ onWake();
61
+ };
62
+ try {
63
+ // Windows short-path aliases can trigger a native libuv prefix assertion.
64
+ watcher = watch(realpathSync.native(directory));
65
+ watcher.on("change", onWake);
66
+ watcher.on("error", onError);
67
+ }
68
+ catch {
69
+ // A timer fallback below keeps waits correct when filesystem notifications
70
+ // are unavailable or the session directory is on an unsupported volume.
71
+ }
72
+ return {
73
+ wait(delayMs, signal) {
74
+ throwIfAborted(signal);
75
+ return new Promise((resolve, reject) => {
76
+ let settled = false;
77
+ const finish = (error) => {
78
+ if (settled)
79
+ return;
80
+ settled = true;
81
+ clearTimeout(timer);
82
+ if (pendingWake === wake)
83
+ pendingWake = undefined;
84
+ signal?.removeEventListener("abort", onAbort);
85
+ if (error)
86
+ reject(error);
87
+ else
88
+ resolve();
89
+ };
90
+ const wake = () => finish();
91
+ const onAbort = () => finish(abortReason(signal));
92
+ const timer = setTimeout(wake, delayMs);
93
+ pendingWake = wake;
94
+ signal?.addEventListener("abort", onAbort, { once: true });
95
+ if (signal?.aborted) {
96
+ onAbort();
97
+ return;
98
+ }
99
+ if (notified) {
100
+ notified = false;
101
+ queueMicrotask(wake);
102
+ }
103
+ });
104
+ },
105
+ close() {
106
+ watcher?.off("change", onWake);
107
+ watcher?.off("error", onError);
108
+ watcher?.close();
109
+ watcher = undefined;
110
+ },
111
+ };
112
+ }
113
+ const defaultWaitDeps = {
114
+ readSession: (id) => sessionStore.readSession(id),
115
+ getSessionDir: async (id) => (await sessionStore.getPaths(id)).dir,
116
+ createChangeSource: createSessionChangeSource,
117
+ now: Date.now,
118
+ };
119
+ export async function waitForSessionTerminal({ id, timeoutMs, signal, fallbackIntervalMs = DEFAULT_FALLBACK_INTERVAL_MS, }, deps = defaultWaitDeps) {
120
+ throwIfAborted(signal);
121
+ const deadline = timeoutMs === undefined ? undefined : deps.now() + timeoutMs;
122
+ let metadata = await deps.readSession(id);
123
+ if (!metadata) {
124
+ throw new Error(`Session "${id}" not found.`);
125
+ }
126
+ if (isTerminalSessionStatus(metadata.status)) {
127
+ return { metadata, waitStatus: "terminal", timedOut: false };
128
+ }
129
+ if (timeoutMs === 0) {
130
+ return { metadata, waitStatus: "timed_out", timedOut: true };
131
+ }
132
+ const directory = await deps.getSessionDir(id);
133
+ const changes = deps.createChangeSource(directory);
134
+ try {
135
+ // Close the read/watch race: the session may have completed while the
136
+ // filesystem watcher was being installed.
137
+ metadata = (await deps.readSession(id)) ?? metadata;
138
+ while (!isTerminalSessionStatus(metadata.status)) {
139
+ throwIfAborted(signal);
140
+ const remaining = deadline === undefined ? undefined : deadline - deps.now();
141
+ if (remaining !== undefined && remaining <= 0) {
142
+ return { metadata, waitStatus: "timed_out", timedOut: true };
143
+ }
144
+ const delayMs = Math.max(1, Math.min(fallbackIntervalMs, remaining ?? fallbackIntervalMs));
145
+ await changes.wait(delayMs, signal);
146
+ metadata = (await deps.readSession(id)) ?? metadata;
147
+ }
148
+ return { metadata, waitStatus: "terminal", timedOut: false };
149
+ }
150
+ finally {
151
+ changes.close();
152
+ }
153
+ }
154
+ export async function runWaitTool(input, extra) {
155
+ const { id, timeoutMs } = waitInputSchema.parse(input);
156
+ const result = await waitForSessionTerminal({ id, timeoutMs, signal: extra?.signal });
157
+ const { metadata } = result;
158
+ const logTail = (await readSessionLogTail(metadata.id, 4_000)) ?? "";
159
+ const summary = `Session ${metadata.id} (${metadata.status}; wait=${result.waitStatus})`;
160
+ return {
161
+ content: [{ type: "text", text: [summary, logTail || "(log empty)"].join("\n") }],
162
+ structuredContent: {
163
+ sessionId: metadata.id,
164
+ status: metadata.status,
165
+ waitStatus: result.waitStatus,
166
+ timedOut: result.timedOut,
167
+ cancelled: metadata.status === "cancelled",
168
+ output: logTail,
169
+ models: summarizeModelRunsForConsult(metadata.models),
170
+ artifacts: summarizeArtifactsForConsult(metadata.artifacts),
171
+ images: summarizeImageArtifactsForConsult(metadata.artifacts),
172
+ },
173
+ };
174
+ }
175
+ export function registerWaitTool(server) {
176
+ server.registerTool("wait", {
177
+ title: "Wait for an oracle session",
178
+ description: "Wait for an existing Oracle session to reach a terminal state without agent-side polling. Omit timeoutMs to wait indefinitely, or set a bounded caller wait. Wait timeout or request cancellation never cancels the Oracle session; call wait again with the same id to continue waiting.",
179
+ inputSchema: z.object(waitInputShape),
180
+ outputSchema: z.object(waitOutputShape),
181
+ }, async (input, context) => runWaitTool(input, { signal: context.mcpReq.signal }));
182
+ }
@@ -29,6 +29,7 @@ export const consultInputSchema = z
29
29
  browserKeepBrowser: z.boolean().optional(),
30
30
  generateImage: z.string().optional(),
31
31
  outputPath: z.string().optional(),
32
+ waitForCompletion: z.boolean().optional(),
32
33
  dryRun: z.boolean().optional(),
33
34
  search: z.boolean().optional(),
34
35
  slug: z.string().optional(),
@@ -41,3 +42,9 @@ export const sessionsInputSchema = z.object({
41
42
  includeAll: z.boolean().optional(),
42
43
  detail: z.boolean().optional(),
43
44
  });
45
+ export const waitInputSchema = z
46
+ .object({
47
+ id: z.string().min(1, "Session id is required."),
48
+ timeoutMs: z.number().int().nonnegative().optional(),
49
+ })
50
+ .strict();
@@ -26,6 +26,17 @@ export class BrowserAutomationError extends OracleUserError {
26
26
  this.name = "BrowserAutomationError";
27
27
  }
28
28
  }
29
+ /**
30
+ * The caller went away. Distinct from a failure: nothing was wrong with the run,
31
+ * it simply stopped being wanted, and a caller that reads this should not retry
32
+ * or report a problem.
33
+ */
34
+ export class BrowserRunCancelledError extends OracleUserError {
35
+ constructor(message = "Browser run cancelled: the caller disconnected.") {
36
+ super("browser-automation", message, { stage: "run-cancelled" });
37
+ this.name = "BrowserRunCancelledError";
38
+ }
39
+ }
29
40
  export class PromptValidationError extends OracleUserError {
30
41
  constructor(message, details, cause) {
31
42
  super("prompt-validation", message, details, cause);
@@ -75,7 +75,8 @@ function buildResolvedProviderRoute(input) {
75
75
  });
76
76
  const provider = state.provider;
77
77
  const isAzureOpenAI = state.isAzureOpenAI;
78
- let baseUrl = input.baseUrl?.trim();
78
+ // Azure owns the endpoint, including metadata lookups before request dispatch.
79
+ let baseUrl = isAzureOpenAI ? undefined : input.baseUrl?.trim();
79
80
  const providerQualifiedOpenRouterCandidate = !isAzureOpenAI && providerMode !== "openai" && input.model.includes("/");
80
81
  if (baseUrl &&
81
82
  providerQualifiedOpenRouterCandidate &&
@@ -83,7 +84,7 @@ function buildResolvedProviderRoute(input) {
83
84
  !isCustomBaseUrl(baseUrl)) {
84
85
  baseUrl = undefined;
85
86
  }
86
- if (!baseUrl) {
87
+ if (!baseUrl && !isAzureOpenAI) {
87
88
  let envBaseUrl;
88
89
  if (input.model.startsWith("grok")) {
89
90
  envBaseUrl = env.XAI_BASE_URL?.trim() || "https://api.x.ai/v1";
@@ -6,30 +6,43 @@ 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
8
  import { MAX_REMOTE_ARTIFACT_BYTES, } from "./types.js";
9
+ import { materializeStagedFallbackBundle } from "../browser/prompt.js";
10
+ import { checkRemoteHealth } from "./health.js";
9
11
  import { parseHostPort } from "../bridge/connection.js";
12
+ import { BrowserRunCancelledError } from "../oracle/errors.js";
10
13
  export function createRemoteBrowserExecutor({ host, token }) {
11
14
  // Return a drop-in replacement for runBrowserMode so the browser session runner can stay unchanged.
12
15
  return async function remoteBrowserExecutor(options) {
16
+ const callerSignal = options.signal;
17
+ if (callerSignal?.aborted)
18
+ throw new BrowserRunCancelledError("Browser run cancelled before the request was sent.");
19
+ if (callerSignal) {
20
+ const health = await checkRemoteHealth({ host, token, signal: callerSignal });
21
+ if (callerSignal.aborted)
22
+ throw new BrowserRunCancelledError();
23
+ if (health.capabilities?.runCancellation !== true)
24
+ throw new Error("Remote host does not support run cancellation; upgrade the host before using an AbortSignal.");
25
+ }
13
26
  const payload = {
14
27
  prompt: options.prompt,
15
28
  attachments: await serializeAttachments(options.attachments ?? []),
16
- fallbackSubmission: options.fallbackSubmission
17
- ? {
18
- prompt: options.fallbackSubmission.prompt,
19
- attachments: await serializeAttachments(options.fallbackSubmission.attachments ?? []),
20
- }
21
- : undefined,
29
+ fallbackSubmission: await serializeFallback(options.fallbackSubmission, { host, token }),
22
30
  browserConfig: options.config ?? {},
23
31
  options: {
24
32
  heartbeatIntervalMs: options.heartbeatIntervalMs,
25
33
  verbose: options.verbose,
26
34
  sessionId: options.sessionId,
27
35
  followUpPrompts: options.followUpPrompts,
36
+ cancelOnDisconnect: callerSignal ? true : undefined,
28
37
  },
29
38
  };
30
39
  const body = Buffer.from(JSON.stringify(payload));
31
40
  const { hostname, port } = parseHost(host);
32
41
  return new Promise((resolve, reject) => {
42
+ if (callerSignal?.aborted) {
43
+ reject(new Error("Browser run cancelled before the request was sent."));
44
+ return;
45
+ }
33
46
  const transferredFiles = [];
34
47
  const transferFailures = [];
35
48
  const transferPromises = [];
@@ -40,6 +53,7 @@ export function createRemoteBrowserExecutor({ host, token }) {
40
53
  if (settled)
41
54
  return;
42
55
  settled = true;
56
+ callerSignal?.removeEventListener("abort", onCallerAbort);
43
57
  reject(error);
44
58
  };
45
59
  const req = http.request({
@@ -107,17 +121,55 @@ export function createRemoteBrowserExecutor({ host, token }) {
107
121
  return;
108
122
  }
109
123
  settled = true;
124
+ callerSignal?.removeEventListener("abort", onCallerAbort);
110
125
  resolve(mergeTransferredArtifacts(resolved, transferredFiles, transferFailures));
111
126
  })().catch(fail);
112
127
  });
113
128
  res.on("error", fail);
114
129
  });
115
130
  req.on("error", fail);
131
+ // Destroying the request closes the socket, which is how the service learns
132
+ // to abort: its own disconnect handler fires and releases the slot and the
133
+ // browser tab.
134
+ const onCallerAbort = () => {
135
+ req.destroy();
136
+ fail(new BrowserRunCancelledError("Browser run cancelled: the caller aborted."));
137
+ };
138
+ callerSignal?.addEventListener("abort", onCallerAbort, { once: true });
116
139
  req.write(body);
117
140
  req.end();
118
141
  });
119
142
  };
120
143
  }
144
+ async function serializeFallback(fallback, remote) {
145
+ if (!fallback)
146
+ return undefined;
147
+ if (fallback.pendingBundle) {
148
+ const health = await checkRemoteHealth(remote);
149
+ if (!health.ok || health.capabilities?.deferredFallbackBundling !== true) {
150
+ // Older hosts ignore bundle metadata, so send a ready-to-upload fallback.
151
+ const prepared = await materializeStagedFallbackBundle({
152
+ composerText: fallback.prompt,
153
+ attachments: fallback.attachments,
154
+ ...fallback.pendingBundle,
155
+ });
156
+ try {
157
+ return {
158
+ prompt: prepared.composerText,
159
+ attachments: await serializeAttachments(prepared.attachments),
160
+ };
161
+ }
162
+ finally {
163
+ await rm(path.dirname(prepared.bundled.bundlePath), { recursive: true, force: true });
164
+ }
165
+ }
166
+ }
167
+ return {
168
+ prompt: fallback.prompt,
169
+ attachments: await serializeAttachments(fallback.attachments),
170
+ bundle: fallback.pendingBundle,
171
+ };
172
+ }
121
173
  async function serializeAttachments(attachments) {
122
174
  const serialized = [];
123
175
  for (const attachment of attachments) {
@@ -173,12 +225,17 @@ function handleEvent(params) {
173
225
  token: params.token,
174
226
  descriptor: event.artifact,
175
227
  sessionId: params.options.sessionId,
228
+ signal: params.options.signal,
176
229
  log: params.options.log,
177
230
  })
178
231
  .then((artifact) => {
179
232
  params.onArtifact(artifact);
180
233
  })
181
234
  .catch((error) => {
235
+ if (params.options.signal?.aborted) {
236
+ params.onError(new BrowserRunCancelledError());
237
+ return;
238
+ }
182
239
  const message = error instanceof Error ? error.message : String(error);
183
240
  const fallback = `Oracle captured the browser text response, but bridge artifact transfer failed for ${displayFilename}. 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. Reason: ${message}`;
184
241
  params.options.log?.(`[browser] ${fallback}`);
@@ -192,6 +249,7 @@ function handleEvent(params) {
192
249
  return null;
193
250
  }
194
251
  async function transferRemoteArtifact(params) {
252
+ params.signal?.throwIfAborted();
195
253
  validateRemoteArtifactDescriptor(params.descriptor);
196
254
  const sessionId = params.sessionId ?? params.descriptor.runId;
197
255
  const artifactsDir = resolveSessionArtifactsDir(sessionId);
@@ -208,6 +266,7 @@ async function transferRemoteArtifact(params) {
208
266
  token: params.token,
209
267
  targetPath: partPath,
210
268
  descriptor: params.descriptor,
269
+ signal: params.signal,
211
270
  }).catch(async (error) => {
212
271
  await rm(partPath, { force: true }).catch(() => undefined);
213
272
  throw error;
@@ -231,6 +290,10 @@ async function transferRemoteArtifact(params) {
231
290
  await rm(partPath, { force: true }).catch(() => undefined);
232
291
  throw new Error(`${validation.type} validation failed: ${validation.error ?? "invalid"}`);
233
292
  }
293
+ if (params.signal?.aborted) {
294
+ await rm(partPath, { force: true });
295
+ throw new BrowserRunCancelledError();
296
+ }
234
297
  await rename(partPath, finalPath);
235
298
  params.log?.(`[browser] Transferred artifact to ${finalPath}`);
236
299
  const publishedFilename = path.basename(finalPath);
@@ -257,6 +320,7 @@ async function downloadArtifactToFile(params) {
257
320
  port: params.port,
258
321
  path: params.path,
259
322
  method: "GET",
323
+ signal: params.signal,
260
324
  headers: params.token ? { authorization: `Bearer ${params.token}` } : undefined,
261
325
  }, (res) => {
262
326
  if (res.statusCode !== 200) {
@@ -30,7 +30,7 @@ export async function checkTcpConnection(host, timeoutMs = 2000) {
30
30
  socket.once("timeout", onTimeout);
31
31
  });
32
32
  }
33
- export async function checkRemoteHealth({ host, token, timeoutMs = 5000, }) {
33
+ export async function checkRemoteHealth({ host, token, timeoutMs = 5000, signal, }) {
34
34
  const { hostname, port } = parseHostPort(host);
35
35
  const headers = { accept: "application/json" };
36
36
  if (token) {
@@ -43,6 +43,7 @@ export async function checkRemoteHealth({ host, token, timeoutMs = 5000, }) {
43
43
  path: "/health",
44
44
  headers,
45
45
  timeoutMs,
46
+ signal,
46
47
  });
47
48
  if (response.statusCode === 200 && typeof response.json === "object" && response.json) {
48
49
  const ok = response.json.ok === true;
@@ -90,6 +91,8 @@ function parseCapabilities(value) {
90
91
  return undefined;
91
92
  }
92
93
  return {
94
+ ...(raw.deferredFallbackBundling === true ? { deferredFallbackBundling: true } : {}),
95
+ ...(raw.runCancellation === true ? { runCancellation: true } : {}),
93
96
  artifactTransfer: true,
94
97
  artifactProtocolVersion,
95
98
  maxArtifactBytes: Math.min(maxArtifactBytes, MAX_REMOTE_ARTIFACT_BYTES),
@@ -105,7 +108,7 @@ function extractErrorMessage(json, bodyText) {
105
108
  const trimmed = bodyText.trim();
106
109
  return trimmed.length ? trimmed : null;
107
110
  }
108
- async function requestJson({ hostname, port, path, headers, timeoutMs, }) {
111
+ async function requestJson({ hostname, port, path, headers, timeoutMs, signal, }) {
109
112
  return await new Promise((resolve, reject) => {
110
113
  const req = http.request({
111
114
  hostname,
@@ -113,6 +116,7 @@ async function requestJson({ hostname, port, path, headers, timeoutMs, }) {
113
116
  path,
114
117
  method: "GET",
115
118
  headers,
119
+ signal,
116
120
  }, (res) => {
117
121
  res.setEncoding("utf8");
118
122
  let body = "";
@@ -0,0 +1,83 @@
1
+ export class RunQueueFullError extends Error {
2
+ constructor() {
3
+ super("The browser run queue is full.");
4
+ this.name = "RunQueueFullError";
5
+ }
6
+ }
7
+ export class RunSlots {
8
+ maxConcurrent;
9
+ maxQueued;
10
+ active = 0;
11
+ waiting = [];
12
+ constructor(maxConcurrent, maxQueued) {
13
+ this.maxConcurrent = maxConcurrent;
14
+ this.maxQueued = maxQueued;
15
+ if (!Number.isSafeInteger(maxConcurrent) ||
16
+ maxConcurrent < 1 ||
17
+ !Number.isSafeInteger(maxQueued) ||
18
+ maxQueued < 0) {
19
+ throw new Error("Run capacity must be a positive integer and queue capacity a nonnegative integer.");
20
+ }
21
+ }
22
+ get activeCount() {
23
+ return this.active;
24
+ }
25
+ get queuedCount() {
26
+ return this.waiting.length;
27
+ }
28
+ get capacity() {
29
+ return this.maxConcurrent;
30
+ }
31
+ get queueCapacity() {
32
+ return this.maxQueued;
33
+ }
34
+ get isSaturated() {
35
+ return this.active >= this.maxConcurrent && this.waiting.length >= this.maxQueued;
36
+ }
37
+ positionFor() {
38
+ return this.active < this.maxConcurrent ? 0 : this.waiting.length + 1;
39
+ }
40
+ // Reservation is synchronous, including the queue bound; request-body reads cannot race it.
41
+ acquire(signal) {
42
+ if (signal?.aborted)
43
+ return Promise.reject(new Error("cancelled before a slot was available"));
44
+ if (this.active < this.maxConcurrent) {
45
+ this.active++;
46
+ return Promise.resolve(this.makeRelease());
47
+ }
48
+ if (this.waiting.length >= this.maxQueued)
49
+ return Promise.reject(new RunQueueFullError());
50
+ return new Promise((resolve, reject) => {
51
+ const onAbort = () => {
52
+ const index = this.waiting.indexOf(entry);
53
+ if (index < 0)
54
+ return;
55
+ this.waiting.splice(index, 1);
56
+ entry.cleanup();
57
+ reject(new Error("cancelled while waiting for a slot"));
58
+ };
59
+ const entry = {
60
+ resolve,
61
+ reject,
62
+ cleanup: () => signal?.removeEventListener("abort", onAbort),
63
+ };
64
+ this.waiting.push(entry);
65
+ signal?.addEventListener("abort", onAbort, { once: true });
66
+ });
67
+ }
68
+ makeRelease() {
69
+ let released = false;
70
+ return () => {
71
+ if (released)
72
+ return;
73
+ released = true;
74
+ const next = this.waiting.shift();
75
+ if (next) {
76
+ next.cleanup();
77
+ next.resolve(this.makeRelease());
78
+ }
79
+ else
80
+ this.active--;
81
+ };
82
+ }
83
+ }