From 045a92e0083af0201b0c6d21ea6f16ba97759348 Mon Sep 17 00:00:00 2001 From: testikun <320479488+testikun@users.noreply.github.com> Date: Thu, 17 Sep 2026 15:41:54 +0900 Subject: [PATCH 1/2] fix(tui): distinguish cleanup confirmation from running bash --- ..._544_CLEANUP_CONFIRMATION_UI_2026-09-17.md | 23 ++++ docs/research/README.md | 2 + extensions/file-mutation-display/index.ts | 39 +++++- extensions/file-mutation-display/render.ts | 35 +++++- extensions/shared/tool-activity.ts | 5 +- extensions/shared/tool-confirmation.ts | 2 + extensions/workspace-cleanup-guard/index.ts | 23 +++- .../file-mutation-display/index.test.ts | 64 +++++++++- .../file-mutation-display/render.test.ts | 113 ++++++++++++++++++ .../workspace-cleanup-guard/index.test.ts | 38 ++++++ 10 files changed, 333 insertions(+), 11 deletions(-) create mode 100644 docs/research/ISSUE_544_CLEANUP_CONFIRMATION_UI_2026-09-17.md create mode 100644 extensions/shared/tool-confirmation.ts diff --git a/docs/research/ISSUE_544_CLEANUP_CONFIRMATION_UI_2026-09-17.md b/docs/research/ISSUE_544_CLEANUP_CONFIRMATION_UI_2026-09-17.md new file mode 100644 index 00000000..83e06917 --- /dev/null +++ b/docs/research/ISSUE_544_CLEANUP_CONFIRMATION_UI_2026-09-17.md @@ -0,0 +1,23 @@ +# Cleanup confirmation is not Bash execution + +- Status: validated at the source and local TUI boundaries described below. +- Created and verified: 2026-09-17. +- Source boundary: OpenPI main `f6b49ae59605b1276b8267f2886d22c03f01533c`, Pi 0.85.1, with a single local OpenPI source reported by `pi list`. +- Related Issue: [#544](https://github.com/openpi-dev/openpi/issues/544). +- Supersedes: none. + +## Observation + +Pi emits `tool_execution_start` before it awaits the extension `tool_call` hooks. The interactive TUI marks the tool component as started at that event. OpenPI's workspace cleanup guard can then pause in `ctx.ui.confirm` before the Bash tool's `execute` function runs. On the baseline, the compact activity renderer interpreted `executionStarted` as active Bash execution and repeatedly displayed `Running rm keep.txt` with elapsed time behind the confirmation dialog. + +A controlled local Provider issued a direct `rm keep.txt` for a pre-existing fixture. While confirmation was unanswered, the file was still present. Declining preserved it; approving allowed deletion. This is an operator-facing phase error, not evidence that the guarded command executed before approval. + +## Repair and evidence + +The guard announces only the confirmation phase through Pi's extension EventBus, keyed by Session and tool-call identity. The TUI display extension projects that phase as `Awaiting approval` without an execution spinner or timer, pauses its timer during confirmation, and resumes the ordinary running display after approval. A refusal or cancellation stays in the waiting projection until Pi reports the blocked result. The guard's allow/block decision, confirmation UI, and default selection are unchanged. Headless sessions keep Pi's native tools and do not install this display projection. + +Focused tests cover the event boundary, Session isolation, waiting display and timer, approval, refusal, and ordinary activity rendering. A local Pi TUI/PTY smoke with the same fixture held the prompt for several seconds: it emitted one `Awaiting approval` row and no `Running rm keep.txt` row during that wait; Esc preserved the file. A separate approval run resumed the running row and deleted the file. Removing the event-driven invalidation as an ablation brought repeated `Running` rows back during confirmation, so the invalidation remains necessary. `bun run check` passed. The full test result and PR revision are linked from Issue #544. + +## Limits + +This validation is on a controlled local Provider, macOS terminal capture, and Pi 0.85.1. It is not a benchmark or proof of behavior in the original reporter's Linux terminal. The separately reported white `read/grep` tool blocks have not been reproduced with OpenPI's renderer on current main; this change does not attempt to fix or explain those blocks. No model-facing tool schema, persisted Session data, or package configuration changes are involved. diff --git a/docs/research/README.md b/docs/research/README.md index dbc41c35..a4123e26 100644 --- a/docs/research/README.md +++ b/docs/research/README.md @@ -8,6 +8,8 @@ Research records preserve sourced investigation and distinguish observations, in ## Validated investigations +- [`ISSUE_544_CLEANUP_CONFIRMATION_UI_2026-09-17.md`](ISSUE_544_CLEANUP_CONFIRMATION_UI_2026-09-17.md) — Pi tool-start timing versus cleanup confirmation and the bounded TUI status repair ([#544](https://github.com/openpi-dev/openpi/issues/544)). + - [`OPENPI_HARNESS_STRENGTH_PROTOCOL_2026-08-30.md`](OPENPI_HARNESS_STRENGTH_PROTOCOL_2026-08-30.md) — source-scoped research and explicitly labelled future proposals ([PR #306](https://github.com/openpi-dev/openpi/pull/306)). - [`OPENPI_ZERO_RESIDENT_SURFACE_DIAGNOSTIC_2026-08-30.md`](OPENPI_ZERO_RESIDENT_SURFACE_DIAGNOSTIC_2026-08-30.md) — source-scoped research and explicitly labelled future proposals ([PR #307](https://github.com/openpi-dev/openpi/pull/307)). - [`CAPABILITY_GATEWAY_BOUNDARY_2026-08-30.md`](CAPABILITY_GATEWAY_BOUNDARY_2026-08-30.md) — source-scoped research and explicitly labelled future proposals ([PR #308](https://github.com/openpi-dev/openpi/pull/308)). diff --git a/extensions/file-mutation-display/index.ts b/extensions/file-mutation-display/index.ts index fd885834..8921b5b3 100644 --- a/extensions/file-mutation-display/index.ts +++ b/extensions/file-mutation-display/index.ts @@ -11,13 +11,15 @@ import { } from "@earendil-works/pi-coding-agent"; import type { TSchema } from "typebox"; import { loadSetupConfig } from "../shared/setup-config.ts"; -import { withActivityRenderer } from "./render.ts"; +import { WORKSPACE_CLEANUP_CONFIRMATION_CHANNEL } from "../shared/tool-confirmation.ts"; +import { withActivityRenderer, type ConfirmationProjection } from "./render.ts"; function compact( definition: ToolDefinition, enabled: boolean, + confirmation?: ConfirmationProjection, ) { - return enabled ? withActivityRenderer(definition) : definition; + return enabled ? withActivityRenderer(definition, confirmation) : definition; } /** @@ -25,7 +27,30 @@ function compact( * native schema, prompt metadata, execute function, result, and details. */ export default function fileMutationDisplay(pi: ExtensionAPI) { + const waiting = new Set(); + const invalidators = new Map void>(); + let sessionId: string | undefined; + pi.events.on(WORKSPACE_CLEANUP_CONFIRMATION_CHANNEL, (data) => { + if ( + !data || + typeof data !== "object" || + !("sessionId" in data) || + data.sessionId !== sessionId || + !("toolCallId" in data) || + typeof data.toolCallId !== "string" || + !("waiting" in data) || + typeof data.waiting !== "boolean" + ) + return; + if (data.waiting) waiting.add(data.toolCallId); + else waiting.delete(data.toolCallId); + invalidators.get(data.toolCallId)?.(); + }); + pi.on("session_start", (_event, ctx) => { + waiting.clear(); + invalidators.clear(); + sessionId = ctx.sessionManager.getSessionId(); const display = loadSetupConfig().ui; // This extension changes only the interactive TUI projection. Headless // sessions must keep Pi's native definitions, especially bash: replacing @@ -41,10 +66,20 @@ export default function fileMutationDisplay(pi: ExtensionAPI) { display.fileMutationDisplay === "full", ); + const confirmation: ConfirmationProjection = { + isWaiting: (id) => waiting.has(id), + track: (id, invalidate) => invalidators.set(id, invalidate), + forget: (id) => { + waiting.delete(id); + invalidators.delete(id); + }, + }; + pi.registerTool( compact( createBashToolDefinition(ctx.cwd), display.bashToolDisplay !== "full", + confirmation, ), ); pi.registerTool( diff --git a/extensions/file-mutation-display/render.ts b/extensions/file-mutation-display/render.ts index d208bf17..f0967db8 100644 --- a/extensions/file-mutation-display/render.ts +++ b/extensions/file-mutation-display/render.ts @@ -5,14 +5,21 @@ import type { } from "@earendil-works/pi-coding-agent"; import type { Component } from "@earendil-works/pi-tui"; import type { TSchema } from "typebox"; -import { renderPaddedToolActivityLine } from "../shared/tool-activity.ts"; +import { + renderPaddedToolActivityLine, + type ToolActivityStatus, +} from "../shared/tool-activity.ts"; -type ActivityStatus = "pending" | "success" | "error"; +export interface ConfirmationProjection { + isWaiting(toolCallId: string): boolean; + track(toolCallId: string, invalidate: () => void): void; + forget(toolCallId: string): void; +} type ActivityRenderState = { openpiActivity?: { result?: AgentToolResult; - status: ActivityStatus; + status: ToolActivityStatus; startedAt?: number; endedAt?: number; interval?: NodeJS.Timeout; @@ -70,6 +77,7 @@ function activityComponent( */ export function withActivityRenderer( definition: ToolDefinition, + confirmation?: ConfirmationProjection, ): ToolDefinition> { const nativeRenderCall = definition.renderCall; const nativeRenderResult = definition.renderResult; @@ -80,7 +88,25 @@ export function withActivityRenderer( const state = context.state as TState & ActivityRenderState; state.openpiActivity ??= { status: "pending" }; const activity = state.openpiActivity; - if (context.executionStarted && activity.startedAt === undefined) { + if (definition.name === "bash" && confirmation) { + confirmation.track(context.toolCallId, context.invalidate); + const waiting = confirmation.isWaiting(context.toolCallId); + if (waiting) { + activity.status = "waiting"; + activity.startedAt = undefined; + if (activity.interval) { + clearInterval(activity.interval); + activity.interval = undefined; + } + } else if (activity.status === "waiting") { + activity.status = "pending"; + } + } + if ( + context.executionStarted && + activity.status === "pending" && + activity.startedAt === undefined + ) { activity.startedAt = Date.now(); } if ( @@ -120,6 +146,7 @@ export function withActivityRenderer( state.openpiActivity ??= { status: "pending" }; const activity = state.openpiActivity; activity.result = result; + if (!options.isPartial) confirmation?.forget(context.toolCallId); activity.status = options.isPartial ? "pending" : context.isError diff --git a/extensions/shared/tool-activity.ts b/extensions/shared/tool-activity.ts index 06087a10..48de099b 100644 --- a/extensions/shared/tool-activity.ts +++ b/extensions/shared/tool-activity.ts @@ -5,7 +5,7 @@ import { truncateToWidth } from "@earendil-works/pi-tui"; import { spinnerFrame } from "./spinner.ts"; import { sanitizeTerminalText } from "./terminal-text.ts"; -export type ToolActivityStatus = "pending" | "success" | "error"; +export type ToolActivityStatus = "pending" | "waiting" | "success" | "error"; export interface ToolActivity { readonly name: string; @@ -258,6 +258,9 @@ export function toolActivityText( ) { const row = activityRow(activity); const duration = elapsed(activity, now); + if (activity.status === "waiting") { + return `${theme.fg("warning", "?")} ${theme.fg("toolTitle", "Awaiting approval")} ${row.target}`; + } const verbText = ( activity.status === "pending" ? pendingVerb(activity.name) diff --git a/extensions/shared/tool-confirmation.ts b/extensions/shared/tool-confirmation.ts new file mode 100644 index 00000000..95da2a51 --- /dev/null +++ b/extensions/shared/tool-confirmation.ts @@ -0,0 +1,2 @@ +export const WORKSPACE_CLEANUP_CONFIRMATION_CHANNEL = + "openpi:workspace-cleanup-confirmation"; diff --git a/extensions/workspace-cleanup-guard/index.ts b/extensions/workspace-cleanup-guard/index.ts index 6492cf35..06ffe145 100644 --- a/extensions/workspace-cleanup-guard/index.ts +++ b/extensions/workspace-cleanup-guard/index.ts @@ -2,6 +2,7 @@ import { type ExtensionAPI, isToolCallEventType, } from "@earendil-works/pi-coding-agent"; +import { WORKSPACE_CLEANUP_CONFIRMATION_CHANNEL } from "../shared/tool-confirmation.ts"; import { createWorkspaceCleanupGuard } from "./workspace-provenance.ts"; const DELETE_CONFIRMATION_TITLE = "Delete pre-existing workspace files?"; @@ -24,16 +25,32 @@ export default function workspaceCleanupGuard(pi: ExtensionAPI) { } if (!isToolCallEventType("bash", event)) return; + const confirmation = { + sessionId: ctx.sessionManager.getSessionId(), + toolCallId: event.toolCallId, + }; const cleanupDecision = await workspaceCleanup.before({ id: event.toolCallId, command: event.input.command, cwd: ctx.cwd, - confirmDelete: (paths) => - ctx.ui.confirm( + confirmDelete: async (paths) => { + pi.events.emit(WORKSPACE_CLEANUP_CONFIRMATION_CHANNEL, { + ...confirmation, + waiting: true, + }); + const approved = await ctx.ui.confirm( DELETE_CONFIRMATION_TITLE, deleteConfirmationMessage(paths), { signal: ctx.signal }, - ), + ); + if (approved) { + pi.events.emit(WORKSPACE_CLEANUP_CONFIRMATION_CHANNEL, { + ...confirmation, + waiting: false, + }); + } + return approved; + }, }); if (cleanupDecision.kind === "block") { return { block: true, reason: cleanupDecision.reason }; diff --git a/tests/extensions/file-mutation-display/index.test.ts b/tests/extensions/file-mutation-display/index.test.ts index 030783fa..7cf91d8d 100644 --- a/tests/extensions/file-mutation-display/index.test.ts +++ b/tests/extensions/file-mutation-display/index.test.ts @@ -18,12 +18,14 @@ import { SessionManager, SettingsManager, ToolExecutionComponent, + type ExtensionAPI, type ExtensionContext, type Theme, } from "@earendil-works/pi-coding-agent"; import type { TUI } from "@earendil-works/pi-tui"; import fileMutationDisplay from "../../../extensions/file-mutation-display/index.ts"; import { withActivityRenderer } from "../../../extensions/file-mutation-display/render.ts"; +import { WORKSPACE_CLEANUP_CONFIRMATION_CHANNEL } from "../../../extensions/shared/tool-confirmation.ts"; initTheme("dark", false); @@ -32,6 +34,7 @@ async function withSession( session: Awaited>["session"], cwd: string, ) => Promise, + extraFactories: Array<(pi: ExtensionAPI) => void> = [], ) { const cwd = await mkdtemp(path.join(tmpdir(), "pi-file-mutation-display-")); const agentDir = path.join(cwd, "agent"); @@ -43,7 +46,7 @@ async function withSession( cwd, agentDir, settingsManager, - extensionFactories: [fileMutationDisplay], + extensionFactories: [fileMutationDisplay, ...extraFactories], }); await loader.reload(); const { session } = await createAgentSession({ @@ -222,3 +225,62 @@ test("real ToolExecutionComponent toggles between one activity row and native ev component.setExpanded(false); assert.equal(nonEmpty().length, 1); }); + +test("cleanup confirmation event refreshes only its session's Bash row", async () => { + let emitConfirmation: (event: unknown) => void = () => { + assert.fail("event probe not registered"); + }; + await withSession( + async (session, cwd) => { + const definition = session.getToolDefinition("bash"); + assert.ok(definition); + let renders = 0; + const ui = { + requestRender() { + renders += 1; + }, + } as unknown as TUI; + const component = new ToolExecutionComponent( + "bash", + "guarded-bash", + { command: "rm keep.txt" }, + { showImages: false }, + definition, + ui, + cwd, + ); + component.markExecutionStarted(); + component.setArgsComplete(); + const row = () => + component.render(80).map(stripVTControlCharacters).join("\n"); + assert.match(row(), /Running\s+rm keep\.txt/); + + const event = { toolCallId: "guarded-bash", waiting: true }; + emitConfirmation({ ...event, sessionId: "another-session" }); + assert.match(row(), /Running\s+rm keep\.txt/); + const beforeWaiting = renders; + emitConfirmation({ ...event, sessionId: session.sessionId }); + assert.ok(renders > beforeWaiting); + assert.match(row(), /Awaiting approval rm keep\.txt/); + assert.doesNotMatch(row(), /Running|\d+s/); + + emitConfirmation({ + ...event, + sessionId: session.sessionId, + waiting: false, + }); + assert.match(row(), /Running\s+rm keep\.txt/); + component.updateResult({ + content: [{ type: "text", text: "deleted" }], + isError: false, + }); + assert.match(row(), /Ran\s+rm keep\.txt/); + }, + [ + (pi) => { + emitConfirmation = (event) => + pi.events.emit(WORKSPACE_CLEANUP_CONFIRMATION_CHANNEL, event); + }, + ], + ); +}); diff --git a/tests/extensions/file-mutation-display/render.test.ts b/tests/extensions/file-mutation-display/render.test.ts index c52c8be6..e044301b 100644 --- a/tests/extensions/file-mutation-display/render.test.ts +++ b/tests/extensions/file-mutation-display/render.test.ts @@ -346,6 +346,119 @@ test("all activity tools render pending and failure as one explicit row", () => } }); +test("guard confirmation pauses the bash activity and resumes only after approval", () => { + const waiting = new Set(); + const invalidators = new Map void>(); + const definition = withActivityRenderer(createBashToolDefinition(cwd), { + isWaiting: (id) => waiting.has(id), + track: (id, invalidate) => invalidators.set(id, invalidate), + forget: (id) => { + waiting.delete(id); + invalidators.delete(id); + }, + }); + const args = { command: "rm keep.txt" }; + const state: Parameters< + NonNullable + >[2]["state"] = { + startedAt: undefined, + endedAt: undefined, + interval: undefined, + }; + const context: Parameters>[2] = { + args, + toolCallId: "remove-1", + invalidate() {}, + lastComponent: undefined, + state, + cwd, + executionStarted: true, + argsComplete: true, + isPartial: true, + expanded: false, + showImages: false, + isError: false, + }; + const render = () => { + const component = definition.renderCall?.(args, theme, context); + assert.ok(component); + return component.render(80)[0] ?? ""; + }; + + assert.match(render(), /Running\s+rm keep\.txt/); + assert.ok(invalidators.has("remove-1")); + waiting.add("remove-1"); + const waitingRow = render(); + assert.match(waitingRow, /\? Awaiting approval rm keep\.txt/); + assert.doesNotMatch(waitingRow, /Running|\d+s|[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/); + const activity = state.openpiActivity as { + status: string; + startedAt?: number; + interval?: NodeJS.Timeout; + }; + assert.equal(activity.startedAt, undefined); + assert.equal(activity.interval, undefined); + + waiting.delete("remove-1"); + assert.match(render(), /Running\s+rm keep\.txt/); + assert.ok(activity.startedAt); + assert.ok(activity.interval); + definition.renderResult?.( + { content: [{ type: "text", text: "deleted" }], details: undefined }, + { expanded: false, isPartial: false }, + theme, + { ...context, isPartial: false }, + ); + assert.equal(activity.interval, undefined); + assert.equal(invalidators.has("remove-1"), false); +}); + +test("refused cleanup remains awaiting approval until the blocked result", () => { + let waiting = true; + const definition = withActivityRenderer(createBashToolDefinition(cwd), { + isWaiting: () => waiting, + track() {}, + forget() { + waiting = false; + }, + }); + const args = { command: "rm keep.txt" }; + const state: Parameters< + NonNullable + >[2]["state"] = { + startedAt: undefined, + endedAt: undefined, + interval: undefined, + }; + const context: Parameters>[2] = { + args, + toolCallId: "remove-2", + invalidate() {}, + lastComponent: undefined, + state, + cwd, + executionStarted: true, + argsComplete: true, + isPartial: true, + expanded: false, + showImages: false, + isError: false, + }; + const row = definition.renderCall?.(args, theme, context); + assert.match(row?.render(80)[0] ?? "", /Awaiting approval/); + definition.renderResult?.( + { + content: [{ type: "text", text: "Blocked cleanup" }], + details: undefined, + }, + { expanded: false, isPartial: false }, + theme, + { ...context, isPartial: false, isError: true }, + ); + assert.match(row?.render(80)[0] ?? "", /Failed\s+rm keep\.txt/); + assert.equal(waiting, false); +}); + test("long activity rows stay one line and fit narrow terminals", () => { const definition = withActivityRenderer(createBashToolDefinition(cwd)); const lines = renderCollapsed( diff --git a/tests/extensions/workspace-cleanup-guard/index.test.ts b/tests/extensions/workspace-cleanup-guard/index.test.ts index 3c4c71c4..a4cd9a34 100644 --- a/tests/extensions/workspace-cleanup-guard/index.test.ts +++ b/tests/extensions/workspace-cleanup-guard/index.test.ts @@ -7,6 +7,7 @@ import type { ExtensionAPI, ExtensionContext, } from "@earendil-works/pi-coding-agent"; +import { WORKSPACE_CLEANUP_CONFIRMATION_CHANNEL } from "../../../extensions/shared/tool-confirmation.ts"; import workspaceCleanupGuard from "../../../extensions/workspace-cleanup-guard/index.ts"; type Handler = (event: unknown, ctx: ExtensionContext) => unknown; @@ -27,14 +28,21 @@ interface HarnessOptions { function harness(options: HarnessOptions) { const handlers = new Map(); + const emitted: Array<{ channel: string; data: unknown }> = []; const pi = { on(event: string, handler: Handler) { handlers.set(event, [...(handlers.get(event) ?? []), handler]); }, + events: { + emit(channel: string, data: unknown) { + emitted.push({ channel, data }); + }, + }, } as unknown as ExtensionAPI; const ctx = { cwd: options.cwd, signal: options.signal, + sessionManager: { getSessionId: () => "session-test" }, ui: { confirm: options.confirm ?? (async () => false), }, @@ -42,6 +50,7 @@ function harness(options: HarnessOptions) { workspaceCleanupGuard(pi); return { + emitted, async emit(event: string, value: unknown) { let result: unknown; for (const handler of handlers.get(event) ?? []) { @@ -121,6 +130,24 @@ test("confirmed deletion of a pre-existing file proceeds", async () => { signal: controller.signal, }, ]); + assert.deepEqual(h.emitted, [ + { + channel: WORKSPACE_CLEANUP_CONFIRMATION_CHANNEL, + data: { + sessionId: "session-test", + toolCallId: "remove", + waiting: true, + }, + }, + { + channel: WORKSPACE_CLEANUP_CONFIRMATION_CHANNEL, + data: { + sessionId: "session-test", + toolCallId: "remove", + waiting: false, + }, + }, + ]); }); }); @@ -137,6 +164,16 @@ test("refused deletion of a pre-existing file is blocked", async () => { assert.equal(result?.block, true); assert.match(result?.reason ?? "", /keep\.txt/u); + assert.deepEqual(h.emitted, [ + { + channel: WORKSPACE_CLEANUP_CONFIRMATION_CHANNEL, + data: { + sessionId: "session-test", + toolCallId: "remove", + waiting: true, + }, + }, + ]); }); }); @@ -160,6 +197,7 @@ test("an unverified rm target is blocked without opening confirmation", async () assert.equal(result?.block, true); assert.match(result?.reason ?? "", /direct rm command/u); assert.equal(confirmations, 0); + assert.deepEqual(h.emitted, []); }); }); From e548f97d90aa52840d74f90b414a7e215dc7b6b6 Mon Sep 17 00:00:00 2001 From: testikun <320479488+testikun@users.noreply.github.com> Date: Thu, 17 Sep 2026 15:43:40 +0900 Subject: [PATCH 2/2] docs: link cleanup confirmation investigation to PR --- docs/research/ISSUE_544_CLEANUP_CONFIRMATION_UI_2026-09-17.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/research/ISSUE_544_CLEANUP_CONFIRMATION_UI_2026-09-17.md b/docs/research/ISSUE_544_CLEANUP_CONFIRMATION_UI_2026-09-17.md index 83e06917..fbb63c9e 100644 --- a/docs/research/ISSUE_544_CLEANUP_CONFIRMATION_UI_2026-09-17.md +++ b/docs/research/ISSUE_544_CLEANUP_CONFIRMATION_UI_2026-09-17.md @@ -4,6 +4,7 @@ - Created and verified: 2026-09-17. - Source boundary: OpenPI main `f6b49ae59605b1276b8267f2886d22c03f01533c`, Pi 0.85.1, with a single local OpenPI source reported by `pi list`. - Related Issue: [#544](https://github.com/openpi-dev/openpi/issues/544). +- Related PR: [#545](https://github.com/openpi-dev/openpi/pull/545). - Supersedes: none. ## Observation @@ -16,7 +17,7 @@ A controlled local Provider issued a direct `rm keep.txt` for a pre-existing fix The guard announces only the confirmation phase through Pi's extension EventBus, keyed by Session and tool-call identity. The TUI display extension projects that phase as `Awaiting approval` without an execution spinner or timer, pauses its timer during confirmation, and resumes the ordinary running display after approval. A refusal or cancellation stays in the waiting projection until Pi reports the blocked result. The guard's allow/block decision, confirmation UI, and default selection are unchanged. Headless sessions keep Pi's native tools and do not install this display projection. -Focused tests cover the event boundary, Session isolation, waiting display and timer, approval, refusal, and ordinary activity rendering. A local Pi TUI/PTY smoke with the same fixture held the prompt for several seconds: it emitted one `Awaiting approval` row and no `Running rm keep.txt` row during that wait; Esc preserved the file. A separate approval run resumed the running row and deleted the file. Removing the event-driven invalidation as an ablation brought repeated `Running` rows back during confirmation, so the invalidation remains necessary. `bun run check` passed. The full test result and PR revision are linked from Issue #544. +Focused tests cover the event boundary, Session isolation, waiting display and timer, approval, refusal, and ordinary activity rendering. A local Pi TUI/PTY smoke with the same fixture held the prompt for several seconds: it emitted one `Awaiting approval` row and no `Running rm keep.txt` row during that wait; Esc preserved the file. A separate approval run resumed the running row and deleted the file. Removing the event-driven invalidation as an ablation brought repeated `Running` rows back during confirmation, so the invalidation remains necessary. `bun run check` passed; `bun run test` passed 1661 Node tests (1 skipped) and 220 Vitest tests. See [PR #545](https://github.com/openpi-dev/openpi/pull/545) for the exact revision and validation receipt. ## Limits