From af5ca488c95de232398e0a16dc0dc6dda7a016d9 Mon Sep 17 00:00:00 2001 From: Mansur Azatbek Date: Tue, 7 Jul 2026 20:38:34 +0500 Subject: [PATCH 1/2] fix(compact): clear a compaction banner that outlived its timer on resume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compaction indicator could linger over the resumed model: the `aboveEditor`/dashboard "Compacting… 95%" banner stayed up while the model was already thinking, calling planner_status, and running tests after the compaction had ended. The resume hooks (agent_start / turn_start / message_start) tore the banner down only when the closure's own `compactTimer` interval was still live. But the banner line is module-level (mirrored into /planner-dashboard) and can outlive that timer — a re-registration on /reload leaves a fresh interval at null while the old banner line still shows. Gating solely on the timer then skipped the clear and the stale banner survived every resume signal. Consult the durable banner state too: clear when EITHER the interval is live OR the shared banner line is still up. Summarization blocks the agent loop, so any resume signal means the compaction is genuinely over, making the force-clear safe. The clear is a no-op outside planner mode (the banner is only ever set while a plan is active), so it never touches Pi's own UX. Co-Authored-By: Claude Fable 5 --- src/index.ts | 15 ++++++++++++++- src/runtime/compact.test.ts | 33 +++++++++++++++++++++++++++++++++ src/runtime/compact.ts | 21 +++++++++++++++++++++ src/runtime/dashboard.test.ts | 15 +++++++++++++++ src/runtime/dashboard.ts | 11 +++++++++++ 5 files changed, 94 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index e24fba8..e94628b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -85,6 +85,7 @@ import { type PlannerContextBudgetDecision, projectPlannerContextBudget, shouldCancelOverlappingCompaction, + shouldClearStaleCompactIndicator, shouldProactivelyCompact, } from "./runtime/compact"; import { @@ -101,6 +102,7 @@ import { type PlannerContractToolName, } from "./runtime/contracts"; import { + isWorkspaceCompactIndicatorActive, openPlannerWorkspace, registerPlannerDashboard, setWorkspaceCompactIndicator, @@ -3571,7 +3573,18 @@ function registerPlannerCompactEvents( // safety cap. This can never clear a live compaction: summarization blocks the // agent loop, so no turn can start while it is running. const clearStaleCompactIndicator = (ctx: ExtensionContext) => { - if (!compactTimer) return; + // The banner (dashboard-mirrored, module-level) can outlive this closure's + // interval — e.g. a re-registration on /reload leaves a fresh timer at null + // while the "Compacting… 95%" line still shows over the resumed model. Clear + // on either signal so the stale banner never lingers past the resume. + if ( + !shouldClearStaleCompactIndicator({ + timerLive: compactTimer !== null, + bannerVisible: isWorkspaceCompactIndicatorActive(), + }) + ) { + return; + } pendingCompact = null; stopCompactIndicator(ctx); }; diff --git a/src/runtime/compact.test.ts b/src/runtime/compact.test.ts index 257e7c1..68076f4 100644 --- a/src/runtime/compact.test.ts +++ b/src/runtime/compact.test.ts @@ -40,6 +40,7 @@ import { PLANNER_SYSTEM_INSTRUCTIONS_HEADER, projectPlannerContextBudget, shouldCancelOverlappingCompaction, + shouldClearStaleCompactIndicator, shouldProactivelyCompact, } from "./compact"; import type { PlannerPreflightResult } from "./preflight"; @@ -331,6 +332,38 @@ describe("planner compact runtime", () => { }); }); + describe("shouldClearStaleCompactIndicator", () => { + it("clears while the interval is still live", () => { + expect( + shouldClearStaleCompactIndicator({ + timerLive: true, + bannerVisible: false, + }), + ).toBe(true); + }); + + it("clears a banner that outlived its timer (the /reload-decoupled case)", () => { + // The regression: the closure interval was torn down (or re-created at + // null) while the module-level banner line still showed over the resumed + // model. Consulting the banner catches it. + expect( + shouldClearStaleCompactIndicator({ + timerLive: false, + bannerVisible: true, + }), + ).toBe(true); + }); + + it("does nothing when neither the timer nor the banner is up", () => { + expect( + shouldClearStaleCompactIndicator({ + timerLive: false, + bannerVisible: false, + }), + ).toBe(false); + }); + }); + describe("estimatePlannerInstructionTokens", () => { it("mirrors Pi's conservative chars/4 heuristic", () => { expect(estimatePlannerInstructionTokens("")).toBe(0); diff --git a/src/runtime/compact.ts b/src/runtime/compact.ts index 9a497b4..22c56b8 100644 --- a/src/runtime/compact.ts +++ b/src/runtime/compact.ts @@ -136,6 +136,27 @@ export function clearPlannerCompactionInFlight( state.compactionInFlight = false; } +/** + * Decide whether a resume signal (`agent_start` / `turn_start` / `message_start`) + * should tear down the compaction indicator. Summarization blocks the agent loop, + * so the loop only runs *between* compactions — any resume signal therefore means + * the compaction is over and an indicator still up is stale. + * + * We clear when EITHER our per-registration interval is live OR the shared, + * dashboard-mirrored banner line is still showing. Gating on the timer alone + * missed the case where the timer was already torn down (or re-created on + * `/reload`) without clearing the module-level banner: the interval was gone yet + * the "Compacting… 95%" line lingered over the resumed model. Consulting the + * banner line closes that gap for both the plain-chat widget and the + * /planner-dashboard mirror. + */ +export function shouldClearStaleCompactIndicator(input: { + timerLive: boolean; + bannerVisible: boolean; +}): boolean { + return input.timerLive || input.bannerVisible; +} + export function markPlannerControlledCompactStarted( state: PlannerCompactRuntimeState, ): void { diff --git a/src/runtime/dashboard.test.ts b/src/runtime/dashboard.test.ts index 3174c99..77b3fd6 100644 --- a/src/runtime/dashboard.test.ts +++ b/src/runtime/dashboard.test.ts @@ -1,6 +1,7 @@ import { visibleWidth } from "@earendil-works/pi-tui"; import { describe, expect, it } from "vitest"; import { + isWorkspaceCompactIndicatorActive, PlannerWorkspaceComponent, setWorkspaceCompactIndicator, } from "./dashboard"; @@ -232,4 +233,18 @@ describe("PlannerWorkspaceComponent rendering", () => { setWorkspaceCompactIndicator(null); } }); + + it("reports whether a compact banner is currently up", () => { + // The resume hooks in index.ts read this durable, module-level flag to clear + // a banner that outlived its per-registration timer. + try { + expect(isWorkspaceCompactIndicatorActive()).toBe(false); + setWorkspaceCompactIndicator("Compacting 68k tok (threshold) ▓▓░░ 40%"); + expect(isWorkspaceCompactIndicatorActive()).toBe(true); + setWorkspaceCompactIndicator(null); + expect(isWorkspaceCompactIndicatorActive()).toBe(false); + } finally { + setWorkspaceCompactIndicator(null); + } + }); }); diff --git a/src/runtime/dashboard.ts b/src/runtime/dashboard.ts index 730ebd8..d273566 100644 --- a/src/runtime/dashboard.ts +++ b/src/runtime/dashboard.ts @@ -112,6 +112,17 @@ export function setWorkspaceCompactIndicator(line: string | null): void { compactIndicatorListener?.(); } +/** + * True while a compaction banner is currently showing anywhere. This is the one + * durable, module-level record of "an indicator is up" — the per-registration + * closure timer in index.ts can be torn down (or re-created on /reload) without + * clearing this line, so the resume hooks consult it to catch a banner that + * outlived its timer instead of gating solely on the timer. + */ +export function isWorkspaceCompactIndicatorActive(): boolean { + return compactIndicatorLine !== null; +} + export function registerPlannerDashboard(pi: ExtensionAPI): void { pi.registerCommand("planner-dashboard", { description: From 9c646d380dc032d69ea716d7934a8366f954c2e9 Mon Sep 17 00:00:00 2001 From: Mansur Azatbek Date: Tue, 7 Jul 2026 20:40:36 +0500 Subject: [PATCH 2/2] build: realign package.json version with the published npm release (0.10.0) package.json drifted to 0.11.0 in #85 while the latest npm release is still 0.10.0 (package-lock.json was never bumped, so it already read 0.10.0). The working tree should track the published version; the release bump is applied by CI from the tag, not carried ahead in main. Reset to 0.10.0 so the two match again. Co-Authored-By: Claude Fable 5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 579e41e..838e516 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pi-code-planner", - "version": "0.11.0", + "version": "0.10.0", "description": "Structured planning, TDD, and Git worktrees for Pi local coding agents", "keywords": [ "pi-package",