pi-plans 0.1.0 → 0.1.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.
package/src/exec.ts CHANGED
@@ -8,13 +8,23 @@
8
8
  */
9
9
 
10
10
  import * as fs from "node:fs";
11
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
11
+ import type {
12
+ CompactionResult,
13
+ ExtensionAPI,
14
+ ExtensionContext,
15
+ SessionBeforeCompactEvent,
16
+ SessionBeforeCompactResult,
17
+ SessionCompactEvent,
18
+ SessionCompactFailedEvent,
19
+ } from "@earendil-works/pi-coding-agent";
12
20
  import {
13
21
  attachPanelBaseline,
14
22
  clearExecutionPanel,
15
23
  completeCompletedItems,
24
+ computeExecutionProgress,
16
25
  createExecutionPanelState,
17
26
  executionPanelFromEntryData,
27
+ formatExecutionStatusLine,
18
28
  refreshExecutionPanel,
19
29
  snapshotPanelState,
20
30
  toggleExpanded,
@@ -22,12 +32,23 @@ import {
22
32
  type ExecutionPanelState,
23
33
  type ItemDiffSummary,
24
34
  } from "./execution-panel.ts";
25
- import { readActive, setRunStatus, utcNow } from "./state.ts";
26
- import { scanDoneMarkers, type CheckItem } from "./plan.ts";
35
+ import { getRun, readActive, setRunStatus, utcNow, type ExecutionConfig } from "./state.ts";
36
+ import {
37
+ latestPlanVersion,
38
+ scanDoneMarkers,
39
+ scanImplMarkers,
40
+ type CheckItem,
41
+ type ImplItem,
42
+ type ImplMarkerState,
43
+ } from "./plan.ts";
27
44
 
28
45
  export interface ExecState extends ExecutionPanelExecutionLike {
29
46
  startedAt: string;
30
47
  panel?: ExecutionPanelState;
48
+ usage: { inToks: number; outToks: number };
49
+ modelState?: ExecutionModelState;
50
+ implItems?: ImplItem[];
51
+ implStatus?: Record<string, ImplMarkerState>;
31
52
  }
32
53
 
33
54
  let execution: ExecState | null = null;
@@ -42,30 +63,258 @@ export function consumePendingPanelSync(): boolean {
42
63
  return pending;
43
64
  }
44
65
 
66
+ // Execution-loop UI churn deferral: mid-agent appendEntry forces a TUI
67
+ // relayout (see toggleExecutionPanelView), so while the agent runs we only
68
+ // arm a pending flag and flush once at a settle boundary (agent_settled,
69
+ // next before_agent_start, stop/complete).
70
+ let pendingExecutionFlush = false;
71
+
72
+ export function consumePendingExecutionFlush(): boolean {
73
+ const pending = pendingExecutionFlush;
74
+ pendingExecutionFlush = false;
75
+ return pending;
76
+ }
77
+
78
+ function requestExecutionFlush(pi: ExtensionAPI, ctx: ExtensionContext): void {
79
+ // Unconditional defer. turn_end fires mid-run in a gap between agent
80
+ // operations where isIdle() reads true — a busy/idle gate never defers
81
+ // there (the 60-writes-in-23-minutes flashing regression). Persistence
82
+ // happens only at the drain points: agent_settled, the next
83
+ // before_agent_start, and stop/complete.
84
+ pendingExecutionFlush = true;
85
+ // Keep both execution views current without persisting or re-registering the
86
+ // panel widget. Collapsed refresh is idempotent; expanded refresh invalidates
87
+ // the live widget's render cache.
88
+ syncExecutionPanel(ctx);
89
+ }
90
+
91
+ export function drainExecutionFlush(pi: ExtensionAPI, ctx: ExtensionContext): void {
92
+ if (!execution) return;
93
+ if (!pendingExecutionFlush && !consumePendingPanelSync()) return;
94
+ pendingExecutionFlush = false;
95
+ persist(pi);
96
+ syncExecutionPanel(ctx);
97
+ updateStatusWidget(ctx);
98
+ }
99
+
45
100
  export function getExecution(): ExecState | null {
46
101
  return execution;
47
102
  }
48
103
 
49
- export function executionProgress(): { done: number; total: number } | null {
50
- if (!execution) return null;
104
+ const THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
105
+ const CURRENT_SESSION_MODEL_CHOICE = "Inherit current session model";
106
+
107
+ export interface ExecutionModelState {
108
+ planningSelector: string | null;
109
+ executionSelector: string;
110
+ }
111
+
112
+ export interface ModelSelection {
113
+ provider: string;
114
+ modelId: string;
115
+ thinkingLevel: string | null;
116
+ selector: string;
117
+ model: any;
118
+ }
119
+
120
+ function buildModelSelector(provider: string, modelId: string, thinkingLevel?: string | null): string {
121
+ return thinkingLevel ? `${provider}/${modelId}:${thinkingLevel}` : `${provider}/${modelId}`;
122
+ }
123
+
124
+ export function currentModelSelection(ctx: Pick<ExtensionContext, "model" | "thinkingLevel">): ModelSelection | null {
125
+ if (!ctx.model) return null;
51
126
  return {
52
- done: execution.items.filter((item) => item.done).length,
53
- total: execution.items.length,
127
+ provider: ctx.model.provider,
128
+ modelId: ctx.model.id,
129
+ thinkingLevel: ctx.thinkingLevel ?? null,
130
+ selector: buildModelSelector(ctx.model.provider, ctx.model.id, ctx.thinkingLevel ?? null),
131
+ model: ctx.model,
54
132
  };
55
133
  }
56
134
 
135
+ export function snapshotCurrentModelSelector(ctx: Pick<ExtensionContext, "model" | "thinkingLevel">): string | null {
136
+ return currentModelSelection(ctx)?.selector ?? null;
137
+ }
138
+
139
+ export function resolveModelSelection(ctx: ExtensionContext, selector: string): ModelSelection | null {
140
+ const slash = selector.indexOf("/");
141
+ if (slash <= 0 || slash === selector.length - 1) return null;
142
+ const provider = selector.slice(0, slash);
143
+ const rawModelId = selector.slice(slash + 1);
144
+ const exact = ctx.modelRegistry.find(provider, rawModelId);
145
+ if (exact) {
146
+ return { provider, modelId: rawModelId, thinkingLevel: null, selector, model: exact };
147
+ }
148
+ const colon = rawModelId.lastIndexOf(":");
149
+ if (colon > 0) {
150
+ const modelId = rawModelId.slice(0, colon);
151
+ const thinkingLevel = rawModelId.slice(colon + 1);
152
+ if (THINKING_LEVELS.has(thinkingLevel)) {
153
+ const model = ctx.modelRegistry.find(provider, modelId);
154
+ if (model) {
155
+ return { provider, modelId, thinkingLevel, selector, model };
156
+ }
157
+ }
158
+ }
159
+ return null;
160
+ }
161
+
162
+ function listAvailableModelSelections(ctx: ExtensionContext): ModelSelection[] {
163
+ const rawSelections = ctx.scopedModels.length > 0
164
+ ? ctx.scopedModels.map((entry) => ({
165
+ provider: entry.model.provider,
166
+ modelId: entry.model.id,
167
+ thinkingLevel: entry.thinkingLevel ?? null,
168
+ selector: buildModelSelector(entry.model.provider, entry.model.id, entry.thinkingLevel ?? null),
169
+ model: entry.model,
170
+ }))
171
+ : ctx.modelRegistry.getAvailable().map((model) => ({
172
+ provider: model.provider,
173
+ modelId: model.id,
174
+ thinkingLevel: null,
175
+ selector: buildModelSelector(model.provider, model.id),
176
+ model,
177
+ }));
178
+ const seen = new Set<string>();
179
+ const unique: ModelSelection[] = [];
180
+ for (const selection of rawSelections) {
181
+ if (seen.has(selection.selector)) continue;
182
+ seen.add(selection.selector);
183
+ unique.push(selection);
184
+ }
185
+ return unique;
186
+ }
187
+
188
+ export async function applyModelSelection(pi: ExtensionAPI, ctx: ExtensionContext, selection: ModelSelection): Promise<boolean> {
189
+ if (ctx.model && ctx.model.provider === selection.provider && ctx.model.id === selection.modelId) {
190
+ if (selection.thinkingLevel !== null && ctx.thinkingLevel !== selection.thinkingLevel) {
191
+ pi.setThinkingLevel(selection.thinkingLevel as never);
192
+ }
193
+ return true;
194
+ }
195
+ const success = await pi.setModel(selection.model);
196
+ if (!success) return false;
197
+ if (selection.thinkingLevel !== null) {
198
+ pi.setThinkingLevel(selection.thinkingLevel as never);
199
+ }
200
+ return true;
201
+ }
202
+
203
+ export async function chooseExecutionModelSelection(
204
+ ctx: ExtensionContext,
205
+ prompt: string,
206
+ configured: Pick<ExecutionConfig, "model_selector" | "source"> | null | undefined,
207
+ onChosenSelector?: (selector: string) => Promise<void> | void,
208
+ ): Promise<ModelSelection | null> {
209
+ const current = currentModelSelection(ctx);
210
+ const available = listAvailableModelSelections(ctx);
211
+ const configuredSelector = configured?.model_selector ?? null;
212
+ const configuredSource = configured?.source ?? "unset";
213
+
214
+ if (configuredSource === "unset") {
215
+ const choices = current
216
+ ? [
217
+ CURRENT_SESSION_MODEL_CHOICE,
218
+ ...available
219
+ .filter((selection) => selection.selector !== current.selector)
220
+ .map((selection) => selection.selector),
221
+ ]
222
+ : available.map((selection) => selection.selector);
223
+ if (!ctx.hasUI || choices.length === 0) {
224
+ ctx.ui.notify(`${prompt}: execution model is not set; continuing with the current session model.`, "warning");
225
+ return current;
226
+ }
227
+ const choice = await ctx.ui.select(
228
+ `${prompt} — execution model is not set. ${current ? "Inherit the current session model or choose another model:" : "Choose a model:"}`,
229
+ choices,
230
+ );
231
+ if (!choice) return current;
232
+ if (choice === CURRENT_SESSION_MODEL_CHOICE) {
233
+ if (onChosenSelector) {
234
+ await onChosenSelector("inherit");
235
+ }
236
+ return current;
237
+ }
238
+ if (onChosenSelector) {
239
+ await onChosenSelector(choice);
240
+ }
241
+ return resolveModelSelection(ctx, choice) ?? current;
242
+ }
243
+
244
+ if (configuredSelector) {
245
+ const resolved = resolveModelSelection(ctx, configuredSelector);
246
+ if (resolved) return resolved;
247
+ } else {
248
+ return current;
249
+ }
250
+
251
+ if (!ctx.hasUI || available.length === 0) {
252
+ ctx.ui.notify(`${prompt}: configured execution model "${configuredSelector}" is unavailable; continuing with the current session model.`, "warning");
253
+ return current;
254
+ }
255
+ const choice = await ctx.ui.select(
256
+ `${prompt} — "${configuredSelector}" is unavailable. Pick a model:`,
257
+ [
258
+ CURRENT_SESSION_MODEL_CHOICE,
259
+ ...available
260
+ .filter((selection) => selection.selector !== current?.selector)
261
+ .map((selection) => selection.selector),
262
+ ],
263
+ );
264
+ if (!choice || choice === CURRENT_SESSION_MODEL_CHOICE) {
265
+ return current;
266
+ }
267
+ if (onChosenSelector) {
268
+ await onChosenSelector(choice);
269
+ }
270
+ return resolveModelSelection(ctx, choice) ?? current;
271
+ }
272
+
273
+ export function executionProgress(): { done: number; total: number } | null {
274
+ if (!execution) return null;
275
+ return computeExecutionProgress(execution);
276
+ }
277
+
57
278
  export function updateStatusWidget(ctx: ExtensionContext): void {
58
- const progress = executionProgress();
59
- if (progress) {
60
- // The below-editor panel owns the in-execution progress display; keep the
61
- // status bar free of a duplicate count (and clear stale ones from before).
62
- ctx.ui.setStatus("pi-plans", undefined);
279
+ if (execution) {
280
+ if (execution.panel?.expanded) {
281
+ // The expanded panel renders the header itself; the footer copy is
282
+ // cleared by refreshExecutionPanel. Write nothing here.
283
+ return;
284
+ }
285
+ const line = formatExecutionStatusLine(execution);
286
+ ctx.ui.setStatus("pi-plans", ctx.ui.theme.fg("accent", line));
63
287
  return;
64
288
  }
65
289
  const active = readActive(ctx.cwd);
66
290
  if (active) {
67
- ctx.ui.setStatus("pi-plans", ctx.ui.theme.fg("warning", `⏸ plans: ${active.run_id}`));
68
- return;
291
+ // Idle indicator depends on the run's lifecycle, not just its existence:
292
+ // done reads as finished, abandoned as closed, stopped/accepted as paused.
293
+ const status = getRun(ctx.cwd, active.run_id)?.status;
294
+ if (status === "done") {
295
+ ctx.ui.setStatus("pi-plans", ctx.ui.theme.fg("success", `🎯 plans: ${active.run_id} (done)`));
296
+ return;
297
+ }
298
+ if (status === "abandoned") {
299
+ ctx.ui.setStatus("pi-plans", ctx.ui.theme.fg("error", `🚫 plans: ${active.run_id}`));
300
+ return;
301
+ }
302
+ if (status === "stopped") {
303
+ ctx.ui.setStatus("pi-plans", ctx.ui.theme.fg("warning", `⛔ plans: ${active.run_id}`));
304
+ return;
305
+ }
306
+ if (status === "accepted") {
307
+ ctx.ui.setStatus("pi-plans", ctx.ui.theme.fg("warning", `⌛ plans: ${active.run_id}`));
308
+ return;
309
+ }
310
+ if (status === "planning") {
311
+ // Planning phase: 💬 while still in Q&A, 📝 once a PLAN draft exists
312
+ // — kept until execution starts (then ⌛ takes over).
313
+ const emoji = latestPlanVersion(active.artifact_dir) ? "📝" : "💬";
314
+ ctx.ui.setStatus("pi-plans", ctx.ui.theme.fg("muted", `${emoji} plans: ${active.run_id}`));
315
+ return;
316
+ }
317
+ // unknown status: no indicator.
69
318
  }
70
319
  ctx.ui.setStatus("pi-plans", undefined);
71
320
  }
@@ -76,7 +325,9 @@ function persist(pi: ExtensionAPI): void {
76
325
  planPath: execution.planPath,
77
326
  items: execution.items,
78
327
  startedAt: execution.startedAt,
328
+ usage: execution.usage,
79
329
  panel: snapshotPanelState(execution),
330
+ implItems: execution.implItems,
80
331
  });
81
332
  }
82
333
 
@@ -88,16 +339,22 @@ export function syncExecutionPanel(ctx: ExtensionContext): void {
88
339
  refreshExecutionPanel(ctx, execution);
89
340
  }
90
341
 
91
- export function startExecution(
342
+ export async function startExecution(
92
343
  pi: ExtensionAPI,
93
344
  ctx: ExtensionContext,
94
345
  planPath: string,
95
346
  items: CheckItem[],
96
- ): void {
97
- execution = { planPath, items, startedAt: utcNow(), panel: createExecutionPanelState() };
347
+ modelState?: ExecutionModelState,
348
+ implItems?: ImplItem[],
349
+ ): Promise<void> {
350
+ execution = { planPath, items, startedAt: utcNow(), panel: createExecutionPanelState(), usage: { inToks: 0, outToks: 0 }, modelState, implItems: implItems ?? [], implStatus: {} };
98
351
  attachPanelBaseline(execution, ctx.cwd);
99
352
  consumePendingPanelSync(); // fresh run: drop any stale deferral from a previous one
353
+ pendingExecutionFlush = false; // fresh run: no inherited flush debt
100
354
  persist(pi);
355
+ if (modelState) {
356
+ await ensureExecutionModelActive(pi, ctx);
357
+ }
101
358
  const active = readActive(ctx.cwd);
102
359
  if (active) {
103
360
  try {
@@ -109,7 +366,7 @@ export function startExecution(
109
366
  pi.sendMessage(
110
367
  {
111
368
  customType: "pi-plans-exec-start",
112
- content: `**pi-plans: executing** \`${planPath}\` — ${items.length} verifier item(s). Progress appears below the editor; mark verified items with \`[DONE:VC-xxx]\`.`,
369
+ content: `**pi-plans: executing** \`${planPath}\` — ${items.length} verifier item(s). Progress appears in the bottom status bar; mark verified items with \`[DONE:VC-xxx]\`.`,
113
370
  display: true,
114
371
  },
115
372
  { triggerTurn: false },
@@ -148,16 +405,556 @@ export function recordTouchedPaths(_workdir: string, paths: string[]): void {
148
405
  panel.touchedPaths = [...merged];
149
406
  }
150
407
 
151
- export function recordExecutionCompletion(pi: ExtensionAPI, ctx: ExtensionContext, completedIds: string[]): ItemDiffSummary | null {
408
+ /** Record one assistant turn: accumulate usage and mark any completed items. */
409
+ export function recordExecutionTurn(
410
+ pi: ExtensionAPI,
411
+ ctx: ExtensionContext,
412
+ completedIds: string[],
413
+ usage?: { input: number; output: number },
414
+ ): ItemDiffSummary | null {
152
415
  if (!execution) return null;
416
+ if (usage) {
417
+ execution.usage.inToks += usage.input;
418
+ execution.usage.outToks += usage.output;
419
+ }
153
420
  const summary = completeCompletedItems(execution, ctx.cwd, completedIds);
154
- persist(pi);
155
- syncExecutionPanel(ctx);
421
+ requestExecutionFlush(pi, ctx);
156
422
  return summary;
157
423
  }
158
424
 
159
- export function stopExecution(pi: ExtensionAPI, ctx: ExtensionContext, reason: string): void {
425
+ export function registerExecutionTurnHandlers(
426
+ pi: ExtensionAPI,
427
+ onTurnEnd?: (ctx: ExtensionContext) => Promise<void> | void,
428
+ ): void {
429
+ // The turn_end projection does not carry usage; message_end delivers the
430
+ // full assistant message, so cache it here and consume it per turn.
431
+ let lastAssistantUsage: { input: number; output: number } | null = null;
432
+ pi.on("message_end", async (event) => {
433
+ const message = event.message as { role?: string; usage?: { input?: number; output?: number } };
434
+ if (message?.role === "assistant" && message.usage) {
435
+ lastAssistantUsage = { input: message.usage.input ?? 0, output: message.usage.output ?? 0 };
436
+ }
437
+ });
438
+
439
+ pi.on("turn_end", async (event, ctx) => {
440
+ const message = event.message as { role?: string; content?: Array<{ type: string; text?: string }> };
441
+ if (!message || message.role !== "assistant") {
442
+ updateStatusWidget(ctx);
443
+ return;
444
+ }
445
+ const text = (message.content ?? [])
446
+ .filter((part) => part.type === "text")
447
+ .map((part) => part.text ?? "")
448
+ .join("\n");
449
+ const changedIds = applyDoneMarkers(text);
450
+ const changedImpls = applyImplMarkers(text);
451
+ const projection = (event.message as { usage?: { input?: number; output?: number } }).usage;
452
+ const raw = projection ?? lastAssistantUsage;
453
+ lastAssistantUsage = null; // consumed: never re-attribute a stale turn
454
+ const usage = raw ? { input: raw.input ?? 0, output: raw.output ?? 0 } : undefined;
455
+ if (usage || changedIds.length > 0 || changedImpls.length > 0) {
456
+ // Attribute this turn's usage now; `[DONE]` markers still only mark completion.
457
+ recordExecutionTurn(pi, ctx, changedIds, usage);
458
+ }
459
+ if (getExecution() && isExecutionComplete()) {
460
+ await completeExecution(pi, ctx);
461
+ }
462
+ await onTurnEnd?.(ctx);
463
+ });
464
+ }
465
+
466
+ const EXECUTION_RESUME_CUSTOM_TYPE = "pi-plans-exec-resume";
467
+
468
+ type CompactBranchEntry = SessionBeforeCompactEvent["branchEntries"][number];
469
+ type CompactMessage = { role?: string; content?: Array<{ type: string; text?: string }> };
470
+
471
+ function compactText(text: string, limit = 180): string {
472
+ const normalized = text.replace(/\s+/g, " ").trim();
473
+ if (normalized.length <= limit) return normalized;
474
+ return `${normalized.slice(0, Math.max(0, limit - 1))}…`;
475
+ }
476
+
477
+ function messageText(message: CompactMessage | undefined): string {
478
+ if (!message) return "";
479
+ return (message.content ?? [])
480
+ .filter((part) => part.type === "text")
481
+ .map((part) => part.text ?? "")
482
+ .join("\n")
483
+ .trim();
484
+ }
485
+
486
+ function messageRoleLabel(role?: string): string {
487
+ switch (role) {
488
+ case "assistant": return "Assistant";
489
+ case "user": return "User";
490
+ case "toolResult": return "Tool";
491
+ case "custom": return "Custom";
492
+ default: return role ? role : "Message";
493
+ }
494
+ }
495
+
496
+ function isInternalExecutionCustomType(customType?: string): boolean {
497
+ return customType === "pi-plans-exec"
498
+ || customType === "pi-plans-exec-cleared"
499
+ || customType === "pi-plans-exec-start"
500
+ || customType === "pi-plans-exec-context"
501
+ || customType === EXECUTION_RESUME_CUSTOM_TYPE;
502
+ }
503
+
504
+ function isSummarizableEntry(entry: CompactBranchEntry): boolean {
505
+ return entry.type === "message" && !!entry.message;
506
+ }
507
+
508
+ function renderMessageLine(entry: CompactBranchEntry): string {
509
+ if (!isSummarizableEntry(entry)) return "";
510
+ const text = messageText(entry.message);
511
+ if (!text) return "";
512
+ return `- [${messageRoleLabel(entry.message?.role)}] ${compactText(text)}`;
513
+ }
514
+
515
+ function findExecutionCompactionCutEntryId(branchEntries: CompactBranchEntry[], fallback: string): string {
516
+ const completionIds = new Set(execution?.items.filter((item) => item.done).map((item) => item.id) ?? []);
517
+ let lastCompletionIndex = -1;
518
+ for (let i = 0; i < branchEntries.length; i++) {
519
+ const entry = branchEntries[i];
520
+ if (!isSummarizableEntry(entry)) continue;
521
+ const markers = scanDoneMarkers(messageText(entry.message));
522
+ if (markers.some((marker) => completionIds.has(marker))) {
523
+ lastCompletionIndex = i;
524
+ }
525
+ }
526
+ if (lastCompletionIndex >= 0) {
527
+ const next = branchEntries.slice(lastCompletionIndex + 1).find((entry) => entry.id && !isInternalExecutionCustomType(entry.customType));
528
+ if (next?.id) return next.id;
529
+ }
530
+ const startIndex = branchEntries.findIndex((entry) => entry.type === "custom" && entry.customType === "pi-plans-exec-start");
531
+ if (startIndex >= 0) {
532
+ const next = branchEntries.slice(startIndex + 1).find((entry) => entry.id && !isInternalExecutionCustomType(entry.customType));
533
+ if (next?.id) return next.id;
534
+ }
535
+ return fallback;
536
+ }
537
+
538
+ function buildFinishedItemSections(summaryEntries: CompactBranchEntry[]): string[] {
539
+ if (!execution) return [];
540
+ const completedItems = execution.items.filter((item) => item.done);
541
+ const sections: string[] = [];
542
+ let completedIndex = 0;
543
+ let currentLines: string[] = [];
544
+ for (const entry of summaryEntries) {
545
+ if (!isSummarizableEntry(entry)) continue;
546
+ const line = renderMessageLine(entry);
547
+ if (line) currentLines.push(line);
548
+ for (const marker of scanDoneMarkers(messageText(entry.message))) {
549
+ const itemIndex = completedItems.findIndex((item, index) => index >= completedIndex && item.id === marker);
550
+ if (itemIndex < 0) continue;
551
+ const item = completedItems[itemIndex];
552
+ sections.push(`### \`${item.id}\` ${item.text.split(";")[0]}
553
+ ${currentLines.length ? currentLines.join("\n") : "- (no transcript captured)"}`);
554
+ currentLines = [];
555
+ completedIndex = itemIndex + 1;
556
+ }
557
+ }
558
+ return sections;
559
+ }
560
+
561
+ export function buildExecutionCompactionResult(event: SessionBeforeCompactEvent, ctx: ExtensionContext): CompactionResult | null {
562
+ if (!execution) return null;
563
+ const active = readActive(ctx.cwd);
564
+ const run = active ? getRun(ctx.cwd, active.run_id) : null;
565
+ const firstKeptEntryId = findExecutionCompactionCutEntryId(event.branchEntries as CompactBranchEntry[], event.preparation.firstKeptEntryId);
566
+ const boundaryIndex = event.branchEntries.findIndex((entry) => entry.id === firstKeptEntryId);
567
+ const summaryEntries = boundaryIndex >= 0
568
+ ? (event.branchEntries.slice(0, boundaryIndex) as CompactBranchEntry[])
569
+ : (event.branchEntries as CompactBranchEntry[]);
570
+ const sections = buildFinishedItemSections(summaryEntries);
571
+ const parts: string[] = [];
572
+ if (event.customInstructions?.trim()) {
573
+ parts.push(`## Compact Instructions
574
+ ${compactText(event.customInstructions, 1000)}`);
575
+ }
576
+ parts.push(`## Plan Before This Run
577
+ - Request: ${compactText(run?.request_text ?? execution.planPath, 280)}
578
+ - Plan file: \`${execution.planPath}\``);
579
+ if (event.preparation.previousSummary?.trim()) {
580
+ parts.push(`## Previous Compact Summary
581
+ ${event.preparation.previousSummary.trim()}`);
582
+ }
583
+ parts.push(`## Finished VC Items
584
+ ${sections.length ? sections.join("\n\n") : "- (none yet)"}`);
585
+ parts.push(`## Current Work
586
+ - Raw tail preserved from \`${firstKeptEntryId}\` onward.${event.preparation.isSplitTurn ? "\n- Split-turn prefix remains in the kept tail." : ""}`);
587
+ return {
588
+ summary: parts.join("\n\n"),
589
+ firstKeptEntryId,
590
+ tokensBefore: event.preparation.tokensBefore,
591
+ details: {
592
+ kind: "pi-plans-execution-compaction",
593
+ reason: event.reason,
594
+ willRetry: event.willRetry,
595
+ finishedItems: execution.items.filter((item) => item.done).map((item) => item.id),
596
+ },
597
+ };
598
+ }
599
+
600
+ export function handleExecutionBeforeCompact(pi: ExtensionAPI, ctx: ExtensionContext, event: SessionBeforeCompactEvent): SessionBeforeCompactResult | undefined {
601
+ if (!execution) return undefined;
602
+ const compaction = buildExecutionCompactionResult(event, ctx);
603
+ if (!compaction) return undefined;
604
+ requestExecutionFlush(pi, ctx);
605
+ return { compaction };
606
+ }
607
+
608
+ export function handleExecutionCompact(pi: ExtensionAPI, ctx: ExtensionContext, event: SessionCompactEvent): void {
609
+ if (!execution) return;
610
+ if (!event.willRetry) {
611
+ pi.sendMessage(
612
+ {
613
+ customType: EXECUTION_RESUME_CUSTOM_TYPE,
614
+ content: "Continue execution.",
615
+ display: false,
616
+ },
617
+ { triggerTurn: true },
618
+ );
619
+ }
620
+ requestExecutionFlush(pi, ctx);
621
+ updateStatusWidget(ctx);
622
+ }
623
+
624
+ export function handleExecutionCompactFailed(pi: ExtensionAPI, ctx: ExtensionContext, event: SessionCompactFailedEvent): void {
625
+ if (!execution) return;
626
+ ctx.ui.notify(
627
+ `pi-plans: compaction failed (${event.reason}); execution remains active and will wait for the next eligible turn.`,
628
+ "warning",
629
+ );
630
+ requestExecutionFlush(pi, ctx);
631
+ }
632
+
633
+ export function filterExecutionResumeMessages<T extends { customType?: string }>(messages: T[]): T[] {
634
+ return messages.filter((message) => message.customType !== EXECUTION_RESUME_CUSTOM_TYPE);
635
+ }
636
+
637
+ // ---------------------------------------------------------------------------
638
+ // Planning-phase auto compaction: same trigger rules and resume pattern as
639
+ // the execution side, but with a different cut-point algorithm and summary
640
+ // shape. The two state machines are kept independent (different memory slot
641
+ // and snapshot key) so execution never bleeds into planning.
642
+ // ---------------------------------------------------------------------------
643
+
644
+ export const PLANNING_RUN_START_CUSTOM_TYPE = "pi-plans-run-start";
645
+ export const PLANNING_PLAN_WRITTEN_CUSTOM_TYPE = "pi-plans-plan-written";
646
+ const PLANNING_QA_SECTION_HEADER = "## Q&A During Planning";
647
+ const PLANNING_RESUME_CUSTOM_TYPE = "pi-plans-plan-resume";
648
+
649
+ interface PlanningCompactionState {
650
+ inFlight: boolean;
651
+ resumeGuard: boolean;
652
+ cooldownActive: boolean;
653
+ lastAttemptReason: "manual" | "threshold" | "overflow" | null;
654
+ lastSuccessfulUsagePercent: number | null;
655
+ lastSuccessfulAt: string | null;
656
+ }
657
+
658
+ interface PlanningBranchEntry {
659
+ id?: string;
660
+ type?: string;
661
+ customType?: string;
662
+ data?: { planPath?: string; runId?: string; artifactDir?: string };
663
+ message?: { role?: string; content?: Array<{ type: string; text?: string }> };
664
+ }
665
+
666
+ function isPlanningInternalCustomType(customType?: string): boolean {
667
+ return (
668
+ customType === "pi-plans-exec"
669
+ || customType === "pi-plans-exec-cleared"
670
+ || customType === "pi-plans-exec-start"
671
+ || customType === "pi-plans-exec-context"
672
+ || customType === EXECUTION_RESUME_CUSTOM_TYPE
673
+ || customType === PLANNING_RUN_START_CUSTOM_TYPE
674
+ || customType === PLANNING_PLAN_WRITTEN_CUSTOM_TYPE
675
+ || customType === PLANNING_RESUME_CUSTOM_TYPE
676
+ );
677
+ }
678
+
679
+ function summarizePlanningMessageLine(entry: PlanningBranchEntry): string | null {
680
+ if (!entry.message) return null;
681
+ const text = (entry.message.content ?? [])
682
+ .filter((part) => part.type === "text")
683
+ .map((part) => part.text ?? "")
684
+ .join("\n")
685
+ .trim();
686
+ if (!text) return null;
687
+ const role = entry.message.role ?? "message";
688
+ const normalized = text.replace(/\s+/g, " ").trim();
689
+ const limit = 180;
690
+ const clipped = normalized.length > limit ? `${normalized.slice(0, limit - 1)}…` : normalized;
691
+ return `- [${role}] ${clipped}`;
692
+ }
693
+
694
+ function findPlanningCutEntryId(
695
+ branchEntries: PlanningBranchEntry[],
696
+ fallback: string,
697
+ ): { id: string; qaWindowEntries: PlanningBranchEntry[]; hasMarker: boolean } {
698
+ const planWrittenIndexes: number[] = [];
699
+ const runStartIndexes: number[] = [];
700
+ for (let i = 0; i < branchEntries.length; i++) {
701
+ const entry = branchEntries[i];
702
+ if (entry.type === "custom" && entry.customType === PLANNING_PLAN_WRITTEN_CUSTOM_TYPE) {
703
+ planWrittenIndexes.push(i);
704
+ } else if (entry.type === "custom" && entry.customType === PLANNING_RUN_START_CUSTOM_TYPE) {
705
+ runStartIndexes.push(i);
706
+ }
707
+ }
708
+ const planIndex = planWrittenIndexes[planWrittenIndexes.length - 1] ?? -1;
709
+ const startIndex = runStartIndexes[runStartIndexes.length - 1] ?? -1;
710
+ const anchorIndex = planIndex >= 0 ? planIndex : startIndex;
711
+ if (anchorIndex < 0) {
712
+ return { id: fallback, qaWindowEntries: [], hasMarker: false };
713
+ }
714
+ const next = branchEntries
715
+ .slice(anchorIndex + 1)
716
+ .find((entry) => entry.id && !isPlanningInternalCustomType(entry.customType));
717
+ if (!next?.id) {
718
+ return { id: fallback, qaWindowEntries: [], hasMarker: true };
719
+ }
720
+ const qaWindowEntries = branchEntries
721
+ .slice(startIndex >= 0 ? startIndex + 1 : 0, anchorIndex)
722
+ .filter((entry) => entry.type === "message" && entry.message);
723
+ return { id: next.id, qaWindowEntries, hasMarker: true };
724
+ }
725
+
726
+ function buildPlanningQASection(qaWindowEntries: PlanningBranchEntry[]): string | null {
727
+ if (!qaWindowEntries.length) return null;
728
+ const lines: string[] = [];
729
+ for (const entry of qaWindowEntries) {
730
+ const line = summarizePlanningMessageLine(entry);
731
+ if (line) lines.push(line);
732
+ }
733
+ if (!lines.length) return null;
734
+ return `${PLANNING_QA_SECTION_HEADER}
735
+ ${lines.join("\n")}`;
736
+ }
737
+
738
+ function resolvePlanningCompactionContext(workdir: string): { runId: string; artifactDir: string } | null {
739
+ const active = readActive(workdir);
740
+ if (!active) return null;
741
+ const run = getRun(workdir, active.run_id);
742
+ if (!run || run.status !== "planning") return null;
743
+ return { runId: run.run_id, artifactDir: run.artifact_dir };
744
+ }
745
+
746
+ export function shouldTriggerPlanningCompaction(ctx: ExtensionContext): boolean {
747
+ if (getExecution()) return false;
748
+ const ctxWorkdir = ctx.cwd;
749
+ if (!resolvePlanningCompactionContext(ctxWorkdir)) return false;
750
+ const percent = ctx.getContextUsage()?.percent ?? null;
751
+ if (percent === null || percent < 100) return false;
752
+ const session = ctx.sessionManager as unknown as { __planningCompaction?: PlanningCompactionState };
753
+ const state = session.__planningCompaction;
754
+ if (!state) return true;
755
+ if (state.inFlight || state.resumeGuard || state.cooldownActive) return false;
756
+ return true;
757
+ }
758
+
759
+ export function consumePlanningCompactionResumeGuard(ctx: ExtensionContext): boolean {
760
+ const session = ctx.sessionManager as unknown as { __planningCompaction?: PlanningCompactionState };
761
+ if (!session.__planningCompaction?.resumeGuard) return false;
762
+ session.__planningCompaction.resumeGuard = false;
763
+ return true;
764
+ }
765
+
766
+ export function refreshPlanningCompactionCooldown(ctx: ExtensionContext): void {
767
+ const session = ctx.sessionManager as unknown as { __planningCompaction?: PlanningCompactionState };
768
+ const state = session.__planningCompaction;
769
+ if (!state) return;
770
+ const percent = ctx.getContextUsage()?.percent ?? null;
771
+ if (percent !== null && percent < 85 && state.cooldownActive) {
772
+ state.cooldownActive = false;
773
+ }
774
+ }
775
+
776
+ export function requestPlanningCompaction(ctx: ExtensionContext): void {
777
+ const session = ctx.sessionManager as unknown as { __planningCompaction?: PlanningCompactionState };
778
+ const state = (session.__planningCompaction ??= {
779
+ inFlight: false,
780
+ resumeGuard: false,
781
+ cooldownActive: false,
782
+ lastAttemptReason: null,
783
+ lastSuccessfulUsagePercent: null,
784
+ lastSuccessfulAt: null,
785
+ } satisfies PlanningCompactionState);
786
+ if (state.inFlight || state.resumeGuard) return;
787
+ state.inFlight = true;
788
+ state.lastAttemptReason = "threshold";
789
+ ctx.compact({ customInstructions: "pi-plans planning auto compact" });
790
+ }
791
+
792
+ export function buildPlanningCompactionResult(event: SessionBeforeCompactEvent, ctx: ExtensionContext): CompactionResult | null {
793
+ const ctxWorkdir = ctx.cwd;
794
+ const planningCtx = resolvePlanningCompactionContext(ctxWorkdir);
795
+ if (!planningCtx) return null;
796
+ const branchEntries = event.branchEntries as unknown as PlanningBranchEntry[];
797
+ const { id: firstKeptEntryId, qaWindowEntries, hasMarker } = findPlanningCutEntryId(branchEntries, event.preparation.firstKeptEntryId);
798
+ const qaSection = hasMarker ? buildPlanningQASection(qaWindowEntries) : null;
799
+ const parts: string[] = [];
800
+ if (event.customInstructions?.trim()) {
801
+ parts.push(`## Compact Instructions
802
+ ${compactText(event.customInstructions, 1000)}`);
803
+ }
804
+ if (qaSection) {
805
+ parts.push(qaSection);
806
+ }
807
+ const previousSummary = event.preparation.previousSummary?.trim();
808
+ parts.push(`## Goal
809
+ ${compactText(planningCtx.runId, 80)} — keep current planning progress.`);
810
+ parts.push(`## Constraints & Preferences
811
+ - Stay in the active planning run (\`${planningCtx.runId}\`).
812
+ - Plan files live under \`${planningCtx.artifactDir}\`.`);
813
+ parts.push(`## Progress
814
+ ### Done
815
+ - Pre-plan history compressed below.
816
+
817
+ ### In Progress
818
+ - Current planning question or open decision.
819
+
820
+ ### Blocked
821
+ - ${hasMarker ? "None" : "Planning cut-point marker missing; falling back to default."}`);
822
+ if (previousSummary) {
823
+ parts.push(`## Previous Compact Summary
824
+ ${previousSummary}`);
825
+ }
826
+ parts.push(`## Next Steps
827
+ - Resume the active planning turn from the raw tail.`);
828
+ return {
829
+ summary: parts.join("\n\n"),
830
+ firstKeptEntryId,
831
+ tokensBefore: event.preparation.tokensBefore,
832
+ details: {
833
+ kind: "pi-plans-planning-compaction",
834
+ reason: event.reason,
835
+ hasMarker,
836
+ },
837
+ };
838
+ }
839
+
840
+ export function handlePlanningBeforeCompact(
841
+ pi: ExtensionAPI,
842
+ ctx: ExtensionContext,
843
+ event: SessionBeforeCompactEvent,
844
+ ): SessionBeforeCompactResult | undefined {
845
+ if (getExecution()) return undefined;
846
+ if (!resolvePlanningCompactionContext(ctx.cwd)) return undefined;
847
+ const session = ctx.sessionManager as unknown as { __planningCompaction?: PlanningCompactionState };
848
+ const state = (session.__planningCompaction ??= {
849
+ inFlight: false,
850
+ resumeGuard: false,
851
+ cooldownActive: false,
852
+ lastAttemptReason: null,
853
+ lastSuccessfulUsagePercent: null,
854
+ lastSuccessfulAt: null,
855
+ } satisfies PlanningCompactionState);
856
+ const percent = ctx.getContextUsage()?.percent ?? null;
857
+ if (event.reason === "threshold" && (percent === null || percent < 100)) {
858
+ state.inFlight = false;
859
+ state.lastAttemptReason = event.reason;
860
+ return { cancel: true };
861
+ }
862
+ state.inFlight = true;
863
+ state.lastAttemptReason = event.reason;
864
+ if (percent !== null && percent < 85) {
865
+ state.cooldownActive = false;
866
+ }
867
+ const compaction = buildPlanningCompactionResult(event, ctx);
868
+ if (!compaction) {
869
+ state.inFlight = false;
870
+ return undefined;
871
+ }
872
+ return { compaction };
873
+ }
874
+
875
+ export function handlePlanningCompact(pi: ExtensionAPI, ctx: ExtensionContext, event: SessionCompactEvent): void {
876
+ if (getExecution()) return;
877
+ const session = ctx.sessionManager as unknown as { __planningCompaction?: PlanningCompactionState };
878
+ const state = session.__planningCompaction;
879
+ if (!state) return;
880
+ state.inFlight = false;
881
+ state.lastAttemptReason = event.reason;
882
+ state.cooldownActive = true;
883
+ state.lastSuccessfulAt = utcNow();
884
+ state.lastSuccessfulUsagePercent = ctx.getContextUsage()?.percent ?? state.lastSuccessfulUsagePercent;
885
+ if (!event.willRetry) {
886
+ state.resumeGuard = true;
887
+ pi.sendMessage(
888
+ {
889
+ customType: PLANNING_RESUME_CUSTOM_TYPE,
890
+ content: "Continue planning.",
891
+ display: false,
892
+ },
893
+ { triggerTurn: true },
894
+ );
895
+ } else {
896
+ state.resumeGuard = false;
897
+ }
898
+ }
899
+
900
+ export function handlePlanningCompactFailed(pi: ExtensionAPI, ctx: ExtensionContext, event: SessionCompactFailedEvent): void {
901
+ if (getExecution()) return;
902
+ const session = ctx.sessionManager as unknown as { __planningCompaction?: PlanningCompactionState };
903
+ const state = session.__planningCompaction;
904
+ if (!state) return;
905
+ const expectedThresholdCancel = event.reason === "threshold" && event.aborted && !state.inFlight;
906
+ if (expectedThresholdCancel) {
907
+ state.lastAttemptReason = event.reason;
908
+ return;
909
+ }
910
+ state.inFlight = false;
911
+ state.resumeGuard = false;
912
+ state.cooldownActive = false;
913
+ state.lastAttemptReason = event.reason;
914
+ ctx.ui.notify(
915
+ `pi-plans: planning compaction failed (${event.reason}); will try again on the next eligible turn.`,
916
+ "warning",
917
+ );
918
+ }
919
+
920
+ export function filterPlanningResumeMessages<T extends { customType?: string }>(messages: T[]): T[] {
921
+ return messages.filter((message) => message.customType !== PLANNING_RESUME_CUSTOM_TYPE);
922
+ }
923
+
924
+ export async function ensureExecutionModelActive(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
925
+ const state = execution?.modelState;
926
+ if (!state) return;
927
+ const selection = resolveModelSelection(ctx, state.executionSelector);
928
+ if (!selection) {
929
+ ctx.ui.notify(`pi-plans: execution model ${state.executionSelector} not found; continuing on the current model.`, "warning");
930
+ return;
931
+ }
932
+ const switched = await applyModelSelection(pi, ctx, selection);
933
+ if (!switched) {
934
+ ctx.ui.notify(`pi-plans: could not switch to execution model ${state.executionSelector} (no API key?); continuing on the current model.`, "warning");
935
+ }
936
+ }
937
+
938
+ async function restorePlanningModel(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
939
+ const state = execution?.modelState;
940
+ if (!state?.planningSelector) return;
941
+ const selection = resolveModelSelection(ctx, state.planningSelector);
942
+ if (!selection) {
943
+ ctx.ui.notify(`pi-plans: planning model ${state.planningSelector} not found; keeping the current model.`, "warning");
944
+ return;
945
+ }
946
+ const restored = await applyModelSelection(pi, ctx, selection);
947
+ if (!restored) {
948
+ ctx.ui.notify(`pi-plans: could not restore planning model ${state.planningSelector} (no API key?); keeping the current model.`, "warning");
949
+ }
950
+ }
951
+
952
+ export async function stopExecution(pi: ExtensionAPI, ctx: ExtensionContext, reason: string): Promise<void> {
160
953
  if (!execution) return;
954
+ await restorePlanningModel(pi, ctx);
955
+ // Final synchronous write: drain any deferred flush and land the last snapshot.
956
+ pendingExecutionFlush = false;
957
+ persist(pi);
161
958
  clearExecutionPanel(ctx);
162
959
  execution = null;
163
960
  pi.appendEntry("pi-plans-exec-cleared", { reason });
@@ -194,12 +991,35 @@ export function applyDoneMarkers(text: string): string[] {
194
991
  return changed;
195
992
  }
196
993
 
994
+ /**
995
+ * Apply [I-xxx:implemented|validating] markers from an assistant message.
996
+ * Unknown I-ids are silently ignored; later markers overwrite earlier ones.
997
+ * Returns the ids whose state actually changed.
998
+ */
999
+ export function applyImplMarkers(text: string): string[] {
1000
+ if (!execution?.implItems?.length) return [];
1001
+ const known = new Set(execution.implItems.map((impl) => impl.id));
1002
+ execution.implStatus ??= {};
1003
+ const changed: string[] = [];
1004
+ for (const marker of scanImplMarkers(text)) {
1005
+ if (!known.has(marker.id)) continue;
1006
+ const previous = execution.implStatus[marker.id];
1007
+ execution.implStatus[marker.id] = marker.state;
1008
+ if (previous !== marker.state) changed.push(marker.id);
1009
+ }
1010
+ return changed;
1011
+ }
1012
+
197
1013
  export function isExecutionComplete(): boolean {
198
1014
  return execution !== null && execution.items.length > 0 && execution.items.every((item) => item.done);
199
1015
  }
200
1016
 
201
- export function completeExecution(pi: ExtensionAPI, ctx: ExtensionContext): void {
1017
+ export async function completeExecution(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
202
1018
  if (!execution) return;
1019
+ await restorePlanningModel(pi, ctx);
1020
+ // Final synchronous write: drain any deferred flush and land the last snapshot.
1021
+ pendingExecutionFlush = false;
1022
+ persist(pi);
203
1023
  const summary = execution.items.map((item) => `- ✅ \`${item.id}\` ${item.text.split(";")[0]}`).join("\n");
204
1024
  const planPath = execution.planPath;
205
1025
  clearExecutionPanel(ctx);
@@ -237,8 +1057,11 @@ Remaining verifier items:
237
1057
  ${list}
238
1058
 
239
1059
  Execution rules:
240
- - Implement implementation items in dependency order.
241
- - Ponytail discipline: for each item, take the laziest rung that holds (does it need to exist; already in this codebase; stdlib; native platform feature; already-installed dependency; one line). Mark deliberate simplifications with \`# ponytail: <ceiling>, <upgrade path>\`.
1060
+ - Implement implementation items in dependency order; grow the change in layers — smallest end-to-end slice first, then stack each new capability on top of what already works.
1061
+ - Report implementation-item progress with lightweight markers in your reply: write \`[I-001:implemented]\` when an item's code is done, \`[I-001:validating]\` when you start verifying it. The status panel tracks these states.
1062
+ - Simplest implementation that fully meets the item: no speculative abstractions, configuration, or indirection; keep components modular with clearly separated concerns.
1063
+ - Architectural decisions are for the long term: no stopgaps. Do not add backward-compatibility layers, fallbacks, or migrations — remove the obsolete paths this change obsoletes.
1064
+ - Prefer established, well-maintained libraries when they reduce complexity or improve reliability; before writing your own implementation or adding a package, check the project's existing dependencies (docs and types) — never reimplement common functionality without a clear reason.
242
1065
  - MINIMUM tests: trivial one-liners get no test; non-trivial logic gets exactly one minimal check; reuse the repo's test runner when one exists; when unsure, skip and emit \`[test skipped: <name>, add when <trigger>]\`.
243
1066
  - After verifying an item's pass condition with its stated evidence, include \`[DONE:VC-xxx]\` in your reply.
244
1067
  - When every item is done, report a completion summary.`;
@@ -256,7 +1079,8 @@ interface SessionEntry {
256
1079
  * pi-plans-exec snapshot, then re-scans assistant messages after it for
257
1080
  * [DONE:VC-xxx] markers so progress survives restarts.
258
1081
  */
259
- export function restoreFromSession(pi: ExtensionAPI, ctx: ExtensionContext, entries: SessionEntry[]): void {
1082
+ export async function restoreFromSession(pi: ExtensionAPI, ctx: ExtensionContext, entries: SessionEntry[]): Promise<void> {
1083
+ pendingExecutionFlush = false; // no flush debt survives a restart
260
1084
  let snapshotIndex = -1;
261
1085
  let snapshot: ExecState | null = null;
262
1086
  for (let i = entries.length - 1; i >= 0; i--) {
@@ -288,9 +1112,14 @@ export function restoreFromSession(pi: ExtensionAPI, ctx: ExtensionContext, entr
288
1112
  return;
289
1113
  }
290
1114
  execution = {
291
- ...snapshot,
1115
+ planPath: snapshot.planPath,
292
1116
  items: snapshot.items.map((item) => ({ ...item })),
1117
+ startedAt: snapshot.startedAt,
1118
+ usage: snapshot.usage ?? { inToks: 0, outToks: 0 },
293
1119
  panel: executionPanelFromEntryData(snapshot.panel) ?? createExecutionPanelState(),
1120
+ modelState: snapshot.modelState,
1121
+ implItems: snapshot.implItems ?? [],
1122
+ implStatus: { ...(snapshot.implStatus ?? {}) },
294
1123
  };
295
1124
  for (let i = snapshotIndex + 1; i < entries.length; i++) {
296
1125
  const entry = entries[i];
@@ -306,11 +1135,15 @@ export function restoreFromSession(pi: ExtensionAPI, ctx: ExtensionContext, entr
306
1135
  .map((part) => part.text ?? "")
307
1136
  .join("\n");
308
1137
  applyDoneMarkers(text);
1138
+ applyImplMarkers(text);
309
1139
  }
310
1140
  }
311
1141
  if (execution) {
312
1142
  persist(pi); // refresh snapshot so the next resume has less to rescan
313
- if (isExecutionComplete()) completeExecution(pi, ctx);
1143
+ if (isExecutionComplete()) {
1144
+ // Completed during the rescan: restore the planning model on the way out.
1145
+ await completeExecution(pi, ctx);
1146
+ }
314
1147
  }
315
1148
  syncExecutionPanel(ctx);
316
1149
  updateStatusWidget(ctx);