pi-plans 0.1.2 → 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,10 +3,11 @@
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
12
  import type {
12
13
  CompactionResult,
@@ -17,56 +18,48 @@ import type {
17
18
  SessionCompactEvent,
18
19
  SessionCompactFailedEvent,
19
20
  } from "@earendil-works/pi-coding-agent";
21
+ import { AUTOCOMPLETE_ENTRY } from "./autocomplete.ts";
20
22
  import {
21
- attachPanelBaseline,
22
- clearExecutionPanel,
23
- completeCompletedItems,
24
- computeExecutionProgress,
25
- createExecutionPanelState,
26
- executionPanelFromEntryData,
27
- formatExecutionStatusLine,
28
- refreshExecutionPanel,
29
- snapshotPanelState,
30
- toggleExpanded,
31
- type ExecutionPanelExecutionLike,
32
- type ExecutionPanelState,
33
- type ItemDiffSummary,
34
- } from "./execution-panel.ts";
35
- import { getRun, readActive, setRunStatus, utcNow, type ExecutionConfig } from "./state.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";
34
+ import { getRun, readActive, setRunStatus, utcNow } from "./state.ts";
36
35
  import {
36
+ extractCoverage,
37
37
  latestPlanVersion,
38
+ resolveImplStatuses,
38
39
  scanDoneMarkers,
39
40
  scanImplMarkers,
41
+ scanCurrentIMarkers,
42
+ resolveCurrentI,
43
+ inferCurrentI,
40
44
  type CheckItem,
41
45
  type ImplItem,
42
46
  type ImplMarkerState,
43
47
  } from "./plan.ts";
44
48
 
45
- export interface ExecState extends ExecutionPanelExecutionLike {
49
+ export interface ExecState {
50
+ planPath: string;
51
+ items: CheckItem[];
46
52
  startedAt: string;
47
- panel?: ExecutionPanelState;
48
53
  usage: { inToks: number; outToks: number };
49
- modelState?: ExecutionModelState;
50
54
  implItems?: ImplItem[];
51
55
  implStatus?: Record<string, ImplMarkerState>;
56
+ currentI?: string;
52
57
  }
53
58
 
54
59
  let execution: ExecState | null = null;
55
60
 
56
- // Set when the user toggles the panel while a turn is streaming; consumed by
57
- // index.ts on turn_end so view state converges without touching the live run.
58
- let pendingPanelSync = false;
59
-
60
- export function consumePendingPanelSync(): boolean {
61
- const pending = pendingPanelSync;
62
- pendingPanelSync = false;
63
- return pending;
64
- }
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).
61
+ // Execution-loop persistence is deferred until the agent settles so turn_end
62
+ // never causes session writes during a streaming run.
70
63
  let pendingExecutionFlush = false;
71
64
 
72
65
  export function consumePendingExecutionFlush(): boolean {
@@ -75,25 +68,17 @@ export function consumePendingExecutionFlush(): boolean {
75
68
  return pending;
76
69
  }
77
70
 
78
- function requestExecutionFlush(pi: ExtensionAPI, ctx: ExtensionContext): void {
71
+ function requestExecutionFlush(_pi: ExtensionAPI, _ctx: ExtensionContext): void {
79
72
  // 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.
73
+ // operations where isIdle() reads true; persistence happens only at the
74
+ // drain points: agent_settled, the next before_agent_start, and stop/complete.
84
75
  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
76
  }
90
77
 
91
78
  export function drainExecutionFlush(pi: ExtensionAPI, ctx: ExtensionContext): void {
92
- if (!execution) return;
93
- if (!pendingExecutionFlush && !consumePendingPanelSync()) return;
79
+ if (!execution || !pendingExecutionFlush) return;
94
80
  pendingExecutionFlush = false;
95
81
  persist(pi);
96
- syncExecutionPanel(ctx);
97
82
  updateStatusWidget(ctx);
98
83
  }
99
84
 
@@ -101,173 +86,158 @@ export function getExecution(): ExecState | null {
101
86
  return execution;
102
87
  }
103
88
 
104
- const THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
105
- const CURRENT_SESSION_MODEL_CHOICE = "Inherit current session model";
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.";
106
93
 
107
- export interface ExecutionModelState {
108
- planningSelector: string | null;
109
- executionSelector: string;
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;
110
102
  }
111
103
 
112
- export interface ModelSelection {
113
- provider: string;
114
- modelId: string;
115
- thinkingLevel: string | null;
116
- selector: string;
117
- model: any;
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;
118
113
  }
119
114
 
120
- function buildModelSelector(provider: string, modelId: string, thinkingLevel?: string | null): string {
121
- return thinkingLevel ? `${provider}/${modelId}:${thinkingLevel}` : `${provider}/${modelId}`;
115
+ function executionCompactionState(ctx: ExtensionContext): ExecutionCompactionState | undefined {
116
+ return getExecutionCompactionSession(ctx)?.__executionCompaction;
122
117
  }
123
118
 
124
- export function currentModelSelection(ctx: Pick<ExtensionContext, "model" | "thinkingLevel">): ModelSelection | null {
125
- if (!ctx.model) return null;
126
- return {
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,
132
- };
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
+ });
133
130
  }
134
131
 
135
- export function snapshotCurrentModelSelector(ctx: Pick<ExtensionContext, "model" | "thinkingLevel">): string | null {
136
- return currentModelSelection(ctx)?.selector ?? null;
132
+ function resetExecutionCompactionState(ctx: ExtensionContext): void {
133
+ const session = getExecutionCompactionSession(ctx);
134
+ if (!session) return;
135
+ delete session.__executionCompaction;
137
136
  }
138
137
 
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);
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;
184
152
  }
185
- return unique;
186
153
  }
187
154
 
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);
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.
192
180
  }
193
- return true;
194
181
  }
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);
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;
199
195
  }
200
196
  return true;
201
197
  }
202
198
 
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;
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");
242
209
  }
210
+ }
243
211
 
244
- if (configuredSelector) {
245
- const resolved = resolveModelSelection(ctx, configuredSelector);
246
- if (resolved) return resolved;
247
- } else {
248
- return current;
212
+ export function handleExecutionTurnCompaction(ctx: ExtensionContext): void {
213
+ refreshExecutionCompactionCooldown(ctx);
214
+ if (consumeExecutionCompactionResumeGuard(ctx)) return;
215
+ if (shouldTriggerExecutionCompaction(ctx)) {
216
+ requestExecutionCompaction(ctx);
249
217
  }
218
+ }
250
219
 
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);
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
+ };
269
236
  }
270
- return resolveModelSelection(ctx, choice) ?? current;
237
+ return {
238
+ done: execution.items.filter((item) => item.done).length,
239
+ total: execution.items.length,
240
+ };
271
241
  }
272
242
 
273
243
  export function executionProgress(): { done: number; total: number } | null {
@@ -275,13 +245,26 @@ export function executionProgress(): { done: number; total: number } | null {
275
245
  return computeExecutionProgress(execution);
276
246
  }
277
247
 
248
+ function formatElapsed(startedAt: string): string {
249
+ const total = Math.max(0, Math.floor((Date.now() - Date.parse(startedAt)) / 1000));
250
+ const h = String(Math.floor(total / 3600)).padStart(2, "0");
251
+ const m = String(Math.floor((total % 3600) / 60)).padStart(2, "0");
252
+ const sec = String(total % 60).padStart(2, "0");
253
+ return `${h}:${m}:${sec}`;
254
+ }
255
+
256
+ function formatToks(tokens: number): string {
257
+ const n = Math.max(0, Math.round(tokens));
258
+ return n < 1000 ? String(n) : `${(n / 1000).toFixed(1)}k`;
259
+ }
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
+
278
266
  export function updateStatusWidget(ctx: ExtensionContext): void {
279
267
  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
268
  const line = formatExecutionStatusLine(execution);
286
269
  ctx.ui.setStatus("pi-plans", ctx.ui.theme.fg("accent", line));
287
270
  return;
@@ -326,35 +309,23 @@ function persist(pi: ExtensionAPI): void {
326
309
  items: execution.items,
327
310
  startedAt: execution.startedAt,
328
311
  usage: execution.usage,
329
- panel: snapshotPanelState(execution),
330
312
  implItems: execution.implItems,
313
+ implStatus: execution.implStatus,
314
+ currentI: execution.currentI,
331
315
  });
332
316
  }
333
317
 
334
- export function syncExecutionPanel(ctx: ExtensionContext): void {
335
- if (!execution) {
336
- clearExecutionPanel(ctx);
337
- return;
338
- }
339
- refreshExecutionPanel(ctx, execution);
340
- }
341
-
342
318
  export async function startExecution(
343
319
  pi: ExtensionAPI,
344
320
  ctx: ExtensionContext,
345
321
  planPath: string,
346
322
  items: CheckItem[],
347
- modelState?: ExecutionModelState,
348
323
  implItems?: ImplItem[],
349
324
  ): Promise<void> {
350
- execution = { planPath, items, startedAt: utcNow(), panel: createExecutionPanelState(), usage: { inToks: 0, outToks: 0 }, modelState, implItems: implItems ?? [], implStatus: {} };
351
- attachPanelBaseline(execution, ctx.cwd);
352
- consumePendingPanelSync(); // fresh run: drop any stale deferral from a previous one
325
+ execution = { planPath, items, startedAt: utcNow(), usage: { inToks: 0, outToks: 0 }, implItems: implItems ?? [], implStatus: {} };
353
326
  pendingExecutionFlush = false; // fresh run: no inherited flush debt
327
+ resetExecutionCompactionState(ctx);
354
328
  persist(pi);
355
- if (modelState) {
356
- await ensureExecutionModelActive(pi, ctx);
357
- }
358
329
  const active = readActive(ctx.cwd);
359
330
  if (active) {
360
331
  try {
@@ -372,54 +343,22 @@ export async function startExecution(
372
343
  { triggerTurn: false },
373
344
  );
374
345
  updateStatusWidget(ctx);
375
- syncExecutionPanel(ctx);
376
- }
377
-
378
- export function toggleExecutionPanelView(pi: ExtensionAPI, ctx: ExtensionContext): boolean | null {
379
- if (!execution) return null;
380
- const expanded = toggleExpanded(execution);
381
- // While a turn is streaming keep this zero-side-effect: flipping the flag is
382
- // pure memory; persisting and re-rendering here would write to the session
383
- // file and force a TUI relayout under the running agent. The next turn_end
384
- // consumes the pending marker and brings the view in line.
385
- const idle = typeof ctx.isIdle !== "function" || ctx.isIdle();
386
- if (idle) {
387
- persist(pi);
388
- syncExecutionPanel(ctx);
389
- } else {
390
- pendingPanelSync = true;
391
- }
392
- return expanded;
393
- }
394
-
395
- export function recordTouchedPaths(_workdir: string, paths: string[]): void {
396
- if (!execution || !paths.length) return;
397
- const panel = execution.panel ?? createExecutionPanelState();
398
- execution.panel = panel;
399
- const merged = new Set(panel.touchedPaths);
400
- for (const raw of paths) {
401
- const normalized = raw.trim().replace(/[\u0000]+/g, "");
402
- if (!normalized) continue;
403
- merged.add(normalized);
404
- }
405
- panel.touchedPaths = [...merged];
406
346
  }
407
347
 
408
348
  /** Record one assistant turn: accumulate usage and mark any completed items. */
409
349
  export function recordExecutionTurn(
410
350
  pi: ExtensionAPI,
411
- ctx: ExtensionContext,
412
- completedIds: string[],
351
+ _ctx: ExtensionContext,
352
+ _completedIds: string[],
413
353
  usage?: { input: number; output: number },
414
- ): ItemDiffSummary | null {
415
- if (!execution) return null;
354
+ ): void {
355
+ if (!execution) return;
416
356
  if (usage) {
417
357
  execution.usage.inToks += usage.input;
418
358
  execution.usage.outToks += usage.output;
419
359
  }
420
- const summary = completeCompletedItems(execution, ctx.cwd, completedIds);
421
- requestExecutionFlush(pi, ctx);
422
- return summary;
360
+ requestExecutionFlush(pi, _ctx);
361
+ updateStatusWidget(_ctx);
423
362
  }
424
363
 
425
364
  export function registerExecutionTurnHandlers(
@@ -448,11 +387,12 @@ export function registerExecutionTurnHandlers(
448
387
  .join("\n");
449
388
  const changedIds = applyDoneMarkers(text);
450
389
  const changedImpls = applyImplMarkers(text);
390
+ const changedCurrentI = applyCurrentIMarker(text);
451
391
  const projection = (event.message as { usage?: { input?: number; output?: number } }).usage;
452
392
  const raw = projection ?? lastAssistantUsage;
453
393
  lastAssistantUsage = null; // consumed: never re-attribute a stale turn
454
394
  const usage = raw ? { input: raw.input ?? 0, output: raw.output ?? 0 } : undefined;
455
- if (usage || changedIds.length > 0 || changedImpls.length > 0) {
395
+ if (usage || changedIds.length > 0 || changedImpls.length > 0 || changedCurrentI) {
456
396
  // Attribute this turn's usage now; `[DONE]` markers still only mark completion.
457
397
  recordExecutionTurn(pi, ctx, changedIds, usage);
458
398
  }
@@ -558,64 +498,264 @@ ${currentLines.length ? currentLines.join("\n") : "- (no transcript captured)"}`
558
498
  return sections;
559
499
  }
560
500
 
561
- export function buildExecutionCompactionResult(event: SessionBeforeCompactEvent, ctx: ExtensionContext): CompactionResult | null {
562
- if (!execution) return null;
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
563
  const active = readActive(ctx.cwd);
564
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);
565
+ const branchEntries = event.branchEntries as unknown as CompactionEntryLike[];
567
566
  const summaryEntries = boundaryIndex >= 0
568
- ? (event.branchEntries.slice(0, boundaryIndex) as CompactBranchEntry[])
569
- : (event.branchEntries as CompactBranchEntry[]);
567
+ ? (branchEntries.slice(0, boundaryIndex) as CompactBranchEntry[])
568
+ : (branchEntries as CompactBranchEntry[]);
570
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
+ });
571
596
  const parts: string[] = [];
572
597
  if (event.customInstructions?.trim()) {
573
- parts.push(`## Compact Instructions
574
- ${compactText(event.customInstructions, 1000)}`);
598
+ parts.push(`## Compact Instructions\n${compactText(event.customInstructions, 1000)}`);
575
599
  }
576
- parts.push(`## Plan Before This Run
577
- - Request: ${compactText(run?.request_text ?? execution.planPath, 280)}
578
- - Plan file: \`${execution.planPath}\``);
600
+ parts.push(`## Plan Before This Run\n- Request: ${compactText(run?.request_text ?? execution.planPath, 280)}\n- Plan file: \`${execution.planPath}\``);
579
601
  if (event.preparation.previousSummary?.trim()) {
580
- parts.push(`## Previous Compact Summary
581
- ${event.preparation.previousSummary.trim()}`);
602
+ parts.push(`## Previous Compact Summary\n${event.preparation.previousSummary.trim()}`);
582
603
  }
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." : ""}`);
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 {
614
+ if (!execution) return null;
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);
587
622
  return {
588
- summary: parts.join("\n\n"),
623
+ summary: parts.join("\\n\\n"),
589
624
  firstKeptEntryId,
590
625
  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
- },
626
+ estimatedTokensAfter: plan?.metrics.estimatedAfterTokens ?? undefined,
627
+ details,
597
628
  };
598
629
  }
599
630
 
600
- export function handleExecutionBeforeCompact(pi: ExtensionAPI, ctx: ExtensionContext, event: SessionBeforeCompactEvent): SessionBeforeCompactResult | undefined {
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 {
601
714
  if (!execution) return undefined;
602
- const compaction = buildExecutionCompactionResult(event, ctx);
603
- if (!compaction) return undefined;
604
- requestExecutionFlush(pi, ctx);
605
- return { compaction };
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
+ });
606
736
  }
607
737
 
608
738
  export function handleExecutionCompact(pi: ExtensionAPI, ctx: ExtensionContext, event: SessionCompactEvent): void {
609
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;
610
747
  if (!event.willRetry) {
748
+ state.resumeGuard = true;
611
749
  pi.sendMessage(
612
750
  {
613
751
  customType: EXECUTION_RESUME_CUSTOM_TYPE,
614
- content: "Continue execution.",
752
+ content: EXECUTION_COMPACTION_RESUME_MESSAGE,
615
753
  display: false,
616
754
  },
617
755
  { triggerTurn: true },
618
756
  );
757
+ } else {
758
+ state.resumeGuard = false;
619
759
  }
620
760
  requestExecutionFlush(pi, ctx);
621
761
  updateStatusWidget(ctx);
@@ -623,6 +763,19 @@ export function handleExecutionCompact(pi: ExtensionAPI, ctx: ExtensionContext,
623
763
 
624
764
  export function handleExecutionCompactFailed(pi: ExtensionAPI, ctx: ExtensionContext, event: SessionCompactFailedEvent): void {
625
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;
771
+ }
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
+ }
626
779
  ctx.ui.notify(
627
780
  `pi-plans: compaction failed (${event.reason}); execution remains active and will wait for the next eligible turn.`,
628
781
  "warning",
@@ -673,9 +826,22 @@ function isPlanningInternalCustomType(customType?: string): boolean {
673
826
  || customType === PLANNING_RUN_START_CUSTOM_TYPE
674
827
  || customType === PLANNING_PLAN_WRITTEN_CUSTOM_TYPE
675
828
  || customType === PLANNING_RESUME_CUSTOM_TYPE
829
+ || customType === AUTOCOMPLETE_ENTRY
676
830
  );
677
831
  }
678
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
+
679
845
  function summarizePlanningMessageLine(entry: PlanningBranchEntry): string | null {
680
846
  if (!entry.message) return null;
681
847
  const text = (entry.message.content ?? [])
@@ -743,12 +909,41 @@ function resolvePlanningCompactionContext(workdir: string): { runId: string; art
743
909
  return { runId: run.run_id, artifactDir: run.artifact_dir };
744
910
  }
745
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
+ }
746
941
  export function shouldTriggerPlanningCompaction(ctx: ExtensionContext): boolean {
747
942
  if (getExecution()) return false;
748
943
  const ctxWorkdir = ctx.cwd;
749
944
  if (!resolvePlanningCompactionContext(ctxWorkdir)) return false;
750
- const percent = ctx.getContextUsage()?.percent ?? null;
751
- if (percent === null || percent < 100) return false;
945
+ const currentUsage = planningCurrentIUsage(ctx);
946
+ if (!currentUsage || currentUsage.eligible === false || !currentIExceedsTrigger(currentUsage.tokens, currentUsage.contextWindow)) return false;
752
947
  const session = ctx.sessionManager as unknown as { __planningCompaction?: PlanningCompactionState };
753
948
  const state = session.__planningCompaction;
754
949
  if (!state) return true;
@@ -786,7 +981,12 @@ export function requestPlanningCompaction(ctx: ExtensionContext): void {
786
981
  if (state.inFlight || state.resumeGuard) return;
787
982
  state.inFlight = true;
788
983
  state.lastAttemptReason = "threshold";
789
- ctx.compact({ customInstructions: "pi-plans planning auto compact" });
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
+ }
790
990
  }
791
991
 
792
992
  export function buildPlanningCompactionResult(event: SessionBeforeCompactEvent, ctx: ExtensionContext): CompactionResult | null {
@@ -794,8 +994,26 @@ export function buildPlanningCompactionResult(event: SessionBeforeCompactEvent,
794
994
  const planningCtx = resolvePlanningCompactionContext(ctxWorkdir);
795
995
  if (!planningCtx) return null;
796
996
  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;
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[]);
799
1017
  const parts: string[] = [];
800
1018
  if (event.customInstructions?.trim()) {
801
1019
  parts.push(`## Compact Instructions
@@ -818,22 +1036,40 @@ ${compactText(planningCtx.runId, 80)} — keep current planning progress.`);
818
1036
  - Current planning question or open decision.
819
1037
 
820
1038
  ### Blocked
821
- - ${hasMarker ? "None" : "Planning cut-point marker missing; falling back to default."}`);
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
+ }
822
1051
  if (previousSummary) {
823
- parts.push(`## Previous Compact Summary
824
- ${previousSummary}`);
1052
+ parts.push(`## Previous Compact Summary\n${previousSummary}`);
825
1053
  }
826
1054
  parts.push(`## Next Steps
827
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
+ });
828
1067
  return {
829
1068
  summary: parts.join("\n\n"),
830
1069
  firstKeptEntryId,
831
1070
  tokensBefore: event.preparation.tokensBefore,
832
- details: {
833
- kind: "pi-plans-planning-compaction",
834
- reason: event.reason,
835
- hasMarker,
836
- },
1071
+ estimatedTokensAfter: iPlan?.metrics.estimatedAfterTokens ?? undefined,
1072
+ details,
837
1073
  };
838
1074
  }
839
1075
 
@@ -864,11 +1100,19 @@ export function handlePlanningBeforeCompact(
864
1100
  if (percent !== null && percent < 85) {
865
1101
  state.cooldownActive = false;
866
1102
  }
867
- const compaction = buildPlanningCompactionResult(event, ctx);
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
+ }
868
1111
  if (!compaction) {
869
1112
  state.inFlight = false;
870
1113
  return undefined;
871
1114
  }
1115
+ notifyHardFloor(ctx, compaction);
872
1116
  return { compaction };
873
1117
  }
874
1118
 
@@ -921,41 +1165,12 @@ export function filterPlanningResumeMessages<T extends { customType?: string }>(
921
1165
  return messages.filter((message) => message.customType !== PLANNING_RESUME_CUSTOM_TYPE);
922
1166
  }
923
1167
 
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
1168
  export async function stopExecution(pi: ExtensionAPI, ctx: ExtensionContext, reason: string): Promise<void> {
953
1169
  if (!execution) return;
954
- await restorePlanningModel(pi, ctx);
1170
+ resetExecutionCompactionState(ctx);
955
1171
  // Final synchronous write: drain any deferred flush and land the last snapshot.
956
1172
  pendingExecutionFlush = false;
957
1173
  persist(pi);
958
- clearExecutionPanel(ctx);
959
1174
  execution = null;
960
1175
  pi.appendEntry("pi-plans-exec-cleared", { reason });
961
1176
  pi.sendMessage(
@@ -1010,19 +1225,28 @@ export function applyImplMarkers(text: string): string[] {
1010
1225
  return changed;
1011
1226
  }
1012
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
+
1013
1237
  export function isExecutionComplete(): boolean {
1014
1238
  return execution !== null && execution.items.length > 0 && execution.items.every((item) => item.done);
1015
1239
  }
1016
1240
 
1017
1241
  export async function completeExecution(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
1018
1242
  if (!execution) return;
1019
- await restorePlanningModel(pi, ctx);
1243
+ resetExecutionCompactionState(ctx);
1020
1244
  // Final synchronous write: drain any deferred flush and land the last snapshot.
1021
1245
  pendingExecutionFlush = false;
1022
1246
  persist(pi);
1247
+
1023
1248
  const summary = execution.items.map((item) => `- ✅ \`${item.id}\` ${item.text.split(";")[0]}`).join("\n");
1024
1249
  const planPath = execution.planPath;
1025
- clearExecutionPanel(ctx);
1026
1250
  execution = null;
1027
1251
  pi.appendEntry("pi-plans-exec-cleared", { reason: "complete" });
1028
1252
  pi.sendMessage(
@@ -1050,15 +1274,19 @@ export function executionContextMessage(): string | null {
1050
1274
  const remaining = execution.items.filter((item) => !item.done);
1051
1275
  const list =
1052
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
+ : "";
1053
1280
  return `[PI-PLANS EXECUTION — write access enabled]
1054
1281
  Implement the accepted plan at ${execution.planPath} (${execution.items.length - remaining.length}/${execution.items.length} verifier items done).
1055
1282
 
1056
1283
  Remaining verifier items:
1057
- ${list}
1284
+ ${list}${implementationItems}
1058
1285
 
1059
1286
  Execution rules:
1060
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.
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.
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.
1062
1290
  - Simplest implementation that fully meets the item: no speculative abstractions, configuration, or indirection; keep components modular with clearly separated concerns.
1063
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.
1064
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.
@@ -1081,6 +1309,7 @@ interface SessionEntry {
1081
1309
  */
1082
1310
  export async function restoreFromSession(pi: ExtensionAPI, ctx: ExtensionContext, entries: SessionEntry[]): Promise<void> {
1083
1311
  pendingExecutionFlush = false; // no flush debt survives a restart
1312
+ resetExecutionCompactionState(ctx);
1084
1313
  let snapshotIndex = -1;
1085
1314
  let snapshot: ExecState | null = null;
1086
1315
  for (let i = entries.length - 1; i >= 0; i--) {
@@ -1093,21 +1322,18 @@ export async function restoreFromSession(pi: ExtensionAPI, ctx: ExtensionContext
1093
1322
  if (entry.type === "custom" && entry.customType === "pi-plans-exec-cleared") {
1094
1323
  // Execution was explicitly stopped or completed after the last snapshot.
1095
1324
  execution = null;
1096
- syncExecutionPanel(ctx);
1097
1325
  updateStatusWidget(ctx);
1098
1326
  return;
1099
1327
  }
1100
1328
  }
1101
1329
  if (!snapshot) {
1102
1330
  execution = null;
1103
- syncExecutionPanel(ctx);
1104
1331
  updateStatusWidget(ctx);
1105
1332
  return;
1106
1333
  }
1107
1334
  // Ignore stale plans whose file vanished.
1108
1335
  if (!fs.existsSync(snapshot.planPath)) {
1109
1336
  execution = null;
1110
- syncExecutionPanel(ctx);
1111
1337
  updateStatusWidget(ctx);
1112
1338
  return;
1113
1339
  }
@@ -1116,16 +1342,14 @@ export async function restoreFromSession(pi: ExtensionAPI, ctx: ExtensionContext
1116
1342
  items: snapshot.items.map((item) => ({ ...item })),
1117
1343
  startedAt: snapshot.startedAt,
1118
1344
  usage: snapshot.usage ?? { inToks: 0, outToks: 0 },
1119
- panel: executionPanelFromEntryData(snapshot.panel) ?? createExecutionPanelState(),
1120
- modelState: snapshot.modelState,
1121
1345
  implItems: snapshot.implItems ?? [],
1122
1346
  implStatus: { ...(snapshot.implStatus ?? {}) },
1347
+ currentI: snapshot.currentI ?? inferCurrentI(snapshot.implItems, snapshot.items, snapshot.implStatus),
1123
1348
  };
1124
1349
  for (let i = snapshotIndex + 1; i < entries.length; i++) {
1125
1350
  const entry = entries[i];
1126
1351
  if (entry.type === "custom" && entry.customType === "pi-plans-exec-cleared") {
1127
1352
  execution = null;
1128
- syncExecutionPanel(ctx);
1129
1353
  break;
1130
1354
  }
1131
1355
  const message = entry.message;
@@ -1136,6 +1360,7 @@ export async function restoreFromSession(pi: ExtensionAPI, ctx: ExtensionContext
1136
1360
  .join("\n");
1137
1361
  applyDoneMarkers(text);
1138
1362
  applyImplMarkers(text);
1363
+ applyCurrentIMarker(text);
1139
1364
  }
1140
1365
  }
1141
1366
  if (execution) {
@@ -1145,6 +1370,5 @@ export async function restoreFromSession(pi: ExtensionAPI, ctx: ExtensionContext
1145
1370
  await completeExecution(pi, ctx);
1146
1371
  }
1147
1372
  }
1148
- syncExecutionPanel(ctx);
1149
1373
  updateStatusWidget(ctx);
1150
1374
  }