@tduyng/pi-shell

One bg tool for background processes, woken on exit, with cursor reads that never re-read bytes.

Packages

Package details

extension

Install @tduyng/pi-shell from npm and Pi will load the resources declared by the package manifest.

$ pi install npm:@tduyng/pi-shell
Package
@tduyng/pi-shell
Version
0.0.5
Published
Nov 10, 2026
Downloads
888/mo · 598/wk
Author
tienduy-nguyen
License
Apache-2.0
Types
extension
Size
54.4 KB
Dependencies
1 dependency · 2 peers
Pi manifest JSON
{
  "extensions": [
    "./src/index.ts"
  ]
}

Security note

Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.

README

@tduyng/pi-shell

Background processes with one tool: start, read new bytes, kill, list. The turn moves on, and an exiting process wakes the session instead of being polled.

A foreground command blocks the turn. & plus sleep is a guess, and the log has to be read from the top to find one line.

The tool

bg({action: "start", command: "bun run dev"})
bg({action: "output", task_id: "t1", wait: "next_write"})
bg({action: "kill", port: 3000})
bg({action: "status"})

output waits three ways: now returns what is there, next_write returns on the next line written, and exit blocks until the process ends, up to timeout seconds or 30 minutes, then arms a wake for the exit. It used to return at once and wake later, and 9 of 15 recorded calls were followed by a sleep loop or a second poll, never by the model ending its turn. A wait that ends on exit also lets the log settle first, since the child's last chunk can still be in the pipe when it exits and the read would come back one line short. kill takes a task_id, a pid, or a port, whichever is known. Logs land in <agent-dir>/shell-logs/, and the start result names the path.

One tool with an action field instead of four tools, because four tools would cost four schemas in every request.

A REPL that keeps its state

bg({action: "start", command: "python3 -i -q", stdin: true})
bg({action: "input", task_id: "t1", text: "import pandas as pd; df = pd.read_parquet('big.parquet')"})
bg({action: "input", task_id: "t1", text: "df.describe()"})

stdin: true keeps the process open to input. input writes the text and a newline, waits until the output has been quiet for 400 ms, and returns what came back, so one call is one step. The data loads once, where python -c would load it on every call. timeout caps the wait at 30 seconds by default. A reply still running past it stays in the log for output.

A multi-line block ends with a blank line, as at a terminal. PYTHONUNBUFFERED=1 is set for these tasks so Python answers through a pipe. Any line-reading program works the same way, psql, node -i, duckdb or R --interactive.

Cursor reads

output takes a byte offset and returns only new bytes. A build that prints 200,000 lines costs the 40 that matter, and a line already read is never re-billed.

Tasks belong to the pi process, not to a session. pi re-imports every extension on /reload, /new, /resume and /fork, so the task list lives on globalThis and the next session still lists, reads and kills what the last one started. A wake belongs to the session that asked for it: switching sessions drops the pending wakes, and /reload keeps them. Quitting pi sends every running task SIGTERM and, one second later, SIGKILL, because a detached child would otherwise outlive pi with nothing reading its output.

The exit code pi reports wrong

A tool_result handler patches pi's silent signal-death bug. A child killed by a signal reports code === null, which pi reads as success, so a command that was killed looks like a command that passed. The handler reports 128+n and adds a note for a benign exit. A timeout gets a second line naming the way out, bg start or a raised timeout, because 28 recorded calls hit the limit and each one cost the turn it was made in. The structured result stays, so a codemode script still gets { output, exit_code } for an rg that found nothing. A no-match search is reported as a result rather than a failure, since exit 1 from grep or rg is a successful search with an empty answer: a failing call aborted the rest of a batched codemode script, and one recorded script died that way.

Long lines

A bash output line over 2000 characters is cut to its first 2000, and the result names a file with the full output. Pi's own limit of 50 KB lets one line of minified JavaScript or JSON fill the whole result: in recorded sessions 34 results carried 310k characters past the cap, 3.7% of all bash output. Codemode scripts still receive the whole output.

Settings

Variable Default Effect
SHELL unset 0, false, no or off disables the extension

SHELL is the variable a login shell sets to its own path, so the switch only fires on the four falsey spellings. The name is a compromise: any other name would break profiles that already set it.

Install

pi install npm:@tduyng/pi-shell

Local checkout: pi install /path/to/dotfiles/pi/agent/extensions/pi-shell

kill by port uses lsof, which is on macOS and most Linux images. Kill by id or pid needs nothing.

Limits

  • A start can set a timeout up to 600 seconds. The default is no timeout, and 0 says the same thing, because that is what a model writes when it means no limit.
  • Logs are kept 7 days, with a 512 MB cap across the log directory.
  • The output view returns at most 8 KB per call. Use from_offset to continue.
  • A wake is sent only when the session is idle, so a process that exits mid-turn shows up at the next settle.