/** * Plan-execution loop: the tracked execution mode for accepted plans. * * When the user approves the execution handoff, the extension switches into * execution mode: every agent turn is injected with the remaining verifier * checklist, assistant messages are scanned for [DONE:VC-xxx] markers, and * progress is reported through the bottom status bar until every item passes. */ import * as fs from "node:fs"; import * as path from "node:path"; import { randomUUID } from "node:crypto"; import type { CompactionResult, ExtensionAPI, ExtensionContext, SessionBeforeCompactEvent, SessionBeforeCompactResult, SessionCompactEvent, SessionCompactFailedEvent, } from "@earendil-works/pi-coding-agent"; import { VERSION } from "@earendil-works/pi-coding-agent"; import { buildPiPlansVccCompaction, compactionCurrentI, entryCurrentIMarkers, formatVccCompactionStats, loadVccSettings, PLANNING_PREPLAN_COMPACT_HINT, scaffoldVccSettings, shouldScheduleAutoContinue, type CompactionEntryLike, type PiPlansCompactionPhase, type PiPlansVccPhaseContext, type PiPlansVccSettings, type VccCompactionBuildResult, type VccCompactionStats, } from "./compaction.ts"; import { getRun, lintPlanIntoNotices, readActive, resolveStateRootOrNull, setRunStatus, StateError, utcNow } from "./state.ts"; import { execChrome, resolveUiLanguage, type UiLanguage } from "./ui-language.ts"; import { bindRun, resolveActiveRun } from "./run-context.ts"; import { OwnershipError } from "./run-ownership.ts"; import { applyExecutionApproved, applyExecutionCompleted, applyExecutionHeadChanged, applyExecutionProgress, applyExecutionStopped, applyQuestionAsked, applyReviewRoundStarted, createCheckpoint, loadCheckpoint, mutateCheckpoint, StaleCheckpointError, planIdentityOf, resolveHeadAt, resolveWorktreeRoot, sha256File, type ExecutionApproval, } from "./workflow-state.ts"; import { graphBlockForExecutor } from "./code-graph/prompts.ts"; import { PANEL_WIDGET_KEY, derivePanelModel, deriveNextAction, deriveImplReviewLoopModel, formatImplReviewLoopSummaryLine, formatPanelSummaryLine, renderImplReviewLoopLines, renderPanelLines, themeImplReviewLoopLines, themePanelLines, } from "./panel.ts"; import { resolveGraphMode } from "./code-graph/mode.ts"; import { TERMINATION_QUESTION, TERMINATION_OPTIONS, TERMINATION_RECORDING_INSTRUCTIONS, defaultImplReviewers, implReviewerCountPromptLine, renderTerminationOptions, } from "./termination-prompt.ts"; import { extractCoverage, latestPlanVersion, parseChecklist, parseImplItems, resolveImplStatuses, scanDoneMarkers, scanImplMarkers, scanCurrentIMarkers, resolveCurrentI, inferCurrentI, lintImplItems, type CheckItem, type ImplItem, type ImplMarkerState, } from "./plan.ts"; export interface ExecState { planPath: string; items: CheckItem[]; startedAt: string; usage: { inToks: number; outToks: number }; implItems?: ImplItem[]; implStatus?: Record; /** Plan-lint warning backing the panel's implWarning line. */ implWarning?: string | null; /** Chrome language for panel/status strings (issue #3); undefined → "en". */ uiLanguage?: UiLanguage; currentI?: string; goalWait?: GoalWaitState; } /** * D-008 (issue #3): re-resolve the chrome language and repaint the panel and * status bar. Called by the plans tool right after a successful * `set-language` so an executing run switches language without a restart * (and without reading config on every render tick). * * Capability guard (implementation review F-001): partial contexts (some * command/test harnesses expose only notify/select/input) may lack * setStatus/theme — the refresh must stay a no-op there instead of throwing * into the caller's error path (updateStatusWidget assumes a full ui). */ export function refreshUiLanguage(ctx: ExtensionContext): void { if (execution) execution.uiLanguage = resolveUiLanguage(ctx.cwd); if (typeof ctx.ui?.setStatus !== "function" || !ctx.ui?.theme) return; updateStatusWidget(ctx); } export interface GoalWaitState { noProgressRounds: number; waitRounds: number; /** Marker/progress snapshot of the last goal-wait round; null = baseline not set. */ lastMarkers: string | null; paused: boolean; pausedReason?: string; } const GOAL_WAIT_MAX_NO_PROGRESS = 3; const GOAL_WAIT_MAX_WAITING = 6; let execution: ExecState | null = null; export const GOAL_WAIT_CUSTOM_TYPE = "pi-plans-goal-wait"; interface GoalWaitRuntime { owner: ExecState; session: ExtensionContext["sessionManager"]; handled: boolean; stopReason?: string; text: string; wakeId?: string; } // Dispatch identity belongs to a live session, never to a persisted checklist. let goalWaitRuntime: GoalWaitRuntime | null = null; function resetGoalWaitRuntime(ctx: ExtensionContext): void { goalWaitRuntime = execution ? { owner: execution, session: ctx.sessionManager, handled: false, text: "" } : null; } function currentGoalWaitRuntime(ctx: ExtensionContext): GoalWaitRuntime | null { return goalWaitRuntime?.owner === execution && goalWaitRuntime.session === ctx.sessionManager ? goalWaitRuntime : null; } // Execution-loop persistence is deferred until the agent settles so turn_end // never causes session writes during a streaming run. let pendingExecutionFlush = false; export function consumePendingExecutionFlush(): boolean { const pending = pendingExecutionFlush; pendingExecutionFlush = false; return pending; } function requestExecutionFlush(_pi: ExtensionAPI, _ctx: ExtensionContext): void { // Unconditional defer. turn_end fires mid-run in a gap between agent // operations where isIdle() reads true; persistence happens only at the // drain points: agent_settled, the next before_agent_start, and stop/complete. pendingExecutionFlush = true; } export function drainExecutionFlush(pi: ExtensionAPI, ctx: ExtensionContext): void { if (!execution || !pendingExecutionFlush) return; pendingExecutionFlush = false; persist(pi); updateStatusWidget(ctx); } export function getExecution(): ExecState | null { return execution; } export interface CheckpointExecutionLoad { status: "loaded" | "no-execution" | "plan-missing" | "plan-mismatch" | "no-checkpoint" | "corrupt"; planPath?: string; doneVcIds?: string[]; reverifyAll?: boolean; pausedReason?: string; error?: string; } /** * Shared restore primitive (I-005/I-006): load the executing state from a run * checkpoint into THIS session. Authorization is kept only when the recorded * approval matches the current plan digest; a HEAD change keeps the * authorization but re-verifies previously verified VCs (D-011/F-001). * F-002: the loaded state is persisted to the current session IMMEDIATELY so * session_start/session_tree restore paths cannot silently clear it. */ export function loadExecutionFromCheckpoint( pi: ExtensionAPI, ctx: ExtensionContext, runId: string, ): CheckpointExecutionLoad { const load = loadCheckpoint(ctx.cwd, runId); if (load.status === "missing") return { status: "no-checkpoint" }; if (load.status === "corrupt") return { status: "corrupt", error: load.error }; const cp = load.checkpoint; if (!cp.execution || cp.phase !== "executing") return { status: "no-execution" }; const planPath = cp.plan?.path; if (!planPath || !fs.existsSync(planPath)) { return { status: "plan-missing", error: planPath ? `plan file vanished: ${planPath}` : "checkpoint has no plan identity" }; } const planText = fs.readFileSync(planPath, "utf8"); // F-002 (implementation review): the recorded plan identity is over BYTES — // an in-place edit at the same path must not inherit the authorization or // the verified VCs. Refuse the load and require a fresh handoff. if (sha256File(planPath) !== cp.plan.sha256) { return { status: "plan-mismatch" as const, error: `plan file changed since the approval record (${planPath}); re-approve via /plans-execute before executing`, }; } const items = parseChecklist(planText); if (items.length === 0) { return { status: "plan-missing", error: `${planPath} has no parsable verifier checklist` }; } const implItems = parseImplItems(planText); const doneIds = new Set(cp.execution.doneVcIds); // D-011/F-001: an unchanged plan digest keeps the recorded authorization; // a changed HEAD under it forces re-verification of previously verified VCs. // F-006 (implementation review): an approval without a resolvable HEAD // recorded an unverifiable code state — re-verify instead of trusting. const headNow = resolveHeadAt(ctx.cwd); const headUnverifiable = cp.execution.approval === null || cp.execution.approval.headAtApproval === null; const headChanged = cp.execution.approval !== null && cp.execution.approval.headAtApproval !== null && cp.execution.approval.headAtApproval !== headNow; const reverifyAll = cp.execution.reverifyAll === true || headChanged || headUnverifiable; if (!reverifyAll) { for (const item of items) { if (doneIds.has(item.id)) item.done = true; } } execution = { planPath, items, startedAt: utcNow(), usage: { inToks: cp.execution.usage.inToks, outToks: cp.execution.usage.outToks }, implItems, implStatus: { ...cp.execution.implStatus }, implWarning: lintImplItems(planText), uiLanguage: resolveUiLanguage(ctx.cwd), currentI: cp.execution.currentI, goalWait: { noProgressRounds: 0, waitRounds: 0, lastMarkers: null, paused: cp.execution.pausedReason !== undefined, pausedReason: cp.execution.pausedReason, }, }; executionRunId = runId; bindRun(ctx.sessionManager, ctx.cwd, runId); resetGoalWaitRuntime(ctx); pendingExecutionFlush = false; // restored state: no inherited flush debt resetExecutionCompactionState(ctx); if (headChanged) { withExecutionCheckpoint(ctx, (current) => applyExecutionHeadChanged(current)); } if (execution.goalWait) execution.goalWait.lastMarkers = goalWaitSnapshot(); persist(pi); // F-002: immediate session snapshot updateStatusWidget(ctx); return { status: "loaded", planPath, doneVcIds: [...doneIds], reverifyAll, pausedReason: cp.execution.pausedReason, }; } const EXECUTION_COMPACTION_RESUME_MESSAGE = "Continue execution."; interface ExecutionCompactionState { inFlight: boolean; resumeGuard: boolean; cooldownActive: boolean; lastAttemptReason: string | null; lastSuccessfulUsagePercent: number | null; lastSuccessfulAt: string | null; rearmPending: boolean; /** Terminal failure metadata is retained for diagnostics, not proactive retry. */ terminalBackoffTokens: number | null; pendingStats: VccCompactionStats | null; pendingFollowUpPrompt: string | null; pendingContinueAfterThresholdCompact: boolean; } type ExecutionCompactionSession = { __executionCompaction?: ExecutionCompactionState }; type ExecutionCompactionContext = ExtensionContext & { sessionManager?: ExecutionCompactionSession }; function getExecutionCompactionSession(ctx: ExtensionContext, create = false): ExecutionCompactionSession | undefined { const carrier = ctx as ExecutionCompactionContext; if (carrier.sessionManager) return carrier.sessionManager; if (!create) return undefined; carrier.sessionManager = {}; return carrier.sessionManager; } function executionCompactionState(ctx: ExtensionContext): ExecutionCompactionState | undefined { return getExecutionCompactionSession(ctx)?.__executionCompaction; } function ensureExecutionCompactionState(ctx: ExtensionContext): ExecutionCompactionState { const session = getExecutionCompactionSession(ctx, true)!; return (session.__executionCompaction ??= { inFlight: false, resumeGuard: false, cooldownActive: false, lastAttemptReason: null, lastSuccessfulUsagePercent: null, lastSuccessfulAt: null, rearmPending: false, terminalBackoffTokens: null, pendingStats: null, pendingFollowUpPrompt: null, pendingContinueAfterThresholdCompact: false, }); } function resetExecutionCompactionState(ctx: ExtensionContext): void { const session = getExecutionCompactionSession(ctx); if (!session) return; delete session.__executionCompaction; } function consumeExecutionCompactionResumeGuard(ctx: ExtensionContext): boolean { const state = executionCompactionState(ctx); if (!state?.resumeGuard) return false; state.resumeGuard = false; return true; } export function shouldTriggerExecutionCompaction(_ctx: ExtensionContext): boolean { return false; } export function handleExecutionTurnCompaction(ctx: ExtensionContext): void { consumeExecutionCompactionResumeGuard(ctx); } export function computeExecutionProgress(execution: ExecState): { done: number; total: number } { const implItems = execution.implItems ?? []; if (implItems.length) { const statuses = resolveImplStatuses(implItems, execution.items, execution.implStatus); const counted = implItems.filter((impl) => execution.items.some((item) => extractCoverage(item.text).includes(impl.id)), ); const total = counted.length > 0 ? counted.length : implItems.length; const vcDone = counted.filter((impl) => statuses[impl.id] === "vc-passed").length; const currentIndex = execution.currentI ? implItems.findIndex((impl) => impl.id === execution.currentI) : -1; return { done: Math.min(total, Math.max(vcDone, currentIndex < 0 ? 0 : currentIndex)), total, }; } return { done: execution.items.filter((item) => item.done).length, total: execution.items.length, }; } function formatElapsed(startedAt: string): string { const total = Math.max(0, Math.floor((Date.now() - Date.parse(startedAt)) / 1000)); const h = String(Math.floor(total / 3600)).padStart(2, "0"); const m = String(Math.floor((total % 3600) / 60)).padStart(2, "0"); const sec = String(total % 60).padStart(2, "0"); return `${h}:${m}:${sec}`; } function formatToks(tokens: number): string { const n = Math.max(0, Math.round(tokens)); return n < 1000 ? String(n) : `${(n / 1000).toFixed(1)}k`; } export function formatExecutionStatusLine(execution: ExecState): string { const progress = computeExecutionProgress(execution); let line = `⌛ plans ${progress.done}/${progress.total}: spent ${formatElapsed(execution.startedAt)} · ${formatToks(execution.usage.inToks)} in-toks · ${formatToks(execution.usage.outToks)} out-toks`; const goalWait = execution.goalWait; if (goalWait?.paused) { line += ` · ⏸ goal-wait paused (${goalWait.pausedReason ?? "paused"})`; } else if (goalWait && (goalWait.noProgressRounds > 0 || goalWait.waitRounds > 0)) { line += execChrome(execution.uiLanguage ?? "en").goalWait(goalWait.noProgressRounds, goalWait.waitRounds); } return line; } /** Approximation for "a subprocess is pending" (matches the exec loop's * `/waiting for/` backoff heuristic; F-008). Passed explicitly into the * shared model so the panel, status line and injection text agree. */ export function executionIsWaiting(execution: ExecState): boolean { const gw = execution.goalWait; return gw !== undefined && !gw.paused && gw.waitRounds > 0; } /** Resolve the run topic for panel headers (falls back to the run id). */ function panelTopic(ctx: ExtensionContext): string { const active = resolveActiveRun(ctx.sessionManager, ctx.cwd); if (active) { const run = getRun(ctx.cwd, active.run_id); if (run?.topic) return run.topic; return active.run_id; } return "pi-plans"; } /** Run info for the panel activity line (CQ1/D-005). Read by the in-flight * executionRunId so a stale/missing run record degrades to null (the model * then falls back to the bare phase word) instead of showing another run's * status. */ function panelRunInfo(ctx: ExtensionContext): { status: string; created_at: string; updated_at: string } | null { if (!executionRunId) return null; // D-005 pointer-consistency: if the workdir's active pointer has moved to // another run (second session / external CLI mutation) while this // execution is live, the activity row degrades to the phase word rather // than mixing the new active run's topic (header) with the old run's // status (impl-review r1 F-001). if (resolveActiveRun(ctx.sessionManager, ctx.cwd)?.run_id !== executionRunId) return null; const run = getRun(ctx.cwd, executionRunId); if (!run) return null; return { status: run.status, created_at: run.created_at, updated_at: run.updated_at }; } let panelRegistered = false; let loopPanelRegistered = false; /** Live implementation-review loop state for the panel: the widget stays * alive while the active run is done BUT its checkpoint is still in the * implementation-review phase (D-3). Read fresh on every call so post-write * redraws (index.ts turn-end updateStatusWidget) never show stale rounds * (D-8). Returns null once the phase flips to completed. */ function implReviewLoopState( ctx: ExtensionContext, ): { topic: string; review: { terminationCondition?: string; reviewerCount?: number; completedRounds: number } } | null { const active = resolveActiveRun(ctx.sessionManager, ctx.cwd); if (!active) return null; if (getRun(ctx.cwd, active.run_id)?.status !== "done") return null; const load = loadCheckpoint(ctx.cwd, active.run_id); if (load.status !== "ok" || load.checkpoint.phase !== "implementation-review") return null; return { topic: panelTopic(ctx), review: load.checkpoint.implementationReview }; } /** * Register/update the fixed tasks' status panel (aboveEditor widget) for the * current execution, or unregister it when execution is gone. The panel and * the bottom status line share the same pure model (D-003/D-014/D-015). The * widget uses the factory form and reads the LIVE theme via `ui.theme` inside * render(width) — per-line width math happens on plain text first, then the * current theme is applied, so theme hot-swaps and resize never produce stale * colors or wrapped rows (F-004). */ function updatePanelWidget(ctx: ExtensionContext): void { // Capability guard: older Pi hosts and test harness mocks may not expose // setWidget (the panel is a UI nicety, never a correctness dependency). if (!ctx.hasUI || typeof ctx.ui.setWidget !== "function") return; // Implementation-review loop widget (D-3): keeps the panel alive after // execution ends while the loop is live; unregisters when the phase // completes. Lives under the same widget key so the two widgets never // stack. const loop = execution === null ? implReviewLoopState(ctx) : null; if (execution === null && loop) { if (!loopPanelRegistered) { ctx.ui.setWidget( PANEL_WIDGET_KEY, (ui, _theme) => ({ render(width: number) { const theme = (ui as { theme?: { fg(color: string, text: string): string } }).theme ?? _theme; // D-8 freshness: re-read the loop state per render; a phase flip to // completed renders an empty box until the next turn-end refresh // unregisters it (index.ts always calls updateStatusWidget then). const live = implReviewLoopState(ctx); if (!live) return []; const model = deriveImplReviewLoopModel(live.topic, live.review); const lines = renderImplReviewLoopLines(model, width); return theme ? themeImplReviewLoopLines(lines, theme as never) : lines; }, }), { placement: "aboveEditor" }, ); loopPanelRegistered = true; } } else if (loopPanelRegistered) { ctx.ui.setWidget(PANEL_WIDGET_KEY, undefined); loopPanelRegistered = false; } if (!execution) { if (panelRegistered) { ctx.ui.setWidget(PANEL_WIDGET_KEY, undefined); panelRegistered = false; } return; } if (!panelRegistered) { ctx.ui.setWidget( PANEL_WIDGET_KEY, (ui, _theme) => ({ render(width: number) { const theme = (ui as { theme?: { fg(color: string, text: string): string } }).theme ?? _theme; const current = execution; if (!current) return []; // F-004 (impl review r1): recompute the topic per render so a // cross-run restart without an intervening unregister cannot // show a stale box header. const model = derivePanelModel(current, panelTopic(ctx), executionIsWaiting(current), panelRunInfo(ctx)); const lines = renderPanelLines(model, width); // Uniform-gray frame: │ borders never inherit the line color; // accents live between the borders only (themePanelLines). return theme ? themePanelLines(lines, model, theme) : lines; }, }), { placement: "aboveEditor" }, ); panelRegistered = true; } else { // Factory components are re-created on every registration; content // updates flow through the closure reads at render time, so a no-op // re-set is unnecessary. Trigger one re-render via a cheap status // touch is NOT used — event-driven flush only (D-013). } } export function updateStatusWidget(ctx: ExtensionContext): void { updatePanelWidget(ctx); if (execution) { // D-015: the status line derives from the same panel model. const line = formatPanelSummaryLine( derivePanelModel(execution, panelTopic(ctx), executionIsWaiting(execution), panelRunInfo(ctx)), ); ctx.ui.setStatus("pi-plans", ctx.ui.theme.fg("accent", line)); return; } const active = resolveActiveRun(ctx.sessionManager, ctx.cwd); if (active) { // Idle indicator depends on the run's lifecycle, not just its existence: // done reads as finished, abandoned as closed, stopped/accepted as paused. const status = getRun(ctx.cwd, active.run_id)?.status; if (status === "done") { // D-3/D-015: while the implementation-review loop is live (checkpoint // still in the implementation-review phase), the status line mirrors // the loop box model instead of a bare "(done)". const loop = implReviewLoopState(ctx); if (loop) { const line = formatImplReviewLoopSummaryLine(deriveImplReviewLoopModel(loop.topic, loop.review)); ctx.ui.setStatus("pi-plans", ctx.ui.theme.fg("accent", line)); return; } ctx.ui.setStatus("pi-plans", ctx.ui.theme.fg("success", `🎯 plans: ${active.run_id} (done)`)); return; } if (status === "abandoned") { ctx.ui.setStatus("pi-plans", ctx.ui.theme.fg("error", `🚫 plans: ${active.run_id}`)); return; } if (status === "stopped") { ctx.ui.setStatus("pi-plans", ctx.ui.theme.fg("warning", `⛔ plans: ${active.run_id}`)); return; } if (status === "accepted") { ctx.ui.setStatus("pi-plans", ctx.ui.theme.fg("warning", `⌛ plans: ${active.run_id}`)); return; } if (status === "planning") { // Planning phase: 💬 while still in Q&A, 📝 once a PLAN draft exists // — kept until execution starts (then ⌛ takes over). const emoji = latestPlanVersion(active.artifact_dir) ? "📝" : "💬"; ctx.ui.setStatus("pi-plans", ctx.ui.theme.fg("muted", `${emoji} plans: ${active.run_id}`)); return; } // unknown status: no indicator. } ctx.ui.setStatus("pi-plans", undefined); } function persist(pi: ExtensionAPI): void { if (!execution) return; pi.appendEntry("pi-plans-exec", { planPath: execution.planPath, items: execution.items, startedAt: execution.startedAt, usage: execution.usage, implItems: execution.implItems, implStatus: execution.implStatus, implWarning: execution.implWarning ?? null, currentI: execution.currentI, goalWait: execution.goalWait, }); } /** Checkpoint bookkeeping for the executing run; best-effort for legacy runs * without checkpoints (their cross-session resume degrades to R-008 rules). */ function withExecutionCheckpoint(ctx: ExtensionContext, mutator: (cp: import("./workflow-state.ts").WorkflowCheckpoint) => import("./workflow-state.ts").WorkflowCheckpoint): void { if (!execution) return; const active = resolveActiveRun(ctx.sessionManager, ctx.cwd); if (!active || active.run_id !== executionRunId) return; try { mutateCheckpoint(ctx.cwd, active.run_id, mutator); } catch (error) { // F-005 (implementation review): ownership loss and revision staleness // must stop the advance, not vanish into the catch block. if (error instanceof OwnershipError || error instanceof StaleCheckpointError) throw error; /* legacy run or corrupt checkpoint: session snapshot still carries the loop */ } } let executionRunId: string | null = null; export async function startExecution( pi: ExtensionAPI, ctx: ExtensionContext, planPath: string, items: CheckItem[], implItems?: ImplItem[], ): Promise { execution = { planPath, items, startedAt: utcNow(), usage: { inToks: 0, outToks: 0 }, implItems: implItems ?? [], implStatus: {}, uiLanguage: resolveUiLanguage(ctx.cwd), // F-001 (impl review r1): derive the plan-lint warning on the live // handoff path too, so the panel shows the ⚠ line immediately for a // zero-parse section instead of only after a checkpoint restore. implWarning: (() => { try { return lintImplItems(fs.readFileSync(planPath, "utf8")); } catch { return null; } })(), goalWait: { noProgressRounds: 0, waitRounds: 0, lastMarkers: null, paused: false }, }; // Seed the marker baseline so the first quiet round is counted against a // real snapshot instead of counting unconditionally (F-006). if (execution.goalWait) execution.goalWait.lastMarkers = goalWaitSnapshot(); resetGoalWaitRuntime(ctx); pendingExecutionFlush = false; // fresh run: no inherited flush debt resetExecutionCompactionState(ctx); persist(pi); const active = resolveActiveRun(ctx.sessionManager, ctx.cwd); executionRunId = active?.run_id ?? null; if (active) { bindRun(ctx.sessionManager, ctx.cwd, active.run_id); // I-005: durable approval evidence — run + plan digest + HEAD at // approval (D-003/D-011). Sets phase executing via the state machine. try { const load = loadCheckpoint(ctx.cwd, active.run_id); if (load.status === "missing") { createCheckpoint(ctx.cwd, { runId: active.run_id, originWorkdir: ctx.cwd, workdir: ctx.cwd }); } const approval: ExecutionApproval = { plan: planIdentityOf(path.resolve(planPath), 1), worktree: resolveWorktreeRoot(ctx.cwd) ?? path.resolve(ctx.cwd), headAtApproval: resolveHeadAt(ctx.cwd), approvedAt: utcNow(), }; mutateCheckpoint(ctx.cwd, active.run_id, (cp) => { // Plan refinement may not have recorded the plan identity yet. const withPlan = cp.plan === null ? { ...cp, plan: approval.plan } : cp; // The checkpoint may not carry accept-execute (legacy flow); // approval here came from the explicit handoff confirmation. const aligned = withPlan.nextAction === "accept-execute" ? withPlan : { ...withPlan, nextAction: "accept-execute" as const }; return applyExecutionApproved(aligned, approval); }); // Plan-lint entry point (execute handoff): a plan whose Implementation // Items section parses to zero items gets a durable run notice so the // execution panel's warning is backed by persisted evidence. lintPlanIntoNotices(ctx.cwd, active.run_id, path.resolve(planPath)); } catch (error) { // F-002 (implementation review): a plan-digest mismatch between the // recorded checkpoint plan and the approval must fail closed and // visibly — never silently execute without durable approval. if (error instanceof StateError && /does not match/.test(error.message)) throw error; /* legacy/corrupt checkpoint: run status still transitions below */ } try { setRunStatus(ctx.cwd, active.run_id, "executing"); } catch { /* status bookkeeping is best-effort */ } } pi.sendMessage( { customType: "pi-plans-exec-start", content: `**pi-plans: executing** \`${planPath}\` — ${items.length} verifier item(s). Progress appears in the bottom status bar; mark verified items with \`[DONE:VC-xxx]\`.`, display: true, }, { triggerTurn: false }, ); updateStatusWidget(ctx); } /** Record one assistant turn: accumulate usage and mark any completed items. */ export function recordExecutionTurn( pi: ExtensionAPI, _ctx: ExtensionContext, _completedIds: string[], usage?: { input: number; output: number }, ): void { if (!execution) return; if (usage) { execution.usage.inToks += usage.input; execution.usage.outToks += usage.output; } // I-005: mirror progress into the run checkpoint so a different session // can resume with the verified VC/I set (R-004). withExecutionCheckpoint(_ctx, (cp) => applyExecutionProgress(cp, { doneVcIds: execution!.items.filter((item) => item.done).map((item) => item.id), implStatus: implStatusSnapshot(), currentI: execution!.currentI, usage: usage ? { inToks: usage.input, outToks: usage.output } : undefined, }), ); requestExecutionFlush(pi, _ctx); updateStatusWidget(_ctx); } function implStatusSnapshot(): Record { const snapshot: Record = {}; if (!execution?.implItems) return snapshot; for (const item of execution.implItems) { const state = execution.implStatus?.[item.id]; if (state) snapshot[item.id] = state; } return snapshot; } export function registerExecutionTurnHandlers( pi: ExtensionAPI, onTurnEnd?: (ctx: ExtensionContext) => Promise | void, ): void { // The turn_end projection does not carry usage; message_end delivers the // full assistant message, so cache it here and consume it per turn. let lastAssistantUsage: { input: number; output: number } | null = null; pi.on("agent_start", async (_event, ctx) => { const runtime = currentGoalWaitRuntime(ctx); if (!runtime) return; runtime.handled = false; runtime.stopReason = undefined; runtime.text = ""; }); pi.on("before_agent_start", async (_event, ctx) => { const runtime = currentGoalWaitRuntime(ctx); if (runtime) runtime.wakeId = undefined; }); pi.on("input", async (event, ctx) => { if (event.source === "interactive" || event.source === "rpc") resumeGoalWaitIfPaused(pi, ctx); }); pi.on("agent_settled", async (_event, ctx) => { drainExecutionFlush(pi, ctx); maybeGoalWaitFollowUp(pi, ctx); }); pi.on("session_shutdown", async (_event, ctx) => { drainExecutionFlush(pi, ctx); execution = null; executionRunId = null; goalWaitRuntime = null; lastAssistantUsage = null; }); pi.on("message_end", async (event) => { const message = event.message as { role?: string; usage?: { input?: number; output?: number } }; if (message?.role === "assistant" && message.usage) { lastAssistantUsage = { input: message.usage.input ?? 0, output: message.usage.output ?? 0 }; } }); pi.on("turn_end", async (event, ctx) => { const message = event.message as { role?: string; stopReason?: string; content?: Array<{ type: string; text?: string }> }; if (!message || message.role !== "assistant") { updateStatusWidget(ctx); return; } const text = (message.content ?? []) .filter((part) => part.type === "text") .map((part) => part.text ?? "") .join("\n"); const runtime = currentGoalWaitRuntime(ctx); if (runtime) { runtime.stopReason = message.stopReason; runtime.text = text; } const changedIds = applyDoneMarkers(text); const changedImpls = applyImplMarkers(text); const changedCurrentI = applyCurrentIMarker(text); const projection = (event.message as { usage?: { input?: number; output?: number } }).usage; const raw = projection ?? lastAssistantUsage; lastAssistantUsage = null; // consumed: never re-attribute a stale turn const usage = raw ? { input: raw.input ?? 0, output: raw.output ?? 0 } : undefined; if (usage || changedIds.length > 0 || changedImpls.length > 0 || changedCurrentI) { // Attribute this turn's usage now; `[DONE]` markers still only mark completion. recordExecutionTurn(pi, ctx, changedIds, usage); } if (getExecution() && isExecutionComplete()) { await completeExecution(pi, ctx); } await onTurnEnd?.(ctx); }); } const EXECUTION_RESUME_CUSTOM_TYPE = "pi-plans-exec-resume"; function activeVccSettings(ctx: ExtensionContext, phase: PiPlansCompactionPhase): { settings: PiPlansVccSettings; runId: string; artifactDir: string } | null { const stateRoot = resolveStateRootOrNull(ctx.cwd); if (!stateRoot) return null; const active = resolveActiveRun(ctx.sessionManager, ctx.cwd); if (!active) return null; const run = getRun(ctx.cwd, active.run_id); if (!run) return null; if (phase === "planning" && run.status !== "planning") return null; if (phase === "execution" && run.status !== "executing") return null; scaffoldVccSettings(stateRoot); return { settings: loadVccSettings(stateRoot), runId: run.run_id, artifactDir: run.artifact_dir }; } function executionVccContext(): PiPlansVccPhaseContext { return { phase: "execution", planPath: execution?.planPath ?? null, currentI: execution?.currentI ?? null, remainingVerifierIds: execution?.items.filter((item) => !item.done).map((item) => item.id) ?? [], implementationIds: execution?.implItems?.map((item) => item.id) ?? [], }; } function planningVccContext(branchEntries: CompactionEntryLike[], fallback: { runId?: string; artifactDir?: string }): PiPlansVccPhaseContext { let runId: string | null = fallback.runId ?? null; let artifactDir: string | null = fallback.artifactDir ?? null; let planPath: string | null = null; let currentI: string | null = null; for (const entry of branchEntries) { if (entry.type === "custom" && entry.customType === PLANNING_RUN_START_CUSTOM_TYPE) { runId = typeof entry.data?.runId === "string" ? entry.data.runId : runId; artifactDir = typeof entry.data?.artifactDir === "string" ? entry.data.artifactDir : artifactDir; } if (entry.type === "custom" && entry.customType === PLANNING_PLAN_WRITTEN_CUSTOM_TYPE) { planPath = typeof entry.data?.planPath === "string" ? entry.data.planPath : planPath; } for (const id of entryCurrentIMarkers(entry)) currentI = id; currentI = compactionCurrentI(entry) ?? currentI; } return { phase: "planning", runId, artifactDir, planPath, currentI }; } function buildExecutionVccResult(event: SessionBeforeCompactEvent, ctx: ExtensionContext): VccCompactionBuildResult | null { if (!execution) return null; const active = activeVccSettings(ctx, "execution"); if (!active) return null; return buildPiPlansVccCompaction({ branchEntries: event.branchEntries as unknown as CompactionEntryLike[], preparation: event.preparation, customInstructions: event.customInstructions, reason: event.reason, willRetry: event.willRetry, settings: active.settings, phaseContext: executionVccContext(), }); } export function buildExecutionCompactionResult(event: SessionBeforeCompactEvent, ctx: ExtensionContext): CompactionResult | null { const built = buildExecutionVccResult(event, ctx); return built?.kind === "compaction" ? built.compaction : null; } export function handleExecutionBeforeCompact( pi: ExtensionAPI, ctx: ExtensionContext, event: SessionBeforeCompactEvent, ): SessionBeforeCompactResult | undefined { if (!execution) return undefined; const state = ensureExecutionCompactionState(ctx); state.inFlight = true; state.lastAttemptReason = event.reason; state.pendingStats = null; state.pendingFollowUpPrompt = null; state.pendingContinueAfterThresholdCompact = false; let built: VccCompactionBuildResult | null; try { built = buildExecutionVccResult(event, ctx); } catch (error) { state.inFlight = false; ctx.ui.notify(`pi-plans: VCC compaction preparation failed; using Pi default compaction (${String(error)}).`, "warning"); return undefined; } if (!built || built.kind === "fallback") { state.inFlight = false; return undefined; } if (built.kind === "cancel") { state.inFlight = false; ctx.ui.notify(built.message, "warning"); return { cancel: true }; } state.pendingStats = built.stats; state.pendingFollowUpPrompt = built.followUpPrompt; state.pendingContinueAfterThresholdCompact = built.settings.continueAfterThresholdCompact; requestExecutionFlush(pi, ctx); return { compaction: built.compaction }; } function runtimePiVersion(ctx: ExtensionContext): unknown { return (ctx as ExtensionContext & { piVersion?: unknown }).piVersion ?? VERSION; } export async function handleExecutionCompact(pi: ExtensionAPI, ctx: ExtensionContext, event: SessionCompactEvent): Promise { if (!execution) return; const state = ensureExecutionCompactionState(ctx); const stats = state.pendingStats; const followUpPrompt = state.pendingFollowUpPrompt; const continueAfterThresholdCompact = state.pendingContinueAfterThresholdCompact; state.pendingStats = null; state.pendingFollowUpPrompt = null; state.pendingContinueAfterThresholdCompact = false; state.inFlight = false; state.lastAttemptReason = event.reason; state.cooldownActive = true; state.rearmPending = false; state.terminalBackoffTokens = null; state.lastSuccessfulAt = utcNow(); state.lastSuccessfulUsagePercent = ctx.getContextUsage()?.percent ?? state.lastSuccessfulUsagePercent; state.resumeGuard = false; if (!event.willRetry && stats) { ctx.ui.notify(formatVccCompactionStats(stats), "info"); if (followUpPrompt) { await pi.sendUserMessage?.(followUpPrompt); } else if ((event.reason === "threshold" || event.reason === "overflow") && shouldScheduleAutoContinue(continueAfterThresholdCompact, runtimePiVersion(ctx))) { state.resumeGuard = true; pi.sendMessage( { customType: EXECUTION_RESUME_CUSTOM_TYPE, content: EXECUTION_COMPACTION_RESUME_MESSAGE, display: false, }, { triggerTurn: true }, ); } } requestExecutionFlush(pi, ctx); updateStatusWidget(ctx); } export function handleExecutionCompactFailed(pi: ExtensionAPI, ctx: ExtensionContext, event: SessionCompactFailedEvent): void { if (!execution) return; const state = executionCompactionState(ctx); const terminal = isTerminalCompactionFailure(event); if (terminal) { // Pi refused or aborted the compaction. Hold the cooldown and re-arm // only after real growth or high-watermark pressure so the loop stops. if (state) { state.inFlight = false; state.resumeGuard = false; state.cooldownActive = true; state.rearmPending = false; state.lastAttemptReason = event.reason; const tokens = ctx.getContextUsage()?.tokens; state.terminalBackoffTokens = typeof tokens === "number" ? tokens : Number.POSITIVE_INFINITY; state.pendingStats = null; state.pendingFollowUpPrompt = null; state.pendingContinueAfterThresholdCompact = false; } const message = terminal.kind === "content" ? "pi-plans: compaction found nothing to summarize; backing off until the session grows past the keep-recent window." : "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."; ctx.ui.notify(message, "info"); requestExecutionFlush(pi, ctx); return; } if (state) { state.inFlight = false; state.resumeGuard = false; state.cooldownActive = false; state.rearmPending = false; state.lastAttemptReason = event.reason; state.pendingStats = null; state.pendingFollowUpPrompt = null; state.pendingContinueAfterThresholdCompact = false; } ctx.ui.notify( `pi-plans: compaction failed (${event.reason}); execution remains active and will wait for the next eligible turn.`, "warning", ); requestExecutionFlush(pi, ctx); } export function filterExecutionResumeMessages(messages: T[]): T[] { return messages.filter((message) => message.customType !== EXECUTION_RESUME_CUSTOM_TYPE); } // --------------------------------------------------------------------------- // Planning-phase compaction: Pi core owns scheduling; this hook customizes // active planning compact events with the same VCC builder used by execution. // The two state machines are kept independent (different memory slot and // snapshot key) so execution never bleeds into planning. // --------------------------------------------------------------------------- export const PLANNING_RUN_START_CUSTOM_TYPE = "pi-plans-run-start"; export const PLANNING_PLAN_WRITTEN_CUSTOM_TYPE = "pi-plans-plan-written"; const PLANNING_RESUME_CUSTOM_TYPE = "pi-plans-plan-resume"; // --------------------------------------------------------------------------- // Pre-plan compaction: right after `plans start-run` creates a new planning // run, the extension triggers one VCC compaction so the new plan starts on a // lean context (LLM reasoning degrades with longer context; see PLAN // preplan-compact). The pending flag is session-scoped and opportunistic: it // is set by the start-run tool case and consumed by the plans tool_result // hook in index.ts, which requests the extension-context compact action and // resumes planning exactly once regardless of success or failure. // --------------------------------------------------------------------------- export { PLANNING_PREPLAN_COMPACT_HINT }; export const PLANNING_PREPLAN_RESUME_CUSTOM_TYPE = "pi-plans-preplan-resume"; interface PrePlanCompactPending { runId: string; } export function markPrePlanCompactPending(ctx: ExtensionContext, runId: string): void { const session = ctx.sessionManager as unknown as { __piPlansPrePlanCompact?: PrePlanCompactPending | null }; session.__piPlansPrePlanCompact = { runId }; } export function consumePrePlanCompactPending(ctx: ExtensionContext): PrePlanCompactPending | null { const session = ctx.sessionManager as unknown as { __piPlansPrePlanCompact?: PrePlanCompactPending | null }; const pending = session.__piPlansPrePlanCompact ?? null; session.__piPlansPrePlanCompact = null; return pending; } /** Hidden resume message after the pre-plan compaction settles (success or * failure): Pi's manual compaction never continues the aborted turn, so the * planning workflow is continued exactly once from here. */ export function sendPrePlanCompactResume(pi: ExtensionAPI): void { pi.sendMessage?.( { customType: PLANNING_PREPLAN_RESUME_CUSTOM_TYPE, content: "Continue planning.", display: false, }, { triggerTurn: true }, ); } interface PlanningCompactionState { inFlight: boolean; resumeGuard: boolean; cooldownActive: boolean; lastAttemptReason: "manual" | "threshold" | "overflow" | null; lastSuccessfulUsagePercent: number | null; lastSuccessfulAt: string | null; /** Terminal "nothing to compact" backoff: tokens observed when Pi refused. */ terminalBackoffTokens: number | null; pendingStats: VccCompactionStats | null; pendingFollowUpPrompt: string | null; pendingContinueAfterThresholdCompact: boolean; } function ensurePlanningCompactionState(ctx: ExtensionContext): PlanningCompactionState { const session = ctx.sessionManager as unknown as { __planningCompaction?: PlanningCompactionState }; return (session.__planningCompaction ??= { inFlight: false, resumeGuard: false, cooldownActive: false, lastAttemptReason: null, lastSuccessfulUsagePercent: null, lastSuccessfulAt: null, terminalBackoffTokens: null, pendingStats: null, pendingFollowUpPrompt: null, pendingContinueAfterThresholdCompact: false, }); } function isTerminalCompactionFailure(event: { errorMessage?: string; aborted?: boolean }): { kind: "content" | "abort-stream" } | null { const message = (event.errorMessage ?? "").toLowerCase(); if (message.includes("nothing to compact") || message.includes("already compacted") || message.includes("session too small")) { return { kind: "content" }; } // abort/stream class: explicit event names only, so that provider blips // (network down, etc.) stay retryable. const abortPatterns = [ "this operation was aborted", "aborted", "stream ended before a terminal response event", "turn prefix summarization failed", "auto-compaction failed", "context overflow recovery failed", ]; if (abortPatterns.some((pattern) => message.includes(pattern))) { return { kind: "abort-stream" }; } // Aborted with no recognized message: still an abort-class terminal so the // next eligible turn does not immediately retry the same operation. if (event.aborted === true) { return { kind: "abort-stream" }; } return null; } /** Session-scoped phase-local "compaction in flight" guard. * - Set on `session_before_compact` for the phase attributed by the custom * instructions hint; auto-compaction (no hint) marks both phases defensively. * - Cleared on `session_compact` and `session_compact_failed`. * - Retained so lifecycle events expose the same phase-local state to tests * and future Pi core schema additions. */ type CompactionPhase = "planning" | "execution"; function compactionLifecycleStore(ctx: ExtensionContext): { planning: boolean; execution: boolean; } { const carrier = ctx.sessionManager as unknown as { __piPlansCompactionInFlight?: { planning: boolean; execution: boolean }; }; carrier.__piPlansCompactionInFlight ??= { planning: false, execution: false }; return carrier.__piPlansCompactionInFlight; } function isPlanningCustomInstructions(hint: unknown): boolean { return typeof hint === "string" && hint.startsWith("pi-plans planning"); } function isExecutionCustomInstructions(hint: unknown): boolean { return typeof hint === "string" && hint.startsWith("pi-plans execution"); } export function noteCompactionStarted(ctx: ExtensionContext, customInstructions: unknown): void { const store = compactionLifecycleStore(ctx); if (isPlanningCustomInstructions(customInstructions)) { store.planning = true; } else if (isExecutionCustomInstructions(customInstructions)) { store.execution = true; } else { // Auto-compaction (threshold/overflow/manual without our hint) marks both. store.planning = true; store.execution = true; } } /** Pi core's `SessionCompactEvent` / `SessionCompactFailedEvent` do not carry * `customInstructions` in any emission site, so the END side has no way to * know which phase the compaction belonged to. Clearing both phases is the * safe default — the per-phase start side (above) already encodes the hint * attribution. The hint parameter is retained for API symmetry and future * Pi core schema additions. */ export function noteCompactionEnded(ctx: ExtensionContext, _customInstructions: unknown): void { const store = compactionLifecycleStore(ctx); store.planning = false; store.execution = false; } export function compactionInFlight(ctx: ExtensionContext, phase: CompactionPhase): boolean { const store = compactionLifecycleStore(ctx); return store[phase]; } export function shouldTriggerPlanningCompaction(_ctx: ExtensionContext): boolean { return false; } export function consumePlanningCompactionResumeGuard(ctx: ExtensionContext): boolean { const session = ctx.sessionManager as unknown as { __planningCompaction?: PlanningCompactionState }; if (!session.__planningCompaction?.resumeGuard) return false; session.__planningCompaction.resumeGuard = false; return true; } export function refreshPlanningCompactionCooldown(_ctx: ExtensionContext): void { // Pi core owns scheduling; retained for lifecycle compatibility only. } export function requestPlanningCompaction(_ctx: ExtensionContext): void { // Generic proactive pi-plans compaction is intentionally disabled. Manual, // threshold, and overflow compactions are handled by session_before_compact. // The single exception is the pre-plan compaction: index.ts requests the // extension-context compact action from the plans tool_result hook right // after start-run (see PLANNING_PREPLAN_COMPACT_HINT). } function buildPlanningVccResult(event: SessionBeforeCompactEvent, ctx: ExtensionContext): VccCompactionBuildResult | null { if (getExecution()) return null; const active = activeVccSettings(ctx, "planning"); if (!active) return null; const branchEntries = event.branchEntries as unknown as CompactionEntryLike[]; return buildPiPlansVccCompaction({ branchEntries, preparation: event.preparation, customInstructions: event.customInstructions, reason: event.reason, willRetry: event.willRetry, settings: active.settings, phaseContext: planningVccContext(branchEntries, active), }); } export function buildPlanningCompactionResult(event: SessionBeforeCompactEvent, ctx: ExtensionContext): CompactionResult | null { const built = buildPlanningVccResult(event, ctx); return built?.kind === "compaction" ? built.compaction : null; } export function handlePlanningBeforeCompact( pi: ExtensionAPI, ctx: ExtensionContext, event: SessionBeforeCompactEvent, ): SessionBeforeCompactResult | undefined { if (getExecution()) return undefined; const state = ensurePlanningCompactionState(ctx); state.inFlight = true; state.lastAttemptReason = event.reason; state.pendingStats = null; state.pendingFollowUpPrompt = null; state.pendingContinueAfterThresholdCompact = false; let built: VccCompactionBuildResult | null; try { built = buildPlanningVccResult(event, ctx); } catch (error) { state.inFlight = false; ctx.ui.notify(`pi-plans: VCC planning compaction preparation failed; using Pi default compaction (${String(error)}).`, "warning"); return undefined; } if (!built || built.kind === "fallback") { state.inFlight = false; return undefined; } if (built.kind === "cancel") { state.inFlight = false; ctx.ui.notify(built.message, "warning"); return { cancel: true }; } state.pendingStats = built.stats; state.pendingFollowUpPrompt = built.followUpPrompt; state.pendingContinueAfterThresholdCompact = built.settings.continueAfterThresholdCompact; return { compaction: built.compaction }; } export async function handlePlanningCompact(pi: ExtensionAPI, ctx: ExtensionContext, event: SessionCompactEvent): Promise { if (getExecution()) return; const session = ctx.sessionManager as unknown as { __planningCompaction?: PlanningCompactionState }; const state = session.__planningCompaction; if (!state) return; const stats = state.pendingStats; const followUpPrompt = state.pendingFollowUpPrompt; const continueAfterThresholdCompact = state.pendingContinueAfterThresholdCompact; state.pendingStats = null; state.pendingFollowUpPrompt = null; state.pendingContinueAfterThresholdCompact = false; state.inFlight = false; state.lastAttemptReason = event.reason; state.terminalBackoffTokens = null; state.cooldownActive = true; state.lastSuccessfulAt = utcNow(); state.lastSuccessfulUsagePercent = ctx.getContextUsage()?.percent ?? state.lastSuccessfulUsagePercent; state.resumeGuard = false; if (!event.willRetry && stats) { ctx.ui.notify(formatVccCompactionStats(stats), "info"); if (followUpPrompt) { await pi.sendUserMessage?.(followUpPrompt); } else if ((event.reason === "threshold" || event.reason === "overflow") && shouldScheduleAutoContinue(continueAfterThresholdCompact, runtimePiVersion(ctx))) { state.resumeGuard = true; pi.sendMessage( { customType: PLANNING_RESUME_CUSTOM_TYPE, content: "Continue planning.", display: false, }, { triggerTurn: true }, ); } } } export function handlePlanningCompactFailed(pi: ExtensionAPI, ctx: ExtensionContext, event: SessionCompactFailedEvent): void { if (getExecution()) return; const session = ctx.sessionManager as unknown as { __planningCompaction?: PlanningCompactionState }; const state = session.__planningCompaction; if (!state) return; const terminal = isTerminalCompactionFailure(event); if (terminal) { // Pi refused or aborted the compaction. Hold the cooldown and re-arm // only after real growth or high-watermark pressure so the loop stops. state.inFlight = false; state.resumeGuard = false; state.cooldownActive = true; state.lastAttemptReason = event.reason; const tokens = ctx.getContextUsage()?.tokens; state.terminalBackoffTokens = typeof tokens === "number" ? tokens : Number.POSITIVE_INFINITY; state.pendingStats = null; state.pendingFollowUpPrompt = null; state.pendingContinueAfterThresholdCompact = false; const message = terminal.kind === "content" ? "pi-plans: compaction found nothing to summarize; backing off until the session grows past the keep-recent window." : "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."; ctx.ui.notify(message, "info"); return; } state.inFlight = false; state.resumeGuard = false; state.cooldownActive = false; state.lastAttemptReason = event.reason; state.pendingStats = null; state.pendingFollowUpPrompt = null; state.pendingContinueAfterThresholdCompact = false; ctx.ui.notify( `pi-plans: planning compaction failed (${event.reason}); will try again on the next eligible turn.`, "warning", ); } export function filterPlanningResumeMessages(messages: T[]): T[] { return messages.filter((message) => message.customType !== PLANNING_RESUME_CUSTOM_TYPE && message.customType !== PLANNING_PREPLAN_RESUME_CUSTOM_TYPE); } export async function stopExecution(pi: ExtensionAPI, ctx: ExtensionContext, reason: string): Promise { if (!execution) return; resetExecutionCompactionState(ctx); // Final synchronous write: drain any deferred flush and land the last snapshot. pendingExecutionFlush = false; persist(pi); // Checkpoint first: withExecutionCheckpoint guards on the live execution. withExecutionCheckpoint(ctx, (cp) => applyExecutionStopped(cp, reason)); execution = null; executionRunId = null; goalWaitRuntime = null; pi.appendEntry("pi-plans-exec-cleared", { reason }); pi.sendMessage( { customType: "pi-plans-exec-stop", content: `**pi-plans: execution stopped** — ${reason}`, display: true, }, { triggerTurn: false }, ); const active = resolveActiveRun(ctx.sessionManager, ctx.cwd); if (active) { try { setRunStatus(ctx.cwd, active.run_id, "stopped"); } catch { /* best-effort */ } } updateStatusWidget(ctx); } /** Apply [DONE:VC-xxx] markers from an assistant message. Returns changed ids. */ export function applyDoneMarkers(text: string): string[] { if (!execution) return []; const changed: string[] = []; for (const id of scanDoneMarkers(text)) { const item = execution.items.find((candidate) => candidate.id === id && !candidate.done); if (item) { item.done = true; changed.push(id); } } return changed; } /** * Apply [I-xxx:implemented|validating] markers from an assistant message. * Unknown I-ids are silently ignored; later markers overwrite earlier ones. * Returns the ids whose state actually changed. */ export function applyImplMarkers(text: string): string[] { if (!execution?.implItems?.length) return []; const known = new Set(execution.implItems.map((impl) => impl.id)); execution.implStatus ??= {}; const changed: string[] = []; for (const marker of scanImplMarkers(text)) { if (!known.has(marker.id)) continue; const previous = execution.implStatus[marker.id]; execution.implStatus[marker.id] = marker.state; if (previous !== marker.state) changed.push(marker.id); } return changed; } export function applyCurrentIMarker(text: string): boolean { if (!execution?.implItems?.length) return false; const markers = scanCurrentIMarkers(text); const resolved = resolveCurrentI(execution.implItems, markers, execution.currentI); if (!resolved || resolved === execution.currentI) return false; execution.currentI = resolved; return true; } export function isExecutionComplete(): boolean { return execution !== null && execution.items.length > 0 && execution.items.every((item) => item.done); } function goalWaitSnapshot(): string { if (!execution) return ""; return JSON.stringify({ done: execution.items .filter((item) => item.done) .map((item) => item.id) .sort() .join("|"), implStatus: execution.implStatus ?? {}, currentI: execution.currentI ?? null, }); } function pauseGoalWait(pi: ExtensionAPI, ctx: ExtensionContext, reason: string): void { const ex = getExecution(); if (!ex?.goalWait) return; ex.goalWait.paused = true; ex.goalWait.pausedReason = reason; persist(pi); ctx.ui.notify?.( `pi-plans: goal-wait paused (${reason}). Send any message or run /plans-execute to resume.`, "warning", ); updateStatusWidget(ctx); } function canWakeExecution(ctx: ExtensionContext, runtime: GoalWaitRuntime): boolean { const compaction = executionCompactionState(ctx); return currentGoalWaitRuntime(ctx) === runtime && (ctx.mode === "tui" || ctx.mode === "rpc") && !isExecutionComplete() && !runtime.owner.goalWait?.paused && ctx.isIdle() && !ctx.hasPendingMessages() && !ctx.signal?.aborted && !compactionInFlight(ctx, "execution") && !compaction?.inFlight && !compaction?.resumeGuard && compaction?.pendingFollowUpPrompt == null; } function sendGoalWaitWake(pi: ExtensionAPI, ctx: ExtensionContext, runtime: GoalWaitRuntime): boolean { if (!canWakeExecution(ctx, runtime)) return false; try { // Custom messages bypass before_agent_start, so carry fresh execution rules. const content = executionContextMessage(ctx); if (!content) return false; runtime.wakeId = randomUUID(); pi.sendMessage({ customType: GOAL_WAIT_CUSTOM_TYPE, content, display: false, details: { wakeId: runtime.wakeId }, }, { triggerTurn: true }); return true; } catch (error) { runtime.wakeId = undefined; pauseGoalWait(pi, ctx, `continuation failed: ${String(error)}`); return false; } } /** Only a fully settled agent run can need an extra wake, never a tool turn. */ function maybeGoalWaitFollowUp(pi: ExtensionAPI, ctx: ExtensionContext): void { const runtime = currentGoalWaitRuntime(ctx); if (!runtime || runtime.handled || !ctx.isIdle()) return; if (ctx.mode !== "tui" && ctx.mode !== "rpc") return; if (runtime.stopReason === "error" || runtime.stopReason === "aborted" || ctx.signal?.aborted) { runtime.handled = true; pauseGoalWait(pi, ctx, runtime.stopReason === "error" ? "agent failed" : "agent interrupted"); return; } if (runtime.stopReason !== "stop" || !canWakeExecution(ctx, runtime)) return; runtime.handled = true; const ex = runtime.owner; ex.goalWait ??= { noProgressRounds: 0, waitRounds: 0, lastMarkers: null, paused: false }; const goalWait = ex.goalWait; const snapshot = goalWaitSnapshot(); const changed = goalWait.lastMarkers !== null && snapshot !== goalWait.lastMarkers; goalWait.lastMarkers = snapshot; if (changed) { goalWait.noProgressRounds = 0; goalWait.waitRounds = 0; } else if (/waiting for/i.test(runtime.text)) { goalWait.waitRounds += 1; } else { goalWait.noProgressRounds += 1; } if (goalWait.noProgressRounds >= GOAL_WAIT_MAX_NO_PROGRESS) { pauseGoalWait(pi, ctx, `no progress in ${goalWait.noProgressRounds} rounds`); return; } if (goalWait.waitRounds >= GOAL_WAIT_MAX_WAITING) { pauseGoalWait(pi, ctx, `waiting without progress for ${goalWait.waitRounds} rounds`); return; } persist(pi); updateStatusWidget(ctx); // No await between the live gate and dispatch: another input cannot interleave. sendGoalWaitWake(pi, ctx, runtime); } export function filterGoalWaitMessages(messages: T[]): T[] { return messages.filter((message) => message.customType !== GOAL_WAIT_CUSTOM_TYPE || (goalWaitRuntime?.owner === execution && goalWaitRuntime?.wakeId !== undefined && (message.details as { wakeId?: unknown } | undefined)?.wakeId === goalWaitRuntime.wakeId)); } /** Called only for genuine user input or an explicit same-execution resume. */ export function resumeGoalWaitIfPaused(pi: ExtensionAPI, ctx: ExtensionContext): boolean { const ex = getExecution(); if (!ex?.goalWait?.paused || !currentGoalWaitRuntime(ctx)) return false; ex.goalWait.paused = false; ex.goalWait.pausedReason = undefined; ex.goalWait.noProgressRounds = 0; ex.goalWait.waitRounds = 0; ex.goalWait.lastMarkers = goalWaitSnapshot(); persist(pi); updateStatusWidget(ctx); return true; } export function resumeActiveExecution(pi: ExtensionAPI, ctx: ExtensionContext): boolean { if (!resumeGoalWaitIfPaused(pi, ctx)) return false; const runtime = currentGoalWaitRuntime(ctx)!; if (canWakeExecution(ctx, runtime)) { runtime.handled = true; sendGoalWaitWake(pi, ctx, runtime); } return true; } export async function completeExecution(pi: ExtensionAPI, ctx: ExtensionContext): Promise { if (!execution) return; resetExecutionCompactionState(ctx); // Final synchronous write: drain any deferred flush and land the last snapshot. pendingExecutionFlush = false; persist(pi); const summary = execution.items.map((item) => `- ✅ \`${item.id}\` ${item.text.split(";")[0]}`).join("\n"); const planPath = execution.planPath; // Checkpoint first (live-execution guard), then clear the session state. withExecutionCheckpoint(ctx, (cp) => applyExecutionCompleted(cp)); execution = null; executionRunId = null; goalWaitRuntime = null; pi.appendEntry("pi-plans-exec-cleared", { reason: "complete" }); // Post-execution goal-running continuation: in interactive sessions, attach // the continuation block and trigger a new turn so the agent immediately // enters the implementation-review loop. Headless sessions keep the silent // completion behavior. Both completeExecution call sites (turn_end and the // restoreFromSession recovery path) share this behavior. const interactive = ctx.hasUI === true; // Skill-aware continuation: the reviewer-count default follows the active // run's skill (D-1/D-4), so the prompt names the run's own recommended // count instead of a static guess. const activeRunForPrompt = resolveActiveRun(ctx.sessionManager, ctx.cwd); const content = interactive ? `**Plan complete!** ✅ \`${planPath}\`\n\n${summary}\n\n${ameliorationPromptText(activeRunForPrompt?.skill)}` : `**Plan complete!** ✅ \`${planPath}\`\n\n${summary}`; pi.sendMessage( { customType: "pi-plans-complete", content, display: true, }, { triggerTurn: interactive }, ); if (interactive) { pi.appendEntry("pi-plans-ameliorate", { planPath, phase: "goal-started", rounds: null, currentRound: 0 }); } const active = resolveActiveRun(ctx.sessionManager, ctx.cwd); if (active) { try { setRunStatus(ctx.cwd, active.run_id, "done"); } catch { /* best-effort */ } } updateStatusWidget(ctx); } /** Instructions appended to the post-execution completion message in * interactive sessions, telling the agent to enter the goal-running * implementation-review loop. Skill-aware: the reviewer-count question's * recommended option follows the run's skill (plan-big / plan-with-refs → 3, * others → 1). Termination options are single-sourced from * src/termination-prompt.ts (shared with the ask_choice trailing branch). */ export function ameliorationPromptText(skill: string | undefined): string { return `--- Goal-running continuation: immediately ask the user now via ask_choice (autoComplete: false, in the session language) the termination question: "${TERMINATION_QUESTION}" Options (recommended first): ${renderTerminationOptions()}. ${TERMINATION_RECORDING_INSTRUCTIONS} ${implReviewerCountPromptLine(skill)} Then keep running the implementation-review loop without asking whether to continue; the goal-wait option keeps the loop running until no unpassed VCs remain.`; } /** Injection text for before_agent_start while executing. */ export function executionContextMessage(ctx: ExtensionContext): string | null { if (!execution) return null; const remaining = execution.items.filter((item) => !item.done); const list = remaining.map((item) => `- \`${item.id}\` ${item.text}`).join("\n") || "(none — report completion now)"; // Live read: the injected guidance and the tool wrappers share the same // tri-state, so they can never contradict each other mid-run. const mode = resolveGraphMode(ctx?.cwd ?? process.cwd()); const graphLine = mode === "config-unavailable" ? `${graphBlockForExecutor(false)}\n[pi-plans: config unreadable this turn; graph features are off until .git/pi_plans/config.json is repaired]` : graphBlockForExecutor(mode === "enabled"); // F-002 (impl review r1): the next-action line is hoisted out of the // implItems ternary so plans without implementation items get the same // same-source guidance the panel shows. const nextActionLine = `\nSuggested next action (displayed in the pi-plans panel): ${deriveNextAction(execution, executionIsWaiting(execution), resolveImplStatuses(execution.implItems ?? [], execution.items, execution.implStatus), remaining, execution.currentI)}`; const implementationItems = execution.implItems?.length ? `\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.` : ""; return `[PI-PLANS EXECUTION — write access enabled] Implement the accepted plan at ${execution.planPath} (${execution.items.length - remaining.length}/${execution.items.length} verifier items done). Remaining verifier items: ${list}${implementationItems}${nextActionLine} ${graphLine} Execution rules: - 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. - 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. - 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. - Simplest implementation that fully meets the item: no speculative abstractions, configuration, or indirection; keep components modular with clearly separated concerns. - 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. - 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. - 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: , add when ]\`. - After verifying an item's pass condition with its stated evidence, include \`[DONE:VC-xxx]\` in your reply. - When every item is done, report a completion summary.`; } interface SessionEntry { type: string; customType?: string; data?: ExecState; message?: { role: string; content: Array<{ type: string; text?: string }> }; } /** * Rebuild execution state from the session on start/resume. Finds the last * pi-plans-exec snapshot, then re-scans assistant messages after it for * [DONE:VC-xxx] markers so progress survives restarts. */ export async function restoreFromSession(pi: ExtensionAPI, ctx: ExtensionContext, entries: SessionEntry[]): Promise { pendingExecutionFlush = false; // no flush debt survives a restart goalWaitRuntime = null; resetExecutionCompactionState(ctx); let snapshotIndex = -1; let snapshot: ExecState | null = null; for (let i = entries.length - 1; i >= 0; i--) { const entry = entries[i]; if (entry.type === "custom" && entry.customType === "pi-plans-exec" && entry.data) { snapshot = entry.data; snapshotIndex = i; break; } if (entry.type === "custom" && entry.customType === "pi-plans-exec-cleared") { // Execution was explicitly stopped or completed after the last snapshot. execution = null; updateStatusWidget(ctx); return; } } if (!snapshot) { execution = null; updateStatusWidget(ctx); return; } // Ignore stale plans whose file vanished. if (!fs.existsSync(snapshot.planPath)) { execution = null; updateStatusWidget(ctx); return; } execution = { planPath: snapshot.planPath, items: snapshot.items.map((item) => ({ ...item })), startedAt: snapshot.startedAt, usage: snapshot.usage ?? { inToks: 0, outToks: 0 }, implItems: snapshot.implItems ?? [], implStatus: { ...(snapshot.implStatus ?? {}) }, // D-008: chrome language is re-resolved at restore time from the // CURRENT config rather than trusted from the snapshot, so a // `plans set-language` change survives restarts. uiLanguage: resolveUiLanguage(ctx.cwd), currentI: snapshot.currentI ?? inferCurrentI(snapshot.implItems, snapshot.items, snapshot.implStatus), goalWait: snapshot.goalWait ? { ...snapshot.goalWait } : { noProgressRounds: 0, waitRounds: 0, lastMarkers: null, paused: false }, }; // Distrust the snapshot's implItems: re-parse + re-lint from the plan // file so a stale empty list (older parse or format drift at snapshot // time) cannot freeze a fake "I 0/0" panel after a restart. try { const planText = fs.readFileSync(snapshot.planPath, "utf8"); execution.implItems = parseImplItems(planText); execution.implWarning = lintImplItems(planText); } catch { /* plan file unreadable mid-restore: keep the snapshot values */ } for (let i = snapshotIndex + 1; i < entries.length; i++) { const entry = entries[i]; if (entry.type === "custom" && entry.customType === "pi-plans-exec-cleared") { execution = null; break; } const message = entry.message; if (message && message.role === "assistant") { const text = message.content .filter((part) => part.type === "text") .map((part) => part.text ?? "") .join("\n"); applyDoneMarkers(text); applyImplMarkers(text); applyCurrentIMarker(text); } } if (execution) { resetGoalWaitRuntime(ctx); // Rebind the run identity after a restart so the panel's activity row // (and any run-status mirroring) resolves to the active run instead of // staying null until the next startExecution (CQ1/D-005 wiring gap). const active = resolveActiveRun(ctx.sessionManager, ctx.cwd); executionRunId = active?.run_id ?? null; if (active) bindRun(ctx.sessionManager, ctx.cwd, active.run_id); // D-010: replay may have advanced progress past the persisted baseline. // Recompute the goal-wait markers; new progress resets the guard counters. if (execution.goalWait) { const markerSnapshot = goalWaitSnapshot(); if (markerSnapshot !== execution.goalWait.lastMarkers) { execution.goalWait.lastMarkers = markerSnapshot; execution.goalWait.noProgressRounds = 0; execution.goalWait.waitRounds = 0; } } persist(pi); // refresh snapshot so the next resume has less to rescan if (isExecutionComplete()) { // Completed during the rescan: restore the planning model on the way out. await completeExecution(pi, ctx); } } updateStatusWidget(ctx); }