@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,695 @@
1
+ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { basename, dirname, join } from "node:path";
4
+ import { createInterface } from "node:readline";
5
+ import { notifyLauncherTurn, readLauncherBrowserHostDescriptor } from "../../launcher-browser-host";
6
+ import { ChatGptWebAdapterError } from "./adapter-error";
7
+ import type { CompiledChatGptWebPrompt } from "./prompt";
8
+ import type { BrowserTurn, ResolvedBrowserConfig } from "./browser-worker";
9
+ import {
10
+ parseChatGptLunaCheckpoint,
11
+ type ChatGptLunaCheckpoint,
12
+ } from "./rolling-checkpoint";
13
+
14
+ interface PendingTurn {
15
+ turn: BrowserTurn;
16
+ resolve: (value: string) => void;
17
+ reject: (error: Error) => void;
18
+ abortListener?: () => void;
19
+ sent?: boolean;
20
+ prepared?: CompiledChatGptWebPrompt & { release: () => void };
21
+ localFailure?: Error;
22
+ progressForwarding?: AbortController;
23
+ }
24
+
25
+ type HelperMessage =
26
+ | { type: "ready"; features?: string[] }
27
+ | { type: "event"; id: string; event: "heartbeat" | "send_activated" | "submitted" | "reasoning" | "commentary" | "text"; text?: string; continuation?: boolean }
28
+ | { type: "event"; id: string; event: "tool_batch_observed"; revision: number }
29
+ | { type: "event"; id: string; event: "completion_fence_begin"; requestId: number }
30
+ | { type: "event"; id: string; event: "completion_fence_commit"; requestId: number; revision: number }
31
+ | { type: "event"; id: string; event: "prepared_selected"; reused: boolean }
32
+ | { type: "event"; id: string; event: "luna_checkpoint"; checkpoint: ChatGptLunaCheckpoint; answerHash: string }
33
+ | { type: "result"; id: string; text: string }
34
+ | {
35
+ type: "error";
36
+ id: string;
37
+ name?: string;
38
+ message: string;
39
+ status?: number;
40
+ errorType?: string;
41
+ code?: string;
42
+ retryable?: boolean;
43
+ };
44
+
45
+ function parseHelperMessage(line: string): HelperMessage {
46
+ const value = JSON.parse(line) as unknown;
47
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
48
+ throw new Error("Launcher browser helper message is not an object");
49
+ }
50
+ const message = value as Record<string, unknown>;
51
+ if (message.type === "ready") {
52
+ const features = message.features;
53
+ if (features !== undefined
54
+ && (!Array.isArray(features) || features.some(feature => typeof feature !== "string"))) {
55
+ throw new Error("Launcher browser helper advertised invalid features");
56
+ }
57
+ return { type: "ready", ...(features ? { features: features as string[] } : {}) };
58
+ }
59
+ if (typeof message.id !== "string" || !message.id) {
60
+ throw new Error("Launcher browser helper message has no turn identity");
61
+ }
62
+ if (message.type === "event") {
63
+ const event = message.event;
64
+ if (event === "tool_batch_observed") {
65
+ if (!Number.isSafeInteger(message.revision) || (message.revision as number) <= 0) {
66
+ throw new Error("Launcher browser helper tool-boundary revision is invalid");
67
+ }
68
+ return { type: "event", id: message.id, event, revision: message.revision as number };
69
+ }
70
+ if (event === "completion_fence_begin") {
71
+ if (!Number.isSafeInteger(message.requestId) || (message.requestId as number) <= 0) {
72
+ throw new Error("Launcher browser helper completion fence request id is invalid");
73
+ }
74
+ return { type: "event", id: message.id, event, requestId: message.requestId as number };
75
+ }
76
+ if (event === "completion_fence_commit") {
77
+ if (!Number.isSafeInteger(message.requestId) || (message.requestId as number) <= 0
78
+ || !Number.isSafeInteger(message.revision) || (message.revision as number) < 0) {
79
+ throw new Error("Launcher browser helper completion fence revision is invalid");
80
+ }
81
+ return {
82
+ type: "event",
83
+ id: message.id,
84
+ event,
85
+ requestId: message.requestId as number,
86
+ revision: message.revision as number,
87
+ };
88
+ }
89
+ if (event === "luna_checkpoint") {
90
+ if (typeof message.answerHash !== "string" || !/^[a-f0-9]{64}$/.test(message.answerHash)) {
91
+ throw new Error("Launcher browser helper Luna checkpoint answer hash is invalid");
92
+ }
93
+ return {
94
+ type: "event",
95
+ id: message.id,
96
+ event,
97
+ checkpoint: parseChatGptLunaCheckpoint(message.checkpoint),
98
+ answerHash: message.answerHash,
99
+ };
100
+ }
101
+ const text = message.text;
102
+ const continuation = message.continuation;
103
+ if (event === "prepared_selected") {
104
+ if (typeof message.reused !== "boolean") {
105
+ throw new Error("Launcher browser helper prompt selection is invalid");
106
+ }
107
+ return { type: "event", id: message.id, event, reused: message.reused };
108
+ }
109
+ if (!["heartbeat", "send_activated", "submitted", "reasoning", "commentary", "text"].includes(String(event))) {
110
+ throw new Error("Launcher browser helper emitted an unknown event");
111
+ }
112
+ if (text !== undefined && typeof text !== "string") {
113
+ throw new Error("Launcher browser helper event text is invalid");
114
+ }
115
+ if (continuation !== undefined && typeof continuation !== "boolean") {
116
+ throw new Error("Launcher browser helper continuation flag is invalid");
117
+ }
118
+ return {
119
+ type: "event",
120
+ id: message.id,
121
+ event: event as "heartbeat" | "send_activated" | "submitted" | "reasoning" | "commentary" | "text",
122
+ ...(text !== undefined ? { text: text as string } : {}),
123
+ ...(continuation !== undefined ? { continuation: continuation as boolean } : {}),
124
+ };
125
+ }
126
+ if (message.type === "result") {
127
+ const text = message.text;
128
+ if (typeof text !== "string") {
129
+ throw new Error("Launcher browser helper result text is invalid");
130
+ }
131
+ return { type: "result", id: message.id, text };
132
+ }
133
+ if (message.type === "error") {
134
+ const errorMessage = message.message;
135
+ const errorName = message.name;
136
+ const status = message.status;
137
+ const errorType = message.errorType;
138
+ const code = message.code;
139
+ const retryable = message.retryable;
140
+ const structured = status !== undefined
141
+ || errorType !== undefined
142
+ || code !== undefined
143
+ || retryable !== undefined;
144
+ if (typeof errorMessage !== "string"
145
+ || (errorName !== undefined && typeof errorName !== "string")
146
+ || (structured && (
147
+ !Number.isInteger(status)
148
+ || (status as number) < 400
149
+ || (status as number) > 599
150
+ || typeof errorType !== "string"
151
+ || !errorType
152
+ || typeof code !== "string"
153
+ || !code
154
+ || typeof retryable !== "boolean"
155
+ ))) {
156
+ throw new Error("Launcher browser helper error payload is invalid");
157
+ }
158
+ return {
159
+ type: "error",
160
+ id: message.id,
161
+ message: errorMessage,
162
+ ...(errorName !== undefined ? { name: errorName as string } : {}),
163
+ ...(structured ? {
164
+ status: status as number,
165
+ errorType: errorType as string,
166
+ code: code as string,
167
+ retryable: retryable as boolean,
168
+ } : {}),
169
+ };
170
+ }
171
+ throw new Error("Launcher browser helper emitted an unknown message type");
172
+ }
173
+
174
+ export class LauncherBrowserHelperClient {
175
+ private child?: ChildProcessWithoutNullStreams;
176
+ private ready?: Promise<void>;
177
+ private readyResolve?: () => void;
178
+ private readyReject?: (error: Error) => void;
179
+ private readonly pending = new Map<string, PendingTurn>();
180
+ private helperFeatures = new Set<string>();
181
+
182
+ constructor(private readonly config: ResolvedBrowserConfig) {}
183
+
184
+ /**
185
+ * The helper that shipped with this daemon, when one sits beside its own entrypoint.
186
+ *
187
+ * The launcher advertises the helper inside its application bundle while the daemon runs from a
188
+ * versioned runtime directory, so the two sides update independently and can disagree about the
189
+ * protocol. Preferring the sibling keeps daemon and helper on the same build by construction;
190
+ * anything else — a source checkout, an unbundled entrypoint — falls back to the advertised path.
191
+ */
192
+ private bundledHelperScript(): string | undefined {
193
+ const entrypoint = process.argv[1];
194
+ // Only the packaged runtime layout is claimed: the bundle builder emits cli.js and
195
+ // browser-helper.cjs into one directory. Matching on that entrypoint name keeps a source
196
+ // checkout, or any other launch shape, on the launcher-advertised helper rather than adopting
197
+ // an unrelated sibling that merely shares a filename.
198
+ if (typeof entrypoint !== "string" || basename(entrypoint) !== "cli.js") return undefined;
199
+ const sibling = join(dirname(entrypoint), "browser-helper.cjs");
200
+ return existsSync(sibling) ? sibling : undefined;
201
+ }
202
+
203
+ async run(turn: BrowserTurn): Promise<string> {
204
+ if (turn.abortSignal?.aborted) throw new DOMException("ChatGPT web turn aborted", "AbortError");
205
+ await this.ensureChild();
206
+ if (turn.abortSignal?.aborted) throw new DOMException("ChatGPT web turn aborted", "AbortError");
207
+ if (turn.externalProgress && !this.helperFeatures.has("tool-boundary-ack")) {
208
+ throw new Error(
209
+ "Launcher browser helper does not support causal Codex tool-boundary acknowledgement; update or restart the launcher",
210
+ );
211
+ }
212
+ if (turn.externalProgress && !this.helperFeatures.has("completion-fence")) {
213
+ throw new Error(
214
+ "Launcher browser helper does not support the MCP completion fence; update or restart the launcher",
215
+ );
216
+ }
217
+ return await new Promise<string>((resolveResult, rejectResult) => {
218
+ if (this.pending.has(turn.traceId)) {
219
+ rejectResult(new Error(`Duplicate launcher browser turn: ${turn.traceId}`));
220
+ return;
221
+ }
222
+ const pending: PendingTurn = { turn, resolve: resolveResult, reject: rejectResult };
223
+ this.pending.set(turn.traceId, pending);
224
+ if (turn.abortSignal) {
225
+ const abortListener = () => {
226
+ if (!pending.sent) {
227
+ this.finishWithError(
228
+ turn.traceId,
229
+ new DOMException("ChatGPT web turn aborted", "AbortError"),
230
+ );
231
+ return;
232
+ }
233
+ void this.send({ type: "abort", id: turn.traceId }).catch(error => {
234
+ this.finishWithError(
235
+ turn.traceId,
236
+ error instanceof Error ? error : new Error(String(error)),
237
+ );
238
+ });
239
+ };
240
+ pending.abortListener = abortListener;
241
+ turn.abortSignal.addEventListener("abort", abortListener, { once: true });
242
+ if (turn.abortSignal.aborted) {
243
+ abortListener();
244
+ return;
245
+ }
246
+ }
247
+ // Setting this before the synchronous write call makes an abort either prevent dispatch or
248
+ // queue an `abort` after the `run` frame; it can never overtake the run frame in the pipe.
249
+ pending.sent = true;
250
+ const progressForwarding = new AbortController();
251
+ pending.progressForwarding = progressForwarding;
252
+ void this.send({
253
+ type: "run",
254
+ id: turn.traceId,
255
+ config: {
256
+ appName: this.config.appName,
257
+ browserHostDescriptorPath: this.config.browserHostDescriptorPath!,
258
+ browserDiagnosticsPath: this.config.browserDiagnosticsPath,
259
+ turnTimeoutMs: this.config.turnTimeoutMs,
260
+ autoApproveToolCalls: this.config.autoApproveToolCalls,
261
+ },
262
+ turn: {
263
+ traceId: turn.traceId,
264
+ modelId: turn.modelId,
265
+ reasoning: turn.reasoning,
266
+ capabilities: turn.capabilities,
267
+ ...(turn.nativeConnector ? { nativeConnector: true } : {}),
268
+ ...(turn.prepareResume ? { resumeAvailable: true } : {}),
269
+ ...(turn.retainConversation ? { retainConversation: true } : {}),
270
+ ...(turn.requireRetainedConversation ? { requireRetainedConversation: true } : {}),
271
+ ...(turn.conversationKey ? { conversationKey: turn.conversationKey } : {}),
272
+ ...(turn.compaction ? { compaction: true } : {}),
273
+ ...(turn.captureLunaCheckpoint ? { captureLunaCheckpoint: true } : {}),
274
+ ...(turn.externalProgress ? { externalProgress: true } : {}),
275
+ },
276
+ })
277
+ // Only mirror once the run frame is on the wire, so the helper never sees progress for a
278
+ // turn it has not been told about and cannot accumulate state for unknown ids.
279
+ .then(() => {
280
+ if (!progressForwarding.signal.aborted) this.forwardProgress(turn, progressForwarding.signal);
281
+ })
282
+ .catch(error => this.finishWithError(turn.traceId, error instanceof Error ? error : new Error(String(error))));
283
+ });
284
+ }
285
+
286
+ async close(): Promise<void> {
287
+ const child = this.child;
288
+ this.child = undefined;
289
+ this.ready = undefined;
290
+ this.readyResolve = undefined;
291
+ this.readyReject = undefined;
292
+ for (const id of [...this.pending.keys()]) {
293
+ this.finishWithError(id, new DOMException("Launcher browser helper is closing", "AbortError"));
294
+ }
295
+ if (!child) return;
296
+ await this.sendTo(child, { type: "shutdown" }).catch(() => {});
297
+ await this.terminateChild(child, 2_000);
298
+ }
299
+
300
+ private async ensureChild(): Promise<void> {
301
+ if (this.child
302
+ && !this.child.killed
303
+ && this.child.exitCode === null
304
+ && this.child.signalCode === null
305
+ && this.ready) {
306
+ return this.ready;
307
+ }
308
+ const descriptor = readLauncherBrowserHostDescriptor(this.config.browserHostDescriptorPath!);
309
+ const child = spawn(
310
+ descriptor.helper.executable,
311
+ [this.config.browserHelperScriptPath ?? this.bundledHelperScript() ?? descriptor.helper.script],
312
+ {
313
+ env: {
314
+ ...process.env,
315
+ ELECTRON_RUN_AS_NODE: "1",
316
+ DSH_CHATGPT_FREE_BROWSER_HELPER_PROCESS: "1",
317
+ },
318
+ stdio: ["pipe", "pipe", "pipe"],
319
+ windowsHide: true,
320
+ },
321
+ );
322
+ this.child = child;
323
+ this.ready = new Promise<void>((resolveReady, rejectReady) => {
324
+ this.readyResolve = resolveReady;
325
+ this.readyReject = rejectReady;
326
+ });
327
+ const output = createInterface({ input: child.stdout });
328
+ output.on("line", line => this.handleLine(child, line));
329
+ const errors = createInterface({ input: child.stderr });
330
+ errors.on("line", line => console.info(`[chatgpt-web-helper] ${line}`));
331
+ const failChild = (error: Error) => {
332
+ const owned = this.child === child;
333
+ this.handleExit(child, error);
334
+ if (owned && Number.isInteger(child.pid) && child.exitCode === null && child.signalCode === null) {
335
+ void this.terminateChild(child, 0).catch(cleanupError => {
336
+ console.error(
337
+ `[chatgpt-web-helper] process-error cleanup failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`,
338
+ );
339
+ });
340
+ }
341
+ };
342
+ child.once("error", failChild);
343
+ child.stdin.once("error", error => failChild(new Error(
344
+ `Launcher browser helper input failed: ${error instanceof Error ? error.message : String(error)}`,
345
+ )));
346
+ child.once("exit", (code, signal) => this.handleExit(child, new Error(
347
+ `Launcher browser helper exited ${signal ? `from signal ${signal}` : `with status ${code ?? 1}`}`,
348
+ )));
349
+ const timer = setTimeout(() => {
350
+ if (this.child === child) this.readyReject?.(new Error("Launcher browser helper did not become ready"));
351
+ }, 15_000);
352
+ try {
353
+ await this.ready;
354
+ } catch (error) {
355
+ if (this.child === child) {
356
+ this.child = undefined;
357
+ this.ready = undefined;
358
+ this.readyResolve = undefined;
359
+ this.readyReject = undefined;
360
+ }
361
+ try {
362
+ await this.terminateChild(child, 500);
363
+ } catch (cleanupError) {
364
+ const primary = error instanceof Error ? error.message : String(error);
365
+ const cleanup = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
366
+ throw new Error(`${primary}; launcher browser helper cleanup failed: ${cleanup}`);
367
+ }
368
+ throw error;
369
+ } finally {
370
+ clearTimeout(timer);
371
+ }
372
+ }
373
+
374
+ private handleLine(child: ChildProcessWithoutNullStreams, line: string): void {
375
+ if (this.child !== child) return;
376
+ let message: HelperMessage;
377
+ try { message = parseHelperMessage(line); }
378
+ catch (error) {
379
+ const detail = error instanceof Error ? error.message : String(error);
380
+ this.handleExit(child, new Error(`Launcher browser helper emitted invalid protocol data: ${detail}`));
381
+ void this.terminateChild(child, 0).catch(error => {
382
+ console.error(`[chatgpt-web-helper] invalid-protocol cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
383
+ });
384
+ return;
385
+ }
386
+ if (message.type === "ready") {
387
+ // Optional frames are sent only when the helper advertises support for them.
388
+ this.helperFeatures = new Set(message.features ?? []);
389
+ this.readyResolve?.();
390
+ this.readyResolve = undefined;
391
+ this.readyReject = undefined;
392
+ return;
393
+ }
394
+ const pending = this.pending.get(message.id);
395
+ if (!pending) return;
396
+ if (message.type === "event") {
397
+ if (message.event === "heartbeat") pending.turn.onHeartbeat?.();
398
+ else if (message.event === "tool_batch_observed") {
399
+ const progress = pending.turn.externalProgress;
400
+ if (!progress) {
401
+ this.abortWithLocalFailure(
402
+ message.id,
403
+ new Error("Launcher browser helper observed a tool boundary for a turn without progress transport"),
404
+ pending,
405
+ );
406
+ return;
407
+ }
408
+ void progress.acknowledgeToolBatch(message.revision).catch(error => this.abortWithLocalFailure(
409
+ message.id,
410
+ error instanceof Error ? error : new Error(String(error)),
411
+ pending,
412
+ ));
413
+ }
414
+ else if (message.event === "completion_fence_begin") {
415
+ const fence = pending.turn.completionFence;
416
+ if (!fence) {
417
+ this.abortWithLocalFailure(
418
+ message.id,
419
+ new Error("Launcher browser helper requested a completion fence for an unfenced turn"),
420
+ pending,
421
+ );
422
+ return;
423
+ }
424
+ void fence.begin().then(revision => {
425
+ if (this.pending.get(message.id) !== pending || pending.localFailure || pending.turn.abortSignal?.aborted) return;
426
+ return this.send({
427
+ type: "completion_fence_begin_ack",
428
+ id: message.id,
429
+ requestId: message.requestId,
430
+ revision: revision ?? null,
431
+ });
432
+ }).catch(error => this.abortWithLocalFailure(
433
+ message.id,
434
+ error instanceof Error ? error : new Error(String(error)),
435
+ pending,
436
+ ));
437
+ }
438
+ else if (message.event === "completion_fence_commit") {
439
+ const fence = pending.turn.completionFence;
440
+ if (!fence) {
441
+ this.abortWithLocalFailure(
442
+ message.id,
443
+ new Error("Launcher browser helper requested a completion fence for an unfenced turn"),
444
+ pending,
445
+ );
446
+ return;
447
+ }
448
+ void fence.commit(message.revision).then(committed => {
449
+ if (this.pending.get(message.id) !== pending || pending.localFailure || pending.turn.abortSignal?.aborted) return;
450
+ return this.send({
451
+ type: "completion_fence_commit_ack",
452
+ id: message.id,
453
+ requestId: message.requestId,
454
+ committed,
455
+ });
456
+ }).catch(error => this.abortWithLocalFailure(
457
+ message.id,
458
+ error instanceof Error ? error : new Error(String(error)),
459
+ pending,
460
+ ));
461
+ }
462
+ else if (message.event === "send_activated") {
463
+ void Promise.resolve().then(() => pending.turn.onSendActivated?.()).then(() => {
464
+ if (this.pending.get(message.id) !== pending) return;
465
+ return this.send({ type: "send_activation_ack", id: message.id });
466
+ }).catch(error => this.abortWithLocalFailure(
467
+ message.id,
468
+ error instanceof Error ? error : new Error(String(error)),
469
+ pending,
470
+ ));
471
+ }
472
+ else if (message.event === "submitted") pending.turn.onSubmitted?.();
473
+ else if (message.event === "prepared_selected") {
474
+ const prepare = message.reused ? pending.turn.prepareResume : pending.turn.prepare;
475
+ void Promise.resolve().then(() => prepare?.()).then(prepared => {
476
+ if (!prepared) throw new Error("Launcher browser helper selected an unavailable continuation prompt");
477
+ if (this.pending.get(message.id) !== pending) {
478
+ prepared.release();
479
+ return;
480
+ }
481
+ pending.prepared = prepared;
482
+ return Promise.resolve(pending.turn.onPreparedSelected?.(message.reused)).then(() => {
483
+ if (this.pending.get(message.id) !== pending) return;
484
+ return this.send({
485
+ type: "prepared_selected_ack",
486
+ id: message.id,
487
+ prepared: {
488
+ text: prepared.text,
489
+ images: prepared.images,
490
+ ...(prepared.multipart ? { multipart: prepared.multipart } : {}),
491
+ ...(prepared.trimmedCompactionMessages !== undefined
492
+ ? { trimmedCompactionMessages: prepared.trimmedCompactionMessages }
493
+ : {}),
494
+ } satisfies CompiledChatGptWebPrompt,
495
+ });
496
+ });
497
+ }).catch(error => this.abortWithLocalFailure(
498
+ message.id,
499
+ error instanceof Error ? error : new Error(String(error)),
500
+ pending,
501
+ ));
502
+ }
503
+ else if (message.event === "luna_checkpoint") {
504
+ if (!pending.turn.captureLunaCheckpoint || !pending.turn.onLunaCheckpoint) {
505
+ this.finishWithError(message.id, new Error("Launcher browser helper emitted an unexpected Luna checkpoint"));
506
+ return;
507
+ }
508
+ pending.turn.onLunaCheckpoint({ checkpoint: message.checkpoint, answerHash: message.answerHash });
509
+ }
510
+ else if (message.event === "reasoning" && message.text) {
511
+ pending.turn.onReasoningSummary?.(message.text, message.continuation === true);
512
+ }
513
+ else if (message.event === "commentary" && message.text) pending.turn.onCommentary?.(message.text, message.continuation === true);
514
+ else if (message.event === "text" && message.text) pending.turn.onTextDelta(message.text);
515
+ return;
516
+ }
517
+ if (message.type === "result") {
518
+ this.finish(message.id);
519
+ if (pending.localFailure) pending.reject(pending.localFailure);
520
+ else pending.resolve(message.text);
521
+ } else if (message.type === "error") {
522
+ const error = message.status !== undefined
523
+ ? new ChatGptWebAdapterError(message.message, {
524
+ status: message.status,
525
+ errorType: message.errorType!,
526
+ code: message.code!,
527
+ retryable: message.retryable!,
528
+ })
529
+ : message.name === "AbortError"
530
+ ? new DOMException(message.message, "AbortError")
531
+ : new Error(message.message);
532
+ this.finish(message.id);
533
+ pending.reject(pending.localFailure ?? error);
534
+ }
535
+ }
536
+
537
+ private abortWithLocalFailure(id: string, error: Error, pending: PendingTurn): void {
538
+ if (this.pending.get(id) !== pending || pending.localFailure) return;
539
+ pending.localFailure = error;
540
+ void this.send({ type: "abort", id }).catch(sendError => {
541
+ if (this.pending.get(id) !== pending) return;
542
+ this.finishWithError(
543
+ id,
544
+ new AggregateError(
545
+ [error, sendError instanceof Error ? sendError : new Error(String(sendError))],
546
+ "Launcher browser helper could not abort after a local protocol failure",
547
+ ),
548
+ );
549
+ });
550
+ }
551
+
552
+ /**
553
+ * Mirrors daemon-recorded MCP progress into the helper process for the life of the turn.
554
+ *
555
+ * The browser worker runs out of process, so without this the worker sees no external progress
556
+ * and cancels turns whose tool calls are still completing.
557
+ */
558
+ private forwardProgress(turn: BrowserTurn, stop: AbortSignal): void {
559
+ const progress = turn.externalProgress;
560
+ if (!progress) return;
561
+ if (!this.helperFeatures.has("progress")) {
562
+ console.warn(
563
+ `[chatgpt-web] browser turn ${turn.traceId} runs without an MCP progress mirror:`
564
+ + " the launcher browser helper predates the progress frame",
565
+ );
566
+ return;
567
+ }
568
+ void (async () => {
569
+ let revision = 0;
570
+ while (!stop.aborted) {
571
+ const snapshot = await progress.waitForChange(revision, stop);
572
+ revision = snapshot.revision;
573
+ if (stop.aborted) return;
574
+ await this.send({ type: "progress", id: turn.traceId, snapshot });
575
+ }
576
+ })().catch(error => {
577
+ // Ending, aborting, or losing the helper stops the mirror by design and is not a fault.
578
+ // Anything else leaves the worker on DOM-only health without saying so, which is exactly the
579
+ // silent degradation this transport exists to remove, so it is surfaced rather than dropped.
580
+ if (stop.aborted || (error instanceof DOMException && error.name === "AbortError")) return;
581
+ console.warn(
582
+ `[chatgpt-web] browser turn ${turn.traceId} lost its MCP progress mirror:`
583
+ + ` ${error instanceof Error ? error.message : String(error)}`,
584
+ );
585
+ });
586
+ }
587
+
588
+ private finish(id: string): void {
589
+ const pending = this.pending.get(id);
590
+ if (!pending) return;
591
+ if (pending.abortListener && pending.turn.abortSignal) {
592
+ pending.turn.abortSignal.removeEventListener("abort", pending.abortListener);
593
+ }
594
+ pending.progressForwarding?.abort();
595
+ pending.progressForwarding = undefined;
596
+ pending.prepared?.release();
597
+ pending.prepared = undefined;
598
+ this.pending.delete(id);
599
+ }
600
+
601
+ private finishWithError(id: string, error: Error): void {
602
+ const pending = this.pending.get(id);
603
+ if (!pending) return;
604
+ this.finish(id);
605
+ pending.reject(error);
606
+ }
607
+
608
+ private handleExit(child: ChildProcessWithoutNullStreams, error: Error): void {
609
+ if (this.child !== child) return;
610
+ this.readyReject?.(error);
611
+ this.readyReject = undefined;
612
+ this.readyResolve = undefined;
613
+ this.ready = undefined;
614
+ this.child = undefined;
615
+ for (const id of [...this.pending.keys()]) {
616
+ const pending = this.pending.get(id);
617
+ if (!pending) continue;
618
+ void notifyLauncherTurn(this.config.browserHostDescriptorPath!, {
619
+ phase: "end",
620
+ traceId: id,
621
+ helperPid: child.pid!,
622
+ status: "failed",
623
+ message: "Launcher browser helper exited before completing the turn",
624
+ }).then(
625
+ () => this.finishWithError(id, pending.localFailure ?? error),
626
+ controlError => this.finishWithError(
627
+ id,
628
+ new AggregateError(
629
+ [pending.localFailure ?? error, controlError instanceof Error ? controlError : new Error(String(controlError))],
630
+ `Launcher browser helper exited and failed to release turn ${id}`,
631
+ ),
632
+ ),
633
+ );
634
+ }
635
+ }
636
+
637
+ private async waitForExit(child: ChildProcessWithoutNullStreams, timeoutMs: number): Promise<boolean> {
638
+ if (child.exitCode !== null || child.signalCode !== null) return true;
639
+ return await new Promise<boolean>(resolveExit => {
640
+ let settled = false;
641
+ const finish = (exited: boolean) => {
642
+ if (settled) return;
643
+ settled = true;
644
+ clearTimeout(timer);
645
+ child.off("exit", onExit);
646
+ child.off("close", onExit);
647
+ resolveExit(exited);
648
+ };
649
+ const onExit = () => finish(true);
650
+ const timer = setTimeout(() => finish(false), timeoutMs);
651
+ child.once("exit", onExit);
652
+ child.once("close", onExit);
653
+ });
654
+ }
655
+
656
+ private async terminateChild(child: ChildProcessWithoutNullStreams, gracefulTimeoutMs: number): Promise<void> {
657
+ if (child.exitCode !== null || child.signalCode !== null) return;
658
+ child.stdin.end();
659
+ if (await this.waitForExit(child, gracefulTimeoutMs)) return;
660
+ if (!child.kill("SIGTERM") && child.exitCode === null && child.signalCode === null) {
661
+ throw new Error("Launcher browser helper refused termination");
662
+ }
663
+ if (await this.waitForExit(child, 2_000)) return;
664
+ if (!child.kill("SIGKILL") && child.exitCode === null && child.signalCode === null) {
665
+ throw new Error("Launcher browser helper refused forced termination");
666
+ }
667
+ if (!await this.waitForExit(child, 2_000)) {
668
+ throw new Error("Launcher browser helper did not exit after forced termination");
669
+ }
670
+ }
671
+
672
+ private send(message: unknown): Promise<void> {
673
+ const child = this.child;
674
+ if (!child
675
+ || child.killed
676
+ || child.exitCode !== null
677
+ || child.signalCode !== null) {
678
+ return Promise.reject(new Error("Launcher browser helper is not running"));
679
+ }
680
+ return this.sendTo(child, message);
681
+ }
682
+
683
+ private async sendTo(child: ChildProcessWithoutNullStreams, message: unknown): Promise<void> {
684
+ const encoded = `${JSON.stringify(message)}\n`;
685
+ if (child.stdin.destroyed || child.stdin.writableEnded) {
686
+ throw new Error("Launcher browser helper input is closed");
687
+ }
688
+ await new Promise<void>((resolveWrite, rejectWrite) => {
689
+ child.stdin.write(encoded, error => {
690
+ if (error) rejectWrite(error);
691
+ else resolveWrite();
692
+ });
693
+ });
694
+ }
695
+ }