diff --git a/hooks/pi/README.md b/hooks/pi/README.md index 0094227129..9ae767c1f2 100644 --- a/hooks/pi/README.md +++ b/hooks/pi/README.md @@ -16,7 +16,7 @@ with other Pi extensions. - TypeScript extension using Pi's `ExtensionAPI` (not a shell hook, no `zx` dependency) - Subscribes to `tool_call` event, narrows to `bash` tool via `isToolCallEventType` -- Calls `rtk rewrite` via `pi.exec`; mutates `event.input.command` in-place if rewrite differs +- Calls `rtk rewrite` via `pi.exec`; returns a revised `{ input }` (not an in-place mutation of `event.input`) when the rewrite differs, per `ToolCallEventResult` - All error paths return `undefined` (pass through); RTK never blocks execution - Version guard at load time: checks `rtk >= 0.23.0`; warns and registers no-op if too old or missing - Installed to `.pi/extensions/rtk.ts` by `rtk init --agent pi` (project-local) or `~/.pi/agent/extensions/rtk.ts` by `rtk init --agent pi --global` diff --git a/hooks/pi/rtk.ts b/hooks/pi/rtk.ts index 32cb2b5f11..0267acf3cc 100644 --- a/hooks/pi/rtk.ts +++ b/hooks/pi/rtk.ts @@ -6,9 +6,9 @@ // To add or change rewrite rules, edit the Rust registry — not this file. // // Exit code contract for `rtk rewrite`: -// 0 + stdout Rewrite found → mutate command +// 0 + stdout Rewrite found → revised command returned to the caller // 1 No RTK equivalent → pass through unchanged -// 3 + stdout Rewrite (advisory) → mutate command +// 3 + stdout Rewrite (advisory) → revised command returned to the caller import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" import { isToolCallEventType } from "@earendil-works/pi-coding-agent" @@ -68,9 +68,14 @@ export default async function (pi: ExtensionAPI) { // Delegate to RTK. const rewritten = await rewriteCommand(pi, cmd, ctx.signal) - if (rewritten && rewritten !== cmd) { - event.input.command = rewritten - } + if (!rewritten || rewritten === cmd) return + + // The Pi/OMP dispatcher builds the args a tool actually executes with + // from a separate copy of `event.input`; it only substitutes a handler's + // RETURNED `{ input }` (see ToolCallEventResult), so mutating + // `event.input.command` here has no effect on execution — it silently + // rewrites a value nothing reads. + return { input: { ...event.input, command: rewritten } } } catch (err) { // Fail open: never block execution on an unexpected error. console.warn("[rtk] unexpected error in tool_call handler; passing through command", err) diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 439dbfefa6..121b407caa 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -7168,6 +7168,25 @@ mod tests { }); } + #[test] + fn test_pi_plugin_returns_revised_input_instead_of_mutating_in_place() { + // Regression test: the Pi/OMP `tool_call` dispatcher builds the args a + // tool actually executes with from a separate copy of `event.input`; + // it only substitutes a handler's RETURNED `{ input }`. A handler that + // mutates `event.input.command` and returns nothing silently rewrites + // a value nothing reads, so the bash command never actually changes. + assert!( + PI_PLUGIN.contains("return { input:"), + "tool_call handler must return the revised input (ToolCallEventResult), \ + not just mutate event.input in place" + ); + assert!( + !PI_PLUGIN.contains("event.input.command = rewritten"), + "tool_call handler must not rely on mutating event.input in place; \ + the dispatcher never observes it" + ); + } + #[test] fn test_run_pi_mode_local_installs_plugin() { let tmp = TempDir::new().unwrap();