pi-unified-exec
v0.6.1
Published
Codex-style unified_exec for pi: long-lived shell sessions the LLM polls and drives with write_stdin (Ctrl-C, arrow keys, PTY, REPLs, ssh, dev servers). Full output logged to disk.
Downloads
661
Maintainers
Readme
unified-exec
A pi extension that ports codex's unified_exec session model: every bash
command becomes a long-lived session the LLM drives with writes and polls,
instead of a single blocking call the agent waits on.
Mirrors codex's exec_command + write_stdin tool surface, with small
pi-flavor additions (kill_session, list_sessions).
Install:
pi install npm:pi-unified-exec
Highlights
- Session-oriented, two-way I/O.
exec_commandopens a long-lived session; the LLM keeps thesession_idand drives the same process across turns by interleavingwrite_stdinwrites and polls. Every byte the child prints is mirrored to an on-disk log file in parallel with the in-memory buffer, so the full history is recoverable viaread(log_path)even after the LLM-visible tail truncates. - Bounded waits — the agent never stalls. Every tool call returns
within a hard ceiling: 30 s for
exec_commandandwrite_stdin, 30 min by default for pure background polls. A long-running process keeps running; the agent just gets control back with asession_idand can poll again when it chooses. - Ctrl-C and other control bytes, not just stdin text.
write_stdindecodes C-style escapes (\x03Ctrl-C,\x04EOF,\x1b[Aarrow-up, …) before writing, so the LLM can interrupt a stuck command or drive an interactive TUI —chars_b64covers the arbitrary-binary case.
Why
Pi's built-in bash tool blocks until the process exits. For a dev server,
tail -f, a REPL, or anything interactive, the agent either has to set a huge
timeout and burn context waiting, or it times out and loses the process.
Codex's alternative: every call opens a session, yields after a bounded
yield_time_ms with output-so-far plus a session_id, and the LLM polls or
drives the session on later turns via write_stdin(session_id, chars, …). A
PTY is available for interactive programs (Python REPL, ssh, sudo, TUIs).
This extension is a faithful port of that design, with codex's constants preserved.
Install
Published on npm as pi-unified-exec.
Install via pi's package manager:
pi install npm:pi-unified-exec # global (~/.pi/agent/settings.json)
pi install -l npm:pi-unified-exec # project-local (.pi/settings.json)pi install runs npm install under the hood, which fetches
@homebridge/node-pty-prebuilt-multiarch (prebuilt binaries for
linux/macOS/Windows — no compilation). If the install fails on your
platform, pipe mode (tty: false) still works, but PTY mode (tty: true)
will error with a clear message.
To try without installing:
pi -e npm:pi-unified-execReload a running pi with /reload.
Tools
exec_command
Runs a command in a persistent session.
| Param | Type | Default | Notes |
|---|---|---|---|
| cmd | string | — | Shell command. Required. |
| workdir | string | turn cwd | Working directory. |
| shell | string | bash | Shell binary. On Windows: bash if on PATH, else powershell. cmd / powershell / pwsh get shell-appropriate flags. |
| tty | boolean | false | Allocate a PTY (requires node-pty). |
| yield_time_ms | number | 10_000 | How long to wait for output, clamped to [250, 30_000]. |
Response body (short output, no truncation):
[still running] (or [exited])
session_id: 1 (mutually exclusive with exit_code)
exit_code: 0 (mutually exclusive with session_id)
signal: SIGTERM (optional, if killed)
log_path: /tmp/pi-unified-exec-1-5cc5e104.log
cwd: /home/you/project
wall_time_seconds: 0.502
chunk_id: a4f2c1
original_token_count: 37
tty: false
---
<captured stdout+stderr>When output exceeds the caps (50 KiB / 2000 lines), a footer is appended:
...tail of output...
[Showing lines 3900-4120 of 4500 (50.0KB limit). Full output: /tmp/pi-unified-exec-1-5cc5e104.log]write_stdin
Drives or polls an existing session.
| Param | Type | Default | Notes |
|---|---|---|---|
| session_id | number | — | Required. |
| chars | string | "" | Empty = pure poll; non-empty writes (after escape decoding) then polls. Mutually exclusive with chars_b64. |
| chars_b64 | string | "" | Base64-encoded bytes to write. Binary-safe. Mutually exclusive with chars. |
| yield_time_ms | number | 250 | Clamped [250, 30_000]. Empty polls clamped [5_000, configured cap]. Default cap: 1_800_000. |
For known long-running, non-interactive jobs, prefer a single long empty poll
instead of many short polls. After exec_command returns a session_id, call
write_stdin with no chars/chars_b64 and a long yield_time_ms up to the
configured cap (1_800_000, or 30 minutes, by default). This is best for
builds, test suites, installs, downloads, and data processing jobs. Avoid long
polls for REPLs, sudo, ssh, password prompts, dev servers, or any command
where you may need to send input soon.
Empty-poll cap configuration
| Env var | Default | Notes |
|---|---|---|
| PI_UNIFIED_EXEC_MAX_EMPTY_POLL_MS | 1_800_000 | Maximum wait for an empty write_stdin poll. Invalid/unset values use the default; positive values below 5_000 are raised to 5_000. Set 300_000 to keep long polls within common 5-minute prompt-cache expiry windows. |
Control bytes and escapes in chars
chars is decoded as a C-style escape string before being written to stdin.
This lets the LLM send control bytes the wire format (antml/JSON tool_use)
strips of their meaning otherwise.
| Escape | Produces |
|---|---|
| \\n \\r \\t \\b \\f \\v | LF CR TAB BS FF VT |
| \\0 | NUL (0x00) |
| \\a | BEL (0x07) |
| \\e | ESC (0x1B) |
| \\xHH (2 hex) | single byte |
| \\uHHHH (4 hex) | Unicode char |
| \\u{H…H} (1–6 hex) | Unicode code point |
| \\\\ \\" \\' | literal \ " ' |
| \\X not in the list above | preserved literally (both chars) |
| Raw bytes in the string | pass through untouched |
Examples:
write_stdin chars="\x03" → Ctrl-C (0x03)
write_stdin chars="\x04" → Ctrl-D (0x04)
write_stdin chars="\x1b:wq\n" → ESC + ":wq" + LF (vim save+quit)
write_stdin chars="\x1b[A" → ESC + "[A" (up arrow)
write_stdin chars="password\n" → "password" + LF
write_stdin chars="C:\\\\temp" → "C:\\temp" (must escape \)For arbitrary binary or when you want zero ambiguity, use chars_b64
instead:
write_stdin chars_b64="G3s6wgo=" → exact 5 decoded bytesThe two parameters are mutually exclusive — passing both rejects the call. Malformed base64 also rejects.
kill_session
Pi-flavor. Not in codex.
| Param | Type | Default | Notes |
|---|---|---|---|
| session_id | number | — | Required. |
| signal | string | "SIGTERM" | Escalates to SIGKILL after 2s. Pass "SIGKILL" to skip the grace. On Windows any signal force-kills the process tree immediately. |
Signal names are normalized (term, INT, sigkill all work). Unknown
names are rejected with an error instead of silently no-opping.
list_sessions
Pi-flavor. Not in codex. Prunes exited sessions from the in-memory store, but
reports each of them one final time (with running: false, exit_code /
signal, and log_path) so exit information is never silently lost.
Command
/unified-exec-sessions— human-facing escape hatch: lists live sessions in a selector and kills the chosen one (or all of them) without going through the model. Uses the same SIGTERM → 2s → SIGKILL escalation askill_session.
Flag
By default, this extension removes pi's built-in bash tool from the
active set at session start so the LLM is steered toward exec_command /
write_stdin.
--keep-builtin-bash— preserve the built-inbashalongside the unified-exec tools. Useful if you've got skills or prompts that explicitly expectbash(cmd, timeout).
TUI rendering
Custom renderCall and renderResult mirror pi's built-in bash tool
styling and add session-aware details:
While streaming (live, updates every second):
$ for i in {1..12}; do echo round $i; sleep 0.5; done (yield 2.5s · cwd: ~/project)
… 1 earlier lines
round 2
round 3
round 4
round 5
elapsed 1.3s · session_id=2 · log: /tmp/pi-unified-exec-2-86b3f006.logAfter yield, session still alive:
yielded 2.5s · session_id=2 · log: /tmp/pi-unified-exec-2-86b3f006.logAfter process exits:
took 4.2s · exit_code=0 · log: /tmp/pi-unified-exec-1-5cc5e104.logwrite_stdin:
⟳ poll → session_id=2 (yield 5.0s) # empty chars
» print(7*6)\n → session_id=1 (yield 1.0s) # with input
» ^C → session_id=1 (yield 1.0s) # control byte
» (base64, 5 bytes) → session_id=1 (yield 1.0s) # chars_b64 payloadRunning-session UI: while any unified-exec process is still alive, the TUI
footer shows unified-exec: N sessions running. After /tree navigation, a
widget above the editor lists the live session_ids and commands so the human
sees that processes survived branch navigation. The footer/widget refreshes as
soon as a background session exits; the exited session remains drainable via
write_stdin until observed, preserving the usual lazy cleanup semantics.
By design this display omits some metadata the LLM sees (chunk_id,
original_token_count, full log path if tildified) — use Ctrl+O on the tool
row to expand the full captured output.
Constants
Codex-parity unless noted:
MIN_YIELD_TIME_MS = 250
MAX_YIELD_TIME_MS = 30_000
MIN_EMPTY_YIELD_TIME_MS = 5_000
DEFAULT_MAX_BACKGROUND_POLL_MS = 1_800_000 (env override: PI_UNIFIED_EXEC_MAX_EMPTY_POLL_MS)
DEFAULT_EXEC_YIELD_MS = 10_000
DEFAULT_WRITE_STDIN_YIELD_MS = 250
EARLY_EXIT_GRACE_PERIOD_MS = 150
HEAD_TAIL_MAX_BYTES = 1 MiB (in-memory drain buffer)
MAX_SESSIONS = 64
WARNING_SESSIONS = 60
LRU_PROTECTED_COUNT = 8
# Diverges from codex — matches pi's built-in bash instead:
DEFAULT_MAX_BYTES = 50 KiB (LLM-visible per-call truncation cap)
DEFAULT_MAX_LINES = 2000
OUTPUT_POLL_INTERVAL_MS = 250 (pi-specific: onUpdate cadence)
PREVIEW_LINES = 5 (TUI preview lines before ctrl+o expand)Semantic notes
- Early exit: commands that finish in <150 ms never touch the session
store. The response has
exit_code, nosession_id. - Session persistence between calls: if a process exits after a tool call
returns but before the next one, the session stays in the store. The next
write_stdin(session_id, …)call will observe the exit and returnexit_code, then remove the session;list_sessionsreports it once with exit info before removing it. (Matches codex'srefresh_process_statepattern.) - Spawn failures are diagnosable: a nonexistent shell binary or
workdir(async ENOENT from the OS) surfaces asfailure_messagein the response instead of a silent empty exit. - Closed stdin is safe: a child that closes its stdin no longer crashes
the host on EPIPE; follow-up
write_stdincalls reportfailure_message: "stdin write failed: …"when bytes can't be delivered. - External abort (Esc): breaks the current call's wait but does not kill the session. The next turn can still drive it.
- Session shutdown: all live sessions are terminated with SIGTERM; after a
1s grace, survivors (e.g. children that trap SIGTERM) are SIGKILLed so
detached process groups don't outlive pi. (Use the separate
bash-backgroundextension if you need true disown.) - LRU eviction: at
MAX_SESSIONS, the oldest non-protected session is evicted. The 8 most-recently-used are never pruned. Exited sessions are preferred as victims. - Head+tail output buffer: per session, up to 1 MiB retained, split 50/50
between the beginning and end of the output stream. A separate 32 KiB
rolling tail window feeds streaming
onUpdateevents during waits.
Architecture
src/
├── index.ts # tool registration, event handlers, flag
├── session.ts # ExecSession: spawn, read, write, kill, log-stream, state
├── session-store.ts # SessionStore + LRU eviction (matches codex)
├── head-tail-buffer.ts # direct port of codex's HeadTailBuffer
├── collect.ts # collectOutputUntilDeadline
├── notify.ts # Notify / Gate / sleep primitives
├── pty.ts # node-pty loader + pipes fallback + Windows tree-kill
├── shell.ts # shell selection & argv construction (Windows-aware)
├── render.ts # renderCall / renderResult for the TUI
└── unescape.ts # C-style escape decoder for write_stdin `chars`Worked examples
1. Dev server (never exits on its own)
> exec_command(cmd="npm run dev", yield_time_ms=5000)
[still running]
session_id: 1
---
> Server listening on :3000
> exec_command(cmd="curl -s localhost:3000/health", yield_time_ms=2000)
[exited]
exit_code: 0
---
{"ok": true}
> write_stdin(session_id=1, chars="", yield_time_ms=10000) # poll dev server
[still running]
---
GET /health 200 in 3ms
> kill_session(session_id=1) # stop it
Killed session 1 (pid 12345) with SIGTERM — exit_code=1432. Long-running non-interactive job
> exec_command(cmd="npm test", yield_time_ms=1000)
[still running]
session_id: 2
---
> test suite started
> write_stdin(session_id=2, yield_time_ms=1800000) # one long empty poll
[exited]
exit_code: 0
---
> all tests passedUse this pattern instead of repeated short polls when the job does not need interaction; it reduces context noise while still returning final output when the command exits or the long poll reaches its deadline.
3. Interactive Python REPL
> exec_command(cmd="python3 -q", tty=true, yield_time_ms=1500)
[still running]
session_id: 1
---
>>>
> write_stdin(session_id=1, chars="print(7*6)\r", yield_time_ms=1000) # \r = Enter (portable; \n fails on Windows)
[still running]
---
42
>>>
> write_stdin(session_id=1, chars="exit()\r", yield_time_ms=1000)
[exited]
exit_code: 04. sudo (interactive password)
> exec_command(cmd="sudo -k && sudo whoami", tty=true, yield_time_ms=1500)
[still running]
session_id: 1
---
[sudo] password for wr:
> write_stdin(session_id=1, chars="<password>\r", yield_time_ms=2000)
[exited]
exit_code: 0
---
rootTests
From the repo root:
npm install
npx tsx --test tests/*.test.ts178 tests across 12 files: HeadTailBuffer (direct port of codex's unit
tests), Notify/Gate/sleep, collectOutputUntilDeadline (10 scenarios incl.
abort-listener/timer cleanup), SessionStore LRU (10 scenarios), truncateTail
(ported from pi, 13 scenarios), unescapeChars (14 scenarios for
\xHH/\uHHHH/\u{…}/unknown escapes/Windows path footguns),
chars-encoding end-to-end (13 scenarios covering raw bytes, escape decoding,
chars_b64 binary-safety, and mutual-exclusion errors), signal-name mapping,
full e2e pipes (35+ scenarios incl. log-file retention, byte/line truncation,
spawn-failure diagnostics, EPIPE safety, exited-session reporting, shutdown
SIGKILL escalation, onUpdate streaming, and the /unified-exec-sessions
command), PTY mode (3 scenarios: simple command, Python REPL drive, Ctrl-C
injection, cmd.exe verbatim payload), shell selection (per-shell argv
construction, PATH lookup with synthetic PATH fixtures, Windows
bash→powershell fallback both branches, WSL-stub exclusion, binary
resolution caching), PTY loader guard (EXPECT_PTY assertion so a prebuild
load failure is a red build, ConPTY disposal mock), and cmd.exe quoting
e2e (operators, embedded quotes, %VAR%, parentheses, pipes).
CI runs the suite on ubuntu, macos, and windows runners.
Improvements over codex
This port preserves codex's session semantics but borrows two pieces from pi's
built-in bash tool that codex itself treats as unsolved:
1. Full output retained on disk, not just head+tail in memory.
Codex caps each session's in-memory buffer at 1 MiB and silently drops middle
bytes once it fills. We mirror every byte the child writes to
/tmp/pi-unified-exec-<sid>-<random>.log in parallel with the in-memory
buffer. The file has the complete, unaltered stream across the entire
session's lifetime; nothing is lost.
2. LLM-visible output is tail-capped at pi's bash defaults (50 KiB or
2000 lines, whichever hits first), with a pointer to the log file.
Codex serializes up to ~40 KiB to the LLM on every call (10 000 tokens of
middle-truncated text). That's a bounded-but-generous context cost per call,
and codex gives the LLM no way to recover the dropped middle. Our port
tail-truncates per pi's bash tool and exposes log_path in the response
header and tool-call details. When the LLM wants the full output it can
read(log_path) with pi's file-read tool.
As a consequence we dropped codex's max_output_tokens parameter on both
exec_command and write_stdin. The per-call cap is fixed; if the LLM
wants a tighter snippet it can ask for a specific slice by reading from the
log file.
| | codex | this port |
|---|---|---|
| Session in-memory retention | 1 MiB head+tail (lossy) | 1 MiB head+tail (lossy) — same |
| Session full retention | none | full log file on disk |
| LLM-visible per call | ≤40 KiB, middle-truncated | ≤50 KiB / ≤2000 lines, tail-truncated |
| LLM-visible truncation recovery | none | read(log_path) for the full stream |
| Per-call max_output_tokens knob | yes (default 10 000) | removed; fixed 50 KiB/2000 lines |
| Truncation marker in body | …N tokens truncated… | [Showing lines X-Y of T. Full output: …] |
The log_path field is exposed in every exec_command and write_stdin
response (as a header line and in tool-call details), plus in list_sessions
per-entry and in kill_session details.
Log files live in /tmp/ and are never auto-deleted (they're just regular
files; /tmp cleanup is the OS's problem). If you run the same session to
completion and never revisit the log, it'll linger until your next reboot.
Other pi-flavor additions
kill_sessionandlist_sessionstools (codex has neither).write_stdinalso works in pipe mode (tty: false), not just PTY. Useful for feeding lines tojq,sort, etc.- Streaming
onUpdatetail window for TUI rendering during yields. - Running-session UI: footer status while processes are alive and a
post-
/treewidget so humans can see that processes survived branch navigation. The UI refreshes immediately on background session exit without pruning the exited session before the nextwrite_stdin/list_sessions. - Rich
renderCall/renderResultmirroring pi bash's styling: command banner with(yield Ns · cwd: …)suffix, 5-line collapsed preview withctrl+oexpand, live "elapsed" counter,yielded/took/exit_codestatus footer, and a⟳ poll/» inputbanner forwrite_stdin. cwd,command, andyield_time_msare surfaced in tool-call details (andcwdin the LLM-visible response header) for easy debugging.
What's not here (vs codex)
- No sandbox / approval / permission stack (pi doesn't have one).
- No network-proxy integration.
- No persistence across pi restarts. (Processes are terminated on
session_shutdown.) - No PTY resize (SIGWINCH) handling.
Windows
Supported — both pipes and PTY mode:
PTY (
tty: true) uses ConPTY via@homebridge/node-pty-prebuilt-multiarch(win32 prebuilds — no compilation).Default shell:
bash, located with an extended probe (first hit wins, cached):PI_UNIFIED_EXEC_BASHenv var (explicit override)bashon PATH — System32'sbash.exe(the WSL stub) is deliberately excluded: it runs commands inside a WSL distro's filesystem view, not Windows'- derived from
git.exeon PATH — Git for Windows' default installer puts onlyGit\cmd(git.exe) on PATH, notGit\bin, so "git works but bash doesn't" is the most common setup; the probe walks up from git.exe and finds<GitRoot>\bin\bash.exe - well-known install roots (
%ProgramFiles%\Git,%ProgramW6432%\Git,%ProgramFiles(x86)%\Git,%LocalAppData%\Programs\Git) powershellfallback, with a one-time warning
When bash is found off PATH (steps 3–4), a one-time info notice shows the path being used. Derived hits use
bin\bash.exe— Git's launcher, which sets up MSYS PATH sols/grep/sedwork in the child — neverusr\bin\bash.exe. Explicitshell: "bash"gets the same extended probe. Explicitshell: "cmd"(/d /s /c, verbatim command line — works in both pipe and tty mode) andshell: "powershell"/"pwsh"(-NoProfile -Command) are supported. Bare shell names are resolved to an absolute path before spawning (failing closed if unresolvable), so a binary planted in an untrusted workdir can't shadow the real shell.No POSIX signals. Every kill —
kill_session,/unified-exec-sessions, LRU eviction,session_shutdown— is a force tree-kill (taskkill /pid <pid> /T /F), regardless of the requested signal name. Killed processes reportexit_code: 1rather thansignal: SIGTERM, and escalation-to-SIGKILL never happens (the first kill is already final). Tree-kill matters: killing only the shell would orphan grandchildren, which also hold the stdio pipes open and delay exit detection.Log files live in
%TEMP%(os.tmpdir()), same naming scheme.Kill failures are reported, not hidden. If
taskkilldoesn't land (access denied, protected process),kill_sessionsays so and the session stays registered for retry — it is never silently dropped while the process lives.Supply-chain note (PTY prebuilds). npm's lockfile integrity covers the
@homebridge/node-pty-prebuilt-multiarchJS payload, but the native ConPTY binary is fetched at install time byprebuild-installfrom the package's GitHub releases (TLS, homebridge org) — those bytes are not covered by an npm digest. The dependency is pinned exactly; if your threat model requires more, vendor the prebuild or build node-pty from source.Ctrl-C injection (
write_stdin chars="\x03") works in PTY mode — ConPTY translates it into a real console interrupt. In pipe mode it's just a byte, as on every platform.Submit tty input with
\r, not\n. POSIX terminals map CR→NL so both work there, but legacy Windows console line input only executes on CR — with\na REPL echoes the text without running it.write_stdin chars="print(6*7)\r"is portable;\nis not.
Source map vs codex
| unified-exec (TS) | codex (Rust) |
|---|---|
| src/head-tail-buffer.ts | codex-rs/core/src/unified_exec/head_tail_buffer.rs |
| src/collect.ts | codex-rs/core/src/unified_exec/process_manager.rs::collect_output_until_deadline |
| src/notify.ts (Notify/Gate) | tokio Notify + watch::Sender<bool> |
| src/session.ts | codex-rs/core/src/unified_exec/process.rs::UnifiedExecProcess |
| src/session-store.ts | codex-rs/core/src/unified_exec/process_manager.rs::ProcessStore |
| src/pty.ts | codex-rs/utils/pty (pty.rs + pipe.rs) |
| truncateTail from @earendil-works/pi-coding-agent | (no equivalent in codex) — pi bash's tail truncator |
| src/unescape.ts | (no equivalent in codex) — C-style escape decoder for chars |
| src/render.ts | (no equivalent in codex) — pi TUI renderCall / renderResult |
| src/index.ts exec_command handler | codex-rs/core/src/tools/handlers/unified_exec.rs |
Contributing / hacking
See docs/DEV.md for the full maintainer guide: onboarding, repo layout, dev loop, test recipes, release workflow (npm publish via GitHub Actions Trusted Publisher), debugging aids, and the codex-side sources to consult before changing core behavior.
