@wlv-zedd/dsh-chatgpt-web 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +140 -140
  3. package/cordis.patch.yml +4 -4
  4. package/lib/cli.js +13269 -11860
  5. package/lib/plugin.js +3 -50
  6. package/package.json +89 -88
  7. package/src/adapters/base.ts +16 -16
  8. package/src/adapters/chatgpt-web/adapter-error.ts +59 -59
  9. package/src/adapters/chatgpt-web/browser-helper-main.ts +513 -513
  10. package/src/adapters/chatgpt-web/browser-helper-prompt-selection.ts +27 -27
  11. package/src/adapters/chatgpt-web/browser-worker.ts +4944 -4944
  12. package/src/adapters/chatgpt-web/codex-rollout-environment.ts +628 -628
  13. package/src/adapters/chatgpt-web/compaction-handoff.ts +533 -533
  14. package/src/adapters/chatgpt-web/compaction-transaction.ts +142 -142
  15. package/src/adapters/chatgpt-web/concurrency.ts +6 -6
  16. package/src/adapters/chatgpt-web/conversation-key.ts +58 -58
  17. package/src/adapters/chatgpt-web/environment.ts +669 -669
  18. package/src/adapters/chatgpt-web/index.ts +1544 -1544
  19. package/src/adapters/chatgpt-web/input-tokens.ts +74 -74
  20. package/src/adapters/chatgpt-web/launcher-helper-client.ts +695 -695
  21. package/src/adapters/chatgpt-web/markdown.ts +418 -418
  22. package/src/adapters/chatgpt-web/mcp-main.ts +25 -25
  23. package/src/adapters/chatgpt-web/mcp-server.ts +933 -933
  24. package/src/adapters/chatgpt-web/model.ts +70 -70
  25. package/src/adapters/chatgpt-web/native-compaction-control.ts +74 -74
  26. package/src/adapters/chatgpt-web/output-validation.ts +62 -62
  27. package/src/adapters/chatgpt-web/process-line-writer.ts +46 -46
  28. package/src/adapters/chatgpt-web/prompt.ts +702 -702
  29. package/src/adapters/chatgpt-web/retry-policy.ts +73 -73
  30. package/src/adapters/chatgpt-web/rolling-checkpoint.ts +384 -384
  31. package/src/adapters/chatgpt-web/thread-environment.ts +238 -238
  32. package/src/adapters/chatgpt-web/tool-stream-parser.ts +1 -1
  33. package/src/adapters/chatgpt-web/turn-broker.ts +1481 -1481
  34. package/src/adapters/chatgpt-web/turn-execution.ts +816 -816
  35. package/src/adapters/chatgpt-web/turn-progress.ts +292 -292
  36. package/src/adapters/chatgpt-web/usage.ts +121 -121
  37. package/src/adapters/image.ts +9 -9
  38. package/src/bridge.ts +1083 -1083
  39. package/src/browser-login.ts +521 -521
  40. package/src/chatgpt-session.ts +240 -240
  41. package/src/chatgpt-web-models.ts +400 -400
  42. package/src/cli.ts +568 -568
  43. package/src/codex-integration-document.ts +824 -824
  44. package/src/codex-integration-journal.ts +212 -212
  45. package/src/codex-integration-route.ts +515 -515
  46. package/src/codex-integration-shared.ts +332 -332
  47. package/src/codex-integration.ts +529 -529
  48. package/src/codex-interrupt-hook.ts +158 -158
  49. package/src/config.ts +616 -616
  50. package/src/dev-chat/cli.ts +432 -432
  51. package/src/dev-chat/constants.ts +3 -3
  52. package/src/dev-chat/driver.ts +655 -655
  53. package/src/dev-chat/profile.ts +223 -223
  54. package/src/dev-chat/session.ts +287 -287
  55. package/src/dev-chat/transport.ts +54 -54
  56. package/src/doctor.ts +237 -237
  57. package/src/event-queue.ts +45 -45
  58. package/src/http-body.ts +30 -30
  59. package/src/launcher-browser-host.ts +695 -695
  60. package/src/lib/errors.ts +281 -281
  61. package/src/lib/token-estimate.ts +42 -42
  62. package/src/login-helper.cjs +1 -1
  63. package/src/model-catalog.ts +197 -197
  64. package/src/native-passthrough.ts +261 -261
  65. package/src/plugin.ts +191 -191
  66. package/src/process.ts +45 -45
  67. package/src/responses/compaction.ts +199 -199
  68. package/src/responses/parser.ts +633 -633
  69. package/src/responses/reasoning-envelope.ts +49 -49
  70. package/src/responses/schema.ts +172 -172
  71. package/src/responses/state.ts +230 -230
  72. package/src/server.ts +1111 -1111
  73. package/src/service.ts +315 -315
  74. package/src/setup.ts +671 -671
  75. package/src/stall-timeout.ts +23 -23
  76. package/src/tunnel-service.ts +160 -160
  77. package/src/tunnel.ts +417 -417
  78. package/src/turndown-plugin-gfm.d.ts +5 -5
  79. package/src/types.ts +307 -307
  80. package/src/usage/totals.ts +12 -12
  81. package/src/version.ts +1 -1
@@ -1,533 +1,533 @@
1
- import { parseDataUrl } from "../image";
2
- import type {
3
- CodexContentPart,
4
- CodexParsedRequest,
5
- CodexToolResultMessage,
6
- } from "../../types";
7
- import { extractChatGptCompactionSourceRevision } from "./environment";
8
- import type { ChatGptBrowserWorker } from "./browser-worker";
9
- import type { CompactionTransactionHandle } from "./compaction-transaction";
10
- import type { ChatGptWebCapabilities } from "./model";
11
- import {
12
- activeCompactionToolResultInstruction,
13
- structuredCompactionHandoffInstruction,
14
- zeroRiskActiveCompactionToolResultInstruction,
15
- } from "./native-compaction-control";
16
- import type { BrokerToolResult, TurnBroker, TurnBrokerOwner } from "./turn-broker";
17
- import type { ChatGptTurnSession } from "./turn-execution";
18
-
19
- export const LATEST_USER_PROMPT_MARKER = "CODEX_LATEST_USER_PROMPT_JSON";
20
-
21
- function brokerContent(content: string | CodexContentPart[]): unknown[] {
22
- if (typeof content === "string") return [{ type: "text", text: content }];
23
- return content.map(part => {
24
- if (part.type === "text") return { type: "text", text: part.text };
25
- const parsed = parseDataUrl(part.imageUrl);
26
- if (parsed) return { type: "image", data: parsed.base64, mimeType: parsed.mediaType };
27
- return { type: "resource_link", uri: part.imageUrl, name: "Codex tool image", mimeType: "image/*" };
28
- });
29
- }
30
-
31
- function structuredContent(text: string): unknown | undefined {
32
- try {
33
- const parsed: unknown = JSON.parse(text);
34
- return parsed !== null && typeof parsed === "object" ? parsed : undefined;
35
- } catch {
36
- return undefined;
37
- }
38
- }
39
-
40
- function toolResult(message: CodexToolResultMessage): BrokerToolResult {
41
- const content = brokerContent(message.content);
42
- const text = typeof message.content === "string"
43
- ? message.content
44
- : message.content.filter(part => part.type === "text").map(part => part.text).join("\n");
45
- const structured = structuredContent(text);
46
- return {
47
- content,
48
- ...(structured !== undefined ? { structuredContent: structured } : {}),
49
- ...(message.isError ? { isError: true } : {}),
50
- };
51
- }
52
-
53
- function interruptedByActiveCompaction(): BrokerToolResult {
54
- return {
55
- content: [{ type: "text", text: activeCompactionToolResultInstruction() }],
56
- isError: true,
57
- };
58
- }
59
-
60
- function withZeroRiskCompactionInstruction(result: BrokerToolResult): BrokerToolResult {
61
- return {
62
- ...result,
63
- content: [
64
- ...result.content,
65
- {
66
- type: "text",
67
- text: zeroRiskActiveCompactionToolResultInstruction(true),
68
- },
69
- ],
70
- };
71
- }
72
-
73
- function interruptedByZeroRiskCompaction(): BrokerToolResult {
74
- return {
75
- content: [{
76
- type: "text",
77
- text: zeroRiskActiveCompactionToolResultInstruction(false),
78
- }],
79
- isError: true,
80
- };
81
- }
82
-
83
- function userPromptText(content: unknown): string | undefined {
84
- if (typeof content === "string") return content;
85
- if (!Array.isArray(content)) return undefined;
86
- const text = content.flatMap(part => {
87
- if (!part || typeof part !== "object" || Array.isArray(part)) return [];
88
- const value = part as { type?: unknown; text?: unknown };
89
- return (value.type === "input_text" || value.type === "text") && typeof value.text === "string"
90
- ? [value.text]
91
- : [];
92
- }).join("\n");
93
- return text || undefined;
94
- }
95
-
96
- export function canonicalizeCompactionHandoff(
97
- parsed: CodexParsedRequest,
98
- summary: string,
99
- ): string {
100
- const normalized = summary.trim();
101
- if (!normalized) throw new Error("ChatGPT returned an empty structured compaction handoff");
102
- const latestUserPrompt = userPromptText(extractChatGptCompactionSourceRevision(parsed).content);
103
- if (latestUserPrompt === undefined) {
104
- throw new Error("ChatGPT compaction source has no canonical latest user prompt");
105
- }
106
- const appendix = `${LATEST_USER_PROMPT_MARKER}\n${JSON.stringify(latestUserPrompt)}`;
107
- const markerOffset = normalized.lastIndexOf(`\n${LATEST_USER_PROMPT_MARKER}\n`);
108
- if (markerOffset < 0) return `${normalized}\n\n${appendix}`;
109
- if (normalized.slice(markerOffset + 1).trimEnd() !== appendix) {
110
- throw new Error("ChatGPT compaction handoff contains a conflicting latest-user marker");
111
- }
112
- return normalized;
113
- }
114
-
115
- function currentToolResults(
116
- parsed: CodexParsedRequest,
117
- session: ChatGptTurnSession,
118
- ): Map<string, CodexToolResultMessage> {
119
- const results = new Map<string, CodexToolResultMessage>();
120
- for (const message of parsed.context.messages) {
121
- if (message.role !== "toolResult" || !session.hasOutstanding(message.toolCallId)) continue;
122
- if (results.has(message.toolCallId)) {
123
- throw new Error(`Codex returned duplicate results for tool call ${message.toolCallId}`);
124
- }
125
- results.set(message.toolCallId, message);
126
- }
127
- return results;
128
- }
129
-
130
- export const MAX_COMPACTION_HANDOFF_TIMEOUT_MS = 5 * 60_000;
131
-
132
- function boundedCompactionTimeout(timeoutMs: number): number {
133
- return Math.min(timeoutMs, MAX_COMPACTION_HANDOFF_TIMEOUT_MS);
134
- }
135
-
136
- function abortReason(signal: AbortSignal): Error {
137
- return signal.reason instanceof Error
138
- ? signal.reason
139
- : new DOMException("ChatGPT compaction handoff aborted", "AbortError");
140
- }
141
-
142
- function withCompactionAbort<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
143
- if (!signal) return promise;
144
- if (signal.aborted) return Promise.reject(abortReason(signal));
145
- return new Promise<T>((resolve, reject) => {
146
- const onAbort = () => reject(abortReason(signal));
147
- signal.addEventListener("abort", onAbort, { once: true });
148
- promise.then(
149
- value => {
150
- signal.removeEventListener("abort", onAbort);
151
- resolve(value);
152
- },
153
- error => {
154
- signal.removeEventListener("abort", onAbort);
155
- reject(error);
156
- },
157
- );
158
- });
159
- }
160
-
161
- export async function settleActiveCompactionSource(
162
- parsed: CodexParsedRequest,
163
- source: ChatGptTurnSession,
164
- broker: TurnBroker,
165
- signal?: AbortSignal,
166
- ): Promise<{ answer: string; compactionInstructionDelivered: boolean }> {
167
- return source.runExclusive(async () => {
168
- if (signal?.aborted) {
169
- source.cancel(abortReason(signal));
170
- throw abortReason(signal);
171
- }
172
- if (!source.isActive() || source.runtime.mode !== "tools") {
173
- throw new Error("The active ChatGPT compaction source has no MCP tool boundary");
174
- }
175
- const outstanding = source.outstanding();
176
- const results = currentToolResults(parsed, source);
177
- if (results.size !== outstanding.length) {
178
- throw new Error(
179
- `Codex supplied ${results.size} of ${outstanding.length} required tool results for compaction`,
180
- );
181
- }
182
- let token: string | undefined;
183
- try {
184
- token = await source.runtime.token;
185
- broker.requestCompaction(token, interruptedByActiveCompaction());
186
- for (const request of outstanding) {
187
- const result = results.get(request.callId)!;
188
- await broker.completeTool(
189
- token,
190
- request.callId,
191
- toolResult(result),
192
- );
193
- source.runtime.externalProgress.recordToolResult();
194
- source.markResultDelivered(request.callId);
195
- }
196
- const browserOutcome = await withCompactionAbort(source.browserOutcome, signal);
197
- if (browserOutcome.type === "error") throw browserOutcome.error;
198
- const compactionInstructionDelivered = broker.compactionDeliveryCount(token) > 0;
199
- // The one structured checkpoint message reuses this exact retained tab. It must not race the
200
- // helper's /turn/end handshake for the response that consumed the canonical tool results.
201
- // `requestCompaction` leaves those results untouched and only intercepts a later tool call, so
202
- // a zero delivery count proves that this is an ordinary publishable terminal response.
203
- await withCompactionAbort(source.physicalSettlement, signal);
204
- return {
205
- answer: browserOutcome.answer,
206
- compactionInstructionDelivered,
207
- };
208
- } catch (error) {
209
- if (signal?.aborted) source.cancel(abortReason(signal));
210
- throw error;
211
- } finally {
212
- if (token) await broker.revoke(token);
213
- }
214
- });
215
- }
216
-
217
- export async function settleActiveZeroRiskCompactionSource(
218
- parsed: CodexParsedRequest,
219
- source: ChatGptTurnSession,
220
- broker: TurnBrokerOwner,
221
- signal?: AbortSignal,
222
- ): Promise<string | undefined> {
223
- return source.runExclusive(async () => {
224
- if (signal?.aborted) {
225
- source.cancel(abortReason(signal));
226
- throw abortReason(signal);
227
- }
228
- if (!source.isActive() || source.runtime.mode !== "tools" || !source.runtime.manualControl) {
229
- throw new Error("The active Zero Risk compaction source has no manual MCP tool boundary");
230
- }
231
- const outstanding = source.outstanding();
232
- const results = currentToolResults(parsed, source);
233
- if (results.size !== outstanding.length) {
234
- throw new Error(
235
- `Codex supplied ${results.size} of ${outstanding.length} required tool results for Zero Risk compaction`,
236
- );
237
- }
238
- let token: string | undefined;
239
- try {
240
- token = await source.runtime.token;
241
- const interruptedQueued = await broker.requestCompaction(
242
- token,
243
- interruptedByZeroRiskCompaction(),
244
- );
245
- for (const [index, request] of outstanding.entries()) {
246
- const result = results.get(request.callId)!;
247
- const canonical = toolResult(result);
248
- await broker.completeTool(
249
- token,
250
- request.callId,
251
- interruptedQueued === 0 && index === outstanding.length - 1
252
- ? withZeroRiskCompactionInstruction(canonical)
253
- : canonical,
254
- );
255
- source.runtime.externalProgress.recordToolResult();
256
- source.markResultDelivered(request.callId);
257
- }
258
- const browserOutcome = await withCompactionAbort(source.browserOutcome, signal);
259
- if (browserOutcome.type === "error") throw browserOutcome.error;
260
- await withCompactionAbort(source.physicalSettlement, signal);
261
- const instructionDelivered = outstanding.length > 0
262
- || await broker.compactionDeliveryCount(token) > 0;
263
- if (!instructionDelivered) return undefined;
264
- const summary = browserOutcome.answer.trim();
265
- if (!summary) throw new Error("The active Zero Risk response returned an empty compaction summary");
266
- return summary;
267
- } catch (error) {
268
- if (signal?.aborted) source.cancel(abortReason(signal));
269
- throw error;
270
- } finally {
271
- if (token) await broker.revoke(token);
272
- }
273
- });
274
- }
275
-
276
- export async function requestRetainedCompactionHandoff(
277
- worker: ChatGptBrowserWorker,
278
- parsed: CodexParsedRequest,
279
- source: ChatGptTurnSession,
280
- broker: TurnBroker,
281
- capabilities: ChatGptWebCapabilities,
282
- traceId: string,
283
- signal?: AbortSignal,
284
- timeoutMs = MAX_COMPACTION_HANDOFF_TIMEOUT_MS,
285
- ): Promise<string> {
286
- const conversationKey = source.conversationKey();
287
- if (!conversationKey) throw new Error("The completed ChatGPT source has no retained conversation identity");
288
- const operationTimeoutMs = boundedCompactionTimeout(timeoutMs);
289
- const deadline = new AbortController();
290
- const deadlineTimer = setTimeout(
291
- () => deadline.abort(new Error(`ChatGPT compaction handoff timed out after ${operationTimeoutMs}ms`)),
292
- operationTimeoutMs,
293
- );
294
- deadlineTimer.unref?.();
295
- const operationSignal = signal
296
- ? AbortSignal.any([signal, deadline.signal])
297
- : deadline.signal;
298
- const browserAbort = new AbortController();
299
- const abortBrowser = () => browserAbort.abort(operationSignal.reason);
300
- let transaction: CompactionTransactionHandle | undefined;
301
- let browser: Promise<string> | undefined;
302
- if (operationSignal.aborted) abortBrowser();
303
- else operationSignal.addEventListener("abort", abortBrowser, { once: true });
304
- try {
305
- const transactionPromise = broker.beginCompactionTransaction(traceId, operationTimeoutMs);
306
- void transactionPromise.then(lateTransaction => {
307
- if (operationSignal.aborted && transaction !== lateTransaction) {
308
- broker.abortCompactionTransaction(lateTransaction.token);
309
- }
310
- }, () => {});
311
- transaction = await withCompactionAbort(transactionPromise, operationSignal);
312
- const instruction = structuredCompactionHandoffInstruction(transaction);
313
- const prepare = async () => ({ text: instruction, images: [], release: () => {} });
314
- browser = worker.run({
315
- traceId,
316
- modelId: parsed.modelId,
317
- reasoning: parsed.options.reasoning,
318
- // The retained connector exposes only the one-shot control token embedded above. It does
319
- // not receive an ordinary Codex tool environment for this checkpoint message.
320
- capabilities: { ...capabilities, localToolsEnabled: false },
321
- nativeConnector: true,
322
- prepare,
323
- prepareResume: prepare,
324
- conversationKey,
325
- requireRetainedConversation: true,
326
- abortSignal: browserAbort.signal,
327
- onTextDelta: () => {},
328
- });
329
- const browserFailure = browser.then<never>(
330
- () => new Promise<never>(() => {}),
331
- error => { throw error; },
332
- );
333
- const summary = await withCompactionAbort(
334
- Promise.race([
335
- broker.waitForCompactionHandoff(transaction.token, operationSignal),
336
- browserFailure,
337
- ]),
338
- operationSignal,
339
- );
340
- // The one-shot control submission is the terminal event for this purpose-built response.
341
- // ChatGPT may render no assistant text after a tool-only response, and therefore no Copy
342
- // action. End our owned turn explicitly and wait for the launcher/helper cleanup handshake.
343
- browserAbort.abort(new DOMException("Structured compaction handoff accepted", "AbortError"));
344
- await withCompactionAbort(
345
- browser.then(() => undefined, () => undefined),
346
- operationSignal,
347
- );
348
- return summary;
349
- } finally {
350
- browserAbort.abort();
351
- if (transaction) broker.abortCompactionTransaction(transaction.token);
352
- if (browser) {
353
- // Logical cancellation is not physical retirement. The retained-session owner tracks
354
- // physical settlement separately, so this helper must not turn its own deadline into an
355
- // unbounded wait when the worker does not acknowledge abort immediately.
356
- await withCompactionAbort(
357
- browser.then(() => undefined, () => undefined),
358
- operationSignal,
359
- ).catch(() => {});
360
- }
361
- operationSignal.removeEventListener("abort", abortBrowser);
362
- clearTimeout(deadlineTimer);
363
- }
364
- }
365
-
366
- interface CachedCompactionRun {
367
- createdAt: number;
368
- ownerKey: string;
369
- traceIds: ReadonlySet<string>;
370
- nativeThreadId?: string;
371
- nativeTurnId?: string;
372
- abort: AbortController;
373
- active: boolean;
374
- promise: Promise<string>;
375
- }
376
-
377
- interface StructuredCompactionInterruption {
378
- createdAt: number;
379
- reason: Error;
380
- }
381
-
382
- export interface StructuredCompactionOwner {
383
- ownerKey: string;
384
- /** Every externally addressable browser trace owned by this structured compaction. */
385
- traceIds: readonly string[];
386
- /** Exact native Codex owner, when supplied by the current Responses request. */
387
- nativeThreadId?: string;
388
- nativeTurnId?: string;
389
- }
390
-
391
- const structuredCompactionRuns = new Map<string, CachedCompactionRun>();
392
- const structuredCompactionOwners = new Map<string, Promise<void>>();
393
- const structuredCompactionInterruptions = new Map<string, StructuredCompactionInterruption>();
394
- const STRUCTURED_COMPACTION_RUN_TTL_MS = 30 * 60_000;
395
-
396
- function nativeTurnIdentityKey(threadId: string, turnId: string): string {
397
- if (!threadId.trim() || !turnId.trim()) {
398
- throw new Error("Structured compaction requires non-empty native thread and turn ids");
399
- }
400
- return JSON.stringify([threadId, turnId]);
401
- }
402
-
403
- function rememberStructuredCompactionInterruption(threadId: string, turnId: string, reason: Error): void {
404
- const identity = nativeTurnIdentityKey(threadId, turnId);
405
- const now = Date.now();
406
- pruneStructuredCompactionInterruptions(now);
407
- const existing = structuredCompactionInterruptions.get(identity);
408
- if (existing) {
409
- existing.createdAt = now;
410
- return;
411
- }
412
- structuredCompactionInterruptions.set(identity, { createdAt: now, reason });
413
- }
414
-
415
- function structuredCompactionInterruption(owner: StructuredCompactionOwner): Error | undefined {
416
- if (owner.nativeThreadId === undefined && owner.nativeTurnId === undefined) return undefined;
417
- pruneStructuredCompactionInterruptions();
418
- return structuredCompactionInterruptions.get(
419
- nativeTurnIdentityKey(owner.nativeThreadId ?? "", owner.nativeTurnId ?? ""),
420
- )?.reason;
421
- }
422
-
423
- function pruneStructuredCompactionInterruptions(now = Date.now()): void {
424
- const cutoff = now - STRUCTURED_COMPACTION_RUN_TTL_MS;
425
- for (const [identity, interruption] of structuredCompactionInterruptions) {
426
- if (interruption.createdAt < cutoff) structuredCompactionInterruptions.delete(identity);
427
- }
428
- }
429
-
430
- function pruneStructuredCompactionRuns(): void {
431
- const now = Date.now();
432
- const cutoff = now - STRUCTURED_COMPACTION_RUN_TTL_MS;
433
- for (const [candidate, run] of structuredCompactionRuns) {
434
- if (run.createdAt < cutoff) structuredCompactionRuns.delete(candidate);
435
- }
436
- pruneStructuredCompactionInterruptions(now);
437
- }
438
-
439
- /** Return the canonical result of an exact compact request, even after its source was retired. */
440
- export function existingStructuredCompactionRun(key: string): Promise<string> | undefined {
441
- pruneStructuredCompactionRuns();
442
- return structuredCompactionRuns.get(key)?.promise;
443
- }
444
-
445
- export function runStructuredCompactionOnce(
446
- key: string,
447
- owner: StructuredCompactionOwner,
448
- start: (operatorSignal: AbortSignal) => Promise<string>,
449
- ): Promise<string> {
450
- pruneStructuredCompactionRuns();
451
- const existing = structuredCompactionRuns.get(key);
452
- if (existing) return existing.promise;
453
- const interrupted = structuredCompactionInterruption(owner);
454
- if (interrupted) return Promise.reject(interrupted);
455
- const abort = new AbortController();
456
- const previousOwner = structuredCompactionOwners.get(owner.ownerKey);
457
- const promise = Promise.resolve().then(async () => {
458
- if (previousOwner) await withCompactionAbort(previousOwner, abort.signal);
459
- if (abort.signal.aborted) throw abortReason(abort.signal);
460
- return start(abort.signal);
461
- });
462
- const run: CachedCompactionRun = {
463
- createdAt: Date.now(),
464
- ownerKey: owner.ownerKey,
465
- traceIds: new Set(owner.traceIds),
466
- ...(owner.nativeThreadId ? { nativeThreadId: owner.nativeThreadId } : {}),
467
- ...(owner.nativeTurnId ? { nativeTurnId: owner.nativeTurnId } : {}),
468
- abort,
469
- active: true,
470
- promise,
471
- };
472
- structuredCompactionRuns.set(key, run);
473
- const ownerSettlement = promise.then(() => undefined, () => undefined);
474
- structuredCompactionOwners.set(owner.ownerKey, ownerSettlement);
475
- void ownerSettlement.then(() => {
476
- run.active = false;
477
- if (structuredCompactionOwners.get(owner.ownerKey) === ownerSettlement) {
478
- structuredCompactionOwners.delete(owner.ownerKey);
479
- }
480
- });
481
- void promise.catch(() => {
482
- if (structuredCompactionRuns.get(key)?.promise === promise) {
483
- structuredCompactionRuns.delete(key);
484
- }
485
- });
486
- return promise;
487
- }
488
-
489
- async function cancelStructuredCompactionRuns(
490
- matches: (run: CachedCompactionRun) => boolean,
491
- reason: Error,
492
- ): Promise<number> {
493
- const runs = [...structuredCompactionRuns.values()].filter(run => run.active && matches(run));
494
- for (const run of runs) {
495
- if (!run.abort.signal.aborted) run.abort.abort(reason);
496
- }
497
- await Promise.allSettled(runs.map(run => run.promise));
498
- return runs.length;
499
- }
500
-
501
- /** Begin cancelling the structured compaction owned by one exact native Codex turn. */
502
- export function cancelStructuredCompactionNativeTurn(
503
- threadId: string,
504
- turnId: string,
505
- reason: Error,
506
- ): { cancelled: number; settlement: Promise<void> } {
507
- // Record before scanning active owners. Registration and cancellation share this synchronous
508
- // boundary, so either registration wins and is aborted below, or interruption wins and the later
509
- // registration rejects without invoking its detached work.
510
- rememberStructuredCompactionInterruption(threadId, turnId, reason);
511
- const runs = [...structuredCompactionRuns.values()].filter(run => (
512
- run.active
513
- && run.nativeThreadId === threadId
514
- && run.nativeTurnId === turnId
515
- ));
516
- for (const run of runs) {
517
- if (!run.abort.signal.aborted) run.abort.abort(reason);
518
- }
519
- return {
520
- cancelled: runs.length,
521
- settlement: Promise.allSettled(runs.map(run => run.promise)).then(() => undefined),
522
- };
523
- }
524
-
525
- /** Cancel a user-requested compaction without treating an HTTP observer disconnect as terminal. */
526
- export function cancelStructuredCompactionTrace(traceId: string, reason: Error): Promise<number> {
527
- return cancelStructuredCompactionRuns(run => run.traceIds.has(traceId), reason);
528
- }
529
-
530
- /** Cancel every active compaction owner and wait for its browser/helper cleanup. */
531
- export function cancelAllStructuredCompactions(reason: Error): Promise<number> {
532
- return cancelStructuredCompactionRuns(() => true, reason);
533
- }
1
+ import { parseDataUrl } from "../image";
2
+ import type {
3
+ CodexContentPart,
4
+ CodexParsedRequest,
5
+ CodexToolResultMessage,
6
+ } from "../../types";
7
+ import { extractChatGptCompactionSourceRevision } from "./environment";
8
+ import type { ChatGptBrowserWorker } from "./browser-worker";
9
+ import type { CompactionTransactionHandle } from "./compaction-transaction";
10
+ import type { ChatGptWebCapabilities } from "./model";
11
+ import {
12
+ activeCompactionToolResultInstruction,
13
+ structuredCompactionHandoffInstruction,
14
+ zeroRiskActiveCompactionToolResultInstruction,
15
+ } from "./native-compaction-control";
16
+ import type { BrokerToolResult, TurnBroker, TurnBrokerOwner } from "./turn-broker";
17
+ import type { ChatGptTurnSession } from "./turn-execution";
18
+
19
+ export const LATEST_USER_PROMPT_MARKER = "CODEX_LATEST_USER_PROMPT_JSON";
20
+
21
+ function brokerContent(content: string | CodexContentPart[]): unknown[] {
22
+ if (typeof content === "string") return [{ type: "text", text: content }];
23
+ return content.map(part => {
24
+ if (part.type === "text") return { type: "text", text: part.text };
25
+ const parsed = parseDataUrl(part.imageUrl);
26
+ if (parsed) return { type: "image", data: parsed.base64, mimeType: parsed.mediaType };
27
+ return { type: "resource_link", uri: part.imageUrl, name: "Codex tool image", mimeType: "image/*" };
28
+ });
29
+ }
30
+
31
+ function structuredContent(text: string): unknown | undefined {
32
+ try {
33
+ const parsed: unknown = JSON.parse(text);
34
+ return parsed !== null && typeof parsed === "object" ? parsed : undefined;
35
+ } catch {
36
+ return undefined;
37
+ }
38
+ }
39
+
40
+ function toolResult(message: CodexToolResultMessage): BrokerToolResult {
41
+ const content = brokerContent(message.content);
42
+ const text = typeof message.content === "string"
43
+ ? message.content
44
+ : message.content.filter(part => part.type === "text").map(part => part.text).join("\n");
45
+ const structured = structuredContent(text);
46
+ return {
47
+ content,
48
+ ...(structured !== undefined ? { structuredContent: structured } : {}),
49
+ ...(message.isError ? { isError: true } : {}),
50
+ };
51
+ }
52
+
53
+ function interruptedByActiveCompaction(): BrokerToolResult {
54
+ return {
55
+ content: [{ type: "text", text: activeCompactionToolResultInstruction() }],
56
+ isError: true,
57
+ };
58
+ }
59
+
60
+ function withZeroRiskCompactionInstruction(result: BrokerToolResult): BrokerToolResult {
61
+ return {
62
+ ...result,
63
+ content: [
64
+ ...result.content,
65
+ {
66
+ type: "text",
67
+ text: zeroRiskActiveCompactionToolResultInstruction(true),
68
+ },
69
+ ],
70
+ };
71
+ }
72
+
73
+ function interruptedByZeroRiskCompaction(): BrokerToolResult {
74
+ return {
75
+ content: [{
76
+ type: "text",
77
+ text: zeroRiskActiveCompactionToolResultInstruction(false),
78
+ }],
79
+ isError: true,
80
+ };
81
+ }
82
+
83
+ function userPromptText(content: unknown): string | undefined {
84
+ if (typeof content === "string") return content;
85
+ if (!Array.isArray(content)) return undefined;
86
+ const text = content.flatMap(part => {
87
+ if (!part || typeof part !== "object" || Array.isArray(part)) return [];
88
+ const value = part as { type?: unknown; text?: unknown };
89
+ return (value.type === "input_text" || value.type === "text") && typeof value.text === "string"
90
+ ? [value.text]
91
+ : [];
92
+ }).join("\n");
93
+ return text || undefined;
94
+ }
95
+
96
+ export function canonicalizeCompactionHandoff(
97
+ parsed: CodexParsedRequest,
98
+ summary: string,
99
+ ): string {
100
+ const normalized = summary.trim();
101
+ if (!normalized) throw new Error("ChatGPT returned an empty structured compaction handoff");
102
+ const latestUserPrompt = userPromptText(extractChatGptCompactionSourceRevision(parsed).content);
103
+ if (latestUserPrompt === undefined) {
104
+ throw new Error("ChatGPT compaction source has no canonical latest user prompt");
105
+ }
106
+ const appendix = `${LATEST_USER_PROMPT_MARKER}\n${JSON.stringify(latestUserPrompt)}`;
107
+ const markerOffset = normalized.lastIndexOf(`\n${LATEST_USER_PROMPT_MARKER}\n`);
108
+ if (markerOffset < 0) return `${normalized}\n\n${appendix}`;
109
+ if (normalized.slice(markerOffset + 1).trimEnd() !== appendix) {
110
+ throw new Error("ChatGPT compaction handoff contains a conflicting latest-user marker");
111
+ }
112
+ return normalized;
113
+ }
114
+
115
+ function currentToolResults(
116
+ parsed: CodexParsedRequest,
117
+ session: ChatGptTurnSession,
118
+ ): Map<string, CodexToolResultMessage> {
119
+ const results = new Map<string, CodexToolResultMessage>();
120
+ for (const message of parsed.context.messages) {
121
+ if (message.role !== "toolResult" || !session.hasOutstanding(message.toolCallId)) continue;
122
+ if (results.has(message.toolCallId)) {
123
+ throw new Error(`Codex returned duplicate results for tool call ${message.toolCallId}`);
124
+ }
125
+ results.set(message.toolCallId, message);
126
+ }
127
+ return results;
128
+ }
129
+
130
+ export const MAX_COMPACTION_HANDOFF_TIMEOUT_MS = 5 * 60_000;
131
+
132
+ function boundedCompactionTimeout(timeoutMs: number): number {
133
+ return Math.min(timeoutMs, MAX_COMPACTION_HANDOFF_TIMEOUT_MS);
134
+ }
135
+
136
+ function abortReason(signal: AbortSignal): Error {
137
+ return signal.reason instanceof Error
138
+ ? signal.reason
139
+ : new DOMException("ChatGPT compaction handoff aborted", "AbortError");
140
+ }
141
+
142
+ function withCompactionAbort<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
143
+ if (!signal) return promise;
144
+ if (signal.aborted) return Promise.reject(abortReason(signal));
145
+ return new Promise<T>((resolve, reject) => {
146
+ const onAbort = () => reject(abortReason(signal));
147
+ signal.addEventListener("abort", onAbort, { once: true });
148
+ promise.then(
149
+ value => {
150
+ signal.removeEventListener("abort", onAbort);
151
+ resolve(value);
152
+ },
153
+ error => {
154
+ signal.removeEventListener("abort", onAbort);
155
+ reject(error);
156
+ },
157
+ );
158
+ });
159
+ }
160
+
161
+ export async function settleActiveCompactionSource(
162
+ parsed: CodexParsedRequest,
163
+ source: ChatGptTurnSession,
164
+ broker: TurnBroker,
165
+ signal?: AbortSignal,
166
+ ): Promise<{ answer: string; compactionInstructionDelivered: boolean }> {
167
+ return source.runExclusive(async () => {
168
+ if (signal?.aborted) {
169
+ source.cancel(abortReason(signal));
170
+ throw abortReason(signal);
171
+ }
172
+ if (!source.isActive() || source.runtime.mode !== "tools") {
173
+ throw new Error("The active ChatGPT compaction source has no MCP tool boundary");
174
+ }
175
+ const outstanding = source.outstanding();
176
+ const results = currentToolResults(parsed, source);
177
+ if (results.size !== outstanding.length) {
178
+ throw new Error(
179
+ `Codex supplied ${results.size} of ${outstanding.length} required tool results for compaction`,
180
+ );
181
+ }
182
+ let token: string | undefined;
183
+ try {
184
+ token = await source.runtime.token;
185
+ broker.requestCompaction(token, interruptedByActiveCompaction());
186
+ for (const request of outstanding) {
187
+ const result = results.get(request.callId)!;
188
+ await broker.completeTool(
189
+ token,
190
+ request.callId,
191
+ toolResult(result),
192
+ );
193
+ source.runtime.externalProgress.recordToolResult();
194
+ source.markResultDelivered(request.callId);
195
+ }
196
+ const browserOutcome = await withCompactionAbort(source.browserOutcome, signal);
197
+ if (browserOutcome.type === "error") throw browserOutcome.error;
198
+ const compactionInstructionDelivered = broker.compactionDeliveryCount(token) > 0;
199
+ // The one structured checkpoint message reuses this exact retained tab. It must not race the
200
+ // helper's /turn/end handshake for the response that consumed the canonical tool results.
201
+ // `requestCompaction` leaves those results untouched and only intercepts a later tool call, so
202
+ // a zero delivery count proves that this is an ordinary publishable terminal response.
203
+ await withCompactionAbort(source.physicalSettlement, signal);
204
+ return {
205
+ answer: browserOutcome.answer,
206
+ compactionInstructionDelivered,
207
+ };
208
+ } catch (error) {
209
+ if (signal?.aborted) source.cancel(abortReason(signal));
210
+ throw error;
211
+ } finally {
212
+ if (token) await broker.revoke(token);
213
+ }
214
+ });
215
+ }
216
+
217
+ export async function settleActiveZeroRiskCompactionSource(
218
+ parsed: CodexParsedRequest,
219
+ source: ChatGptTurnSession,
220
+ broker: TurnBrokerOwner,
221
+ signal?: AbortSignal,
222
+ ): Promise<string | undefined> {
223
+ return source.runExclusive(async () => {
224
+ if (signal?.aborted) {
225
+ source.cancel(abortReason(signal));
226
+ throw abortReason(signal);
227
+ }
228
+ if (!source.isActive() || source.runtime.mode !== "tools" || !source.runtime.manualControl) {
229
+ throw new Error("The active Zero Risk compaction source has no manual MCP tool boundary");
230
+ }
231
+ const outstanding = source.outstanding();
232
+ const results = currentToolResults(parsed, source);
233
+ if (results.size !== outstanding.length) {
234
+ throw new Error(
235
+ `Codex supplied ${results.size} of ${outstanding.length} required tool results for Zero Risk compaction`,
236
+ );
237
+ }
238
+ let token: string | undefined;
239
+ try {
240
+ token = await source.runtime.token;
241
+ const interruptedQueued = await broker.requestCompaction(
242
+ token,
243
+ interruptedByZeroRiskCompaction(),
244
+ );
245
+ for (const [index, request] of outstanding.entries()) {
246
+ const result = results.get(request.callId)!;
247
+ const canonical = toolResult(result);
248
+ await broker.completeTool(
249
+ token,
250
+ request.callId,
251
+ interruptedQueued === 0 && index === outstanding.length - 1
252
+ ? withZeroRiskCompactionInstruction(canonical)
253
+ : canonical,
254
+ );
255
+ source.runtime.externalProgress.recordToolResult();
256
+ source.markResultDelivered(request.callId);
257
+ }
258
+ const browserOutcome = await withCompactionAbort(source.browserOutcome, signal);
259
+ if (browserOutcome.type === "error") throw browserOutcome.error;
260
+ await withCompactionAbort(source.physicalSettlement, signal);
261
+ const instructionDelivered = outstanding.length > 0
262
+ || await broker.compactionDeliveryCount(token) > 0;
263
+ if (!instructionDelivered) return undefined;
264
+ const summary = browserOutcome.answer.trim();
265
+ if (!summary) throw new Error("The active Zero Risk response returned an empty compaction summary");
266
+ return summary;
267
+ } catch (error) {
268
+ if (signal?.aborted) source.cancel(abortReason(signal));
269
+ throw error;
270
+ } finally {
271
+ if (token) await broker.revoke(token);
272
+ }
273
+ });
274
+ }
275
+
276
+ export async function requestRetainedCompactionHandoff(
277
+ worker: ChatGptBrowserWorker,
278
+ parsed: CodexParsedRequest,
279
+ source: ChatGptTurnSession,
280
+ broker: TurnBroker,
281
+ capabilities: ChatGptWebCapabilities,
282
+ traceId: string,
283
+ signal?: AbortSignal,
284
+ timeoutMs = MAX_COMPACTION_HANDOFF_TIMEOUT_MS,
285
+ ): Promise<string> {
286
+ const conversationKey = source.conversationKey();
287
+ if (!conversationKey) throw new Error("The completed ChatGPT source has no retained conversation identity");
288
+ const operationTimeoutMs = boundedCompactionTimeout(timeoutMs);
289
+ const deadline = new AbortController();
290
+ const deadlineTimer = setTimeout(
291
+ () => deadline.abort(new Error(`ChatGPT compaction handoff timed out after ${operationTimeoutMs}ms`)),
292
+ operationTimeoutMs,
293
+ );
294
+ deadlineTimer.unref?.();
295
+ const operationSignal = signal
296
+ ? AbortSignal.any([signal, deadline.signal])
297
+ : deadline.signal;
298
+ const browserAbort = new AbortController();
299
+ const abortBrowser = () => browserAbort.abort(operationSignal.reason);
300
+ let transaction: CompactionTransactionHandle | undefined;
301
+ let browser: Promise<string> | undefined;
302
+ if (operationSignal.aborted) abortBrowser();
303
+ else operationSignal.addEventListener("abort", abortBrowser, { once: true });
304
+ try {
305
+ const transactionPromise = broker.beginCompactionTransaction(traceId, operationTimeoutMs);
306
+ void transactionPromise.then(lateTransaction => {
307
+ if (operationSignal.aborted && transaction !== lateTransaction) {
308
+ broker.abortCompactionTransaction(lateTransaction.token);
309
+ }
310
+ }, () => {});
311
+ transaction = await withCompactionAbort(transactionPromise, operationSignal);
312
+ const instruction = structuredCompactionHandoffInstruction(transaction);
313
+ const prepare = async () => ({ text: instruction, images: [], release: () => {} });
314
+ browser = worker.run({
315
+ traceId,
316
+ modelId: parsed.modelId,
317
+ reasoning: parsed.options.reasoning,
318
+ // The retained connector exposes only the one-shot control token embedded above. It does
319
+ // not receive an ordinary Codex tool environment for this checkpoint message.
320
+ capabilities: { ...capabilities, localToolsEnabled: false },
321
+ nativeConnector: true,
322
+ prepare,
323
+ prepareResume: prepare,
324
+ conversationKey,
325
+ requireRetainedConversation: true,
326
+ abortSignal: browserAbort.signal,
327
+ onTextDelta: () => {},
328
+ });
329
+ const browserFailure = browser.then<never>(
330
+ () => new Promise<never>(() => {}),
331
+ error => { throw error; },
332
+ );
333
+ const summary = await withCompactionAbort(
334
+ Promise.race([
335
+ broker.waitForCompactionHandoff(transaction.token, operationSignal),
336
+ browserFailure,
337
+ ]),
338
+ operationSignal,
339
+ );
340
+ // The one-shot control submission is the terminal event for this purpose-built response.
341
+ // ChatGPT may render no assistant text after a tool-only response, and therefore no Copy
342
+ // action. End our owned turn explicitly and wait for the launcher/helper cleanup handshake.
343
+ browserAbort.abort(new DOMException("Structured compaction handoff accepted", "AbortError"));
344
+ await withCompactionAbort(
345
+ browser.then(() => undefined, () => undefined),
346
+ operationSignal,
347
+ );
348
+ return summary;
349
+ } finally {
350
+ browserAbort.abort();
351
+ if (transaction) broker.abortCompactionTransaction(transaction.token);
352
+ if (browser) {
353
+ // Logical cancellation is not physical retirement. The retained-session owner tracks
354
+ // physical settlement separately, so this helper must not turn its own deadline into an
355
+ // unbounded wait when the worker does not acknowledge abort immediately.
356
+ await withCompactionAbort(
357
+ browser.then(() => undefined, () => undefined),
358
+ operationSignal,
359
+ ).catch(() => {});
360
+ }
361
+ operationSignal.removeEventListener("abort", abortBrowser);
362
+ clearTimeout(deadlineTimer);
363
+ }
364
+ }
365
+
366
+ interface CachedCompactionRun {
367
+ createdAt: number;
368
+ ownerKey: string;
369
+ traceIds: ReadonlySet<string>;
370
+ nativeThreadId?: string;
371
+ nativeTurnId?: string;
372
+ abort: AbortController;
373
+ active: boolean;
374
+ promise: Promise<string>;
375
+ }
376
+
377
+ interface StructuredCompactionInterruption {
378
+ createdAt: number;
379
+ reason: Error;
380
+ }
381
+
382
+ export interface StructuredCompactionOwner {
383
+ ownerKey: string;
384
+ /** Every externally addressable browser trace owned by this structured compaction. */
385
+ traceIds: readonly string[];
386
+ /** Exact native Codex owner, when supplied by the current Responses request. */
387
+ nativeThreadId?: string;
388
+ nativeTurnId?: string;
389
+ }
390
+
391
+ const structuredCompactionRuns = new Map<string, CachedCompactionRun>();
392
+ const structuredCompactionOwners = new Map<string, Promise<void>>();
393
+ const structuredCompactionInterruptions = new Map<string, StructuredCompactionInterruption>();
394
+ const STRUCTURED_COMPACTION_RUN_TTL_MS = 30 * 60_000;
395
+
396
+ function nativeTurnIdentityKey(threadId: string, turnId: string): string {
397
+ if (!threadId.trim() || !turnId.trim()) {
398
+ throw new Error("Structured compaction requires non-empty native thread and turn ids");
399
+ }
400
+ return JSON.stringify([threadId, turnId]);
401
+ }
402
+
403
+ function rememberStructuredCompactionInterruption(threadId: string, turnId: string, reason: Error): void {
404
+ const identity = nativeTurnIdentityKey(threadId, turnId);
405
+ const now = Date.now();
406
+ pruneStructuredCompactionInterruptions(now);
407
+ const existing = structuredCompactionInterruptions.get(identity);
408
+ if (existing) {
409
+ existing.createdAt = now;
410
+ return;
411
+ }
412
+ structuredCompactionInterruptions.set(identity, { createdAt: now, reason });
413
+ }
414
+
415
+ function structuredCompactionInterruption(owner: StructuredCompactionOwner): Error | undefined {
416
+ if (owner.nativeThreadId === undefined && owner.nativeTurnId === undefined) return undefined;
417
+ pruneStructuredCompactionInterruptions();
418
+ return structuredCompactionInterruptions.get(
419
+ nativeTurnIdentityKey(owner.nativeThreadId ?? "", owner.nativeTurnId ?? ""),
420
+ )?.reason;
421
+ }
422
+
423
+ function pruneStructuredCompactionInterruptions(now = Date.now()): void {
424
+ const cutoff = now - STRUCTURED_COMPACTION_RUN_TTL_MS;
425
+ for (const [identity, interruption] of structuredCompactionInterruptions) {
426
+ if (interruption.createdAt < cutoff) structuredCompactionInterruptions.delete(identity);
427
+ }
428
+ }
429
+
430
+ function pruneStructuredCompactionRuns(): void {
431
+ const now = Date.now();
432
+ const cutoff = now - STRUCTURED_COMPACTION_RUN_TTL_MS;
433
+ for (const [candidate, run] of structuredCompactionRuns) {
434
+ if (run.createdAt < cutoff) structuredCompactionRuns.delete(candidate);
435
+ }
436
+ pruneStructuredCompactionInterruptions(now);
437
+ }
438
+
439
+ /** Return the canonical result of an exact compact request, even after its source was retired. */
440
+ export function existingStructuredCompactionRun(key: string): Promise<string> | undefined {
441
+ pruneStructuredCompactionRuns();
442
+ return structuredCompactionRuns.get(key)?.promise;
443
+ }
444
+
445
+ export function runStructuredCompactionOnce(
446
+ key: string,
447
+ owner: StructuredCompactionOwner,
448
+ start: (operatorSignal: AbortSignal) => Promise<string>,
449
+ ): Promise<string> {
450
+ pruneStructuredCompactionRuns();
451
+ const existing = structuredCompactionRuns.get(key);
452
+ if (existing) return existing.promise;
453
+ const interrupted = structuredCompactionInterruption(owner);
454
+ if (interrupted) return Promise.reject(interrupted);
455
+ const abort = new AbortController();
456
+ const previousOwner = structuredCompactionOwners.get(owner.ownerKey);
457
+ const promise = Promise.resolve().then(async () => {
458
+ if (previousOwner) await withCompactionAbort(previousOwner, abort.signal);
459
+ if (abort.signal.aborted) throw abortReason(abort.signal);
460
+ return start(abort.signal);
461
+ });
462
+ const run: CachedCompactionRun = {
463
+ createdAt: Date.now(),
464
+ ownerKey: owner.ownerKey,
465
+ traceIds: new Set(owner.traceIds),
466
+ ...(owner.nativeThreadId ? { nativeThreadId: owner.nativeThreadId } : {}),
467
+ ...(owner.nativeTurnId ? { nativeTurnId: owner.nativeTurnId } : {}),
468
+ abort,
469
+ active: true,
470
+ promise,
471
+ };
472
+ structuredCompactionRuns.set(key, run);
473
+ const ownerSettlement = promise.then(() => undefined, () => undefined);
474
+ structuredCompactionOwners.set(owner.ownerKey, ownerSettlement);
475
+ void ownerSettlement.then(() => {
476
+ run.active = false;
477
+ if (structuredCompactionOwners.get(owner.ownerKey) === ownerSettlement) {
478
+ structuredCompactionOwners.delete(owner.ownerKey);
479
+ }
480
+ });
481
+ void promise.catch(() => {
482
+ if (structuredCompactionRuns.get(key)?.promise === promise) {
483
+ structuredCompactionRuns.delete(key);
484
+ }
485
+ });
486
+ return promise;
487
+ }
488
+
489
+ async function cancelStructuredCompactionRuns(
490
+ matches: (run: CachedCompactionRun) => boolean,
491
+ reason: Error,
492
+ ): Promise<number> {
493
+ const runs = [...structuredCompactionRuns.values()].filter(run => run.active && matches(run));
494
+ for (const run of runs) {
495
+ if (!run.abort.signal.aborted) run.abort.abort(reason);
496
+ }
497
+ await Promise.allSettled(runs.map(run => run.promise));
498
+ return runs.length;
499
+ }
500
+
501
+ /** Begin cancelling the structured compaction owned by one exact native Codex turn. */
502
+ export function cancelStructuredCompactionNativeTurn(
503
+ threadId: string,
504
+ turnId: string,
505
+ reason: Error,
506
+ ): { cancelled: number; settlement: Promise<void> } {
507
+ // Record before scanning active owners. Registration and cancellation share this synchronous
508
+ // boundary, so either registration wins and is aborted below, or interruption wins and the later
509
+ // registration rejects without invoking its detached work.
510
+ rememberStructuredCompactionInterruption(threadId, turnId, reason);
511
+ const runs = [...structuredCompactionRuns.values()].filter(run => (
512
+ run.active
513
+ && run.nativeThreadId === threadId
514
+ && run.nativeTurnId === turnId
515
+ ));
516
+ for (const run of runs) {
517
+ if (!run.abort.signal.aborted) run.abort.abort(reason);
518
+ }
519
+ return {
520
+ cancelled: runs.length,
521
+ settlement: Promise.allSettled(runs.map(run => run.promise)).then(() => undefined),
522
+ };
523
+ }
524
+
525
+ /** Cancel a user-requested compaction without treating an HTTP observer disconnect as terminal. */
526
+ export function cancelStructuredCompactionTrace(traceId: string, reason: Error): Promise<number> {
527
+ return cancelStructuredCompactionRuns(run => run.traceIds.has(traceId), reason);
528
+ }
529
+
530
+ /** Cancel every active compaction owner and wait for its browser/helper cleanup. */
531
+ export function cancelAllStructuredCompactions(reason: Error): Promise<number> {
532
+ return cancelStructuredCompactionRuns(() => true, reason);
533
+ }