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