Stop pi from hanging on runaway bash commands. Set a max runtime in
settings.json — pi kills the process tree at the cap and returns control with
a timeout:<seconds> error. No more waiting forever on a stuck build or
server.
The actual extension is a single TypeScript file:
extensions/pi-timeout.ts
pi discovers it through the pi manifest in package.json:
{ "pi": { "extensions": ["./extensions"] } }pi loads every .ts/.js file under extensions/ with its built-in TypeScript
loader — no compile step. extensions/pi-timeout.ts is a thin adapter
(~30 lines): it registers a tool_call listener and delegates all real work to
the pure, dependency-free modules under src/.
| File | Role |
|---|---|
extensions/pi-timeout.ts |
The extension pi loads. Registers the tool_call hook. Only file that imports pi. |
src/classify.ts |
Classifies a command as run / safe / unknown. |
src/config.ts |
Parses / validates / clamps / merges runTimeout. |
src/load-settings.ts |
Reads global + project settings.json. |
src/handler.ts |
Resolves the timeout and mutates the tool call input. |
So installing the package means pointing pi at this folder; the entry point it
actually runs is extensions/pi-timeout.ts.
On every bash tool call the extension:
-
Loads your
runTimeoutsettings (project settings override global). -
Classifies the command as
run,safe, orunknown(see the classification reference). -
Resolves a timeout and injects it into the call:
Command class Timeout applied run(recognized program launch)maxSeconds, orfallbackMaxSecondsifmaxSecondsis unsetunknown(anything not recognized)fallbackMaxSecondssafe(git / build / test / install / file ops)never capped A timeout the model set explicitly on the call is always respected and never overridden.
When the timeout elapses, pi kills the process tree and the tool returns a
timeout:<seconds> error, so pi can react instead of hanging.
Add the absolute path to the package in your pi settings:
// ~/.pi/agent/settings.json
{
"packages": ["/absolute/path/to/pi-timeout"]
}Restart pi. The extension loads automatically.
The package is structured for publishing with no changes. Once published:
{ "packages": ["npm:@jamiefutch/pi-timeout"] }or
{ "packages": ["git:github.com/you/pi-timeout"] }Add a runTimeout object to ~/.pi/agent/settings.json (global) or
.pi/settings.json (project). Project settings override global settings per
key.
| Key | Type | Meaning |
|---|---|---|
runTimeout.maxSeconds |
positive number (seconds) | Cap for recognized run-commands. |
runTimeout.fallbackMaxSeconds |
positive number (seconds) | Safety-net cap for everything non-safe — unknown commands, and run-commands when maxSeconds is unset. |
Rules:
- Units are seconds (pi's bash
timeoutunit). - Values must be positive finite numbers. Values above
2147483.647seconds (pi's bash ceiling) are clamped down. Invalid values are ignored. - Opt-in: if neither key is set, the extension does nothing.
Simplest "never hang" mode — cap every non-safe command at 2 minutes:
{ "runTimeout": { "fallbackMaxSeconds": 120 } }Tight cap on recognized program launches, looser net for everything else:
{ "runTimeout": { "maxSeconds": 30, "fallbackMaxSeconds": 300 } }Project override — global sets a loose fallback; this project wants a tight cap:
// ~/.pi/agent/settings.json
{ "runTimeout": { "fallbackMaxSeconds": 300 } }
// .pi/settings.json
{ "runTimeout": { "maxSeconds": 10 } }
// Effective: maxSeconds = 10 (project), fallbackMaxSeconds = 300 (global)There is no build step to use the package. pi loads the TypeScript extension directly, so you never compile or bundle anything for pi to run it.
For development, install the dev tooling once:
npm installThen use the checks (full test suite + strict typecheck):
npm test # vitest run — 112 tests
npm run typecheck # tsc --noEmit — strict type checkTest that the timeout extension works using the included C# app:
cd test/csharp-hang
dotnet build -c Release
./bin/Release/net9.0/csharp-hangWith default settings (maxSeconds: 30), the 10-second sleep should complete
normally. To verify the cap fires, temporarily lower maxSeconds in
~/.pi/agent/settings.json:
{ "runTimeout": { "maxSeconds": 3 } }Then re-run — pi kills the process at 3 seconds with a timeout:3
error instead of finishing the 10-second sleep. Restore your settings
after.
- Install — add the package to
packagesin~/.pi/agent/settings.json(see Install). - Configure — set
runTimeout.maxSecondsand/orrunTimeout.fallbackMaxSeconds(see Configure). - Restart pi. The extension loads automatically and caps bash commands from then on.
Smoke test that it's active — with runTimeout.maxSeconds: 3 set, ask pi:
Run this exact bash command: sleep 30
pi should return in ~3 seconds with a timeout:3 result instead of waiting 30
seconds.
npm test # run all tests once
npm run test:watch # re-run on changeCommands are split on &&, ||, ;, and |, and leading wrappers (sudo,
nohup, time, command, exec, env, and VAR=value assignments) are
stripped before classifying. If any segment is a run-command, the whole
command is treated as run; if all segments are safe, it is safe;
otherwise unknown.
| Shape | Examples |
|---|---|
| .NET | dotnet run, dotnet bin/Debug/net8.0/App.dll, dotnet ./out/App.exe |
| Node / Bun / Deno | node dist/index.js, bun app.js, bun run dist/app.js, deno run main.ts |
| Rust | cargo run, cargo run --release |
| Go | go run ., go run cmd/server/main.go |
| Python | python main.py, python3 app.py, uv run serve.py |
| Java | java -jar app.jar, java com.example.Main |
| PHP / Ruby | php index.php, ruby script.rb |
| npm scripts | npm start, npm run serve |
| Bare executable | ./app, ./target/release/app, ../bin/run, /usr/local/bin/tool, ./app.exe |
Note:
npm run <script>(includingnpm run build) is treated asrun, because npm scripts commonly start long-lived processes. If a script is a quick build step, raisemaxSecondsor have pi set an explicit timeout.
- File/text ops:
git,ls,cat,grep,rg,find,echo,mkdir,rm,cp,mv,touch,head,tail,wc,sort,diff, … - Build/test/install verbs:
cargo build|test|check|clippy,dotnet build|test|restore,go build|test|vet,make,cmake,mvn,gradle,npm|pnpm|yarn install|test|lint|build,pip install,brew.
Anything not matched above, e.g. sleep 30, curl https://…,
some-custom-tool. This is the safety net that guarantees pi never hangs on an
unrecognized command (when fallbackMaxSeconds is set).
- Explicit timeouts win. If pi sets a
timeouton a specific bash call, it is never overridden. - Safe tooling is never capped, so long builds and test suites run to completion.
- Process tree is killed at the cap (pi's bash tool behavior), so child processes don't linger.
- No UI. The timeout surfaces as the bash tool's
timeout:<seconds>error, which the model sees and typically reports.
A command I expected to be capped wasn't.
It probably classified as safe. Check the reference above. If it should be
capped, set fallbackMaxSeconds (covers unknown) — and if it's a tool we
marked safe that you want capped, that's a rule change in src/classify.ts.
My build (npm run build) got killed.
npm run <script> is classified as run. Raise maxSeconds, or have pi pass
an explicit longer timeout on that call (explicit timeouts are respected).
I want to disable it temporarily.
Remove the runTimeout keys (or set them to invalid values). The extension
becomes inert.
Are seconds really the unit?
Yes. pi's bash timeout is in seconds; this package matches it.
All decision logic is pure and dependency-free under src/; only
extensions/pi-timeout.ts imports pi (see
What you're installing). For build and test commands
see Build and Run.
MIT