@wlv-zedd/dsh-chatgpt-web 1.0.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 (85) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +140 -0
  3. package/assets/demo.gif +0 -0
  4. package/assets/hero-demo.png +0 -0
  5. package/assets/promo-dshmarket-official.png +0 -0
  6. package/cordis.patch.yml +4 -0
  7. package/lib/cli.js +239642 -0
  8. package/lib/plugin.js +195 -0
  9. package/package.json +88 -0
  10. package/screenshots.json +5 -0
  11. package/src/adapters/base.ts +16 -0
  12. package/src/adapters/chatgpt-web/adapter-error.ts +59 -0
  13. package/src/adapters/chatgpt-web/browser-helper-main.ts +513 -0
  14. package/src/adapters/chatgpt-web/browser-helper-prompt-selection.ts +27 -0
  15. package/src/adapters/chatgpt-web/browser-worker.ts +4944 -0
  16. package/src/adapters/chatgpt-web/codex-rollout-environment.ts +628 -0
  17. package/src/adapters/chatgpt-web/compaction-handoff.ts +533 -0
  18. package/src/adapters/chatgpt-web/compaction-transaction.ts +142 -0
  19. package/src/adapters/chatgpt-web/concurrency.ts +6 -0
  20. package/src/adapters/chatgpt-web/conversation-key.ts +58 -0
  21. package/src/adapters/chatgpt-web/environment.ts +669 -0
  22. package/src/adapters/chatgpt-web/index.ts +1544 -0
  23. package/src/adapters/chatgpt-web/input-tokens.ts +74 -0
  24. package/src/adapters/chatgpt-web/launcher-helper-client.ts +695 -0
  25. package/src/adapters/chatgpt-web/markdown.ts +418 -0
  26. package/src/adapters/chatgpt-web/mcp-main.ts +25 -0
  27. package/src/adapters/chatgpt-web/mcp-server.ts +933 -0
  28. package/src/adapters/chatgpt-web/model.ts +70 -0
  29. package/src/adapters/chatgpt-web/native-compaction-control.ts +74 -0
  30. package/src/adapters/chatgpt-web/output-validation.ts +62 -0
  31. package/src/adapters/chatgpt-web/process-line-writer.ts +46 -0
  32. package/src/adapters/chatgpt-web/prompt.ts +702 -0
  33. package/src/adapters/chatgpt-web/retry-policy.ts +73 -0
  34. package/src/adapters/chatgpt-web/rolling-checkpoint.ts +384 -0
  35. package/src/adapters/chatgpt-web/thread-environment.ts +238 -0
  36. package/src/adapters/chatgpt-web/tool-stream-parser.ts +601 -0
  37. package/src/adapters/chatgpt-web/turn-broker.ts +1481 -0
  38. package/src/adapters/chatgpt-web/turn-execution.ts +816 -0
  39. package/src/adapters/chatgpt-web/turn-progress.ts +292 -0
  40. package/src/adapters/chatgpt-web/usage.ts +121 -0
  41. package/src/adapters/image.ts +9 -0
  42. package/src/bridge.ts +1083 -0
  43. package/src/browser-login.ts +521 -0
  44. package/src/chatgpt-session.ts +240 -0
  45. package/src/chatgpt-web-models.ts +400 -0
  46. package/src/cli.ts +568 -0
  47. package/src/codex-integration-document.ts +824 -0
  48. package/src/codex-integration-journal.ts +212 -0
  49. package/src/codex-integration-route.ts +515 -0
  50. package/src/codex-integration-shared.ts +332 -0
  51. package/src/codex-integration.ts +529 -0
  52. package/src/codex-interrupt-hook.ts +158 -0
  53. package/src/config.ts +616 -0
  54. package/src/dev-chat/cli.ts +432 -0
  55. package/src/dev-chat/constants.ts +3 -0
  56. package/src/dev-chat/driver.ts +655 -0
  57. package/src/dev-chat/profile.ts +223 -0
  58. package/src/dev-chat/session.ts +287 -0
  59. package/src/dev-chat/transport.ts +54 -0
  60. package/src/doctor.ts +237 -0
  61. package/src/event-queue.ts +45 -0
  62. package/src/http-body.ts +30 -0
  63. package/src/launcher-browser-host.ts +695 -0
  64. package/src/lib/errors.ts +281 -0
  65. package/src/lib/token-estimate.ts +42 -0
  66. package/src/login-helper.cjs +140 -0
  67. package/src/model-catalog.ts +197 -0
  68. package/src/native-passthrough.ts +261 -0
  69. package/src/plugin.ts +191 -0
  70. package/src/process.ts +45 -0
  71. package/src/responses/compaction.ts +199 -0
  72. package/src/responses/parser.ts +633 -0
  73. package/src/responses/reasoning-envelope.ts +49 -0
  74. package/src/responses/schema.ts +172 -0
  75. package/src/responses/state.ts +230 -0
  76. package/src/server.ts +1111 -0
  77. package/src/service.ts +315 -0
  78. package/src/setup.ts +671 -0
  79. package/src/stall-timeout.ts +23 -0
  80. package/src/tunnel-service.ts +160 -0
  81. package/src/tunnel.ts +417 -0
  82. package/src/turndown-plugin-gfm.d.ts +5 -0
  83. package/src/types.ts +307 -0
  84. package/src/usage/totals.ts +12 -0
  85. package/src/version.ts +1 -0
@@ -0,0 +1,1544 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { resolve } from "node:path";
3
+ import { isChatGptWebZeroRiskBackendModel } from "../../chatgpt-web-models";
4
+ import { defaultBrokerEndpoint, expandUserPath, resolveBrokerEndpoint } from "../../config";
5
+ import {
6
+ cancelLauncherManualTurn,
7
+ endLauncherManualTurn,
8
+ LauncherBrowserTurnCancelledError,
9
+ LauncherManualTurnFailedError,
10
+ LauncherManualTurnTimedOutError,
11
+ markLauncherManualTurnStarted,
12
+ releaseLauncherRetainedConversation,
13
+ startLauncherManualTurn,
14
+ waitForLauncherManualSent,
15
+ waitForLauncherManualTerminal,
16
+ type LauncherManualTurnEnd,
17
+ type LauncherManualTurnOwner,
18
+ type LauncherManualTurnStart,
19
+ } from "../../launcher-browser-host";
20
+ import { namespacedToolName, type AdapterEvent, type CodexContentPart, type CodexParsedRequest, type CodexProviderConfig, type CodexToolResultMessage, type CodexUsage } from "../../types";
21
+ import type { ProviderAdapter } from "../base";
22
+ import { parseDataUrl } from "../image";
23
+ import { ChatGptWebAdapterError } from "./adapter-error";
24
+ import { ChatGptBrowserWorker } from "./browser-worker";
25
+ import { extractChatGptTurnEnvironment, extractChatGptTurnIdentity, priorChatGptAbortedTurnIds } from "./environment";
26
+ import { CHATGPT_WEB_LUNA_MODEL_ID, resolveChatGptWebModelMode, type ChatGptWebCapabilities } from "./model";
27
+ import { chatGptReadOnlyContextWarning, compileChatGptWebPrompt } from "./prompt";
28
+ import { createChatGptStructuredOutputValidator } from "./output-validation";
29
+ import { ChatGptToolStreamParser, type ParsedToolCall } from "./tool-stream-parser";
30
+ import { chatGptWebTurnRetryPolicy } from "./retry-policy";
31
+ import { TurnBroker, type BrokerToolRequest, type BrokerToolResult, type TurnBrokerOwner } from "./turn-broker";
32
+ import { ChatGptTextFeed, ChatGptTraceFeed, chatGptCompactionSourceExecutionKey, chatGptThreadOwnershipKey, chatGptTurnExecutionKey, chatGptTurnRetryKey, chatGptTurnRoundKey, chatGptTurnSessions, type ChatGptBrowserOutcome, type ChatGptTraceEvent, type ChatGptTurnRuntime, type ChatGptTurnSession } from "./turn-execution";
33
+ import { estimateChatGptWebUsage, resolveBiggerContextMultipartParts } from "./usage";
34
+ import { ChatGptThreadEnvironmentStore } from "./thread-environment";
35
+ import {
36
+ ChatGptLunaCheckpointStore,
37
+ type CapturedChatGptLunaCheckpoint,
38
+ } from "./rolling-checkpoint";
39
+ import { ChatGptExternalTurnProgress } from "./turn-progress";
40
+ import {
41
+ canonicalizeCompactionHandoff,
42
+ existingStructuredCompactionRun,
43
+ MAX_COMPACTION_HANDOFF_TIMEOUT_MS,
44
+ requestRetainedCompactionHandoff,
45
+ runStructuredCompactionOnce,
46
+ settleActiveCompactionSource,
47
+ settleActiveZeroRiskCompactionSource,
48
+ } from "./compaction-handoff";
49
+ import {
50
+ chatGptConversationKey,
51
+ retainedConversationResumeRequest,
52
+ } from "./conversation-key";
53
+
54
+ function extractLatestUserPrompt(parsed: CodexParsedRequest): string {
55
+ const messages = parsed.context.messages ?? [];
56
+ const userTexts: string[] = [];
57
+ for (let i = messages.length - 1; i >= 0; i--) {
58
+ const msg = messages[i];
59
+ if (msg && msg.role === "user") {
60
+ let text = "";
61
+ if (typeof msg.content === "string") {
62
+ text = msg.content;
63
+ } else if (Array.isArray(msg.content)) {
64
+ text = msg.content
65
+ .map(part => {
66
+ if (typeof part === "string") return part;
67
+ if (part && "text" in part && typeof (part as { text?: string }).text === "string") return (part as { text: string }).text;
68
+ return "";
69
+ })
70
+ .join(" ");
71
+ }
72
+ text = text.trim();
73
+ if (!text) continue;
74
+ // Skip operational or system-injected messages
75
+ if (
76
+ text.startsWith("Time sampled") ||
77
+ text.includes("<environment_context>") ||
78
+ text.includes("<system-reminder>") ||
79
+ text.includes("Context injection") ||
80
+ text.startsWith("Turn checkpoint")
81
+ ) {
82
+ continue;
83
+ }
84
+ userTexts.push(text);
85
+ if (userTexts.length >= 5) break;
86
+ }
87
+ }
88
+ return userTexts.join(" ");
89
+ }
90
+
91
+ function brokerSocketPath(provider: CodexProviderConfig): string {
92
+ const configured = provider.chatgptWeb?.brokerSocketPath?.trim();
93
+ return resolveBrokerEndpoint(configured || defaultBrokerEndpoint());
94
+ }
95
+
96
+ function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void; reject: (error: Error) => void } {
97
+ let resolvePromise!: (value: T) => void;
98
+ let rejectPromise!: (error: Error) => void;
99
+ const promise = new Promise<T>((resolveDeferred, rejectDeferred) => {
100
+ resolvePromise = resolveDeferred;
101
+ rejectPromise = rejectDeferred;
102
+ });
103
+ return { promise, resolve: resolvePromise, reject: rejectPromise };
104
+ }
105
+
106
+ function abortError(signal?: AbortSignal): Error {
107
+ if (signal?.reason instanceof ChatGptWebAdapterError) return signal.reason;
108
+ return new DOMException("ChatGPT web turn aborted", "AbortError");
109
+ }
110
+
111
+ function withAbort<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
112
+ if (!signal) return promise;
113
+ if (signal.aborted) return Promise.reject(abortError(signal));
114
+ return new Promise<T>((resolveWait, rejectWait) => {
115
+ const onAbort = () => rejectWait(abortError(signal));
116
+ signal.addEventListener("abort", onAbort, { once: true });
117
+ promise.then(
118
+ value => {
119
+ signal.removeEventListener("abort", onAbort);
120
+ resolveWait(value);
121
+ },
122
+ error => {
123
+ signal.removeEventListener("abort", onAbort);
124
+ rejectWait(error);
125
+ },
126
+ );
127
+ });
128
+ }
129
+
130
+ function cancellableBrowserTurn(
131
+ run: Promise<string>,
132
+ controller: AbortController,
133
+ ): { browser: Promise<string>; physicalSettlement: Promise<void>; cancel: (reason?: Error) => void } {
134
+ let rejectCancellation!: (error: Error) => void;
135
+ const cancellation = new Promise<never>((_resolve, reject) => {
136
+ rejectCancellation = reject;
137
+ });
138
+ let cancellationRejected = false;
139
+ return {
140
+ // Cancellation wins immediately even while the detached Playwright helper is still unwinding.
141
+ // The helper keeps the same abort signal and remains responsible for its normal end/cleanup
142
+ // handshake, but the Codex Responses turn no longer waits on that process cleanup.
143
+ browser: Promise.race([run, cancellation]),
144
+ // `browser` is the fast client-facing result. Replacement ownership must wait for the actual
145
+ // worker promise, whose finally block completes the launcher /turn/end handshake.
146
+ physicalSettlement: run.then(() => undefined, () => undefined),
147
+ cancel(reason?: Error) {
148
+ if (!controller.signal.aborted) controller.abort(reason);
149
+ // Explicit targeted cancellation ends the Codex Responses turn immediately. Generic
150
+ // retirement (client disconnect or compaction replacement) still waits for the helper's
151
+ // cleanup handshake before a replacement browser may start.
152
+ if (reason && !cancellationRejected) {
153
+ cancellationRejected = true;
154
+ rejectCancellation(reason);
155
+ }
156
+ },
157
+ };
158
+ }
159
+
160
+ export interface ChatGptZeroRiskManualControl {
161
+ start(descriptorPath: string, activity: LauncherManualTurnStart): Promise<unknown>;
162
+ waitSent(
163
+ descriptorPath: string,
164
+ owner: LauncherManualTurnOwner,
165
+ options?: { abortSignal?: AbortSignal; timeoutMs?: number },
166
+ ): Promise<unknown>;
167
+ waitTerminal(
168
+ descriptorPath: string,
169
+ owner: LauncherManualTurnOwner,
170
+ options?: { abortSignal?: AbortSignal; timeoutMs?: number },
171
+ ): Promise<{ status: "cancelled" | "failed" }>;
172
+ markStarted(descriptorPath: string, owner: LauncherManualTurnOwner): Promise<void>;
173
+ end(descriptorPath: string, activity: LauncherManualTurnEnd): Promise<unknown>;
174
+ cancel(descriptorPath: string, owner: LauncherManualTurnOwner): Promise<void>;
175
+ }
176
+
177
+ const launcherZeroRiskManualControl: ChatGptZeroRiskManualControl = {
178
+ start: startLauncherManualTurn,
179
+ waitSent: waitForLauncherManualSent,
180
+ waitTerminal: waitForLauncherManualTerminal,
181
+ markStarted: markLauncherManualTurnStarted,
182
+ end: endLauncherManualTurn,
183
+ cancel: cancelLauncherManualTurn,
184
+ };
185
+
186
+ function safeManualAdapterError(error: unknown): Error {
187
+ if (error instanceof DOMException && error.name === "AbortError") return error;
188
+ if (error instanceof ChatGptWebAdapterError) return error;
189
+ if (error instanceof LauncherManualTurnTimedOutError) {
190
+ return new ChatGptWebAdapterError(error.message, {
191
+ status: 408,
192
+ errorType: "invalid_request_error",
193
+ code: "manual_handoff_timeout",
194
+ retryable: false,
195
+ });
196
+ }
197
+ if (error instanceof LauncherBrowserTurnCancelledError) {
198
+ return new ChatGptWebAdapterError(error.message, {
199
+ status: 409,
200
+ errorType: "invalid_request_error",
201
+ code: "manual_turn_cancelled",
202
+ retryable: false,
203
+ });
204
+ }
205
+ if (error instanceof LauncherManualTurnFailedError) {
206
+ return new ChatGptWebAdapterError(error.message, {
207
+ status: 502,
208
+ errorType: "server_error",
209
+ code: "manual_launcher_failed",
210
+ retryable: false,
211
+ });
212
+ }
213
+ return error instanceof Error ? error : new Error(String(error));
214
+ }
215
+
216
+ function safeManualTerminalError(status: "cancelled" | "failed"): ChatGptWebAdapterError {
217
+ if (status === "cancelled") {
218
+ return new ChatGptWebAdapterError("The Zero Risk browser turn was cancelled in the Launcher", {
219
+ status: 409,
220
+ errorType: "invalid_request_error",
221
+ code: "manual_turn_cancelled",
222
+ retryable: false,
223
+ });
224
+ }
225
+ return new ChatGptWebAdapterError("The Zero Risk browser tab failed before ChatGPT completed the turn", {
226
+ status: 502,
227
+ errorType: "server_error",
228
+ code: "manual_launcher_failed",
229
+ retryable: false,
230
+ });
231
+ }
232
+
233
+ export function chatGptWebExecutionNamespace(provider: CodexProviderConfig): string {
234
+ return createHash("sha256").update(JSON.stringify({
235
+ baseUrl: provider.baseUrl,
236
+ chatgptWeb: provider.chatgptWeb ?? {},
237
+ })).digest("hex");
238
+ }
239
+
240
+ export function chatGptWebTraceId(provider: CodexProviderConfig, parsed: CodexParsedRequest): string {
241
+ return createHash("sha256")
242
+ .update(`${chatGptWebExecutionNamespace(provider)}:${chatGptTurnExecutionKey(parsed)}`)
243
+ .digest("hex")
244
+ .slice(0, 12);
245
+ }
246
+
247
+ function structuredContent(text: string): unknown | undefined {
248
+ try {
249
+ const parsed: unknown = JSON.parse(text);
250
+ return parsed !== null && typeof parsed === "object" ? parsed : undefined;
251
+ } catch {
252
+ return undefined;
253
+ }
254
+ }
255
+
256
+ function brokerContent(content: string | CodexContentPart[]): unknown[] {
257
+ if (typeof content === "string") return [{ type: "text", text: content }];
258
+ return content.map(part => {
259
+ if (part.type === "text") return { type: "text", text: part.text };
260
+ const parsed = parseDataUrl(part.imageUrl);
261
+ if (parsed) return { type: "image", data: parsed.base64, mimeType: parsed.mediaType };
262
+ return { type: "resource_link", uri: part.imageUrl, name: "Codex tool image", mimeType: "image/*" };
263
+ });
264
+ }
265
+
266
+ function brokerResult(message: CodexToolResultMessage): BrokerToolResult {
267
+ const content = brokerContent(message.content);
268
+ const text = typeof message.content === "string"
269
+ ? message.content
270
+ : message.content.filter(part => part.type === "text").map(part => part.text).join("\n");
271
+ const structured = structuredContent(text);
272
+ return {
273
+ content,
274
+ ...(structured !== undefined ? { structuredContent: structured } : {}),
275
+ ...(message.isError ? { isError: true } : {}),
276
+ };
277
+ }
278
+
279
+ function emitToolBatch(requests: BrokerToolRequest[], usage: CodexUsage, emit: (event: AdapterEvent) => void): void {
280
+ for (const request of requests) {
281
+ emit({ type: "tool_call_start", id: request.callId, name: request.wireName });
282
+ emit({
283
+ type: "tool_call_delta",
284
+ arguments: request.freeform
285
+ ? JSON.stringify({ input: request.input ?? "" })
286
+ : JSON.stringify(request.arguments ?? {}),
287
+ });
288
+ emit({ type: "tool_call_end" });
289
+ }
290
+ emit({ type: "done", stopReason: "tool_use", endTurn: false, usage });
291
+ }
292
+
293
+ function emitBrowserCompletion(outcome: ChatGptBrowserOutcome, usage: CodexUsage, emit: (event: AdapterEvent) => void): void {
294
+ if (outcome.type === "error") throw outcome.error;
295
+ emit({ type: "done", stopReason: "stop", endTurn: true, usage });
296
+ }
297
+
298
+ function emitTraceEvents(trace: ChatGptTraceEvent[], emit: (event: AdapterEvent) => void): void {
299
+ for (const event of trace) {
300
+ if (!event.continuation) emit({ type: "assistant_boundary" });
301
+ if (event.kind === "commentary") {
302
+ emit({ type: "text_delta", text: event.text, phase: "commentary" });
303
+ } else {
304
+ emit({ type: "thinking_delta", thinking: event.text });
305
+ }
306
+ }
307
+ }
308
+
309
+ function emitTextDeltas(deltas: string[], emit: (event: AdapterEvent) => void): void {
310
+ for (const text of deltas) emit({ type: "text_delta", text, phase: "final_answer" });
311
+ }
312
+
313
+ function emitReadOnlyContextWarning(
314
+ parsed: CodexParsedRequest,
315
+ capabilities: ChatGptWebCapabilities,
316
+ emit: (event: AdapterEvent) => void,
317
+ ): void {
318
+ const warning = chatGptReadOnlyContextWarning(parsed, capabilities);
319
+ if (!warning) return;
320
+ emit({ type: "assistant_boundary" });
321
+ emit({ type: "text_delta", text: warning, phase: "commentary" });
322
+ emit({ type: "assistant_boundary" });
323
+ }
324
+
325
+ function replayEvents(events: AdapterEvent[], emit: (event: AdapterEvent) => void): void {
326
+ for (const event of events) emit(event);
327
+ }
328
+
329
+ function submittedTurnFailure(session: ChatGptTurnSession, error: unknown): Error {
330
+ const normalized = error instanceof Error ? error : new Error(String(error));
331
+ if (normalized instanceof ChatGptWebAdapterError) return normalized;
332
+ const phase = session.runtime.submission?.phase;
333
+ if (!phase || phase === "prepared") return normalized;
334
+ const ambiguous = phase === "send_activated";
335
+ return new ChatGptWebAdapterError(
336
+ ambiguous
337
+ ? "ChatGPT did not confirm that the prompt was sent. Check the ChatGPT tab before continuing."
338
+ : "ChatGPT stopped responding after the task started. Check the ChatGPT tab before continuing.",
339
+ {
340
+ status: 502,
341
+ errorType: "server_error",
342
+ code: ambiguous ? "chatgpt_submission_ambiguous" : "chatgpt_submitted_turn_failed",
343
+ retryable: false,
344
+ cause: normalized,
345
+ },
346
+ );
347
+ }
348
+
349
+ function currentToolResults(parsed: CodexParsedRequest, session: ChatGptTurnSession): CodexToolResultMessage[] {
350
+ const byId = new Map<string, CodexToolResultMessage>();
351
+ for (const message of parsed.context.messages) {
352
+ if (message.role !== "toolResult" || !session.hasOutstanding(message.toolCallId)) continue;
353
+ if (byId.has(message.toolCallId)) throw new Error(`Codex returned duplicate results for tool call ${message.toolCallId}`);
354
+ byId.set(message.toolCallId, message);
355
+ }
356
+ return [...byId.values()];
357
+ }
358
+
359
+ function validateBatchTools(parsed: CodexParsedRequest, requests: BrokerToolRequest[]): void {
360
+ const available = new Set((parsed.context.tools ?? []).map(tool => namespacedToolName(tool.namespace, tool.name)));
361
+ for (const request of requests) {
362
+ if (!available.has(request.wireName)) {
363
+ throw new Error(`ChatGPT requested a tool that the active Codex round did not advertise: ${request.wireName}`);
364
+ }
365
+ }
366
+ }
367
+
368
+ /** Keep the Responses bridge alive during every awaited phase of a browser turn. */
369
+ export const CHATGPT_WEB_ADAPTER_HEARTBEAT_MS = 10_000;
370
+
371
+ export function createChatGptWebAdapter(
372
+ provider: CodexProviderConfig,
373
+ dependencies: {
374
+ broker?: TurnBrokerOwner;
375
+ zeroRiskManualControl?: ChatGptZeroRiskManualControl;
376
+ } = {},
377
+ ): ProviderAdapter {
378
+ const worker = ChatGptBrowserWorker.forProvider(provider);
379
+ const broker = dependencies.broker ?? TurnBroker.forSocket(brokerSocketPath(provider));
380
+ const zeroRiskManualControl = dependencies.zeroRiskManualControl ?? launcherZeroRiskManualControl;
381
+ const structuredBroker = broker instanceof TurnBroker ? broker : undefined;
382
+ const timeoutMs = provider.chatgptWeb?.turnTimeoutMs;
383
+ const experimentalBiggerContext = provider.chatgptWeb?.experimentalBiggerContext;
384
+ if (experimentalBiggerContext !== undefined && typeof experimentalBiggerContext !== "boolean") {
385
+ throw new Error("ChatGPT Bigger Context preference must be a boolean");
386
+ }
387
+ const configuredCapabilities: ChatGptWebCapabilities = {
388
+ localToolsEnabled: provider.chatgptWeb?.localToolsEnabled === true,
389
+ solAvailable: provider.chatgptWeb?.solAvailable !== false,
390
+ proAvailable: provider.chatgptWeb?.proAvailable === true,
391
+ };
392
+ const manualInteraction = provider.chatgptWeb?.browserInteractionMode === "manual";
393
+ const executionNamespace = chatGptWebExecutionNamespace(provider);
394
+ const retainedLauncherDescriptor = provider.chatgptWeb?.browserHost === "launcher"
395
+ && provider.chatgptWeb.browserHostDescriptorPath
396
+ ? resolve(expandUserPath(provider.chatgptWeb.browserHostDescriptorPath))
397
+ : undefined;
398
+ if (manualInteraction) {
399
+ if (!configuredCapabilities.localToolsEnabled) {
400
+ throw new Error("ChatGPT Zero Risk requires the Full Codex harness");
401
+ }
402
+ if (!retainedLauncherDescriptor) {
403
+ throw new Error("ChatGPT Zero Risk requires the Launcher browser host");
404
+ }
405
+ }
406
+ const environmentStore = new ChatGptThreadEnvironmentStore(
407
+ provider.chatgptWeb?.threadEnvironmentStatePath
408
+ ? resolve(expandUserPath(provider.chatgptWeb.threadEnvironmentStatePath))
409
+ : undefined,
410
+ );
411
+ const lunaCheckpointStore = new ChatGptLunaCheckpointStore(
412
+ provider.chatgptWeb?.lunaCheckpointStatePath
413
+ ? resolve(expandUserPath(provider.chatgptWeb.lunaCheckpointStatePath))
414
+ : undefined,
415
+ );
416
+ const currentUsageInput = (parsed: CodexParsedRequest): CodexParsedRequest => (
417
+ parsed.modelId === CHATGPT_WEB_LUNA_MODEL_ID && !parsed._compactionRequest
418
+ ? lunaCheckpointStore.apply(parsed).parsed
419
+ : parsed
420
+ );
421
+
422
+ const startRuntime = (
423
+ parsed: CodexParsedRequest,
424
+ environment: ReturnType<typeof extractChatGptTurnEnvironment> | undefined,
425
+ traceId: string,
426
+ turnCapabilities: ChatGptWebCapabilities,
427
+ ): ChatGptTurnRuntime => {
428
+ const manualRequest = isChatGptWebZeroRiskBackendModel(parsed.modelId);
429
+ if (manualRequest !== manualInteraction) {
430
+ throw new Error(
431
+ manualInteraction
432
+ ? "ChatGPT Zero Risk requires the Zero Risk Web model route"
433
+ : "The Zero Risk Web model route requires ChatGPT Zero Risk interaction mode",
434
+ );
435
+ }
436
+ const mode = manualRequest
437
+ ? { localTools: true }
438
+ : resolveChatGptWebModelMode(parsed.modelId, parsed.options.reasoning, turnCapabilities);
439
+ const identity = extractChatGptTurnIdentity(parsed);
440
+ const captureLunaCheckpoint = parsed.modelId === CHATGPT_WEB_LUNA_MODEL_ID
441
+ && !parsed._compactionRequest
442
+ && Boolean(identity.threadId && identity.turnId);
443
+ const checkpointInput = captureLunaCheckpoint
444
+ ? lunaCheckpointStore.apply(parsed)
445
+ : { parsed, applied: false };
446
+ const conversationKey = !parsed._compactionRequest
447
+ && parsed.modelId !== CHATGPT_WEB_LUNA_MODEL_ID
448
+ && mode.localTools
449
+ && retainedLauncherDescriptor
450
+ ? chatGptConversationKey(checkpointInput.parsed, executionNamespace)
451
+ : undefined;
452
+ const resumeInput = conversationKey
453
+ ? retainedConversationResumeRequest(checkpointInput.parsed)
454
+ : undefined;
455
+ const retainConversation = conversationKey !== undefined;
456
+ const releaseRetainedConversation = conversationKey && retainedLauncherDescriptor
457
+ ? async () => {
458
+ await releaseLauncherRetainedConversation(retainedLauncherDescriptor, conversationKey);
459
+ }
460
+ : undefined;
461
+ const compileOptionsFor = (input: CodexParsedRequest) => {
462
+ if (manualRequest) return {};
463
+ const experimentalMultipartParts = experimentalBiggerContext
464
+ ? resolveBiggerContextMultipartParts(input, turnCapabilities)
465
+ : undefined;
466
+ return {
467
+ captureLunaCheckpoint,
468
+ ...(experimentalMultipartParts !== undefined
469
+ ? { experimentalMultipartParts }
470
+ : {}),
471
+ };
472
+ };
473
+ if (captureLunaCheckpoint) {
474
+ console.info(
475
+ `[chatgpt-web] Luna rolling checkpoint applied=${checkpointInput.applied}${checkpointInput.reason ? ` reason=${checkpointInput.reason}` : ""}`,
476
+ );
477
+ }
478
+ let capturedCheckpoint: CapturedChatGptLunaCheckpoint | undefined;
479
+ let checkpointCaptureError: Error | undefined;
480
+ const captureCheckpoint = (captured: CapturedChatGptLunaCheckpoint): void => {
481
+ if (capturedCheckpoint) {
482
+ checkpointCaptureError = new Error("ChatGPT Luna emitted more than one rolling checkpoint");
483
+ return;
484
+ }
485
+ capturedCheckpoint = captured;
486
+ };
487
+ const finalizeCheckpoint = (browser: Promise<string>): Promise<string> => browser.then(answer => {
488
+ if (!captureLunaCheckpoint) return answer;
489
+ if (checkpointCaptureError) throw checkpointCaptureError;
490
+ if (capturedCheckpoint) lunaCheckpointStore.commit(parsed, capturedCheckpoint, answer);
491
+ return answer;
492
+ });
493
+ const browserAbort = new AbortController();
494
+ let browserOwnerSettled = false;
495
+ const trackBrowserOwner = (browser: Promise<string>): Promise<string> => browser.finally(() => {
496
+ browserOwnerSettled = true;
497
+ });
498
+ const trace = new ChatGptTraceFeed();
499
+ const text = new ChatGptTextFeed();
500
+ const observedCapabilityTokens = new Set<string>();
501
+ const observeCapabilityRetirement = (
502
+ turnToken: string,
503
+ externalProgress: ChatGptExternalTurnProgress,
504
+ ): void => {
505
+ if (observedCapabilityTokens.has(turnToken)) return;
506
+ observedCapabilityTokens.add(turnToken);
507
+ void broker.waitForRetirement(turnToken).then(
508
+ () => {
509
+ const retirement = new Error("Codex Native retired the turn binding before its tool work completed");
510
+ externalProgress.retire(retirement);
511
+ if (!browserOwnerSettled && !browserAbort.signal.aborted) browserAbort.abort(retirement);
512
+ },
513
+ error => {
514
+ const failure = new Error("ChatGPT could not observe Codex Native turn retirement", {
515
+ cause: error,
516
+ });
517
+ externalProgress.retire(failure);
518
+ if (!browserAbort.signal.aborted) browserAbort.abort(failure);
519
+ },
520
+ );
521
+ };
522
+ const submission: NonNullable<ChatGptTurnRuntime["submission"]> = { phase: "prepared" };
523
+ // A canonical compaction request is side-effect free and remains safe to rebuild after an
524
+ // ambiguous browser send. Normal task prompts must never be replayed after Send activation.
525
+ const submissionLifecycle = parsed._compactionRequest ? {} : {
526
+ onSendActivated: () => { submission.phase = "send_activated" as const; },
527
+ onSubmitted: () => { submission.phase = "accepted" as const; },
528
+ };
529
+ if (manualRequest) {
530
+ if (!environment) throw new Error("ChatGPT Zero Risk requires a trusted Codex environment");
531
+ if (!retainedLauncherDescriptor) throw new Error("ChatGPT Zero Risk requires the Launcher browser host");
532
+ const token = deferred<string>();
533
+ const externalProgress = new ChatGptExternalTurnProgress();
534
+ const surfaceNonce = randomBytes(32).toString("base64url");
535
+ const owner: LauncherManualTurnOwner = { traceId, helperPid: process.pid };
536
+ let tokenSettled = false;
537
+ let activeToken: string | undefined;
538
+ let launcherStarted = false;
539
+ let launcherEnded = false;
540
+ const finishLauncher = async (status: LauncherManualTurnEnd["status"]): Promise<void> => {
541
+ if (!launcherStarted || launcherEnded) return;
542
+ await zeroRiskManualControl.end(retainedLauncherDescriptor, {
543
+ ...owner,
544
+ status,
545
+ ...(status === "completed" && retainConversation ? { retain: true } : {}),
546
+ });
547
+ launcherEnded = true;
548
+ };
549
+ const runManual = async (): Promise<string> => {
550
+ try {
551
+ activeToken = await broker.registerSafe(environment, surfaceNonce, undefined, traceId);
552
+ observeCapabilityRetirement(activeToken, externalProgress);
553
+ const compiled = compileChatGptWebPrompt(
554
+ checkpointInput.parsed,
555
+ turnCapabilities,
556
+ activeToken,
557
+ { manualControl: true },
558
+ );
559
+ const resumeCompiled = resumeInput
560
+ ? compileChatGptWebPrompt(
561
+ resumeInput,
562
+ turnCapabilities,
563
+ activeToken,
564
+ { manualControl: true },
565
+ )
566
+ : undefined;
567
+ for (const candidate of [compiled, resumeCompiled]) {
568
+ if (!candidate) continue;
569
+ if (candidate.multipart) {
570
+ throw new ChatGptWebAdapterError("ChatGPT Zero Risk does not support multipart browser transport", {
571
+ status: 409,
572
+ errorType: "invalid_request_error",
573
+ code: "manual_multipart_unsupported",
574
+ retryable: false,
575
+ });
576
+ }
577
+ }
578
+ tokenSettled = true;
579
+ token.resolve(activeToken);
580
+ if (!parsed._compactionRequest) {
581
+ trace.push({
582
+ kind: "commentary",
583
+ text: "> **Action required in Zero Risk**\n>\n> Open the launcher, copy and paste the prompt into ChatGPT, add any images yourself because Zero Risk cannot transfer them, select the `Codex Zero Risk` plugin and the model you want, send the prompt, then confirm it was sent in the launcher.",
584
+ });
585
+ }
586
+ await zeroRiskManualControl.start(retainedLauncherDescriptor, {
587
+ ...owner,
588
+ prompt: compiled.text,
589
+ ...(resumeCompiled ? { resumePrompt: resumeCompiled.text } : {}),
590
+ ...(conversationKey ? { conversationKey } : {}),
591
+ });
592
+ launcherStarted = true;
593
+ await zeroRiskManualControl.waitSent(retainedLauncherDescriptor, owner, {
594
+ abortSignal: browserAbort.signal,
595
+ });
596
+ await broker.confirmSafeTurnSent(activeToken, surfaceNonce);
597
+ submission.phase = "accepted";
598
+ if (!parsed._compactionRequest) trace.push({
599
+ kind: "commentary",
600
+ text: "> **Waiting for ChatGPT**\n>\n> The prompt is marked `Sent`. Waiting for `Codex Zero Risk` to bind this turn through the selected ChatGPT connector.",
601
+ });
602
+ const terminalAbort = new AbortController();
603
+ const abortTerminal = () => terminalAbort.abort();
604
+ browserAbort.signal.addEventListener("abort", abortTerminal, { once: true });
605
+ const terminalFailure = zeroRiskManualControl.waitTerminal(
606
+ retainedLauncherDescriptor,
607
+ owner,
608
+ { abortSignal: terminalAbort.signal },
609
+ ).then(observed => Promise.reject(safeManualTerminalError(observed.status)))
610
+ .catch(error => terminalAbort.signal.aborted
611
+ ? new Promise<never>(() => {})
612
+ : Promise.reject(error));
613
+ let answer: string;
614
+ try {
615
+ await Promise.race([
616
+ broker.waitForSafeStart(activeToken, browserAbort.signal),
617
+ terminalFailure,
618
+ ]);
619
+ await zeroRiskManualControl.markStarted(retainedLauncherDescriptor, owner);
620
+ if (!parsed._compactionRequest) trace.push({
621
+ kind: "commentary",
622
+ text: "> **Zero Risk connected**\n>\n> `Codex Zero Risk` is connected. ChatGPT is now working through the native Codex harness; progress remains visible in the launcher.",
623
+ });
624
+ answer = await Promise.race([
625
+ broker.waitForSafeCompletion(activeToken, browserAbort.signal),
626
+ terminalFailure,
627
+ ]);
628
+ } finally {
629
+ terminalAbort.abort();
630
+ browserAbort.signal.removeEventListener("abort", abortTerminal);
631
+ }
632
+ text.push(answer);
633
+ try {
634
+ await finishLauncher("completed");
635
+ } catch (controlError) {
636
+ // The broker result is already authoritative. A launcher acknowledgement failure may
637
+ // leave UI cleanup pending, but it must not replace a completed Codex answer with an
638
+ // error or trigger a contradictory failed terminal mutation.
639
+ console.error(
640
+ `[chatgpt-web] completed Zero Risk turn but could not confirm launcher cleanup: ${controlError instanceof Error ? controlError.message : String(controlError)}`,
641
+ );
642
+ }
643
+ return answer;
644
+ } catch (error) {
645
+ const normalized = safeManualAdapterError(error);
646
+ // Capture the causal state before our own cleanup revokes the broker capability. The
647
+ // retirement observer also aborts browserAbort, but that self-induced abort must not turn
648
+ // an ordinary launcher/runtime failure into a user cancellation.
649
+ const externallyAborted = browserAbort.signal.aborted;
650
+ if (activeToken) await Promise.resolve(broker.revoke(activeToken, normalized)).catch(() => {});
651
+ try {
652
+ await finishLauncher(externallyAborted ? "aborted" : "failed");
653
+ } catch (controlError) {
654
+ console.error(
655
+ `[chatgpt-web] failed to release Zero Risk launcher turn: ${controlError instanceof Error ? controlError.message : String(controlError)}`,
656
+ );
657
+ }
658
+ throw normalized;
659
+ }
660
+ };
661
+ const browserTurn = cancellableBrowserTurn(trackBrowserOwner(runManual()), browserAbort);
662
+ void browserTurn.browser.catch(error => {
663
+ if (tokenSettled) return;
664
+ tokenSettled = true;
665
+ token.reject(error instanceof Error ? error : new Error(String(error)));
666
+ });
667
+ return {
668
+ mode: "tools",
669
+ token: token.promise,
670
+ externalProgress,
671
+ browser: browserTurn.browser,
672
+ physicalSettlement: browserTurn.physicalSettlement,
673
+ trace,
674
+ text,
675
+ usageInput: checkpointInput.parsed,
676
+ manualControl: { surfaceNonce },
677
+ ...(conversationKey ? { conversationKey } : {}),
678
+ ...(releaseRetainedConversation ? { releaseRetainedConversation } : {}),
679
+ retireCapability: async () => {
680
+ if (activeToken) await broker.revoke(activeToken);
681
+ },
682
+ submission,
683
+ cancel: (reason?: Error) => {
684
+ browserTurn.cancel(reason);
685
+ if (activeToken) {
686
+ void Promise.resolve(broker.revoke(activeToken, reason)).catch(error => {
687
+ console.error(`[chatgpt-web] failed to revoke cancelled Zero Risk request: ${error instanceof Error ? error.message : String(error)}`);
688
+ });
689
+ }
690
+ },
691
+ };
692
+ }
693
+ if (!mode.localTools) {
694
+ const browserTurn = cancellableBrowserTurn(finalizeCheckpoint(worker.run({
695
+ traceId,
696
+ modelId: parsed.modelId,
697
+ reasoning: parsed.options.reasoning,
698
+ capabilities: turnCapabilities,
699
+ prepare: async () => ({
700
+ ...compileChatGptWebPrompt(
701
+ checkpointInput.parsed,
702
+ turnCapabilities,
703
+ undefined,
704
+ compileOptionsFor(checkpointInput.parsed),
705
+ ),
706
+ release: () => {},
707
+ }),
708
+ abortSignal: browserAbort.signal,
709
+ ...(parsed._compactionRequest ? { compaction: true } : {}),
710
+ ...submissionLifecycle,
711
+ onReasoningSummary: (text, continuation) => trace.push({ kind: "reasoning", text, ...(continuation ? { continuation: true } : {}) }),
712
+ onCommentary: (text, continuation) => trace.push({ kind: "commentary", text, ...(continuation ? { continuation: true } : {}) }),
713
+ onTextDelta: delta => text.push(delta),
714
+ ...(captureLunaCheckpoint ? {
715
+ captureLunaCheckpoint: true,
716
+ onLunaCheckpoint: captureCheckpoint,
717
+ } : {}),
718
+ })), browserAbort);
719
+ return {
720
+ mode: "read-only",
721
+ browser: browserTurn.browser,
722
+ physicalSettlement: browserTurn.physicalSettlement,
723
+ trace,
724
+ text,
725
+ usageInput: checkpointInput.parsed,
726
+ submission,
727
+ cancel: browserTurn.cancel,
728
+ };
729
+ }
730
+ if (!environment) throw new Error("Tool-capable ChatGPT web mode requires a trusted Codex environment");
731
+ const token = deferred<string>();
732
+ const externalProgress = new ChatGptExternalTurnProgress();
733
+ let tokenSettled = false;
734
+ let activeToken: string | undefined;
735
+ const prepareWith = async (input: CodexParsedRequest) => {
736
+ const turnToken = activeToken ?? await broker.register(
737
+ environment,
738
+ timeoutMs === undefined ? undefined : timeoutMs + 60_000,
739
+ traceId,
740
+ );
741
+ activeToken = turnToken;
742
+ observeCapabilityRetirement(turnToken, externalProgress);
743
+ if (!tokenSettled) {
744
+ tokenSettled = true;
745
+ token.resolve(turnToken);
746
+ }
747
+ try {
748
+ const compiled = compileChatGptWebPrompt(
749
+ input,
750
+ turnCapabilities,
751
+ turnToken,
752
+ compileOptionsFor(input),
753
+ );
754
+ return { ...compiled, release: () => {} };
755
+ } catch (error) {
756
+ await broker.revoke(turnToken);
757
+ activeToken = undefined;
758
+ throw error;
759
+ }
760
+ };
761
+ const browserTurn = cancellableBrowserTurn(trackBrowserOwner(finalizeCheckpoint(worker.run({
762
+ traceId,
763
+ modelId: parsed.modelId,
764
+ reasoning: parsed.options.reasoning,
765
+ capabilities: turnCapabilities,
766
+ prepare: () => prepareWith(checkpointInput.parsed),
767
+ ...(resumeInput ? { prepareResume: () => prepareWith(resumeInput) } : {}),
768
+ ...(retainConversation ? { retainConversation: true, conversationKey } : {}),
769
+ abortSignal: browserAbort.signal,
770
+ ...(parsed._compactionRequest ? { compaction: true } : {}),
771
+ ...submissionLifecycle,
772
+ onReasoningSummary: (text, continuation) => trace.push({ kind: "reasoning", text, ...(continuation ? { continuation: true } : {}) }),
773
+ onCommentary: (text, continuation) => trace.push({ kind: "commentary", text, ...(continuation ? { continuation: true } : {}) }),
774
+ onTextDelta: delta => text.push(delta),
775
+ externalProgress,
776
+ completionFence: {
777
+ begin: async () => broker.beginCompletionFence(await token.promise),
778
+ commit: async revision => broker.commitCompletionFence(await token.promise, revision),
779
+ },
780
+ ...(captureLunaCheckpoint ? {
781
+ captureLunaCheckpoint: true,
782
+ onLunaCheckpoint: captureCheckpoint,
783
+ } : {}),
784
+ }))), browserAbort);
785
+ void browserTurn.browser.catch(error => {
786
+ if (!tokenSettled) {
787
+ tokenSettled = true;
788
+ token.reject(error instanceof Error ? error : new Error(String(error)));
789
+ }
790
+ });
791
+ return {
792
+ mode: "tools",
793
+ token: token.promise,
794
+ externalProgress,
795
+ browser: browserTurn.browser,
796
+ physicalSettlement: browserTurn.physicalSettlement,
797
+ trace,
798
+ text,
799
+ usageInput: checkpointInput.parsed,
800
+ ...(conversationKey ? { conversationKey } : {}),
801
+ ...(releaseRetainedConversation ? { releaseRetainedConversation } : {}),
802
+ retireCapability: async () => {
803
+ if (activeToken) await broker.revoke(activeToken);
804
+ },
805
+ submission,
806
+ cancel: (reason?: Error) => {
807
+ browserTurn.cancel(reason);
808
+ if (activeToken) {
809
+ void Promise.resolve(broker.revoke(activeToken, reason)).catch(error => {
810
+ console.error(`[chatgpt-web] failed to revoke cancelled turn token: ${error instanceof Error ? error.message : String(error)}`);
811
+ });
812
+ }
813
+ },
814
+ };
815
+ };
816
+
817
+ return {
818
+ name: "chatgpt-web",
819
+ async runTurn(parsed, incoming, emit) {
820
+ const runChatGptWebTurn = async (): Promise<void> => {
821
+ const manualRequest = isChatGptWebZeroRiskBackendModel(parsed.modelId);
822
+ if (manualRequest !== manualInteraction) {
823
+ emit({
824
+ type: "error",
825
+ message: manualInteraction
826
+ ? "ChatGPT Zero Risk requires the Zero Risk Web model route."
827
+ : "The Zero Risk Web model route is unavailable while automatic browser interaction is enabled.",
828
+ status: 409,
829
+ errorType: "invalid_request_error",
830
+ code: "browser_interaction_mode_mismatch",
831
+ retryable: false,
832
+ });
833
+ return;
834
+ }
835
+ const turnCapabilities = parsed._compactionRequest && !manualRequest
836
+ ? { ...configuredCapabilities, localToolsEnabled: false }
837
+ : configuredCapabilities;
838
+ const mode = manualRequest
839
+ ? { localTools: true }
840
+ : resolveChatGptWebModelMode(parsed.modelId, parsed.options.reasoning, turnCapabilities);
841
+ const structuredOutputValidator = parsed._compactionRequest
842
+ ? undefined
843
+ : createChatGptStructuredOutputValidator(parsed.options.outputFormat);
844
+ const bufferStructuredOutput = structuredOutputValidator !== undefined;
845
+ const retryKey = `${executionNamespace}:${chatGptTurnRetryKey(parsed)}`;
846
+ const exhaustedRetry = chatGptWebTurnRetryPolicy.exhaustedError(retryKey);
847
+ if (exhaustedRetry) {
848
+ emit({
849
+ type: "error",
850
+ message: exhaustedRetry.message,
851
+ status: exhaustedRetry.status,
852
+ errorType: exhaustedRetry.errorType,
853
+ code: exhaustedRetry.code,
854
+ retryable: false,
855
+ });
856
+ return;
857
+ }
858
+ let environment: ReturnType<typeof extractChatGptTurnEnvironment> | undefined;
859
+ if (mode.localTools) {
860
+ try {
861
+ environment = environmentStore.resolve(parsed);
862
+ } catch (error) {
863
+ const identity = extractChatGptTurnIdentity(parsed);
864
+ console.warn(
865
+ `[chatgpt-web] trusted environment unavailable (thread_id=${identity.threadId ? "present" : "missing"}, turn_id=${identity.turnId ? "present" : "missing"}, previous_response_id=${parsed.previousResponseId ?? "none"}, replay_prefix_items=${parsed._replayPrefixLen ?? 0}, context_messages=${parsed.context.messages.length})`,
866
+ );
867
+ throw error;
868
+ }
869
+ }
870
+ if (parsed._compactionRequest) {
871
+ const structuredCompactionRequired = parsed.modelId !== CHATGPT_WEB_LUNA_MODEL_ID
872
+ && configuredCapabilities.localToolsEnabled;
873
+ if (structuredCompactionRequired
874
+ && (!retainedLauncherDescriptor || (!manualRequest && !structuredBroker))) {
875
+ emit({
876
+ type: "error",
877
+ message: manualRequest
878
+ ? "Zero Risk could not resume the active ChatGPT conversation for context handoff. Retry the task from the Launcher."
879
+ : "ChatGPT could not resume the active conversation for context handoff. Retry the task.",
880
+ status: 409,
881
+ errorType: "invalid_request_error",
882
+ code: "compaction_control_unavailable",
883
+ retryable: false,
884
+ });
885
+ return;
886
+ }
887
+ if (structuredCompactionRequired) {
888
+ const compactionExecutionKey = `${executionNamespace}:${chatGptTurnExecutionKey(parsed)}`;
889
+ const compactedSourceExecutionKey = `${executionNamespace}:${chatGptCompactionSourceExecutionKey(parsed)}`;
890
+ const handoffTraceId = createHash("sha256")
891
+ .update(`${compactionExecutionKey}:handoff`)
892
+ .digest("hex")
893
+ .slice(0, 12);
894
+ const compactionTraceId = createHash("sha256")
895
+ .update(compactionExecutionKey)
896
+ .digest("hex")
897
+ .slice(0, 12);
898
+ const compactionNativeIdentity = extractChatGptTurnIdentity(parsed);
899
+ let sharedSummary = existingStructuredCompactionRun(compactionExecutionKey);
900
+ if (!sharedSummary) {
901
+ sharedSummary = runStructuredCompactionOnce(
902
+ compactionExecutionKey,
903
+ {
904
+ ownerKey: `${executionNamespace}:${chatGptThreadOwnershipKey(parsed)}`,
905
+ traceIds: [
906
+ compactionTraceId,
907
+ handoffTraceId,
908
+ `${handoffTraceId}_fallback`,
909
+ ],
910
+ ...(compactionNativeIdentity.threadId
911
+ ? { nativeThreadId: compactionNativeIdentity.threadId }
912
+ : {}),
913
+ ...(compactionNativeIdentity.turnId
914
+ ? { nativeTurnId: compactionNativeIdentity.turnId }
915
+ : {}),
916
+ },
917
+ async operatorSignal => {
918
+ const handoffTimeoutMs = Math.min(
919
+ timeoutMs ?? MAX_COMPACTION_HANDOFF_TIMEOUT_MS,
920
+ MAX_COMPACTION_HANDOFF_TIMEOUT_MS,
921
+ );
922
+ const handoffDeadline = new AbortController();
923
+ const handoffTimeoutError = new ChatGptWebAdapterError(
924
+ `ChatGPT compaction did not fully settle within ${handoffTimeoutMs}ms`,
925
+ {
926
+ status: 409,
927
+ errorType: "invalid_request_error",
928
+ code: "compaction_handoff_timeout",
929
+ retryable: false,
930
+ },
931
+ );
932
+ const handoffTimer = setTimeout(
933
+ () => handoffDeadline.abort(handoffTimeoutError),
934
+ handoffTimeoutMs,
935
+ );
936
+ handoffTimer.unref?.();
937
+ const operationSignal = AbortSignal.any([operatorSignal, handoffDeadline.signal]);
938
+ const sourceConversationKey = chatGptConversationKey(parsed, executionNamespace);
939
+ const runFreshCompactionFallback = async (reason: string): Promise<string> => {
940
+ console.warn(`[chatgpt-web] retained compaction fallback=${reason}`);
941
+ const fallbackRuntime = startRuntime(
942
+ parsed,
943
+ manualRequest ? environment : undefined,
944
+ `${handoffTraceId}_fallback`,
945
+ turnCapabilities,
946
+ );
947
+ try {
948
+ const rawSummary = await withAbort(fallbackRuntime.browser, operationSignal);
949
+ await withAbort(fallbackRuntime.physicalSettlement, operationSignal);
950
+ return canonicalizeCompactionHandoff(parsed, rawSummary);
951
+ } catch (error) {
952
+ fallbackRuntime.cancel(error instanceof Error ? error : new Error(String(error)));
953
+ await withAbort(
954
+ fallbackRuntime.physicalSettlement,
955
+ // Operator cancellation must still honor the physical fallback owner.
956
+ // The handoff deadline is independent, so cancel-all cannot acknowledge
957
+ // before the Launcher/worker cleanup handshake has completed.
958
+ handoffDeadline.signal,
959
+ ).catch(() => {});
960
+ throw error;
961
+ }
962
+ };
963
+ let source: ChatGptTurnSession | undefined;
964
+ let preserveFinalResponse = false;
965
+ try {
966
+ // The previous compaction may already have detached the retained head while
967
+ // its browser/helper is still unwinding. Do not inspect that old epoch or
968
+ // decide to open a fresh fallback until physical release has completed.
969
+ if (sourceConversationKey) {
970
+ await chatGptTurnSessions.waitForConversationRetirement(
971
+ sourceConversationKey,
972
+ operationSignal,
973
+ );
974
+ }
975
+ source = sourceConversationKey
976
+ ? chatGptTurnSessions.findConversationHead(sourceConversationKey)
977
+ : undefined;
978
+ preserveFinalResponse = !source?.isActive()
979
+ && source?.settledOutcome()?.type === "final";
980
+ const retainedKey = source?.conversationKey();
981
+ if (!source || !retainedKey) {
982
+ return await runFreshCompactionFallback("source_unavailable_before_handoff");
983
+ }
984
+ let rawSummary: string;
985
+ if (manualRequest && source.isActive() && source.runtime.mode === "tools") {
986
+ const zeroRiskSummary = await settleActiveZeroRiskCompactionSource(
987
+ parsed,
988
+ source,
989
+ broker,
990
+ operationSignal,
991
+ );
992
+ if (zeroRiskSummary === undefined) {
993
+ preserveFinalResponse = true;
994
+ rawSummary = await runFreshCompactionFallback("zero_risk_source_had_no_compaction_boundary");
995
+ } else {
996
+ rawSummary = zeroRiskSummary;
997
+ }
998
+ } else if (manualRequest) {
999
+ if (source.isActive()) {
1000
+ const outcome = await withAbort(source.browserOutcome, operationSignal);
1001
+ if (outcome.type === "error") throw outcome.error;
1002
+ await withAbort(source.physicalSettlement, operationSignal);
1003
+ preserveFinalResponse = true;
1004
+ }
1005
+ rawSummary = await runFreshCompactionFallback("zero_risk_source_already_completed");
1006
+ } else if (source.isActive() && source.runtime.mode === "tools") {
1007
+ const settlement = await settleActiveCompactionSource(
1008
+ parsed,
1009
+ source,
1010
+ structuredBroker!,
1011
+ operationSignal,
1012
+ );
1013
+ preserveFinalResponse = !settlement.compactionInstructionDelivered;
1014
+ rawSummary = await requestRetainedCompactionHandoff(
1015
+ worker,
1016
+ parsed,
1017
+ source,
1018
+ structuredBroker!,
1019
+ configuredCapabilities,
1020
+ handoffTraceId,
1021
+ operationSignal,
1022
+ handoffTimeoutMs,
1023
+ );
1024
+ } else {
1025
+ if (source.isActive()) {
1026
+ const outcome = await withAbort(source.browserOutcome, operationSignal);
1027
+ if (outcome.type === "error") throw outcome.error;
1028
+ await withAbort(source.physicalSettlement, operationSignal);
1029
+ preserveFinalResponse = true;
1030
+ }
1031
+ rawSummary = await requestRetainedCompactionHandoff(
1032
+ worker,
1033
+ parsed,
1034
+ source,
1035
+ structuredBroker!,
1036
+ configuredCapabilities,
1037
+ handoffTraceId,
1038
+ operationSignal,
1039
+ handoffTimeoutMs,
1040
+ );
1041
+ }
1042
+ const summary = canonicalizeCompactionHandoff(parsed, rawSummary);
1043
+ await withAbort(
1044
+ preserveFinalResponse
1045
+ ? chatGptTurnSessions.retireConversationPreservingFinalResponse(
1046
+ retainedKey,
1047
+ source,
1048
+ compactedSourceExecutionKey,
1049
+ )
1050
+ : chatGptTurnSessions.retireConversationAndWait(retainedKey),
1051
+ operationSignal,
1052
+ );
1053
+ return summary;
1054
+ } catch (error) {
1055
+ const retainedKey = source?.conversationKey();
1056
+ if (!retainedKey) throw error;
1057
+ let handoffError = error instanceof Error ? error : new Error(String(error));
1058
+ try {
1059
+ // Operator cancellation ends the logical compaction, but cancel-all must not
1060
+ // acknowledge until the retained browser/helper owner has physically retired.
1061
+ await (preserveFinalResponse
1062
+ ? chatGptTurnSessions.retireConversationPreservingFinalResponse(
1063
+ retainedKey,
1064
+ source!,
1065
+ compactedSourceExecutionKey,
1066
+ )
1067
+ : chatGptTurnSessions.retireConversationAndWait(retainedKey));
1068
+ } catch (retirementError) {
1069
+ handoffError = new AggregateError(
1070
+ [handoffError, retirementError instanceof Error ? retirementError : new Error(String(retirementError))],
1071
+ "Structured compaction failed and its retained conversation could not be retired",
1072
+ );
1073
+ }
1074
+ if (handoffError instanceof ChatGptWebAdapterError
1075
+ && handoffError.code === "compaction_source_unavailable") {
1076
+ return await runFreshCompactionFallback("source_disappeared_before_handoff");
1077
+ }
1078
+ throw handoffError;
1079
+ } finally {
1080
+ clearTimeout(handoffTimer);
1081
+ }
1082
+ },
1083
+ );
1084
+ }
1085
+ emit({ type: "heartbeat" });
1086
+ let summary: string;
1087
+ try {
1088
+ summary = await withAbort(sharedSummary, incoming.abortSignal);
1089
+ } catch (error) {
1090
+ if (incoming.abortSignal?.aborted
1091
+ && error instanceof DOMException
1092
+ && error.name === "AbortError") {
1093
+ // The observer detached; the shared exact compaction round continues and remains
1094
+ // available to a canonical reconnect without a second browser submission.
1095
+ throw error;
1096
+ }
1097
+ const handoffError = error instanceof Error ? error : new Error(String(error));
1098
+ console.error("[chatgpt-web] structured context handoff failed:", handoffError);
1099
+ emit({
1100
+ type: "error",
1101
+ message: "ChatGPT did not complete the context handoff. Retry the task.",
1102
+ status: 409,
1103
+ errorType: "invalid_request_error",
1104
+ code: "compaction_handoff_failed",
1105
+ retryable: false,
1106
+ });
1107
+ return;
1108
+ }
1109
+ emit({ type: "text_delta", text: summary, phase: "final_answer" });
1110
+ emitBrowserCompletion(
1111
+ { type: "final", answer: summary },
1112
+ estimateChatGptWebUsage(parsed, { answer: summary, reasoning: [] }, turnCapabilities),
1113
+ emit,
1114
+ );
1115
+ chatGptWebTurnRetryPolicy.clear(retryKey);
1116
+ return;
1117
+ }
1118
+ const responseExecutionKey = `${executionNamespace}:${chatGptCompactionSourceExecutionKey(parsed)}`;
1119
+ await chatGptTurnSessions.retireAndWait(responseExecutionKey, incoming.abortSignal);
1120
+ }
1121
+ const executionKey = `${executionNamespace}:${chatGptTurnExecutionKey(parsed)}`;
1122
+ const ownerKey = `${executionNamespace}:${chatGptThreadOwnershipKey(parsed)}`;
1123
+ const nativeIdentity = extractChatGptTurnIdentity(parsed);
1124
+ const nativeTurnId = nativeIdentity.turnId;
1125
+ if (!nativeTurnId) throw new Error("ChatGPT web requires native Codex turn_id metadata for browser ownership");
1126
+ const abortedTurnIds = manualRequest ? new Set(priorChatGptAbortedTurnIds(parsed)) : undefined;
1127
+ if (abortedTurnIds?.size) {
1128
+ chatGptTurnSessions.retireAbortedOwnerTurns(ownerKey, abortedTurnIds, executionKey);
1129
+ }
1130
+ const traceId = createHash("sha256").update(executionKey).digest("hex").slice(0, 12);
1131
+ const session = await chatGptTurnSessions.getOrCreateAfterOwnerRetirement(
1132
+ executionKey,
1133
+ ownerKey,
1134
+ () => startRuntime(parsed, environment, traceId, turnCapabilities),
1135
+ traceId,
1136
+ incoming.abortSignal,
1137
+ nativeTurnId,
1138
+ nativeIdentity.threadId,
1139
+ );
1140
+ const roundKey = chatGptTurnRoundKey(parsed);
1141
+ const emitRoundEvents = (events: readonly AdapterEvent[]): void => {
1142
+ // Journal the complete synchronous event batch before touching the HTTP observer. If the
1143
+ // observer disconnects midway through emission, an exact reconnect can replay the entire
1144
+ // canonical batch instead of losing the already-drained tail.
1145
+ session.appendRoundEvents(roundKey, events);
1146
+ for (const event of events) emit(event);
1147
+ };
1148
+ const emitRoundBatch = (
1149
+ produce: (buffer: (event: AdapterEvent) => void) => void,
1150
+ ): void => {
1151
+ const events: AdapterEvent[] = [];
1152
+ produce(event => events.push(event));
1153
+ emitRoundEvents(events);
1154
+ };
1155
+ const emitRoundEvent = (event: AdapterEvent): void => emitRoundEvents([event]);
1156
+ try {
1157
+ await session.runExclusive(async () => {
1158
+ const replay = session.roundEvents(roundKey);
1159
+ replayEvents(replay, emit);
1160
+ if (session.roundCompleted(roundKey)) {
1161
+ const failure = session.roundFailure(roundKey);
1162
+ if (failure) throw failure;
1163
+ return;
1164
+ }
1165
+ if (session.roundHasTerminalEvent(roundKey)) {
1166
+ session.completeRound(roundKey);
1167
+ return;
1168
+ }
1169
+ const settled = session.settledOutcome();
1170
+ if (settled) {
1171
+ if (settled.type === "error") throw settled.error;
1172
+ const trace = session.runtime.trace.drain();
1173
+ const completedTextDeltas = session.runtime.text.drain();
1174
+ const finalReplay = replay.length === 0
1175
+ && trace.length === 0
1176
+ && completedTextDeltas.length === 0
1177
+ ? session.eventsForFinalReplay()
1178
+ : [];
1179
+ if (finalReplay.length > 0) {
1180
+ session.appendRoundReasoning(roundKey, session.reasoningForFinalReplay());
1181
+ emitRoundEvents(finalReplay);
1182
+ } else {
1183
+ const userPrompt = extractLatestUserPrompt(parsed);
1184
+ const streamParser = new ChatGptToolStreamParser(userPrompt);
1185
+ const collectedToolCalls: ParsedToolCall[] = [];
1186
+ const parsedDeltas: string[] = [];
1187
+ const parsedThinking: string[] = [];
1188
+ for (const delta of completedTextDeltas) {
1189
+ const chunk = streamParser.feed(delta);
1190
+ if (chunk.thinking) parsedThinking.push(chunk.thinking);
1191
+ if (chunk.text) parsedDeltas.push(chunk.text);
1192
+ if (chunk.toolCalls.length > 0) collectedToolCalls.push(...chunk.toolCalls);
1193
+ }
1194
+ const flushed = streamParser.flush();
1195
+ if (flushed.thinking) parsedThinking.push(flushed.thinking);
1196
+ if (flushed.text) parsedDeltas.push(flushed.text);
1197
+ if (flushed.toolCalls.length > 0) collectedToolCalls.push(...flushed.toolCalls);
1198
+
1199
+ const traceTexts = trace.map(event => event.text);
1200
+ session.appendRoundReasoning(roundKey, [...traceTexts, ...parsedThinking]);
1201
+ if (replay.length === 0 && !parsed._compactionRequest) {
1202
+ emitRoundBatch(buffer => emitReadOnlyContextWarning(parsed, turnCapabilities, buffer));
1203
+ }
1204
+ emitRoundBatch(buffer => emitTraceEvents(trace, buffer));
1205
+ for (const thinking of parsedThinking) {
1206
+ emitRoundBatch(buffer => buffer({ type: "thinking_delta", thinking }));
1207
+ }
1208
+
1209
+ if (turnCapabilities.localToolsEnabled && collectedToolCalls.length > 0) {
1210
+ console.info(`[chatgpt-web] collectedToolCalls:`, JSON.stringify(collectedToolCalls));
1211
+ const requests: BrokerToolRequest[] = collectedToolCalls.map(tc => ({
1212
+ callId: tc.id,
1213
+ wireName: tc.name,
1214
+ freeform: false,
1215
+ arguments: tc.arguments,
1216
+ }));
1217
+ session.setOutstanding(requests, session.roundReasoning(roundKey), session.roundEvents(roundKey));
1218
+ emitRoundBatch(buffer => emitToolBatch(
1219
+ requests,
1220
+ estimateChatGptWebUsage(currentUsageInput(parsed), { reasoning: session.roundReasoning(roundKey), toolRequests: requests }, turnCapabilities),
1221
+ buffer,
1222
+ ));
1223
+ session.completeRound(roundKey);
1224
+ chatGptWebTurnRetryPolicy.clear(retryKey);
1225
+ return;
1226
+ }
1227
+
1228
+ if (!bufferStructuredOutput) {
1229
+ emitRoundBatch(buffer => emitTextDeltas(parsedDeltas, buffer));
1230
+ }
1231
+ }
1232
+ if (session.runtime.text.value() !== settled.answer) {
1233
+ throw new Error("ChatGPT browser Markdown stream did not reproduce the completed answer");
1234
+ }
1235
+ structuredOutputValidator?.(settled.answer);
1236
+ if (bufferStructuredOutput) {
1237
+ emitRoundBatch(buffer => emitTextDeltas([settled.answer], buffer));
1238
+ }
1239
+ const reasoning = session.roundReasoning(roundKey);
1240
+ session.setFinalReasoning(reasoning);
1241
+ session.setFinalEvents(session.roundEvents(roundKey));
1242
+ emitRoundBatch(buffer => emitBrowserCompletion(
1243
+ settled,
1244
+ estimateChatGptWebUsage(currentUsageInput(parsed), { answer: settled.answer, reasoning }, turnCapabilities),
1245
+ buffer,
1246
+ ));
1247
+ session.completeRound(roundKey);
1248
+ chatGptWebTurnRetryPolicy.clear(retryKey);
1249
+ return;
1250
+ }
1251
+
1252
+ let turnToken: string | undefined;
1253
+ if (session.runtime.mode === "tools") {
1254
+ turnToken = await withAbort(session.runtime.token, incoming.abortSignal);
1255
+ if (!environment) throw new Error("Tool-capable ChatGPT web runtime lost its trusted environment");
1256
+ await broker.updateEnvironment(turnToken, environment);
1257
+
1258
+ const outstanding = session.outstanding();
1259
+ if (outstanding.length > 0) {
1260
+ const results = currentToolResults(parsed, session);
1261
+ if (results.length === 0) {
1262
+ const reasoning = session.reasoningForOutstandingReplay();
1263
+ if (replay.length === 0) emitRoundEvents(session.eventsForOutstandingReplay());
1264
+ emitRoundBatch(buffer => emitToolBatch(
1265
+ outstanding,
1266
+ estimateChatGptWebUsage(currentUsageInput(parsed), { reasoning, toolRequests: outstanding }, turnCapabilities),
1267
+ buffer,
1268
+ ));
1269
+ session.completeRound(roundKey);
1270
+ return;
1271
+ }
1272
+ if (results.length !== outstanding.length) {
1273
+ throw new Error(`Codex returned ${results.length} of ${outstanding.length} results for a parallel ChatGPT tool batch`);
1274
+ }
1275
+ for (const message of results) {
1276
+ await broker.completeTool(turnToken, message.toolCallId, brokerResult(message));
1277
+ session.runtime.externalProgress.recordToolResult();
1278
+ session.markResultDelivered(message.toolCallId);
1279
+ }
1280
+ }
1281
+ } else if (session.outstanding().length > 0) {
1282
+ const outstanding = session.outstanding();
1283
+ const reasoning = session.reasoningForOutstandingReplay();
1284
+ if (replay.length === 0) emitRoundEvents(session.eventsForOutstandingReplay());
1285
+ emitRoundBatch(buffer => emitToolBatch(
1286
+ outstanding,
1287
+ estimateChatGptWebUsage(currentUsageInput(parsed), { reasoning, toolRequests: outstanding }, turnCapabilities),
1288
+ buffer,
1289
+ ));
1290
+ session.completeRound(roundKey);
1291
+ return;
1292
+ }
1293
+
1294
+ const toolWaitAbort = new AbortController();
1295
+ try {
1296
+ const roundReasoning = session.roundReasoning(roundKey);
1297
+ const userPrompt = extractLatestUserPrompt(parsed);
1298
+ const streamParser = new ChatGptToolStreamParser(userPrompt);
1299
+ const collectedToolCalls: ParsedToolCall[] = [];
1300
+
1301
+ const emitNewTrace = (trace: ChatGptTraceEvent[]) => {
1302
+ roundReasoning.push(...trace.map(event => event.text));
1303
+ session.appendRoundReasoning(roundKey, trace.map(event => event.text));
1304
+ emitRoundBatch(buffer => emitTraceEvents(trace, buffer));
1305
+ };
1306
+ const emitNewText = (deltas: string[]) => {
1307
+ const textChunks: string[] = [];
1308
+ for (const delta of deltas) {
1309
+ const chunk = streamParser.feed(delta);
1310
+ if (chunk.thinking) {
1311
+ roundReasoning.push(chunk.thinking);
1312
+ session.appendRoundReasoning(roundKey, [chunk.thinking]);
1313
+ emitRoundBatch(buffer => buffer({ type: "thinking_delta", thinking: chunk.thinking }));
1314
+ }
1315
+ if (chunk.text) {
1316
+ textChunks.push(chunk.text);
1317
+ }
1318
+ if (turnCapabilities.localToolsEnabled && chunk.toolCalls.length > 0) {
1319
+ collectedToolCalls.push(...chunk.toolCalls);
1320
+ }
1321
+ }
1322
+ if (textChunks.length > 0 && !bufferStructuredOutput) {
1323
+ emitRoundBatch(buffer => emitTextDeltas(textChunks, buffer));
1324
+ }
1325
+ };
1326
+ if (replay.length === 0 && !parsed._compactionRequest) {
1327
+ emitRoundBatch(buffer => emitReadOnlyContextWarning(parsed, turnCapabilities, buffer));
1328
+ }
1329
+ emitNewTrace(session.runtime.trace.drain());
1330
+ emitNewText(session.runtime.text.drain());
1331
+ const externalProgress = session.runtime.mode === "tools"
1332
+ ? session.runtime.externalProgress
1333
+ : undefined;
1334
+ const armNextTools = () => turnToken
1335
+ ? broker.nextToolBatch(turnToken, toolWaitAbort.signal).then(async requests => {
1336
+ if (!externalProgress) {
1337
+ throw new Error("ChatGPT broker returned tools for a read-only browser turn");
1338
+ }
1339
+ if (requests.length > 0) {
1340
+ const revision = externalProgress.recordToolBatch(requests.length);
1341
+ if (!session.runtime.manualControl) {
1342
+ // The browser outcome is in the same race below and owns the semantic DOM and
1343
+ // renderer deadlines. A second fixed timer here can retire an accepted turn
1344
+ // while its same-tab observer is still recovering. Keep the causal barrier —
1345
+ // tools are not emitted until the browser captures their text boundary — but
1346
+ // let browser settlement or request cancellation end the wait.
1347
+ await externalProgress.waitForToolBatchObservation(
1348
+ revision,
1349
+ toolWaitAbort.signal,
1350
+ );
1351
+ }
1352
+ externalProgress.assertToolBatchActive(revision);
1353
+ }
1354
+ return { type: "tools" as const, requests };
1355
+ }).catch(error => toolWaitAbort.signal.aborted
1356
+ ? new Promise<never>(() => {})
1357
+ : Promise.reject(error))
1358
+ : undefined;
1359
+ let nextTools = armNextTools();
1360
+ const browserOutcome = session.browserOutcome.then(outcome => ({ type: "browser" as const, outcome }));
1361
+ const finishBrowserOutcome = async (completedOutcome: ChatGptBrowserOutcome): Promise<void> => {
1362
+ // Zero Risk completion and its owner-only empty-batch signal are resolved by the
1363
+ // same broker transition. Drain once more so the accepted final answer cannot be
1364
+ // overtaken by the terminal owner notification.
1365
+ emitNewTrace(session.runtime.trace.drain());
1366
+ emitNewText(session.runtime.text.drain());
1367
+ const flushed = streamParser.flush();
1368
+ if (flushed.thinking) {
1369
+ roundReasoning.push(flushed.thinking);
1370
+ session.appendRoundReasoning(roundKey, [flushed.thinking]);
1371
+ emitRoundBatch(buffer => buffer({ type: "thinking_delta", thinking: flushed.thinking }));
1372
+ }
1373
+ if (flushed.text && !bufferStructuredOutput) {
1374
+ emitRoundBatch(buffer => emitTextDeltas([flushed.text], buffer));
1375
+ }
1376
+ if (flushed.toolCalls.length > 0) {
1377
+ collectedToolCalls.push(...flushed.toolCalls);
1378
+ }
1379
+
1380
+ if (turnToken) await broker.revoke(turnToken);
1381
+ if (completedOutcome.type === "error") throw completedOutcome.error;
1382
+ if (session.runtime.text.value() !== completedOutcome.answer) {
1383
+ throw new Error("ChatGPT browser Markdown stream did not reproduce the completed answer");
1384
+ }
1385
+
1386
+ if (collectedToolCalls.length > 0) {
1387
+ console.info(`[chatgpt-web] collectedToolCalls:`, JSON.stringify(collectedToolCalls));
1388
+ const requests: BrokerToolRequest[] = collectedToolCalls.map(tc => ({
1389
+ callId: tc.id,
1390
+ wireName: tc.name,
1391
+ freeform: false,
1392
+ arguments: tc.arguments,
1393
+ }));
1394
+ session.setOutstanding(requests, roundReasoning, session.roundEvents(roundKey));
1395
+ emitRoundBatch(buffer => emitToolBatch(
1396
+ requests,
1397
+ estimateChatGptWebUsage(currentUsageInput(parsed), { reasoning: roundReasoning, toolRequests: requests }, turnCapabilities),
1398
+ buffer,
1399
+ ));
1400
+ session.completeRound(roundKey);
1401
+ chatGptWebTurnRetryPolicy.clear(retryKey);
1402
+ return;
1403
+ }
1404
+
1405
+ session.setFinalReasoning(roundReasoning);
1406
+ session.setFinalEvents(session.roundEvents(roundKey));
1407
+ structuredOutputValidator?.(completedOutcome.answer);
1408
+ if (bufferStructuredOutput) {
1409
+ emitRoundBatch(buffer => emitTextDeltas([completedOutcome.answer], buffer));
1410
+ }
1411
+ emitRoundBatch(buffer => emitBrowserCompletion(
1412
+ completedOutcome,
1413
+ estimateChatGptWebUsage(currentUsageInput(parsed), { answer: completedOutcome.answer, reasoning: roundReasoning }, turnCapabilities),
1414
+ buffer,
1415
+ ));
1416
+ session.completeRound(roundKey);
1417
+ chatGptWebTurnRetryPolicy.clear(retryKey);
1418
+ };
1419
+ const waitForTrace = () => session.runtime.trace.wait(toolWaitAbort.signal)
1420
+ .then(() => ({ type: "trace" as const }))
1421
+ .catch(error => toolWaitAbort.signal.aborted
1422
+ ? new Promise<never>(() => {})
1423
+ : Promise.reject(error));
1424
+ const waitForText = () => session.runtime.text.wait(toolWaitAbort.signal)
1425
+ .then(() => ({ type: "text" as const }))
1426
+ .catch(error => toolWaitAbort.signal.aborted
1427
+ ? new Promise<never>(() => {})
1428
+ : Promise.reject(error));
1429
+ let nextTrace = waitForTrace();
1430
+ let nextText = waitForText();
1431
+ for (;;) {
1432
+ const next = await withAbort(
1433
+ Promise.race([
1434
+ ...(nextTools ? [nextTools] : []),
1435
+ browserOutcome,
1436
+ nextTrace,
1437
+ nextText,
1438
+ ]),
1439
+ incoming.abortSignal,
1440
+ );
1441
+ if (next.type === "trace") {
1442
+ emitNewTrace(session.runtime.trace.drain());
1443
+ nextTrace = waitForTrace();
1444
+ continue;
1445
+ }
1446
+ if (next.type === "text") {
1447
+ emitNewText(session.runtime.text.drain());
1448
+ nextText = waitForText();
1449
+ continue;
1450
+ }
1451
+ emitNewTrace(session.runtime.trace.drain());
1452
+ emitNewText(session.runtime.text.drain());
1453
+ if (next.type === "browser") {
1454
+ await finishBrowserOutcome(next.outcome);
1455
+ return;
1456
+ }
1457
+ if (!turnToken || session.runtime.mode !== "tools" || !externalProgress) {
1458
+ throw new Error("Read-only ChatGPT Web runtime received a broker tool batch");
1459
+ }
1460
+ if (next.requests.length === 0) {
1461
+ if (!session.runtime.manualControl) {
1462
+ throw new Error("ChatGPT tool bridge returned an empty batch");
1463
+ }
1464
+ await finishBrowserOutcome(await session.browserOutcome);
1465
+ return;
1466
+ }
1467
+ validateBatchTools(parsed, next.requests);
1468
+ session.setOutstanding(next.requests, roundReasoning, session.roundEvents(roundKey));
1469
+ emitRoundBatch(buffer => emitToolBatch(
1470
+ next.requests,
1471
+ estimateChatGptWebUsage(currentUsageInput(parsed), { reasoning: roundReasoning, toolRequests: next.requests }, turnCapabilities),
1472
+ buffer,
1473
+ ));
1474
+ session.completeRound(roundKey);
1475
+ return;
1476
+ }
1477
+ } finally {
1478
+ toolWaitAbort.abort();
1479
+ }
1480
+ });
1481
+ } catch (error) {
1482
+ if (incoming.abortSignal?.aborted && error instanceof DOMException && error.name === "AbortError") {
1483
+ if (session.runtime.manualControl) {
1484
+ // Zero Risk is user-driven and has no DOM observer that can distinguish continued
1485
+ // work from a stopped native turn. A closed Responses stream is therefore terminal:
1486
+ // revoke the MCP capability and release the Launcher tab instead of leaving a task
1487
+ // that Codex already shows as stopped waiting forever.
1488
+ chatGptTurnSessions.retire(executionKey, session);
1489
+ }
1490
+ // Automatic browser turns keep their exact execution and journal for reconnect. Their
1491
+ // owned DOM observer can continue proving the same accepted ChatGPT submission.
1492
+ throw error;
1493
+ }
1494
+ const turnError = submittedTurnFailure(session, error);
1495
+ const handledError = turnError instanceof ChatGptWebAdapterError && turnError.retryable
1496
+ ? chatGptWebTurnRetryPolicy.recordRetryableFailure(retryKey, turnError)
1497
+ : turnError;
1498
+ if (!(turnError instanceof ChatGptWebAdapterError && turnError.retryable)) {
1499
+ chatGptWebTurnRetryPolicy.clear(retryKey);
1500
+ }
1501
+ if (handledError instanceof ChatGptWebAdapterError && !handledError.retryable) {
1502
+ // A deterministic request failure remains replayable so a native reconnect cannot burn
1503
+ // another browser attempt. Every other failure retires the browser session: client
1504
+ // disconnects, stage failures, and retryable ChatGPT errors must start a fresh surface
1505
+ // instead of replaying one rejected browser outcome for the registry's full TTL.
1506
+ session.cancel();
1507
+ } else {
1508
+ chatGptTurnSessions.retire(executionKey, session);
1509
+ }
1510
+ if (session.runtime.mode === "tools") {
1511
+ void session.runtime.token.then(turnToken => broker.revoke(turnToken)).catch(() => {});
1512
+ }
1513
+ if (handledError instanceof ChatGptWebAdapterError) {
1514
+ emitRoundEvent({
1515
+ type: "error",
1516
+ message: handledError.message,
1517
+ status: handledError.status,
1518
+ errorType: handledError.errorType,
1519
+ code: handledError.code,
1520
+ retryable: handledError.retryable,
1521
+ });
1522
+ session.completeRound(roundKey);
1523
+ return;
1524
+ }
1525
+ session.failRound(roundKey, turnError);
1526
+ chatGptWebTurnRetryPolicy.clear(retryKey);
1527
+ throw turnError;
1528
+ }
1529
+ };
1530
+
1531
+ // Arm this before any awaited work, including environment lookup and owner retirement.
1532
+ const heartbeat = setInterval(
1533
+ () => emit({ type: "heartbeat" }),
1534
+ CHATGPT_WEB_ADAPTER_HEARTBEAT_MS,
1535
+ );
1536
+ try {
1537
+ emit({ type: "heartbeat" });
1538
+ await runChatGptWebTurn();
1539
+ } finally {
1540
+ clearInterval(heartbeat);
1541
+ }
1542
+ },
1543
+ };
1544
+ }