pi-openai-toolkit 0.15.0 → 0.16.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.
@@ -0,0 +1,433 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
3
+ import { buildSessionContext, estimateTokens, type ExtensionAPI, type ExtensionContext, type SessionBeforeCompactEvent, type SessionEntry } from "@earendil-works/pi-coding-agent";
4
+ import { resolveToolkitConfig, type ResolvedToolkitConfig } from "../config";
5
+ import { isAutoModeEligible } from "../auto-mode/policy";
6
+ import { protectAutoModeSelection } from "../auto-mode/model-selection-guard";
7
+ import { parseModelSpec } from "../runtime";
8
+ import { resolveCodexContextProvider } from "./codex-provider";
9
+ import { findLatestNotesCheckpointSinceBoundary, findLatestWindowBoundaryEntry, type CodexContextWindowManager } from "./window-manager";
10
+ import { isNonEmptyString, isRecord, type CodexContextProvider, type ContextWindowIdentity } from "./types";
11
+ import type { ContextRolloverHandoff } from "./tools";
12
+
13
+ export const MANUAL_COMPACT_ENTRY_TYPE = "pi-openai-toolkit:manual-compact";
14
+ const PHASES = ["requested", "switching", "checkpointing", "rollover-scheduled", "restoring", "restore-needed", "completed", "failed", "cancelled", "superseded"] as const;
15
+ type Phase = typeof PHASES[number];
16
+ const TERMINAL = new Set<Phase>(["completed", "failed", "cancelled", "superseded"]);
17
+ const THINKING = new Set<string>(["off", "minimal", "low", "medium", "high", "xhigh"]);
18
+ const REQUIRED_TOOLS = ["notes", "history", "new_context", "get_context_remaining"];
19
+
20
+ /** Non-LLM recovery state. No auth, model object, transcript or note text belongs here. */
21
+ export type ManualCompactRecord = {
22
+ version: 1;
23
+ operationId: string;
24
+ sessionId: string;
25
+ sourceWindowId: string;
26
+ anchorId: string;
27
+ originalModel: string;
28
+ targetModel: string;
29
+ originalThinking: ThinkingLevel;
30
+ phase: Phase;
31
+ targetWindowId?: string;
32
+ };
33
+
34
+ export function decodeManualCompactRecord(value: unknown): ManualCompactRecord | undefined {
35
+ if (!isRecord(value) || value.version !== 1
36
+ || ![value.operationId, value.sessionId, value.sourceWindowId, value.anchorId].every(isNonEmptyString)
37
+ || typeof value.originalModel !== "string" || !parseModelSpec(value.originalModel)
38
+ || typeof value.targetModel !== "string" || !parseModelSpec(value.targetModel)
39
+ || typeof value.originalThinking !== "string" || !THINKING.has(value.originalThinking)
40
+ || !PHASES.includes(value.phase as Phase)
41
+ || (value.targetWindowId !== undefined && !isNonEmptyString(value.targetWindowId))) return undefined;
42
+ return value as ManualCompactRecord;
43
+ }
44
+
45
+ /** Same gateway credential/affinity domain, or the same native backend/account. Never log this comparison. */
46
+ export function sameContextScope(left: CodexContextProvider, right: CodexContextProvider): boolean {
47
+ if (left.kind !== right.kind || left.baseUrl !== right.baseUrl || left.provider !== right.provider) return false;
48
+ if (left.kind === "native-codex" && right.kind === "native-codex") return left.accountId === right.accountId;
49
+ return left.kind === "codex-gateway" && right.kind === "codex-gateway" && left.apiKey === right.apiKey
50
+ && JSON.stringify(Object.entries(left.headers).sort()) === JSON.stringify(Object.entries(right.headers).sort());
51
+ }
52
+
53
+ async function preflightDeadline<T>(work: Promise<T>, signal: AbortSignal): Promise<T> {
54
+ let onAbort!: () => void;
55
+ let timer: ReturnType<typeof setTimeout> | undefined;
56
+ const cancelled = new Promise<never>((_resolve, reject) => {
57
+ onAbort = () => reject(new Error("cancelled before switching models"));
58
+ if (signal.aborted) onAbort();
59
+ else signal.addEventListener("abort", onAbort, { once: true });
60
+ timer = setTimeout(() => reject(new Error("checkpoint-model authentication preflight timed out")), 10_000);
61
+ });
62
+ try { return await Promise.race([work, cancelled]); }
63
+ finally { clearTimeout(timer); signal.removeEventListener("abort", onAbort); }
64
+ }
65
+
66
+ function modelKey(model: ExtensionContext["model"]): string | undefined {
67
+ return model && `${model.provider}/${model.id}`;
68
+ }
69
+ function requestEntry(entries: readonly SessionEntry[], record: ManualCompactRecord): SessionEntry | undefined {
70
+ return entries.find((entry) => entry.type === "custom" && entry.customType === MANUAL_COMPACT_ENTRY_TYPE
71
+ && decodeManualCompactRecord(entry.data)?.operationId === record.operationId);
72
+ }
73
+ function freshCheckpoint(entries: readonly SessionEntry[], record: ManualCompactRecord) {
74
+ const request = requestEntry(entries, record);
75
+ if (!request) return undefined;
76
+ let afterId = request.id;
77
+ // Queued user input delivered after a checkpoint must also be checkpointed.
78
+ for (const entry of entries.slice(entries.indexOf(request) + 1)) {
79
+ if (entry.type === "message" && entry.message.role === "user") afterId = entry.id;
80
+ }
81
+ return findLatestNotesCheckpointSinceBoundary(entries, record.sessionId, afterId);
82
+ }
83
+
84
+ /** Owns only manual managed handoffs, not normal window rollovers or Remote V2. */
85
+ export class ManagedManualCompact implements ContextRolloverHandoff {
86
+ private record: ManualCompactRecord | undefined;
87
+ private generation = 0;
88
+ private sourcePolicy: ResolvedToolkitConfig | undefined;
89
+ private instructions = "";
90
+ private attemptSignal: AbortSignal | undefined;
91
+ private releaseApproval: (() => void) | undefined;
92
+ private selecting: string | undefined;
93
+ private selectionPromise: Promise<boolean> | undefined;
94
+ private expectedThinking: ThinkingLevel | undefined;
95
+ private newerSelection: { model: NonNullable<ExtensionContext["model"]>; thinking: ThinkingLevel } | undefined;
96
+ private deliveryTimer: ReturnType<typeof setTimeout> | undefined;
97
+ private delivered = false;
98
+ private resumeAllowed = true;
99
+ private checkpointStopped = false;
100
+ private maintenance = 0;
101
+
102
+ constructor(private readonly pi: ExtensionAPI, private readonly windows: CodexContextWindowManager, private readonly deliveryTimeoutMs = 10_000) {}
103
+
104
+ get busy(): boolean { return !!this.record && !TERMINAL.has(this.record.phase); }
105
+ get snapshot() { return this.busy ? this.sourcePolicy?.snapshot : undefined; }
106
+ get isMaintenance(): boolean { return this.maintenance > 0; }
107
+ beginMaintenance(): () => void {
108
+ this.maintenance++;
109
+ let done = false;
110
+ return () => { if (!done) { done = true; this.maintenance--; } };
111
+ }
112
+
113
+ private notify(ctx: ExtensionContext, text: string, error = false): void {
114
+ const content = `Managed /compact: ${text}`;
115
+ if (ctx.hasUI) ctx.ui.notify(content, error ? "warning" : "info");
116
+ else this.pi.sendMessage({ customType: `${MANUAL_COMPACT_ENTRY_TYPE}:status`, content, display: true }, { triggerTurn: false });
117
+ }
118
+ private write(phase: Phase): void {
119
+ if (!this.record) return;
120
+ this.record = { ...this.record, phase };
121
+ this.pi.appendEntry(MANUAL_COMPACT_ENTRY_TYPE, this.record);
122
+ }
123
+ private clearActivity(): void {
124
+ clearTimeout(this.deliveryTimer);
125
+ this.deliveryTimer = undefined;
126
+ this.releaseApproval?.();
127
+ this.releaseApproval = undefined;
128
+ this.sourcePolicy = undefined;
129
+ }
130
+ private belongs(ctx: ExtensionContext, record = this.record): boolean {
131
+ if (!record || ctx.sessionManager.getSessionId() !== record.sessionId) return false;
132
+ const branch = ctx.sessionManager.getBranch();
133
+ return branch.some((entry) => entry.id === record.anchorId) && !!requestEntry(branch, record);
134
+ }
135
+ private current(ctx: ExtensionContext, generation: number): boolean {
136
+ return generation === this.generation && this.busy && this.belongs(ctx);
137
+ }
138
+ private toolsAvailable(): boolean {
139
+ const active = new Set(this.pi.getActiveTools());
140
+ const published = new Set(this.pi.getAllTools().map((tool) => tool.name));
141
+ return REQUIRED_TOOLS.every((tool) => active.has(tool) && published.has(tool));
142
+ }
143
+
144
+ async request(event: SessionBeforeCompactEvent, ctx: ExtensionContext, source: ResolvedToolkitConfig): Promise<void> {
145
+ if (this.busy) { this.notify(ctx, "a handoff is already active; no second window was scheduled."); return; }
146
+ this.windows.synchronize(ctx);
147
+ const boundary = findLatestWindowBoundaryEntry(ctx.sessionManager.getBranch(), ctx.sessionManager.getSessionId());
148
+ if (!ctx.model || !boundary || event.signal.aborted || !this.toolsAvailable()) {
149
+ this.notify(ctx, "requires a live managed window and all four permitted context tools.", true); return;
150
+ }
151
+ if (this.windows.hasPendingRollover(ctx) || !this.windows.canRolloverFromCurrentWindow(ctx)) {
152
+ this.notify(ctx, "this window is already switching or has not produced substantive tool activity since its last switch.", true); return;
153
+ }
154
+ const targetKey = source.config.compaction.remoteCompactModel ?? modelKey(ctx.model)!;
155
+ const spec = parseModelSpec(targetKey);
156
+ const target = spec && ctx.modelRegistry.find(spec.provider, spec.modelId);
157
+ if (!target) { this.notify(ctx, `configured checkpoint model ${targetKey} is unavailable.`, true); return; }
158
+ const targetPolicy = resolveToolkitConfig(source.snapshot, target);
159
+ if (targetPolicy.invalidFeatures.some((feature) => ["context", "compatibility", "diagnostics"].includes(feature))
160
+ || !targetPolicy.config.compaction.enabled || targetPolicy.config.compaction.contextManagement !== "remote") {
161
+ this.notify(ctx, "the checkpoint model must have valid Remote Context policy.", true); return;
162
+ }
163
+ const projected = this.windows.project(buildSessionContext(ctx.sessionManager.getBranch()).messages, "remote");
164
+ const tokens = projected.reduce((total, message) => total + estimateTokens(message), 0);
165
+ const guidanceTokens = Math.ceil((event.customInstructions?.length ?? 0) / 4);
166
+ if (!Number.isFinite(target.contextWindow) || !Number.isFinite(target.maxTokens) || target.maxTokens <= 0
167
+ || tokens + target.maxTokens + guidanceTokens + 2048 >= target.contextWindow) {
168
+ this.notify(ctx, "the checkpoint model cannot fit this window plus checkpoint output; no history was truncated.", true); return;
169
+ }
170
+ this.newerSelection = undefined;
171
+ this.delivered = false;
172
+ this.resumeAllowed = true;
173
+ this.checkpointStopped = false;
174
+ this.sourcePolicy = source;
175
+ this.attemptSignal = event.signal;
176
+ this.instructions = event.customInstructions?.trim() ?? "";
177
+ this.record = {
178
+ version: 1, operationId: randomUUID(), sessionId: ctx.sessionManager.getSessionId(),
179
+ sourceWindowId: boundary.details.contextManagement.currentWindowId,
180
+ anchorId: ctx.sessionManager.getLeafId() ?? boundary.id,
181
+ originalModel: modelKey(ctx.model)!, targetModel: targetKey,
182
+ originalThinking: this.pi.getThinkingLevel(), phase: "requested",
183
+ };
184
+ this.write("requested");
185
+ const generation = ++this.generation;
186
+ try {
187
+ const [originalProvider, targetProvider] = await preflightDeadline(Promise.all([
188
+ resolveCodexContextProvider(ctx, ctx.model, source.gatewayModelKeys),
189
+ resolveCodexContextProvider(ctx, target, targetPolicy.gatewayModelKeys),
190
+ ]), event.signal);
191
+ if (!this.current(ctx, generation)) return;
192
+ if (event.signal.aborted) throw new Error("cancelled before switching models");
193
+ if (!originalProvider.ok || !targetProvider.ok || !sameContextScope(originalProvider.provider, targetProvider.provider)) {
194
+ throw new Error("checkpoint model must authenticate to the same context backend/account");
195
+ }
196
+ const protection = protectAutoModeSelection(this.pi, {
197
+ sessionId: this.record!.sessionId, operationId: this.record!.operationId, target,
198
+ eligible: !targetPolicy.invalidFeatures.includes("autoMode") && isAutoModeEligible(target, targetPolicy.config.autoMode),
199
+ });
200
+ this.releaseApproval = protection.release;
201
+ if (!protection.allowed) throw new Error("checkpoint model would disable the engaged approval gate; allowlist it or choose another model");
202
+ this.notify(ctx, `checkpoint handoff requested (${this.record!.originalModel} → ${targetKey}); native compaction is intentionally cancelled.`);
203
+ } catch (error) {
204
+ if (!this.current(ctx, generation)) return;
205
+ this.write(event.signal.aborted ? "cancelled" : "failed"); this.clearActivity();
206
+ this.notify(ctx, error instanceof Error ? error.message : "preflight failed", true);
207
+ }
208
+ }
209
+
210
+ /** Called only after the native manual operation has exposed idle state. */
211
+ async compactFailed(ctx: ExtensionContext): Promise<void> {
212
+ if (this.record?.phase !== "requested") return;
213
+ if (!ctx.isIdle() || this.attemptSignal?.aborted) {
214
+ this.write("cancelled"); this.clearActivity(); this.notify(ctx, "cancelled before checkpoint duty began.", true); return;
215
+ }
216
+ const generation = this.generation;
217
+ // Fence a second terminal callback while public selection awaits auth.
218
+ this.write("switching");
219
+ try {
220
+ if (!this.belongs(ctx) || !this.toolsAvailable()) throw new Error("session or context-tool availability changed");
221
+ if (!(await this.select(this.record.targetModel, ctx))) throw new Error("checkpoint model selection failed");
222
+ if (!this.current(ctx, generation)) { await this.honorNewerSelection(ctx); return; }
223
+ if (this.attemptSignal?.aborted) throw new Error("cancelled during model selection");
224
+ if (!this.toolsAvailable()) throw new Error("required context tools became unavailable");
225
+ this.write("checkpointing");
226
+ const prompt = `[Managed /compact ${this.record.operationId}]\n`
227
+ + "Checkpoint duty only. Preserve the real active user request, restrictions, decisions, progress, learnings, known history IDs/references and next steps in notes. Do not perform unrelated work or answer the task. "
228
+ + "Use the existing notes tool to write or append a NEW checkpoint, wait for its successful persisted result, then call new_context exactly once in a later tool turn. An older checkpoint cannot authorize this handoff. "
229
+ + "After scheduling the window, stop; the extension will restore the original model before resuming. No conversation summary carries over."
230
+ + (this.instructions ? `\nAdditional user guidance for this checkpoint: ${this.instructions}` : "");
231
+ this.deliveryTimer = setTimeout(() => {
232
+ if (this.current(ctx, generation) && !this.delivered && ctx.isIdle()) void this.finish(ctx, false, false);
233
+ }, this.deliveryTimeoutMs);
234
+ this.deliveryTimer.unref?.();
235
+ this.pi.sendUserMessage(prompt);
236
+ } catch {
237
+ if (this.current(ctx, generation)) await this.finish(ctx, false, false);
238
+ }
239
+ }
240
+
241
+ messageStarted(message: { role: string; content?: unknown }): void {
242
+ if (this.record?.phase !== "checkpointing" || message.role !== "user") return;
243
+ const text = typeof message.content === "string" ? message.content : JSON.stringify(message.content);
244
+ if (!text?.includes(`[Managed /compact ${this.record.operationId}]`)) return;
245
+ this.delivered = true;
246
+ clearTimeout(this.deliveryTimer);
247
+ }
248
+
249
+ prepare(ctx: ExtensionContext): { triggerTurn: false; beforeSchedule: (identity: ContextWindowIdentity) => void } | undefined {
250
+ if (!this.busy) return undefined;
251
+ const record = this.record!;
252
+ if (!this.belongs(ctx) || record.phase !== "checkpointing" || !this.delivered
253
+ || modelKey(ctx.model) !== record.targetModel || this.windows.currentIdentity()?.currentWindowId !== record.sourceWindowId) {
254
+ throw new Error("Managed /compact is not ready for a rollover; do not retry while restoration is pending.");
255
+ }
256
+ if (!freshCheckpoint(ctx.sessionManager.getBranch(), record)) {
257
+ throw new Error("Managed /compact requires a new persisted successful notes write/append after this operation and the latest delivered user message.");
258
+ }
259
+ const generation = this.generation;
260
+ return {
261
+ triggerTurn: false,
262
+ beforeSchedule: (identity) => {
263
+ const boundary = findLatestWindowBoundaryEntry(ctx.sessionManager.getBranch(), record.sessionId);
264
+ if (!this.current(ctx, generation) || modelKey(ctx.model) !== record.targetModel
265
+ || boundary?.details.contextManagement.currentWindowId !== record.sourceWindowId
266
+ || !freshCheckpoint(ctx.sessionManager.getBranch(), record)) throw new Error("Managed /compact was interrupted before rollover.");
267
+ this.record = { ...record, targetWindowId: identity.currentWindowId };
268
+ this.write("rollover-scheduled");
269
+ },
270
+ };
271
+ }
272
+
273
+ /** Abort without waiting: a queued/mixed continuation must not send the captured temporary model. */
274
+ turnEnded(ctx: ExtensionContext): void {
275
+ if (this.busy && this.record?.phase !== "checkpointing" && this.record?.phase !== "requested") {
276
+ if (!this.checkpointStopped) {
277
+ this.resumeAllowed = !ctx.signal?.aborted;
278
+ this.checkpointStopped = true;
279
+ }
280
+ ctx.abort();
281
+ }
282
+ }
283
+ guardRequest(ctx: ExtensionContext): void {
284
+ if (ctx.signal?.aborted) return;
285
+ if (this.selectionPromise && this.newerSelection) {
286
+ ctx.abort();
287
+ throw new Error("Managed /compact is preserving your newer model selection; this provider request was cancelled.");
288
+ }
289
+ if (!this.busy) return;
290
+ if (this.record?.phase === "checkpointing" && this.belongs(ctx) && this.toolsAvailable() && modelKey(ctx.model) === this.record.targetModel
291
+ && this.windows.currentIdentity()?.currentWindowId === this.record.sourceWindowId) return;
292
+ ctx.abort();
293
+ throw new Error("Managed /compact is restoring the original model; this provider request was cancelled.");
294
+ }
295
+ guardTool(name: string): { block: true; terminate: true; reason: string } | undefined {
296
+ if (!this.busy && !this.selectionPromise) return;
297
+ if (this.record?.phase === "checkpointing" && REQUIRED_TOOLS.includes(name)) return;
298
+ return { block: true, terminate: true, reason: "Managed /compact owns this checkpoint-only turn. Wait for the original-model continuation before ordinary work." };
299
+ }
300
+
301
+ modelSelected(model: NonNullable<ExtensionContext["model"]>, ctx: ExtensionContext): void {
302
+ if ((!this.busy && !this.selectionPromise) || modelKey(model) === this.selecting) return;
303
+ this.newerSelection = { model, thinking: this.pi.getThinkingLevel() };
304
+ ++this.generation;
305
+ if (this.belongs(ctx)) this.write("superseded");
306
+ this.clearActivity();
307
+ if (!ctx.isIdle()) ctx.abort();
308
+ this.notify(ctx, "stopped; your newer model selection takes priority.");
309
+ }
310
+ thinkingSelected(level: ThinkingLevel, ctx: ExtensionContext): void {
311
+ if (!this.busy || this.selecting || level === this.expectedThinking) return;
312
+ if (ctx.model) this.modelSelected(ctx.model, ctx);
313
+ }
314
+ private async honorNewerSelection(ctx: ExtensionContext): Promise<void> {
315
+ const choice = this.newerSelection;
316
+ if (!choice || ctx.sessionManager.getSessionId() !== this.record?.sessionId) return;
317
+ const key = modelKey(choice.model)!;
318
+ try {
319
+ const selected = await this.select(key, ctx);
320
+ if (choice !== this.newerSelection) { await this.honorNewerSelection(ctx); return; }
321
+ if (!selected || modelKey(ctx.model) !== key) throw new Error("selection refused");
322
+ this.setThinking(choice.thinking);
323
+ } catch {
324
+ if (choice !== this.newerSelection) { await this.honorNewerSelection(ctx); return; }
325
+ if (!this.belongs(ctx) || !this.record) return;
326
+ this.record = { ...this.record, originalModel: key, originalThinking: choice.thinking, targetModel: modelKey(ctx.model) ?? this.record.targetModel };
327
+ this.write("restore-needed");
328
+ this.notify(ctx, `could not preserve your newer selection ${key}; restore its authentication and reload, or select a model explicitly.`, true);
329
+ }
330
+ }
331
+ private setThinking(level: ThinkingLevel): void {
332
+ this.expectedThinking = level;
333
+ this.pi.setThinkingLevel(level);
334
+ // Pi emits this event without awaiting earlier extension handlers. Keep
335
+ // the owned value so a delayed echo is not mistaken for a user selection.
336
+ this.expectedThinking = this.pi.getThinkingLevel();
337
+ }
338
+ private async select(key: string, ctx: ExtensionContext): Promise<boolean> {
339
+ if (this.selectionPromise) await this.selectionPromise.catch(() => false);
340
+ if (modelKey(ctx.model) === key) return true;
341
+ const spec = parseModelSpec(key)!;
342
+ const model = ctx.modelRegistry.find(spec.provider, spec.modelId);
343
+ if (!model) return false;
344
+ this.selecting = key;
345
+ const pending = this.pi.setModel(model);
346
+ this.selectionPromise = pending;
347
+ try {
348
+ const selected = await pending;
349
+ this.expectedThinking = this.pi.getThinkingLevel();
350
+ return selected;
351
+ } finally {
352
+ if (this.selectionPromise === pending) { this.selecting = undefined; this.selectionPromise = undefined; }
353
+ }
354
+ }
355
+
356
+ async settled(ctx: ExtensionContext): Promise<boolean> {
357
+ if (!this.busy) return false;
358
+ if (this.record?.phase === "requested" || this.record?.phase === "switching" || this.record?.phase === "restore-needed" || this.record?.phase === "restoring") return true;
359
+ await this.finish(ctx, this.resumeAllowed, true);
360
+ return true;
361
+ }
362
+
363
+ private async finish(ctx: ExtensionContext, mayResume: boolean, inspectMarker: boolean): Promise<void> {
364
+ const record = this.record;
365
+ if (!record || !ctx.isIdle()) return;
366
+ const generation = this.generation;
367
+ if (!this.belongs(ctx)) { this.clearActivity(); this.record = undefined; ++this.generation; return; }
368
+ const branch = ctx.sessionManager.getBranch();
369
+ const marker = findLatestWindowBoundaryEntry(branch, record.sessionId);
370
+ const switched = inspectMarker && !!record.targetWindowId && marker?.details.contextManagement.currentWindowId === record.targetWindowId
371
+ && marker.details.contextManagement.previousWindowId === record.sourceWindowId;
372
+ const receipt = switched ? freshCheckpoint(branch.slice(0, branch.indexOf(marker!)), record) : undefined;
373
+ if (modelKey(ctx.model) !== record.targetModel && modelKey(ctx.model) !== record.originalModel) {
374
+ this.write("superseded"); this.clearActivity(); return;
375
+ }
376
+ this.write("restoring");
377
+ try {
378
+ if (!(await this.select(record.originalModel, ctx))) throw new Error("selection refused");
379
+ if (!this.current(ctx, generation)) { await this.honorNewerSelection(ctx); return; }
380
+ this.setThinking(record.originalThinking);
381
+ if (modelKey(ctx.model) !== record.originalModel || this.pi.getThinkingLevel() !== record.originalThinking) throw new Error("restoration did not match");
382
+ } catch {
383
+ if (!this.current(ctx, generation)) return;
384
+ this.write("restore-needed");
385
+ this.notify(ctx, `could not restore ${record.originalModel}; no continuation was started. Restore model/auth, then reload or select a model explicitly.`, true);
386
+ return;
387
+ }
388
+ this.write(switched && receipt ? "completed" : "failed");
389
+ this.clearActivity();
390
+ this.notify(ctx, switched && receipt ? `new window persisted; restored ${record.originalModel}.` : "checkpoint handoff did not complete; original model restored.", !receipt);
391
+ if (mayResume && receipt) {
392
+ this.pi.sendUserMessage(`Managed /compact completed. Read the persisted checkpoint first with notes action "read_file", path ${JSON.stringify(receipt.path)}, then resume the active user task and any queued user requests in order. Do not checkpoint or call new_context again as part of this handoff.`);
393
+ }
394
+ }
395
+
396
+ /** Reload/navigation recovers selection only. It never retries inference or writes notes. */
397
+ async recover(ctx: ExtensionContext): Promise<void> {
398
+ const previous = this.busy ? this.record : undefined;
399
+ ++this.generation;
400
+ await this.selectionPromise?.catch(() => false);
401
+ this.clearActivity();
402
+ if (previous && ctx.isIdle() && previous.sessionId === ctx.sessionManager.getSessionId()
403
+ && modelKey(ctx.model) === previous.targetModel && !this.belongs(ctx, previous)) {
404
+ // Tree navigation can leave the owned temporary selection on a sibling
405
+ // without its request anchor. Re-anchor recovery only; never resume work.
406
+ const anchorId = ctx.sessionManager.getLeafId();
407
+ if (anchorId) {
408
+ this.record = { ...previous, operationId: randomUUID(), anchorId, targetWindowId: undefined };
409
+ this.write("restoring");
410
+ await this.finish(ctx, false, false);
411
+ return;
412
+ }
413
+ }
414
+ this.record = undefined;
415
+ this.newerSelection = undefined;
416
+ const branch = ctx.sessionManager.getBranch();
417
+ const latest = [...branch].reverse().find((entry) => entry.type === "custom" && entry.customType === MANUAL_COMPACT_ENTRY_TYPE);
418
+ if (!latest || latest.type !== "custom") return;
419
+ const record = decodeManualCompactRecord(latest.data);
420
+ if (!record || TERMINAL.has(record.phase) || record.sessionId !== ctx.sessionManager.getSessionId()) return;
421
+ // A newer explicit model change on the branch wins over the saved operation.
422
+ if (branch.slice(branch.indexOf(latest) + 1).some((entry) => entry.type === "model_change" && `${entry.provider}/${entry.modelId}` !== record.targetModel && `${entry.provider}/${entry.modelId}` !== record.originalModel)) return;
423
+ this.record = record;
424
+ await this.finish(ctx, false, true);
425
+ }
426
+ async shutdown(ctx: ExtensionContext): Promise<void> {
427
+ ++this.generation;
428
+ await this.selectionPromise?.catch(() => false);
429
+ if (this.busy) await this.finish(ctx, false, true);
430
+ this.clearActivity();
431
+ this.record = undefined;
432
+ }
433
+ }
@@ -8,7 +8,7 @@ import {
8
8
  NOTES_ACTION_FIELDS,
9
9
  NOTES_ENDPOINTS,
10
10
  } from "./history-notes";
11
- import type { HistoryAction, NotesAction } from "./types";
11
+ import type { ContextWindowIdentity, HistoryAction, NotesAction } from "./types";
12
12
 
13
13
  const EMPTY_PARAMETERS = Type.Object({}, { additionalProperties: false });
14
14
 
@@ -98,11 +98,16 @@ export type ContextManagementTools = {
98
98
  export type ContextOperationPolicy = { active: boolean; gatewayModels: readonly string[] };
99
99
  type ContextActivity = (ctx: ExtensionContext) => Promise<boolean | ContextOperationPolicy> | boolean | ContextOperationPolicy;
100
100
 
101
+ export interface ContextRolloverHandoff {
102
+ prepare(ctx: ExtensionContext): { triggerTurn: false; beforeSchedule: (identity: ContextWindowIdentity) => void } | undefined;
103
+ }
104
+
101
105
  export function createContextManagementTools(
102
106
  pi: ExtensionAPI,
103
107
  manager: CodexContextWindowManager,
104
108
  isActive: ContextActivity,
105
109
  getGatewayModels: () => readonly string[] = () => [],
110
+ handoff?: ContextRolloverHandoff,
106
111
  ): ContextManagementTools {
107
112
  const assertActive = async (ctx: ExtensionContext): Promise<readonly string[]> => {
108
113
  const policy = await isActive(ctx);
@@ -139,11 +144,13 @@ export function createContextManagementTools(
139
144
  if (!manager.hasNotesCheckpointSinceBoundary(ctx)) {
140
145
  throw new Error(NEW_CONTEXT_CHECKPOINT_REQUIRED_MESSAGE);
141
146
  }
147
+ const scheduling = handoff?.prepare(ctx);
142
148
  const started = await manager.startNewWindow(pi, ctx, {
143
149
  triggerTurn: true,
144
150
  signal,
145
151
  trimPreviousWindow: true,
146
152
  gatewayModels,
153
+ ...scheduling,
147
154
  });
148
155
  return {
149
156
  content: [{
@@ -153,6 +160,7 @@ export function createContextManagementTools(
153
160
  : "A new context window is already scheduled.",
154
161
  }],
155
162
  details: { started },
163
+ ...(started && scheduling ? { terminate: true } : {}),
156
164
  };
157
165
  },
158
166
  };
@@ -337,9 +345,10 @@ export function registerContextManagementTools(
337
345
  manager: CodexContextWindowManager,
338
346
  isActive: ContextActivity,
339
347
  getGatewayModels?: () => readonly string[],
348
+ handoff?: ContextRolloverHandoff,
340
349
  ): ContextManagementToolController {
341
350
  const controller = new ContextManagementToolController(pi);
342
- controller.register(createContextManagementTools(pi, manager, isActive, getGatewayModels));
351
+ controller.register(createContextManagementTools(pi, manager, isActive, getGatewayModels, handoff));
343
352
  return controller;
344
353
  }
345
354
 
@@ -48,6 +48,8 @@ interface StartContextWindowOptions {
48
48
  trimPreviousWindow: boolean;
49
49
  /** Captured with the tool activation policy, before any awaited backend work. */
50
50
  gatewayModels?: readonly string[];
51
+ /** Persist an owned handoff's exact target before the marker can be queued. */
52
+ beforeSchedule?: (identity: ContextWindowIdentity) => void;
51
53
  }
52
54
 
53
55
  type ThreadHintLoader = (
@@ -75,6 +77,8 @@ export class CodexContextWindowManager {
75
77
  private identity: ContextWindowIdentity | undefined;
76
78
  private sessionId: string | undefined;
77
79
  private restoredMarkerId: string | undefined;
80
+ private restoredCompactionId: string | undefined;
81
+ private branchStateInvalidated = false;
78
82
  private readonly budget = new ContextWindowBudget();
79
83
  private pendingRollover: PendingRollover | undefined;
80
84
  private trimPendingWindowId: string | undefined;
@@ -141,6 +145,8 @@ export class CodexContextWindowManager {
141
145
  this.identity = undefined;
142
146
  this.sessionId = undefined;
143
147
  this.restoredMarkerId = undefined;
148
+ this.restoredCompactionId = undefined;
149
+ this.branchStateInvalidated = false;
144
150
  this.budget.reset();
145
151
  this.trimPendingWindowId = undefined;
146
152
  }
@@ -152,6 +158,7 @@ export class CodexContextWindowManager {
152
158
  this.sessionId = sessionId;
153
159
  for (const entry of entries) {
154
160
  if (entry.type === "compaction") {
161
+ this.restoredCompactionId = entry.id;
155
162
  this.recordCompaction(entry.details);
156
163
  continue;
157
164
  }
@@ -169,6 +176,9 @@ export class CodexContextWindowManager {
169
176
  }
170
177
  this.budget.restore(details.kind, details.currentWindowId);
171
178
  }
179
+ // recordCompaction may invalidate the cache while replaying entries; the
180
+ // completed replay already reconciled those acknowledgments with this branch.
181
+ this.branchStateInvalidated = false;
172
182
  this.retireSatisfiedOrStalePending(entries, sessionId);
173
183
  }
174
184
 
@@ -178,7 +188,16 @@ export class CodexContextWindowManager {
178
188
  const sessionId = ctx.sessionManager.getSessionId();
179
189
  this.retireSatisfiedOrStalePending(entries, sessionId);
180
190
  const latestMarkerId = findLatestContextMarkerId(entries, sessionId);
181
- if (this.sessionId !== sessionId || this.restoredMarkerId !== latestMarkerId) {
191
+ let latestCompactionId: string | undefined;
192
+ for (let index = entries.length - 1; index >= 0; index--) {
193
+ if (entries[index]!.type === "compaction") {
194
+ latestCompactionId = entries[index]!.id;
195
+ break;
196
+ }
197
+ }
198
+ // Hook-provided compactions may have no success callback. The persisted
199
+ // branch also detects navigation before/after a commit with the same marker.
200
+ if (this.branchStateInvalidated || this.sessionId !== sessionId || this.restoredMarkerId !== latestMarkerId || this.restoredCompactionId !== latestCompactionId) {
182
201
  this.restore(entries, sessionId);
183
202
  }
184
203
  }
@@ -321,6 +340,7 @@ export class CodexContextWindowManager {
321
340
  }
322
341
  }
323
342
  if (options.signal?.aborted) throw new Error("Remote context rollover was aborted");
343
+ options.beforeSchedule?.(next);
324
344
  this.sendWindowMessage(pi, ctx, next, options, threadHint, checkpoint);
325
345
  return true;
326
346
  } catch (error) {
@@ -415,17 +435,20 @@ export class CodexContextWindowManager {
415
435
  return { cancel: true };
416
436
  }
417
437
  const compaction = this.createCompaction(event);
418
- // Consume the trim synchronously. Pi does not re-fire session_compact
419
- // for hook-written entries, so relying on that event leaks the pending
420
- // id and lets every later compaction pass this gate again, writing an
421
- // endless run of no-op boundaries for the same window.
422
- this.trimPendingWindowId = undefined;
438
+ // Preparation is only a proposal: Pi may abort before appending it.
439
+ // synchronize() consumes the trim after observing a durable compaction;
440
+ // recordCompaction() also handles hosts that emit a success callback.
423
441
  return { compaction };
424
442
  }
425
443
 
426
444
  recordCompaction(details: unknown): void {
427
445
  if (!isContextWindowCompactionDetails(details)) return;
428
- if (details.windowId === this.trimPendingWindowId) this.trimPendingWindowId = undefined;
446
+ if (this.trimPendingWindowId !== undefined && details.windowId === this.trimPendingWindowId) {
447
+ this.trimPendingWindowId = undefined;
448
+ // The callback supplies no branch identity. Force durable reconciliation
449
+ // even if navigation returns to the cached pre-commit branch before sync.
450
+ this.branchStateInvalidated = true;
451
+ }
429
452
  }
430
453
 
431
454
  createCompaction(event: SessionBeforeCompactEvent): CompactionResult<ContextWindowCompactionDetails> {
@@ -554,9 +577,12 @@ export function hasAssistantUsageSinceWindowBoundary(
554
577
  export function findLatestNotesCheckpointSinceBoundary(
555
578
  entries: readonly SessionEntry[],
556
579
  sessionId?: string,
580
+ afterEntryId?: string,
557
581
  ): NotesCheckpointReceipt | undefined {
558
- const boundaryIndex = findLatestWindowBoundaryIndex(entries, sessionId);
559
- if (boundaryIndex < 0) return undefined;
582
+ const windowBoundaryIndex = findLatestWindowBoundaryIndex(entries, sessionId);
583
+ const afterIndex = afterEntryId === undefined ? -1 : entries.findIndex((entry) => entry.id === afterEntryId);
584
+ if (windowBoundaryIndex < 0 || (afterEntryId !== undefined && afterIndex < 0)) return undefined;
585
+ const boundaryIndex = Math.max(windowBoundaryIndex, afterIndex);
560
586
  const checkpointCalls = new Map<string, NotesCheckpointReceipt>();
561
587
  let latest: NotesCheckpointReceipt | undefined;
562
588
  for (let index = boundaryIndex + 1; index < entries.length; index += 1) {