@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,816 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { AdapterEvent, CodexParsedRequest } from "../../types";
3
+ import type { BrokerToolRequest } from "./turn-broker";
4
+ import { chatGptBrowserTabClosedError } from "./adapter-error";
5
+ import {
6
+ extractChatGptCompactionSourceRevision,
7
+ extractChatGptTurnIdentity,
8
+ extractChatGptTurnUserRevision,
9
+ } from "./environment";
10
+ import { MAX_CHATGPT_BROWSER_TABS } from "./concurrency";
11
+ import type { ChatGptExternalTurnProgress } from "./turn-progress";
12
+
13
+ function awaitWithAbort<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
14
+ if (!signal) return promise;
15
+ if (signal.aborted) {
16
+ // Keep the underlying retirement promise observed even when the caller arrived after abort;
17
+ // another owner may still depend on its eventual settlement and rejection must not become an
18
+ // unhandled process-level error.
19
+ void promise.catch(() => {});
20
+ return Promise.reject(new DOMException("ChatGPT web turn aborted", "AbortError"));
21
+ }
22
+ return new Promise<T>((resolve, reject) => {
23
+ const onAbort = () => reject(new DOMException("ChatGPT web turn aborted", "AbortError"));
24
+ signal.addEventListener("abort", onAbort, { once: true });
25
+ promise.then(
26
+ value => {
27
+ signal.removeEventListener("abort", onAbort);
28
+ resolve(value);
29
+ },
30
+ error => {
31
+ signal.removeEventListener("abort", onAbort);
32
+ reject(error);
33
+ },
34
+ );
35
+ });
36
+ }
37
+
38
+ export type ChatGptBrowserOutcome =
39
+ | { type: "final"; answer: string }
40
+ | { type: "error"; error: Error };
41
+
42
+ export interface ChatGptTraceEvent {
43
+ kind: "reasoning" | "commentary";
44
+ text: string;
45
+ continuation?: boolean;
46
+ }
47
+
48
+ interface TraceWaiter {
49
+ resolve: () => void;
50
+ reject: (error: Error) => void;
51
+ signal?: AbortSignal;
52
+ onAbort?: () => void;
53
+ }
54
+
55
+ export class ChatGptTraceFeed {
56
+ private readonly queued: ChatGptTraceEvent[] = [];
57
+ private readonly waiters = new Set<TraceWaiter>();
58
+
59
+ push(event: ChatGptTraceEvent): void {
60
+ const normalized = event.continuation ? event.text : event.text.trim();
61
+ if (!normalized) return;
62
+ const normalizedEvent = { ...event, text: normalized };
63
+ this.queued.push(normalizedEvent);
64
+ const waiter = this.waiters.values().next().value as TraceWaiter | undefined;
65
+ if (!waiter) return;
66
+ this.waiters.delete(waiter);
67
+ if (waiter.signal && waiter.onAbort) waiter.signal.removeEventListener("abort", waiter.onAbort);
68
+ waiter.resolve();
69
+ }
70
+
71
+ drain(): ChatGptTraceEvent[] {
72
+ return this.queued.splice(0);
73
+ }
74
+
75
+ wait(signal?: AbortSignal): Promise<void> {
76
+ if (this.queued.length > 0) return Promise.resolve();
77
+ if (signal?.aborted) return Promise.reject(new DOMException("trace wait aborted", "AbortError"));
78
+ return new Promise<void>((resolveWait, rejectWait) => {
79
+ const waiter: TraceWaiter = { resolve: resolveWait, reject: rejectWait, ...(signal ? { signal } : {}) };
80
+ if (signal) {
81
+ waiter.onAbort = () => {
82
+ this.waiters.delete(waiter);
83
+ rejectWait(new DOMException("trace wait aborted", "AbortError"));
84
+ };
85
+ signal.addEventListener("abort", waiter.onAbort, { once: true });
86
+ }
87
+ this.waiters.add(waiter);
88
+ });
89
+ }
90
+ }
91
+
92
+ interface TextWaiter {
93
+ resolve: () => void;
94
+ reject: (error: Error) => void;
95
+ signal?: AbortSignal;
96
+ onAbort?: () => void;
97
+ }
98
+
99
+ /** Append-only browser Markdown feed. Waiters are notifications; `drain` owns consumption. */
100
+ export class ChatGptTextFeed {
101
+ private readonly queued: string[] = [];
102
+ private readonly waiters = new Set<TextWaiter>();
103
+ private text = "";
104
+
105
+ push(delta: string): void {
106
+ if (!delta) return;
107
+ this.text += delta;
108
+ this.queued.push(delta);
109
+ const waiter = this.waiters.values().next().value as TextWaiter | undefined;
110
+ if (!waiter) return;
111
+ this.waiters.delete(waiter);
112
+ if (waiter.signal && waiter.onAbort) waiter.signal.removeEventListener("abort", waiter.onAbort);
113
+ waiter.resolve();
114
+ }
115
+
116
+ drain(): string[] {
117
+ return this.queued.splice(0);
118
+ }
119
+
120
+ value(): string {
121
+ return this.text;
122
+ }
123
+
124
+ wait(signal?: AbortSignal): Promise<void> {
125
+ if (this.queued.length > 0) return Promise.resolve();
126
+ if (signal?.aborted) return Promise.reject(new DOMException("text wait aborted", "AbortError"));
127
+ return new Promise<void>((resolveWait, rejectWait) => {
128
+ const waiter: TextWaiter = { resolve: resolveWait, reject: rejectWait, ...(signal ? { signal } : {}) };
129
+ if (signal) {
130
+ waiter.onAbort = () => {
131
+ this.waiters.delete(waiter);
132
+ rejectWait(new DOMException("text wait aborted", "AbortError"));
133
+ };
134
+ signal.addEventListener("abort", waiter.onAbort, { once: true });
135
+ }
136
+ this.waiters.add(waiter);
137
+ });
138
+ }
139
+ }
140
+
141
+ interface ChatGptTurnRuntimeBase {
142
+ browser: Promise<string>;
143
+ /** Physical helper/Playwright settlement, including the launcher end/release acknowledgement. */
144
+ physicalSettlement: Promise<void>;
145
+ trace: ChatGptTraceFeed;
146
+ text: ChatGptTextFeed;
147
+ usageInput?: CodexParsedRequest;
148
+ conversationKey?: string;
149
+ releaseRetainedConversation?: () => Promise<void>;
150
+ /** Idempotently retire the turn-bound MCP capability after browser and observer settlement. */
151
+ retireCapability?: () => void | Promise<void>;
152
+ submission?: { phase: "prepared" | "send_activated" | "accepted" };
153
+ /** Present only when the visible ChatGPT tab is driven manually through the Codex Zero Risk MCP contract. */
154
+ manualControl?: { surfaceNonce: string };
155
+ cancel: (reason?: Error) => void;
156
+ }
157
+
158
+ export type ChatGptTurnRuntime =
159
+ | (ChatGptTurnRuntimeBase & {
160
+ mode: "tools";
161
+ token: Promise<string>;
162
+ externalProgress: ChatGptExternalTurnProgress;
163
+ })
164
+ | (ChatGptTurnRuntimeBase & { mode: "read-only" });
165
+
166
+ function executionKey(parsed: CodexParsedRequest, payload: unknown): string {
167
+ return createHash("sha256").update(JSON.stringify({
168
+ modelId: parsed.modelId,
169
+ reasoning: parsed.options.reasoning,
170
+ payload,
171
+ })).digest("hex");
172
+ }
173
+
174
+ function compactionInputRevision(parsed: CodexParsedRequest): unknown[] {
175
+ const body = parsed._rawBody;
176
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
177
+ throw new Error("ChatGPT web compaction requires the complete native Codex request body");
178
+ }
179
+ const input = (body as { input?: unknown }).input;
180
+ if (!Array.isArray(input)) {
181
+ throw new Error("ChatGPT web compaction requires the complete native Codex input history");
182
+ }
183
+ return input;
184
+ }
185
+
186
+ export function chatGptTurnExecutionKey(parsed: CodexParsedRequest): string {
187
+ const identity = extractChatGptTurnIdentity(parsed);
188
+ if (!identity.turnId) throw new Error("ChatGPT web requires native Codex turn_id metadata for browser-session replay");
189
+ return executionKey(parsed, {
190
+ threadId: identity.threadId,
191
+ turnId: identity.turnId,
192
+ purpose: parsed._compactionRequest ? "compaction" : "response",
193
+ revision: parsed._compactionRequest
194
+ ? compactionInputRevision(parsed)
195
+ : extractChatGptTurnUserRevision(parsed),
196
+ });
197
+ }
198
+
199
+ /** Exact canonical Responses request identity inside one long-lived browser execution. */
200
+ export function chatGptTurnRoundKey(parsed: CodexParsedRequest): string {
201
+ const identity = extractChatGptTurnIdentity(parsed);
202
+ if (!identity.turnId) throw new Error("ChatGPT web requires native Codex turn_id metadata for round replay");
203
+ const body = parsed._rawBody;
204
+ if (!body || typeof body !== "object" || Array.isArray(body)
205
+ || !Array.isArray((body as { input?: unknown }).input)) {
206
+ throw new Error("ChatGPT web requires the complete native Codex input for round replay");
207
+ }
208
+ return executionKey(parsed, {
209
+ threadId: identity.threadId,
210
+ turnId: identity.turnId,
211
+ purpose: parsed._compactionRequest ? "compaction" : "response",
212
+ input: (body as { input: unknown[] }).input,
213
+ });
214
+ }
215
+
216
+ /** Stable identity for limiting automatic retries of one native Codex turn. */
217
+ export function chatGptTurnRetryKey(parsed: CodexParsedRequest): string {
218
+ const identity = extractChatGptTurnIdentity(parsed);
219
+ if (!identity.turnId) throw new Error("ChatGPT web requires native Codex turn_id metadata for browser-turn retry budgeting");
220
+ return createHash("sha256").update(JSON.stringify({
221
+ threadId: identity.threadId,
222
+ turnId: identity.turnId,
223
+ purpose: parsed._compactionRequest ? "compaction" : "response",
224
+ })).digest("hex");
225
+ }
226
+
227
+ /** One native Codex thread may own at most one live ChatGPT browser surface. */
228
+ export function chatGptThreadOwnershipKey(parsed: CodexParsedRequest): string {
229
+ const identity = extractChatGptTurnIdentity(parsed);
230
+ const owner = identity.threadId
231
+ ? { kind: "thread", id: identity.threadId }
232
+ : identity.promptCacheKey
233
+ ? { kind: "prompt_cache", id: identity.promptCacheKey }
234
+ : identity.turnId
235
+ ? { kind: "turn", id: identity.turnId }
236
+ : undefined;
237
+ if (!owner) throw new Error("ChatGPT web requires native Codex turn identity metadata for browser ownership");
238
+ return createHash("sha256").update(JSON.stringify(owner)).digest("hex");
239
+ }
240
+
241
+ /** Locate the browser response that a native mid-turn compaction replaces. */
242
+ export function chatGptCompactionSourceExecutionKey(parsed: CodexParsedRequest): string {
243
+ const identity = extractChatGptTurnIdentity(parsed);
244
+ if (!identity.turnId) throw new Error("ChatGPT web requires native Codex turn_id metadata for browser-session replay");
245
+ const source = extractChatGptCompactionSourceRevision(parsed);
246
+ return executionKey(parsed, {
247
+ threadId: identity.threadId,
248
+ turnId: source.turnId ?? identity.turnId,
249
+ purpose: "response",
250
+ revision: source.content,
251
+ });
252
+ }
253
+
254
+ export class ChatGptTurnSession {
255
+ readonly createdAt = Date.now();
256
+ private lastTouchedAt = this.createdAt;
257
+ readonly browserOutcome: Promise<ChatGptBrowserOutcome>;
258
+ readonly physicalSettlement: Promise<void>;
259
+ private readonly outstandingById = new Map<string, BrokerToolRequest>();
260
+ private readonly deliveredResultIds = new Set<string>();
261
+ private outstandingReasoning: string[] = [];
262
+ private finalReasoning: string[] = [];
263
+ private outstandingPrelude: AdapterEvent[] = [];
264
+ private finalPrelude: AdapterEvent[] = [];
265
+ private settledBrowserOutcome?: ChatGptBrowserOutcome;
266
+ private settledPhysical = false;
267
+ private attachedConversationKey: string | undefined;
268
+ private tail: Promise<void> = Promise.resolve();
269
+ private capabilityRetirementScheduled = false;
270
+ private readonly rounds = new Map<string, {
271
+ events: AdapterEvent[];
272
+ reasoning: string[];
273
+ completed: boolean;
274
+ failure?: Error;
275
+ }>();
276
+
277
+ constructor(
278
+ readonly runtime: ChatGptTurnRuntime,
279
+ readonly traceId?: string,
280
+ readonly ownerKey?: string,
281
+ readonly nativeTurnId?: string,
282
+ readonly nativeThreadId?: string,
283
+ ) {
284
+ this.attachedConversationKey = runtime.conversationKey;
285
+ this.physicalSettlement = runtime.physicalSettlement.then(
286
+ () => { this.settledPhysical = true; },
287
+ error => {
288
+ this.settledPhysical = true;
289
+ throw error;
290
+ },
291
+ );
292
+ this.browserOutcome = runtime.browser
293
+ .then(answer => ({ type: "final", answer }) as ChatGptBrowserOutcome)
294
+ .catch(error => ({ type: "error", error: error instanceof Error ? error : new Error(String(error)) }) as ChatGptBrowserOutcome)
295
+ .then(outcome => {
296
+ this.settledBrowserOutcome = outcome;
297
+ return outcome;
298
+ });
299
+ }
300
+
301
+ runExclusive<T>(task: () => Promise<T>): Promise<T> {
302
+ this.touch();
303
+ const run = this.tail.then(task);
304
+ this.tail = run.then(() => undefined, () => undefined);
305
+ this.scheduleCapabilityRetirement();
306
+ return run;
307
+ }
308
+
309
+ touch(): void {
310
+ this.lastTouchedAt = Date.now();
311
+ }
312
+
313
+ lastUsedAt(): number {
314
+ return this.lastTouchedAt;
315
+ }
316
+
317
+ outstanding(): BrokerToolRequest[] {
318
+ return [...this.outstandingById.values()];
319
+ }
320
+
321
+ settledOutcome(): ChatGptBrowserOutcome | undefined {
322
+ return this.settledBrowserOutcome;
323
+ }
324
+
325
+ conversationKey(): string | undefined {
326
+ return this.attachedConversationKey;
327
+ }
328
+
329
+ detachConversation(conversationKey: string): boolean {
330
+ if (this.attachedConversationKey !== conversationKey) return false;
331
+ this.attachedConversationKey = undefined;
332
+ return true;
333
+ }
334
+
335
+ isActive(): boolean {
336
+ return this.settledBrowserOutcome === undefined;
337
+ }
338
+
339
+ /** The client-visible browser result can settle before launcher/helper cleanup does. */
340
+ isPhysicallySettled(): boolean {
341
+ return this.settledPhysical;
342
+ }
343
+
344
+ setOutstanding(requests: BrokerToolRequest[], reasoning: string[] = [], prelude: AdapterEvent[] = []): void {
345
+ if (this.outstandingById.size > 0) throw new Error("cannot emit a new ChatGPT tool batch while the previous batch is unresolved");
346
+ for (const request of requests) {
347
+ if (this.deliveredResultIds.has(request.callId) || this.outstandingById.has(request.callId)) {
348
+ throw new Error(`duplicate ChatGPT bridge tool call id: ${request.callId}`);
349
+ }
350
+ this.outstandingById.set(request.callId, request);
351
+ }
352
+ this.outstandingReasoning = [...reasoning];
353
+ this.outstandingPrelude = [...prelude];
354
+ }
355
+
356
+ hasOutstanding(callId: string): boolean {
357
+ return this.outstandingById.has(callId);
358
+ }
359
+
360
+ markResultDelivered(callId: string): void {
361
+ if (!this.outstandingById.delete(callId)) throw new Error(`ChatGPT bridge tool result does not match an outstanding call: ${callId}`);
362
+ this.deliveredResultIds.add(callId);
363
+ if (this.outstandingById.size === 0) {
364
+ this.outstandingReasoning = [];
365
+ this.outstandingPrelude = [];
366
+ }
367
+ }
368
+
369
+ reasoningForOutstandingReplay(): string[] {
370
+ return [...this.outstandingReasoning];
371
+ }
372
+
373
+ eventsForOutstandingReplay(): AdapterEvent[] {
374
+ return [...this.outstandingPrelude];
375
+ }
376
+
377
+ setFinalReasoning(reasoning: string[]): void {
378
+ this.finalReasoning = [...reasoning];
379
+ }
380
+
381
+ reasoningForFinalReplay(): string[] {
382
+ return [...this.finalReasoning];
383
+ }
384
+
385
+ setFinalEvents(events: AdapterEvent[]): void {
386
+ this.finalPrelude = [...events];
387
+ }
388
+
389
+ eventsForFinalReplay(): AdapterEvent[] {
390
+ return [...this.finalPrelude];
391
+ }
392
+
393
+ roundEvents(key: string): AdapterEvent[] {
394
+ return [...this.round(key).events];
395
+ }
396
+
397
+ roundReasoning(key: string): string[] {
398
+ return [...this.round(key).reasoning];
399
+ }
400
+
401
+ appendRoundEvent(key: string, event: AdapterEvent): void {
402
+ this.appendRoundEvents(key, [event]);
403
+ }
404
+
405
+ appendRoundEvents(key: string, events: readonly AdapterEvent[]): void {
406
+ if (events.length === 0) return;
407
+ const round = this.round(key);
408
+ if (round.completed) throw new Error("cannot append to a completed ChatGPT native round");
409
+ round.events.push(...events);
410
+ }
411
+
412
+ appendRoundReasoning(key: string, values: readonly string[]): void {
413
+ if (values.length === 0) return;
414
+ const round = this.round(key);
415
+ if (round.completed) throw new Error("cannot append reasoning to a completed ChatGPT native round");
416
+ round.reasoning.push(...values);
417
+ }
418
+
419
+ completeRound(key: string): void {
420
+ this.round(key).completed = true;
421
+ }
422
+
423
+ failRound(key: string, error: Error): void {
424
+ const round = this.round(key);
425
+ round.failure = error;
426
+ round.completed = true;
427
+ }
428
+
429
+ roundCompleted(key: string): boolean {
430
+ return this.rounds.get(key)?.completed === true;
431
+ }
432
+
433
+ roundFailure(key: string): Error | undefined {
434
+ return this.rounds.get(key)?.failure;
435
+ }
436
+
437
+ roundHasTerminalEvent(key: string): boolean {
438
+ return this.rounds.get(key)?.events.some(event => event.type === "done" || event.type === "error") === true;
439
+ }
440
+
441
+ cancel(reason?: Error): void {
442
+ this.runtime.cancel(reason);
443
+ }
444
+
445
+ private scheduleCapabilityRetirement(): void {
446
+ if (this.capabilityRetirementScheduled || !this.runtime.retireCapability) return;
447
+ this.capabilityRetirementScheduled = true;
448
+ // Register only after the first observer entered `runExclusive`. This ensures an immediately
449
+ // completed mocked/real browser cannot revoke its token ahead of the browser-outcome branch.
450
+ // At physical settlement, read the current tail so every tool-result/reconnect observer that
451
+ // was already admitted finishes before the capability is retired.
452
+ void this.physicalSettlement
453
+ .then(() => this.tail)
454
+ .then(() => this.runtime.retireCapability!())
455
+ .catch(error => {
456
+ console.error(
457
+ `[chatgpt-web] failed to retire settled turn capability: ${error instanceof Error ? error.message : String(error)}`,
458
+ );
459
+ });
460
+ }
461
+
462
+ private round(key: string) {
463
+ let round = this.rounds.get(key);
464
+ if (round) return round;
465
+ round = { events: [], reasoning: [], completed: false };
466
+ this.rounds.set(key, round);
467
+ while (this.rounds.size > 512) {
468
+ const oldestCompleted = [...this.rounds].find(([, candidate]) => candidate.completed);
469
+ if (!oldestCompleted) {
470
+ throw new Error("ChatGPT native round journal is full (512 unfinished rounds)");
471
+ }
472
+ this.rounds.delete(oldestCompleted[0]);
473
+ }
474
+ return round;
475
+ }
476
+ }
477
+
478
+ export class ChatGptTurnSessions {
479
+ private readonly entries = new Map<string, ChatGptTurnSession>();
480
+ private readonly conversationHeads = new Map<string, ChatGptTurnSession>();
481
+ private readonly retirements = new Map<string, Promise<void>>();
482
+ private readonly ownerRetirements = new Map<string, Promise<void>>();
483
+ private readonly conversationRetirements = new Map<string, Promise<void>>();
484
+
485
+ constructor(
486
+ private readonly ttlMs = 30 * 60_000,
487
+ private readonly maxEntries = 256,
488
+ ) {}
489
+
490
+ getOrCreate(
491
+ key: string,
492
+ start: () => ChatGptTurnRuntime,
493
+ traceId?: string,
494
+ ownerKey?: string,
495
+ nativeTurnId?: string,
496
+ nativeThreadId?: string,
497
+ ): ChatGptTurnSession {
498
+ this.prune();
499
+ const existing = this.entries.get(key);
500
+ if (existing) {
501
+ existing.touch();
502
+ return existing;
503
+ }
504
+ const active = [...this.entries.values()].filter(session => session.isActive()).length;
505
+ if (active >= MAX_CHATGPT_BROWSER_TABS) {
506
+ throw new Error(
507
+ `ChatGPT Web supports at most ${MAX_CHATGPT_BROWSER_TABS} simultaneous browser turns; close or finish a browser tab before starting another`,
508
+ );
509
+ }
510
+ if (this.entries.size >= this.maxEntries) throw new Error(`ChatGPT web session registry is full (${this.maxEntries} entries)`);
511
+ const session = new ChatGptTurnSession(start(), traceId, ownerKey, nativeTurnId, nativeThreadId);
512
+ this.entries.set(key, session);
513
+ const conversationKey = session.conversationKey();
514
+ if (conversationKey) this.conversationHeads.set(conversationKey, session);
515
+ return session;
516
+ }
517
+
518
+ async getOrCreateAfterOwnerRetirement(
519
+ key: string,
520
+ ownerKey: string,
521
+ start: () => ChatGptTurnRuntime,
522
+ traceId?: string,
523
+ signal?: AbortSignal,
524
+ nativeTurnId?: string,
525
+ nativeThreadId?: string,
526
+ ): Promise<ChatGptTurnSession> {
527
+ for (;;) {
528
+ if (signal?.aborted) throw new DOMException("ChatGPT web turn aborted", "AbortError");
529
+ const existing = this.entries.get(key);
530
+ if (existing) {
531
+ existing.touch();
532
+ return existing;
533
+ }
534
+ const pending = this.retirements.get(key) ?? this.ownerRetirements.get(ownerKey);
535
+ if (pending) {
536
+ await awaitWithAbort(pending, signal);
537
+ continue;
538
+ }
539
+ const activeOwner = [...this.entries].find(([ownedKey, session]) => (
540
+ ownedKey !== key && session.ownerKey === ownerKey && !session.isPhysicallySettled()
541
+ ));
542
+ if (activeOwner) {
543
+ const [, ownedSession] = activeOwner;
544
+ // A different native message for the same thread is sequential work, not permission to
545
+ // kill the response already using that retained conversation. Wait for its complete
546
+ // browser/launcher settlement; explicit tab close and lifecycle cancellation remain the
547
+ // only paths that preempt an active owner.
548
+ await awaitWithAbort(ownedSession.physicalSettlement, signal);
549
+ continue;
550
+ }
551
+ if (signal?.aborted) throw new DOMException("ChatGPT web turn aborted", "AbortError");
552
+ return this.getOrCreate(key, start, traceId, ownerKey, nativeTurnId, nativeThreadId);
553
+ }
554
+ }
555
+
556
+ find(key: string): ChatGptTurnSession | undefined {
557
+ const session = this.entries.get(key);
558
+ session?.touch();
559
+ return session;
560
+ }
561
+
562
+ findConversationHead(conversationKey: string): ChatGptTurnSession | undefined {
563
+ const session = this.conversationHeads.get(conversationKey);
564
+ session?.touch();
565
+ return session;
566
+ }
567
+
568
+ /** Wait for a retained conversation epoch that has been detached but not physically released. */
569
+ async waitForConversationRetirement(conversationKey: string, signal?: AbortSignal): Promise<void> {
570
+ const pending = this.conversationRetirements.get(conversationKey);
571
+ if (pending) await awaitWithAbort(pending, signal);
572
+ }
573
+
574
+ async retireConversationAndWait(conversationKey: string): Promise<number> {
575
+ return this.closeConversationAndWait(conversationKey);
576
+ }
577
+
578
+ /**
579
+ * Close the physical retained-chat epoch without discarding a terminal response that won the
580
+ * compaction race before any compaction instruction reached that response. The detached logical
581
+ * session remains addressable by its exact Responses execution key, so the post-compaction
582
+ * native round can consume the already-committed answer instead of opening another browser turn.
583
+ */
584
+ async retireConversationPreservingFinalResponse(
585
+ conversationKey: string,
586
+ preserved: ChatGptTurnSession,
587
+ preservedExecutionKey: string,
588
+ ): Promise<number> {
589
+ if (!preservedExecutionKey) throw new Error("Preserved ChatGPT response execution key is required");
590
+ const outcome = preserved.settledOutcome();
591
+ if (!outcome || outcome.type !== "final") {
592
+ throw new Error("Only a settled final ChatGPT response can survive retained-conversation retirement");
593
+ }
594
+ return this.closeConversationAndWait(conversationKey, {
595
+ session: preserved,
596
+ executionKey: preservedExecutionKey,
597
+ });
598
+ }
599
+
600
+ private async closeConversationAndWait(
601
+ conversationKey: string,
602
+ preserved?: { session: ChatGptTurnSession; executionKey: string },
603
+ ): Promise<number> {
604
+ const pending = this.conversationRetirements.get(conversationKey);
605
+ if (pending) {
606
+ await pending;
607
+ return 0;
608
+ }
609
+ const matches = [...this.entries].filter(([, session]) => (
610
+ session.conversationKey() === conversationKey
611
+ ));
612
+ if (matches.length === 0) return 0;
613
+ if (preserved && !matches.some(([, session]) => session === preserved.session)) {
614
+ throw new Error("The final ChatGPT response does not own the retained conversation being retired");
615
+ }
616
+ const target = preserved ? this.entries.get(preserved.executionKey) : undefined;
617
+ if (target && target !== preserved?.session) {
618
+ throw new Error("The compacted ChatGPT response execution key is already owned by another session");
619
+ }
620
+ this.conversationHeads.delete(conversationKey);
621
+ for (const [key, session] of matches) {
622
+ if (this.entries.get(key) === session
623
+ && (session !== preserved?.session || key !== preserved.executionKey)) {
624
+ this.entries.delete(key);
625
+ }
626
+ if (session.isActive()) session.cancel();
627
+ if (!session.detachConversation(conversationKey)) {
628
+ throw new Error("ChatGPT retained-conversation ownership changed during retirement");
629
+ }
630
+ }
631
+ if (preserved) this.entries.set(preserved.executionKey, preserved.session);
632
+ const release = matches.findLast(([, session]) => (
633
+ session.runtime.releaseRetainedConversation !== undefined
634
+ ))?.[1].runtime.releaseRetainedConversation;
635
+ const retirement = Promise.all(matches.map(([, session]) => session.physicalSettlement))
636
+ .then(async () => { await release?.(); });
637
+ this.conversationRetirements.set(conversationKey, retirement);
638
+ try {
639
+ await retirement;
640
+ } finally {
641
+ if (this.conversationRetirements.get(conversationKey) === retirement) {
642
+ this.conversationRetirements.delete(conversationKey);
643
+ }
644
+ }
645
+ return matches.length;
646
+ }
647
+
648
+ async waitForRetirement(key: string): Promise<void> {
649
+ await this.retirements.get(key);
650
+ }
651
+
652
+ async retireAndWait(key: string, signal?: AbortSignal): Promise<boolean> {
653
+ const pending = this.retirements.get(key);
654
+ if (pending) {
655
+ await awaitWithAbort(pending, signal);
656
+ return true;
657
+ }
658
+ const session = this.entries.get(key);
659
+ if (!session) return false;
660
+
661
+ this.entries.delete(key);
662
+ this.forgetConversationHead(session);
663
+ await awaitWithAbort(this.beginRetirement(key, session), signal);
664
+ return true;
665
+ }
666
+
667
+ retire(key: string, session: ChatGptTurnSession): boolean {
668
+ if (this.entries.get(key) !== session) return false;
669
+ this.entries.delete(key);
670
+ this.forgetConversationHead(session);
671
+ this.beginRetirement(key, session);
672
+ return true;
673
+ }
674
+
675
+ /** Cancel only active responses whose exact native turn ids Codex marked as interrupted. */
676
+ retireAbortedOwnerTurns(
677
+ ownerKey: string,
678
+ abortedTurnIds: ReadonlySet<string>,
679
+ keepKey: string,
680
+ ): number {
681
+ const matches = [...this.entries].filter(([key, session]) => (
682
+ key !== keepKey
683
+ && session.ownerKey === ownerKey
684
+ && session.nativeTurnId !== undefined
685
+ && abortedTurnIds.has(session.nativeTurnId)
686
+ && session.isActive()
687
+ ));
688
+ for (const [key, session] of matches) {
689
+ this.entries.delete(key);
690
+ this.forgetConversationHead(session);
691
+ this.beginRetirement(key, session);
692
+ }
693
+ return matches.length;
694
+ }
695
+
696
+ clear(): number {
697
+ const cancelled = this.entries.size;
698
+ for (const [key, session] of this.entries) this.beginRetirement(key, session);
699
+ this.entries.clear();
700
+ this.conversationHeads.clear();
701
+ return cancelled;
702
+ }
703
+
704
+ async cancelTrace(traceId: string, reason = chatGptBrowserTabClosedError()): Promise<number> {
705
+ const sessions = [...this.entries.values()]
706
+ .filter(session => session.traceId === traceId && session.isActive());
707
+ for (const session of sessions) session.cancel(reason);
708
+ await Promise.all(sessions.map(session => session.physicalSettlement));
709
+ return sessions.length;
710
+ }
711
+
712
+ /**
713
+ * Begin retiring only the browser execution owned by the exact native Codex turn.
714
+ *
715
+ * Codex runs Interrupt hooks synchronously with a short deadline. Ownership is removed and the
716
+ * abort is delivered before this method returns; physical helper cleanup remains represented by
717
+ * `settlement`, so replacement turns still serialize behind the real teardown without blocking
718
+ * the hook acknowledgement itself.
719
+ */
720
+ cancelNativeTurn(
721
+ threadId: string,
722
+ turnId: string,
723
+ reason: Error,
724
+ ): { cancelled: number; settlement: Promise<void> } {
725
+ const matches = [...this.entries].filter(([, session]) => (
726
+ session.nativeThreadId === threadId
727
+ && session.nativeTurnId === turnId
728
+ ));
729
+ for (const [key, session] of matches) {
730
+ if (this.entries.get(key) !== session) continue;
731
+ this.entries.delete(key);
732
+ this.forgetConversationHead(session);
733
+ }
734
+ const settlement = Promise.all(
735
+ matches.map(([key, session]) => this.beginRetirement(key, session, reason)),
736
+ ).then(() => undefined);
737
+ return { cancelled: matches.length, settlement };
738
+ }
739
+
740
+ cancelledError(traceId: string): Error | undefined {
741
+ for (const session of this.entries.values()) {
742
+ if (session.traceId !== traceId) continue;
743
+ const outcome = session.settledOutcome();
744
+ if (outcome?.type !== "error") continue;
745
+ if ("code" in outcome.error && outcome.error.code === "client_cancelled") return outcome.error;
746
+ }
747
+ return undefined;
748
+ }
749
+
750
+ activeCount(): number {
751
+ this.prune();
752
+ let active = 0;
753
+ for (const session of this.entries.values()) if (session.isActive()) active += 1;
754
+ return active;
755
+ }
756
+
757
+ private prune(): void {
758
+ const cutoff = Date.now() - this.ttlMs;
759
+ for (const [key, session] of this.entries) {
760
+ if (session.isActive() || session.lastUsedAt() >= cutoff) continue;
761
+ session.cancel();
762
+ this.entries.delete(key);
763
+ this.forgetConversationHead(session);
764
+ }
765
+ }
766
+
767
+ private forgetConversationHead(session: ChatGptTurnSession): void {
768
+ const conversationKey = session.conversationKey();
769
+ if (conversationKey && this.conversationHeads.get(conversationKey) === session) {
770
+ this.conversationHeads.delete(conversationKey);
771
+ }
772
+ }
773
+
774
+ private beginRetirement(key: string, session: ChatGptTurnSession, reason?: Error): Promise<void> {
775
+ const existing = this.retirements.get(key);
776
+ if (existing) return existing;
777
+ const conversationKey = session.conversationKey();
778
+ session.cancel(reason);
779
+ const retirement = session.physicalSettlement;
780
+ this.retirements.set(key, retirement);
781
+ void retirement.then(() => {
782
+ if (this.retirements.get(key) === retirement) this.retirements.delete(key);
783
+ });
784
+ if (session.ownerKey) {
785
+ const previous = this.ownerRetirements.get(session.ownerKey);
786
+ const ownerRetirement = previous
787
+ ? Promise.all([previous, retirement]).then(() => undefined)
788
+ : retirement;
789
+ this.ownerRetirements.set(session.ownerKey, ownerRetirement);
790
+ void ownerRetirement.then(() => {
791
+ if (this.ownerRetirements.get(session.ownerKey!) === ownerRetirement) {
792
+ this.ownerRetirements.delete(session.ownerKey!);
793
+ }
794
+ });
795
+ }
796
+ if (conversationKey) {
797
+ const previous = this.conversationRetirements.get(conversationKey);
798
+ const conversationRetirement = previous
799
+ ? Promise.all([previous, retirement]).then(() => undefined)
800
+ : retirement;
801
+ this.conversationRetirements.set(conversationKey, conversationRetirement);
802
+ const forgetConversationRetirement = () => {
803
+ if (this.conversationRetirements.get(conversationKey) === conversationRetirement) {
804
+ this.conversationRetirements.delete(conversationKey);
805
+ }
806
+ };
807
+ void conversationRetirement.then(
808
+ forgetConversationRetirement,
809
+ forgetConversationRetirement,
810
+ );
811
+ }
812
+ return retirement;
813
+ }
814
+ }
815
+
816
+ export const chatGptTurnSessions = new ChatGptTurnSessions();