Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions docs/research/ISSUE_544_CLEANUP_CONFIRMATION_UI_2026-09-17.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# 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).
- Related PR: [#545](https://github.com/openpi-dev/openpi/pull/545).
- 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; `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

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.
2 changes: 2 additions & 0 deletions docs/research/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).
Expand Down
39 changes: 37 additions & 2 deletions extensions/file-mutation-display/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,46 @@ 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<TParams extends TSchema, TDetails, TState>(
definition: ToolDefinition<TParams, TDetails, TState>,
enabled: boolean,
confirmation?: ConfirmationProjection,
) {
return enabled ? withActivityRenderer(definition) : definition;
return enabled ? withActivityRenderer(definition, confirmation) : definition;
}

/**
* Override only Pi's TUI projection. Every wrapped definition retains its
* native schema, prompt metadata, execute function, result, and details.
*/
export default function fileMutationDisplay(pi: ExtensionAPI) {
const waiting = new Set<string>();
const invalidators = new Map<string, () => 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
Expand All @@ -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(
Expand Down
35 changes: 31 additions & 4 deletions extensions/file-mutation-display/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TDetails> = {
openpiActivity?: {
result?: AgentToolResult<TDetails>;
status: ActivityStatus;
status: ToolActivityStatus;
startedAt?: number;
endedAt?: number;
interval?: NodeJS.Timeout;
Expand Down Expand Up @@ -70,6 +77,7 @@ function activityComponent(
*/
export function withActivityRenderer<TParams extends TSchema, TDetails, TState>(
definition: ToolDefinition<TParams, TDetails, TState>,
confirmation?: ConfirmationProjection,
): ToolDefinition<TParams, TDetails, TState & ActivityRenderState<TDetails>> {
const nativeRenderCall = definition.renderCall;
const nativeRenderResult = definition.renderResult;
Expand All @@ -80,7 +88,25 @@ export function withActivityRenderer<TParams extends TSchema, TDetails, TState>(
const state = context.state as TState & ActivityRenderState<TDetails>;
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 (
Expand Down Expand Up @@ -120,6 +146,7 @@ export function withActivityRenderer<TParams extends TSchema, TDetails, TState>(
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
Expand Down
5 changes: 4 additions & 1 deletion extensions/shared/tool-activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions extensions/shared/tool-confirmation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const WORKSPACE_CLEANUP_CONFIRMATION_CHANNEL =
"openpi:workspace-cleanup-confirmation";
23 changes: 20 additions & 3 deletions extensions/workspace-cleanup-guard/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?";
Expand All @@ -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 };
Expand Down
64 changes: 63 additions & 1 deletion tests/extensions/file-mutation-display/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -32,6 +34,7 @@ async function withSession(
session: Awaited<ReturnType<typeof createAgentSession>>["session"],
cwd: string,
) => Promise<void>,
extraFactories: Array<(pi: ExtensionAPI) => void> = [],
) {
const cwd = await mkdtemp(path.join(tmpdir(), "pi-file-mutation-display-"));
const agentDir = path.join(cwd, "agent");
Expand All @@ -43,7 +46,7 @@ async function withSession(
cwd,
agentDir,
settingsManager,
extensionFactories: [fileMutationDisplay],
extensionFactories: [fileMutationDisplay, ...extraFactories],
});
await loader.reload();
const { session } = await createAgentSession({
Expand Down Expand Up @@ -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);
},
],
);
});
Loading
Loading