pi-plans 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/exec.ts CHANGED
@@ -3,58 +3,248 @@
3
3
  *
4
4
  * When the user approves the execution handoff, the extension switches into
5
5
  * execution mode: every agent turn is injected with the remaining verifier
6
- * checklist, assistant messages are scanned for [DONE:VC-xxx] markers, and the
7
- * below-editor panel tracks progress until every item passes.
6
+ * checklist, assistant messages are scanned for [DONE:VC-xxx] markers, and
7
+ * progress is reported through the bottom status bar until every item passes.
8
8
  */
9
9
 
10
+ import { randomUUID } from "node:crypto";
10
11
  import * as fs from "node:fs";
11
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
12
+ import type {
13
+ CompactionResult,
14
+ ExtensionAPI,
15
+ ExtensionContext,
16
+ SessionBeforeCompactEvent,
17
+ SessionBeforeCompactResult,
18
+ SessionCompactEvent,
19
+ SessionCompactFailedEvent,
20
+ } from "@earendil-works/pi-coding-agent";
21
+ import { AUTOCOMPLETE_ENTRY } from "./autocomplete.ts";
12
22
  import {
13
- attachPanelBaseline,
14
- clearExecutionPanel,
15
- completeCompletedItems,
16
- createExecutionPanelState,
17
- executionPanelFromEntryData,
18
- refreshExecutionPanel,
19
- snapshotPanelState,
20
- toggleExpanded,
21
- type ExecutionPanelExecutionLike,
22
- type ExecutionPanelState,
23
- type ItemDiffSummary,
24
- } from "./execution-panel.ts";
23
+ compactText as boundedCompactionText,
24
+ compactionCurrentI,
25
+ currentIExceedsTrigger,
26
+ entryCurrentIMarkers,
27
+ extractReadRecords,
28
+ formatReadRecord,
29
+ mergeCompactionDetails,
30
+ planIAwareCompaction,
31
+ type CompactionDetailsLike,
32
+ type CompactionEntryLike,
33
+ } from "./compaction.ts";
25
34
  import { getRun, readActive, setRunStatus, utcNow } from "./state.ts";
26
- import { latestPlanVersion, scanDoneMarkers, type CheckItem } from "./plan.ts";
35
+ import {
36
+ extractCoverage,
37
+ latestPlanVersion,
38
+ resolveImplStatuses,
39
+ scanDoneMarkers,
40
+ scanImplMarkers,
41
+ scanCurrentIMarkers,
42
+ resolveCurrentI,
43
+ inferCurrentI,
44
+ type CheckItem,
45
+ type ImplItem,
46
+ type ImplMarkerState,
47
+ } from "./plan.ts";
27
48
 
28
- export interface ExecState extends ExecutionPanelExecutionLike {
49
+ export interface ExecState {
50
+ planPath: string;
51
+ items: CheckItem[];
29
52
  startedAt: string;
30
- panel?: ExecutionPanelState;
31
53
  usage: { inToks: number; outToks: number };
54
+ implItems?: ImplItem[];
55
+ implStatus?: Record<string, ImplMarkerState>;
56
+ currentI?: string;
32
57
  }
33
58
 
34
59
  let execution: ExecState | null = null;
35
60
 
36
- // Set when the user toggles the panel while a turn is streaming; consumed by
37
- // index.ts on turn_end so view state converges without touching the live run.
38
- let pendingPanelSync = false;
61
+ // Execution-loop persistence is deferred until the agent settles so turn_end
62
+ // never causes session writes during a streaming run.
63
+ let pendingExecutionFlush = false;
39
64
 
40
- export function consumePendingPanelSync(): boolean {
41
- const pending = pendingPanelSync;
42
- pendingPanelSync = false;
65
+ export function consumePendingExecutionFlush(): boolean {
66
+ const pending = pendingExecutionFlush;
67
+ pendingExecutionFlush = false;
43
68
  return pending;
44
69
  }
45
70
 
71
+ function requestExecutionFlush(_pi: ExtensionAPI, _ctx: ExtensionContext): void {
72
+ // Unconditional defer. turn_end fires mid-run in a gap between agent
73
+ // operations where isIdle() reads true; persistence happens only at the
74
+ // drain points: agent_settled, the next before_agent_start, and stop/complete.
75
+ pendingExecutionFlush = true;
76
+ }
77
+
78
+ export function drainExecutionFlush(pi: ExtensionAPI, ctx: ExtensionContext): void {
79
+ if (!execution || !pendingExecutionFlush) return;
80
+ pendingExecutionFlush = false;
81
+ persist(pi);
82
+ updateStatusWidget(ctx);
83
+ }
84
+
46
85
  export function getExecution(): ExecState | null {
47
86
  return execution;
48
87
  }
49
88
 
50
- export function executionProgress(): { done: number; total: number } | null {
51
- if (!execution) return null;
89
+ const EXECUTION_COMPACTION_TRIGGER_PERCENT = 20;
90
+ const EXECUTION_COMPACTION_REARM_PERCENT = 80;
91
+ const EXECUTION_COMPACTION_REARM_HIGH_PERCENT = 95;
92
+ const EXECUTION_COMPACTION_RESUME_MESSAGE = "Continue execution.";
93
+
94
+ interface ExecutionCompactionState {
95
+ inFlight: boolean;
96
+ resumeGuard: boolean;
97
+ cooldownActive: boolean;
98
+ lastAttemptReason: string | null;
99
+ lastSuccessfulUsagePercent: number | null;
100
+ lastSuccessfulAt: string | null;
101
+ rearmPending: boolean;
102
+ }
103
+
104
+ type ExecutionCompactionSession = { __executionCompaction?: ExecutionCompactionState };
105
+ type ExecutionCompactionContext = ExtensionContext & { sessionManager?: ExecutionCompactionSession };
106
+
107
+ function getExecutionCompactionSession(ctx: ExtensionContext, create = false): ExecutionCompactionSession | undefined {
108
+ const carrier = ctx as ExecutionCompactionContext;
109
+ if (carrier.sessionManager) return carrier.sessionManager;
110
+ if (!create) return undefined;
111
+ carrier.sessionManager = {};
112
+ return carrier.sessionManager;
113
+ }
114
+
115
+ function executionCompactionState(ctx: ExtensionContext): ExecutionCompactionState | undefined {
116
+ return getExecutionCompactionSession(ctx)?.__executionCompaction;
117
+ }
118
+
119
+ function ensureExecutionCompactionState(ctx: ExtensionContext): ExecutionCompactionState {
120
+ const session = getExecutionCompactionSession(ctx, true)!;
121
+ return (session.__executionCompaction ??= {
122
+ inFlight: false,
123
+ resumeGuard: false,
124
+ cooldownActive: false,
125
+ lastAttemptReason: null,
126
+ lastSuccessfulUsagePercent: null,
127
+ lastSuccessfulAt: null,
128
+ rearmPending: false,
129
+ });
130
+ }
131
+
132
+ function resetExecutionCompactionState(ctx: ExtensionContext): void {
133
+ const session = getExecutionCompactionSession(ctx);
134
+ if (!session) return;
135
+ delete session.__executionCompaction;
136
+ }
137
+
138
+ function consumeExecutionCompactionResumeGuard(ctx: ExtensionContext): boolean {
139
+ const state = executionCompactionState(ctx);
140
+ if (!state?.resumeGuard) return false;
141
+ state.resumeGuard = false;
142
+ return true;
143
+ }
144
+
145
+ function refreshExecutionCompactionCooldown(ctx: ExtensionContext): void {
146
+ const state = executionCompactionState(ctx);
147
+ if (!state) return;
148
+ const percent = ctx.getContextUsage()?.percent ?? null;
149
+ if (percent !== null && percent < EXECUTION_COMPACTION_REARM_PERCENT && state.cooldownActive) {
150
+ state.cooldownActive = false;
151
+ state.rearmPending = true;
152
+ }
153
+ }
154
+
155
+ function executionCurrentIUsage(ctx: ExtensionContext): { tokens: number; contextWindow: number; eligible?: boolean } | null {
156
+ const usage = ctx.getContextUsage();
157
+ if (!usage || typeof usage.contextWindow !== "number" || usage.contextWindow <= 0) return null;
158
+ const manager = ctx.sessionManager as unknown as { getBranch?: () => CompactionEntryLike[] };
159
+ if (execution?.implItems?.length && typeof manager.getBranch === "function") {
160
+ try {
161
+ const entries = manager.getBranch();
162
+ if (entries.length) {
163
+ const plan = planIAwareCompaction({
164
+ entries,
165
+ currentI: execution.currentI,
166
+ knownIIds: execution.implItems.map((item) => item.id),
167
+ contextWindow: usage.contextWindow,
168
+ tokensBefore: usage.tokens ?? undefined,
169
+ });
170
+ if (plan.currentI || execution.currentI) {
171
+ return {
172
+ tokens: plan.currentITokens,
173
+ contextWindow: usage.contextWindow,
174
+ eligible: plan.firstKeptEntryIndex !== null && plan.firstKeptEntryIndex > 0,
175
+ };
176
+ }
177
+ }
178
+ } catch {
179
+ // A read-only session projection is optional in test and startup contexts.
180
+ }
181
+ }
182
+ if (typeof usage.tokens !== "number") return null;
183
+ return { tokens: usage.tokens, contextWindow: usage.contextWindow };
184
+ }
185
+ export function shouldTriggerExecutionCompaction(ctx: ExtensionContext): boolean {
186
+ const currentUsage = executionCurrentIUsage(ctx);
187
+ if (!currentUsage || currentUsage.eligible === false || !currentIExceedsTrigger(currentUsage.tokens, currentUsage.contextWindow)) return false;
188
+ const percent = (currentUsage.tokens / currentUsage.contextWindow) * 100;
189
+ const state = executionCompactionState(ctx);
190
+ if (!state) return true;
191
+ if (state.inFlight || state.resumeGuard || state.cooldownActive) return false;
192
+ if (state.rearmPending) {
193
+ if (percent < EXECUTION_COMPACTION_REARM_HIGH_PERCENT) return false;
194
+ state.rearmPending = false;
195
+ }
196
+ return true;
197
+ }
198
+
199
+ function requestExecutionCompaction(ctx: ExtensionContext): void {
200
+ const state = ensureExecutionCompactionState(ctx);
201
+ if (state.inFlight || state.resumeGuard) return;
202
+ state.inFlight = true;
203
+ state.lastAttemptReason = "threshold";
204
+ try {
205
+ ctx.compact({ customInstructions: "pi-plans execution auto compact" });
206
+ } catch (error) {
207
+ state.inFlight = false;
208
+ ctx.ui.notify(`pi-plans: could not request execution compaction (${String(error)}).`, "warning");
209
+ }
210
+ }
211
+
212
+ export function handleExecutionTurnCompaction(ctx: ExtensionContext): void {
213
+ refreshExecutionCompactionCooldown(ctx);
214
+ if (consumeExecutionCompactionResumeGuard(ctx)) return;
215
+ if (shouldTriggerExecutionCompaction(ctx)) {
216
+ requestExecutionCompaction(ctx);
217
+ }
218
+ }
219
+
220
+ export function computeExecutionProgress(execution: ExecState): { done: number; total: number } {
221
+ const implItems = execution.implItems ?? [];
222
+ if (implItems.length) {
223
+ const statuses = resolveImplStatuses(implItems, execution.items, execution.implStatus);
224
+ const counted = implItems.filter((impl) =>
225
+ execution.items.some((item) => extractCoverage(item.text).includes(impl.id)),
226
+ );
227
+ const total = counted.length > 0 ? counted.length : implItems.length;
228
+ const vcDone = counted.filter((impl) => statuses[impl.id] === "vc-passed").length;
229
+ const currentIndex = execution.currentI
230
+ ? implItems.findIndex((impl) => impl.id === execution.currentI)
231
+ : -1;
232
+ return {
233
+ done: Math.min(total, Math.max(vcDone, currentIndex < 0 ? 0 : currentIndex)),
234
+ total,
235
+ };
236
+ }
52
237
  return {
53
238
  done: execution.items.filter((item) => item.done).length,
54
239
  total: execution.items.length,
55
240
  };
56
241
  }
57
242
 
243
+ export function executionProgress(): { done: number; total: number } | null {
244
+ if (!execution) return null;
245
+ return computeExecutionProgress(execution);
246
+ }
247
+
58
248
  function formatElapsed(startedAt: string): string {
59
249
  const total = Math.max(0, Math.floor((Date.now() - Date.parse(startedAt)) / 1000));
60
250
  const h = String(Math.floor(total / 3600)).padStart(2, "0");
@@ -68,13 +258,14 @@ function formatToks(tokens: number): string {
68
258
  return n < 1000 ? String(n) : `${(n / 1000).toFixed(1)}k`;
69
259
  }
70
260
 
261
+ export function formatExecutionStatusLine(execution: ExecState): string {
262
+ const progress = computeExecutionProgress(execution);
263
+ return `⌛ plans ${progress.done}/${progress.total}: spent ${formatElapsed(execution.startedAt)} · ${formatToks(execution.usage.inToks)} in-toks · ${formatToks(execution.usage.outToks)} out-toks`;
264
+ }
265
+
71
266
  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}`;
267
+ if (execution) {
268
+ const line = formatExecutionStatusLine(execution);
78
269
  ctx.ui.setStatus("pi-plans", ctx.ui.theme.fg("accent", line));
79
270
  return;
80
271
  }
@@ -118,27 +309,22 @@ function persist(pi: ExtensionAPI): void {
118
309
  items: execution.items,
119
310
  startedAt: execution.startedAt,
120
311
  usage: execution.usage,
121
- panel: snapshotPanelState(execution),
312
+ implItems: execution.implItems,
313
+ implStatus: execution.implStatus,
314
+ currentI: execution.currentI,
122
315
  });
123
316
  }
124
317
 
125
- export function syncExecutionPanel(ctx: ExtensionContext): void {
126
- if (!execution) {
127
- clearExecutionPanel(ctx);
128
- return;
129
- }
130
- refreshExecutionPanel(ctx, execution);
131
- }
132
-
133
- export function startExecution(
318
+ export async function startExecution(
134
319
  pi: ExtensionAPI,
135
320
  ctx: ExtensionContext,
136
321
  planPath: string,
137
322
  items: CheckItem[],
138
- ): void {
139
- execution = { planPath, items, startedAt: utcNow(), panel: createExecutionPanelState(), usage: { inToks: 0, outToks: 0 } };
140
- attachPanelBaseline(execution, ctx.cwd);
141
- consumePendingPanelSync(); // fresh run: drop any stale deferral from a previous one
323
+ implItems?: ImplItem[],
324
+ ): Promise<void> {
325
+ execution = { planPath, items, startedAt: utcNow(), usage: { inToks: 0, outToks: 0 }, implItems: implItems ?? [], implStatus: {} };
326
+ pendingExecutionFlush = false; // fresh run: no inherited flush debt
327
+ resetExecutionCompactionState(ctx);
142
328
  persist(pi);
143
329
  const active = readActive(ctx.cwd);
144
330
  if (active) {
@@ -157,59 +343,834 @@ export function startExecution(
157
343
  { triggerTurn: false },
158
344
  );
159
345
  updateStatusWidget(ctx);
160
- syncExecutionPanel(ctx);
161
346
  }
162
347
 
163
- export function toggleExecutionPanelView(pi: ExtensionAPI, ctx: ExtensionContext): boolean | null {
348
+ /** Record one assistant turn: accumulate usage and mark any completed items. */
349
+ export function recordExecutionTurn(
350
+ pi: ExtensionAPI,
351
+ _ctx: ExtensionContext,
352
+ _completedIds: string[],
353
+ usage?: { input: number; output: number },
354
+ ): void {
355
+ if (!execution) return;
356
+ if (usage) {
357
+ execution.usage.inToks += usage.input;
358
+ execution.usage.outToks += usage.output;
359
+ }
360
+ requestExecutionFlush(pi, _ctx);
361
+ updateStatusWidget(_ctx);
362
+ }
363
+
364
+ export function registerExecutionTurnHandlers(
365
+ pi: ExtensionAPI,
366
+ onTurnEnd?: (ctx: ExtensionContext) => Promise<void> | void,
367
+ ): void {
368
+ // The turn_end projection does not carry usage; message_end delivers the
369
+ // full assistant message, so cache it here and consume it per turn.
370
+ let lastAssistantUsage: { input: number; output: number } | null = null;
371
+ pi.on("message_end", async (event) => {
372
+ const message = event.message as { role?: string; usage?: { input?: number; output?: number } };
373
+ if (message?.role === "assistant" && message.usage) {
374
+ lastAssistantUsage = { input: message.usage.input ?? 0, output: message.usage.output ?? 0 };
375
+ }
376
+ });
377
+
378
+ pi.on("turn_end", async (event, ctx) => {
379
+ const message = event.message as { role?: string; content?: Array<{ type: string; text?: string }> };
380
+ if (!message || message.role !== "assistant") {
381
+ updateStatusWidget(ctx);
382
+ return;
383
+ }
384
+ const text = (message.content ?? [])
385
+ .filter((part) => part.type === "text")
386
+ .map((part) => part.text ?? "")
387
+ .join("\n");
388
+ const changedIds = applyDoneMarkers(text);
389
+ const changedImpls = applyImplMarkers(text);
390
+ const changedCurrentI = applyCurrentIMarker(text);
391
+ const projection = (event.message as { usage?: { input?: number; output?: number } }).usage;
392
+ const raw = projection ?? lastAssistantUsage;
393
+ lastAssistantUsage = null; // consumed: never re-attribute a stale turn
394
+ const usage = raw ? { input: raw.input ?? 0, output: raw.output ?? 0 } : undefined;
395
+ if (usage || changedIds.length > 0 || changedImpls.length > 0 || changedCurrentI) {
396
+ // Attribute this turn's usage now; `[DONE]` markers still only mark completion.
397
+ recordExecutionTurn(pi, ctx, changedIds, usage);
398
+ }
399
+ if (getExecution() && isExecutionComplete()) {
400
+ await completeExecution(pi, ctx);
401
+ }
402
+ await onTurnEnd?.(ctx);
403
+ });
404
+ }
405
+
406
+ const EXECUTION_RESUME_CUSTOM_TYPE = "pi-plans-exec-resume";
407
+
408
+ type CompactBranchEntry = SessionBeforeCompactEvent["branchEntries"][number];
409
+ type CompactMessage = { role?: string; content?: Array<{ type: string; text?: string }> };
410
+
411
+ function compactText(text: string, limit = 180): string {
412
+ const normalized = text.replace(/\s+/g, " ").trim();
413
+ if (normalized.length <= limit) return normalized;
414
+ return `${normalized.slice(0, Math.max(0, limit - 1))}…`;
415
+ }
416
+
417
+ function messageText(message: CompactMessage | undefined): string {
418
+ if (!message) return "";
419
+ return (message.content ?? [])
420
+ .filter((part) => part.type === "text")
421
+ .map((part) => part.text ?? "")
422
+ .join("\n")
423
+ .trim();
424
+ }
425
+
426
+ function messageRoleLabel(role?: string): string {
427
+ switch (role) {
428
+ case "assistant": return "Assistant";
429
+ case "user": return "User";
430
+ case "toolResult": return "Tool";
431
+ case "custom": return "Custom";
432
+ default: return role ? role : "Message";
433
+ }
434
+ }
435
+
436
+ function isInternalExecutionCustomType(customType?: string): boolean {
437
+ return customType === "pi-plans-exec"
438
+ || customType === "pi-plans-exec-cleared"
439
+ || customType === "pi-plans-exec-start"
440
+ || customType === "pi-plans-exec-context"
441
+ || customType === EXECUTION_RESUME_CUSTOM_TYPE;
442
+ }
443
+
444
+ function isSummarizableEntry(entry: CompactBranchEntry): boolean {
445
+ return entry.type === "message" && !!entry.message;
446
+ }
447
+
448
+ function renderMessageLine(entry: CompactBranchEntry): string {
449
+ if (!isSummarizableEntry(entry)) return "";
450
+ const text = messageText(entry.message);
451
+ if (!text) return "";
452
+ return `- [${messageRoleLabel(entry.message?.role)}] ${compactText(text)}`;
453
+ }
454
+
455
+ function findExecutionCompactionCutEntryId(branchEntries: CompactBranchEntry[], fallback: string): string {
456
+ const completionIds = new Set(execution?.items.filter((item) => item.done).map((item) => item.id) ?? []);
457
+ let lastCompletionIndex = -1;
458
+ for (let i = 0; i < branchEntries.length; i++) {
459
+ const entry = branchEntries[i];
460
+ if (!isSummarizableEntry(entry)) continue;
461
+ const markers = scanDoneMarkers(messageText(entry.message));
462
+ if (markers.some((marker) => completionIds.has(marker))) {
463
+ lastCompletionIndex = i;
464
+ }
465
+ }
466
+ if (lastCompletionIndex >= 0) {
467
+ const next = branchEntries.slice(lastCompletionIndex + 1).find((entry) => entry.id && !isInternalExecutionCustomType(entry.customType));
468
+ if (next?.id) return next.id;
469
+ }
470
+ const startIndex = branchEntries.findIndex((entry) => entry.type === "custom" && entry.customType === "pi-plans-exec-start");
471
+ if (startIndex >= 0) {
472
+ const next = branchEntries.slice(startIndex + 1).find((entry) => entry.id && !isInternalExecutionCustomType(entry.customType));
473
+ if (next?.id) return next.id;
474
+ }
475
+ return fallback;
476
+ }
477
+
478
+ function buildFinishedItemSections(summaryEntries: CompactBranchEntry[]): string[] {
479
+ if (!execution) return [];
480
+ const completedItems = execution.items.filter((item) => item.done);
481
+ const sections: string[] = [];
482
+ let completedIndex = 0;
483
+ let currentLines: string[] = [];
484
+ for (const entry of summaryEntries) {
485
+ if (!isSummarizableEntry(entry)) continue;
486
+ const line = renderMessageLine(entry);
487
+ if (line) currentLines.push(line);
488
+ for (const marker of scanDoneMarkers(messageText(entry.message))) {
489
+ const itemIndex = completedItems.findIndex((item, index) => index >= completedIndex && item.id === marker);
490
+ if (itemIndex < 0) continue;
491
+ const item = completedItems[itemIndex];
492
+ sections.push(`### \`${item.id}\` ${item.text.split(";")[0]}
493
+ ${currentLines.length ? currentLines.join("\n") : "- (no transcript captured)"}`);
494
+ currentLines = [];
495
+ completedIndex = itemIndex + 1;
496
+ }
497
+ }
498
+ return sections;
499
+ }
500
+
501
+ function splitTurnBoundaryIndex(event: SessionBeforeCompactEvent): number | undefined {
502
+ if (!event.preparation.isSplitTurn) return undefined;
503
+ const index = event.branchEntries.findIndex((entry) => entry.id === event.preparation.firstKeptEntryId);
504
+ return index >= 0 ? index : undefined;
505
+ }
506
+
507
+ function buildExecutionIPlan(event: SessionBeforeCompactEvent, ctx?: ExtensionContext): ReturnType<typeof planIAwareCompaction> {
508
+ if (!execution) throw new Error("execution state is unavailable");
509
+ const usage = eventPreparationUsage(event, ctx);
510
+ return planIAwareCompaction({
511
+ entries: event.branchEntries as unknown as CompactionEntryLike[],
512
+ currentI: execution.currentI,
513
+ knownIIds: execution.implItems?.map((item) => item.id),
514
+ contextWindow: usage.contextWindow,
515
+ tokensBefore: event.preparation.tokensBefore,
516
+ fallbackFirstKeptEntryId: event.preparation.firstKeptEntryId,
517
+ maxFirstKeptEntryIndex: splitTurnBoundaryIndex(event),
518
+ });
519
+ }
520
+
521
+ function eventPreparationUsage(event: SessionBeforeCompactEvent, ctx?: ExtensionContext): { contextWindow: number | null } {
522
+ const fromContext = ctx?.getContextUsage()?.contextWindow;
523
+ const contextWindow = (event as SessionBeforeCompactEvent & { contextWindow?: number }).contextWindow ?? fromContext;
524
+ return { contextWindow: typeof contextWindow === "number" && contextWindow > 0 ? contextWindow : null };
525
+ }
526
+
527
+ function previousCompactionDetails(entries: CompactionEntryLike[]): CompactionDetailsLike | undefined {
528
+ let merged: CompactionDetailsLike | undefined;
529
+ for (const entry of entries) {
530
+ if (entry.type !== "compaction") continue;
531
+ const raw = entry.details ?? entry.data;
532
+ if (!raw || typeof raw !== "object") continue;
533
+ const details = raw as CompactionDetailsLike;
534
+ if (!details.readRecords && !details.metrics && !details.iSections) continue;
535
+ merged = mergeCompactionDetails(merged, details);
536
+ }
537
+ return merged;
538
+ }
539
+
540
+ function buildImplementationSections(plan: ReturnType<typeof planIAwareCompaction>): string[] {
541
+ const sections: string[] = [];
542
+ for (const slice of plan.slices) {
543
+ if (slice.id === null) continue;
544
+ const lines = slice.entries
545
+ .filter((entry) => plan.summaryEntries.includes(entry))
546
+ .map((entry) => renderMessageLine(entry as CompactBranchEntry))
547
+ .filter(Boolean);
548
+ if (lines.length) {
549
+ sections.push(`### ${slice.current ? "Current I" : "Implementation I"} \`${slice.id}\`\n${lines.join("\\n")}`);
550
+ }
551
+ }
552
+ return sections;
553
+ }
554
+
555
+ function executionSummaryParts(
556
+ event: SessionBeforeCompactEvent,
557
+ ctx: ExtensionContext,
558
+ firstKeptEntryId: string,
559
+ boundaryIndex: number,
560
+ plan: ReturnType<typeof planIAwareCompaction> | null,
561
+ ): { parts: string[]; details: CompactionDetailsLike } {
562
+ if (!execution) return { parts: [], details: {} };
563
+ const active = readActive(ctx.cwd);
564
+ const run = active ? getRun(ctx.cwd, active.run_id) : null;
565
+ const branchEntries = event.branchEntries as unknown as CompactionEntryLike[];
566
+ const summaryEntries = boundaryIndex >= 0
567
+ ? (branchEntries.slice(0, boundaryIndex) as CompactBranchEntry[])
568
+ : (branchEntries as CompactBranchEntry[]);
569
+ const sections = buildFinishedItemSections(summaryEntries);
570
+ const readRecords = plan?.readRecords ?? extractReadRecords(summaryEntries as unknown as CompactionEntryLike[]);
571
+ const priorDetails = previousCompactionDetails(branchEntries);
572
+ const metrics = plan?.metrics ?? {
573
+ contextWindow: ctx.getContextUsage()?.contextWindow ?? null,
574
+ tokensBefore: event.preparation.tokensBefore,
575
+ currentITokens: 0,
576
+ summaryTokens: 0,
577
+ keptSuffixTokens: 0,
578
+ estimatedAfterTokens: null,
579
+ targetRatio: 0.1,
580
+ currentI: execution.currentI ?? null,
581
+ firstKeptEntryId,
582
+ targetMet: false,
583
+ hardFloorReason: "I-aware budget unavailable for this legacy execution snapshot",
584
+ };
585
+ const details = mergeCompactionDetails(priorDetails, {
586
+ kind: "pi-plans-execution-compaction",
587
+ version: 1,
588
+ currentI: execution.currentI ?? plan?.currentI ?? null,
589
+ iSections: plan?.slices.map((slice) => ({ id: slice.id, entryIds: slice.entries.map((entry) => entry.id).filter((id): id is string => !!id) })) ?? [],
590
+ readRecords,
591
+ metrics: { ...metrics, firstKeptEntryId },
592
+ reason: event.reason,
593
+ willRetry: event.willRetry,
594
+ finishedItems: execution.items.filter((item) => item.done).map((item) => item.id),
595
+ });
596
+ const parts: string[] = [];
597
+ if (event.customInstructions?.trim()) {
598
+ parts.push(`## Compact Instructions\n${compactText(event.customInstructions, 1000)}`);
599
+ }
600
+ parts.push(`## Plan Before This Run\n- Request: ${compactText(run?.request_text ?? execution.planPath, 280)}\n- Plan file: \`${execution.planPath}\``);
601
+ if (event.preparation.previousSummary?.trim()) {
602
+ parts.push(`## Previous Compact Summary\n${event.preparation.previousSummary.trim()}`);
603
+ }
604
+ parts.push(`## Implementation Items\n${plan?.slices.length ? (buildImplementationSections(plan).join("\\n\\n") || "- (no I transcript captured)") : "- Legacy snapshot: current I is inferred from the execution frontier."}`);
605
+ parts.push(`## Finished VC Items\n${sections.length ? sections.join("\\n\\n") : "- (none yet)"}`);
606
+ parts.push(`## Current I\n- \`${execution.currentI ?? plan?.currentI ?? "unknown"}\`\n- Recent legal suffix begins at \`${firstKeptEntryId}\`.`);
607
+ parts.push(`## Read Records\n${readRecords.length ? readRecords.map((record) => formatReadRecord(record)).join("\\n") : "- (none)"}`);
608
+ parts.push(`## Compaction Boundary\n- firstKeptEntryId: \`${firstKeptEntryId}\`\n- currentI: \`${execution.currentI ?? plan?.currentI ?? "unknown"}\`\n- tokensBefore: ${metrics.tokensBefore}\n- estimatedAfterTokens: ${metrics.estimatedAfterTokens ?? "unknown"}\n- targetRatio: ${metrics.targetRatio}\n- targetMet: ${metrics.targetMet}\n- hardFloorReason: ${metrics.hardFloorReason ?? "none"}`);
609
+ parts.push(`## Current Work\n- Raw tail preserved from \`${firstKeptEntryId}\` onward.${event.preparation.isSplitTurn ? "\\n- Split-turn prefix remains in the kept tail." : ""}`);
610
+ return { parts, details };
611
+ }
612
+
613
+ export function buildExecutionCompactionResult(event: SessionBeforeCompactEvent, ctx: ExtensionContext): CompactionResult | null {
164
614
  if (!execution) return null;
165
- const expanded = toggleExpanded(execution);
166
- // While a turn is streaming keep this zero-side-effect: flipping the flag is
167
- // pure memory; persisting and re-rendering here would write to the session
168
- // file and force a TUI relayout under the running agent. The next turn_end
169
- // consumes the pending marker and brings the view in line.
170
- const idle = typeof ctx.isIdle !== "function" || ctx.isIdle();
171
- if (idle) {
172
- persist(pi);
173
- syncExecutionPanel(ctx);
615
+ const branchEntries = event.branchEntries as unknown as CompactionEntryLike[];
616
+ const hasIState = (execution.implItems?.length ?? 0) > 0;
617
+ const plan = hasIState ? buildExecutionIPlan(event, ctx) : null;
618
+ const firstKeptEntryId = plan?.firstKeptEntryId
619
+ ?? findExecutionCompactionCutEntryId(event.branchEntries as CompactBranchEntry[], event.preparation.firstKeptEntryId);
620
+ const boundaryIndex = event.branchEntries.findIndex((entry) => entry.id === firstKeptEntryId);
621
+ const { parts, details } = executionSummaryParts(event, ctx, firstKeptEntryId, boundaryIndex, plan);
622
+ return {
623
+ summary: parts.join("\\n\\n"),
624
+ firstKeptEntryId,
625
+ tokensBefore: event.preparation.tokensBefore,
626
+ estimatedTokensAfter: plan?.metrics.estimatedAfterTokens ?? undefined,
627
+ details,
628
+ };
629
+ }
630
+
631
+ function assistantResponseText(response: { content?: Array<{ type?: string; text?: string }> }): string {
632
+ return (response.content ?? [])
633
+ .filter((part) => part.type === "text")
634
+ .map((part) => part.text ?? "")
635
+ .join("\\n")
636
+ .trim();
637
+ }
638
+
639
+ function isUsableCompactionResponse(response: { content?: Array<{ type?: string; text?: string }>; stopReason?: string }, summary: string): boolean {
640
+ if (!summary) return false;
641
+ if (["length", "toolUse", "error", "aborted", "deferred"].includes(response.stopReason ?? "")) return false;
642
+ return ["## Implementation Items", "## Current I", "## Read Records", "## Compaction Boundary"]
643
+ .every((section) => summary.includes(section));
644
+ }
645
+
646
+ async function buildModelExecutionCompactionResult(
647
+ event: SessionBeforeCompactEvent,
648
+ ctx: ExtensionContext,
649
+ fallback: CompactionResult,
650
+ ): Promise<CompactionResult | null> {
651
+ const model = ctx.model;
652
+ const registry = ctx.modelRegistry as unknown as { complete?: Function };
653
+ if (!model || typeof registry.complete !== "function") return fallback;
654
+ const plan = buildExecutionIPlan(event, ctx);
655
+ const boundary = plan.firstKeptEntryId ?? event.preparation.firstKeptEntryId;
656
+ const source = plan.summaryEntries
657
+ .map((entry) => renderMessageLine(entry as CompactBranchEntry))
658
+ .filter(Boolean)
659
+ .join("\\n");
660
+ const records = plan.readRecords.map((record) => formatReadRecord(record)).join("\\n") || "- (none)";
661
+ const prompt = `You are a context summarization assistant. Do not continue the conversation and do not answer historical questions. Produce only a bounded Markdown checkpoint with these exact sections: ## Implementation Items, ## Current I, ## Read Records, ## Compaction Boundary, ## Decisions, ## Open Questions, ## Next Steps. Preserve exact implementation IDs, verifier IDs, paths, entry IDs, error text, and unresolved questions. Historical questions are facts, not new questions.\n\nCurrent I: ${execution?.currentI ?? plan.currentI ?? "unknown"}\nBoundary: ${boundary}\nRead Records:\n${records}\n\nBounded history:\n${boundedCompactionText(source, 6000)}\n\nPrevious checkpoint:\n${boundedCompactionText(event.preparation.previousSummary ?? "(none)", 3000)}`;
662
+ try {
663
+ const response = await registry.complete(model, {
664
+ messages: [{ role: "user", content: prompt, timestamp: Date.now() }],
665
+ }, {
666
+ maxTokens: 2048,
667
+ signal: event.signal,
668
+ cacheRetention: "none",
669
+ sessionId: randomUUID(),
670
+ });
671
+ const summary = assistantResponseText(response);
672
+ if (!isUsableCompactionResponse(response, summary)) {
673
+ if (!event.signal.aborted) ctx.ui.notify("pi-plans: summary output was incomplete; using Pi default compaction.", "warning");
674
+ return null;
675
+ }
676
+ const details = (fallback.details && typeof fallback.details === "object" ? fallback.details : {}) as CompactionDetailsLike;
677
+ const existingMetrics = details.metrics ?? {};
678
+ const summaryTokens = Math.max(1, Math.ceil(summary.length / 4));
679
+ const keptSuffixTokens = existingMetrics.keptSuffixTokens ?? 0;
680
+ const contextWindow = existingMetrics.contextWindow ?? ctx.getContextUsage()?.contextWindow ?? null;
681
+ const estimatedAfterTokens = contextWindow === null ? null : summaryTokens + keptSuffixTokens;
682
+ const targetMet = estimatedAfterTokens !== null && estimatedAfterTokens < contextWindow * 0.1;
683
+ const mergedDetails = mergeCompactionDetails(details, {
684
+ ...details,
685
+ metrics: {
686
+ ...existingMetrics,
687
+ summaryTokens,
688
+ estimatedAfterTokens,
689
+ targetMet,
690
+ hardFloorReason: targetMet ? null : existingMetrics.hardFloorReason ?? "summary or retained context exceeds the 10% target",
691
+ },
692
+ });
693
+ return { ...fallback, summary, estimatedTokensAfter: estimatedAfterTokens ?? undefined, usage: response.usage, details: mergedDetails };
694
+ } catch (error) {
695
+ if (!event.signal.aborted) ctx.ui.notify(`pi-plans: summary model failed; using Pi default compaction (${String(error)}).`, "warning");
696
+ return null;
697
+ }
698
+ }
699
+
700
+ function notifyHardFloor(ctx: ExtensionContext, compaction: CompactionResult): void {
701
+ const metrics = (compaction.details as CompactionDetailsLike | undefined)?.metrics;
702
+ if (!metrics || metrics.targetMet !== false) return;
703
+ ctx.ui.notify(
704
+ `pi-plans: compaction target <10% is unreachable; retaining the legal floor (${metrics.hardFloorReason ?? "unknown reason"}).`,
705
+ "warning",
706
+ );
707
+ }
708
+
709
+ export function handleExecutionBeforeCompact(
710
+ pi: ExtensionAPI,
711
+ ctx: ExtensionContext,
712
+ event: SessionBeforeCompactEvent,
713
+ ): SessionBeforeCompactResult | Promise<SessionBeforeCompactResult | undefined> | undefined {
714
+ if (!execution) return undefined;
715
+ let fallback: CompactionResult | null;
716
+ try {
717
+ fallback = buildExecutionCompactionResult(event, ctx);
718
+ } catch (error) {
719
+ const state = executionCompactionState(ctx);
720
+ if (state) state.inFlight = false;
721
+ ctx.ui.notify(`pi-plans: compaction preparation failed; using Pi default compaction (${String(error)}).`, "warning");
722
+ return undefined;
723
+ }
724
+ if (!fallback) return undefined;
725
+ notifyHardFloor(ctx, fallback);
726
+ const registry = ctx.modelRegistry as unknown as { complete?: Function };
727
+ if (!ctx.model || typeof registry.complete !== "function") {
728
+ requestExecutionFlush(pi, ctx);
729
+ return { compaction: fallback };
730
+ }
731
+ return buildModelExecutionCompactionResult(event, ctx, fallback).then((compaction) => {
732
+ if (!compaction) return undefined;
733
+ requestExecutionFlush(pi, ctx);
734
+ return { compaction };
735
+ });
736
+ }
737
+
738
+ export function handleExecutionCompact(pi: ExtensionAPI, ctx: ExtensionContext, event: SessionCompactEvent): void {
739
+ if (!execution) return;
740
+ const state = ensureExecutionCompactionState(ctx);
741
+ state.inFlight = false;
742
+ state.lastAttemptReason = event.reason;
743
+ state.cooldownActive = true;
744
+ state.rearmPending = false;
745
+ state.lastSuccessfulAt = utcNow();
746
+ state.lastSuccessfulUsagePercent = ctx.getContextUsage()?.percent ?? state.lastSuccessfulUsagePercent;
747
+ if (!event.willRetry) {
748
+ state.resumeGuard = true;
749
+ pi.sendMessage(
750
+ {
751
+ customType: EXECUTION_RESUME_CUSTOM_TYPE,
752
+ content: EXECUTION_COMPACTION_RESUME_MESSAGE,
753
+ display: false,
754
+ },
755
+ { triggerTurn: true },
756
+ );
174
757
  } else {
175
- pendingPanelSync = true;
758
+ state.resumeGuard = false;
176
759
  }
177
- return expanded;
760
+ requestExecutionFlush(pi, ctx);
761
+ updateStatusWidget(ctx);
178
762
  }
179
763
 
180
- export function recordTouchedPaths(_workdir: string, paths: string[]): void {
181
- if (!execution || !paths.length) return;
182
- const panel = execution.panel ?? createExecutionPanelState();
183
- execution.panel = panel;
184
- const merged = new Set(panel.touchedPaths);
185
- for (const raw of paths) {
186
- const normalized = raw.trim().replace(/[\u0000]+/g, "");
187
- if (!normalized) continue;
188
- merged.add(normalized);
764
+ export function handleExecutionCompactFailed(pi: ExtensionAPI, ctx: ExtensionContext, event: SessionCompactFailedEvent): void {
765
+ if (!execution) return;
766
+ const state = executionCompactionState(ctx);
767
+ const expectedThresholdCancel = event.reason === "threshold" && event.aborted && !state?.inFlight;
768
+ if (expectedThresholdCancel) {
769
+ if (state) state.lastAttemptReason = event.reason;
770
+ return;
189
771
  }
190
- panel.touchedPaths = [...merged];
772
+ if (state) {
773
+ state.inFlight = false;
774
+ state.resumeGuard = false;
775
+ state.cooldownActive = false;
776
+ state.rearmPending = false;
777
+ state.lastAttemptReason = event.reason;
778
+ }
779
+ ctx.ui.notify(
780
+ `pi-plans: compaction failed (${event.reason}); execution remains active and will wait for the next eligible turn.`,
781
+ "warning",
782
+ );
783
+ requestExecutionFlush(pi, ctx);
784
+ }
785
+
786
+ export function filterExecutionResumeMessages<T extends { customType?: string }>(messages: T[]): T[] {
787
+ return messages.filter((message) => message.customType !== EXECUTION_RESUME_CUSTOM_TYPE);
788
+ }
789
+
790
+ // ---------------------------------------------------------------------------
791
+ // Planning-phase auto compaction: same trigger rules and resume pattern as
792
+ // the execution side, but with a different cut-point algorithm and summary
793
+ // shape. The two state machines are kept independent (different memory slot
794
+ // and snapshot key) so execution never bleeds into planning.
795
+ // ---------------------------------------------------------------------------
796
+
797
+ export const PLANNING_RUN_START_CUSTOM_TYPE = "pi-plans-run-start";
798
+ export const PLANNING_PLAN_WRITTEN_CUSTOM_TYPE = "pi-plans-plan-written";
799
+ const PLANNING_QA_SECTION_HEADER = "## Q&A During Planning";
800
+ const PLANNING_RESUME_CUSTOM_TYPE = "pi-plans-plan-resume";
801
+
802
+ interface PlanningCompactionState {
803
+ inFlight: boolean;
804
+ resumeGuard: boolean;
805
+ cooldownActive: boolean;
806
+ lastAttemptReason: "manual" | "threshold" | "overflow" | null;
807
+ lastSuccessfulUsagePercent: number | null;
808
+ lastSuccessfulAt: string | null;
809
+ }
810
+
811
+ interface PlanningBranchEntry {
812
+ id?: string;
813
+ type?: string;
814
+ customType?: string;
815
+ data?: { planPath?: string; runId?: string; artifactDir?: string };
816
+ message?: { role?: string; content?: Array<{ type: string; text?: string }> };
817
+ }
818
+
819
+ function isPlanningInternalCustomType(customType?: string): boolean {
820
+ return (
821
+ customType === "pi-plans-exec"
822
+ || customType === "pi-plans-exec-cleared"
823
+ || customType === "pi-plans-exec-start"
824
+ || customType === "pi-plans-exec-context"
825
+ || customType === EXECUTION_RESUME_CUSTOM_TYPE
826
+ || customType === PLANNING_RUN_START_CUSTOM_TYPE
827
+ || customType === PLANNING_PLAN_WRITTEN_CUSTOM_TYPE
828
+ || customType === PLANNING_RESUME_CUSTOM_TYPE
829
+ || customType === AUTOCOMPLETE_ENTRY
830
+ );
831
+ }
832
+
833
+ function summarizePlanningEntryText(entry: PlanningBranchEntry): string {
834
+ return (entry.message?.content ?? [])
835
+ .filter((part) => part.type === "text")
836
+ .map((part) => part.text ?? "")
837
+ .join("\n")
838
+ .trim();
839
+ }
840
+
841
+ function summarizePlanningEntryLine(entry: PlanningBranchEntry): string | null {
842
+ return summarizePlanningMessageLine(entry);
843
+ }
844
+
845
+ function summarizePlanningMessageLine(entry: PlanningBranchEntry): string | null {
846
+ if (!entry.message) return null;
847
+ const text = (entry.message.content ?? [])
848
+ .filter((part) => part.type === "text")
849
+ .map((part) => part.text ?? "")
850
+ .join("\n")
851
+ .trim();
852
+ if (!text) return null;
853
+ const role = entry.message.role ?? "message";
854
+ const normalized = text.replace(/\s+/g, " ").trim();
855
+ const limit = 180;
856
+ const clipped = normalized.length > limit ? `${normalized.slice(0, limit - 1)}…` : normalized;
857
+ return `- [${role}] ${clipped}`;
191
858
  }
192
859
 
193
- export function recordExecutionCompletion(
860
+ function findPlanningCutEntryId(
861
+ branchEntries: PlanningBranchEntry[],
862
+ fallback: string,
863
+ ): { id: string; qaWindowEntries: PlanningBranchEntry[]; hasMarker: boolean } {
864
+ const planWrittenIndexes: number[] = [];
865
+ const runStartIndexes: number[] = [];
866
+ for (let i = 0; i < branchEntries.length; i++) {
867
+ const entry = branchEntries[i];
868
+ if (entry.type === "custom" && entry.customType === PLANNING_PLAN_WRITTEN_CUSTOM_TYPE) {
869
+ planWrittenIndexes.push(i);
870
+ } else if (entry.type === "custom" && entry.customType === PLANNING_RUN_START_CUSTOM_TYPE) {
871
+ runStartIndexes.push(i);
872
+ }
873
+ }
874
+ const planIndex = planWrittenIndexes[planWrittenIndexes.length - 1] ?? -1;
875
+ const startIndex = runStartIndexes[runStartIndexes.length - 1] ?? -1;
876
+ const anchorIndex = planIndex >= 0 ? planIndex : startIndex;
877
+ if (anchorIndex < 0) {
878
+ return { id: fallback, qaWindowEntries: [], hasMarker: false };
879
+ }
880
+ const next = branchEntries
881
+ .slice(anchorIndex + 1)
882
+ .find((entry) => entry.id && !isPlanningInternalCustomType(entry.customType));
883
+ if (!next?.id) {
884
+ return { id: fallback, qaWindowEntries: [], hasMarker: true };
885
+ }
886
+ const qaWindowEntries = branchEntries
887
+ .slice(startIndex >= 0 ? startIndex + 1 : 0, anchorIndex)
888
+ .filter((entry) => entry.type === "message" && entry.message);
889
+ return { id: next.id, qaWindowEntries, hasMarker: true };
890
+ }
891
+
892
+ function buildPlanningQASection(qaWindowEntries: PlanningBranchEntry[]): string | null {
893
+ if (!qaWindowEntries.length) return null;
894
+ const lines: string[] = [];
895
+ for (const entry of qaWindowEntries) {
896
+ const line = summarizePlanningMessageLine(entry);
897
+ if (line) lines.push(line);
898
+ }
899
+ if (!lines.length) return null;
900
+ return `${PLANNING_QA_SECTION_HEADER}
901
+ ${lines.join("\n")}`;
902
+ }
903
+
904
+ function resolvePlanningCompactionContext(workdir: string): { runId: string; artifactDir: string } | null {
905
+ const active = readActive(workdir);
906
+ if (!active) return null;
907
+ const run = getRun(workdir, active.run_id);
908
+ if (!run || run.status !== "planning") return null;
909
+ return { runId: run.run_id, artifactDir: run.artifact_dir };
910
+ }
911
+
912
+ function planningCurrentIUsage(ctx: ExtensionContext): { tokens: number; contextWindow: number; eligible?: boolean } | null {
913
+ const usage = ctx.getContextUsage();
914
+ if (!usage || typeof usage.contextWindow !== "number" || usage.contextWindow <= 0) return null;
915
+ const manager = ctx.sessionManager as unknown as { getBranch?: () => CompactionEntryLike[] };
916
+ if (typeof manager.getBranch === "function") {
917
+ try {
918
+ const entries = manager.getBranch();
919
+ const currentI = entries.flatMap((entry) => entryCurrentIMarkers(entry)).at(-1)
920
+ ?? entries.map((entry) => compactionCurrentI(entry)).filter((id): id is string => !!id).at(-1);
921
+ if (currentI) {
922
+ const plan = planIAwareCompaction({
923
+ entries,
924
+ currentI,
925
+ contextWindow: usage.contextWindow,
926
+ tokensBefore: usage.tokens ?? undefined,
927
+ });
928
+ return {
929
+ tokens: plan.currentITokens,
930
+ contextWindow: usage.contextWindow,
931
+ eligible: plan.firstKeptEntryIndex !== null && plan.firstKeptEntryIndex > 0,
932
+ };
933
+ }
934
+ } catch {
935
+ // Session projection is unavailable during startup in some hosts.
936
+ }
937
+ }
938
+ if (typeof usage.tokens !== "number") return null;
939
+ return { tokens: usage.tokens, contextWindow: usage.contextWindow };
940
+ }
941
+ export function shouldTriggerPlanningCompaction(ctx: ExtensionContext): boolean {
942
+ if (getExecution()) return false;
943
+ const ctxWorkdir = ctx.cwd;
944
+ if (!resolvePlanningCompactionContext(ctxWorkdir)) return false;
945
+ const currentUsage = planningCurrentIUsage(ctx);
946
+ if (!currentUsage || currentUsage.eligible === false || !currentIExceedsTrigger(currentUsage.tokens, currentUsage.contextWindow)) return false;
947
+ const session = ctx.sessionManager as unknown as { __planningCompaction?: PlanningCompactionState };
948
+ const state = session.__planningCompaction;
949
+ if (!state) return true;
950
+ if (state.inFlight || state.resumeGuard || state.cooldownActive) return false;
951
+ return true;
952
+ }
953
+
954
+ export function consumePlanningCompactionResumeGuard(ctx: ExtensionContext): boolean {
955
+ const session = ctx.sessionManager as unknown as { __planningCompaction?: PlanningCompactionState };
956
+ if (!session.__planningCompaction?.resumeGuard) return false;
957
+ session.__planningCompaction.resumeGuard = false;
958
+ return true;
959
+ }
960
+
961
+ export function refreshPlanningCompactionCooldown(ctx: ExtensionContext): void {
962
+ const session = ctx.sessionManager as unknown as { __planningCompaction?: PlanningCompactionState };
963
+ const state = session.__planningCompaction;
964
+ if (!state) return;
965
+ const percent = ctx.getContextUsage()?.percent ?? null;
966
+ if (percent !== null && percent < 85 && state.cooldownActive) {
967
+ state.cooldownActive = false;
968
+ }
969
+ }
970
+
971
+ export function requestPlanningCompaction(ctx: ExtensionContext): void {
972
+ const session = ctx.sessionManager as unknown as { __planningCompaction?: PlanningCompactionState };
973
+ const state = (session.__planningCompaction ??= {
974
+ inFlight: false,
975
+ resumeGuard: false,
976
+ cooldownActive: false,
977
+ lastAttemptReason: null,
978
+ lastSuccessfulUsagePercent: null,
979
+ lastSuccessfulAt: null,
980
+ } satisfies PlanningCompactionState);
981
+ if (state.inFlight || state.resumeGuard) return;
982
+ state.inFlight = true;
983
+ state.lastAttemptReason = "threshold";
984
+ try {
985
+ ctx.compact({ customInstructions: "pi-plans planning auto compact" });
986
+ } catch (error) {
987
+ state.inFlight = false;
988
+ ctx.ui.notify(`pi-plans: could not request planning compaction (${String(error)}).`, "warning");
989
+ }
990
+ }
991
+
992
+ export function buildPlanningCompactionResult(event: SessionBeforeCompactEvent, ctx: ExtensionContext): CompactionResult | null {
993
+ const ctxWorkdir = ctx.cwd;
994
+ const planningCtx = resolvePlanningCompactionContext(ctxWorkdir);
995
+ if (!planningCtx) return null;
996
+ const branchEntries = event.branchEntries as unknown as PlanningBranchEntry[];
997
+ const legacyCut = findPlanningCutEntryId(branchEntries, event.preparation.firstKeptEntryId);
998
+ const markerIds = branchEntries.flatMap((entry) => scanCurrentIMarkers(summarizePlanningEntryText(entry)));
999
+ const currentI = markerIds.at(-1)?.id
1000
+ ?? branchEntries.map((entry) => compactionCurrentI(entry)).filter((id): id is string => !!id).at(-1)
1001
+ ?? null;
1002
+ const iPlan = currentI
1003
+ ? planIAwareCompaction({
1004
+ entries: branchEntries as unknown as CompactionEntryLike[],
1005
+ currentI,
1006
+ contextWindow: eventPreparationUsage(event, ctx).contextWindow,
1007
+ tokensBefore: event.preparation.tokensBefore,
1008
+ fallbackFirstKeptEntryId: event.preparation.firstKeptEntryId,
1009
+ maxFirstKeptEntryIndex: splitTurnBoundaryIndex(event),
1010
+ })
1011
+ : null;
1012
+ const firstKeptEntryId = iPlan?.firstKeptEntryId ?? legacyCut.id;
1013
+ const qaSection = legacyCut.hasMarker ? buildPlanningQASection(legacyCut.qaWindowEntries) : null;
1014
+ const boundaryIndex = event.branchEntries.findIndex((entry) => entry.id === firstKeptEntryId);
1015
+ const summaryEntries = boundaryIndex >= 0 ? branchEntries.slice(0, boundaryIndex) : branchEntries;
1016
+ const readRecords = iPlan?.readRecords ?? extractReadRecords(summaryEntries as unknown as CompactionEntryLike[]);
1017
+ const parts: string[] = [];
1018
+ if (event.customInstructions?.trim()) {
1019
+ parts.push(`## Compact Instructions
1020
+ ${compactText(event.customInstructions, 1000)}`);
1021
+ }
1022
+ if (qaSection) {
1023
+ parts.push(qaSection);
1024
+ }
1025
+ const previousSummary = event.preparation.previousSummary?.trim();
1026
+ parts.push(`## Goal
1027
+ ${compactText(planningCtx.runId, 80)} — keep current planning progress.`);
1028
+ parts.push(`## Constraints & Preferences
1029
+ - Stay in the active planning run (\`${planningCtx.runId}\`).
1030
+ - Plan files live under \`${planningCtx.artifactDir}\`.`);
1031
+ parts.push(`## Progress
1032
+ ### Done
1033
+ - Pre-plan history compressed below.
1034
+
1035
+ ### In Progress
1036
+ - Current planning question or open decision.
1037
+
1038
+ ### Blocked
1039
+ - ${legacyCut.hasMarker ? "None" : "Planning cut-point marker missing; falling back to default."}`);
1040
+ if (iPlan) {
1041
+ const iSections = iPlan.slices
1042
+ .map((slice) => {
1043
+ const lines = slice.entries.filter((entry) => iPlan.summaryEntries.includes(entry)).map((entry) => summarizePlanningEntryLine(entry)).filter(Boolean);
1044
+ return slice.id && lines.length ? `### ${slice.current ? "Current I" : "Implementation I"} \`${slice.id}\`\n${lines.join("\\n")}` : "";
1045
+ })
1046
+ .filter(Boolean);
1047
+ parts.push(`## Current I\n- \`${currentI}\`\n${iSections.join("\\n\\n") || "- Current I transcript is in the retained suffix."}`);
1048
+ parts.push(`## Read Records\n${readRecords.length ? readRecords.map((record) => formatReadRecord(record)).join("\\n") : "- (none)"}`);
1049
+ parts.push(`## Compaction Boundary\n- firstKeptEntryId: \`${firstKeptEntryId}\`\n- currentI: \`${currentI}\`\n- targetMet: ${iPlan.metrics.targetMet}\n- hardFloorReason: ${iPlan.metrics.hardFloorReason ?? "none"}`);
1050
+ }
1051
+ if (previousSummary) {
1052
+ parts.push(`## Previous Compact Summary\n${previousSummary}`);
1053
+ }
1054
+ parts.push(`## Next Steps
1055
+ - Resume the active planning turn from the raw tail.`);
1056
+ const priorDetails = previousCompactionDetails(branchEntries as unknown as CompactionEntryLike[]);
1057
+ const details = mergeCompactionDetails(priorDetails, {
1058
+ kind: "pi-plans-planning-compaction",
1059
+ version: 1,
1060
+ currentI,
1061
+ iSections: iPlan?.slices.map((slice) => ({ id: slice.id, entryIds: slice.entries.map((entry) => entry.id).filter((id): id is string => !!id) })),
1062
+ readRecords,
1063
+ metrics: iPlan?.metrics,
1064
+ reason: event.reason,
1065
+ hasMarker: legacyCut.hasMarker,
1066
+ });
1067
+ return {
1068
+ summary: parts.join("\n\n"),
1069
+ firstKeptEntryId,
1070
+ tokensBefore: event.preparation.tokensBefore,
1071
+ estimatedTokensAfter: iPlan?.metrics.estimatedAfterTokens ?? undefined,
1072
+ details,
1073
+ };
1074
+ }
1075
+
1076
+ export function handlePlanningBeforeCompact(
194
1077
  pi: ExtensionAPI,
195
1078
  ctx: ExtensionContext,
196
- completedIds: string[],
197
- usage?: { input: number; output: number },
198
- ): ItemDiffSummary | null {
199
- if (!execution) return null;
200
- if (usage) {
201
- execution.usage.inToks += usage.input;
202
- execution.usage.outToks += usage.output;
1079
+ event: SessionBeforeCompactEvent,
1080
+ ): SessionBeforeCompactResult | undefined {
1081
+ if (getExecution()) return undefined;
1082
+ if (!resolvePlanningCompactionContext(ctx.cwd)) return undefined;
1083
+ const session = ctx.sessionManager as unknown as { __planningCompaction?: PlanningCompactionState };
1084
+ const state = (session.__planningCompaction ??= {
1085
+ inFlight: false,
1086
+ resumeGuard: false,
1087
+ cooldownActive: false,
1088
+ lastAttemptReason: null,
1089
+ lastSuccessfulUsagePercent: null,
1090
+ lastSuccessfulAt: null,
1091
+ } satisfies PlanningCompactionState);
1092
+ const percent = ctx.getContextUsage()?.percent ?? null;
1093
+ if (event.reason === "threshold" && (percent === null || percent < 100)) {
1094
+ state.inFlight = false;
1095
+ state.lastAttemptReason = event.reason;
1096
+ return { cancel: true };
203
1097
  }
204
- const summary = completeCompletedItems(execution, ctx.cwd, completedIds);
205
- persist(pi);
206
- syncExecutionPanel(ctx);
207
- return summary;
1098
+ state.inFlight = true;
1099
+ state.lastAttemptReason = event.reason;
1100
+ if (percent !== null && percent < 85) {
1101
+ state.cooldownActive = false;
1102
+ }
1103
+ let compaction: CompactionResult | null;
1104
+ try {
1105
+ compaction = buildPlanningCompactionResult(event, ctx);
1106
+ } catch (error) {
1107
+ state.inFlight = false;
1108
+ ctx.ui.notify(`pi-plans: planning compaction preparation failed; using Pi default compaction (${String(error)}).`, "warning");
1109
+ return undefined;
1110
+ }
1111
+ if (!compaction) {
1112
+ state.inFlight = false;
1113
+ return undefined;
1114
+ }
1115
+ notifyHardFloor(ctx, compaction);
1116
+ return { compaction };
1117
+ }
1118
+
1119
+ export function handlePlanningCompact(pi: ExtensionAPI, ctx: ExtensionContext, event: SessionCompactEvent): void {
1120
+ if (getExecution()) return;
1121
+ const session = ctx.sessionManager as unknown as { __planningCompaction?: PlanningCompactionState };
1122
+ const state = session.__planningCompaction;
1123
+ if (!state) return;
1124
+ state.inFlight = false;
1125
+ state.lastAttemptReason = event.reason;
1126
+ state.cooldownActive = true;
1127
+ state.lastSuccessfulAt = utcNow();
1128
+ state.lastSuccessfulUsagePercent = ctx.getContextUsage()?.percent ?? state.lastSuccessfulUsagePercent;
1129
+ if (!event.willRetry) {
1130
+ state.resumeGuard = true;
1131
+ pi.sendMessage(
1132
+ {
1133
+ customType: PLANNING_RESUME_CUSTOM_TYPE,
1134
+ content: "Continue planning.",
1135
+ display: false,
1136
+ },
1137
+ { triggerTurn: true },
1138
+ );
1139
+ } else {
1140
+ state.resumeGuard = false;
1141
+ }
1142
+ }
1143
+
1144
+ export function handlePlanningCompactFailed(pi: ExtensionAPI, ctx: ExtensionContext, event: SessionCompactFailedEvent): void {
1145
+ if (getExecution()) return;
1146
+ const session = ctx.sessionManager as unknown as { __planningCompaction?: PlanningCompactionState };
1147
+ const state = session.__planningCompaction;
1148
+ if (!state) return;
1149
+ const expectedThresholdCancel = event.reason === "threshold" && event.aborted && !state.inFlight;
1150
+ if (expectedThresholdCancel) {
1151
+ state.lastAttemptReason = event.reason;
1152
+ return;
1153
+ }
1154
+ state.inFlight = false;
1155
+ state.resumeGuard = false;
1156
+ state.cooldownActive = false;
1157
+ state.lastAttemptReason = event.reason;
1158
+ ctx.ui.notify(
1159
+ `pi-plans: planning compaction failed (${event.reason}); will try again on the next eligible turn.`,
1160
+ "warning",
1161
+ );
208
1162
  }
209
1163
 
210
- export function stopExecution(pi: ExtensionAPI, ctx: ExtensionContext, reason: string): void {
1164
+ export function filterPlanningResumeMessages<T extends { customType?: string }>(messages: T[]): T[] {
1165
+ return messages.filter((message) => message.customType !== PLANNING_RESUME_CUSTOM_TYPE);
1166
+ }
1167
+
1168
+ export async function stopExecution(pi: ExtensionAPI, ctx: ExtensionContext, reason: string): Promise<void> {
211
1169
  if (!execution) return;
212
- clearExecutionPanel(ctx);
1170
+ resetExecutionCompactionState(ctx);
1171
+ // Final synchronous write: drain any deferred flush and land the last snapshot.
1172
+ pendingExecutionFlush = false;
1173
+ persist(pi);
213
1174
  execution = null;
214
1175
  pi.appendEntry("pi-plans-exec-cleared", { reason });
215
1176
  pi.sendMessage(
@@ -245,15 +1206,47 @@ export function applyDoneMarkers(text: string): string[] {
245
1206
  return changed;
246
1207
  }
247
1208
 
1209
+ /**
1210
+ * Apply [I-xxx:implemented|validating] markers from an assistant message.
1211
+ * Unknown I-ids are silently ignored; later markers overwrite earlier ones.
1212
+ * Returns the ids whose state actually changed.
1213
+ */
1214
+ export function applyImplMarkers(text: string): string[] {
1215
+ if (!execution?.implItems?.length) return [];
1216
+ const known = new Set(execution.implItems.map((impl) => impl.id));
1217
+ execution.implStatus ??= {};
1218
+ const changed: string[] = [];
1219
+ for (const marker of scanImplMarkers(text)) {
1220
+ if (!known.has(marker.id)) continue;
1221
+ const previous = execution.implStatus[marker.id];
1222
+ execution.implStatus[marker.id] = marker.state;
1223
+ if (previous !== marker.state) changed.push(marker.id);
1224
+ }
1225
+ return changed;
1226
+ }
1227
+
1228
+ export function applyCurrentIMarker(text: string): boolean {
1229
+ if (!execution?.implItems?.length) return false;
1230
+ const markers = scanCurrentIMarkers(text);
1231
+ const resolved = resolveCurrentI(execution.implItems, markers, execution.currentI);
1232
+ if (!resolved || resolved === execution.currentI) return false;
1233
+ execution.currentI = resolved;
1234
+ return true;
1235
+ }
1236
+
248
1237
  export function isExecutionComplete(): boolean {
249
1238
  return execution !== null && execution.items.length > 0 && execution.items.every((item) => item.done);
250
1239
  }
251
1240
 
252
- export function completeExecution(pi: ExtensionAPI, ctx: ExtensionContext): void {
1241
+ export async function completeExecution(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
253
1242
  if (!execution) return;
1243
+ resetExecutionCompactionState(ctx);
1244
+ // Final synchronous write: drain any deferred flush and land the last snapshot.
1245
+ pendingExecutionFlush = false;
1246
+ persist(pi);
1247
+
254
1248
  const summary = execution.items.map((item) => `- ✅ \`${item.id}\` ${item.text.split(";")[0]}`).join("\n");
255
1249
  const planPath = execution.planPath;
256
- clearExecutionPanel(ctx);
257
1250
  execution = null;
258
1251
  pi.appendEntry("pi-plans-exec-cleared", { reason: "complete" });
259
1252
  pi.sendMessage(
@@ -281,14 +1274,19 @@ export function executionContextMessage(): string | null {
281
1274
  const remaining = execution.items.filter((item) => !item.done);
282
1275
  const list =
283
1276
  remaining.map((item) => `- \`${item.id}\` ${item.text}`).join("\n") || "(none — report completion now)";
1277
+ const implementationItems = execution.implItems?.length
1278
+ ? `\nImplementation items: ${execution.implItems.map((item) => item.id).join(", ")}${execution.currentI ? `\nCurrent implementation item: \`${execution.currentI}\`` : ""}\nWhen beginning an implementation item, emit its current anchor exactly once as \`[I-###:current]\`; then use \`[I-###:implemented]\` or \`[I-###:validating]\` for progress.`
1279
+ : "";
284
1280
  return `[PI-PLANS EXECUTION — write access enabled]
285
1281
  Implement the accepted plan at ${execution.planPath} (${execution.items.length - remaining.length}/${execution.items.length} verifier items done).
286
1282
 
287
1283
  Remaining verifier items:
288
- ${list}
1284
+ ${list}${implementationItems}
289
1285
 
290
1286
  Execution rules:
291
1287
  - 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.
1288
+ - 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 execution status bar tracks these states.
1289
+ - For subprocess-backed verification, when a step starts a subprocess and needs its result before verifying, use literal \`waiting for\` with backoff \`5s -> 10s -> 20s -> 40s -> 80s\`, then keep polling at 80s; restart at 5s for each new subprocess.
292
1290
  - Simplest implementation that fully meets the item: no speculative abstractions, configuration, or indirection; keep components modular with clearly separated concerns.
293
1291
  - 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
1292
  - 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 +1307,9 @@ interface SessionEntry {
309
1307
  * pi-plans-exec snapshot, then re-scans assistant messages after it for
310
1308
  * [DONE:VC-xxx] markers so progress survives restarts.
311
1309
  */
312
- export function restoreFromSession(pi: ExtensionAPI, ctx: ExtensionContext, entries: SessionEntry[]): void {
1310
+ export async function restoreFromSession(pi: ExtensionAPI, ctx: ExtensionContext, entries: SessionEntry[]): Promise<void> {
1311
+ pendingExecutionFlush = false; // no flush debt survives a restart
1312
+ resetExecutionCompactionState(ctx);
313
1313
  let snapshotIndex = -1;
314
1314
  let snapshot: ExecState | null = null;
315
1315
  for (let i = entries.length - 1; i >= 0; i--) {
@@ -322,35 +1322,34 @@ export function restoreFromSession(pi: ExtensionAPI, ctx: ExtensionContext, entr
322
1322
  if (entry.type === "custom" && entry.customType === "pi-plans-exec-cleared") {
323
1323
  // Execution was explicitly stopped or completed after the last snapshot.
324
1324
  execution = null;
325
- syncExecutionPanel(ctx);
326
1325
  updateStatusWidget(ctx);
327
1326
  return;
328
1327
  }
329
1328
  }
330
1329
  if (!snapshot) {
331
1330
  execution = null;
332
- syncExecutionPanel(ctx);
333
1331
  updateStatusWidget(ctx);
334
1332
  return;
335
1333
  }
336
1334
  // Ignore stale plans whose file vanished.
337
1335
  if (!fs.existsSync(snapshot.planPath)) {
338
1336
  execution = null;
339
- syncExecutionPanel(ctx);
340
1337
  updateStatusWidget(ctx);
341
1338
  return;
342
1339
  }
343
1340
  execution = {
344
- ...snapshot,
1341
+ planPath: snapshot.planPath,
345
1342
  items: snapshot.items.map((item) => ({ ...item })),
1343
+ startedAt: snapshot.startedAt,
346
1344
  usage: snapshot.usage ?? { inToks: 0, outToks: 0 },
347
- panel: executionPanelFromEntryData(snapshot.panel) ?? createExecutionPanelState(),
1345
+ implItems: snapshot.implItems ?? [],
1346
+ implStatus: { ...(snapshot.implStatus ?? {}) },
1347
+ currentI: snapshot.currentI ?? inferCurrentI(snapshot.implItems, snapshot.items, snapshot.implStatus),
348
1348
  };
349
1349
  for (let i = snapshotIndex + 1; i < entries.length; i++) {
350
1350
  const entry = entries[i];
351
1351
  if (entry.type === "custom" && entry.customType === "pi-plans-exec-cleared") {
352
1352
  execution = null;
353
- syncExecutionPanel(ctx);
354
1353
  break;
355
1354
  }
356
1355
  const message = entry.message;
@@ -360,12 +1359,16 @@ export function restoreFromSession(pi: ExtensionAPI, ctx: ExtensionContext, entr
360
1359
  .map((part) => part.text ?? "")
361
1360
  .join("\n");
362
1361
  applyDoneMarkers(text);
1362
+ applyImplMarkers(text);
1363
+ applyCurrentIMarker(text);
363
1364
  }
364
1365
  }
365
1366
  if (execution) {
366
1367
  persist(pi); // refresh snapshot so the next resume has less to rescan
367
- if (isExecutionComplete()) completeExecution(pi, ctx);
1368
+ if (isExecutionComplete()) {
1369
+ // Completed during the rescan: restore the planning model on the way out.
1370
+ await completeExecution(pi, ctx);
1371
+ }
368
1372
  }
369
- syncExecutionPanel(ctx);
370
1373
  updateStatusWidget(ctx);
371
1374
  }