@tian.zuo/pi-background-terminals

Replace Pi's built-in Bash with automatic background yielding, exactly-once completion notifications, and a /ps viewer.

Packages

Package details

extension

Install @tian.zuo/pi-background-terminals from npm and Pi will load the resources declared by the package manifest.

$ pi install npm:@tian.zuo/pi-background-terminals
Package
@tian.zuo/pi-background-terminals
Version
0.5.3
Published
Sep 8, 2026
Downloads
901/mo · 83/wk
Author
tian.zuo
License
MIT
Types
extension
Size
214.5 KB
Dependencies
2 dependencies · 4 peers
Pi manifest JSON
{
  "extensions": [
    "./index.ts"
  ]
}

Security note

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

README

@tian.zuo/pi-background-terminals

Release notes: changelog · GitHub releases

A managed replacement for Pi's built-in bash tool.

Every model shell command follows one path: start it, wait briefly, and return its final output if it finishes. If it outlives the initial wait, return control to the model while the command continues as a session-scoped background terminal. Its final result is delivered automatically exactly once. Results that settle close together share one bounded follow-up; an isolated result keeps its existing message shape. Quick Bash calls show a bounded command/output preview in the main transcript; only calls that actually yield collapse to compact terminal rows. /ps retains complete invocation metadata and the detailed stdout/stderr viewer.

■ 2 background terminals running • /ps to view

Canonical bash override

Pi officially supports replacing a built-in tool by registering the same name. This extension registers bash, so the model no longer chooses between normal Bash and a separate background tool. Pi may display an expected startup warning that the built-in has been overridden.

Parameters:

  • command — Bash script to execute.
  • timeout — optional hard total runtime timeout in seconds. When reached, the whole process tree is terminated. There is no default runtime timeout.
  • working_dir — optional working directory for this fresh shell invocation; defaults to the session directory. A standalone cd never affects later calls.
  • title — optional short /ps label. The default strips common leading D=/path; or cd /path && setup and preserves both ends when bounding a long command, so repeated setup prefixes do not hide the actual work.
  • yield_time_ms — optional initial wait, default 10 seconds. Integer values are clamped to 250–30,000 ms rather than rejected when out of range.

Behavior:

  1. Resolve Bash using Pi's normal platform logic and preserve Pi's configured shellPath and shellCommandPrefix settings.
  2. Preserve Pi's managed PATH (<agent-dir>/bin is prepended unless already present) and inject the same PI_SESSION_ID, PI_SESSION_FILE, PI_PROVIDER, PI_MODEL, and PI_REASONING_LEVEL values as built-in Bash.
  3. Start the command in a fresh shell with no interactive stdin and capture stdout/stderr. During the initial wait, the main Bash row shows the useful command title and a small sanitized output preview.
  4. If it exits during yield_time_ms, return its final status and bounded head+tail output to the model. Non-zero exits and hard timeouts are Bash tool errors. The TUI keeps the quick command visibly distinct from background work.
  5. If it remains alive, return an id such as bt-a12b34c56d78ef90-1. Only then does the row collapse to compact background-terminal status. The model should continue rather than poll. Nearby exits share one compact follow-up after a 1,000 ms sliding quiet window, with a 3,000 ms maximum hold. An isolated exit keeps the original message shape, and every terminal result remains exactly-once. Detailed stdout/stderr remain in /ps.

There are no model-facing status, list, kill, polling, or stdin tools. The read-only terminal_log_read tool only pages an opaque archive ref emitted by bash; it returns at most 64 KiB per call, and 256 KiB across at most 8 reads per agent run. The user still owns terminal inspection and termination through /ps.

Terminal IDs include a random runtime identifier, so archive references from before /reload or /resume cannot resolve to a different command's output. Old references become unavailable rather than being reused. The read budgets apply only to terminal_log_read, not to Bash inspection commands.

Cancellation before spawn prevents execution (including foreground fallback). Once a command has started, cancellation only ends the initial wait; the command continues in the background and remains eligible for its completion message.

Safe foreground fallback

If the managed Effect runtime cannot initialize—or start() returns a typed spawn error proving no child was created—the extension falls back to Pi's standard foreground Bash implementation. The result includes a warning that automatic yielding and /ps tracking were unavailable.

The extension deliberately never retries through fallback after a spawn, non-zero exit, timeout, or abort. Re-executing an arbitrary shell command could duplicate destructive side effects.

/ps viewer

While at least one terminal runs, a one-line widget renders above the editor. /ps opens a two-stage full-screen overlay:

  1. List — every tracked terminal, newest first; ↑/↓/j/k select, Enter inspect, x stop the selected running terminal, Esc close.
  2. Detail — three tabs ordered Info (default), stdout, stderr. Info shows the complete invocation metadata (command, cwd, PID, status, timing/timeout, exit state, stream sizes, spill paths, and errors). t, ←/→, or h/l switches tabs. Output tabs support live tailing, scrolling (↑/↓, PgUp/PgDn, g/G), and x to stop. Once a stream outgrows its in-memory retention the viewer reads the complete on-disk log instead: scrolling up immediately pauses live following and freezes the reading position (including retained output). Scrolling past the top of the loaded window pulls in earlier bytes; G returns to the live tail. The note row shows how much is loaded, how much lies on either side, and the log's path.

Design

  • Automatic yielding, no polling. Quick commands return directly; only commands that outlive the initial wait become background work. Completion uses pi.sendMessage(..., { deliverAs: "followUp", triggerTurn: true }).
  • Truthful quick-vs-yielded rendering. Quick and initial-wait Bash rows show a bounded sanitized preview. Only a command that actually yields is rendered as a compact background terminal; asynchronous completion rows remain compact.
  • Fresh-shell guidance. Model guidance points to working_dir instead of persistent cd assumptions and prefers dedicated inspection tools.
  • Loud failure for the two silent mistakes. Prompt wording only makes a misunderstanding less likely, so both contract errors that produce no error signal are refused before spawning. A command that only mutates the discarded shell (cd packages/x, export FOO=1) is rejected with the working_dir fix, because otherwise it exits 0 and the next call silently runs elsewhere. Re-issuing a command identical to one still running in the same directory is rejected too, because a yielded command looks like a hang and the duplicate would repeat every side effect while both copies report success. Ambiguous syntax (redirects, command substitution) always fails open. Every settled result also names the directory the command actually ran in, because the common mistake is assuming a cwd that was never set.
  • Exactly-once batched completion. A race-safe waiter token decides whether the initial Bash call or a later follow-up owns settlement. A drain-once map handles delivery retries without duplicates. A bounded quiet-window scheduler combines nearby map entries into one follow-up, caps aggregate content at 32 KiB, and leaves isolated completion messages unchanged.
  • Bounded head+tail memory plus full capture. Each stdout/stderr stream retains a stable 256 KiB startup head and rolling tail within a 2 MiB cap. Omitted middle bytes are marked. Complete output spills from byte zero to an owner-only file (0600 in a 0700 session directory), capped at 256 MiB per stream. A terminal's spill files are deleted when it is pruned from the 32-entry history. The /ps detail view pages that file through a bounded window (1 MiB live tail, up to 4 MiB when reading backwards), so the user can read output the model's bounded result never showed.
  • Separate stdout and stderr. Both streams are independently retained, spilled, inspected, and formatted. The model can page a bounded settled archive with terminal_log_read using the opaque ref and byte offsets from the Bash result; windows are snapped to UTF-8 boundaries so paging with next_offset is byte-exact, and the tool never reports status or controls a process.
  • No interactive stdin. Normal commands see EOF. The legacy WSL Bash transport may receive the script over stdin, but that pipe is closed immediately and cannot be used interactively.
  • Process-tree termination. POSIX children use their own process group. Cleanup checks that group independently of stdio closure, so descendants with redirected output are also reaped on natural shell exit and shutdown. On Windows the manager creates a dedicated Job Object before starting a terminal, and a pre-shell launcher joins it before Bash can run; every descendant therefore inherits KILL_ON_JOB_CLOSE membership without an assignment race. Closing the handle reliably reaps descendants that outlive the shell and keep stdio pipes open, with taskkill /T as the first attempt. Hosts whose outer Windows job forbids nesting are detected once and retain the legacy direct-spawn/taskkill fallback. Shutdown and hard timeouts send SIGTERM and escalate to SIGKILL after two seconds. A synchronous process-exit tracker also kills managed trees when a Pi crash bypasses normal extension cleanup.
  • Session scoped. /new, /resume, /fork, /reload, and quit terminate every process tree and remove the remaining spill directory.

The async core uses Effect v4. Node child_process output remains callback-driven. See docs/implementation-guide.md for internal invariants.

Install

pi install npm:@tian.zuo/pi-background-terminals

Restart Pi or run /reload afterwards.

Development

This workspace pins Effect 4.0.0-beta.101 and uses TypeScript 7 (tsgo), so it is checked in isolation:

pnpm install --filter @tian.zuo/pi-background-terminals
cd packages/pi-background-terminals
pnpm run check
pnpm test

Tests run without forced process exit, including lifecycle regressions for redirected descendants, pre-spawn cancellation, and runtime-scoped archive IDs.

Credits

License

MIT