From ccacdc3faea8b2945416c72fe43acb7aefebb529 Mon Sep 17 00:00:00 2001 From: Mansur Azatbek Date: Mon, 27 Jul 2026 13:51:36 +0500 Subject: [PATCH 1/4] fix(workflow): refuse to clear a compact boundary while the compaction runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit planner_request_compact fires ctx.compact() and returns text asking the model not to continue. Nothing enforced it. In one measured session the model cleared the boundary immediately and walked the whole of finalize — doubt review, verification, final summary, through to done — while summarization was still running. compact_before_doubt exists so the audit reads persisted artifacts instead of live confidence; run that way it achieved the exact opposite, and none of those four minutes reached the session file, because the SDK disconnects the session from agent events for the duration of a compaction. The exit gate now refuses complete_compact while a compaction this extension asked for is in flight. The flag is passed in rather than read from a module: whether a compaction is running is the host's knowledge, and the planner's own recovery paths must be able to release a boundary precisely when one IS in flight — they omit the flag and say why. Co-Authored-By: Claude Opus 5 --- src/index.ts | 5 ++ src/runtime/workflow-tools.test.ts | 87 ++++++++++++++++++++++++++++++ src/runtime/workflow-tools.ts | 29 +++++++++- 3 files changed, 119 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index 0af23d9..bea051a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3414,6 +3414,11 @@ function registerPlannerTools( projectPaths, toolName, params, + // The model may not clear a compact boundary while the compaction + // it asked for is still running. The planner's own resolve paths + // omit this deliberately — they run when the boundary must be + // released without compacting. + compactionInFlight: isPlannerCompactionInFlight(compactRuntime), }); const compact = await maybeStartPlannerControlledCompact({ pi, diff --git a/src/runtime/workflow-tools.test.ts b/src/runtime/workflow-tools.test.ts index 039a0f7..4a2987e 100644 --- a/src/runtime/workflow-tools.test.ts +++ b/src/runtime/workflow-tools.test.ts @@ -110,6 +110,93 @@ describe("workflowToolTransition", () => { expect(result.text).toContain("Call planner_status"); }); + describe("the compact boundary while a compaction runs", () => { + // finalize/compact_before_doubt with the boundary pending — the exact shape + // planner_request_compact leaves behind. + async function pendingCompactBoundary() { + const fs = new MockPlannerFs(); + const git = new MockGitRunner(); + const projectPaths = createProjectStoragePaths({ + agentDir: "/agent", + projectRoot: "/repo/app", + }); + const planPaths = createPlanStoragePaths(projectPaths, "plan-a"); + const worktreePath = "/repo/app/.pi/pi-code-planner/worktrees/plan-a"; + await ensureProjectRecord(fs, projectPaths); + await initializePlanFiles( + fs, + planPaths, + createPlanRecord({ planId: "plan-a", title: "Plan A" }), + ); + await fs.mkdirp(worktreePath); + await initializePlanState(fs, planPaths, { + ...createInitialPlanState({ + baseBranch: "main", + planBranch: "plan/plan-a", + worktreePath, + }), + stage: "finalize", + step: "compact_before_doubt", + stepStatus: "blocked", + requiresCompact: true, + blockedReason: "Planner compact boundary is required.", + currentBranch: "plan/plan-a", + }); + await setActivePlan(fs, projectPaths, "plan-a"); + return { fs, git, projectPaths }; + } + + it("refuses to clear the boundary while the compaction is still running", async () => { + // One measured session cleared it at once and then walked the whole of + // finalize on the context the boundary exists to drop. + const { fs, git, projectPaths } = await pendingCompactBoundary(); + + const result = await executePlannerWorkflowTool({ + fs, + git, + projectPaths, + toolName: "planner_complete_compact", + params: {}, + compactionInFlight: true, + }); + + expect(result.result.status).toBe("blocked"); + expect(result.text).toContain("still running"); + }); + + it("clears the boundary once the compaction has finished", async () => { + const { fs, git, projectPaths } = await pendingCompactBoundary(); + + const result = await executePlannerWorkflowTool({ + fs, + git, + projectPaths, + toolName: "planner_complete_compact", + params: {}, + compactionInFlight: false, + }); + + expect(result.result.status).toBe("applied"); + }); + + it("lets a caller that omits the flag resolve the boundary anyway", async () => { + // The planner's own recovery paths run precisely when a compaction is in + // flight or has just failed, and must be able to release the boundary + // rather than leave the session frozen on it. + const { fs, git, projectPaths } = await pendingCompactBoundary(); + + const result = await executePlannerWorkflowTool({ + fs, + git, + projectPaths, + toolName: "planner_complete_compact", + params: {}, + }); + + expect(result.result.status).toBe("applied"); + }); + }); + it("blocks discovery finish until discovered contracts are routed and read", async () => { const fs = new MockPlannerFs(); const git = new MockGitRunner(); diff --git a/src/runtime/workflow-tools.ts b/src/runtime/workflow-tools.ts index 43a13a4..95103a9 100644 --- a/src/runtime/workflow-tools.ts +++ b/src/runtime/workflow-tools.ts @@ -67,8 +67,18 @@ export type PlannerWorkflowToolName = type PlannerTargetPosition = { stage: PlannerStage; step: PlannerStep }; -export type PlannerWorkflowToolExecutionInput = - PlannerToolExecutionInput; +export interface PlannerWorkflowToolExecutionInput + extends PlannerToolExecutionInput { + /** + * True while a compaction this extension asked for is between the request and + * its completion. Passed in rather than read from anywhere, because whether a + * compaction is running is runtime knowledge that lives in the extension host, + * and because the planner's own recovery paths must be able to resolve a + * boundary precisely when one *is* in flight — they call this without the flag + * and say so on purpose. + */ + compactionInFlight?: boolean; +} export interface PlannerWorkflowToolExecutionResult { text: string; @@ -136,6 +146,7 @@ export async function executePlannerWorkflowTool( ...input, orchestrator, transition, + compactionInFlight: input.compactionInFlight === true, }); if (exitBlock) { const state = @@ -373,7 +384,21 @@ async function validateWorkflowExit(input: { git: GitRunner; orchestrator: Awaited>; transition: PlannerStateTransition; + compactionInFlight?: boolean; }): Promise { + // The compact boundary has to hold, or it means nothing. Nothing used to + // check that the compaction the model asked for had actually happened: the + // tool result asked it to wait, and one measured session shows it did not — + // it cleared the boundary at once and walked the whole of finalize (doubt + // review, verification, final summary) while summarization was still running. + // compact_before_doubt exists so the audit reads persisted artifacts instead + // of live confidence; run that way it achieves the exact opposite. + if ( + input.transition.type === "complete_compact" && + input.compactionInFlight + ) { + return "The compaction you requested is still running. Wait for it to finish — the boundary cannot be cleared while the context it exists to drop is still in the conversation. When it completes you will be told to call planner_status; call planner_complete_compact after that."; + } if ( input.transition.type !== "finish_step" || input.orchestrator.preflight.context.status !== "ready" From 102f252be45429116b9bdadb150c7a101d58e8a7 Mon Sep 17 00:00:00 2001 From: Mansur Azatbek Date: Mon, 27 Jul 2026 13:55:03 +0500 Subject: [PATCH 2/4] fix(compact): send planner messages as a turn when nothing is running MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deliverAs:"followUp" is a streaming mode. The agent loop drains that queue only where it would otherwise stop, so with no run in flight the message is not queued behind anything — it sits in the input queue as if the user had typed it and never pressed enter. A planner-controlled compaction aborts the run on its way in, so the post-compact instruction landed in exactly that state. One measured session sat two hours waiting for a keypress. Follow-up delivery still exists for the reason it was introduced — a compaction that finishes late must not interrupt the run it landed in — so the choice is now made rather than assumed, in one place all three senders share. Where idle is unknown (the watchdog before its first event) the guess is "running": delivered late beats not delivered, and the same reasoning covers the race, so a turn refused by a run that just started falls back to the queue. enqueuePlannerPostCompactMessage had taken isIdle since it was written and ignored it; hasPendingMessages was never used at all and is gone. Both compact-path sends now happen on the next macrotask. compact() disconnects the session from agent events for its whole duration and reconnects in a `finally` that has not run when session_compact fires — a turn started inside the handler would run against a session that is not listening, which is how four minutes of work went missing from the session file. Co-Authored-By: Claude Opus 5 --- src/index.ts | 80 +++++++++++++++++++++++++++++-------- src/runtime/compact.test.ts | 42 ++++++------------- src/runtime/compact.ts | 46 ++++++++++++++++++++- 3 files changed, 120 insertions(+), 48 deletions(-) diff --git a/src/index.ts b/src/index.ts index bea051a..df89510 100644 --- a/src/index.ts +++ b/src/index.ts @@ -59,6 +59,7 @@ import { import { buildPlannerCompactInstructionBundle, buildPlannerPostCompactMessage, + choosePlannerMessageDelivery, clearPlannerCompactionInFlight, clearPlannerControlledCompact, collectAutoCompactInstructionSections, @@ -1414,6 +1415,18 @@ interface PlannerIdleRuntimeState { latestCwd: string | null; checking: boolean; timer: ReturnType | null; + /** + * Whether the agent is running, read from the most recent event context. + * + * The watchdog ticks on a timer and has no context of its own, but it must + * know: a wake sent as a follow-up while nothing is running is never + * delivered, it just sits in the input queue. Refreshed wherever `latestCwd` + * is, so it is exactly as current as the cwd the tick already relies on, and + * null before the first event — which the caller reads as "assume running", + * the safe direction, since a follow-up sent too early is delivered late + * rather than lost. + */ + isIdle: (() => boolean) | null; } export default function piCodePlannerExtension(pi: ExtensionAPI): void { @@ -1422,6 +1435,7 @@ export default function piCodePlannerExtension(pi: ExtensionAPI): void { latestCwd: null, checking: false, timer: null, + isIdle: null, }; const timerRuntime = createPlannerTimerRuntimeState(); installPlannerToolErrorBoundary(pi); @@ -3882,13 +3896,21 @@ function registerPlannerCompactEvents( preflight, }); const message = buildPlannerPostCompactMessage({ preflight, sections }); - enqueuePlannerPostCompactMessage({ - message, - isIdle: ctx.isIdle(), - hasPendingMessages: ctx.hasPendingMessages(), - sendUserMessage: (content, options) => - pi.sendUserMessage(content, options as never), - }); + // Deliver on the next macrotask, not from inside this handler. `compact()` + // disconnects the session from agent events for its whole duration and + // reconnects in a `finally` that has not run yet when `session_compact` is + // emitted — a turn started here would run against a session that is not + // listening, which is how four minutes of real work went missing from a + // measured session file. The same deferral `ctx.compact()` itself uses. + // Idle is re-read at that point because it is only then that it matters. + setTimeout(() => { + enqueuePlannerPostCompactMessage({ + message, + isIdle: ctx.isIdle(), + sendUserMessage: (content, options) => + pi.sendUserMessage(content, options as never), + }); + }, 0); }); // The agent resuming work is our only extension-visible signal that a @@ -3928,9 +3950,11 @@ function registerPlannerIdleWatchdog( ): void { pi.on("session_start", async (_event, ctx) => { runtime.latestCwd = ctx.cwd; + runtime.isIdle = () => ctx.isIdle(); }); pi.on("tool_call", async (_event, ctx) => { runtime.latestCwd = ctx.cwd; + runtime.isIdle = () => ctx.isIdle(); }); if (runtime.timer) { @@ -3982,7 +4006,21 @@ async function runPlannerIdleWatchdogTick( markPlannerIdleWakeQueued(state, decision.timestamp), ); try { - pi.sendUserMessage(decision.message, FOLLOW_UP_MESSAGE_OPTIONS as never); + // A wake exists to reach a model that stopped, so it is normally sent + // into an idle session — and a follow-up queued with nothing running is + // never delivered. Unknown idleness (no event seen yet) is treated as + // running: a follow-up that arrives late still arrives. + const delivery = choosePlannerMessageDelivery({ + isIdle: runtime.isIdle?.() === true, + }); + if (delivery === "turn") { + pi.sendUserMessage(decision.message); + } else { + pi.sendUserMessage( + decision.message, + FOLLOW_UP_MESSAGE_OPTIONS as never, + ); + } } catch (error) { await updatePlanState(fs, context.planPaths, (state) => ({ ...state, @@ -4237,14 +4275,24 @@ async function handlePlannerCompactError(input: { formatPlannerCompactFailure(input.error, { boundaryResolved: resolved }), benign ? "info" : "error", ); - if (resolved) { - input.pi.sendUserMessage( - formatPlannerCompactSkipped( - benign ? "session too small to compact" : "compaction failed", - ), - FOLLOW_UP_MESSAGE_OPTIONS, - ); - } + if (!resolved) return; + const message = formatPlannerCompactSkipped( + benign ? "session too small to compact" : "compaction failed", + ); + // A failing ctx.compact() aborts the run on its way in, so this usually lands + // on an idle session — where a follow-up is never delivered. Deferred for the + // same reason the post-compact message is: compact() reconnects the session to + // the agent in a `finally` that has not run yet. + setTimeout(() => { + const delivery = choosePlannerMessageDelivery({ + isIdle: input.ctx.isIdle(), + }); + if (delivery === "turn") { + input.pi.sendUserMessage(message); + } else { + input.pi.sendUserMessage(message, FOLLOW_UP_MESSAGE_OPTIONS); + } + }, 0); } async function maybeStartPlannerStuckCompact(input: { diff --git a/src/runtime/compact.test.ts b/src/runtime/compact.test.ts index 7bdeb0c..ac651f4 100644 --- a/src/runtime/compact.test.ts +++ b/src/runtime/compact.test.ts @@ -92,7 +92,12 @@ describe("planner compact runtime", () => { expect(message).toContain("Check git state before resuming."); }); - it("queues post-compact instructions behind pending user messages", () => { + it("starts a turn when nothing is running, because a follow-up would not be delivered", () => { + // The defect this replaces: the post-compact instruction was always queued + // as a follow-up, and the agent loop drains that queue only where it would + // otherwise stop. A planner-controlled compaction aborts the run, so the + // message landed in an idle session and sat in the input queue — one + // measured session waited two hours for a keypress. const calls: Array<{ message: string; options?: { deliverAs: "followUp" }; @@ -102,45 +107,22 @@ describe("planner compact runtime", () => { enqueuePlannerPostCompactMessage({ message: "[SYSTEM_INSTRUCTIONS]\nCall planner_status.", isIdle: true, - hasPendingMessages: true, sendUserMessage(message, options) { calls.push({ message, options }); }, }), - ).toBe("followUp"); - expect(calls).toEqual([ - { - message: "[SYSTEM_INSTRUCTIONS]\nCall planner_status.", - options: { deliverAs: "followUp" }, - }, - ]); - }); - - it("queues post-compact instructions even when Pi reports idle", () => { - const calls: Array<{ - message: string; - options?: { deliverAs: "followUp" }; - }> = []; - - expect( - enqueuePlannerPostCompactMessage({ - message: "[SYSTEM_INSTRUCTIONS]\nCall planner_status.", - isIdle: true, - hasPendingMessages: false, - sendUserMessage(message, options) { - calls.push({ message, options }); - }, - }), - ).toBe("followUp"); + ).toBe("turn"); expect(calls).toEqual([ { message: "[SYSTEM_INSTRUCTIONS]\nCall planner_status.", - options: { deliverAs: "followUp" }, + options: undefined, }, ]); }); it("queues post-compact instructions while Pi is still processing", () => { + // The reason follow-up delivery exists and stays: a compaction that finishes + // late must not interrupt the run it landed in. const calls: Array<{ message: string; options?: { deliverAs: "followUp" }; @@ -150,7 +132,6 @@ describe("planner compact runtime", () => { enqueuePlannerPostCompactMessage({ message: "[SYSTEM_INSTRUCTIONS]\nCall planner_status.", isIdle: false, - hasPendingMessages: false, sendUserMessage(message, options) { calls.push({ message, options }); }, @@ -165,6 +146,8 @@ describe("planner compact runtime", () => { }); it("falls back to follow-up when idle state races with active processing", () => { + // Idle was read a moment earlier; a run may have started since. Starting a + // turn into a live run is the one case that throws rather than queueing. const calls: Array<{ message: string; options?: { deliverAs: "followUp" }; @@ -174,7 +157,6 @@ describe("planner compact runtime", () => { enqueuePlannerPostCompactMessage({ message: "[SYSTEM_INSTRUCTIONS]\nCall planner_status.", isIdle: true, - hasPendingMessages: false, sendUserMessage(message, options) { if (!options) { throw new Error( diff --git a/src/runtime/compact.ts b/src/runtime/compact.ts index 569ac2a..0cf9611 100644 --- a/src/runtime/compact.ts +++ b/src/runtime/compact.ts @@ -42,7 +42,39 @@ export interface PlannerCompactInstructionSection { appendPath: string | null; } -export type PlannerPostCompactDelivery = "followUp"; +/** + * How a planner message reaches the model. + * + * `followUp` queues behind a run that is still going; `turn` starts one. They + * are not interchangeable, and picking the wrong one loses the message — see + * {@link choosePlannerMessageDelivery}. + */ +export type PlannerPostCompactDelivery = "followUp" | "turn"; + +/** + * Which delivery a message needs right now. + * + * `deliverAs: "followUp"` is a *streaming* mode. The agent loop drains the + * follow-up queue only where it would otherwise stop, so a follow-up queued + * while a run is going is delivered at the end of it — which is exactly what + * this wants, and why the mode was introduced: a compaction that finished late + * used to drop its instruction into a turn that had already moved on. + * + * But with no run in flight there is nothing to queue behind. The message sits + * in the input queue as if the user had typed it and never pressed enter. That + * is the observed failure: a planner-controlled compaction aborts the run, so + * by the time `session_compact` fires the agent is idle, the post-compact + * instruction is queued, and the session waits — one measured session sat two + * hours that way. + * + * Idle is therefore the whole question, and `sendUserMessage` with no options + * always starts a turn. + */ +export function choosePlannerMessageDelivery(input: { + isIdle: boolean; +}): PlannerPostCompactDelivery { + return input.isIdle ? "turn" : "followUp"; +} export function createPlannerCompactRuntimeState(): PlannerCompactRuntimeState { return { @@ -251,12 +283,22 @@ export function buildPlannerPostCompactMessage(input: { export function enqueuePlannerPostCompactMessage(input: { message: string; isIdle: boolean; - hasPendingMessages: boolean; sendUserMessage: ( message: string, options?: { deliverAs: "followUp" }, ) => void; }): PlannerPostCompactDelivery { + const delivery = choosePlannerMessageDelivery({ isIdle: input.isIdle }); + if (delivery === "turn") { + try { + input.sendUserMessage(input.message); + return "turn"; + } catch { + // Idle was read a moment ago and a run may have started since. Starting a + // turn into a live run is the one case that throws instead of queueing, + // so fall back to the queue: delivered late beats not delivered. + } + } input.sendUserMessage(input.message, { deliverAs: "followUp" }); return "followUp"; } From dd1aea19bfc526266667814869e34bd361646a66 Mon Sep 17 00:00:00 2001 From: Mansur Azatbek Date: Mon, 27 Jul 2026 13:55:58 +0500 Subject: [PATCH 3/4] fix(watchdog): let the compact rescue refuse for the same reasons the gate does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rescue exists to unfreeze a compact boundary that neither completed nor errored, and to do that it bypasses the idle gate entirely. Every reason that gate would have refused for therefore has to be restated, or it is silently lost — and it named only `broken` and `requiresUserDecision`, which leaves out the rest of "the next move is the user's". The done stage sets no flag at all; it is recognised only through isPlannerWaitingOnUser, which is what the post-compact enqueue already consults. A boundary pending at goal approval or awaiting acceptance would have woken the model there. Co-Authored-By: Claude Opus 5 --- src/runtime/idle-watchdog.test.ts | 20 ++++++++++++++++++++ src/runtime/idle-watchdog.ts | 14 ++++++++++---- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/runtime/idle-watchdog.test.ts b/src/runtime/idle-watchdog.test.ts index 5598b38..79b8382 100644 --- a/src/runtime/idle-watchdog.test.ts +++ b/src/runtime/idle-watchdog.test.ts @@ -220,6 +220,26 @@ describe("planner idle watchdog", () => { ).toMatchObject({ action: "disabled" }); }); + it("does not rescue a pending compact where the next move is the user's", () => { + // The rescue bypasses the whole idle gate, so it has to restate every reason + // that gate would have refused for. `done` sets no requiresUserDecision flag + // — it has to be recognised through isPlannerWaitingOnUser, or a boundary + // left pending here would wake the model while the user is the one to act. + for (const position of [ + { stage: "done" as const, step: "await_user_acceptance" as const }, + { stage: "intake" as const, step: "await_goal_approval" as const }, + ]) { + expect( + evaluatePlannerIdleWake({ + state: { ...stuckCompact(), ...position }, + settings, + now: 601_000, + compactionInFlight: false, + }), + ).toMatchObject({ action: "disabled" }); + } + }); + it("waits for the timeout before rescuing a stuck compact", () => { expect( evaluatePlannerIdleWake({ diff --git a/src/runtime/idle-watchdog.ts b/src/runtime/idle-watchdog.ts index 16ccfde..d768688 100644 --- a/src/runtime/idle-watchdog.ts +++ b/src/runtime/idle-watchdog.ts @@ -76,9 +76,15 @@ export function evaluatePlannerIdleWake(input: { }; } -// A pending compact boundary with no compaction in flight, at a normal (not -// broken / user-decision) position — the exact shape a failed or hung -// compaction leaves behind. +// A pending compact boundary with no compaction in flight, at a normal position — +// the exact shape a failed or hung compaction leaves behind. +// +// The rescue bypasses the whole idle gate, so every reason that gate would have +// refused for has to be restated here or it is silently lost. It used to name +// only `broken` and `requiresUserDecision`, which left out the rest of "the next +// move is the user's" — the done stage, goal approval, unanswered questions. A +// boundary that happened to be pending at one of those would have woken the +// model while the user was the one expected to act. function isStuckCompactBoundary( state: PlanStateRecord, compactionInFlight: boolean, @@ -87,7 +93,7 @@ function isStuckCompactBoundary( state.requiresCompact && !compactionInFlight && !state.broken && - !state.requiresUserDecision + !isPlannerWaitingOnUser(state) ); } From f273e96ed353f59ee50192601d613c61efd3ee95 Mon Sep 17 00:00:00 2001 From: Mansur Azatbek Date: Mon, 27 Jul 2026 13:56:42 +0500 Subject: [PATCH 4/4] docs(runtime): record the compact boundary, delivery choice, and rescue gate Co-Authored-By: Claude Opus 5 --- src/runtime/AGENTS.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/runtime/AGENTS.md b/src/runtime/AGENTS.md index 00af7a8..a97249e 100644 --- a/src/runtime/AGENTS.md +++ b/src/runtime/AGENTS.md @@ -103,14 +103,14 @@ Runtime domain for planner stages, model-facing status, tool wrappers, timers, r **Timers / watchdog** — background wake-ups outside the tool-call path. - `timer.ts` → `PlannerTimerRuntimeState` + reconcile loop; reads `active-plan.ts`, gated by `index.tool-visibility.ts` `isPlanActive`, writes `PlannerTimerState` via `storage/state-store.ts`. -- `idle-watchdog.ts` → reads `state.activeTaskId`/`state.step` → emits a follow-up wake-up message if no activity for `idle.timeoutMinutes`; only fires for `IDLE_EXECUTION_STEPS` while not in a `USER_WAIT_STEPS`/blocked/compact state. +- `idle-watchdog.ts` → reads `state.activeTaskId`/`state.step` → emits a wake-up message if no activity for `idle.timeoutMinutes`; only fires for `IDLE_EXECUTION_STEPS` while not in a `USER_WAIT_STEPS`/blocked/compact state. A pending compact boundary with no compaction in flight is the one case that **bypasses** that gate (a compaction that neither completed nor errored would otherwise freeze the session forever) — so every refusal the gate makes has to be restated in `isStuckCompactBoundary`, which is why it asks `isPlannerWaitingOnUser` rather than only `requiresUserDecision`. **Misc** -- `compact.ts` → builds the system-instructions bundle injected after a context compact (`PLANNER_COMPACT_MARKER`/`PLANNER_SYSTEM_INSTRUCTIONS_HEADER`), pulling section content from `instructions/manager.ts`. +- `compact.ts` → builds the system-instructions bundle injected after a context compact (`PLANNER_COMPACT_MARKER`/`PLANNER_SYSTEM_INSTRUCTIONS_HEADER`), pulling section content from `instructions/manager.ts`. It also owns **how** a planner message reaches the model: `choosePlannerMessageDelivery` is the single answer for all three senders (post-compact, idle wake, compact-failure notice). `deliverAs: "followUp"` is a *streaming* mode — the agent loop drains that queue only where it would otherwise stop — so with no run in flight a follow-up is never delivered, it just sits in the input queue. A planner-controlled compaction aborts the run on its way in, which is exactly that state. Idle → a plain `sendUserMessage` (always starts a turn); running → follow-up, which is why the mode exists (a late compaction must not interrupt the run it landed in). Unknown idleness counts as running: delivered late beats not delivered. Both compact-path sends are deferred one macrotask, because `ctx.compact()` disconnects the session from agent events and reconnects in a `finally` that has not run when `session_compact` fires. - **Window management belongs to Pi, not to the planner.** The extension no longer decides *when* to compact for context relief: the window-management compact steps (`compact_discovery`/`spec`/`planning`/`task` and `compact_finalize`) were removed from `PLANNER_STAGE_STEPS`, and the proactive `turn_end` monitor that replaced them (an output-aware token floor) has now been removed too. Reason: the floor was derived from `model.maxTokens` because an extension cannot read Pi's `reserveTokens`, so the two thresholds land wherever the user's settings happen to put them. On a real session (window 131072, `reserveTokens` 24576) our floor sat at 106823 against Pi's 106496 — 0.25% of the window apart, i.e. a guaranteed race instead of the intended separation, and a second compaction started right after Pi's leaves nothing to summarize (`prepareCompaction` measures only what accumulated since the previous cut point, so it throws "Nothing to compact (session too small)" while the window is 96% full). Pi's own auto-compaction sees `reserveTokens` and cannot race itself, so it is the only trigger now. The one surviving compact step is `compact_before_doubt` — a deliberate confidence reset before the doubt audit, not window relief — so `isPlannerCompactEnabled` returns true for it unconditionally and `planner_request_compact` runs it with no token gate at all. Everything that reacts *after* a compaction is untouched and works for Pi's compactions exactly as it did for ours: the `session_before_compact` indicator, the `session_compact` handler, and the post-compact `[SYSTEM_INSTRUCTIONS]` bundle. A legacy `state.json` parked at a removed step is remapped forward by `normalizePlanState` (`storage/state-store.ts`). The `compactBoundaries` (`compact.stage`/`compact.task`) setting and the `state.compactBoundaries` field were removed with the boundaries they gated — a legacy `settings.json` with a `compact` block gets an "unknown key" note, and a legacy `state.json` with a `compactBoundaries` field just ignores it. - `prefix-watch.ts` → pure probe over the actual provider payload (`before_provider_request`). Tool schemas render INTO the system message, so they sit at the very front of the prompt, and every serving backend reuses exactly one thing: a prefix of bytes it already read. `describePayload` reduces a payload to `headChars`/`toolNames`/`toolChars`/`headKey`; `comparePayloads` reports what the head gained and lost; `PrefixWatch` holds ONE previous shape — no history, no defect log, nothing to report from later — and separates *when* (between runs is expected; between two calls of ONE run the prefix was discarded for nothing) from *who* (`setActiveTools` is a global setter and this extension is not the only writer, so `record()` takes the list WE last set — `plannerLastSetToolNames()`). Only mid-run AND foreign earns an alarm; wired in `index.ts` `registerPlannerPrefixWatch`, one `ctx.ui.notify` per run, nothing sent anywhere. It stays armed because the foreign writer is real, not hypothetical: the same session runs pi-telegram-manager, which answers `session_before_compact` with its own compaction. Ported from that extension's `core/payload-probe.ts`. - `compact-eta.ts` → pure empirical ETA for the compaction indicator. `estimateCompactionDuration` fits `T(x)=a+b·x` (weighted least squares, recency- and model-weighted; falls back to a through-origin rate model) over `storage/compact-timing-store.ts` history and reports a point ETA + band + variance (`cv` → `stable`/`noisy`/`single`/`none`). `compactionProgressFraction` drives an honest asymptotic bar that fills to a confidence-scaled target at the ETA and never reaches 100% before the real completion event. Wired in `index.ts` `registerPlannerCompactEvents`, gated by `isPlanActive`; the SDK does not stream summary generation, so this learned model is the only real progress signal. - `skill-library.ts` → `planner_skill_create`/`planner_skill_update`: persists reusable skill markdown + a JSON index (`storage/json.ts`) keyed by source kind (`stuck`/`debug`/`doubt_review`/…); `status.ts` lists active skills from here. - `question-tools.ts` → `planner_questions_submit`/`planner_questions_resolve`, the user-clarification loop; writes state via `storage/state-store.ts`. -- `workflow-tools.ts` → step-finish exit gates: blocks `finish_step` unless required artifacts/sections exist (via `contracts.ts`, `doubt-review.ts`) and the worktree is clean (`git-state-sync.ts` reality). The last checkpoint before `state-transition.ts` is allowed to advance. +- `workflow-tools.ts` → step-finish exit gates: blocks `finish_step` unless required artifacts/sections exist (via `contracts.ts`, `doubt-review.ts`) and the worktree is clean (`git-state-sync.ts` reality). The last checkpoint before `state-transition.ts` is allowed to advance. It also refuses `complete_compact` while `compactionInFlight` — passed in by the caller, never read from a module, because whether a compaction is running is the host's knowledge and the planner's own recovery paths must be able to release a boundary precisely when one *is* in flight. Without that refusal the compact boundary is decorative: nothing checked that the requested compaction had happened, and a measured session cleared it at once and finished the whole plan on the context the boundary exists to drop.