pi-openai-toolkit 0.20.7 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/LICENSE +21 -21
  2. package/NOTICE +83 -83
  3. package/README.md +8 -26
  4. package/README.zh.md +8 -26
  5. package/config.schema.json +278 -366
  6. package/docs/configuration.md +294 -310
  7. package/docs/internals.md +166 -175
  8. package/package.json +3 -13
  9. package/src/compact-client.ts +412 -389
  10. package/src/compaction-errors.ts +96 -0
  11. package/src/compaction-projection.ts +35 -35
  12. package/src/config/legacy.ts +519 -717
  13. package/src/config/migration.ts +103 -100
  14. package/src/config/notifications.ts +36 -28
  15. package/src/config/policy.ts +10 -18
  16. package/src/config/v2.ts +1167 -1380
  17. package/src/config.ts +310 -319
  18. package/src/context-management/codex-provider.ts +270 -270
  19. package/src/context-management/history-notes.ts +357 -357
  20. package/src/context-management/manual-compact.ts +0 -11
  21. package/src/context-management/tools.ts +371 -371
  22. package/src/context-management/types.ts +168 -168
  23. package/src/context-management/window-manager.ts +801 -801
  24. package/src/debug.ts +238 -237
  25. package/src/deferred-tool-carryover.ts +282 -282
  26. package/src/details-store.ts +151 -151
  27. package/src/extension-runtime.ts +1662 -1623
  28. package/src/image-generation/extension.ts +189 -189
  29. package/src/image-generation/protocol.ts +579 -579
  30. package/src/image-generation/types.ts +300 -300
  31. package/src/native-fallback.ts +4 -132
  32. package/src/payload-rewrite.ts +515 -518
  33. package/src/pi-context-hook.ts +117 -117
  34. package/src/remote-v2-client.ts +811 -814
  35. package/src/request-context-cache.ts +155 -155
  36. package/src/responses-headers.ts +180 -180
  37. package/src/runtime.ts +865 -832
  38. package/src/serializer.ts +882 -882
  39. package/src/supported-environment.ts +27 -27
  40. package/src/types.ts +565 -648
  41. package/src/web-search/alpha-client.ts +876 -876
  42. package/src/web-search/extension.ts +530 -530
  43. package/src/web-search/payload.ts +299 -299
  44. package/src/web-search/prompt.ts +60 -60
  45. package/src/web-search/types.ts +166 -166
  46. package/tsconfig.check.json +50 -54
  47. package/src/auto-mode/classifier.ts +0 -161
  48. package/src/auto-mode/extension.ts +0 -691
  49. package/src/auto-mode/model-selection-guard.ts +0 -52
  50. package/src/auto-mode/policy.ts +0 -146
  51. package/src/auto-mode/prompt.ts +0 -279
  52. package/src/auto-mode/reviewer.ts +0 -325
  53. package/src/auto-mode/tool-review-tui.ts +0 -211
  54. package/src/auto-mode/transcript.ts +0 -135
  55. package/src/auto-mode/types.ts +0 -137
@@ -1,801 +1,801 @@
1
- import { randomUUID } from "node:crypto";
2
- import type { AgentMessage } from "@earendil-works/pi-agent-core";
3
- import type { ProviderHeaders } from "@earendil-works/pi-ai";
4
- import {
5
- buildSessionProjection,
6
- calculateContextTokens,
7
- estimateTokens,
8
- type CompactionResult,
9
- type ExtensionAPI,
10
- type ExtensionContext,
11
- type SessionBeforeCompactEvent,
12
- type SessionEntry,
13
- } from "@earendil-works/pi-coding-agent";
14
- import { ContextWindowBudget, type ContextRemaining } from "./window-budget";
15
- import {
16
- rewriteEncryptedToolOutputs,
17
- rewriteWindowHeaders,
18
- rewriteWindowPayload,
19
- } from "./window-request";
20
- import {
21
- CONTEXT_WINDOW_COMPACTION_STRATEGY,
22
- CONTEXT_WINDOW_COMPACTION_SUMMARY,
23
- isContextWindowBoundary,
24
- preserveSystemHead,
25
- renderContextWindowMessage,
26
- sendContextWindowMessage,
27
- } from "./messages";
28
- import {
29
- CODEX_CONTEXT_WINDOW_MESSAGE_TYPE,
30
- CONTEXT_MANAGEMENT_PROTOCOL,
31
- type CodexContextManagementMessageDetails,
32
- type ContextWindowCompactionDetails,
33
- type ContextWindowIdentity,
34
- isCodexContextManagementMessageDetails,
35
- isNonEmptyString,
36
- isRecord,
37
- isContextWindowCompactionDetails,
38
- type NotesCheckpointReceipt,
39
- } from "./types";
40
- import {
41
- encodeEncryptedOutputForContext,
42
- isSuccessfulHistoryNotesToolResult,
43
- loadHistoryNotesThreadHint,
44
- } from "./history-notes";
45
- import { rewriteContextNamespaceTools } from "./namespace-tools";
46
- import { hasInvalidatedCompactionContext } from "../payload-rewrite";
47
-
48
- interface StartContextWindowOptions {
49
- triggerTurn: boolean;
50
- signal?: AbortSignal;
51
- trimPreviousWindow: boolean;
52
- /** Captured with the tool activation policy, before any awaited backend work. */
53
- gatewayModels?: readonly string[];
54
- /** Persist an owned handoff's exact target before the marker can be queued. */
55
- beforeSchedule?: (identity: ContextWindowIdentity) => void;
56
- }
57
-
58
- type ThreadHintLoader = (
59
- ctx: ExtensionContext,
60
- signal?: AbortSignal,
61
- gatewayModels?: readonly string[],
62
- ) => Promise<string | undefined>;
63
-
64
- type WindowBoundaryEntry = Extract<SessionEntry, { type: "custom_message" }> & {
65
- details: CodexContextManagementMessageDetails;
66
- };
67
-
68
- /**
69
- * In-process duplicate guard for one scheduled rollover. It is anchored to the
70
- * session that scheduled it and to the exact target window id, so it can never
71
- * be satisfied by an older or foreign marker. It is not checkpoint evidence:
72
- * only a persisted notes result unlocks the gate.
73
- */
74
- type PendingRollover = {
75
- sessionId?: string;
76
- targetWindowId: string;
77
- /** The current request already contains this marker; never persistence proof. */
78
- projected?: boolean;
79
- };
80
-
81
- export class CodexContextWindowManager {
82
- private identity: ContextWindowIdentity | undefined;
83
- private sessionId: string | undefined;
84
- private restoredMarkerId: string | undefined;
85
- private restoredCompactionId: string | undefined;
86
- private boundary: WindowBoundaryResolution = { kind: "none" };
87
- private readonly budget = new ContextWindowBudget();
88
- private pendingRollover: PendingRollover | undefined;
89
- private readonly projectionDiagnostics = new Set<string>();
90
- private lastKnownSystemHead: AgentMessage | undefined;
91
- /**
92
- * Pi's getContextUsage() measures the durable transcript. Remote windows are
93
- * projected only by this extension, so remember the last provider-visible
94
- * estimate for budget decisions and get_context_remaining.
95
- */
96
- private projectedContextTokens: number | undefined;
97
- private readonly loadThreadHint: ThreadHintLoader;
98
-
99
- constructor(loadThreadHint?: ThreadHintLoader) {
100
- this.loadThreadHint = loadThreadHint ?? ((ctx, signal, gatewayModels) => loadHistoryNotesThreadHint(ctx, signal, gatewayModels));
101
- }
102
-
103
- reset(): void {
104
- this.resetWindowState();
105
- this.pendingRollover = undefined;
106
- this.projectionDiagnostics.clear();
107
- this.lastKnownSystemHead = undefined;
108
- }
109
-
110
- currentIdentity(): ContextWindowIdentity | undefined {
111
- return this.identity ? { ...this.identity } : undefined;
112
- }
113
- /** Derived eligibility, never a replayed marker command. */
114
- hasPendingTrim(): boolean {
115
- return this.boundary.kind === "effective" && !this.boundary.cleaned &&
116
- this.boundary.entry.details.contextManagement.trimPreviousWindow === true;
117
- }
118
-
119
- /** Historical identity does not own a newer native/opaque checkpoint. */
120
- hasEffectiveWindow(): boolean {
121
- if (!this.identity || this.boundary.kind === "ambiguous") return false;
122
- return this.boundary.kind !== "superseded" ||
123
- (this.pendingRollover?.projected === true && this.pendingRollover.targetWindowId === this.identity.currentWindowId);
124
- }
125
-
126
- hasSupersededWindow(): boolean {
127
- return this.boundary.kind === "superseded";
128
- }
129
-
130
- private resetWindowState(): void {
131
- this.identity = undefined;
132
- this.sessionId = undefined;
133
- this.restoredMarkerId = undefined;
134
- this.restoredCompactionId = undefined;
135
- this.boundary = { kind: "none" };
136
- this.budget.reset();
137
- this.projectedContextTokens = undefined;
138
- }
139
-
140
- restore(entries: readonly SessionEntry[], sessionId?: string): void {
141
- // A rebuild of derived state must not drop an outstanding rollover guard;
142
- // only a changed session or an observed target marker retires it below.
143
- this.resetWindowState();
144
- this.sessionId = sessionId;
145
- for (const entry of entries) {
146
- if (entry.type === "compaction") {
147
- this.restoredCompactionId = entry.id;
148
- continue;
149
- }
150
- if (entry.type !== "custom_message" || entry.customType !== CODEX_CONTEXT_WINDOW_MESSAGE_TYPE) continue;
151
- if (!couldBelongToSession(entry.details, sessionId)) continue;
152
- if (!isCodexContextManagementMessageDetails(entry.details)) {
153
- throw new Error("Malformed persisted Codex context-window message");
154
- }
155
- if (!matchesSession(entry.details.sessionId, sessionId)) continue;
156
- const details = entry.details.contextManagement;
157
- this.restoredMarkerId = entry.id;
158
- if (details.kind === "window") {
159
- this.identity = identityFromDetails(entry.details);
160
- }
161
- this.budget.restore(details.kind, details.currentWindowId);
162
- }
163
- this.boundary = resolveWindowBoundary(entries, sessionId);
164
- this.retireSatisfiedOrStalePending(entries, sessionId);
165
- }
166
-
167
- /** Rebuild state when Pi navigates to a different session/branch. */
168
- synchronize(ctx: Pick<ExtensionContext, "sessionManager">): void {
169
- const entries = ctx.sessionManager.getBranch();
170
- const sessionId = ctx.sessionManager.getSessionId();
171
- this.retireSatisfiedOrStalePending(entries, sessionId);
172
- const latestMarkerId = findLatestContextMarkerId(entries, sessionId);
173
- let latestCompactionId: string | undefined;
174
- for (let index = entries.length - 1; index >= 0; index--) {
175
- if (entries[index]!.type === "compaction") {
176
- latestCompactionId = entries[index]!.id;
177
- break;
178
- }
179
- }
180
- // Hook-provided compactions may have no success callback. The persisted
181
- // branch also detects navigation before/after a commit with the same marker.
182
- if (this.sessionId !== sessionId || this.restoredMarkerId !== latestMarkerId || this.restoredCompactionId !== latestCompactionId) {
183
- this.restore(entries, sessionId);
184
- }
185
- // Edits and sibling navigation may leave marker/compaction IDs unchanged.
186
- this.boundary = resolveWindowBoundary(entries, sessionId);
187
- }
188
-
189
- /**
190
- * Drop a pending rollover only for durable reasons: the target window marker
191
- * is now persisted in this session, or the session changed. A queued marker
192
- * visible in an in-memory message list is not durable evidence.
193
- */
194
- private retireSatisfiedOrStalePending(
195
- entries: readonly SessionEntry[],
196
- sessionId: string | undefined,
197
- ): void {
198
- const pending = this.pendingRollover;
199
- if (!pending) return;
200
- if (pending.sessionId !== undefined && pending.sessionId !== sessionId) {
201
- this.pendingRollover = undefined;
202
- return;
203
- }
204
- if (hasPersistedWindowBoundary(entries, sessionId, pending.targetWindowId)) {
205
- this.pendingRollover = undefined;
206
- }
207
- }
208
-
209
- ensureInitialized(pi: ExtensionAPI, ctx: ExtensionContext, active: boolean): void {
210
- if (!active) return;
211
- this.synchronize(ctx);
212
- if (this.identity) return;
213
- const windowId = randomUUID();
214
- this.sendWindowMessage(
215
- pi,
216
- ctx,
217
- { firstWindowId: windowId, currentWindowId: windowId, windowNumber: 0 },
218
- { triggerTurn: false, trimPreviousWindow: false },
219
- );
220
- }
221
-
222
- project(
223
- messages: readonly AgentMessage[],
224
- mode: "off" | "local" | "remote",
225
- /** Delay encoding until verified retained copies have been removed. */
226
- encodeEncryptedOutputs = true,
227
- ): AgentMessage[] {
228
- // Recompute the request-only preview on every projection. Observing a
229
- // queued marker must not authorize maintenance or retire the duplicate guard.
230
- if (this.pendingRollover) this.pendingRollover.projected = false;
231
- if (mode === "off") {
232
- this.projectedContextTokens = undefined;
233
- return messages.filter(
234
- (message) => message.role !== "custom" || message.customType !== CODEX_CONTEXT_WINDOW_MESSAGE_TYPE,
235
- );
236
- }
237
- if (this.boundary.kind === "ambiguous") {
238
- throw new Error("Persisted Codex context-window boundary missing or edited in canonical projection");
239
- }
240
- if (this.boundary.kind === "superseded") this.identity = identityFromDetails(this.boundary.entry.details);
241
- let boundaryIndex = -1;
242
- for (let index = 0; index < messages.length; index += 1) {
243
- const message = messages[index]!;
244
- if (
245
- message.role === "custom" &&
246
- message.customType === CODEX_CONTEXT_WINDOW_MESSAGE_TYPE
247
- ) {
248
- if (!couldBelongToSession(message.details, this.sessionId)) continue;
249
- if (!isCodexContextManagementMessageDetails(message.details)) {
250
- throw new Error("Malformed persisted Codex context-window message");
251
- }
252
- if (!matchesSession(message.details.sessionId, this.sessionId)) continue;
253
- }
254
- if (!isContextWindowBoundary(message)) continue;
255
- const queuedRollover = mode === "remote" && this.pendingRollover?.targetWindowId === message.details.contextManagement.currentWindowId;
256
- if (this.boundary.kind === "superseded" && !queuedRollover) continue;
257
- // Local consumers follow the synchronized persisted identity, not a
258
- // queued marker from the request view. They never own the Remote session.
259
- if (mode === "local" && message.details.contextManagement.currentWindowId !== this.identity?.currentWindowId) continue;
260
- boundaryIndex = index;
261
- if (mode === "remote") {
262
- this.identity = identityFromDetails(message.details);
263
- if (this.pendingRollover) this.pendingRollover.projected = queuedRollover;
264
- }
265
- }
266
- if (boundaryIndex < 0 && this.boundary.kind === "effective") {
267
- throw new Error("Persisted Codex context-window boundary missing from effective projection");
268
- }
269
- // Projection observes the request view, never durable session state. A
270
- // queued rollover marker must not clear the duplicate guard here.
271
- const trimmed = boundaryIndex < 0 ? [...messages] : this.trimToWindow(messages, boundaryIndex, mode === "local");
272
- const projected = mode === "remote" ? (encodeEncryptedOutputs ? projectEncryptedToolResults(trimmed) : trimmed) : trimmed.filter(
273
- (message) => message.role !== "custom" || message.customType !== CODEX_CONTEXT_WINDOW_MESSAGE_TYPE,
274
- );
275
- this.projectedContextTokens = mode === "remote" && this.identity
276
- ? estimateProjectedWindowTokens(projected)
277
- : undefined;
278
- return projected;
279
- }
280
-
281
- /**
282
- * Trim everything before the window boundary while keeping Pi's prompt/tool head.
283
- *
284
- * When the head cannot be preserved the trim is refused and the full message list is
285
- * sent, because a window without tools silently degrades into a chat the model can
286
- * only narrate. Each refusal is reported once per window and reason.
287
- */
288
- private trimToWindow(messages: readonly AgentMessage[], boundaryIndex: number, local = false): AgentMessage[] {
289
- const repair = preserveSystemHead({ messages, boundaryIndex, fallbackHead: this.lastKnownSystemHead });
290
- if (repair.head) this.lastKnownSystemHead = repair.head;
291
- if (repair.shrinkRejected) {
292
- // Remote requests retain the existing tool-preservation fallback. A
293
- // local request must never recover tools by leaking retired windows.
294
- if (local) throw new Error("Cannot preserve the system/tool head in local context-window projection");
295
- this.recordProjectionDiagnostic(repair.lostToolNames);
296
- }
297
- return [...repair.messages];
298
- }
299
-
300
- private recordProjectionDiagnostic(lostToolNames: readonly string[]): void {
301
- const key = `${this.identity?.currentWindowId ?? "unknown"}|${[...lostToolNames].sort().join(",")}`;
302
- this.projectionDiagnostics.add(key);
303
- }
304
-
305
- /**
306
- * Drain the pending `tool-loadout-shrink` reports. Returns one entry per window and
307
- * lost-tool set, so a caller can surface the incident without flooding the UI.
308
- */
309
- takeProjectionDiagnostics(): string[] {
310
- if (this.projectionDiagnostics.size === 0) return [];
311
- const pending = [...this.projectionDiagnostics];
312
- this.projectionDiagnostics.clear();
313
- return pending;
314
- }
315
-
316
- async startNewWindow(
317
- pi: ExtensionAPI,
318
- ctx: ExtensionContext,
319
- options: StartContextWindowOptions,
320
- ): Promise<boolean> {
321
- this.synchronize(ctx);
322
- // A same-session rollover whose target marker is not persisted yet is
323
- // still in flight: report it instead of scheduling a second window.
324
- if (this.hasPendingRollover(ctx)) return false;
325
- if (options.signal?.aborted) throw new Error("Remote context rollover was aborted");
326
- const current = this.identity;
327
- const liveSessionId = ctx.sessionManager.getSessionId();
328
- const currentWindowId = randomUUID();
329
- const next: ContextWindowIdentity = current
330
- ? {
331
- firstWindowId: current.firstWindowId,
332
- currentWindowId,
333
- previousWindowId: current.currentWindowId,
334
- windowNumber: current.windowNumber + 1,
335
- }
336
- : { firstWindowId: currentWindowId, currentWindowId, windowNumber: 0 };
337
- this.pendingRollover = { sessionId: liveSessionId, targetWindowId: currentWindowId };
338
- try {
339
- const checkpoint = current
340
- ? findLatestNotesCheckpointSinceBoundary(ctx.sessionManager.getBranch(), liveSessionId)
341
- : undefined;
342
- let threadHint: string | undefined;
343
- if (current) {
344
- try {
345
- threadHint = await this.loadThreadHint(ctx, options.signal, options.gatewayModels);
346
- } catch (error) {
347
- if (options.signal?.aborted) throw error;
348
- }
349
- }
350
- if (options.signal?.aborted) throw new Error("Remote context rollover was aborted");
351
- options.beforeSchedule?.(next);
352
- this.sendWindowMessage(pi, ctx, next, options, threadHint, checkpoint);
353
- return true;
354
- } catch (error) {
355
- this.pendingRollover = undefined;
356
- throw error;
357
- }
358
- }
359
-
360
- recordBudget(
361
- pi: ExtensionAPI,
362
- ctx: ExtensionContext,
363
- active: boolean,
364
- contextReminderThresholdPercent: number,
365
- contextTokens?: number,
366
- ): void {
367
- if (!active || !this.identity || contextReminderThresholdPercent <= 0) return;
368
- // Right after a rollover, Pi's usage anchor still reports the previous
369
- // window's last request until the new window's first request completes.
370
- // Budget decisions taken in that gap act on stale numbers: they burn the
371
- // once-per-window reminder on a false alarm seconds after a successful
372
- // rollover, leaving the window silent for the rest of its life.
373
- if (!hasAssistantUsageSinceWindowBoundary(ctx.sessionManager.getBranch(), this.sessionId)) return;
374
- const reminder = this.budget.record(
375
- ctx,
376
- this.identity,
377
- contextTokens ?? this.projectedContextTokens,
378
- contextReminderThresholdPercent,
379
- );
380
- if (!reminder) return;
381
- sendContextWindowMessage(
382
- pi,
383
- reminder.content,
384
- reminder.kind,
385
- this.identity,
386
- { triggerTurn: reminder.kind === "fallback", sessionId: ctx.sessionManager.getSessionId() },
387
- );
388
- }
389
-
390
- remaining(ctx: ExtensionContext, contextTokens?: number): ContextRemaining {
391
- return this.budget.remaining(ctx, this.identity, contextTokens ?? this.projectedContextTokens);
392
- }
393
-
394
- /**
395
- * True when a notes checkpoint succeeded in the current window (after the
396
- * latest boundary). The scan always uses the live session id: a cached id can
397
- * belong to a session Pi already navigated away from, which would filter the
398
- * current window's marker and result out as foreign.
399
- */
400
- hasNotesCheckpointSinceBoundary(
401
- ctx: Pick<ExtensionContext, "sessionManager">,
402
- ): boolean {
403
- return findNotesCheckpointSinceBoundary(
404
- ctx.sessionManager.getBranch(),
405
- ctx.sessionManager.getSessionId(),
406
- );
407
- }
408
-
409
- /**
410
- * True while a rollover scheduled for the current session is awaiting its
411
- * persisted target marker. Call `synchronize()` first so a marker that has
412
- * already been persisted or a session switch retires the guard.
413
- */
414
- hasPendingRollover(ctx: Pick<ExtensionContext, "sessionManager">): boolean {
415
- const pending = this.pendingRollover;
416
- if (!pending) return false;
417
- return pending.sessionId === undefined || pending.sessionId === ctx.sessionManager.getSessionId();
418
- }
419
-
420
- /**
421
- * Whether a rollover may be scheduled from the current window.
422
- *
423
- * A window that was entered by a context switch has to do real work before it switches
424
- * again: without this guard a model can checkpoint and roll over on its very first turn,
425
- * which is how long sessions start looping between notes and new_context.
426
- */
427
- canRolloverFromCurrentWindow(ctx: ExtensionContext): boolean {
428
- if (!this.identity || this.identity.windowNumber === 0) return true;
429
- // An exhausted window must always be able to escape: the budget fallback tells the
430
- // model to checkpoint and switch, and refusing that would strand the session. An
431
- // unmeasurable window keeps the guard, since nothing there is pushing for a switch.
432
- const { remainingTokens } = this.remaining(ctx);
433
- if (remainingTokens !== undefined && remainingTokens <= 0) return true;
434
- return hasSubstantiveToolResultSinceBoundary(ctx.sessionManager.getBranch(), this.sessionId);
435
- }
436
-
437
- prepareCompaction(
438
- event: SessionBeforeCompactEvent,
439
- ): { cancel: true } | { compaction: CompactionResult<ContextWindowCompactionDetails> } {
440
- const boundary = resolveWindowBoundary(event.branchEntries, this.sessionId);
441
- if (boundary.kind !== "effective" || boundary.cleaned || !boundary.entry.details.contextManagement.trimPreviousWindow) {
442
- return { cancel: true };
443
- }
444
- // An unpersisted/cancelled proposal changes no eligibility.
445
- return { compaction: this.createCompaction(event, boundary.entry) };
446
- }
447
-
448
- /**
449
- * Detach a local consumer at the durable Remote boundary, without summarizing
450
- * any Remote-managed history. Unlike a scheduled rollover trim, this needs
451
- * no pending trim and remains retryable until Pi persists the result.
452
- */
453
- prepareDetachedCompaction(
454
- event: SessionBeforeCompactEvent,
455
- ): { cancel: true } | { compaction: CompactionResult<ContextWindowCompactionDetails> } | undefined {
456
- const boundary = resolveWindowBoundary(event.branchEntries, this.sessionId);
457
- if (boundary.kind === "ambiguous") throw new Error("Persisted Codex context-window boundary missing or edited");
458
- if (boundary.kind !== "effective") return undefined;
459
- if (boundary.cleaned) return { cancel: true };
460
- const compaction = this.createCompaction(event, boundary.entry);
461
- // Pi's preparation/tokensBefore describes the durable branch, which includes
462
- // Remote-managed history. Detached local accounting must use the same current
463
- // window projection that the non-window provider sees instead.
464
- return {
465
- compaction: {
466
- ...compaction,
467
- tokensBefore: this.estimateLocalContextTokens(event.branchEntries),
468
- },
469
- };
470
- }
471
-
472
- private estimateLocalContextTokens(entries: readonly SessionEntry[]): number {
473
- const messages = buildSessionProjection([...entries]).messages;
474
- return this.project(messages, "local").reduce((total, message) => total + estimateTokens(message as never), 0);
475
- }
476
-
477
- private createCompaction(event: SessionBeforeCompactEvent, boundary: WindowBoundaryEntry): CompactionResult<ContextWindowCompactionDetails> {
478
- return {
479
- summary: CONTEXT_WINDOW_COMPACTION_SUMMARY,
480
- firstKeptEntryId: boundary.id,
481
- tokensBefore: event.preparation.tokensBefore,
482
- details: {
483
- protocol: CONTEXT_MANAGEMENT_PROTOCOL,
484
- strategy: CONTEXT_WINDOW_COMPACTION_STRATEGY,
485
- windowId: boundary.details.contextManagement.currentWindowId,
486
- },
487
- };
488
- }
489
-
490
- rewritePayload(payload: unknown, ctx: ExtensionContext): unknown {
491
- const withMetadata = rewriteWindowPayload(payload, ctx, this.identity);
492
- return rewriteContextNamespaceTools(withMetadata, { encrypted: true });
493
- }
494
-
495
- rewriteHeaders(headers: ProviderHeaders, ctx: ExtensionContext): void {
496
- rewriteWindowHeaders(headers, ctx, this.identity);
497
- }
498
-
499
- private sendWindowMessage(
500
- pi: ExtensionAPI,
501
- ctx: ExtensionContext,
502
- identity: ContextWindowIdentity,
503
- options: StartContextWindowOptions,
504
- threadHint?: string,
505
- checkpoint?: NotesCheckpointReceipt,
506
- ): void {
507
- sendContextWindowMessage(
508
- pi,
509
- renderContextWindowMessage(identity, threadHint, checkpoint),
510
- "window",
511
- identity,
512
- { triggerTurn: options.triggerTurn, sessionId: ctx.sessionManager.getSessionId() },
513
- options.trimPreviousWindow,
514
- );
515
- this.identity = identity;
516
- this.sessionId = ctx.sessionManager.getSessionId();
517
- this.restoredMarkerId = undefined;
518
- // sendMessage() acceptance is not persistence. The pending rollover stays
519
- // armed until synchronize() observes this target window in the persisted
520
- // branch (or the session changes).
521
- }
522
- }
523
-
524
- type WindowBoundaryResolution =
525
- | { kind: "none" | "ambiguous" }
526
- | { kind: "superseded"; entry: WindowBoundaryEntry }
527
- | { kind: "effective"; entry: WindowBoundaryEntry; cleaned: boolean };
528
-
529
- /** Pi's projection owns context; raw chronology only establishes window ownership. */
530
- function resolveWindowBoundary(entries: readonly SessionEntry[], sessionId?: string): WindowBoundaryResolution {
531
- const boundary = findLatestWindowBoundaryEntry(entries, sessionId);
532
- if (!boundary) return { kind: "none" };
533
- const projection = buildSessionProjection([...entries]);
534
- const latest = projection.entries.find((entry) => entry.sourceEntry.type === "compaction")?.sourceEntry;
535
- const boundaryIndex = entries.findIndex((entry) => entry.id === boundary.id);
536
- const laterCompaction = latest?.type === "compaction" && entries.findIndex((entry) => entry.id === latest.id) > boundaryIndex
537
- ? latest : undefined;
538
- if (laterCompaction && hasInvalidatedCompactionContext([...entries], laterCompaction.id)) return { kind: "ambiguous" };
539
- const marker = projection.entries.find((entry) => entry.sourceEntry.id === boundary.id);
540
- // A surviving edit can replace content without changing custom details.
541
- const edited = entries.some((entry) => entry.type === "context_edit" && entry.targetId === boundary.id &&
542
- projection.entries.some((projected) => projected.sourceEntry.id === entry.id));
543
- if (edited) return { kind: "ambiguous" };
544
- const cleaned = laterCompaction !== undefined &&
545
- isContextWindowCompactionDetails(laterCompaction.details) &&
546
- laterCompaction.details.windowId === boundary.details.contextManagement.currentWindowId &&
547
- laterCompaction.firstKeptEntryId === boundary.id;
548
- if (laterCompaction && !cleaned) return { kind: "superseded", entry: boundary };
549
- if (!marker?.messages.some(isContextWindowBoundary)) return { kind: "ambiguous" };
550
- return { kind: "effective", entry: boundary, cleaned };
551
- }
552
-
553
- function identityFromDetails(details: CodexContextManagementMessageDetails): ContextWindowIdentity {
554
- const context = details.contextManagement;
555
- return {
556
- firstWindowId: context.firstWindowId,
557
- currentWindowId: context.currentWindowId,
558
- ...(context.previousWindowId ? { previousWindowId: context.previousWindowId } : {}),
559
- windowNumber: context.windowNumber,
560
- };
561
- }
562
-
563
- const NOTES_CHECKPOINT_ACTIONS: ReadonlySet<string> = new Set(["append_to_file", "write_file"]);
564
-
565
- /** Tools that only move context around; they never count as work done in a window. */
566
- const CONTEXT_MANAGEMENT_TOOL_NAMES: ReadonlySet<string> = new Set([
567
- "new_context",
568
- "get_context_remaining",
569
- "history",
570
- "notes",
571
- ]);
572
-
573
- /**
574
- * Whether the current window produced a tool result from anything other than the
575
- * context-handoff tools. Derived from persisted entries so it survives restarts.
576
- */
577
- export function hasSubstantiveToolResultSinceBoundary(
578
- entries: readonly SessionEntry[],
579
- sessionId?: string,
580
- ): boolean {
581
- const boundaryIndex = findLatestWindowBoundaryIndex(entries, sessionId);
582
- if (boundaryIndex < 0) return true;
583
- for (let index = boundaryIndex + 1; index < entries.length; index += 1) {
584
- const entry = entries[index]!;
585
- if (entry.type !== "message") continue;
586
- const message = entry.message as unknown as { role?: string; toolName?: string };
587
- if (message.role !== "toolResult" || typeof message.toolName !== "string") continue;
588
- if (!CONTEXT_MANAGEMENT_TOOL_NAMES.has(message.toolName)) return true;
589
- }
590
- return false;
591
- }
592
-
593
- /**
594
- * Whether the branch contains a successful assistant usage after the latest
595
- * window boundary. Before that first usage lands, any context measurement is
596
- * still anchored to the previous window's last request.
597
- */
598
- export function hasAssistantUsageSinceWindowBoundary(
599
- entries: readonly SessionEntry[],
600
- sessionId?: string,
601
- ): boolean {
602
- const boundaryIndex = findLatestWindowBoundaryIndex(entries, sessionId);
603
- if (boundaryIndex < 0) return false;
604
- for (let index = boundaryIndex + 1; index < entries.length; index += 1) {
605
- const entry = entries[index]!;
606
- if (entry.type !== "message") continue;
607
- const message = entry.message;
608
- if (message.role !== "assistant") continue;
609
- const candidate = message as unknown as {
610
- stopReason?: string;
611
- usage?: { totalTokens?: number; input?: number; output?: number; cacheRead?: number; cacheWrite?: number };
612
- };
613
- if (candidate.stopReason === "aborted" || candidate.stopReason === "error") continue;
614
- const usage = candidate.usage;
615
- if (!usage) continue;
616
- const tokens = usage.totalTokens ?? (usage.input ?? 0) + (usage.output ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
617
- if (tokens > 0) return true;
618
- }
619
- return false;
620
- }
621
-
622
- /**
623
- * Return the most recent successful notes write after the latest window
624
- * boundary. The receipt is derived from persisted session entries so it
625
- * survives restarts and can be used for an explicit rollover handoff.
626
- */
627
- export function findLatestNotesCheckpointSinceBoundary(
628
- entries: readonly SessionEntry[],
629
- sessionId?: string,
630
- afterEntryId?: string,
631
- ): NotesCheckpointReceipt | undefined {
632
- const windowBoundaryIndex = findLatestWindowBoundaryIndex(entries, sessionId);
633
- const afterIndex = afterEntryId === undefined ? -1 : entries.findIndex((entry) => entry.id === afterEntryId);
634
- if (windowBoundaryIndex < 0 || (afterEntryId !== undefined && afterIndex < 0)) return undefined;
635
- const boundaryIndex = Math.max(windowBoundaryIndex, afterIndex);
636
- const checkpointCalls = new Map<string, NotesCheckpointReceipt>();
637
- let latest: NotesCheckpointReceipt | undefined;
638
- for (let index = boundaryIndex + 1; index < entries.length; index += 1) {
639
- const entry = entries[index]!;
640
- if (entry.type !== "message") continue;
641
- const message = entry.message;
642
- if (message.role === "assistant") {
643
- const parts = Array.isArray(message.content) ? message.content : [];
644
- for (const part of parts) {
645
- if (!isRecord(part) || part.type !== "toolCall" || part.name !== "notes") continue;
646
- const args = isRecord(part.arguments) ? part.arguments : undefined;
647
- const action = typeof args?.action === "string" ? args.action : "";
648
- const path = isNonEmptyString(args?.path) ? args.path : undefined;
649
- if (isNonEmptyString(part.id) && path !== undefined && NOTES_CHECKPOINT_ACTIONS.has(action)) {
650
- checkpointCalls.set(part.id, { path, toolCallId: part.id });
651
- }
652
- }
653
- continue;
654
- }
655
- if (message.role !== "toolResult" || message.toolName !== "notes") continue;
656
- const receipt = checkpointCalls.get(message.toolCallId);
657
- if (!receipt) continue;
658
- checkpointCalls.delete(message.toolCallId);
659
- if (message.isError !== false || !isSuccessfulHistoryNotesToolResult(message.details)) continue;
660
- latest = receipt;
661
- }
662
- return latest;
663
- }
664
-
665
- /**
666
- * Whether the branch contains a successful notes append/write result after the
667
- * latest window boundary. Reads and failed writes never count as checkpoints.
668
- */
669
- export function findNotesCheckpointSinceBoundary(
670
- entries: readonly SessionEntry[],
671
- sessionId?: string,
672
- ): boolean {
673
- return findLatestNotesCheckpointSinceBoundary(entries, sessionId) !== undefined;
674
- }
675
-
676
- function findLatestWindowBoundaryIndex(
677
- entries: readonly SessionEntry[],
678
- sessionId?: string,
679
- ): number {
680
- for (let index = entries.length - 1; index >= 0; index -= 1) {
681
- const entry = entries[index]!;
682
- if (entry.type !== "custom_message" || entry.customType !== CODEX_CONTEXT_WINDOW_MESSAGE_TYPE) continue;
683
- if (!couldBelongToSession(entry.details, sessionId)) continue;
684
- if (!isCodexContextManagementMessageDetails(entry.details)) {
685
- throw new Error("Malformed persisted Codex context-window message");
686
- }
687
- if (!matchesSession(entry.details.sessionId, sessionId)) continue;
688
- if (entry.details.contextManagement.kind === "window") return index;
689
- }
690
- return -1;
691
- }
692
-
693
- export function findLatestWindowBoundaryEntry(
694
- entries: readonly SessionEntry[],
695
- sessionId?: string,
696
- ): WindowBoundaryEntry | undefined {
697
- for (let index = entries.length - 1; index >= 0; index -= 1) {
698
- const entry = entries[index]!;
699
- if (entry.type !== "custom_message" || entry.customType !== CODEX_CONTEXT_WINDOW_MESSAGE_TYPE) continue;
700
- if (!couldBelongToSession(entry.details, sessionId)) continue;
701
- if (!isCodexContextManagementMessageDetails(entry.details)) {
702
- throw new Error("Malformed persisted Codex context-window message");
703
- }
704
- if (!matchesSession(entry.details.sessionId, sessionId)) continue;
705
- if (entry.details.contextManagement.kind === "window") return entry as WindowBoundaryEntry;
706
- }
707
- return undefined;
708
- }
709
-
710
- /**
711
- * Whether this exact window boundary is already persisted for the session.
712
- * Only durable entries count; a queued/visible marker does not.
713
- */
714
- function hasPersistedWindowBoundary(
715
- entries: readonly SessionEntry[],
716
- sessionId: string | undefined,
717
- windowId: string,
718
- ): boolean {
719
- for (const entry of entries) {
720
- if (entry.type !== "custom_message" || entry.customType !== CODEX_CONTEXT_WINDOW_MESSAGE_TYPE) continue;
721
- if (!couldBelongToSession(entry.details, sessionId)) continue;
722
- if (!isCodexContextManagementMessageDetails(entry.details)) continue;
723
- if (!matchesSession(entry.details.sessionId, sessionId)) continue;
724
- const details = entry.details.contextManagement;
725
- if (details.kind === "window" && details.currentWindowId === windowId) return true;
726
- }
727
- return false;
728
- }
729
-
730
- function findLatestContextMarkerId(
731
- entries: readonly SessionEntry[],
732
- sessionId?: string,
733
- ): string | undefined {
734
- for (let index = entries.length - 1; index >= 0; index -= 1) {
735
- const entry = entries[index]!;
736
- if (entry.type !== "custom_message" || entry.customType !== CODEX_CONTEXT_WINDOW_MESSAGE_TYPE) continue;
737
- if (!couldBelongToSession(entry.details, sessionId)) continue;
738
- if (!isCodexContextManagementMessageDetails(entry.details)) {
739
- throw new Error("Malformed persisted Codex context-window message");
740
- }
741
- if (matchesSession(entry.details.sessionId, sessionId)) return entry.id;
742
- }
743
- return undefined;
744
- }
745
-
746
- function couldBelongToSession(details: unknown, sessionId: string | undefined): boolean {
747
- if (sessionId === undefined || !isRecord(details)) return true;
748
- const markerSessionId = details.sessionId;
749
- return !isNonEmptyString(markerSessionId) || markerSessionId === sessionId;
750
- }
751
-
752
- function matchesSession(markerSessionId: string | undefined, sessionId: string | undefined): boolean {
753
- return sessionId === undefined || markerSessionId === sessionId;
754
- }
755
-
756
- function estimateProjectedWindowTokens(messages: readonly AgentMessage[]): number {
757
- let lastUsageIndex = -1;
758
- let usageTokens = 0;
759
- for (let index = messages.length - 1; index >= 0; index -= 1) {
760
- const message = messages[index]!;
761
- if (message.role !== "assistant" || message.stopReason === "aborted" || message.stopReason === "error" || !message.usage) continue;
762
- const tokens = calculateContextTokens(message.usage);
763
- if (tokens <= 0) continue;
764
- lastUsageIndex = index;
765
- usageTokens = tokens;
766
- break;
767
- }
768
- if (lastUsageIndex < 0) return messages.reduce((total, message) => total + estimateProjectedMessageTokens(message), 0);
769
- return usageTokens + messages
770
- .slice(lastUsageIndex + 1)
771
- .reduce((total, message) => total + estimateProjectedMessageTokens(message), 0);
772
- }
773
-
774
- function estimateProjectedMessageTokens(message: AgentMessage): number {
775
- // A synthetic custom marker may be represented without content in a test or
776
- // by an older host. It contributes no measurable text; it must not make the
777
- // context hook fail closed merely because the diagnostic estimate is missing.
778
- if (
779
- !("content" in message) ||
780
- (typeof message.content !== "string" && !Array.isArray(message.content))
781
- ) return 0;
782
- return estimateTokens(message);
783
- }
784
-
785
- function projectEncryptedToolResults(messages: readonly AgentMessage[]): AgentMessage[] {
786
- let changed = false;
787
- const projected = messages.map((message) => {
788
- if (message.role !== "toolResult" || !isRecord(message.details)) return message;
789
- const historyNotes = message.details.codexHistoryNotes;
790
- if (!isRecord(historyNotes) || typeof historyNotes.encrypted_output !== "string") return message;
791
- changed = true;
792
- return {
793
- ...message,
794
- content: [
795
- { type: "text" as const, text: encodeEncryptedOutputForContext(historyNotes.encrypted_output) },
796
- ...message.content.filter((item) => item.type === "image"),
797
- ],
798
- };
799
- });
800
- return changed ? projected : [...messages];
801
- }
1
+ import { randomUUID } from "node:crypto";
2
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
3
+ import type { ProviderHeaders } from "@earendil-works/pi-ai";
4
+ import {
5
+ buildSessionProjection,
6
+ calculateContextTokens,
7
+ estimateTokens,
8
+ type CompactionResult,
9
+ type ExtensionAPI,
10
+ type ExtensionContext,
11
+ type SessionBeforeCompactEvent,
12
+ type SessionEntry,
13
+ } from "@earendil-works/pi-coding-agent";
14
+ import { ContextWindowBudget, type ContextRemaining } from "./window-budget";
15
+ import {
16
+ rewriteEncryptedToolOutputs,
17
+ rewriteWindowHeaders,
18
+ rewriteWindowPayload,
19
+ } from "./window-request";
20
+ import {
21
+ CONTEXT_WINDOW_COMPACTION_STRATEGY,
22
+ CONTEXT_WINDOW_COMPACTION_SUMMARY,
23
+ isContextWindowBoundary,
24
+ preserveSystemHead,
25
+ renderContextWindowMessage,
26
+ sendContextWindowMessage,
27
+ } from "./messages";
28
+ import {
29
+ CODEX_CONTEXT_WINDOW_MESSAGE_TYPE,
30
+ CONTEXT_MANAGEMENT_PROTOCOL,
31
+ type CodexContextManagementMessageDetails,
32
+ type ContextWindowCompactionDetails,
33
+ type ContextWindowIdentity,
34
+ isCodexContextManagementMessageDetails,
35
+ isNonEmptyString,
36
+ isRecord,
37
+ isContextWindowCompactionDetails,
38
+ type NotesCheckpointReceipt,
39
+ } from "./types";
40
+ import {
41
+ encodeEncryptedOutputForContext,
42
+ isSuccessfulHistoryNotesToolResult,
43
+ loadHistoryNotesThreadHint,
44
+ } from "./history-notes";
45
+ import { rewriteContextNamespaceTools } from "./namespace-tools";
46
+ import { hasInvalidatedCompactionContext } from "../payload-rewrite";
47
+
48
+ interface StartContextWindowOptions {
49
+ triggerTurn: boolean;
50
+ signal?: AbortSignal;
51
+ trimPreviousWindow: boolean;
52
+ /** Captured with the tool activation policy, before any awaited backend work. */
53
+ gatewayModels?: readonly string[];
54
+ /** Persist an owned handoff's exact target before the marker can be queued. */
55
+ beforeSchedule?: (identity: ContextWindowIdentity) => void;
56
+ }
57
+
58
+ type ThreadHintLoader = (
59
+ ctx: ExtensionContext,
60
+ signal?: AbortSignal,
61
+ gatewayModels?: readonly string[],
62
+ ) => Promise<string | undefined>;
63
+
64
+ type WindowBoundaryEntry = Extract<SessionEntry, { type: "custom_message" }> & {
65
+ details: CodexContextManagementMessageDetails;
66
+ };
67
+
68
+ /**
69
+ * In-process duplicate guard for one scheduled rollover. It is anchored to the
70
+ * session that scheduled it and to the exact target window id, so it can never
71
+ * be satisfied by an older or foreign marker. It is not checkpoint evidence:
72
+ * only a persisted notes result unlocks the gate.
73
+ */
74
+ type PendingRollover = {
75
+ sessionId?: string;
76
+ targetWindowId: string;
77
+ /** The current request already contains this marker; never persistence proof. */
78
+ projected?: boolean;
79
+ };
80
+
81
+ export class CodexContextWindowManager {
82
+ private identity: ContextWindowIdentity | undefined;
83
+ private sessionId: string | undefined;
84
+ private restoredMarkerId: string | undefined;
85
+ private restoredCompactionId: string | undefined;
86
+ private boundary: WindowBoundaryResolution = { kind: "none" };
87
+ private readonly budget = new ContextWindowBudget();
88
+ private pendingRollover: PendingRollover | undefined;
89
+ private readonly projectionDiagnostics = new Set<string>();
90
+ private lastKnownSystemHead: AgentMessage | undefined;
91
+ /**
92
+ * Pi's getContextUsage() measures the durable transcript. Remote windows are
93
+ * projected only by this extension, so remember the last provider-visible
94
+ * estimate for budget decisions and get_context_remaining.
95
+ */
96
+ private projectedContextTokens: number | undefined;
97
+ private readonly loadThreadHint: ThreadHintLoader;
98
+
99
+ constructor(loadThreadHint?: ThreadHintLoader) {
100
+ this.loadThreadHint = loadThreadHint ?? ((ctx, signal, gatewayModels) => loadHistoryNotesThreadHint(ctx, signal, gatewayModels));
101
+ }
102
+
103
+ reset(): void {
104
+ this.resetWindowState();
105
+ this.pendingRollover = undefined;
106
+ this.projectionDiagnostics.clear();
107
+ this.lastKnownSystemHead = undefined;
108
+ }
109
+
110
+ currentIdentity(): ContextWindowIdentity | undefined {
111
+ return this.identity ? { ...this.identity } : undefined;
112
+ }
113
+ /** Derived eligibility, never a replayed marker command. */
114
+ hasPendingTrim(): boolean {
115
+ return this.boundary.kind === "effective" && !this.boundary.cleaned &&
116
+ this.boundary.entry.details.contextManagement.trimPreviousWindow === true;
117
+ }
118
+
119
+ /** Historical identity does not own a newer native/opaque checkpoint. */
120
+ hasEffectiveWindow(): boolean {
121
+ if (!this.identity || this.boundary.kind === "ambiguous") return false;
122
+ return this.boundary.kind !== "superseded" ||
123
+ (this.pendingRollover?.projected === true && this.pendingRollover.targetWindowId === this.identity.currentWindowId);
124
+ }
125
+
126
+ hasSupersededWindow(): boolean {
127
+ return this.boundary.kind === "superseded";
128
+ }
129
+
130
+ private resetWindowState(): void {
131
+ this.identity = undefined;
132
+ this.sessionId = undefined;
133
+ this.restoredMarkerId = undefined;
134
+ this.restoredCompactionId = undefined;
135
+ this.boundary = { kind: "none" };
136
+ this.budget.reset();
137
+ this.projectedContextTokens = undefined;
138
+ }
139
+
140
+ restore(entries: readonly SessionEntry[], sessionId?: string): void {
141
+ // A rebuild of derived state must not drop an outstanding rollover guard;
142
+ // only a changed session or an observed target marker retires it below.
143
+ this.resetWindowState();
144
+ this.sessionId = sessionId;
145
+ for (const entry of entries) {
146
+ if (entry.type === "compaction") {
147
+ this.restoredCompactionId = entry.id;
148
+ continue;
149
+ }
150
+ if (entry.type !== "custom_message" || entry.customType !== CODEX_CONTEXT_WINDOW_MESSAGE_TYPE) continue;
151
+ if (!couldBelongToSession(entry.details, sessionId)) continue;
152
+ if (!isCodexContextManagementMessageDetails(entry.details)) {
153
+ throw new Error("Malformed persisted Codex context-window message");
154
+ }
155
+ if (!matchesSession(entry.details.sessionId, sessionId)) continue;
156
+ const details = entry.details.contextManagement;
157
+ this.restoredMarkerId = entry.id;
158
+ if (details.kind === "window") {
159
+ this.identity = identityFromDetails(entry.details);
160
+ }
161
+ this.budget.restore(details.kind, details.currentWindowId);
162
+ }
163
+ this.boundary = resolveWindowBoundary(entries, sessionId);
164
+ this.retireSatisfiedOrStalePending(entries, sessionId);
165
+ }
166
+
167
+ /** Rebuild state when Pi navigates to a different session/branch. */
168
+ synchronize(ctx: Pick<ExtensionContext, "sessionManager">): void {
169
+ const entries = ctx.sessionManager.getBranch();
170
+ const sessionId = ctx.sessionManager.getSessionId();
171
+ this.retireSatisfiedOrStalePending(entries, sessionId);
172
+ const latestMarkerId = findLatestContextMarkerId(entries, sessionId);
173
+ let latestCompactionId: string | undefined;
174
+ for (let index = entries.length - 1; index >= 0; index--) {
175
+ if (entries[index]!.type === "compaction") {
176
+ latestCompactionId = entries[index]!.id;
177
+ break;
178
+ }
179
+ }
180
+ // Hook-provided compactions may have no success callback. The persisted
181
+ // branch also detects navigation before/after a commit with the same marker.
182
+ if (this.sessionId !== sessionId || this.restoredMarkerId !== latestMarkerId || this.restoredCompactionId !== latestCompactionId) {
183
+ this.restore(entries, sessionId);
184
+ }
185
+ // Edits and sibling navigation may leave marker/compaction IDs unchanged.
186
+ this.boundary = resolveWindowBoundary(entries, sessionId);
187
+ }
188
+
189
+ /**
190
+ * Drop a pending rollover only for durable reasons: the target window marker
191
+ * is now persisted in this session, or the session changed. A queued marker
192
+ * visible in an in-memory message list is not durable evidence.
193
+ */
194
+ private retireSatisfiedOrStalePending(
195
+ entries: readonly SessionEntry[],
196
+ sessionId: string | undefined,
197
+ ): void {
198
+ const pending = this.pendingRollover;
199
+ if (!pending) return;
200
+ if (pending.sessionId !== undefined && pending.sessionId !== sessionId) {
201
+ this.pendingRollover = undefined;
202
+ return;
203
+ }
204
+ if (hasPersistedWindowBoundary(entries, sessionId, pending.targetWindowId)) {
205
+ this.pendingRollover = undefined;
206
+ }
207
+ }
208
+
209
+ ensureInitialized(pi: ExtensionAPI, ctx: ExtensionContext, active: boolean): void {
210
+ if (!active) return;
211
+ this.synchronize(ctx);
212
+ if (this.identity) return;
213
+ const windowId = randomUUID();
214
+ this.sendWindowMessage(
215
+ pi,
216
+ ctx,
217
+ { firstWindowId: windowId, currentWindowId: windowId, windowNumber: 0 },
218
+ { triggerTurn: false, trimPreviousWindow: false },
219
+ );
220
+ }
221
+
222
+ project(
223
+ messages: readonly AgentMessage[],
224
+ mode: "off" | "local" | "remote",
225
+ /** Delay encoding until verified retained copies have been removed. */
226
+ encodeEncryptedOutputs = true,
227
+ ): AgentMessage[] {
228
+ // Recompute the request-only preview on every projection. Observing a
229
+ // queued marker must not authorize maintenance or retire the duplicate guard.
230
+ if (this.pendingRollover) this.pendingRollover.projected = false;
231
+ if (mode === "off") {
232
+ this.projectedContextTokens = undefined;
233
+ return messages.filter(
234
+ (message) => message.role !== "custom" || message.customType !== CODEX_CONTEXT_WINDOW_MESSAGE_TYPE,
235
+ );
236
+ }
237
+ if (this.boundary.kind === "ambiguous") {
238
+ throw new Error("Persisted Codex context-window boundary missing or edited in canonical projection");
239
+ }
240
+ if (this.boundary.kind === "superseded") this.identity = identityFromDetails(this.boundary.entry.details);
241
+ let boundaryIndex = -1;
242
+ for (let index = 0; index < messages.length; index += 1) {
243
+ const message = messages[index]!;
244
+ if (
245
+ message.role === "custom" &&
246
+ message.customType === CODEX_CONTEXT_WINDOW_MESSAGE_TYPE
247
+ ) {
248
+ if (!couldBelongToSession(message.details, this.sessionId)) continue;
249
+ if (!isCodexContextManagementMessageDetails(message.details)) {
250
+ throw new Error("Malformed persisted Codex context-window message");
251
+ }
252
+ if (!matchesSession(message.details.sessionId, this.sessionId)) continue;
253
+ }
254
+ if (!isContextWindowBoundary(message)) continue;
255
+ const queuedRollover = mode === "remote" && this.pendingRollover?.targetWindowId === message.details.contextManagement.currentWindowId;
256
+ if (this.boundary.kind === "superseded" && !queuedRollover) continue;
257
+ // Local consumers follow the synchronized persisted identity, not a
258
+ // queued marker from the request view. They never own the Remote session.
259
+ if (mode === "local" && message.details.contextManagement.currentWindowId !== this.identity?.currentWindowId) continue;
260
+ boundaryIndex = index;
261
+ if (mode === "remote") {
262
+ this.identity = identityFromDetails(message.details);
263
+ if (this.pendingRollover) this.pendingRollover.projected = queuedRollover;
264
+ }
265
+ }
266
+ if (boundaryIndex < 0 && this.boundary.kind === "effective") {
267
+ throw new Error("Persisted Codex context-window boundary missing from effective projection");
268
+ }
269
+ // Projection observes the request view, never durable session state. A
270
+ // queued rollover marker must not clear the duplicate guard here.
271
+ const trimmed = boundaryIndex < 0 ? [...messages] : this.trimToWindow(messages, boundaryIndex, mode === "local");
272
+ const projected = mode === "remote" ? (encodeEncryptedOutputs ? projectEncryptedToolResults(trimmed) : trimmed) : trimmed.filter(
273
+ (message) => message.role !== "custom" || message.customType !== CODEX_CONTEXT_WINDOW_MESSAGE_TYPE,
274
+ );
275
+ this.projectedContextTokens = mode === "remote" && this.identity
276
+ ? estimateProjectedWindowTokens(projected)
277
+ : undefined;
278
+ return projected;
279
+ }
280
+
281
+ /**
282
+ * Trim everything before the window boundary while keeping Pi's prompt/tool head.
283
+ *
284
+ * When the head cannot be preserved the trim is refused and the full message list is
285
+ * sent, because a window without tools silently degrades into a chat the model can
286
+ * only narrate. Each refusal is reported once per window and reason.
287
+ */
288
+ private trimToWindow(messages: readonly AgentMessage[], boundaryIndex: number, local = false): AgentMessage[] {
289
+ const repair = preserveSystemHead({ messages, boundaryIndex, fallbackHead: this.lastKnownSystemHead });
290
+ if (repair.head) this.lastKnownSystemHead = repair.head;
291
+ if (repair.shrinkRejected) {
292
+ // Remote requests retain the existing tool-preservation fallback. A
293
+ // local request must never recover tools by leaking retired windows.
294
+ if (local) throw new Error("Cannot preserve the system/tool head in local context-window projection");
295
+ this.recordProjectionDiagnostic(repair.lostToolNames);
296
+ }
297
+ return [...repair.messages];
298
+ }
299
+
300
+ private recordProjectionDiagnostic(lostToolNames: readonly string[]): void {
301
+ const key = `${this.identity?.currentWindowId ?? "unknown"}|${[...lostToolNames].sort().join(",")}`;
302
+ this.projectionDiagnostics.add(key);
303
+ }
304
+
305
+ /**
306
+ * Drain the pending `tool-loadout-shrink` reports. Returns one entry per window and
307
+ * lost-tool set, so a caller can surface the incident without flooding the UI.
308
+ */
309
+ takeProjectionDiagnostics(): string[] {
310
+ if (this.projectionDiagnostics.size === 0) return [];
311
+ const pending = [...this.projectionDiagnostics];
312
+ this.projectionDiagnostics.clear();
313
+ return pending;
314
+ }
315
+
316
+ async startNewWindow(
317
+ pi: ExtensionAPI,
318
+ ctx: ExtensionContext,
319
+ options: StartContextWindowOptions,
320
+ ): Promise<boolean> {
321
+ this.synchronize(ctx);
322
+ // A same-session rollover whose target marker is not persisted yet is
323
+ // still in flight: report it instead of scheduling a second window.
324
+ if (this.hasPendingRollover(ctx)) return false;
325
+ if (options.signal?.aborted) throw new Error("Remote context rollover was aborted");
326
+ const current = this.identity;
327
+ const liveSessionId = ctx.sessionManager.getSessionId();
328
+ const currentWindowId = randomUUID();
329
+ const next: ContextWindowIdentity = current
330
+ ? {
331
+ firstWindowId: current.firstWindowId,
332
+ currentWindowId,
333
+ previousWindowId: current.currentWindowId,
334
+ windowNumber: current.windowNumber + 1,
335
+ }
336
+ : { firstWindowId: currentWindowId, currentWindowId, windowNumber: 0 };
337
+ this.pendingRollover = { sessionId: liveSessionId, targetWindowId: currentWindowId };
338
+ try {
339
+ const checkpoint = current
340
+ ? findLatestNotesCheckpointSinceBoundary(ctx.sessionManager.getBranch(), liveSessionId)
341
+ : undefined;
342
+ let threadHint: string | undefined;
343
+ if (current) {
344
+ try {
345
+ threadHint = await this.loadThreadHint(ctx, options.signal, options.gatewayModels);
346
+ } catch (error) {
347
+ if (options.signal?.aborted) throw error;
348
+ }
349
+ }
350
+ if (options.signal?.aborted) throw new Error("Remote context rollover was aborted");
351
+ options.beforeSchedule?.(next);
352
+ this.sendWindowMessage(pi, ctx, next, options, threadHint, checkpoint);
353
+ return true;
354
+ } catch (error) {
355
+ this.pendingRollover = undefined;
356
+ throw error;
357
+ }
358
+ }
359
+
360
+ recordBudget(
361
+ pi: ExtensionAPI,
362
+ ctx: ExtensionContext,
363
+ active: boolean,
364
+ contextReminderThresholdPercent: number,
365
+ contextTokens?: number,
366
+ ): void {
367
+ if (!active || !this.identity || contextReminderThresholdPercent <= 0) return;
368
+ // Right after a rollover, Pi's usage anchor still reports the previous
369
+ // window's last request until the new window's first request completes.
370
+ // Budget decisions taken in that gap act on stale numbers: they burn the
371
+ // once-per-window reminder on a false alarm seconds after a successful
372
+ // rollover, leaving the window silent for the rest of its life.
373
+ if (!hasAssistantUsageSinceWindowBoundary(ctx.sessionManager.getBranch(), this.sessionId)) return;
374
+ const reminder = this.budget.record(
375
+ ctx,
376
+ this.identity,
377
+ contextTokens ?? this.projectedContextTokens,
378
+ contextReminderThresholdPercent,
379
+ );
380
+ if (!reminder) return;
381
+ sendContextWindowMessage(
382
+ pi,
383
+ reminder.content,
384
+ reminder.kind,
385
+ this.identity,
386
+ { triggerTurn: reminder.kind === "fallback", sessionId: ctx.sessionManager.getSessionId() },
387
+ );
388
+ }
389
+
390
+ remaining(ctx: ExtensionContext, contextTokens?: number): ContextRemaining {
391
+ return this.budget.remaining(ctx, this.identity, contextTokens ?? this.projectedContextTokens);
392
+ }
393
+
394
+ /**
395
+ * True when a notes checkpoint succeeded in the current window (after the
396
+ * latest boundary). The scan always uses the live session id: a cached id can
397
+ * belong to a session Pi already navigated away from, which would filter the
398
+ * current window's marker and result out as foreign.
399
+ */
400
+ hasNotesCheckpointSinceBoundary(
401
+ ctx: Pick<ExtensionContext, "sessionManager">,
402
+ ): boolean {
403
+ return findNotesCheckpointSinceBoundary(
404
+ ctx.sessionManager.getBranch(),
405
+ ctx.sessionManager.getSessionId(),
406
+ );
407
+ }
408
+
409
+ /**
410
+ * True while a rollover scheduled for the current session is awaiting its
411
+ * persisted target marker. Call `synchronize()` first so a marker that has
412
+ * already been persisted or a session switch retires the guard.
413
+ */
414
+ hasPendingRollover(ctx: Pick<ExtensionContext, "sessionManager">): boolean {
415
+ const pending = this.pendingRollover;
416
+ if (!pending) return false;
417
+ return pending.sessionId === undefined || pending.sessionId === ctx.sessionManager.getSessionId();
418
+ }
419
+
420
+ /**
421
+ * Whether a rollover may be scheduled from the current window.
422
+ *
423
+ * A window that was entered by a context switch has to do real work before it switches
424
+ * again: without this guard a model can checkpoint and roll over on its very first turn,
425
+ * which is how long sessions start looping between notes and new_context.
426
+ */
427
+ canRolloverFromCurrentWindow(ctx: ExtensionContext): boolean {
428
+ if (!this.identity || this.identity.windowNumber === 0) return true;
429
+ // An exhausted window must always be able to escape: the budget fallback tells the
430
+ // model to checkpoint and switch, and refusing that would strand the session. An
431
+ // unmeasurable window keeps the guard, since nothing there is pushing for a switch.
432
+ const { remainingTokens } = this.remaining(ctx);
433
+ if (remainingTokens !== undefined && remainingTokens <= 0) return true;
434
+ return hasSubstantiveToolResultSinceBoundary(ctx.sessionManager.getBranch(), this.sessionId);
435
+ }
436
+
437
+ prepareCompaction(
438
+ event: SessionBeforeCompactEvent,
439
+ ): { cancel: true } | { compaction: CompactionResult<ContextWindowCompactionDetails> } {
440
+ const boundary = resolveWindowBoundary(event.branchEntries, this.sessionId);
441
+ if (boundary.kind !== "effective" || boundary.cleaned || !boundary.entry.details.contextManagement.trimPreviousWindow) {
442
+ return { cancel: true };
443
+ }
444
+ // An unpersisted/cancelled proposal changes no eligibility.
445
+ return { compaction: this.createCompaction(event, boundary.entry) };
446
+ }
447
+
448
+ /**
449
+ * Detach a local consumer at the durable Remote boundary, without summarizing
450
+ * any Remote-managed history. Unlike a scheduled rollover trim, this needs
451
+ * no pending trim and remains retryable until Pi persists the result.
452
+ */
453
+ prepareDetachedCompaction(
454
+ event: SessionBeforeCompactEvent,
455
+ ): { cancel: true } | { compaction: CompactionResult<ContextWindowCompactionDetails> } | undefined {
456
+ const boundary = resolveWindowBoundary(event.branchEntries, this.sessionId);
457
+ if (boundary.kind === "ambiguous") throw new Error("Persisted Codex context-window boundary missing or edited");
458
+ if (boundary.kind !== "effective") return undefined;
459
+ if (boundary.cleaned) return { cancel: true };
460
+ const compaction = this.createCompaction(event, boundary.entry);
461
+ // Pi's preparation/tokensBefore describes the durable branch, which includes
462
+ // Remote-managed history. Detached local accounting must use the same current
463
+ // window projection that the non-window provider sees instead.
464
+ return {
465
+ compaction: {
466
+ ...compaction,
467
+ tokensBefore: this.estimateLocalContextTokens(event.branchEntries),
468
+ },
469
+ };
470
+ }
471
+
472
+ private estimateLocalContextTokens(entries: readonly SessionEntry[]): number {
473
+ const messages = buildSessionProjection([...entries]).messages;
474
+ return this.project(messages, "local").reduce((total, message) => total + estimateTokens(message as never), 0);
475
+ }
476
+
477
+ private createCompaction(event: SessionBeforeCompactEvent, boundary: WindowBoundaryEntry): CompactionResult<ContextWindowCompactionDetails> {
478
+ return {
479
+ summary: CONTEXT_WINDOW_COMPACTION_SUMMARY,
480
+ firstKeptEntryId: boundary.id,
481
+ tokensBefore: event.preparation.tokensBefore,
482
+ details: {
483
+ protocol: CONTEXT_MANAGEMENT_PROTOCOL,
484
+ strategy: CONTEXT_WINDOW_COMPACTION_STRATEGY,
485
+ windowId: boundary.details.contextManagement.currentWindowId,
486
+ },
487
+ };
488
+ }
489
+
490
+ rewritePayload(payload: unknown, ctx: ExtensionContext): unknown {
491
+ const withMetadata = rewriteWindowPayload(payload, ctx, this.identity);
492
+ return rewriteContextNamespaceTools(withMetadata, { encrypted: true });
493
+ }
494
+
495
+ rewriteHeaders(headers: ProviderHeaders, ctx: ExtensionContext): void {
496
+ rewriteWindowHeaders(headers, ctx, this.identity);
497
+ }
498
+
499
+ private sendWindowMessage(
500
+ pi: ExtensionAPI,
501
+ ctx: ExtensionContext,
502
+ identity: ContextWindowIdentity,
503
+ options: StartContextWindowOptions,
504
+ threadHint?: string,
505
+ checkpoint?: NotesCheckpointReceipt,
506
+ ): void {
507
+ sendContextWindowMessage(
508
+ pi,
509
+ renderContextWindowMessage(identity, threadHint, checkpoint),
510
+ "window",
511
+ identity,
512
+ { triggerTurn: options.triggerTurn, sessionId: ctx.sessionManager.getSessionId() },
513
+ options.trimPreviousWindow,
514
+ );
515
+ this.identity = identity;
516
+ this.sessionId = ctx.sessionManager.getSessionId();
517
+ this.restoredMarkerId = undefined;
518
+ // sendMessage() acceptance is not persistence. The pending rollover stays
519
+ // armed until synchronize() observes this target window in the persisted
520
+ // branch (or the session changes).
521
+ }
522
+ }
523
+
524
+ type WindowBoundaryResolution =
525
+ | { kind: "none" | "ambiguous" }
526
+ | { kind: "superseded"; entry: WindowBoundaryEntry }
527
+ | { kind: "effective"; entry: WindowBoundaryEntry; cleaned: boolean };
528
+
529
+ /** Pi's projection owns context; raw chronology only establishes window ownership. */
530
+ function resolveWindowBoundary(entries: readonly SessionEntry[], sessionId?: string): WindowBoundaryResolution {
531
+ const boundary = findLatestWindowBoundaryEntry(entries, sessionId);
532
+ if (!boundary) return { kind: "none" };
533
+ const projection = buildSessionProjection([...entries]);
534
+ const latest = projection.entries.find((entry) => entry.sourceEntry.type === "compaction")?.sourceEntry;
535
+ const boundaryIndex = entries.findIndex((entry) => entry.id === boundary.id);
536
+ const laterCompaction = latest?.type === "compaction" && entries.findIndex((entry) => entry.id === latest.id) > boundaryIndex
537
+ ? latest : undefined;
538
+ if (laterCompaction && hasInvalidatedCompactionContext([...entries], laterCompaction.id)) return { kind: "ambiguous" };
539
+ const marker = projection.entries.find((entry) => entry.sourceEntry.id === boundary.id);
540
+ // A surviving edit can replace content without changing custom details.
541
+ const edited = entries.some((entry) => entry.type === "context_edit" && entry.targetId === boundary.id &&
542
+ projection.entries.some((projected) => projected.sourceEntry.id === entry.id));
543
+ if (edited) return { kind: "ambiguous" };
544
+ const cleaned = laterCompaction !== undefined &&
545
+ isContextWindowCompactionDetails(laterCompaction.details) &&
546
+ laterCompaction.details.windowId === boundary.details.contextManagement.currentWindowId &&
547
+ laterCompaction.firstKeptEntryId === boundary.id;
548
+ if (laterCompaction && !cleaned) return { kind: "superseded", entry: boundary };
549
+ if (!marker?.messages.some(isContextWindowBoundary)) return { kind: "ambiguous" };
550
+ return { kind: "effective", entry: boundary, cleaned };
551
+ }
552
+
553
+ function identityFromDetails(details: CodexContextManagementMessageDetails): ContextWindowIdentity {
554
+ const context = details.contextManagement;
555
+ return {
556
+ firstWindowId: context.firstWindowId,
557
+ currentWindowId: context.currentWindowId,
558
+ ...(context.previousWindowId ? { previousWindowId: context.previousWindowId } : {}),
559
+ windowNumber: context.windowNumber,
560
+ };
561
+ }
562
+
563
+ const NOTES_CHECKPOINT_ACTIONS: ReadonlySet<string> = new Set(["append_to_file", "write_file"]);
564
+
565
+ /** Tools that only move context around; they never count as work done in a window. */
566
+ const CONTEXT_MANAGEMENT_TOOL_NAMES: ReadonlySet<string> = new Set([
567
+ "new_context",
568
+ "get_context_remaining",
569
+ "history",
570
+ "notes",
571
+ ]);
572
+
573
+ /**
574
+ * Whether the current window produced a tool result from anything other than the
575
+ * context-handoff tools. Derived from persisted entries so it survives restarts.
576
+ */
577
+ export function hasSubstantiveToolResultSinceBoundary(
578
+ entries: readonly SessionEntry[],
579
+ sessionId?: string,
580
+ ): boolean {
581
+ const boundaryIndex = findLatestWindowBoundaryIndex(entries, sessionId);
582
+ if (boundaryIndex < 0) return true;
583
+ for (let index = boundaryIndex + 1; index < entries.length; index += 1) {
584
+ const entry = entries[index]!;
585
+ if (entry.type !== "message") continue;
586
+ const message = entry.message as unknown as { role?: string; toolName?: string };
587
+ if (message.role !== "toolResult" || typeof message.toolName !== "string") continue;
588
+ if (!CONTEXT_MANAGEMENT_TOOL_NAMES.has(message.toolName)) return true;
589
+ }
590
+ return false;
591
+ }
592
+
593
+ /**
594
+ * Whether the branch contains a successful assistant usage after the latest
595
+ * window boundary. Before that first usage lands, any context measurement is
596
+ * still anchored to the previous window's last request.
597
+ */
598
+ export function hasAssistantUsageSinceWindowBoundary(
599
+ entries: readonly SessionEntry[],
600
+ sessionId?: string,
601
+ ): boolean {
602
+ const boundaryIndex = findLatestWindowBoundaryIndex(entries, sessionId);
603
+ if (boundaryIndex < 0) return false;
604
+ for (let index = boundaryIndex + 1; index < entries.length; index += 1) {
605
+ const entry = entries[index]!;
606
+ if (entry.type !== "message") continue;
607
+ const message = entry.message;
608
+ if (message.role !== "assistant") continue;
609
+ const candidate = message as unknown as {
610
+ stopReason?: string;
611
+ usage?: { totalTokens?: number; input?: number; output?: number; cacheRead?: number; cacheWrite?: number };
612
+ };
613
+ if (candidate.stopReason === "aborted" || candidate.stopReason === "error") continue;
614
+ const usage = candidate.usage;
615
+ if (!usage) continue;
616
+ const tokens = usage.totalTokens ?? (usage.input ?? 0) + (usage.output ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
617
+ if (tokens > 0) return true;
618
+ }
619
+ return false;
620
+ }
621
+
622
+ /**
623
+ * Return the most recent successful notes write after the latest window
624
+ * boundary. The receipt is derived from persisted session entries so it
625
+ * survives restarts and can be used for an explicit rollover handoff.
626
+ */
627
+ export function findLatestNotesCheckpointSinceBoundary(
628
+ entries: readonly SessionEntry[],
629
+ sessionId?: string,
630
+ afterEntryId?: string,
631
+ ): NotesCheckpointReceipt | undefined {
632
+ const windowBoundaryIndex = findLatestWindowBoundaryIndex(entries, sessionId);
633
+ const afterIndex = afterEntryId === undefined ? -1 : entries.findIndex((entry) => entry.id === afterEntryId);
634
+ if (windowBoundaryIndex < 0 || (afterEntryId !== undefined && afterIndex < 0)) return undefined;
635
+ const boundaryIndex = Math.max(windowBoundaryIndex, afterIndex);
636
+ const checkpointCalls = new Map<string, NotesCheckpointReceipt>();
637
+ let latest: NotesCheckpointReceipt | undefined;
638
+ for (let index = boundaryIndex + 1; index < entries.length; index += 1) {
639
+ const entry = entries[index]!;
640
+ if (entry.type !== "message") continue;
641
+ const message = entry.message;
642
+ if (message.role === "assistant") {
643
+ const parts = Array.isArray(message.content) ? message.content : [];
644
+ for (const part of parts) {
645
+ if (!isRecord(part) || part.type !== "toolCall" || part.name !== "notes") continue;
646
+ const args = isRecord(part.arguments) ? part.arguments : undefined;
647
+ const action = typeof args?.action === "string" ? args.action : "";
648
+ const path = isNonEmptyString(args?.path) ? args.path : undefined;
649
+ if (isNonEmptyString(part.id) && path !== undefined && NOTES_CHECKPOINT_ACTIONS.has(action)) {
650
+ checkpointCalls.set(part.id, { path, toolCallId: part.id });
651
+ }
652
+ }
653
+ continue;
654
+ }
655
+ if (message.role !== "toolResult" || message.toolName !== "notes") continue;
656
+ const receipt = checkpointCalls.get(message.toolCallId);
657
+ if (!receipt) continue;
658
+ checkpointCalls.delete(message.toolCallId);
659
+ if (message.isError !== false || !isSuccessfulHistoryNotesToolResult(message.details)) continue;
660
+ latest = receipt;
661
+ }
662
+ return latest;
663
+ }
664
+
665
+ /**
666
+ * Whether the branch contains a successful notes append/write result after the
667
+ * latest window boundary. Reads and failed writes never count as checkpoints.
668
+ */
669
+ export function findNotesCheckpointSinceBoundary(
670
+ entries: readonly SessionEntry[],
671
+ sessionId?: string,
672
+ ): boolean {
673
+ return findLatestNotesCheckpointSinceBoundary(entries, sessionId) !== undefined;
674
+ }
675
+
676
+ function findLatestWindowBoundaryIndex(
677
+ entries: readonly SessionEntry[],
678
+ sessionId?: string,
679
+ ): number {
680
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
681
+ const entry = entries[index]!;
682
+ if (entry.type !== "custom_message" || entry.customType !== CODEX_CONTEXT_WINDOW_MESSAGE_TYPE) continue;
683
+ if (!couldBelongToSession(entry.details, sessionId)) continue;
684
+ if (!isCodexContextManagementMessageDetails(entry.details)) {
685
+ throw new Error("Malformed persisted Codex context-window message");
686
+ }
687
+ if (!matchesSession(entry.details.sessionId, sessionId)) continue;
688
+ if (entry.details.contextManagement.kind === "window") return index;
689
+ }
690
+ return -1;
691
+ }
692
+
693
+ export function findLatestWindowBoundaryEntry(
694
+ entries: readonly SessionEntry[],
695
+ sessionId?: string,
696
+ ): WindowBoundaryEntry | undefined {
697
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
698
+ const entry = entries[index]!;
699
+ if (entry.type !== "custom_message" || entry.customType !== CODEX_CONTEXT_WINDOW_MESSAGE_TYPE) continue;
700
+ if (!couldBelongToSession(entry.details, sessionId)) continue;
701
+ if (!isCodexContextManagementMessageDetails(entry.details)) {
702
+ throw new Error("Malformed persisted Codex context-window message");
703
+ }
704
+ if (!matchesSession(entry.details.sessionId, sessionId)) continue;
705
+ if (entry.details.contextManagement.kind === "window") return entry as WindowBoundaryEntry;
706
+ }
707
+ return undefined;
708
+ }
709
+
710
+ /**
711
+ * Whether this exact window boundary is already persisted for the session.
712
+ * Only durable entries count; a queued/visible marker does not.
713
+ */
714
+ function hasPersistedWindowBoundary(
715
+ entries: readonly SessionEntry[],
716
+ sessionId: string | undefined,
717
+ windowId: string,
718
+ ): boolean {
719
+ for (const entry of entries) {
720
+ if (entry.type !== "custom_message" || entry.customType !== CODEX_CONTEXT_WINDOW_MESSAGE_TYPE) continue;
721
+ if (!couldBelongToSession(entry.details, sessionId)) continue;
722
+ if (!isCodexContextManagementMessageDetails(entry.details)) continue;
723
+ if (!matchesSession(entry.details.sessionId, sessionId)) continue;
724
+ const details = entry.details.contextManagement;
725
+ if (details.kind === "window" && details.currentWindowId === windowId) return true;
726
+ }
727
+ return false;
728
+ }
729
+
730
+ function findLatestContextMarkerId(
731
+ entries: readonly SessionEntry[],
732
+ sessionId?: string,
733
+ ): string | undefined {
734
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
735
+ const entry = entries[index]!;
736
+ if (entry.type !== "custom_message" || entry.customType !== CODEX_CONTEXT_WINDOW_MESSAGE_TYPE) continue;
737
+ if (!couldBelongToSession(entry.details, sessionId)) continue;
738
+ if (!isCodexContextManagementMessageDetails(entry.details)) {
739
+ throw new Error("Malformed persisted Codex context-window message");
740
+ }
741
+ if (matchesSession(entry.details.sessionId, sessionId)) return entry.id;
742
+ }
743
+ return undefined;
744
+ }
745
+
746
+ function couldBelongToSession(details: unknown, sessionId: string | undefined): boolean {
747
+ if (sessionId === undefined || !isRecord(details)) return true;
748
+ const markerSessionId = details.sessionId;
749
+ return !isNonEmptyString(markerSessionId) || markerSessionId === sessionId;
750
+ }
751
+
752
+ function matchesSession(markerSessionId: string | undefined, sessionId: string | undefined): boolean {
753
+ return sessionId === undefined || markerSessionId === sessionId;
754
+ }
755
+
756
+ function estimateProjectedWindowTokens(messages: readonly AgentMessage[]): number {
757
+ let lastUsageIndex = -1;
758
+ let usageTokens = 0;
759
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
760
+ const message = messages[index]!;
761
+ if (message.role !== "assistant" || message.stopReason === "aborted" || message.stopReason === "error" || !message.usage) continue;
762
+ const tokens = calculateContextTokens(message.usage);
763
+ if (tokens <= 0) continue;
764
+ lastUsageIndex = index;
765
+ usageTokens = tokens;
766
+ break;
767
+ }
768
+ if (lastUsageIndex < 0) return messages.reduce((total, message) => total + estimateProjectedMessageTokens(message), 0);
769
+ return usageTokens + messages
770
+ .slice(lastUsageIndex + 1)
771
+ .reduce((total, message) => total + estimateProjectedMessageTokens(message), 0);
772
+ }
773
+
774
+ function estimateProjectedMessageTokens(message: AgentMessage): number {
775
+ // A synthetic custom marker may be represented without content in a test or
776
+ // by an older host. It contributes no measurable text; it must not make the
777
+ // context hook fail closed merely because the diagnostic estimate is missing.
778
+ if (
779
+ !("content" in message) ||
780
+ (typeof message.content !== "string" && !Array.isArray(message.content))
781
+ ) return 0;
782
+ return estimateTokens(message);
783
+ }
784
+
785
+ function projectEncryptedToolResults(messages: readonly AgentMessage[]): AgentMessage[] {
786
+ let changed = false;
787
+ const projected = messages.map((message) => {
788
+ if (message.role !== "toolResult" || !isRecord(message.details)) return message;
789
+ const historyNotes = message.details.codexHistoryNotes;
790
+ if (!isRecord(historyNotes) || typeof historyNotes.encrypted_output !== "string") return message;
791
+ changed = true;
792
+ return {
793
+ ...message,
794
+ content: [
795
+ { type: "text" as const, text: encodeEncryptedOutputForContext(historyNotes.encrypted_output) },
796
+ ...message.content.filter((item) => item.type === "image"),
797
+ ],
798
+ };
799
+ });
800
+ return changed ? projected : [...messages];
801
+ }