pi-smart-timeout
v0.1.2
Published
Content-aware shell command timeouts for Pi: caps every bash/powershell call so a hung command cannot stall the agent, while long-running builds keep the room they need.
Maintainers
Readme
pi-smart-timeout
Content-aware shell command timeouts for Pi.
Pi's bash tool has no default timeout — timeout is an optional parameter the
model has to remember to pass, and it usually doesn't. When a command hangs (a bare
REPL, tail -f, a stalled network call, an interactive prompt), nothing kills it and
the agent sits there until you interrupt by hand.
This extension caps every bash and powershell call so Pi's own process-tree kill
fires and the tool returns Command timed out after N seconds. The model sees the
error and recovers on its own.
Install
pi install npm:pi-smart-timeoutOr load it for a single run:
pi -e npm:pi-smart-timeoutWhy "smart"
A single blunt number has an obvious failure mode: low enough to catch hangs and it
kills your cargo build; high enough to let builds finish and it doesn't catch hangs.
So the cap is chosen per command.
Decision order — first match wins:
| # | Condition | Applied cap |
|---|-----------|-------------|
| 1 | mode is off | nothing injected |
| 2 | the model supplied timeout | used as-is, clamped to maxSeconds |
| 3 | the command contains timeout N ... | min(N + graceSeconds, maxSeconds) |
| 4 | the command looks long-running | longSeconds (default 1800) |
| 5 | anything else | defaultSeconds (default 120) |
Hangs are caught, and a 20-minute build is not.
How this differs from the other timeout extensions
Three Pi timeout packages exist, and the difference is what they do with the command
text. @jamiefutch/pi-timeout sorts commands into safe / run / unknown and never
caps safe — but its safe set includes tail -f and npm install, which is exactly
where hangs live:
| Command | Safe/never-capped classifier | pi-smart-timeout |
|---------|------------------------------|------------------|
| tail -f app.log | safe → never capped | 1800s → killed |
| npm install | safe → never capped | 1800s |
| git status | safe → never capped | 120s |
| grep -rn 'make sure' src/ | — | 120s (heuristics ignore quoted text and English words) |
@rukaachan/pi-timeout guards a different failure mode: a model passing 60000 when
it meant 60 seconds. It clamps that number but applies the same cap to every command.
pi-extend-timeout changes the timeout of a command already running, which is a
recovery tool rather than a preventive one.
This package keeps two buckets so a low cap catches hangs without killing builds, and
treats tail -f-style blocking commands as the long-running case they are.
Case 3 in detail
If the command already guards itself, the outer cap gets out of the way so the inner guard fires first and you get a precise error instead of an ambiguous kill:
timeout 2m cargo build -> 150 (120 + 30 grace)
timeout -s KILL 90 job -> 120
/usr/bin/timeout 45 job -> 75
timeout 1h backup.sh -> 3600 (3630 clamped to maxSeconds)
echo "timeout 5" -> 120 (quoted, not a guard)
sh -c 'echo "a"; timeout 7 x' -> 37 (quote state resets per segment)
timeout 0 npm install -> 1800 (coreutils: `timeout 0` means no limit)
timeout 99999 cat -> 3600 (huge inner guard clamped to maxSeconds)Option values are tokenized properly rather than matched with one regex, so -s KILL
and --kill-after=5s are not misread as the duration. The inner guard is clamped to
maxSeconds like every other path: when N exceeds the ceiling the inner guard could
not fire before it anyway, so clamping never races the guard.
It also tells the model
The policy is appended to the system prompt each turn, so instead of being silently
killed the model knows to pass timeout whenever a command might outlast the default
cap, and knows not to re-run a timed-out command unchanged. The long bucket is framed
as what commands known to run long receive, not as a blanket allowance, so the model
asks for more time explicitly rather than relying on a promoted default. This turns a
hard kill into a recoverable error.
Configuration
Settings are merged over built-in defaults. Project settings only apply to trusted projects.
~/.pi/agent/settings.json (global) or <cwd>/.pi/settings.json (project):
{
"smartTimeout": {
"mode": "long",
"defaultSeconds": 120,
"longSeconds": 1800,
"maxSeconds": 3600,
"graceSeconds": 30,
"longPatterns": ["\\bmy-slow-tool\\b", "scripts/nightly\\.sh"]
}
}| Key | Default | Meaning |
|-----|---------|---------|
| mode | "long" | "long", "short" (cap everything at defaultSeconds), or "off" (never inject) |
| defaultSeconds | 120 | Cap for ordinary commands |
| longSeconds | 1800 | Cap for long-running commands |
| maxSeconds | 3600 | Hard ceiling applied even to model-supplied values. 0 disables it |
| graceSeconds | 30 | Headroom added on top of a detected inner timeout N |
| longPatterns | [] | Extra regexes (matched against the raw command) marking it long-running |
Environment variables work too and sit below settings files:
PI_BASH_TIMEOUT_SEC, PI_BASH_TIMEOUT_LONG_SEC, PI_BASH_TIMEOUT_MAX_SEC,
PI_BASH_TIMEOUT_MODE, PI_BASH_TIMEOUT_LOG (append one line per decision — useful
for verifying what was applied).
Invalid values are rejected with a warning and fall back to defaults; a malformed
settings.json never breaks startup.
Commands
/bash-timeout show current configuration
/bash-timeout off disable capping for this session
/bash-timeout short cap everything at defaultSeconds
/bash-timeout long restore content-aware capping
/bash-timeout reload re-read settings.json without restartingWhat it does not do
- It can't make Pi have a default timeout. Pi's
bashschema istimeout?: numberwith no default; only injecting the field changes behavior. - It won't kill deliberately detached work.
nohup x &leaves a process outside the killed tree. That is usually what you want. - It doesn't hang-proof interactive network tools. Bare
sshandssh-keygenandcurl/wgetstay in the default bucket: they hang on prompts and unreachable hosts far more often than they legitimately run long, so a low cap catches the hang. Pass an explicittimeoutwhen you really do need a long session or a big download. - It doesn't promote
while trueloops. Awhile true; do ...; doneloop is a hang by definition and gets the default cap. Only bounded poll loops (until ping -c1 host; do sleep 10; done) get the long bucket. - It doesn't stop output flooding. It caps duration, not volume. Pi truncates output on its own (2000 lines / 50KB, full output saved to a temp file).
timeout 0 cmdis not treated as a guard, because coreutils reads it as "no timeout" — so the outer cap still applies.
Tests
npm install
npm test # fast: decision table + config + notifications (~2s)
npm run test:e2e # slow: real kill path, spawns and kills processes (~2min)| Suite | What it covers |
|-------|----------------|
| tests/logic.test.mts | ~75-case decision table: classification, inner-guard parsing, clamping, mode switching, system-prompt injection |
| tests/config.test.mts | settings.json layering, project trust gating, validation, malformed input, /bash-timeout reload |
| tests/ui.test.mts | notifications, hasUI: false, malformed tool input |
| tests/e2e.test.mts | drives Pi's real createBashTool: verifies 15 blocking scenarios are actually killed, that no orphaned grandchildren survive, and that fast commands and 3s commands under a 6s cap are not killed |
The e2e suite asserts on real process state, not mocks: it reads taskkilled process
tables via WMI to confirm the whole tree dies.
License
MIT
