pi-plans 0.1.1 → 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,13 +32,23 @@ import {
22
32
  type ExecutionPanelState,
23
33
  type ItemDiffSummary,
24
34
  } from "./execution-panel.ts";
25
- import { getRun, readActive, setRunStatus, utcNow } from "./state.ts";
26
- import { latestPlanVersion, 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;
31
48
  usage: { inToks: number; outToks: number };
49
+ modelState?: ExecutionModelState;
50
+ implItems?: ImplItem[];
51
+ implStatus?: Record<string, ImplMarkerState>;
32
52
  }
33
53
 
34
54
  let execution: ExecState | null = null;
@@ -43,38 +63,226 @@ export function consumePendingPanelSync(): boolean {
43
63
  return pending;
44
64
  }
45
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
+
46
100
  export function getExecution(): ExecState | null {
47
101
  return execution;
48
102
  }
49
103
 
50
- export function executionProgress(): { done: number; total: number } | null {
51
- 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;
52
126
  return {
53
- done: execution.items.filter((item) => item.done).length,
54
- 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,
55
132
  };
56
133
  }
57
134
 
58
- function formatElapsed(startedAt: string): string {
59
- const total = Math.max(0, Math.floor((Date.now() - Date.parse(startedAt)) / 1000));
60
- const h = String(Math.floor(total / 3600)).padStart(2, "0");
61
- const m = String(Math.floor((total % 3600) / 60)).padStart(2, "0");
62
- const sec = String(total % 60).padStart(2, "0");
63
- return `${h}:${m}:${sec}`;
135
+ export function snapshotCurrentModelSelector(ctx: Pick<ExtensionContext, "model" | "thinkingLevel">): string | null {
136
+ return currentModelSelection(ctx)?.selector ?? null;
64
137
  }
65
138
 
66
- function formatToks(tokens: number): string {
67
- const n = Math.max(0, Math.round(tokens));
68
- return n < 1000 ? String(n) : `${(n / 1000).toFixed(1)}k`;
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);
69
276
  }
70
277
 
71
278
  export function updateStatusWidget(ctx: ExtensionContext): void {
72
- const progress = executionProgress();
73
- if (progress && execution) {
74
- // Progress lives in the bottom status bar the same layer as the ⛔/⌛
75
- // paused indicator so both execution states read from one place.
76
- const tail = execution.panel?.expanded ? "/plans-list hide" : "/plans-list details";
77
- const line = `⌛ plans ${progress.done}/${progress.total}: spent ${formatElapsed(execution.startedAt)} · ${formatToks(execution.usage.inToks)} in-toks · ${formatToks(execution.usage.outToks)} out-toks · ${tail}`;
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);
78
286
  ctx.ui.setStatus("pi-plans", ctx.ui.theme.fg("accent", line));
79
287
  return;
80
288
  }
@@ -119,6 +327,7 @@ function persist(pi: ExtensionAPI): void {
119
327
  startedAt: execution.startedAt,
120
328
  usage: execution.usage,
121
329
  panel: snapshotPanelState(execution),
330
+ implItems: execution.implItems,
122
331
  });
123
332
  }
124
333
 
@@ -130,16 +339,22 @@ export function syncExecutionPanel(ctx: ExtensionContext): void {
130
339
  refreshExecutionPanel(ctx, execution);
131
340
  }
132
341
 
133
- export function startExecution(
342
+ export async function startExecution(
134
343
  pi: ExtensionAPI,
135
344
  ctx: ExtensionContext,
136
345
  planPath: string,
137
346
  items: CheckItem[],
138
- ): void {
139
- execution = { planPath, items, startedAt: utcNow(), panel: createExecutionPanelState(), usage: { inToks: 0, outToks: 0 } };
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: {} };
140
351
  attachPanelBaseline(execution, ctx.cwd);
141
352
  consumePendingPanelSync(); // fresh run: drop any stale deferral from a previous one
353
+ pendingExecutionFlush = false; // fresh run: no inherited flush debt
142
354
  persist(pi);
355
+ if (modelState) {
356
+ await ensureExecutionModelActive(pi, ctx);
357
+ }
143
358
  const active = readActive(ctx.cwd);
144
359
  if (active) {
145
360
  try {
@@ -190,7 +405,8 @@ export function recordTouchedPaths(_workdir: string, paths: string[]): void {
190
405
  panel.touchedPaths = [...merged];
191
406
  }
192
407
 
193
- export function recordExecutionCompletion(
408
+ /** Record one assistant turn: accumulate usage and mark any completed items. */
409
+ export function recordExecutionTurn(
194
410
  pi: ExtensionAPI,
195
411
  ctx: ExtensionContext,
196
412
  completedIds: string[],
@@ -202,13 +418,543 @@ export function recordExecutionCompletion(
202
418
  execution.usage.outToks += usage.output;
203
419
  }
204
420
  const summary = completeCompletedItems(execution, ctx.cwd, completedIds);
205
- persist(pi);
206
- syncExecutionPanel(ctx);
421
+ requestExecutionFlush(pi, ctx);
207
422
  return summary;
208
423
  }
209
424
 
210
- 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 {
211
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> {
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);
212
958
  clearExecutionPanel(ctx);
213
959
  execution = null;
214
960
  pi.appendEntry("pi-plans-exec-cleared", { reason });
@@ -245,12 +991,35 @@ export function applyDoneMarkers(text: string): string[] {
245
991
  return changed;
246
992
  }
247
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
+
248
1013
  export function isExecutionComplete(): boolean {
249
1014
  return execution !== null && execution.items.length > 0 && execution.items.every((item) => item.done);
250
1015
  }
251
1016
 
252
- export function completeExecution(pi: ExtensionAPI, ctx: ExtensionContext): void {
1017
+ export async function completeExecution(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
253
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);
254
1023
  const summary = execution.items.map((item) => `- ✅ \`${item.id}\` ${item.text.split(";")[0]}`).join("\n");
255
1024
  const planPath = execution.planPath;
256
1025
  clearExecutionPanel(ctx);
@@ -289,6 +1058,7 @@ ${list}
289
1058
 
290
1059
  Execution rules:
291
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.
292
1062
  - Simplest implementation that fully meets the item: no speculative abstractions, configuration, or indirection; keep components modular with clearly separated concerns.
293
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.
294
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.
@@ -309,7 +1079,8 @@ interface SessionEntry {
309
1079
  * pi-plans-exec snapshot, then re-scans assistant messages after it for
310
1080
  * [DONE:VC-xxx] markers so progress survives restarts.
311
1081
  */
312
- 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
313
1084
  let snapshotIndex = -1;
314
1085
  let snapshot: ExecState | null = null;
315
1086
  for (let i = entries.length - 1; i >= 0; i--) {
@@ -341,10 +1112,14 @@ export function restoreFromSession(pi: ExtensionAPI, ctx: ExtensionContext, entr
341
1112
  return;
342
1113
  }
343
1114
  execution = {
344
- ...snapshot,
1115
+ planPath: snapshot.planPath,
345
1116
  items: snapshot.items.map((item) => ({ ...item })),
1117
+ startedAt: snapshot.startedAt,
346
1118
  usage: snapshot.usage ?? { inToks: 0, outToks: 0 },
347
1119
  panel: executionPanelFromEntryData(snapshot.panel) ?? createExecutionPanelState(),
1120
+ modelState: snapshot.modelState,
1121
+ implItems: snapshot.implItems ?? [],
1122
+ implStatus: { ...(snapshot.implStatus ?? {}) },
348
1123
  };
349
1124
  for (let i = snapshotIndex + 1; i < entries.length; i++) {
350
1125
  const entry = entries[i];
@@ -360,11 +1135,15 @@ export function restoreFromSession(pi: ExtensionAPI, ctx: ExtensionContext, entr
360
1135
  .map((part) => part.text ?? "")
361
1136
  .join("\n");
362
1137
  applyDoneMarkers(text);
1138
+ applyImplMarkers(text);
363
1139
  }
364
1140
  }
365
1141
  if (execution) {
366
1142
  persist(pi); // refresh snapshot so the next resume has less to rescan
367
- 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
+ }
368
1147
  }
369
1148
  syncExecutionPanel(ctx);
370
1149
  updateStatusWidget(ctx);