pi-openai-codex-compat 0.0.2 → 0.0.4
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.
- package/CHANGELOG.md +86 -0
- package/README.md +86 -38
- package/extensions/openai-codex-compat/apply-patch-diff-render.ts +6 -2
- package/extensions/openai-codex-compat/apply-patch-engine.ts +89 -5
- package/extensions/openai-codex-compat/apply-patch.ts +4 -5
- package/extensions/openai-codex-compat/codex-cache-diagnostics.ts +97 -0
- package/extensions/openai-codex-compat/codex-cache-key.ts +9 -0
- package/extensions/openai-codex-compat/codex-installation.ts +51 -0
- package/extensions/openai-codex-compat/codex-metadata.ts +139 -0
- package/extensions/openai-codex-compat/codex-protocol.ts +4 -2
- package/extensions/openai-codex-compat/codex-provider.ts +708 -128
- package/extensions/openai-codex-compat/codex-stream.ts +137 -40
- package/extensions/openai-codex-compat/codex-thread-lineage.ts +156 -0
- package/extensions/openai-codex-compat/codex-transport.ts +1795 -199
- package/extensions/openai-codex-compat/compaction-checkpoint.ts +2 -2
- package/extensions/openai-codex-compat/config.ts +15 -2
- package/extensions/openai-codex-compat/image-generation-schema.ts +37 -0
- package/extensions/openai-codex-compat/image-generation.ts +25 -48
- package/extensions/openai-codex-compat/index.ts +13 -0
- package/extensions/openai-codex-compat/namespaced-tools.ts +2 -0
- package/extensions/openai-codex-compat/output-limit-continuation.ts +151 -0
- package/extensions/openai-codex-compat/provider-error.ts +79 -0
- package/extensions/openai-codex-compat/remote-compaction.ts +13 -0
- package/extensions/openai-codex-compat/request-options.ts +2 -2
- package/extensions/openai-codex-compat/responses-lite.ts +147 -0
- package/extensions/openai-codex-compat/responses-replay.ts +0 -7
- package/extensions/openai-codex-compat/settings-pane.ts +11 -0
- package/extensions/openai-codex-compat/web-run.ts +7 -0
- package/package.json +2 -1
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type TextSignatureV1,
|
|
9
9
|
type ThinkingContent,
|
|
10
10
|
type ToolCall,
|
|
11
|
+
type Usage,
|
|
11
12
|
} from "@earendil-works/pi-ai";
|
|
12
13
|
import { isObject, type JsonRecord } from "./codex-protocol.ts";
|
|
13
14
|
import { CODEX_NAMESPACED_TOOL_NAMES, namespacedToolCallName } from "./namespaced-tools.ts";
|
|
@@ -38,6 +39,33 @@ type OutputSlot =
|
|
|
38
39
|
|
|
39
40
|
type ToolCallSlot = Extract<OutputSlot, { type: "toolCall" }>;
|
|
40
41
|
|
|
42
|
+
type ProcessCodexStreamOptions = {
|
|
43
|
+
applyServiceTierPricing?(usage: Usage, responseServiceTier: string | undefined): void;
|
|
44
|
+
attemptState?: CodexStreamAttemptState;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type CodexStreamAttemptState = {
|
|
48
|
+
startedContentIndexes: Set<number>;
|
|
49
|
+
completedContentIndexes: Set<number>;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
type CodexResponseStatus =
|
|
53
|
+
| "completed"
|
|
54
|
+
| "incomplete"
|
|
55
|
+
| "failed"
|
|
56
|
+
| "cancelled"
|
|
57
|
+
| "queued"
|
|
58
|
+
| "in_progress";
|
|
59
|
+
|
|
60
|
+
const CODEX_RESPONSE_STATUSES = new Set<CodexResponseStatus>([
|
|
61
|
+
"completed",
|
|
62
|
+
"incomplete",
|
|
63
|
+
"failed",
|
|
64
|
+
"cancelled",
|
|
65
|
+
"queued",
|
|
66
|
+
"in_progress",
|
|
67
|
+
]);
|
|
68
|
+
|
|
41
69
|
function outputIndex(event: JsonRecord): number {
|
|
42
70
|
return typeof event["output_index"] === "number" ? event["output_index"] : 0;
|
|
43
71
|
}
|
|
@@ -46,6 +74,18 @@ function stringValue(value: unknown): string {
|
|
|
46
74
|
return typeof value === "string" ? value : "";
|
|
47
75
|
}
|
|
48
76
|
|
|
77
|
+
function piToolCallName(item: JsonRecord): string {
|
|
78
|
+
const wireName = stringValue(item.name);
|
|
79
|
+
const name =
|
|
80
|
+
item["namespace"] === undefined
|
|
81
|
+
? wireName
|
|
82
|
+
: namespacedToolCallName(item["namespace"], wireName);
|
|
83
|
+
if (item["namespace"] === undefined && CODEX_NAMESPACED_TOOL_NAMES.has(name)) {
|
|
84
|
+
throw new Error(`Codex returned namespaced tool "${name}" as a flat function call.`);
|
|
85
|
+
}
|
|
86
|
+
return name;
|
|
87
|
+
}
|
|
88
|
+
|
|
49
89
|
function encodeTextSignature(id: string, phase: unknown): string {
|
|
50
90
|
const payload: TextSignatureV1 = { v: 1, id };
|
|
51
91
|
if (phase === "commentary" || phase === "final_answer") payload.phase = phase;
|
|
@@ -60,7 +100,7 @@ function appendGrammarDelta(
|
|
|
60
100
|
): string | undefined {
|
|
61
101
|
if (buffer.closed) {
|
|
62
102
|
if (close && nextInput === buffer.input) return undefined;
|
|
63
|
-
throw new Error(`grammar tool input for property "${property}" changed after
|
|
103
|
+
throw new Error(`grammar tool input for property "${property}" changed after it was closed`);
|
|
64
104
|
}
|
|
65
105
|
if (!nextInput.startsWith(buffer.input)) {
|
|
66
106
|
throw new Error(`grammar tool input for property "${property}" changed non-monotonically`);
|
|
@@ -128,13 +168,35 @@ function reasoningText(item: JsonRecord): string {
|
|
|
128
168
|
.join("\n\n")
|
|
129
169
|
: "";
|
|
130
170
|
if (summary) return summary;
|
|
131
|
-
return
|
|
171
|
+
return Array.isArray(item.content)
|
|
172
|
+
? item.content
|
|
173
|
+
.filter(isObject)
|
|
174
|
+
.map((part) => (typeof part.text === "string" ? part.text : ""))
|
|
175
|
+
.join("\n\n")
|
|
176
|
+
: "";
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function normalizeCodexStatus(status: unknown): CodexResponseStatus | undefined {
|
|
180
|
+
return typeof status === "string" && CODEX_RESPONSE_STATUSES.has(status as CodexResponseStatus)
|
|
181
|
+
? (status as CodexResponseStatus)
|
|
182
|
+
: undefined;
|
|
132
183
|
}
|
|
133
184
|
|
|
134
|
-
function mapStopReason(
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
185
|
+
function mapStopReason(
|
|
186
|
+
status: CodexResponseStatus | undefined,
|
|
187
|
+
incompleteReason: string | undefined,
|
|
188
|
+
): { stopReason: AssistantMessage["stopReason"]; errorMessage?: string } {
|
|
189
|
+
if (status === "incomplete") {
|
|
190
|
+
if (incompleteReason === "max_output_tokens") return { stopReason: "length" };
|
|
191
|
+
return {
|
|
192
|
+
stopReason: "error",
|
|
193
|
+
errorMessage: incompleteReason
|
|
194
|
+
? `Response incomplete: ${incompleteReason}`
|
|
195
|
+
: "Response incomplete without a provider reason",
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
if (status === "failed" || status === "cancelled") return { stopReason: "error" };
|
|
199
|
+
return { stopReason: "stop" };
|
|
138
200
|
}
|
|
139
201
|
|
|
140
202
|
export async function processCodexStream(
|
|
@@ -143,10 +205,16 @@ export async function processCodexStream(
|
|
|
143
205
|
stream: AssistantMessageEventStream,
|
|
144
206
|
model: Model<any>,
|
|
145
207
|
grammarToolInputProperties: ReadonlyMap<string, string>,
|
|
208
|
+
options?: ProcessCodexStreamOptions,
|
|
146
209
|
): Promise<void> {
|
|
147
210
|
let terminal = false;
|
|
148
211
|
const slots = new Map<number, OutputSlot>();
|
|
149
212
|
const reasoningById = new Map<string, ThinkingContent>();
|
|
213
|
+
const applyMessagePhaseStopReason = (item: JsonRecord): void => {
|
|
214
|
+
if (item.type === "message" && item["phase"] === "final_answer") {
|
|
215
|
+
output.stopReason = "stop";
|
|
216
|
+
}
|
|
217
|
+
};
|
|
150
218
|
|
|
151
219
|
const getSlot = <TType extends OutputSlot["type"]>(
|
|
152
220
|
index: number,
|
|
@@ -166,40 +234,43 @@ export async function processCodexStream(
|
|
|
166
234
|
});
|
|
167
235
|
};
|
|
168
236
|
|
|
237
|
+
const trackStarted = <TSlot extends OutputSlot>(slot: TSlot): TSlot => {
|
|
238
|
+
options?.attemptState?.startedContentIndexes.add(slot.contentIndex);
|
|
239
|
+
return slot;
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const trackCompleted = (slot: OutputSlot): void => {
|
|
243
|
+
options?.attemptState?.completedContentIndexes.add(slot.contentIndex);
|
|
244
|
+
};
|
|
245
|
+
|
|
169
246
|
const createSlot = (index: number, item: JsonRecord): OutputSlot | undefined => {
|
|
170
247
|
if (item.type === "reasoning") {
|
|
171
248
|
const block: ThinkingContent = { type: "thinking", thinking: "" };
|
|
172
249
|
output.content.push(block);
|
|
173
|
-
const slot = {
|
|
250
|
+
const slot = trackStarted({
|
|
174
251
|
type: "thinking",
|
|
175
252
|
block,
|
|
176
253
|
contentIndex: output.content.length - 1,
|
|
177
|
-
} satisfies OutputSlot;
|
|
254
|
+
} satisfies OutputSlot);
|
|
178
255
|
slots.set(index, slot);
|
|
179
256
|
stream.push({ type: "thinking_start", contentIndex: slot.contentIndex, partial: output });
|
|
180
257
|
return slot;
|
|
181
258
|
}
|
|
182
259
|
if (item.type === "message") {
|
|
260
|
+
applyMessagePhaseStopReason(item);
|
|
183
261
|
const block: TextContent = { type: "text", text: "" };
|
|
184
262
|
output.content.push(block);
|
|
185
|
-
const slot = {
|
|
263
|
+
const slot = trackStarted({
|
|
186
264
|
type: "text",
|
|
187
265
|
block,
|
|
188
266
|
contentIndex: output.content.length - 1,
|
|
189
|
-
} satisfies OutputSlot;
|
|
267
|
+
} satisfies OutputSlot);
|
|
190
268
|
slots.set(index, slot);
|
|
191
269
|
stream.push({ type: "text_start", contentIndex: slot.contentIndex, partial: output });
|
|
192
270
|
return slot;
|
|
193
271
|
}
|
|
194
272
|
if (item.type === "function_call") {
|
|
195
|
-
const
|
|
196
|
-
const name =
|
|
197
|
-
item["namespace"] === undefined
|
|
198
|
-
? wireName
|
|
199
|
-
: namespacedToolCallName(item["namespace"], wireName);
|
|
200
|
-
if (item["namespace"] === undefined && CODEX_NAMESPACED_TOOL_NAMES.has(name)) {
|
|
201
|
-
throw new Error(`Codex returned namespaced tool "${name}" as a flat function call.`);
|
|
202
|
-
}
|
|
273
|
+
const name = piToolCallName(item);
|
|
203
274
|
const block: StreamingToolCall = {
|
|
204
275
|
type: "toolCall",
|
|
205
276
|
id: `${stringValue(item["call_id"])}|${stringValue(item.id)}`,
|
|
@@ -208,17 +279,17 @@ export async function processCodexStream(
|
|
|
208
279
|
partialJson: typeof item.arguments === "string" ? item.arguments : "",
|
|
209
280
|
};
|
|
210
281
|
output.content.push(block);
|
|
211
|
-
const slot = {
|
|
282
|
+
const slot = trackStarted({
|
|
212
283
|
type: "toolCall",
|
|
213
284
|
block,
|
|
214
285
|
contentIndex: output.content.length - 1,
|
|
215
|
-
} satisfies OutputSlot;
|
|
286
|
+
} satisfies OutputSlot);
|
|
216
287
|
slots.set(index, slot);
|
|
217
288
|
stream.push({ type: "toolcall_start", contentIndex: slot.contentIndex, partial: output });
|
|
218
289
|
return slot;
|
|
219
290
|
}
|
|
220
291
|
if (item.type === "custom_tool_call") {
|
|
221
|
-
const name =
|
|
292
|
+
const name = piToolCallName(item);
|
|
222
293
|
const property = grammarToolInputProperties.get(name) ?? "input";
|
|
223
294
|
const input = typeof item["input"] === "string" ? item["input"] : "";
|
|
224
295
|
const block: StreamingToolCall = {
|
|
@@ -232,11 +303,11 @@ export async function processCodexStream(
|
|
|
232
303
|
},
|
|
233
304
|
};
|
|
234
305
|
output.content.push(block);
|
|
235
|
-
const slot = {
|
|
306
|
+
const slot = trackStarted({
|
|
236
307
|
type: "toolCall",
|
|
237
308
|
block,
|
|
238
309
|
contentIndex: output.content.length - 1,
|
|
239
|
-
} satisfies OutputSlot;
|
|
310
|
+
} satisfies OutputSlot);
|
|
240
311
|
slots.set(index, slot);
|
|
241
312
|
stream.push({ type: "toolcall_start", contentIndex: slot.contentIndex, partial: output });
|
|
242
313
|
return slot;
|
|
@@ -270,12 +341,15 @@ export async function processCodexStream(
|
|
|
270
341
|
typeof outputDetails?.["reasoning_tokens"] === "number"
|
|
271
342
|
? outputDetails["reasoning_tokens"]
|
|
272
343
|
: 0,
|
|
273
|
-
totalTokens:
|
|
274
|
-
typeof usage.total_tokens === "number" ? usage.total_tokens : input + outputTokens,
|
|
344
|
+
totalTokens: typeof usage.total_tokens === "number" ? usage.total_tokens || 0 : 0,
|
|
275
345
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
276
346
|
};
|
|
277
|
-
calculateCost(model, output.usage);
|
|
278
347
|
}
|
|
348
|
+
calculateCost(model, output.usage);
|
|
349
|
+
options?.applyServiceTierPricing?.(
|
|
350
|
+
output.usage,
|
|
351
|
+
typeof response.service_tier === "string" ? response.service_tier : undefined,
|
|
352
|
+
);
|
|
279
353
|
for (const item of responseItems(response["output"])) {
|
|
280
354
|
if (item.type !== "reasoning" || typeof item.id !== "string") continue;
|
|
281
355
|
const block = reasoningById.get(item.id);
|
|
@@ -288,8 +362,20 @@ export async function processCodexStream(
|
|
|
288
362
|
});
|
|
289
363
|
}
|
|
290
364
|
}
|
|
291
|
-
|
|
292
|
-
|
|
365
|
+
const status = normalizeCodexStatus(response["status"]);
|
|
366
|
+
const incompleteDetails = isObject(response["incomplete_details"])
|
|
367
|
+
? response["incomplete_details"]
|
|
368
|
+
: undefined;
|
|
369
|
+
const incompleteReason =
|
|
370
|
+
typeof incompleteDetails?.["reason"] === "string" ? incompleteDetails["reason"] : undefined;
|
|
371
|
+
const rawStopReason =
|
|
372
|
+
status === "incomplete" && incompleteReason ? `${status}.${incompleteReason}` : status;
|
|
373
|
+
if (rawStopReason === undefined) delete output.rawStopReason;
|
|
374
|
+
else output.rawStopReason = rawStopReason;
|
|
375
|
+
const mappedStop = mapStopReason(status, incompleteReason);
|
|
376
|
+
output.stopReason = mappedStop.stopReason;
|
|
377
|
+
if (mappedStop.errorMessage === undefined) delete output.errorMessage;
|
|
378
|
+
else output.errorMessage = mappedStop.errorMessage;
|
|
293
379
|
if (output.stopReason === "stop" && output.content.some((block) => block.type === "toolCall")) {
|
|
294
380
|
output.stopReason = "toolUse";
|
|
295
381
|
}
|
|
@@ -353,8 +439,10 @@ export async function processCodexStream(
|
|
|
353
439
|
const previous = slot.block.partialJson;
|
|
354
440
|
slot.block.partialJson = event.arguments;
|
|
355
441
|
slot.block.arguments = parseStreamingJson(event.arguments);
|
|
356
|
-
if (event.arguments.startsWith(previous))
|
|
357
|
-
|
|
442
|
+
if (event.arguments.startsWith(previous)) {
|
|
443
|
+
const delta = event.arguments.slice(previous.length);
|
|
444
|
+
if (delta.length > 0) pushToolDelta(slot, delta);
|
|
445
|
+
}
|
|
358
446
|
} else if (event.type === "response.custom_tool_call_input.delta") {
|
|
359
447
|
const slot = getSlot(index, "toolCall");
|
|
360
448
|
if (!slot || typeof event["delta"] !== "string") continue;
|
|
@@ -368,6 +456,7 @@ export async function processCodexStream(
|
|
|
368
456
|
pushToolDelta(slot, appendCustomInput(slot.block, event["input"], true));
|
|
369
457
|
} else if (event.type === "response.output_item.done" && isObject(event.item)) {
|
|
370
458
|
const item = event.item;
|
|
459
|
+
applyMessagePhaseStopReason(item);
|
|
371
460
|
const slot = slotFor(index, item);
|
|
372
461
|
if (item.type === "reasoning" && slot?.type === "thinking") {
|
|
373
462
|
slot.block.thinking = reasoningText(item) || slot.block.thinking;
|
|
@@ -379,6 +468,7 @@ export async function processCodexStream(
|
|
|
379
468
|
content: slot.block.thinking,
|
|
380
469
|
partial: output,
|
|
381
470
|
});
|
|
471
|
+
trackCompleted(slot);
|
|
382
472
|
slots.delete(index);
|
|
383
473
|
} else if (item.type === "message" && slot?.type === "text") {
|
|
384
474
|
slot.block.text = itemContentText(item);
|
|
@@ -391,17 +481,14 @@ export async function processCodexStream(
|
|
|
391
481
|
content: slot.block.text,
|
|
392
482
|
partial: output,
|
|
393
483
|
});
|
|
484
|
+
trackCompleted(slot);
|
|
394
485
|
slots.delete(index);
|
|
395
486
|
} else if (
|
|
396
487
|
item.type === "function_call" &&
|
|
397
488
|
slot?.type === "toolCall" &&
|
|
398
489
|
slot.block.partialJson !== undefined
|
|
399
490
|
) {
|
|
400
|
-
|
|
401
|
-
slot.block.name = namespacedToolCallName(item["namespace"], item.name);
|
|
402
|
-
} else if (typeof item.name === "string" && CODEX_NAMESPACED_TOOL_NAMES.has(item.name)) {
|
|
403
|
-
throw new Error(`Codex returned namespaced tool "${item.name}" as a flat function call.`);
|
|
404
|
-
}
|
|
491
|
+
slot.block.name = piToolCallName(item);
|
|
405
492
|
const argumentsJson =
|
|
406
493
|
typeof item.arguments === "string" ? item.arguments : slot.block.partialJson || "{}";
|
|
407
494
|
slot.block.arguments = parseStreamingJson(argumentsJson);
|
|
@@ -412,8 +499,10 @@ export async function processCodexStream(
|
|
|
412
499
|
toolCall: slot.block,
|
|
413
500
|
partial: output,
|
|
414
501
|
});
|
|
502
|
+
trackCompleted(slot);
|
|
415
503
|
slots.delete(index);
|
|
416
504
|
} else if (item.type === "custom_tool_call" && slot?.type === "toolCall") {
|
|
505
|
+
slot.block.name = piToolCallName(item);
|
|
417
506
|
const input = typeof item["input"] === "string" ? item["input"] : customInput(slot.block);
|
|
418
507
|
pushToolDelta(slot, appendCustomInput(slot.block, input, true));
|
|
419
508
|
delete slot.block.customInput;
|
|
@@ -423,6 +512,7 @@ export async function processCodexStream(
|
|
|
423
512
|
toolCall: slot.block,
|
|
424
513
|
partial: output,
|
|
425
514
|
});
|
|
515
|
+
trackCompleted(slot);
|
|
426
516
|
slots.delete(index);
|
|
427
517
|
}
|
|
428
518
|
} else if (
|
|
@@ -431,14 +521,21 @@ export async function processCodexStream(
|
|
|
431
521
|
) {
|
|
432
522
|
finalize(event.response);
|
|
433
523
|
} else if (event.type === "response.failed") {
|
|
434
|
-
terminal = true;
|
|
435
524
|
const response = isObject(event.response) ? event.response : undefined;
|
|
525
|
+
if (response) {
|
|
526
|
+
finalize(response);
|
|
527
|
+
} else {
|
|
528
|
+
terminal = true;
|
|
529
|
+
}
|
|
530
|
+
output.stopReason = "error";
|
|
531
|
+
output.rawStopReason ??= "failed";
|
|
436
532
|
const error = isObject(response?.["error"]) ? response["error"] : undefined;
|
|
437
|
-
|
|
438
|
-
typeof error?.["message"] === "string" ? error["message"] : "Codex response failed"
|
|
439
|
-
);
|
|
533
|
+
output.errorMessage =
|
|
534
|
+
typeof error?.["message"] === "string" ? error["message"] : "Codex response failed";
|
|
440
535
|
}
|
|
441
536
|
}
|
|
442
537
|
|
|
443
|
-
if (!terminal)
|
|
538
|
+
if (!terminal) {
|
|
539
|
+
throw new Error("OpenAI Responses stream ended before a terminal response event");
|
|
540
|
+
}
|
|
444
541
|
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { uuidv7 } from "@earendil-works/pi-ai";
|
|
3
|
+
import { codexCacheKey } from "./codex-cache-key.ts";
|
|
4
|
+
|
|
5
|
+
export const CODEX_THREAD_MARKER_ENTRY_TYPE = "openai-codex-compat-thread";
|
|
6
|
+
|
|
7
|
+
export type CodexThreadMarkerData = {
|
|
8
|
+
version: 1;
|
|
9
|
+
sessionId: string;
|
|
10
|
+
threadId: string;
|
|
11
|
+
forkedFromThreadId: string;
|
|
12
|
+
branchParentEntryId: string | null;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type CodexThreadIdentity = {
|
|
16
|
+
threadId: string;
|
|
17
|
+
forkedFromThreadId?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type PendingTreeFork = {
|
|
21
|
+
expectedLeafId: string | null;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function markerData(entry: SessionEntry, sessionId: string): CodexThreadMarkerData | undefined {
|
|
25
|
+
if (entry.type !== "custom" || entry.customType !== CODEX_THREAD_MARKER_ENTRY_TYPE) {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
const data = entry.data;
|
|
29
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
|
30
|
+
throw new Error("The active Pi branch contains an invalid OpenAI Codex thread marker.");
|
|
31
|
+
}
|
|
32
|
+
const candidate = data as Record<string, unknown>;
|
|
33
|
+
if (
|
|
34
|
+
candidate["version"] !== 1 ||
|
|
35
|
+
typeof candidate["sessionId"] !== "string" ||
|
|
36
|
+
candidate["sessionId"].length === 0 ||
|
|
37
|
+
typeof candidate["threadId"] !== "string" ||
|
|
38
|
+
candidate["threadId"].length === 0 ||
|
|
39
|
+
typeof candidate["forkedFromThreadId"] !== "string" ||
|
|
40
|
+
candidate["forkedFromThreadId"].length === 0 ||
|
|
41
|
+
(candidate["branchParentEntryId"] !== null &&
|
|
42
|
+
typeof candidate["branchParentEntryId"] !== "string")
|
|
43
|
+
) {
|
|
44
|
+
throw new Error("The active Pi branch contains an invalid OpenAI Codex thread marker.");
|
|
45
|
+
}
|
|
46
|
+
if (candidate["sessionId"] !== sessionId) return undefined;
|
|
47
|
+
if (entry.parentId !== candidate["branchParentEntryId"]) {
|
|
48
|
+
throw new Error("The active Pi branch contains a misplaced OpenAI Codex thread marker.");
|
|
49
|
+
}
|
|
50
|
+
return candidate as CodexThreadMarkerData;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function latestMarkerIndex(sessionId: string, branch: readonly SessionEntry[]): number {
|
|
54
|
+
for (let index = branch.length - 1; index >= 0; index -= 1) {
|
|
55
|
+
if (markerData(branch[index]!, sessionId)) return index;
|
|
56
|
+
}
|
|
57
|
+
return -1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function resolveCodexThreadIdentity(
|
|
61
|
+
sessionId: string,
|
|
62
|
+
branch: readonly SessionEntry[],
|
|
63
|
+
): CodexThreadIdentity {
|
|
64
|
+
for (let index = branch.length - 1; index >= 0; index -= 1) {
|
|
65
|
+
const marker = markerData(branch[index]!, sessionId);
|
|
66
|
+
if (marker) {
|
|
67
|
+
return {
|
|
68
|
+
threadId: marker.threadId,
|
|
69
|
+
forkedFromThreadId: marker.forkedFromThreadId,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return { threadId: codexCacheKey(sessionId)! };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function shouldForkOnNextAppend(
|
|
77
|
+
sessionId: string,
|
|
78
|
+
branch: readonly SessionEntry[],
|
|
79
|
+
entries: readonly SessionEntry[],
|
|
80
|
+
): boolean {
|
|
81
|
+
// Pi does not persist an explicit branch-start flag. Its append order is
|
|
82
|
+
// stable, so the first child inherits its thread and later children are
|
|
83
|
+
// forks unless the active path already contains a marker for that fork.
|
|
84
|
+
const firstChildByParent = new Map<string | null, string>();
|
|
85
|
+
for (const entry of entries) {
|
|
86
|
+
if (!firstChildByParent.has(entry.parentId)) {
|
|
87
|
+
firstChildByParent.set(entry.parentId, entry.id);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const markerIndex = latestMarkerIndex(sessionId, branch);
|
|
92
|
+
for (let index = markerIndex + 1; index < branch.length; index += 1) {
|
|
93
|
+
const entry = branch[index]!;
|
|
94
|
+
if (firstChildByParent.get(entry.parentId) !== entry.id) return true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const leafId = branch.at(-1)?.id ?? null;
|
|
98
|
+
return entries.some((entry) => entry.parentId === leafId);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function armPendingFork(pending: Map<string, PendingTreeFork>, ctx: ExtensionContext): void {
|
|
102
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
103
|
+
const branch = ctx.sessionManager.getBranch() as SessionEntry[];
|
|
104
|
+
const entries = ctx.sessionManager.getEntries() as SessionEntry[];
|
|
105
|
+
if (!shouldForkOnNextAppend(sessionId, branch, entries)) {
|
|
106
|
+
pending.delete(sessionId);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
pending.set(sessionId, { expectedLeafId: ctx.sessionManager.getLeafId() });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function pendingLeafIsActive(pending: PendingTreeFork, branch: readonly SessionEntry[]): boolean {
|
|
113
|
+
return (
|
|
114
|
+
pending.expectedLeafId === null || branch.some((entry) => entry.id === pending.expectedLeafId)
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export default function registerCodexThreadLineage(pi: ExtensionAPI): void {
|
|
119
|
+
const pending = new Map<string, PendingTreeFork>();
|
|
120
|
+
|
|
121
|
+
pi.on("session_start", (_event, ctx) => {
|
|
122
|
+
armPendingFork(pending, ctx);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
pi.on("session_tree", (_event, ctx) => {
|
|
126
|
+
armPendingFork(pending, ctx);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
pi.on("message_end", (event, ctx) => {
|
|
130
|
+
if (event.message.role !== "user") return;
|
|
131
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
132
|
+
const candidate = pending.get(sessionId);
|
|
133
|
+
if (!candidate) return;
|
|
134
|
+
pending.delete(sessionId);
|
|
135
|
+
|
|
136
|
+
const branch = ctx.sessionManager.getBranch() as SessionEntry[];
|
|
137
|
+
if (!pendingLeafIsActive(candidate, branch)) return;
|
|
138
|
+
|
|
139
|
+
const parent = resolveCodexThreadIdentity(sessionId, branch);
|
|
140
|
+
const branchParentEntryId = ctx.sessionManager.getLeafId();
|
|
141
|
+
// Pi invokes message_end handlers immediately before persisting the
|
|
142
|
+
// finalized user message. Advancing the leaf here makes this context-free
|
|
143
|
+
// marker the user's parent without writing anything during /tree itself.
|
|
144
|
+
pi.appendEntry<CodexThreadMarkerData>(CODEX_THREAD_MARKER_ENTRY_TYPE, {
|
|
145
|
+
version: 1,
|
|
146
|
+
sessionId,
|
|
147
|
+
threadId: uuidv7(),
|
|
148
|
+
forkedFromThreadId: parent.threadId,
|
|
149
|
+
branchParentEntryId,
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
154
|
+
pending.delete(ctx.sessionManager.getSessionId());
|
|
155
|
+
});
|
|
156
|
+
}
|