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.
Files changed (29) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +86 -38
  3. package/extensions/openai-codex-compat/apply-patch-diff-render.ts +6 -2
  4. package/extensions/openai-codex-compat/apply-patch-engine.ts +89 -5
  5. package/extensions/openai-codex-compat/apply-patch.ts +4 -5
  6. package/extensions/openai-codex-compat/codex-cache-diagnostics.ts +97 -0
  7. package/extensions/openai-codex-compat/codex-cache-key.ts +9 -0
  8. package/extensions/openai-codex-compat/codex-installation.ts +51 -0
  9. package/extensions/openai-codex-compat/codex-metadata.ts +139 -0
  10. package/extensions/openai-codex-compat/codex-protocol.ts +4 -2
  11. package/extensions/openai-codex-compat/codex-provider.ts +708 -128
  12. package/extensions/openai-codex-compat/codex-stream.ts +137 -40
  13. package/extensions/openai-codex-compat/codex-thread-lineage.ts +156 -0
  14. package/extensions/openai-codex-compat/codex-transport.ts +1795 -199
  15. package/extensions/openai-codex-compat/compaction-checkpoint.ts +2 -2
  16. package/extensions/openai-codex-compat/config.ts +15 -2
  17. package/extensions/openai-codex-compat/image-generation-schema.ts +37 -0
  18. package/extensions/openai-codex-compat/image-generation.ts +25 -48
  19. package/extensions/openai-codex-compat/index.ts +13 -0
  20. package/extensions/openai-codex-compat/namespaced-tools.ts +2 -0
  21. package/extensions/openai-codex-compat/output-limit-continuation.ts +151 -0
  22. package/extensions/openai-codex-compat/provider-error.ts +79 -0
  23. package/extensions/openai-codex-compat/remote-compaction.ts +13 -0
  24. package/extensions/openai-codex-compat/request-options.ts +2 -2
  25. package/extensions/openai-codex-compat/responses-lite.ts +147 -0
  26. package/extensions/openai-codex-compat/responses-replay.ts +0 -7
  27. package/extensions/openai-codex-compat/settings-pane.ts +11 -0
  28. package/extensions/openai-codex-compat/web-run.ts +7 -0
  29. 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 closure`);
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 itemContentText(item);
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(status: unknown): AssistantMessage["stopReason"] {
135
- if (status === "incomplete") return "length";
136
- if (status === "failed" || status === "cancelled") return "error";
137
- return "stop";
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 wireName = stringValue(item.name);
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 = stringValue(item.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
- if (typeof response["status"] === "string") output.rawStopReason = response["status"];
292
- output.stopReason = mapStopReason(response["status"]);
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
- pushToolDelta(slot, event.arguments.slice(previous.length));
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
- if (item["namespace"] !== undefined) {
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
- throw new Error(
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) throw new Error("Codex stream ended before a terminal response event");
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
+ }