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
2 changes: 1 addition & 1 deletion hooks/pi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
15 changes: 10 additions & 5 deletions hooks/pi/rtk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
19 changes: 19 additions & 0 deletions src/hooks/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down