pi-openai-toolkit 0.23.0 → 0.23.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,20 +1,16 @@
1
- import { createHash, randomUUID } from "node:crypto";
2
- import { isDeepStrictEqual } from "node:util";
1
+ import { randomUUID } from "node:crypto";
3
2
  import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
4
- import { buildSessionContext, estimateTokens, type CompactionResult, type ExtensionAPI, type ExtensionContext, type SessionBeforeCompactEvent, type SessionEntry } from "@earendil-works/pi-coding-agent";
5
- import { resolveToolkitConfig, type ResolvedToolkitConfig } from "../config";
3
+ import type { ExtensionAPI, ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
6
4
  import { parseModelSpec } from "../runtime";
7
- import { THINKING_LEVELS, type NativeCompactionDetails } from "../types";
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";
5
+ import { THINKING_LEVELS } from "../types";
6
+ import { findLatestNotesCheckpointSinceBoundary, findLatestWindowBoundaryEntry } from "./window-manager";
7
+ import { isNonEmptyString, isRecord } from "./types";
11
8
  import type { ContextRolloverHandoff } from "./tools";
12
9
 
13
10
  export const MANUAL_COMPACT_ENTRY_TYPE = "pi-openai-toolkit:manual-compact";
14
11
  const PHASES = ["requested", "switching", "checkpointing", "rollover-scheduled", "restoring", "restore-needed", "completed", "failed", "cancelled", "superseded"] as const;
15
12
  type Phase = typeof PHASES[number];
16
13
  const TERMINAL = new Set<Phase>(["completed", "failed", "cancelled", "superseded"]);
17
- const REQUIRED_TOOLS = ["notes", "history", "new_context", "get_context_remaining"];
18
14
 
19
15
  /** Non-LLM recovery state. No auth, model object, transcript or note text belongs here. */
20
16
  export type ManualCompactRecord = {
@@ -41,27 +37,6 @@ export function decodeManualCompactRecord(value: unknown): ManualCompactRecord |
41
37
  return value as ManualCompactRecord;
42
38
  }
43
39
 
44
- /** Same gateway credential/affinity domain, or the same native backend/account. Never log this comparison. */
45
- export function sameContextScope(left: CodexContextProvider, right: CodexContextProvider): boolean {
46
- if (left.kind !== right.kind || left.baseUrl !== right.baseUrl || left.provider !== right.provider) return false;
47
- if (left.kind === "native-codex" && right.kind === "native-codex") return left.accountId === right.accountId;
48
- return left.kind === "codex-gateway" && right.kind === "codex-gateway" && left.apiKey === right.apiKey
49
- && JSON.stringify(Object.entries(left.headers).sort()) === JSON.stringify(Object.entries(right.headers).sort());
50
- }
51
-
52
- async function preflightDeadline<T>(work: Promise<T>, signal: AbortSignal): Promise<T> {
53
- let onAbort!: () => void;
54
- let timer: ReturnType<typeof setTimeout> | undefined;
55
- const cancelled = new Promise<never>((_resolve, reject) => {
56
- onAbort = () => reject(new Error("cancelled before switching models"));
57
- if (signal.aborted) onAbort();
58
- else signal.addEventListener("abort", onAbort, { once: true });
59
- timer = setTimeout(() => reject(new Error("checkpoint-model authentication preflight timed out")), 10_000);
60
- });
61
- try { return await Promise.race([work, cancelled]); }
62
- finally { clearTimeout(timer); signal.removeEventListener("abort", onAbort); }
63
- }
64
-
65
40
  function modelKey(model: ExtensionContext["model"]): string | undefined {
66
41
  return model && `${model.provider}/${model.id}`;
67
42
  }
@@ -80,203 +55,19 @@ function freshCheckpoint(entries: readonly SessionEntry[], record: ManualCompact
80
55
  return findLatestNotesCheckpointSinceBoundary(entries, record.sessionId, afterId);
81
56
  }
82
57
 
83
- function branchFingerprint(entries: readonly SessionEntry[]): string {
84
- return createHash("sha256").update(JSON.stringify(entries)).digest("hex");
85
- }
86
-
87
- export type RemoteManualCompactOperation = {
88
- generation: number;
89
- sessionId: string;
90
- sessionFile: string | undefined;
91
- anchorId: string;
92
- branchLength: number;
93
- branchFingerprint: string;
94
- windowId: string;
95
- model: string;
96
- api: string;
97
- baseUrl: string;
98
- thinking: ThinkingLevel;
99
- controller: AbortController;
100
- signal: AbortSignal;
101
- removeAbortListeners: () => void;
102
- policy: ResolvedToolkitConfig;
103
- candidate?: CompactionResult<NativeCompactionDetails>;
104
- persistedId?: string;
105
- deadline?: number;
106
- };
107
-
108
- /**
109
- * One transient Remote V2 operation. It never selects a model, writes recovery
110
- * records, takes host queues, or resumes from reload. Legacy duty recovery below
111
- * remains independent.
112
- */
113
- export class RemoteManualCompact {
114
- private generation = 0;
115
- private operation: RemoteManualCompactOperation | undefined;
116
- private timer: ReturnType<typeof setTimeout> | undefined;
117
-
118
- constructor(private readonly pi: ExtensionAPI, private readonly windows: CodexContextWindowManager,
119
- private readonly idleTimeoutMs = 10_000) {}
120
-
121
- get snapshot() { return this.operation?.policy.snapshot; }
122
-
123
- invalidate(): void {
124
- ++this.generation;
125
- clearTimeout(this.timer);
126
- this.timer = undefined;
127
- const operation = this.operation;
128
- this.operation = undefined;
129
- operation?.removeAbortListeners();
130
- operation?.controller.abort();
131
- }
132
-
133
- cancel(operation: RemoteManualCompactOperation | undefined): void {
134
- if (operation && this.operation === operation) this.invalidate();
135
- }
136
-
137
- begin(event: SessionBeforeCompactEvent, ctx: ExtensionContext, policy: ResolvedToolkitConfig): RemoteManualCompactOperation | undefined {
138
- this.invalidate();
139
- const model = ctx.model;
140
- const anchorId = ctx.sessionManager.getLeafId();
141
- const windowId = this.windows.currentIdentity()?.currentWindowId;
142
- if (!model || !anchorId || !windowId || event.signal.aborted) return undefined;
143
- const branch = ctx.sessionManager.getBranch();
144
- if (!isDeepStrictEqual(event.branchEntries, branch)) return undefined;
145
- const controller = new AbortController();
146
- const onAbort = () => { if (this.operation === operation) this.invalidate(); };
147
- // Manual compact has already aborted the preceding agent run. Its old,
148
- // aborted ctx.signal is not cancellation of this newly initiated operation.
149
- const signals = [event.signal, ...(ctx.signal && !ctx.signal.aborted && ctx.signal !== event.signal ? [ctx.signal] : [])];
150
- const operation: RemoteManualCompactOperation = {
151
- generation: this.generation, sessionId: ctx.sessionManager.getSessionId(),
152
- sessionFile: ctx.sessionManager.getSessionFile(), anchorId,
153
- branchLength: branch.length, branchFingerprint: branchFingerprint(branch), windowId,
154
- model: modelKey(model)!, api: model.api, baseUrl: model.baseUrl, thinking: this.pi.getThinkingLevel(),
155
- controller, signal: controller.signal, policy,
156
- removeAbortListeners: () => { for (const signal of signals) signal.removeEventListener("abort", onAbort); },
157
- };
158
- this.operation = operation;
159
- for (const signal of signals) signal.addEventListener("abort", onAbort, { once: true });
160
- return operation;
161
- }
162
-
163
- private owns(operation: RemoteManualCompactOperation, ctx: ExtensionContext): boolean {
164
- if (this.operation !== operation || operation.generation !== this.generation || operation.signal.aborted
165
- || ctx.sessionManager.getSessionId() !== operation.sessionId || ctx.sessionManager.getSessionFile() !== operation.sessionFile
166
- || modelKey(ctx.model) !== operation.model || ctx.model?.api !== operation.api || ctx.model?.baseUrl !== operation.baseUrl
167
- || this.pi.getThinkingLevel() !== operation.thinking || ctx.hasPendingMessages()) return false;
168
- this.windows.synchronize(ctx);
169
- return this.windows.currentIdentity()?.currentWindowId === operation.windowId && !this.windows.hasPendingRollover(ctx);
170
- }
171
-
172
- current(operation: RemoteManualCompactOperation, ctx: ExtensionContext): boolean {
173
- try {
174
- const branch = ctx.sessionManager.getBranch();
175
- return this.owns(operation, ctx) && ctx.sessionManager.getLeafId() === operation.anchorId
176
- && branch.length === operation.branchLength && branchFingerprint(branch) === operation.branchFingerprint;
177
- } catch { return false; }
178
- }
179
-
180
- arm(
181
- operation: RemoteManualCompactOperation,
182
- ctx: ExtensionContext,
183
- candidate: CompactionResult<NativeCompactionDetails>,
184
- ): { readonly cancel: boolean; compaction: CompactionResult<NativeCompactionDetails> } | undefined {
185
- if (!this.current(operation, ctx)) return undefined;
186
- operation.candidate = structuredClone(candidate);
187
- const owner = this;
188
- return {
189
- compaction: candidate,
190
- // Pi reads this public result again after later before-compact observers.
191
- // Revalidate at consumption, not just at return: newer work must revoke
192
- // the candidate before persistence, without ctx.abort() draining TUI queues.
193
- get cancel() { return !owner.current(operation, ctx); },
194
- };
195
- }
196
-
197
- private durableCandidate(operation: RemoteManualCompactOperation, ctx: ExtensionContext): string | undefined {
198
- if (!this.owns(operation, ctx) || !operation.candidate) return undefined;
199
- const branch = ctx.sessionManager.getBranch();
200
- // The event's ID can refer to an OLD equal-summary checkpoint in Pi 1.0.
201
- // Only the new actual branch child and its complete candidate are proof.
202
- if (branch.length !== operation.branchLength + 1
203
- || branchFingerprint(branch.slice(0, operation.branchLength)) !== operation.branchFingerprint) return undefined;
204
- const entry = branch[operation.branchLength];
205
- const candidate = operation.candidate;
206
- if (!entry || entry.type !== "compaction" || entry.parentId !== operation.anchorId
207
- || entry.id === operation.anchorId || ctx.sessionManager.getLeafId() !== entry.id || entry.fromHook !== true
208
- || entry.summary !== candidate.summary || entry.firstKeptEntryId !== candidate.firstKeptEntryId
209
- || entry.tokensBefore !== candidate.tokensBefore
210
- // Persistence omits undefined optional fields; compare the actual JSON
211
- // data, not object identity, summary text, or provider response ID alone.
212
- || !isDeepStrictEqual(JSON.parse(JSON.stringify(entry.details)), JSON.parse(JSON.stringify(candidate.details)))) return undefined;
213
- return entry.id;
214
- }
215
-
216
- private requestManualResume(ctx: ExtensionContext, message = "Remote /compact saved its checkpoint; resume manually."): void {
217
- try { if (ctx.hasUI) ctx.ui.notify(message, "warning"); } catch { /* stale UI must not escape a deferred callback */ }
218
- }
219
-
220
- /** Observer only: never await idle or submit a prompt inside session_compact. */
221
- persisted(ctx: ExtensionContext): void {
222
- const operation = this.operation;
223
- if (!operation?.candidate || operation.persistedId) return;
224
- try { operation.persistedId = this.durableCandidate(operation, ctx); } catch { /* fail closed */ }
225
- if (!operation.persistedId) {
226
- this.invalidate();
227
- this.requestManualResume(ctx, "Remote /compact could not verify its exact saved checkpoint; resume manually.");
228
- return;
229
- }
230
- operation.deadline = Date.now() + this.idleTimeoutMs;
231
- this.schedule(operation, ctx, 0);
232
- }
233
-
234
- private schedule(operation: RemoteManualCompactOperation, ctx: ExtensionContext, delay: number): void {
235
- this.timer = setTimeout(() => {
236
- this.timer = undefined;
237
- if (this.operation !== operation) return;
238
- try {
239
- if (this.durableCandidate(operation, ctx) !== operation.persistedId) { this.invalidate(); return; }
240
- if (Date.now() >= operation.deadline!) {
241
- this.invalidate();
242
- this.requestManualResume(ctx, "Remote /compact saved its checkpoint; automatic continuation could not safely reach idle. Resume manually.");
243
- return;
244
- }
245
- if (!ctx.isIdle()) { this.schedule(operation, ctx, 25); return; }
246
- // No await between final public ownership/queue/idle proof and send.
247
- // Disarm first, so our own input/run events cannot invalidate a newer operation.
248
- this.invalidate();
249
- this.pi.sendUserMessage("Context compressed. Resume the active user task.");
250
- } catch {
251
- this.invalidate();
252
- this.requestManualResume(ctx);
253
- }
254
- }, delay);
255
- this.timer.unref?.();
256
- }
257
- }
258
-
259
- /** Owns only legacy managed handoffs, not normal window rollovers or Remote V2. */
58
+ /** Restore already-persisted legacy selections only; never start duty or inference. */
260
59
  export class ManagedManualCompact implements ContextRolloverHandoff {
261
60
  private record: ManualCompactRecord | undefined;
262
61
  private generation = 0;
263
- private sourcePolicy: ResolvedToolkitConfig | undefined;
264
- private instructions = "";
265
- private attemptSignal: AbortSignal | undefined;
266
62
  private selecting: string | undefined;
267
63
  private selectionPromise: Promise<boolean> | undefined;
268
64
  private expectedThinking: ThinkingLevel | undefined;
269
65
  private newerSelection: { model: NonNullable<ExtensionContext["model"]>; thinking: ThinkingLevel } | undefined;
270
- private deliveryTimer: ReturnType<typeof setTimeout> | undefined;
271
- private delivered = false;
272
- private resumeAllowed = true;
273
- private checkpointStopped = false;
274
66
  private maintenance = 0;
275
67
 
276
- constructor(private readonly pi: ExtensionAPI, private readonly windows: CodexContextWindowManager, private readonly deliveryTimeoutMs = 10_000) {}
68
+ constructor(private readonly pi: ExtensionAPI) {}
277
69
 
278
70
  get busy(): boolean { return !!this.record && !TERMINAL.has(this.record.phase); }
279
- get snapshot() { return this.busy ? this.sourcePolicy?.snapshot : undefined; }
280
71
  get isMaintenance(): boolean { return this.maintenance > 0; }
281
72
  beginMaintenance(): () => void {
282
73
  this.maintenance++;
@@ -294,11 +85,6 @@ export class ManagedManualCompact implements ContextRolloverHandoff {
294
85
  this.record = { ...this.record, phase };
295
86
  this.pi.appendEntry(MANUAL_COMPACT_ENTRY_TYPE, this.record);
296
87
  }
297
- private clearActivity(): void {
298
- clearTimeout(this.deliveryTimer);
299
- this.deliveryTimer = undefined;
300
- this.sourcePolicy = undefined;
301
- }
302
88
  private belongs(ctx: ExtensionContext, record = this.record): boolean {
303
89
  if (!record || ctx.sessionManager.getSessionId() !== record.sessionId) return false;
304
90
  const branch = ctx.sessionManager.getBranch();
@@ -307,144 +93,16 @@ export class ManagedManualCompact implements ContextRolloverHandoff {
307
93
  private current(ctx: ExtensionContext, generation: number): boolean {
308
94
  return generation === this.generation && this.busy && this.belongs(ctx);
309
95
  }
310
- private toolsAvailable(): boolean {
311
- const active = new Set(this.pi.getActiveTools());
312
- const published = new Set(this.pi.getAllTools().map((tool) => tool.name));
313
- return REQUIRED_TOOLS.every((tool) => active.has(tool) && published.has(tool));
314
- }
315
-
316
- async request(event: SessionBeforeCompactEvent, ctx: ExtensionContext, source: ResolvedToolkitConfig): Promise<void> {
317
- if (this.busy) { this.notify(ctx, "a handoff is already active; no second window was scheduled."); return; }
318
- this.windows.synchronize(ctx);
319
- const boundary = findLatestWindowBoundaryEntry(ctx.sessionManager.getBranch(), ctx.sessionManager.getSessionId());
320
- if (!ctx.model || !boundary || event.signal.aborted || !this.toolsAvailable()) {
321
- this.notify(ctx, "requires a live managed window and all four permitted context tools.", true); return;
322
- }
323
- if (this.windows.hasPendingRollover(ctx) || !this.windows.canRolloverFromCurrentWindow(ctx)) {
324
- this.notify(ctx, "this window is already switching or has not produced substantive tool activity since its last switch.", true); return;
325
- }
326
- const targetKey = source.config.compaction.remoteCompactModel ?? modelKey(ctx.model)!;
327
- const spec = parseModelSpec(targetKey);
328
- const target = spec && ctx.modelRegistry.find(spec.provider, spec.modelId);
329
- if (!target) { this.notify(ctx, `configured checkpoint model ${targetKey} is unavailable.`, true); return; }
330
- const targetPolicy = resolveToolkitConfig(source.snapshot, target);
331
- if (targetPolicy.invalidFeatures.some((feature) => ["context", "compatibility", "diagnostics"].includes(feature))
332
- || !targetPolicy.config.compaction.enabled || targetPolicy.config.compaction.contextManagement !== "remote") {
333
- this.notify(ctx, "the checkpoint model must have valid Remote Context policy.", true); return;
334
- }
335
- const projected = this.windows.project(buildSessionContext(ctx.sessionManager.getBranch()).messages, "remote");
336
- const tokens = projected.reduce((total, message) => total + estimateTokens(message), 0);
337
- const guidanceTokens = Math.ceil((event.customInstructions?.length ?? 0) / 4);
338
- if (!Number.isFinite(target.contextWindow) || !Number.isFinite(target.maxTokens) || target.maxTokens <= 0
339
- || tokens + target.maxTokens + guidanceTokens + 2048 >= target.contextWindow) {
340
- this.notify(ctx, "the checkpoint model cannot fit this window plus checkpoint output; no history was truncated.", true); return;
341
- }
342
- this.newerSelection = undefined;
343
- this.delivered = false;
344
- this.resumeAllowed = true;
345
- this.checkpointStopped = false;
346
- this.sourcePolicy = source;
347
- this.attemptSignal = event.signal;
348
- this.instructions = event.customInstructions?.trim() ?? "";
349
- this.record = {
350
- version: 1, operationId: randomUUID(), sessionId: ctx.sessionManager.getSessionId(),
351
- sourceWindowId: boundary.details.contextManagement.currentWindowId,
352
- anchorId: ctx.sessionManager.getLeafId() ?? boundary.id,
353
- originalModel: modelKey(ctx.model)!, targetModel: targetKey,
354
- originalThinking: this.pi.getThinkingLevel(), phase: "requested",
355
- };
356
- this.write("requested");
357
- const generation = ++this.generation;
358
- try {
359
- const [originalProvider, targetProvider] = await preflightDeadline(Promise.all([
360
- resolveCodexContextProvider(ctx, ctx.model, source.gatewayModelKeys),
361
- resolveCodexContextProvider(ctx, target, targetPolicy.gatewayModelKeys),
362
- ]), event.signal);
363
- if (!this.current(ctx, generation)) return;
364
- if (event.signal.aborted) throw new Error("cancelled before switching models");
365
- if (!originalProvider.ok || !targetProvider.ok || !sameContextScope(originalProvider.provider, targetProvider.provider)) {
366
- throw new Error("checkpoint model must authenticate to the same context backend/account");
367
- }
368
- this.notify(ctx, `checkpoint handoff requested (${this.record!.originalModel} → ${targetKey}); native compaction is intentionally cancelled.`);
369
- } catch (error) {
370
- if (!this.current(ctx, generation)) return;
371
- this.write(event.signal.aborted ? "cancelled" : "failed"); this.clearActivity();
372
- this.notify(ctx, error instanceof Error ? error.message : "preflight failed", true);
373
- }
374
- }
375
-
376
- /** Called only after the native manual operation has exposed idle state. */
377
- async compactFailed(ctx: ExtensionContext): Promise<void> {
378
- if (this.record?.phase !== "requested") return;
379
- if (!ctx.isIdle() || this.attemptSignal?.aborted) {
380
- this.write("cancelled"); this.clearActivity(); this.notify(ctx, "cancelled before checkpoint duty began.", true); return;
381
- }
382
- const generation = this.generation;
383
- // Fence a second terminal callback while public selection awaits auth.
384
- this.write("switching");
385
- try {
386
- if (!this.belongs(ctx) || !this.toolsAvailable()) throw new Error("session or context-tool availability changed");
387
- if (!(await this.select(this.record.targetModel, ctx))) throw new Error("checkpoint model selection failed");
388
- if (!this.current(ctx, generation)) { await this.honorNewerSelection(ctx); return; }
389
- if (this.attemptSignal?.aborted) throw new Error("cancelled during model selection");
390
- if (!this.toolsAvailable()) throw new Error("required context tools became unavailable");
391
- this.write("checkpointing");
392
- const prompt = `[Managed /compact ${this.record.operationId}]\n`
393
- + "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. "
394
- + "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. "
395
- + "After scheduling the window, stop; the extension will restore the original model before resuming. No conversation summary carries over."
396
- + (this.instructions ? `\nAdditional user guidance for this checkpoint: ${this.instructions}` : "");
397
- this.deliveryTimer = setTimeout(() => {
398
- if (this.current(ctx, generation) && !this.delivered && ctx.isIdle()) void this.finish(ctx, false, false);
399
- }, this.deliveryTimeoutMs);
400
- this.deliveryTimer.unref?.();
401
- this.pi.sendUserMessage(prompt);
402
- } catch {
403
- if (this.current(ctx, generation)) await this.finish(ctx, false, false);
404
- }
405
- }
406
96
 
407
- messageStarted(message: { role: string; content?: unknown }): void {
408
- if (this.record?.phase !== "checkpointing" || message.role !== "user") return;
409
- const text = typeof message.content === "string" ? message.content : JSON.stringify(message.content);
410
- if (!text?.includes(`[Managed /compact ${this.record.operationId}]`)) return;
411
- this.delivered = true;
412
- clearTimeout(this.deliveryTimer);
413
- }
414
-
415
- prepare(ctx: ExtensionContext): { triggerTurn: false; beforeSchedule: (identity: ContextWindowIdentity) => void } | undefined {
416
- if (!this.busy) return undefined;
417
- const record = this.record!;
418
- if (!this.belongs(ctx) || record.phase !== "checkpointing" || !this.delivered
419
- || modelKey(ctx.model) !== record.targetModel || this.windows.currentIdentity()?.currentWindowId !== record.sourceWindowId) {
420
- throw new Error("Managed /compact is not ready for a rollover; do not retry while restoration is pending.");
421
- }
422
- if (!freshCheckpoint(ctx.sessionManager.getBranch(), record)) {
423
- throw new Error("Managed /compact requires a new persisted successful notes write/append after this operation and the latest delivered user message.");
424
- }
425
- const generation = this.generation;
426
- return {
427
- triggerTurn: false,
428
- beforeSchedule: (identity) => {
429
- const boundary = findLatestWindowBoundaryEntry(ctx.sessionManager.getBranch(), record.sessionId);
430
- if (!this.current(ctx, generation) || modelKey(ctx.model) !== record.targetModel
431
- || boundary?.details.contextManagement.currentWindowId !== record.sourceWindowId
432
- || !freshCheckpoint(ctx.sessionManager.getBranch(), record)) throw new Error("Managed /compact was interrupted before rollover.");
433
- this.record = { ...record, targetWindowId: identity.currentWindowId };
434
- this.write("rollover-scheduled");
435
- },
436
- };
97
+ /** Legacy recovery may block a rollover, but never initiate a handoff. */
98
+ prepare(_ctx: ExtensionContext): undefined {
99
+ if (this.busy) throw new Error("Legacy /compact selection recovery is pending; wait for restoration.");
100
+ return undefined;
437
101
  }
438
102
 
439
103
  /** Abort without waiting: a queued/mixed continuation must not send the captured temporary model. */
440
104
  turnEnded(ctx: ExtensionContext): void {
441
- if (this.busy && this.record?.phase !== "checkpointing" && this.record?.phase !== "requested") {
442
- if (!this.checkpointStopped) {
443
- this.resumeAllowed = !ctx.signal?.aborted;
444
- this.checkpointStopped = true;
445
- }
446
- ctx.abort();
447
- }
105
+ if (this.busy) ctx.abort();
448
106
  }
449
107
  guardRequest(ctx: ExtensionContext): void {
450
108
  if (ctx.signal?.aborted) return;
@@ -453,15 +111,12 @@ export class ManagedManualCompact implements ContextRolloverHandoff {
453
111
  throw new Error("Managed /compact is preserving your newer model selection; this provider request was cancelled.");
454
112
  }
455
113
  if (!this.busy) return;
456
- if (this.record?.phase === "checkpointing" && this.belongs(ctx) && this.toolsAvailable() && modelKey(ctx.model) === this.record.targetModel
457
- && this.windows.currentIdentity()?.currentWindowId === this.record.sourceWindowId) return;
458
114
  ctx.abort();
459
115
  throw new Error("Managed /compact is restoring the original model; this provider request was cancelled.");
460
116
  }
461
- guardTool(name: string): { block: true; terminate: true; reason: string } | undefined {
117
+ guardTool(_name: string): { block: true; terminate: true; reason: string } | undefined {
462
118
  if (!this.busy && !this.selectionPromise) return;
463
- if (this.record?.phase === "checkpointing" && REQUIRED_TOOLS.includes(name)) return;
464
- return { block: true, terminate: true, reason: "Managed /compact owns this checkpoint-only turn. Wait for the original-model continuation before ordinary work." };
119
+ return { block: true, terminate: true, reason: "Legacy /compact selection recovery is pending. Wait for restoration before ordinary work." };
465
120
  }
466
121
 
467
122
  modelSelected(model: NonNullable<ExtensionContext["model"]>, ctx: ExtensionContext): void {
@@ -469,7 +124,6 @@ export class ManagedManualCompact implements ContextRolloverHandoff {
469
124
  this.newerSelection = { model, thinking: this.pi.getThinkingLevel() };
470
125
  ++this.generation;
471
126
  if (this.belongs(ctx)) this.write("superseded");
472
- this.clearActivity();
473
127
  if (!ctx.isIdle()) ctx.abort();
474
128
  this.notify(ctx, "stopped; your newer model selection takes priority.");
475
129
  }
@@ -521,23 +175,23 @@ export class ManagedManualCompact implements ContextRolloverHandoff {
521
175
 
522
176
  async settled(ctx: ExtensionContext): Promise<boolean> {
523
177
  if (!this.busy) return false;
524
- if (this.record?.phase === "requested" || this.record?.phase === "switching" || this.record?.phase === "restore-needed" || this.record?.phase === "restoring") return true;
525
- await this.finish(ctx, this.resumeAllowed, true);
178
+ if (this.record?.phase === "restore-needed" || this.record?.phase === "restoring") return true;
179
+ await this.finish(ctx, true);
526
180
  return true;
527
181
  }
528
182
 
529
- private async finish(ctx: ExtensionContext, mayResume: boolean, inspectMarker: boolean): Promise<void> {
183
+ private async finish(ctx: ExtensionContext, inspectMarker: boolean): Promise<void> {
530
184
  const record = this.record;
531
185
  if (!record || !ctx.isIdle()) return;
532
186
  const generation = this.generation;
533
- if (!this.belongs(ctx)) { this.clearActivity(); this.record = undefined; ++this.generation; return; }
187
+ if (!this.belongs(ctx)) { this.record = undefined; ++this.generation; return; }
534
188
  const branch = ctx.sessionManager.getBranch();
535
189
  const marker = findLatestWindowBoundaryEntry(branch, record.sessionId);
536
190
  const switched = inspectMarker && !!record.targetWindowId && marker?.details.contextManagement.currentWindowId === record.targetWindowId
537
191
  && marker.details.contextManagement.previousWindowId === record.sourceWindowId;
538
192
  const receipt = switched ? freshCheckpoint(branch.slice(0, branch.indexOf(marker!)), record) : undefined;
539
193
  if (modelKey(ctx.model) !== record.targetModel && modelKey(ctx.model) !== record.originalModel) {
540
- this.write("superseded"); this.clearActivity(); return;
194
+ this.write("superseded"); return;
541
195
  }
542
196
  this.write("restoring");
543
197
  try {
@@ -552,11 +206,7 @@ export class ManagedManualCompact implements ContextRolloverHandoff {
552
206
  return;
553
207
  }
554
208
  this.write(switched && receipt ? "completed" : "failed");
555
- this.clearActivity();
556
- this.notify(ctx, switched && receipt ? `new window persisted; restored ${record.originalModel}.` : "checkpoint handoff did not complete; original model restored.", !receipt);
557
- if (mayResume && receipt) {
558
- 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.`);
559
- }
209
+ this.notify(ctx, switched && receipt ? `new window persisted; restored ${record.originalModel}.` : "legacy checkpoint duty stopped; original model restored.", !receipt);
560
210
  }
561
211
 
562
212
  /** Reload/navigation recovers selection only. It never retries inference or writes notes. */
@@ -564,7 +214,6 @@ export class ManagedManualCompact implements ContextRolloverHandoff {
564
214
  const previous = this.busy ? this.record : undefined;
565
215
  ++this.generation;
566
216
  await this.selectionPromise?.catch(() => false);
567
- this.clearActivity();
568
217
  if (previous && ctx.isIdle() && previous.sessionId === ctx.sessionManager.getSessionId()
569
218
  && modelKey(ctx.model) === previous.targetModel && !this.belongs(ctx, previous)) {
570
219
  // Tree navigation can leave the owned temporary selection on a sibling
@@ -573,7 +222,7 @@ export class ManagedManualCompact implements ContextRolloverHandoff {
573
222
  if (anchorId) {
574
223
  this.record = { ...previous, operationId: randomUUID(), anchorId, targetWindowId: undefined };
575
224
  this.write("restoring");
576
- await this.finish(ctx, false, false);
225
+ await this.finish(ctx, false);
577
226
  return;
578
227
  }
579
228
  }
@@ -587,13 +236,12 @@ export class ManagedManualCompact implements ContextRolloverHandoff {
587
236
  // A newer explicit model change on the branch wins over the saved operation.
588
237
  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;
589
238
  this.record = record;
590
- await this.finish(ctx, false, true);
239
+ await this.finish(ctx, true);
591
240
  }
592
241
  async shutdown(ctx: ExtensionContext): Promise<void> {
593
242
  ++this.generation;
594
243
  await this.selectionPromise?.catch(() => false);
595
- if (this.busy) await this.finish(ctx, false, true);
596
- this.clearActivity();
244
+ if (this.busy) await this.finish(ctx, true);
597
245
  this.record = undefined;
598
246
  }
599
247
  }