pi-plans 0.1.2 → 0.3.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.
Files changed (76) hide show
  1. package/README.md +98 -19
  2. package/index.ts +147 -66
  3. package/package.json +7 -1
  4. package/references/pi-planning-workflow.md +21 -3
  5. package/references/state-and-config.md +35 -3
  6. package/scripts/validate.ts +4 -0
  7. package/src/autocomplete.ts +163 -0
  8. package/src/code-graph/commands.ts +437 -0
  9. package/src/code-graph/discovery.ts +118 -0
  10. package/src/code-graph/git.ts +108 -0
  11. package/src/code-graph/identity.ts +59 -0
  12. package/src/code-graph/indexer.ts +281 -0
  13. package/src/code-graph/materialize.ts +166 -0
  14. package/src/code-graph/mode.ts +28 -0
  15. package/src/code-graph/mutations.ts +160 -0
  16. package/src/code-graph/parser.ts +51 -0
  17. package/src/code-graph/parsers/javascript.ts +35 -0
  18. package/src/code-graph/parsers/python.ts +160 -0
  19. package/src/code-graph/parsers/tree-sitter.ts +316 -0
  20. package/src/code-graph/paths.ts +85 -0
  21. package/src/code-graph/prompts.ts +18 -0
  22. package/src/code-graph/resolver.ts +69 -0
  23. package/src/code-graph/runtime.ts +158 -0
  24. package/src/code-graph/schema.ts +135 -0
  25. package/src/code-graph/screening.ts +82 -0
  26. package/src/code-graph/store.ts +278 -0
  27. package/src/code-graph/summary.ts +435 -0
  28. package/src/code-graph/types.ts +163 -0
  29. package/src/compaction.ts +1256 -0
  30. package/src/config-command.ts +326 -0
  31. package/src/exec.ts +519 -625
  32. package/src/plan.ts +37 -0
  33. package/src/query-hook.ts +82 -0
  34. package/src/refine-prompts.ts +50 -0
  35. package/src/refine-ui-helpers.ts +142 -0
  36. package/src/refine-ui-state.ts +144 -0
  37. package/src/refine-ui.ts +430 -0
  38. package/src/state.ts +24 -29
  39. package/src/subagent.ts +299 -70
  40. package/tests/ask-choice.test.ts +263 -0
  41. package/tests/autocomplete.test.ts +147 -0
  42. package/tests/code-graph-apply.test.ts +185 -0
  43. package/tests/code-graph-commands.test.ts +211 -0
  44. package/tests/code-graph-db.test.ts +166 -0
  45. package/tests/code-graph-discovery.test.ts +38 -0
  46. package/tests/code-graph-git.test.ts +94 -0
  47. package/tests/code-graph-index.test.ts +175 -0
  48. package/tests/code-graph-loop.e2e.test.ts +159 -0
  49. package/tests/code-graph-mutations.test.ts +117 -0
  50. package/tests/code-graph-parser.test.ts +85 -0
  51. package/tests/code-graph-rollback.test.ts +100 -0
  52. package/tests/code-graph-summary-batching.test.ts +518 -0
  53. package/tests/code-graph-summary.test.ts +148 -0
  54. package/tests/compaction.test.ts +388 -0
  55. package/tests/config-command.test.ts +255 -0
  56. package/tests/exec.test.ts +751 -422
  57. package/tests/execute-plan.test.ts +65 -0
  58. package/tests/fixtures/code-graph/sample.js +36 -0
  59. package/tests/fixtures/code-graph/sample.py +20 -0
  60. package/tests/fixtures/code-graph/sample.ts +15 -0
  61. package/tests/graph-aware-file-tools.test.ts +411 -0
  62. package/tests/plan.test.ts +11 -1
  63. package/tests/plans.test.ts +6 -5
  64. package/tests/query-hook.test.ts +82 -0
  65. package/tests/refine-prompts.test.ts +67 -2
  66. package/tests/refine-ui.test.ts +392 -0
  67. package/tests/state.test.ts +12 -15
  68. package/tests/subagent.test.ts +120 -0
  69. package/tools/ask-choice.ts +180 -11
  70. package/tools/code-graph.ts +254 -0
  71. package/tools/execute-plan.ts +7 -39
  72. package/tools/graph-aware-file-tools.ts +392 -0
  73. package/tools/plans.ts +84 -18
  74. package/tools/refine.ts +180 -80
  75. package/src/execution-panel.ts +0 -633
  76. package/tests/execution-panel.test.ts +0 -234
package/src/exec.ts CHANGED
@@ -3,8 +3,8 @@
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
10
  import * as fs from "node:fs";
@@ -17,56 +17,53 @@ import type {
17
17
  SessionCompactEvent,
18
18
  SessionCompactFailedEvent,
19
19
  } from "@earendil-works/pi-coding-agent";
20
+ import { VERSION } from "@earendil-works/pi-coding-agent";
20
21
  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";
22
+ buildPiPlansVccCompaction,
23
+ compactionCurrentI,
24
+ entryCurrentIMarkers,
25
+ formatVccCompactionStats,
26
+ loadVccSettings,
27
+ scaffoldVccSettings,
28
+ shouldScheduleAutoContinue,
29
+ type CompactionEntryLike,
30
+ type PiPlansCompactionPhase,
31
+ type PiPlansVccPhaseContext,
32
+ type PiPlansVccSettings,
33
+ type VccCompactionBuildResult,
34
+ type VccCompactionStats,
35
+ } from "./compaction.ts";
36
+ import { getRun, readActive, resolveStateRootOrNull, setRunStatus, utcNow } from "./state.ts";
37
+ import { graphBlockForExecutor } from "./code-graph/prompts.ts";
38
+ import { resolveGraphMode } from "./code-graph/mode.ts";
36
39
  import {
40
+ extractCoverage,
37
41
  latestPlanVersion,
42
+ resolveImplStatuses,
38
43
  scanDoneMarkers,
39
44
  scanImplMarkers,
45
+ scanCurrentIMarkers,
46
+ resolveCurrentI,
47
+ inferCurrentI,
40
48
  type CheckItem,
41
49
  type ImplItem,
42
50
  type ImplMarkerState,
43
51
  } from "./plan.ts";
44
52
 
45
- export interface ExecState extends ExecutionPanelExecutionLike {
53
+ export interface ExecState {
54
+ planPath: string;
55
+ items: CheckItem[];
46
56
  startedAt: string;
47
- panel?: ExecutionPanelState;
48
57
  usage: { inToks: number; outToks: number };
49
- modelState?: ExecutionModelState;
50
58
  implItems?: ImplItem[];
51
59
  implStatus?: Record<string, ImplMarkerState>;
60
+ currentI?: string;
52
61
  }
53
62
 
54
63
  let execution: ExecState | null = null;
55
64
 
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).
65
+ // Execution-loop persistence is deferred until the agent settles so turn_end
66
+ // never causes session writes during a streaming run.
70
67
  let pendingExecutionFlush = false;
71
68
 
72
69
  export function consumePendingExecutionFlush(): boolean {
@@ -75,25 +72,17 @@ export function consumePendingExecutionFlush(): boolean {
75
72
  return pending;
76
73
  }
77
74
 
78
- function requestExecutionFlush(pi: ExtensionAPI, ctx: ExtensionContext): void {
75
+ function requestExecutionFlush(_pi: ExtensionAPI, _ctx: ExtensionContext): void {
79
76
  // 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.
77
+ // operations where isIdle() reads true; persistence happens only at the
78
+ // drain points: agent_settled, the next before_agent_start, and stop/complete.
84
79
  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
80
  }
90
81
 
91
82
  export function drainExecutionFlush(pi: ExtensionAPI, ctx: ExtensionContext): void {
92
- if (!execution) return;
93
- if (!pendingExecutionFlush && !consumePendingPanelSync()) return;
83
+ if (!execution || !pendingExecutionFlush) return;
94
84
  pendingExecutionFlush = false;
95
85
  persist(pi);
96
- syncExecutionPanel(ctx);
97
86
  updateStatusWidget(ctx);
98
87
  }
99
88
 
@@ -101,187 +90,119 @@ export function getExecution(): ExecState | null {
101
90
  return execution;
102
91
  }
103
92
 
104
- const THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
105
- const CURRENT_SESSION_MODEL_CHOICE = "Inherit current session model";
93
+ const EXECUTION_COMPACTION_RESUME_MESSAGE = "Continue execution.";
106
94
 
107
- export interface ExecutionModelState {
108
- planningSelector: string | null;
109
- executionSelector: string;
95
+ interface ExecutionCompactionState {
96
+ inFlight: boolean;
97
+ resumeGuard: boolean;
98
+ cooldownActive: boolean;
99
+ lastAttemptReason: string | null;
100
+ lastSuccessfulUsagePercent: number | null;
101
+ lastSuccessfulAt: string | null;
102
+ rearmPending: boolean;
103
+ /** Terminal failure metadata is retained for diagnostics, not proactive retry. */
104
+ terminalBackoffTokens: number | null;
105
+ pendingStats: VccCompactionStats | null;
106
+ pendingFollowUpPrompt: string | null;
107
+ pendingContinueAfterThresholdCompact: boolean;
110
108
  }
111
109
 
112
- export interface ModelSelection {
113
- provider: string;
114
- modelId: string;
115
- thinkingLevel: string | null;
116
- selector: string;
117
- model: any;
110
+ type ExecutionCompactionSession = { __executionCompaction?: ExecutionCompactionState };
111
+ type ExecutionCompactionContext = ExtensionContext & { sessionManager?: ExecutionCompactionSession };
112
+
113
+ function getExecutionCompactionSession(ctx: ExtensionContext, create = false): ExecutionCompactionSession | undefined {
114
+ const carrier = ctx as ExecutionCompactionContext;
115
+ if (carrier.sessionManager) return carrier.sessionManager;
116
+ if (!create) return undefined;
117
+ carrier.sessionManager = {};
118
+ return carrier.sessionManager;
118
119
  }
119
120
 
120
- function buildModelSelector(provider: string, modelId: string, thinkingLevel?: string | null): string {
121
- return thinkingLevel ? `${provider}/${modelId}:${thinkingLevel}` : `${provider}/${modelId}`;
121
+ function executionCompactionState(ctx: ExtensionContext): ExecutionCompactionState | undefined {
122
+ return getExecutionCompactionSession(ctx)?.__executionCompaction;
122
123
  }
123
124
 
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
- };
125
+ function ensureExecutionCompactionState(ctx: ExtensionContext): ExecutionCompactionState {
126
+ const session = getExecutionCompactionSession(ctx, true)!;
127
+ return (session.__executionCompaction ??= {
128
+ inFlight: false,
129
+ resumeGuard: false,
130
+ cooldownActive: false,
131
+ lastAttemptReason: null,
132
+ lastSuccessfulUsagePercent: null,
133
+ lastSuccessfulAt: null,
134
+ rearmPending: false,
135
+ terminalBackoffTokens: null,
136
+ pendingStats: null,
137
+ pendingFollowUpPrompt: null,
138
+ pendingContinueAfterThresholdCompact: false,
139
+ });
133
140
  }
134
141
 
135
- export function snapshotCurrentModelSelector(ctx: Pick<ExtensionContext, "model" | "thinkingLevel">): string | null {
136
- return currentModelSelection(ctx)?.selector ?? null;
142
+ function resetExecutionCompactionState(ctx: ExtensionContext): void {
143
+ const session = getExecutionCompactionSession(ctx);
144
+ if (!session) return;
145
+ delete session.__executionCompaction;
137
146
  }
138
147
 
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;
148
+ function consumeExecutionCompactionResumeGuard(ctx: ExtensionContext): boolean {
149
+ const state = executionCompactionState(ctx);
150
+ if (!state?.resumeGuard) return false;
151
+ state.resumeGuard = false;
152
+ return true;
160
153
  }
161
154
 
162
- function listAvailableModelSelections(ctx: ExtensionContext): ModelSelection[] {
163
- const rawSelections = ctx.scopedModels.length > 0
164
- ? ctx.scopedModels.map((entry) => ({
165
- provider: entry.model.provider,
166
- modelId: entry.model.id,
167
- thinkingLevel: entry.thinkingLevel ?? null,
168
- selector: buildModelSelector(entry.model.provider, entry.model.id, entry.thinkingLevel ?? null),
169
- model: entry.model,
170
- }))
171
- : ctx.modelRegistry.getAvailable().map((model) => ({
172
- provider: model.provider,
173
- modelId: model.id,
174
- thinkingLevel: null,
175
- selector: buildModelSelector(model.provider, model.id),
176
- model,
177
- }));
178
- const seen = new Set<string>();
179
- const unique: ModelSelection[] = [];
180
- for (const selection of rawSelections) {
181
- if (seen.has(selection.selector)) continue;
182
- seen.add(selection.selector);
183
- unique.push(selection);
184
- }
185
- return unique;
155
+ export function shouldTriggerExecutionCompaction(_ctx: ExtensionContext): boolean {
156
+ return false;
186
157
  }
187
158
 
188
- export async function applyModelSelection(pi: ExtensionAPI, ctx: ExtensionContext, selection: ModelSelection): Promise<boolean> {
189
- if (ctx.model && ctx.model.provider === selection.provider && ctx.model.id === selection.modelId) {
190
- if (selection.thinkingLevel !== null && ctx.thinkingLevel !== selection.thinkingLevel) {
191
- pi.setThinkingLevel(selection.thinkingLevel as never);
192
- }
193
- return true;
194
- }
195
- const success = await pi.setModel(selection.model);
196
- if (!success) return false;
197
- if (selection.thinkingLevel !== null) {
198
- pi.setThinkingLevel(selection.thinkingLevel as never);
199
- }
200
- return true;
159
+ export function handleExecutionTurnCompaction(ctx: ExtensionContext): void {
160
+ consumeExecutionCompactionResumeGuard(ctx);
201
161
  }
202
162
 
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,
163
+ export function computeExecutionProgress(execution: ExecState): { done: number; total: number } {
164
+ const implItems = execution.implItems ?? [];
165
+ if (implItems.length) {
166
+ const statuses = resolveImplStatuses(implItems, execution.items, execution.implStatus);
167
+ const counted = implItems.filter((impl) =>
168
+ execution.items.some((item) => extractCoverage(item.text).includes(impl.id)),
230
169
  );
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;
170
+ const total = counted.length > 0 ? counted.length : implItems.length;
171
+ const vcDone = counted.filter((impl) => statuses[impl.id] === "vc-passed").length;
172
+ const currentIndex = execution.currentI
173
+ ? implItems.findIndex((impl) => impl.id === execution.currentI)
174
+ : -1;
175
+ return {
176
+ done: Math.min(total, Math.max(vcDone, currentIndex < 0 ? 0 : currentIndex)),
177
+ total,
178
+ };
242
179
  }
180
+ return {
181
+ done: execution.items.filter((item) => item.done).length,
182
+ total: execution.items.length,
183
+ };
184
+ }
243
185
 
244
- if (configuredSelector) {
245
- const resolved = resolveModelSelection(ctx, configuredSelector);
246
- if (resolved) return resolved;
247
- } else {
248
- return current;
249
- }
186
+ function formatElapsed(startedAt: string): string {
187
+ const total = Math.max(0, Math.floor((Date.now() - Date.parse(startedAt)) / 1000));
188
+ const h = String(Math.floor(total / 3600)).padStart(2, "0");
189
+ const m = String(Math.floor((total % 3600) / 60)).padStart(2, "0");
190
+ const sec = String(total % 60).padStart(2, "0");
191
+ return `${h}:${m}:${sec}`;
192
+ }
250
193
 
251
- if (!ctx.hasUI || available.length === 0) {
252
- ctx.ui.notify(`${prompt}: configured execution model "${configuredSelector}" is unavailable; continuing with the current session model.`, "warning");
253
- return current;
254
- }
255
- const choice = await ctx.ui.select(
256
- `${prompt} — "${configuredSelector}" is unavailable. Pick a model:`,
257
- [
258
- CURRENT_SESSION_MODEL_CHOICE,
259
- ...available
260
- .filter((selection) => selection.selector !== current?.selector)
261
- .map((selection) => selection.selector),
262
- ],
263
- );
264
- if (!choice || choice === CURRENT_SESSION_MODEL_CHOICE) {
265
- return current;
266
- }
267
- if (onChosenSelector) {
268
- await onChosenSelector(choice);
269
- }
270
- return resolveModelSelection(ctx, choice) ?? current;
194
+ function formatToks(tokens: number): string {
195
+ const n = Math.max(0, Math.round(tokens));
196
+ return n < 1000 ? String(n) : `${(n / 1000).toFixed(1)}k`;
271
197
  }
272
198
 
273
- export function executionProgress(): { done: number; total: number } | null {
274
- if (!execution) return null;
275
- return computeExecutionProgress(execution);
199
+ export function formatExecutionStatusLine(execution: ExecState): string {
200
+ const progress = computeExecutionProgress(execution);
201
+ return `⌛ plans ${progress.done}/${progress.total}: spent ${formatElapsed(execution.startedAt)} · ${formatToks(execution.usage.inToks)} in-toks · ${formatToks(execution.usage.outToks)} out-toks`;
276
202
  }
277
203
 
278
204
  export function updateStatusWidget(ctx: ExtensionContext): void {
279
205
  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
206
  const line = formatExecutionStatusLine(execution);
286
207
  ctx.ui.setStatus("pi-plans", ctx.ui.theme.fg("accent", line));
287
208
  return;
@@ -326,35 +247,23 @@ function persist(pi: ExtensionAPI): void {
326
247
  items: execution.items,
327
248
  startedAt: execution.startedAt,
328
249
  usage: execution.usage,
329
- panel: snapshotPanelState(execution),
330
250
  implItems: execution.implItems,
251
+ implStatus: execution.implStatus,
252
+ currentI: execution.currentI,
331
253
  });
332
254
  }
333
255
 
334
- export function syncExecutionPanel(ctx: ExtensionContext): void {
335
- if (!execution) {
336
- clearExecutionPanel(ctx);
337
- return;
338
- }
339
- refreshExecutionPanel(ctx, execution);
340
- }
341
-
342
256
  export async function startExecution(
343
257
  pi: ExtensionAPI,
344
258
  ctx: ExtensionContext,
345
259
  planPath: string,
346
260
  items: CheckItem[],
347
- modelState?: ExecutionModelState,
348
261
  implItems?: ImplItem[],
349
262
  ): 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
263
+ execution = { planPath, items, startedAt: utcNow(), usage: { inToks: 0, outToks: 0 }, implItems: implItems ?? [], implStatus: {} };
353
264
  pendingExecutionFlush = false; // fresh run: no inherited flush debt
265
+ resetExecutionCompactionState(ctx);
354
266
  persist(pi);
355
- if (modelState) {
356
- await ensureExecutionModelActive(pi, ctx);
357
- }
358
267
  const active = readActive(ctx.cwd);
359
268
  if (active) {
360
269
  try {
@@ -372,54 +281,22 @@ export async function startExecution(
372
281
  { triggerTurn: false },
373
282
  );
374
283
  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
284
  }
407
285
 
408
286
  /** Record one assistant turn: accumulate usage and mark any completed items. */
409
287
  export function recordExecutionTurn(
410
288
  pi: ExtensionAPI,
411
- ctx: ExtensionContext,
412
- completedIds: string[],
289
+ _ctx: ExtensionContext,
290
+ _completedIds: string[],
413
291
  usage?: { input: number; output: number },
414
- ): ItemDiffSummary | null {
415
- if (!execution) return null;
292
+ ): void {
293
+ if (!execution) return;
416
294
  if (usage) {
417
295
  execution.usage.inToks += usage.input;
418
296
  execution.usage.outToks += usage.output;
419
297
  }
420
- const summary = completeCompletedItems(execution, ctx.cwd, completedIds);
421
- requestExecutionFlush(pi, ctx);
422
- return summary;
298
+ requestExecutionFlush(pi, _ctx);
299
+ updateStatusWidget(_ctx);
423
300
  }
424
301
 
425
302
  export function registerExecutionTurnHandlers(
@@ -448,11 +325,12 @@ export function registerExecutionTurnHandlers(
448
325
  .join("\n");
449
326
  const changedIds = applyDoneMarkers(text);
450
327
  const changedImpls = applyImplMarkers(text);
328
+ const changedCurrentI = applyCurrentIMarker(text);
451
329
  const projection = (event.message as { usage?: { input?: number; output?: number } }).usage;
452
330
  const raw = projection ?? lastAssistantUsage;
453
331
  lastAssistantUsage = null; // consumed: never re-attribute a stale turn
454
332
  const usage = raw ? { input: raw.input ?? 0, output: raw.output ?? 0 } : undefined;
455
- if (usage || changedIds.length > 0 || changedImpls.length > 0) {
333
+ if (usage || changedIds.length > 0 || changedImpls.length > 0 || changedCurrentI) {
456
334
  // Attribute this turn's usage now; `[DONE]` markers still only mark completion.
457
335
  recordExecutionTurn(pi, ctx, changedIds, usage);
458
336
  }
@@ -465,164 +343,182 @@ export function registerExecutionTurnHandlers(
465
343
 
466
344
  const EXECUTION_RESUME_CUSTOM_TYPE = "pi-plans-exec-resume";
467
345
 
468
- type CompactBranchEntry = SessionBeforeCompactEvent["branchEntries"][number];
469
- type CompactMessage = { role?: string; content?: Array<{ type: string; text?: string }> };
470
-
471
- function compactText(text: string, limit = 180): string {
472
- const normalized = text.replace(/\s+/g, " ").trim();
473
- if (normalized.length <= limit) return normalized;
474
- return `${normalized.slice(0, Math.max(0, limit - 1))}…`;
346
+ function activeVccSettings(ctx: ExtensionContext, phase: PiPlansCompactionPhase): { settings: PiPlansVccSettings; runId: string; artifactDir: string } | null {
347
+ const stateRoot = resolveStateRootOrNull(ctx.cwd);
348
+ if (!stateRoot) return null;
349
+ const active = readActive(ctx.cwd);
350
+ if (!active) return null;
351
+ const run = getRun(ctx.cwd, active.run_id);
352
+ if (!run) return null;
353
+ if (phase === "planning" && run.status !== "planning") return null;
354
+ if (phase === "execution" && run.status !== "executing") return null;
355
+ scaffoldVccSettings(stateRoot);
356
+ return { settings: loadVccSettings(stateRoot), runId: run.run_id, artifactDir: run.artifact_dir };
475
357
  }
476
358
 
477
- function messageText(message: CompactMessage | undefined): string {
478
- if (!message) return "";
479
- return (message.content ?? [])
480
- .filter((part) => part.type === "text")
481
- .map((part) => part.text ?? "")
482
- .join("\n")
483
- .trim();
359
+ function executionVccContext(): PiPlansVccPhaseContext {
360
+ return {
361
+ phase: "execution",
362
+ planPath: execution?.planPath ?? null,
363
+ currentI: execution?.currentI ?? null,
364
+ remainingVerifierIds: execution?.items.filter((item) => !item.done).map((item) => item.id) ?? [],
365
+ implementationIds: execution?.implItems?.map((item) => item.id) ?? [],
366
+ };
484
367
  }
485
368
 
486
- function messageRoleLabel(role?: string): string {
487
- switch (role) {
488
- case "assistant": return "Assistant";
489
- case "user": return "User";
490
- case "toolResult": return "Tool";
491
- case "custom": return "Custom";
492
- default: return role ? role : "Message";
369
+ function planningVccContext(branchEntries: CompactionEntryLike[], fallback: { runId?: string; artifactDir?: string }): PiPlansVccPhaseContext {
370
+ let runId: string | null = fallback.runId ?? null;
371
+ let artifactDir: string | null = fallback.artifactDir ?? null;
372
+ let planPath: string | null = null;
373
+ let currentI: string | null = null;
374
+ for (const entry of branchEntries) {
375
+ if (entry.type === "custom" && entry.customType === PLANNING_RUN_START_CUSTOM_TYPE) {
376
+ runId = typeof entry.data?.runId === "string" ? entry.data.runId : runId;
377
+ artifactDir = typeof entry.data?.artifactDir === "string" ? entry.data.artifactDir : artifactDir;
378
+ }
379
+ if (entry.type === "custom" && entry.customType === PLANNING_PLAN_WRITTEN_CUSTOM_TYPE) {
380
+ planPath = typeof entry.data?.planPath === "string" ? entry.data.planPath : planPath;
381
+ }
382
+ for (const id of entryCurrentIMarkers(entry)) currentI = id;
383
+ currentI = compactionCurrentI(entry) ?? currentI;
493
384
  }
385
+ return { phase: "planning", runId, artifactDir, planPath, currentI };
494
386
  }
495
387
 
496
- function isInternalExecutionCustomType(customType?: string): boolean {
497
- return customType === "pi-plans-exec"
498
- || customType === "pi-plans-exec-cleared"
499
- || customType === "pi-plans-exec-start"
500
- || customType === "pi-plans-exec-context"
501
- || customType === EXECUTION_RESUME_CUSTOM_TYPE;
502
- }
503
-
504
- function isSummarizableEntry(entry: CompactBranchEntry): boolean {
505
- return entry.type === "message" && !!entry.message;
506
- }
507
-
508
- function renderMessageLine(entry: CompactBranchEntry): string {
509
- if (!isSummarizableEntry(entry)) return "";
510
- const text = messageText(entry.message);
511
- if (!text) return "";
512
- return `- [${messageRoleLabel(entry.message?.role)}] ${compactText(text)}`;
388
+ function buildExecutionVccResult(event: SessionBeforeCompactEvent, ctx: ExtensionContext): VccCompactionBuildResult | null {
389
+ if (!execution) return null;
390
+ const active = activeVccSettings(ctx, "execution");
391
+ if (!active) return null;
392
+ return buildPiPlansVccCompaction({
393
+ branchEntries: event.branchEntries as unknown as CompactionEntryLike[],
394
+ preparation: event.preparation,
395
+ customInstructions: event.customInstructions,
396
+ reason: event.reason,
397
+ willRetry: event.willRetry,
398
+ settings: active.settings,
399
+ phaseContext: executionVccContext(),
400
+ });
513
401
  }
514
402
 
515
- function findExecutionCompactionCutEntryId(branchEntries: CompactBranchEntry[], fallback: string): string {
516
- const completionIds = new Set(execution?.items.filter((item) => item.done).map((item) => item.id) ?? []);
517
- let lastCompletionIndex = -1;
518
- for (let i = 0; i < branchEntries.length; i++) {
519
- const entry = branchEntries[i];
520
- if (!isSummarizableEntry(entry)) continue;
521
- const markers = scanDoneMarkers(messageText(entry.message));
522
- if (markers.some((marker) => completionIds.has(marker))) {
523
- lastCompletionIndex = i;
524
- }
525
- }
526
- if (lastCompletionIndex >= 0) {
527
- const next = branchEntries.slice(lastCompletionIndex + 1).find((entry) => entry.id && !isInternalExecutionCustomType(entry.customType));
528
- if (next?.id) return next.id;
529
- }
530
- const startIndex = branchEntries.findIndex((entry) => entry.type === "custom" && entry.customType === "pi-plans-exec-start");
531
- if (startIndex >= 0) {
532
- const next = branchEntries.slice(startIndex + 1).find((entry) => entry.id && !isInternalExecutionCustomType(entry.customType));
533
- if (next?.id) return next.id;
534
- }
535
- return fallback;
403
+ export function buildExecutionCompactionResult(event: SessionBeforeCompactEvent, ctx: ExtensionContext): CompactionResult | null {
404
+ const built = buildExecutionVccResult(event, ctx);
405
+ return built?.kind === "compaction" ? built.compaction : null;
536
406
  }
537
407
 
538
- function buildFinishedItemSections(summaryEntries: CompactBranchEntry[]): string[] {
539
- if (!execution) return [];
540
- const completedItems = execution.items.filter((item) => item.done);
541
- const sections: string[] = [];
542
- let completedIndex = 0;
543
- let currentLines: string[] = [];
544
- for (const entry of summaryEntries) {
545
- if (!isSummarizableEntry(entry)) continue;
546
- const line = renderMessageLine(entry);
547
- if (line) currentLines.push(line);
548
- for (const marker of scanDoneMarkers(messageText(entry.message))) {
549
- const itemIndex = completedItems.findIndex((item, index) => index >= completedIndex && item.id === marker);
550
- if (itemIndex < 0) continue;
551
- const item = completedItems[itemIndex];
552
- sections.push(`### \`${item.id}\` ${item.text.split(";")[0]}
553
- ${currentLines.length ? currentLines.join("\n") : "- (no transcript captured)"}`);
554
- currentLines = [];
555
- completedIndex = itemIndex + 1;
556
- }
408
+ export function handleExecutionBeforeCompact(
409
+ pi: ExtensionAPI,
410
+ ctx: ExtensionContext,
411
+ event: SessionBeforeCompactEvent,
412
+ ): SessionBeforeCompactResult | undefined {
413
+ if (!execution) return undefined;
414
+ const state = ensureExecutionCompactionState(ctx);
415
+ state.inFlight = true;
416
+ state.lastAttemptReason = event.reason;
417
+ state.pendingStats = null;
418
+ state.pendingFollowUpPrompt = null;
419
+ state.pendingContinueAfterThresholdCompact = false;
420
+ let built: VccCompactionBuildResult | null;
421
+ try {
422
+ built = buildExecutionVccResult(event, ctx);
423
+ } catch (error) {
424
+ state.inFlight = false;
425
+ ctx.ui.notify(`pi-plans: VCC compaction preparation failed; using Pi default compaction (${String(error)}).`, "warning");
426
+ return undefined;
557
427
  }
558
- return sections;
559
- }
560
-
561
- export function buildExecutionCompactionResult(event: SessionBeforeCompactEvent, ctx: ExtensionContext): CompactionResult | null {
562
- if (!execution) return null;
563
- const active = readActive(ctx.cwd);
564
- const run = active ? getRun(ctx.cwd, active.run_id) : null;
565
- const firstKeptEntryId = findExecutionCompactionCutEntryId(event.branchEntries as CompactBranchEntry[], event.preparation.firstKeptEntryId);
566
- const boundaryIndex = event.branchEntries.findIndex((entry) => entry.id === firstKeptEntryId);
567
- const summaryEntries = boundaryIndex >= 0
568
- ? (event.branchEntries.slice(0, boundaryIndex) as CompactBranchEntry[])
569
- : (event.branchEntries as CompactBranchEntry[]);
570
- const sections = buildFinishedItemSections(summaryEntries);
571
- const parts: string[] = [];
572
- if (event.customInstructions?.trim()) {
573
- parts.push(`## Compact Instructions
574
- ${compactText(event.customInstructions, 1000)}`);
428
+ if (!built || built.kind === "fallback") {
429
+ state.inFlight = false;
430
+ return undefined;
575
431
  }
576
- parts.push(`## Plan Before This Run
577
- - Request: ${compactText(run?.request_text ?? execution.planPath, 280)}
578
- - Plan file: \`${execution.planPath}\``);
579
- if (event.preparation.previousSummary?.trim()) {
580
- parts.push(`## Previous Compact Summary
581
- ${event.preparation.previousSummary.trim()}`);
432
+ if (built.kind === "cancel") {
433
+ state.inFlight = false;
434
+ ctx.ui.notify(built.message, "warning");
435
+ return { cancel: true };
582
436
  }
583
- parts.push(`## Finished VC Items
584
- ${sections.length ? sections.join("\n\n") : "- (none yet)"}`);
585
- parts.push(`## Current Work
586
- - Raw tail preserved from \`${firstKeptEntryId}\` onward.${event.preparation.isSplitTurn ? "\n- Split-turn prefix remains in the kept tail." : ""}`);
587
- return {
588
- summary: parts.join("\n\n"),
589
- firstKeptEntryId,
590
- tokensBefore: event.preparation.tokensBefore,
591
- details: {
592
- kind: "pi-plans-execution-compaction",
593
- reason: event.reason,
594
- willRetry: event.willRetry,
595
- finishedItems: execution.items.filter((item) => item.done).map((item) => item.id),
596
- },
597
- };
437
+ state.pendingStats = built.stats;
438
+ state.pendingFollowUpPrompt = built.followUpPrompt;
439
+ state.pendingContinueAfterThresholdCompact = built.settings.continueAfterThresholdCompact;
440
+ requestExecutionFlush(pi, ctx);
441
+ return { compaction: built.compaction };
598
442
  }
599
443
 
600
- export function handleExecutionBeforeCompact(pi: ExtensionAPI, ctx: ExtensionContext, event: SessionBeforeCompactEvent): SessionBeforeCompactResult | undefined {
601
- if (!execution) return undefined;
602
- const compaction = buildExecutionCompactionResult(event, ctx);
603
- if (!compaction) return undefined;
604
- requestExecutionFlush(pi, ctx);
605
- return { compaction };
444
+ function runtimePiVersion(ctx: ExtensionContext): unknown {
445
+ return (ctx as ExtensionContext & { piVersion?: unknown }).piVersion ?? VERSION;
606
446
  }
607
447
 
608
- export function handleExecutionCompact(pi: ExtensionAPI, ctx: ExtensionContext, event: SessionCompactEvent): void {
448
+ export async function handleExecutionCompact(pi: ExtensionAPI, ctx: ExtensionContext, event: SessionCompactEvent): Promise<void> {
609
449
  if (!execution) return;
610
- if (!event.willRetry) {
611
- pi.sendMessage(
612
- {
613
- customType: EXECUTION_RESUME_CUSTOM_TYPE,
614
- content: "Continue execution.",
615
- display: false,
616
- },
617
- { triggerTurn: true },
618
- );
450
+ const state = ensureExecutionCompactionState(ctx);
451
+ const stats = state.pendingStats;
452
+ const followUpPrompt = state.pendingFollowUpPrompt;
453
+ const continueAfterThresholdCompact = state.pendingContinueAfterThresholdCompact;
454
+ state.pendingStats = null;
455
+ state.pendingFollowUpPrompt = null;
456
+ state.pendingContinueAfterThresholdCompact = false;
457
+ state.inFlight = false;
458
+ state.lastAttemptReason = event.reason;
459
+ state.cooldownActive = true;
460
+ state.rearmPending = false;
461
+ state.terminalBackoffTokens = null;
462
+ state.lastSuccessfulAt = utcNow();
463
+ state.lastSuccessfulUsagePercent = ctx.getContextUsage()?.percent ?? state.lastSuccessfulUsagePercent;
464
+ state.resumeGuard = false;
465
+ if (!event.willRetry && stats) {
466
+ ctx.ui.notify(formatVccCompactionStats(stats), "info");
467
+ if (followUpPrompt) {
468
+ await pi.sendUserMessage?.(followUpPrompt);
469
+ } else if ((event.reason === "threshold" || event.reason === "overflow") && shouldScheduleAutoContinue(continueAfterThresholdCompact, runtimePiVersion(ctx))) {
470
+ state.resumeGuard = true;
471
+ pi.sendMessage(
472
+ {
473
+ customType: EXECUTION_RESUME_CUSTOM_TYPE,
474
+ content: EXECUTION_COMPACTION_RESUME_MESSAGE,
475
+ display: false,
476
+ },
477
+ { triggerTurn: true },
478
+ );
479
+ }
619
480
  }
620
481
  requestExecutionFlush(pi, ctx);
621
482
  updateStatusWidget(ctx);
622
483
  }
623
484
 
485
+
624
486
  export function handleExecutionCompactFailed(pi: ExtensionAPI, ctx: ExtensionContext, event: SessionCompactFailedEvent): void {
625
487
  if (!execution) return;
488
+ const state = executionCompactionState(ctx);
489
+ const terminal = isTerminalCompactionFailure(event);
490
+ if (terminal) {
491
+ // Pi refused or aborted the compaction. Hold the cooldown and re-arm
492
+ // only after real growth or high-watermark pressure so the loop stops.
493
+ if (state) {
494
+ state.inFlight = false;
495
+ state.resumeGuard = false;
496
+ state.cooldownActive = true;
497
+ state.rearmPending = false;
498
+ state.lastAttemptReason = event.reason;
499
+ const tokens = ctx.getContextUsage()?.tokens;
500
+ state.terminalBackoffTokens = typeof tokens === "number" ? tokens : Number.POSITIVE_INFINITY;
501
+ state.pendingStats = null;
502
+ state.pendingFollowUpPrompt = null;
503
+ state.pendingContinueAfterThresholdCompact = false;
504
+ }
505
+ const message = terminal.kind === "content"
506
+ ? "pi-plans: compaction found nothing to summarize; backing off until the session grows past the keep-recent window."
507
+ : "pi-plans: compaction was aborted (provider interruption, user cancel, or a competing manual compact); backing off until the session grows or usage nears the window.";
508
+ ctx.ui.notify(message, "info");
509
+ requestExecutionFlush(pi, ctx);
510
+ return;
511
+ }
512
+ if (state) {
513
+ state.inFlight = false;
514
+ state.resumeGuard = false;
515
+ state.cooldownActive = false;
516
+ state.rearmPending = false;
517
+ state.lastAttemptReason = event.reason;
518
+ state.pendingStats = null;
519
+ state.pendingFollowUpPrompt = null;
520
+ state.pendingContinueAfterThresholdCompact = false;
521
+ }
626
522
  ctx.ui.notify(
627
523
  `pi-plans: compaction failed (${event.reason}); execution remains active and will wait for the next eligible turn.`,
628
524
  "warning",
@@ -635,15 +531,14 @@ export function filterExecutionResumeMessages<T extends { customType?: string }>
635
531
  }
636
532
 
637
533
  // ---------------------------------------------------------------------------
638
- // Planning-phase auto compaction: same trigger rules and resume pattern as
639
- // the execution side, but with a different cut-point algorithm and summary
640
- // shape. The two state machines are kept independent (different memory slot
641
- // and snapshot key) so execution never bleeds into planning.
534
+ // Planning-phase compaction: Pi core owns scheduling; this hook customizes
535
+ // active planning compact events with the same VCC builder used by execution.
536
+ // The two state machines are kept independent (different memory slot and
537
+ // snapshot key) so execution never bleeds into planning.
642
538
  // ---------------------------------------------------------------------------
643
539
 
644
540
  export const PLANNING_RUN_START_CUSTOM_TYPE = "pi-plans-run-start";
645
541
  export const PLANNING_PLAN_WRITTEN_CUSTOM_TYPE = "pi-plans-plan-written";
646
- const PLANNING_QA_SECTION_HEADER = "## Q&A During Planning";
647
542
  const PLANNING_RESUME_CUSTOM_TYPE = "pi-plans-plan-resume";
648
543
 
649
544
  interface PlanningCompactionState {
@@ -653,107 +548,114 @@ interface PlanningCompactionState {
653
548
  lastAttemptReason: "manual" | "threshold" | "overflow" | null;
654
549
  lastSuccessfulUsagePercent: number | null;
655
550
  lastSuccessfulAt: string | null;
551
+ /** Terminal "nothing to compact" backoff: tokens observed when Pi refused. */
552
+ terminalBackoffTokens: number | null;
553
+ pendingStats: VccCompactionStats | null;
554
+ pendingFollowUpPrompt: string | null;
555
+ pendingContinueAfterThresholdCompact: boolean;
656
556
  }
657
557
 
658
- interface PlanningBranchEntry {
659
- id?: string;
660
- type?: string;
661
- customType?: string;
662
- data?: { planPath?: string; runId?: string; artifactDir?: string };
663
- message?: { role?: string; content?: Array<{ type: string; text?: string }> };
558
+ function ensurePlanningCompactionState(ctx: ExtensionContext): PlanningCompactionState {
559
+ const session = ctx.sessionManager as unknown as { __planningCompaction?: PlanningCompactionState };
560
+ return (session.__planningCompaction ??= {
561
+ inFlight: false,
562
+ resumeGuard: false,
563
+ cooldownActive: false,
564
+ lastAttemptReason: null,
565
+ lastSuccessfulUsagePercent: null,
566
+ lastSuccessfulAt: null,
567
+ terminalBackoffTokens: null,
568
+ pendingStats: null,
569
+ pendingFollowUpPrompt: null,
570
+ pendingContinueAfterThresholdCompact: false,
571
+ });
664
572
  }
665
573
 
666
- function isPlanningInternalCustomType(customType?: string): boolean {
667
- return (
668
- customType === "pi-plans-exec"
669
- || customType === "pi-plans-exec-cleared"
670
- || customType === "pi-plans-exec-start"
671
- || customType === "pi-plans-exec-context"
672
- || customType === EXECUTION_RESUME_CUSTOM_TYPE
673
- || customType === PLANNING_RUN_START_CUSTOM_TYPE
674
- || customType === PLANNING_PLAN_WRITTEN_CUSTOM_TYPE
675
- || customType === PLANNING_RESUME_CUSTOM_TYPE
676
- );
574
+ function isTerminalCompactionFailure(event: { errorMessage?: string; aborted?: boolean }): { kind: "content" | "abort-stream" } | null {
575
+ const message = (event.errorMessage ?? "").toLowerCase();
576
+ if (message.includes("nothing to compact") || message.includes("already compacted") || message.includes("session too small")) {
577
+ return { kind: "content" };
578
+ }
579
+ // abort/stream class: explicit event names only, so that provider blips
580
+ // (network down, etc.) stay retryable.
581
+ const abortPatterns = [
582
+ "this operation was aborted",
583
+ "aborted",
584
+ "stream ended before a terminal response event",
585
+ "turn prefix summarization failed",
586
+ "auto-compaction failed",
587
+ "context overflow recovery failed",
588
+ ];
589
+ if (abortPatterns.some((pattern) => message.includes(pattern))) {
590
+ return { kind: "abort-stream" };
591
+ }
592
+ // Aborted with no recognized message: still an abort-class terminal so the
593
+ // next eligible turn does not immediately retry the same operation.
594
+ if (event.aborted === true) {
595
+ return { kind: "abort-stream" };
596
+ }
597
+ return null;
677
598
  }
678
599
 
679
- function summarizePlanningMessageLine(entry: PlanningBranchEntry): string | null {
680
- if (!entry.message) return null;
681
- const text = (entry.message.content ?? [])
682
- .filter((part) => part.type === "text")
683
- .map((part) => part.text ?? "")
684
- .join("\n")
685
- .trim();
686
- if (!text) return null;
687
- const role = entry.message.role ?? "message";
688
- const normalized = text.replace(/\s+/g, " ").trim();
689
- const limit = 180;
690
- const clipped = normalized.length > limit ? `${normalized.slice(0, limit - 1)}…` : normalized;
691
- return `- [${role}] ${clipped}`;
600
+ /** Session-scoped phase-local "compaction in flight" guard.
601
+ * - Set on `session_before_compact` for the phase attributed by the custom
602
+ * instructions hint; auto-compaction (no hint) marks both phases defensively.
603
+ * - Cleared on `session_compact` and `session_compact_failed`.
604
+ * - Retained so lifecycle events expose the same phase-local state to tests
605
+ * and future Pi core schema additions. */
606
+ type CompactionPhase = "planning" | "execution";
607
+
608
+ function compactionLifecycleStore(ctx: ExtensionContext): {
609
+ planning: boolean;
610
+ execution: boolean;
611
+ } {
612
+ const carrier = ctx.sessionManager as unknown as {
613
+ __piPlansCompactionInFlight?: { planning: boolean; execution: boolean };
614
+ };
615
+ carrier.__piPlansCompactionInFlight ??= { planning: false, execution: false };
616
+ return carrier.__piPlansCompactionInFlight;
692
617
  }
693
618
 
694
- function findPlanningCutEntryId(
695
- branchEntries: PlanningBranchEntry[],
696
- fallback: string,
697
- ): { id: string; qaWindowEntries: PlanningBranchEntry[]; hasMarker: boolean } {
698
- const planWrittenIndexes: number[] = [];
699
- const runStartIndexes: number[] = [];
700
- for (let i = 0; i < branchEntries.length; i++) {
701
- const entry = branchEntries[i];
702
- if (entry.type === "custom" && entry.customType === PLANNING_PLAN_WRITTEN_CUSTOM_TYPE) {
703
- planWrittenIndexes.push(i);
704
- } else if (entry.type === "custom" && entry.customType === PLANNING_RUN_START_CUSTOM_TYPE) {
705
- runStartIndexes.push(i);
706
- }
707
- }
708
- const planIndex = planWrittenIndexes[planWrittenIndexes.length - 1] ?? -1;
709
- const startIndex = runStartIndexes[runStartIndexes.length - 1] ?? -1;
710
- const anchorIndex = planIndex >= 0 ? planIndex : startIndex;
711
- if (anchorIndex < 0) {
712
- return { id: fallback, qaWindowEntries: [], hasMarker: false };
713
- }
714
- const next = branchEntries
715
- .slice(anchorIndex + 1)
716
- .find((entry) => entry.id && !isPlanningInternalCustomType(entry.customType));
717
- if (!next?.id) {
718
- return { id: fallback, qaWindowEntries: [], hasMarker: true };
719
- }
720
- const qaWindowEntries = branchEntries
721
- .slice(startIndex >= 0 ? startIndex + 1 : 0, anchorIndex)
722
- .filter((entry) => entry.type === "message" && entry.message);
723
- return { id: next.id, qaWindowEntries, hasMarker: true };
619
+ function isPlanningCustomInstructions(hint: unknown): boolean {
620
+ return typeof hint === "string" && hint.startsWith("pi-plans planning");
621
+ }
622
+
623
+ function isExecutionCustomInstructions(hint: unknown): boolean {
624
+ return typeof hint === "string" && hint.startsWith("pi-plans execution");
724
625
  }
725
626
 
726
- function buildPlanningQASection(qaWindowEntries: PlanningBranchEntry[]): string | null {
727
- if (!qaWindowEntries.length) return null;
728
- const lines: string[] = [];
729
- for (const entry of qaWindowEntries) {
730
- const line = summarizePlanningMessageLine(entry);
731
- if (line) lines.push(line);
627
+ export function noteCompactionStarted(ctx: ExtensionContext, customInstructions: unknown): void {
628
+ const store = compactionLifecycleStore(ctx);
629
+ if (isPlanningCustomInstructions(customInstructions)) {
630
+ store.planning = true;
631
+ } else if (isExecutionCustomInstructions(customInstructions)) {
632
+ store.execution = true;
633
+ } else {
634
+ // Auto-compaction (threshold/overflow/manual without our hint) marks both.
635
+ store.planning = true;
636
+ store.execution = true;
732
637
  }
733
- if (!lines.length) return null;
734
- return `${PLANNING_QA_SECTION_HEADER}
735
- ${lines.join("\n")}`;
736
638
  }
737
639
 
738
- function resolvePlanningCompactionContext(workdir: string): { runId: string; artifactDir: string } | null {
739
- const active = readActive(workdir);
740
- if (!active) return null;
741
- const run = getRun(workdir, active.run_id);
742
- if (!run || run.status !== "planning") return null;
743
- return { runId: run.run_id, artifactDir: run.artifact_dir };
640
+ /** Pi core's `SessionCompactEvent` / `SessionCompactFailedEvent` do not carry
641
+ * `customInstructions` in any emission site, so the END side has no way to
642
+ * know which phase the compaction belonged to. Clearing both phases is the
643
+ * safe default — the per-phase start side (above) already encodes the hint
644
+ * attribution. The hint parameter is retained for API symmetry and future
645
+ * Pi core schema additions. */
646
+ export function noteCompactionEnded(ctx: ExtensionContext, _customInstructions: unknown): void {
647
+ const store = compactionLifecycleStore(ctx);
648
+ store.planning = false;
649
+ store.execution = false;
744
650
  }
745
651
 
746
- export function shouldTriggerPlanningCompaction(ctx: ExtensionContext): boolean {
747
- if (getExecution()) return false;
748
- const ctxWorkdir = ctx.cwd;
749
- if (!resolvePlanningCompactionContext(ctxWorkdir)) return false;
750
- const percent = ctx.getContextUsage()?.percent ?? null;
751
- if (percent === null || percent < 100) return false;
752
- const session = ctx.sessionManager as unknown as { __planningCompaction?: PlanningCompactionState };
753
- const state = session.__planningCompaction;
754
- if (!state) return true;
755
- if (state.inFlight || state.resumeGuard || state.cooldownActive) return false;
756
- return true;
652
+ export function compactionInFlight(ctx: ExtensionContext, phase: CompactionPhase): boolean {
653
+ const store = compactionLifecycleStore(ctx);
654
+ return store[phase];
655
+ }
656
+
657
+ export function shouldTriggerPlanningCompaction(_ctx: ExtensionContext): boolean {
658
+ return false;
757
659
  }
758
660
 
759
661
  export function consumePlanningCompactionResumeGuard(ctx: ExtensionContext): boolean {
@@ -763,78 +665,34 @@ export function consumePlanningCompactionResumeGuard(ctx: ExtensionContext): boo
763
665
  return true;
764
666
  }
765
667
 
766
- export function refreshPlanningCompactionCooldown(ctx: ExtensionContext): void {
767
- const session = ctx.sessionManager as unknown as { __planningCompaction?: PlanningCompactionState };
768
- const state = session.__planningCompaction;
769
- if (!state) return;
770
- const percent = ctx.getContextUsage()?.percent ?? null;
771
- if (percent !== null && percent < 85 && state.cooldownActive) {
772
- state.cooldownActive = false;
773
- }
668
+ export function refreshPlanningCompactionCooldown(_ctx: ExtensionContext): void {
669
+ // Pi core owns scheduling; retained for lifecycle compatibility only.
774
670
  }
775
671
 
776
- export function requestPlanningCompaction(ctx: ExtensionContext): void {
777
- const session = ctx.sessionManager as unknown as { __planningCompaction?: PlanningCompactionState };
778
- const state = (session.__planningCompaction ??= {
779
- inFlight: false,
780
- resumeGuard: false,
781
- cooldownActive: false,
782
- lastAttemptReason: null,
783
- lastSuccessfulUsagePercent: null,
784
- lastSuccessfulAt: null,
785
- } satisfies PlanningCompactionState);
786
- if (state.inFlight || state.resumeGuard) return;
787
- state.inFlight = true;
788
- state.lastAttemptReason = "threshold";
789
- ctx.compact({ customInstructions: "pi-plans planning auto compact" });
672
+ export function requestPlanningCompaction(_ctx: ExtensionContext): void {
673
+ // Proactive pi-plans compaction is intentionally disabled. Manual,
674
+ // threshold, and overflow compactions are handled by session_before_compact.
675
+ }
676
+
677
+ function buildPlanningVccResult(event: SessionBeforeCompactEvent, ctx: ExtensionContext): VccCompactionBuildResult | null {
678
+ if (getExecution()) return null;
679
+ const active = activeVccSettings(ctx, "planning");
680
+ if (!active) return null;
681
+ const branchEntries = event.branchEntries as unknown as CompactionEntryLike[];
682
+ return buildPiPlansVccCompaction({
683
+ branchEntries,
684
+ preparation: event.preparation,
685
+ customInstructions: event.customInstructions,
686
+ reason: event.reason,
687
+ willRetry: event.willRetry,
688
+ settings: active.settings,
689
+ phaseContext: planningVccContext(branchEntries, active),
690
+ });
790
691
  }
791
692
 
792
693
  export function buildPlanningCompactionResult(event: SessionBeforeCompactEvent, ctx: ExtensionContext): CompactionResult | null {
793
- const ctxWorkdir = ctx.cwd;
794
- const planningCtx = resolvePlanningCompactionContext(ctxWorkdir);
795
- if (!planningCtx) return null;
796
- const branchEntries = event.branchEntries as unknown as PlanningBranchEntry[];
797
- const { id: firstKeptEntryId, qaWindowEntries, hasMarker } = findPlanningCutEntryId(branchEntries, event.preparation.firstKeptEntryId);
798
- const qaSection = hasMarker ? buildPlanningQASection(qaWindowEntries) : null;
799
- const parts: string[] = [];
800
- if (event.customInstructions?.trim()) {
801
- parts.push(`## Compact Instructions
802
- ${compactText(event.customInstructions, 1000)}`);
803
- }
804
- if (qaSection) {
805
- parts.push(qaSection);
806
- }
807
- const previousSummary = event.preparation.previousSummary?.trim();
808
- parts.push(`## Goal
809
- ${compactText(planningCtx.runId, 80)} — keep current planning progress.`);
810
- parts.push(`## Constraints & Preferences
811
- - Stay in the active planning run (\`${planningCtx.runId}\`).
812
- - Plan files live under \`${planningCtx.artifactDir}\`.`);
813
- parts.push(`## Progress
814
- ### Done
815
- - Pre-plan history compressed below.
816
-
817
- ### In Progress
818
- - Current planning question or open decision.
819
-
820
- ### Blocked
821
- - ${hasMarker ? "None" : "Planning cut-point marker missing; falling back to default."}`);
822
- if (previousSummary) {
823
- parts.push(`## Previous Compact Summary
824
- ${previousSummary}`);
825
- }
826
- parts.push(`## Next Steps
827
- - Resume the active planning turn from the raw tail.`);
828
- return {
829
- summary: parts.join("\n\n"),
830
- firstKeptEntryId,
831
- tokensBefore: event.preparation.tokensBefore,
832
- details: {
833
- kind: "pi-plans-planning-compaction",
834
- reason: event.reason,
835
- hasMarker,
836
- },
837
- };
694
+ const built = buildPlanningVccResult(event, ctx);
695
+ return built?.kind === "compaction" ? built.compaction : null;
838
696
  }
839
697
 
840
698
  export function handlePlanningBeforeCompact(
@@ -843,74 +701,103 @@ export function handlePlanningBeforeCompact(
843
701
  event: SessionBeforeCompactEvent,
844
702
  ): SessionBeforeCompactResult | undefined {
845
703
  if (getExecution()) return undefined;
846
- if (!resolvePlanningCompactionContext(ctx.cwd)) return undefined;
847
- const session = ctx.sessionManager as unknown as { __planningCompaction?: PlanningCompactionState };
848
- const state = (session.__planningCompaction ??= {
849
- inFlight: false,
850
- resumeGuard: false,
851
- cooldownActive: false,
852
- lastAttemptReason: null,
853
- lastSuccessfulUsagePercent: null,
854
- lastSuccessfulAt: null,
855
- } satisfies PlanningCompactionState);
856
- const percent = ctx.getContextUsage()?.percent ?? null;
857
- if (event.reason === "threshold" && (percent === null || percent < 100)) {
858
- state.inFlight = false;
859
- state.lastAttemptReason = event.reason;
860
- return { cancel: true };
861
- }
704
+ const state = ensurePlanningCompactionState(ctx);
862
705
  state.inFlight = true;
863
706
  state.lastAttemptReason = event.reason;
864
- if (percent !== null && percent < 85) {
865
- state.cooldownActive = false;
707
+ state.pendingStats = null;
708
+ state.pendingFollowUpPrompt = null;
709
+ state.pendingContinueAfterThresholdCompact = false;
710
+ let built: VccCompactionBuildResult | null;
711
+ try {
712
+ built = buildPlanningVccResult(event, ctx);
713
+ } catch (error) {
714
+ state.inFlight = false;
715
+ ctx.ui.notify(`pi-plans: VCC planning compaction preparation failed; using Pi default compaction (${String(error)}).`, "warning");
716
+ return undefined;
866
717
  }
867
- const compaction = buildPlanningCompactionResult(event, ctx);
868
- if (!compaction) {
718
+ if (!built || built.kind === "fallback") {
869
719
  state.inFlight = false;
870
720
  return undefined;
871
721
  }
872
- return { compaction };
722
+ if (built.kind === "cancel") {
723
+ state.inFlight = false;
724
+ ctx.ui.notify(built.message, "warning");
725
+ return { cancel: true };
726
+ }
727
+ state.pendingStats = built.stats;
728
+ state.pendingFollowUpPrompt = built.followUpPrompt;
729
+ state.pendingContinueAfterThresholdCompact = built.settings.continueAfterThresholdCompact;
730
+ return { compaction: built.compaction };
873
731
  }
874
732
 
875
- export function handlePlanningCompact(pi: ExtensionAPI, ctx: ExtensionContext, event: SessionCompactEvent): void {
733
+ export async function handlePlanningCompact(pi: ExtensionAPI, ctx: ExtensionContext, event: SessionCompactEvent): Promise<void> {
876
734
  if (getExecution()) return;
877
735
  const session = ctx.sessionManager as unknown as { __planningCompaction?: PlanningCompactionState };
878
736
  const state = session.__planningCompaction;
879
737
  if (!state) return;
738
+ const stats = state.pendingStats;
739
+ const followUpPrompt = state.pendingFollowUpPrompt;
740
+ const continueAfterThresholdCompact = state.pendingContinueAfterThresholdCompact;
741
+ state.pendingStats = null;
742
+ state.pendingFollowUpPrompt = null;
743
+ state.pendingContinueAfterThresholdCompact = false;
880
744
  state.inFlight = false;
881
745
  state.lastAttemptReason = event.reason;
746
+ state.terminalBackoffTokens = null;
882
747
  state.cooldownActive = true;
883
748
  state.lastSuccessfulAt = utcNow();
884
749
  state.lastSuccessfulUsagePercent = ctx.getContextUsage()?.percent ?? state.lastSuccessfulUsagePercent;
885
- if (!event.willRetry) {
886
- state.resumeGuard = true;
887
- pi.sendMessage(
888
- {
889
- customType: PLANNING_RESUME_CUSTOM_TYPE,
890
- content: "Continue planning.",
891
- display: false,
892
- },
893
- { triggerTurn: true },
894
- );
895
- } else {
896
- state.resumeGuard = false;
750
+ state.resumeGuard = false;
751
+ if (!event.willRetry && stats) {
752
+ ctx.ui.notify(formatVccCompactionStats(stats), "info");
753
+ if (followUpPrompt) {
754
+ await pi.sendUserMessage?.(followUpPrompt);
755
+ } else if ((event.reason === "threshold" || event.reason === "overflow") && shouldScheduleAutoContinue(continueAfterThresholdCompact, runtimePiVersion(ctx))) {
756
+ state.resumeGuard = true;
757
+ pi.sendMessage(
758
+ {
759
+ customType: PLANNING_RESUME_CUSTOM_TYPE,
760
+ content: "Continue planning.",
761
+ display: false,
762
+ },
763
+ { triggerTurn: true },
764
+ );
765
+ }
897
766
  }
898
767
  }
899
768
 
769
+
900
770
  export function handlePlanningCompactFailed(pi: ExtensionAPI, ctx: ExtensionContext, event: SessionCompactFailedEvent): void {
901
771
  if (getExecution()) return;
902
772
  const session = ctx.sessionManager as unknown as { __planningCompaction?: PlanningCompactionState };
903
773
  const state = session.__planningCompaction;
904
774
  if (!state) return;
905
- const expectedThresholdCancel = event.reason === "threshold" && event.aborted && !state.inFlight;
906
- if (expectedThresholdCancel) {
775
+ const terminal = isTerminalCompactionFailure(event);
776
+ if (terminal) {
777
+ // Pi refused or aborted the compaction. Hold the cooldown and re-arm
778
+ // only after real growth or high-watermark pressure so the loop stops.
779
+ state.inFlight = false;
780
+ state.resumeGuard = false;
781
+ state.cooldownActive = true;
907
782
  state.lastAttemptReason = event.reason;
783
+ const tokens = ctx.getContextUsage()?.tokens;
784
+ state.terminalBackoffTokens = typeof tokens === "number" ? tokens : Number.POSITIVE_INFINITY;
785
+ state.pendingStats = null;
786
+ state.pendingFollowUpPrompt = null;
787
+ state.pendingContinueAfterThresholdCompact = false;
788
+ const message = terminal.kind === "content"
789
+ ? "pi-plans: compaction found nothing to summarize; backing off until the session grows past the keep-recent window."
790
+ : "pi-plans: compaction was aborted (provider interruption, user cancel, or a competing manual compact); backing off until the session grows or usage nears the window.";
791
+ ctx.ui.notify(message, "info");
908
792
  return;
909
793
  }
910
794
  state.inFlight = false;
911
795
  state.resumeGuard = false;
912
796
  state.cooldownActive = false;
913
797
  state.lastAttemptReason = event.reason;
798
+ state.pendingStats = null;
799
+ state.pendingFollowUpPrompt = null;
800
+ state.pendingContinueAfterThresholdCompact = false;
914
801
  ctx.ui.notify(
915
802
  `pi-plans: planning compaction failed (${event.reason}); will try again on the next eligible turn.`,
916
803
  "warning",
@@ -921,41 +808,12 @@ export function filterPlanningResumeMessages<T extends { customType?: string }>(
921
808
  return messages.filter((message) => message.customType !== PLANNING_RESUME_CUSTOM_TYPE);
922
809
  }
923
810
 
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
811
  export async function stopExecution(pi: ExtensionAPI, ctx: ExtensionContext, reason: string): Promise<void> {
953
812
  if (!execution) return;
954
- await restorePlanningModel(pi, ctx);
813
+ resetExecutionCompactionState(ctx);
955
814
  // Final synchronous write: drain any deferred flush and land the last snapshot.
956
815
  pendingExecutionFlush = false;
957
816
  persist(pi);
958
- clearExecutionPanel(ctx);
959
817
  execution = null;
960
818
  pi.appendEntry("pi-plans-exec-cleared", { reason });
961
819
  pi.sendMessage(
@@ -1010,29 +868,50 @@ export function applyImplMarkers(text: string): string[] {
1010
868
  return changed;
1011
869
  }
1012
870
 
871
+ export function applyCurrentIMarker(text: string): boolean {
872
+ if (!execution?.implItems?.length) return false;
873
+ const markers = scanCurrentIMarkers(text);
874
+ const resolved = resolveCurrentI(execution.implItems, markers, execution.currentI);
875
+ if (!resolved || resolved === execution.currentI) return false;
876
+ execution.currentI = resolved;
877
+ return true;
878
+ }
879
+
1013
880
  export function isExecutionComplete(): boolean {
1014
881
  return execution !== null && execution.items.length > 0 && execution.items.every((item) => item.done);
1015
882
  }
1016
883
 
1017
884
  export async function completeExecution(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
1018
885
  if (!execution) return;
1019
- await restorePlanningModel(pi, ctx);
886
+ resetExecutionCompactionState(ctx);
1020
887
  // Final synchronous write: drain any deferred flush and land the last snapshot.
1021
888
  pendingExecutionFlush = false;
1022
889
  persist(pi);
890
+
1023
891
  const summary = execution.items.map((item) => `- ✅ \`${item.id}\` ${item.text.split(";")[0]}`).join("\n");
1024
892
  const planPath = execution.planPath;
1025
- clearExecutionPanel(ctx);
1026
893
  execution = null;
1027
894
  pi.appendEntry("pi-plans-exec-cleared", { reason: "complete" });
895
+ // Post-execution goal-running continuation: in interactive sessions, attach
896
+ // the continuation block and trigger a new turn so the agent immediately
897
+ // enters the implementation-review loop. Headless sessions keep the silent
898
+ // completion behavior. Both completeExecution call sites (turn_end and the
899
+ // restoreFromSession recovery path) share this behavior.
900
+ const interactive = ctx.hasUI === true;
901
+ const content = interactive
902
+ ? `**Plan complete!** ✅ \`${planPath}\`\n\n${summary}\n\n${AMELIORATION_PROMPT_TEXT}`
903
+ : `**Plan complete!** ✅ \`${planPath}\`\n\n${summary}`;
1028
904
  pi.sendMessage(
1029
905
  {
1030
906
  customType: "pi-plans-complete",
1031
- content: `**Plan complete!** ✅ \`${planPath}\`\n\n${summary}`,
907
+ content,
1032
908
  display: true,
1033
909
  },
1034
- { triggerTurn: false },
910
+ { triggerTurn: interactive },
1035
911
  );
912
+ if (interactive) {
913
+ pi.appendEntry("pi-plans-ameliorate", { planPath, phase: "goal-started", rounds: null, currentRound: 0 });
914
+ }
1036
915
  const active = readActive(ctx.cwd);
1037
916
  if (active) {
1038
917
  try {
@@ -1044,21 +923,40 @@ export async function completeExecution(pi: ExtensionAPI, ctx: ExtensionContext)
1044
923
  updateStatusWidget(ctx);
1045
924
  }
1046
925
 
926
+ /** Instructions appended to the post-execution completion message in
927
+ * interactive sessions, telling the agent to enter the goal-running
928
+ * implementation-review loop. */
929
+ export const AMELIORATION_PROMPT_TEXT = `---
930
+ Goal-running continuation: immediately ask the user now via ask_choice (autoComplete: false, in the session language) for the termination condition of the implementation-review loop: until no high-severity finding (hard cap 5 rounds, recommended) / 1 round / 2 rounds / 3 rounds. Then keep running the loop without asking whether to continue: each round calls refine (role: "reviewer", target: "implementation"), accepts findings on evidence, applies fixes, re-runs relevant tests, and repeats until the chosen termination condition or the 5-round cap.`;
931
+
1047
932
  /** Injection text for before_agent_start while executing. */
1048
- export function executionContextMessage(): string | null {
933
+ export function executionContextMessage(ctx: ExtensionContext): string | null {
1049
934
  if (!execution) return null;
1050
935
  const remaining = execution.items.filter((item) => !item.done);
1051
936
  const list =
1052
937
  remaining.map((item) => `- \`${item.id}\` ${item.text}`).join("\n") || "(none — report completion now)";
938
+ // Live read: the injected guidance and the tool wrappers share the same
939
+ // tri-state, so they can never contradict each other mid-run.
940
+ const mode = resolveGraphMode(ctx?.cwd ?? process.cwd());
941
+ const graphLine =
942
+ mode === "config-unavailable"
943
+ ? `${graphBlockForExecutor(false)}\n[pi-plans: config unreadable this turn; graph features are off until .git/pi_plans/config.json is repaired]`
944
+ : graphBlockForExecutor(mode === "enabled");
945
+ const implementationItems = execution.implItems?.length
946
+ ? `\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.`
947
+ : "";
1053
948
  return `[PI-PLANS EXECUTION — write access enabled]
1054
949
  Implement the accepted plan at ${execution.planPath} (${execution.items.length - remaining.length}/${execution.items.length} verifier items done).
1055
950
 
1056
951
  Remaining verifier items:
1057
- ${list}
952
+ ${list}${implementationItems}
953
+
954
+ ${graphLine}
1058
955
 
1059
956
  Execution rules:
1060
957
  - 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.
958
+ - 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.
959
+ - 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
960
  - Simplest implementation that fully meets the item: no speculative abstractions, configuration, or indirection; keep components modular with clearly separated concerns.
1063
961
  - 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
962
  - 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 +979,7 @@ interface SessionEntry {
1081
979
  */
1082
980
  export async function restoreFromSession(pi: ExtensionAPI, ctx: ExtensionContext, entries: SessionEntry[]): Promise<void> {
1083
981
  pendingExecutionFlush = false; // no flush debt survives a restart
982
+ resetExecutionCompactionState(ctx);
1084
983
  let snapshotIndex = -1;
1085
984
  let snapshot: ExecState | null = null;
1086
985
  for (let i = entries.length - 1; i >= 0; i--) {
@@ -1093,21 +992,18 @@ export async function restoreFromSession(pi: ExtensionAPI, ctx: ExtensionContext
1093
992
  if (entry.type === "custom" && entry.customType === "pi-plans-exec-cleared") {
1094
993
  // Execution was explicitly stopped or completed after the last snapshot.
1095
994
  execution = null;
1096
- syncExecutionPanel(ctx);
1097
995
  updateStatusWidget(ctx);
1098
996
  return;
1099
997
  }
1100
998
  }
1101
999
  if (!snapshot) {
1102
1000
  execution = null;
1103
- syncExecutionPanel(ctx);
1104
1001
  updateStatusWidget(ctx);
1105
1002
  return;
1106
1003
  }
1107
1004
  // Ignore stale plans whose file vanished.
1108
1005
  if (!fs.existsSync(snapshot.planPath)) {
1109
1006
  execution = null;
1110
- syncExecutionPanel(ctx);
1111
1007
  updateStatusWidget(ctx);
1112
1008
  return;
1113
1009
  }
@@ -1116,16 +1012,14 @@ export async function restoreFromSession(pi: ExtensionAPI, ctx: ExtensionContext
1116
1012
  items: snapshot.items.map((item) => ({ ...item })),
1117
1013
  startedAt: snapshot.startedAt,
1118
1014
  usage: snapshot.usage ?? { inToks: 0, outToks: 0 },
1119
- panel: executionPanelFromEntryData(snapshot.panel) ?? createExecutionPanelState(),
1120
- modelState: snapshot.modelState,
1121
1015
  implItems: snapshot.implItems ?? [],
1122
1016
  implStatus: { ...(snapshot.implStatus ?? {}) },
1017
+ currentI: snapshot.currentI ?? inferCurrentI(snapshot.implItems, snapshot.items, snapshot.implStatus),
1123
1018
  };
1124
1019
  for (let i = snapshotIndex + 1; i < entries.length; i++) {
1125
1020
  const entry = entries[i];
1126
1021
  if (entry.type === "custom" && entry.customType === "pi-plans-exec-cleared") {
1127
1022
  execution = null;
1128
- syncExecutionPanel(ctx);
1129
1023
  break;
1130
1024
  }
1131
1025
  const message = entry.message;
@@ -1136,6 +1030,7 @@ export async function restoreFromSession(pi: ExtensionAPI, ctx: ExtensionContext
1136
1030
  .join("\n");
1137
1031
  applyDoneMarkers(text);
1138
1032
  applyImplMarkers(text);
1033
+ applyCurrentIMarker(text);
1139
1034
  }
1140
1035
  }
1141
1036
  if (execution) {
@@ -1145,6 +1040,5 @@ export async function restoreFromSession(pi: ExtensionAPI, ctx: ExtensionContext
1145
1040
  await completeExecution(pi, ctx);
1146
1041
  }
1147
1042
  }
1148
- syncExecutionPanel(ctx);
1149
1043
  updateStatusWidget(ctx);
1150
1044
  }