pi-tool-guard
Pi extension with two values: it runs an expensive bash command once and keeps its full log so the agent reads any slice instead of re-running, and it repairs LLM tool calls — argument aliases for edit/write/read plus trailing head/tail/grep pipeline extr
Package details
Install pi-tool-guard from npm and Pi will load the resources declared by the package manifest.
$ pi install npm:pi-tool-guard- Package
pi-tool-guard- Version
0.6.0- Published
- Oct 16, 2026
- Downloads
- 484/mo · 24/wk
- Author
- tychenjiajun
- License
- MIT
- Types
- extension
- Size
- 153.6 KB
- Dependencies
- 1 dependency · 2 peers
Pi manifest JSON
{
"image": "./assets/preview.svg",
"extensions": [
"./index.ts"
]
}Security note
Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.
README
pi-tool-guard
English | 简体中文
In one sentence: pi-tool-guard gives every tool call two second chances — a minutes-long command is run once and its full log kept so it is never re-run just to see another slice; and a call with the wrong argument names is rewritten, so it never fails.
pi-tool-guard is an open-source (MIT), TypeScript extension for the pi AI coding agent. It brings two independent values to every session, and both matter:
| # | The failure | What the guard does |
|---|---|---|
| 1. Expensive commands | A vitest run takes minutes and prints the same lines over and over; you only want a few tests' results, and re-running to see them is pure waste. |
Runs the cleaned command once, keeps the full log, returns just the head/tail/grep view that was asked for, and says to read the log instead of re-running. |
| 2. Broken arguments | The model knows what to do but guesses the field names — file, old_str, contents, a top-level oldText — and the call is rejected before it runs. |
Rewrites the arguments to the canonical names before schema validation — no error, no retry. |
Value 1 — a slow command runs once, then you read its log
vitest is the canonical case: minutes of repetitive per-test output, when what
you really want is a handful of results inside it.
# the model writes
npx vitest run 2>&1 | grep -E '✓|×|FAIL' | tail -40
# the guard runs `npx vitest run` a single time, keeps every line in
# pi-bash-guard-<hex>.log, and returns only the 40 matching lines.
# Need another slice? grep the log — the multi-minute suite is never re-run.
Pi's built-in bash tool only keeps a full-output file when the result exceeds
50 KB / 2,000 lines — and a grep/head view can still hide the lines it did
not select. The guard keeps a full log for every slow command it projects:
| Step | What the guard does |
|---|---|
| 1. Run once | The cleaned command executes a single time. |
| 2. Keep the log | The full, unextracted output is written to pi-bash-guard-<hex>.log in the OS temp dir — even when Pi would not have truncated it. |
| 3. Return the view | The model gets exactly the head/tail/grep projection it asked for. |
| 4. Read, don't re-run | A Full-Output Readback notice inlines the path and says to read it on demand — the read tool with offset/limit, or sed -n 'START,ENDp' <path> — instead of re-running the command. |
The win is wall-clock time, not just tokens: the agent pages or greps through a finished batch run instead of paying for it again. The guard never fabricates a cached result — a fresh invocation still executes the command — but the persisted log makes re-running unnecessary, which is the point. This is the same trade a batch job makes: submit once, then read the log.
Value 2 — a broken tool call still goes through
The model rarely gets the schema exactly right on the first try. file for
path, old_str for oldText, contents for content, or the shorthand
{ oldText, newText } instead of { edits: [...] } are all reasonable guesses —
and pi rejects each of them before any extension sees the call.
// the model sends
{ "file": "src/app.ts", "old_str": "foo", "new_str": "bar" }
// the guard validates and runs
{ "path": "src/app.ts", "edits": [{ "oldText": "foo", "newText": "bar" }] }
Underneath that one example are real alias maps for edit, write, and read,
plus three shape repairs: a top-level oldText/newText (or aliases) is wrapped
into edits, a JSON-string edits is parsed, and a string offset/limit on
read is coerced to a number. No re-prompt, no retry, no
Error: invalid arguments. If the model can call the tool at all, the guard
makes the call go through.
Both values point the same way: the model should spend its turns on the task, not on re-running expensive commands or re-phrasing a rejected tool call.
Install
# from npm (recommended)
pi install npm:pi-tool-guard
# from git
pi install git:github.com/tychenjiajun/pi-tool-guard
# from a local checkout
pi install ./pi-tool-guard
# try it for one session without saving it to settings
pi -e npm:pi-tool-guard
The extension overrides the built-in tool definitions through the public
registerTool API, so there is nothing to uninstall from pi and nothing to
configure. pi list shows configured packages; pi remove <source> removes
one.
What it does
Two independent guards, both installed automatically at session start:
| Guard | Tool(s) | Trigger | Effect |
|---|---|---|---|
| Argument normalization | edit, write, read |
An alias field name, a top-level oldText/newText, a JSON-string edits, or a string offset/limit |
Rewrites arguments in place via prepareArguments, before schema validation |
| Bash pipeline projection | bash |
A trailing view extractor (head, tail, grep, rg, …) |
Runs the cleaned command, re-applies the view, and keeps the full output on disk |
Before → after
Alias repair — no error, no retry:
// the model sends
{ "file": "src/app.ts", "old_str": "foo", "new_str": "bar" }
// the guard validates and runs
{ "path": "src/app.ts", "edits": [{ "oldText": "foo", "newText": "bar" }] }
Bash projection — the model keeps its pipeline, the context stops paying for it:
# the model writes
npm test 2>&1 | grep -E 'FAIL|Tests' | tail -5
# the guard runs, and returns just
Tests 12 failed | 4 passed (16)
Slow commands get a guard receipt instead of pi's opaque Took line:
🛡 pi-tool-guard · grep FAIL | head -5
Context saved · 1,234 → 20 lines · 98% smaller · 19 KiB saved
Took 6.5s ██████████████░░░░ cmd 5.0s · filter 1.5s
The bar is proportional — accent █ for the cleaned command, warning ░ for
the guard's extractor. Fast commands are untouched and look exactly like the
built-in bash tool.
Real results
Measured from guard-stamped results in local pi sessions (38 projects, 2,264 guarded bash calls):
| Metric | Value |
|---|---|
| Projections applied | 2,235 (98.7%) |
| Full-output fallbacks | 29 |
| Output lines projected | 102,966 → 42,105 — 59% fewer |
| Output bytes projected | 6.56 MB → 2.91 MB — 56% smaller |
| Median extractor overhead | +33 ms (p90 +73 ms) |
| Slow runs that surfaced the receipt | 344 |
| No-op projections (no UI, nothing changed) | 1,092 |
| Full-Output Readback notices attached | 293 |
The most common stripped pipelines were head -40, head -20, head -30,
tail -20, and grep -v node_modules; the largest single projection took a
1,462-line log down to the 20 lines the model asked for. The guard never
deletes a byte: every projected slow command keeps a full batch log regardless
of Pi's truncation limit, so the agent can re-read any section of a finished run
instead of paying for it again.
Argument alias normalization
Field aliases are renamed in place by prepareArguments on the overridden
edit/write/read definitions — the cleanest pi extension pattern for
argument correction, running before pi's schema validation. Three shapes are
repaired beyond simple key renames:
- Top-level shorthand:
{ oldText, newText }(or their aliases) is wrapped into{ edits: [{ oldText, newText }] }. - JSON-string edits:
editsgiven as a JSON string is parsed into an array. - Type coercion for
read: stringoffset/limitbecome integers.
| Tool | Canonical | Accepted aliases |
|---|---|---|
edit |
path |
file, filePath, file_path, target, filename, file_name |
edit |
edits[].oldText |
old_str, old_string, old_text, oldStr, oldString, oldContent, old_content, old, original, search |
edit |
edits[].newText |
new_str, new_string, new_text, newStr, newString, newContent, new_content, new, replacement, replace |
write |
path |
file, filePath, file_path, target, filename, file_name |
write |
content |
text, body, code, data, fileContent, file_content, contents |
read |
path |
file, filePath, file_path, target, filename, file_name |
read |
offset |
start, startLine, start_line, from, line |
read |
limit |
lines, maxLines, max_lines, count, numLines, num_lines |
Bash pipeline projection
Bash commands are parsed with unbash and the trailing pipeline is classified as view or transform:
| Kind | Commands | Behavior |
|---|---|---|
| View (stripped) | head, tail, grep, egrep, fgrep, rg, less, more |
Removed and re-applied by the guard to the full output |
| Transform (kept) | sed, awk, cut, sort, uniq, wc, column, jq, yq, tr |
Left in the command; the shell applies it, the guard stays out of the way |
Only view extractors are stripped: npm test | grep FAIL | head -5 becomes
npm test, and the guard re-applies grep FAIL | head -5 itself. Stripping
happens in execute (not prepareArguments), so tool_result.input.command
keeps the command the user actually wrote.
Multi-statement safety (hoisting)
Stripping is only attempted when the trailing view's stdout is the whole
command's stdout. Statements around the window are hoisted into a temp
side-channel sink ({ <text>\n} >> '<sink>' 2>&1) so the shell preserves every
byte, and the guard re-attaches that text around the projection:
cd x && echo "=== build ===" && npm test | tail -12
# →
cd x && { echo "=== build ==="
} >> '<sink>' 2>&1 && npm test
A silent prefix (cd, bare assignments, true, :, test, [, without
redirects) needs no sink at all. The guard leaves the command completely
untouched when it cannot be faithful:
a statement that carries its own extractor (
a | head && b | tail),a backgrounded final statement (
… &) — slicing out the view would drop the&and silently foreground the job,a command containing a here-document (its body lives outside the statement's span),
a trailing statement that reads
$?,${?}, orPIPESTATUS— the removed view is no longer the pipeline's last command, so the guard passes the whole command through rather than print a wrong status:printf 'a\n' | grep nomatch; echo "R=$?" # bash prints R=1; stripping would print R=0
Exit codes and empty output
Extractor exit codes are propagated, matching pi's own cmd | extractor
behavior: cmd | grep no-match fails with Command exited with code 1 instead
of returning an empty success. That holds only while the extractor is the
command's last command; with a following statement (cmd | grep x; echo done)
bash exits with that statement's status, so the guard projects instead of
throwing. An empty inline projection renders as (no output), matching pi's
own empty-result rendering.
Pi's bash never throws for a non-zero exit (pi ≥ 0.99): it resolves with
isError: true and appends Command exited with code N to the content. That
status belongs to the cleaned command, so the guard strips it before the view
sees the output and reports the view's exit — what pi itself would report
for cmd | view. When nothing re-decides it (a statement after the window, or
a view that never ran) the status line and isError flag are kept exactly as
pi wrote them. The structuredContent payload of pi's bash outputSchema is
re-stamped around the projection (output, exit_code, wall_time_seconds)
instead of dropped; truncated/full_output_path still point at the
unprojected log.
Fast and slow paths
| Command | Behavior |
|---|---|
| Fast (< 10 s) | Run the cleaned command, apply the view to pi's formatted result, return the projection. No notice, no receipt — visually identical to the built-in bash tool. |
| Slow (≥ 10 s) | Persist the full, unextracted output to pi-bash-guard-<hex>.log in the OS temp dir, apply the view, return the projection plus a Full-Output Readback notice that inlines the path. |
The slow log exists even when pi itself did not truncate, and its
pi-bash-guard-*.log name — published as details.fullOutputPath — keeps it
readable by SoL-Pi's evidence-preserving
reducer. Logs older than 24 h (FULL_OUTPUT_TTL_MS) are swept best-effort on
subsequent persists. If persisting fails and pi did not truncate, the guard
falls back to the full output rather than a projection, so the notice is never
misleading.
Silent no-op
When applying the extractor leaves the output unchanged (e.g. tail -20 on a
2-line result), the guard shows no notice and no receipt — pi's plain Took
line stays. No-op detection is computed for inline (non-truncated) projections
only.
Interop: details.piToolGuard
When the guard strips extractors it stamps a versioned record on
details.piToolGuard so other tool_result handlers — notably SoL-Pi's
evidence-preserving reducer — can tell what the visible content really is:
interface PiToolGuardDetails {
version: 1;
originalCommand: string; // e.g. "npm test | tail -20"
cleanedCommand: string; // e.g. "npm test"
removed: string[]; // e.g. ["tail"]
removedPipeline: string; // e.g. "tail -20"
outcome: "projection-applied" | "full-output";
sourcePath?: string; // untruncated output, when pi produced one
timing?: { // per-phase wall clock
commandMs: number; // cleaned command execution
extractorMs: number; // guard's extractor projection (0 when none ran)
totalMs: number; // commandMs + extractorMs
};
projection?: { // before/after size, when measurable
inputLines: number;
inputBytes: number;
outputLines: number;
outputBytes: number;
};
noop?: boolean; // projection left the output unchanged (inline only)
slow?: boolean; // cleaned command ran >= 10s; only slow runs show guard UI
capped?: boolean; // text was capped to pi's 2000-line / 50KB limit
}
outcome: "projection-applied"—contentis the user's projection, applied as a fallback. Because the projection re-runs against the full persisted output, it is re-capped to pi's model-facing limits (2000 lines / 50KB, tail kept) exactly as pi's native executor would cap the same pipeline:head -60over 30 matches of a minified HTML file can be megabytes, line-for-line unchanged, and must not bypass pi's truncation. When capped the result carriescapped: trueand a[Showing … Full output: <path>]banner (fast runs; slow runs point via the readback notice). The guard always keepsdetails.fullOutputPath/truncation, and mirrors the path aspiToolGuard.sourcePath, so a downstream reducer can read the exact full log and override the projection with its own receipt.outcome: "full-output"—contentis the cleaned command's full output anddetails.fullOutputPathis kept. Also the fallback when a slow command's full output cannot be persisted and pi did not truncate.slow—truewhen the cleaned command ran for at leastFAST_THRESHOLD_MS. Only slow runs surface guard UI (Notice, footer status, receipt).- No extractors stripped → no record;
detailsis left untouched. - The error path (non-zero exit) cannot carry
details, so no contract is published there — a documented limitation.
The contract is additive and versioned. Consumers must parse with
readPiToolGuardDetails (exported from contract.ts) and fall back to the
inline content for unknown versions.
Architecture
index.ts Extension entry; registers the edit/write/read/bash overrides
aliases.ts Argument normalization for edit / write / read
pipeline.ts Bash parsing (unbash) and extractor stripping
execute.ts Core bash execution: strip, run, project, persist, notice
contract.ts Versioned details.piToolGuard interop contract
bash.ts Extractor runner + notice builders
timing.ts Duration formatting and proportional bar split
format.ts Pure text measurement, savings/count/byte formatting
render.ts Swaps pi's trailing Took line for the guard receipt
tui.ts Themed receipt component and transient footer status
Bash flow:
executeparses the command and strips trailing view extractors, hoisting any non-silent statements it must preserve.- The cleaned command runs through the original built-in execute.
- Fast + truncated → run the extractor against pi's full-output file via
pi.exec. Fast + inline → pipe the result through the extractor. Either way the projection is re-capped to pi's 2000-line / 50KB model-facing limit (capModelOutput), with the[Showing … Full output: <path>]banner when the cap trimmed it. - Slow → persist the full, unextracted output, project, and append the Full-Output Readback notice.
- Non-zero exit → pi resolves an
isErrorresult ending inCommand exited with code N(it never throws for exit codes). The guard strips that status, applies the view, and reports the view's exit — pi's own semantics forcmd | view. A non-zero view throws the projection plus[Command exited with code N. Full output: <path>]; thrown errors without an exit code (timeout/abort) are rethrown unchanged.
Compatibility and limitations
Documented, intentional behaviors — not bugs:
- Fast-path stderr merge. Pi merges stdout and stderr before the guard
projects, so a stripped
grep/headalso filters stderr lines; a native shell pipeline would not. The guard trims trailing whitespace from the projection. - Live stream. During a fast run pi's
onUpdatestream shows the cleaned command's unfiltered output; only the returned content is the projection. - Error path has no contract. A thrown error cannot carry
details. - Exit status follows the view. A failed command's
Command exited with code Nline is stripped before projecting; the result reports the view's exit, as a nativecmd | viewpipeline would. With a statement after the window the status is kept as pi wrote it. structuredContentis re-stamped, not dropped. The bashoutputSchemapayload keepstruncated/full_output_path(the unprojected log) and takes the projectedoutput, the view'sexit_code, and the guarded run'swall_time_seconds.- Transform extractors are never stripped.
sed,awk,jq,sort, … stay in the command on purpose. - No session scanning and no replay. Every invocation executes the command again; the guard never returns a cached run.
FAQ
Is it safe? Yes. The guard only rewrites tool arguments and re-applies a shell command the model already asked for. It never invents a command, never runs anything the model did not write, and never deletes output — the full log is always persisted for slow runs.
Does it replace pi's built-in tools? It re-registers their definitions with
a small prepareArguments / execute shim, keeping the original schema and
behavior. Disable the extension and the built-in tools are untouched.
Why strip head/tail/grep but not sed/jq? View extractors only
filter the stream, so removing and re-applying one is lossless. Transform
extractors change the data; stripping them would silently drop the
transformation. The guard leaves them in the shell.
What happens to the output the model didn't ask for? For fast commands it
never leaves the process. For slow commands it is written to
pi-bash-guard-<hex>.log and pointed at by a Full-Output Readback notice, so
the model can read it on demand instead of re-running an expensive command.
How is this different from pi's built-in fullOutputPath? Pi only writes
that file when a result is large enough to truncate. A projected slow command —
say npm run build | grep error, whose full output is still under 50 KB —
otherwise leaves no complete log at all. The guard writes one for every slow
command it projects, so the un-projected output is always there to read. (A slow
command with no view is passed straight through; the model already sees its full
output, and Pi's truncation file covers the oversized case.)
Does it work with other extensions? Yes — it publishes
details.piToolGuard so downstream consumers such as SoL-Pi's
evidence-preserving reducer can interoperate, and it never scans sessions or
mutates other tools.
Does it need configuration? No. Install and it works. FAST_THRESHOLD_MS
(10 s) and FULL_OUTPUT_TTL_MS (24 h) are the only tunables, both internal
constants today.
Related pi extensions
- pi-codemode-guard — compiles LLM-written codemode scripts before they reach the sandbox.
- pi-codemode-snippets —
turns a
// @snippet:.jsfile into a named, composable pi tool. - pi-skill-guard — guards skill loading and invocation.
- pi-voice — speech input for pi.
- SoL-Pi — evidence-preserving context
reduction; reads this extension's
details.piToolGuardcontract.
Development
pnpm install
pnpm test # 268 unit tests
pnpm typecheck
pi -e . # load this checkout as an extension for one session
Layout and design notes live in AGENTS.md and
CONTEXT.md. Pi's own documentation:
Extensions ·
Pi packages.
License
MIT