pi-unified-exec
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.
Package details
Install pi-unified-exec from npm and Pi will load the resources declared by the package manifest.
$ pi install npm:pi-unified-exec- Package
pi-unified-exec- Version
0.7.3- Published
- Jul 22, 2026
- Downloads
- 958/mo · 357/wk
- Author
- iamwrm
- License
- MIT
- Types
- extension
- Size
- 193.3 KB
- Dependencies
- 0 dependencies · 3 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
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 (set_on_exit, 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_commandand interactivewrite_stdin, 290 s for pure background polls (yield_time_ms). For a human-requested long attached wait,yield_untilstays attached until an absolute UTC deadline (no default max horizon; multi-day waits re-arm timers safely). A long-running process keeps running; the agent just gets control back with asession_idand can poll again when it chooses. - Completion can resume the agent (opt-in).
exec_command(on_exit: "wake")(default is"none") delivers exactly one follow-up model prompt (bounded exit metadata, no raw output) when a backgrounded process exits while nothing is observing it — completions seen directly by a tool result consume the wake instead.set_on_exitdisarms or re-arms wake without killing the process (including coordinator tombstones after eviction);list_sessionsand the running-session UI surfacewake_armed/⏰wake. - 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-exec
Reload 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 this call stays attached waiting for output (an attachment window, not the command's lifetime), clamped to [250, 30_000]. |
on_exit |
"none" | "wake" |
"none" |
Persistent per-session policy for exits nobody is observing. Prefer "none". "wake": one synthetic follow-up prompt resumes the agent — only when the human explicitly wants auto-resume. Change later with set_on_exit. |
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 |
Attachment/progress window (not a process timeout). Clamped [250, 30_000] with input. Empty polls clamped [5_000, 290_000]; values above the cap are rejected with an error that includes the current host UTC time (tool_time_utc). Mutually exclusive with yield_until. |
yield_until |
string | — | Absolute UTC deadline for an empty poll only, strict RFC 3339 UTC (2026-07-21T18:30:00Z or with .mmm; uppercase Z; no offsets/local time; real calendar dates only). Only when the human explicitly asks for a long attached wait — not a shortcut around the 290 s cap. The call stays attached until the process exits, the call is cancelled, or the deadline arrives — whichever is first. A past deadline is an immediate poll. No default max horizon. Mutually exclusive with yield_time_ms and with input bytes. |
Waiting on long-running commands — the rules
- Use
yield_time_msfor interaction or an empty progress poll of at most 290 seconds (cache-friendly; stays under Anthropic's 5-minute prompt-cache TTL). Repeat polls as needed. - Use
yield_untilonly when the human explicitly asks for a long attached wait or a wall-clock deadline on a finite, non-interactive command. Do not use it just to bypass the 290 s cap. Omityield_time_msand pass a future UTC timestamp ending inZ(compute fromtool_time_utc). The call still returns immediately when the process exits; Esc never kills the process. on_exitdefaults to"none". Use"wake"only when the human explicitly wants auto-resume on unobserved completion. If you armed wake by mistake or the job is abandoned, callset_on_exit(session_id, on_exit: "none")promptly (does not kill).- Combining wake with an observing
write_stdinis safe: a completion observed directly by a tool result consumes the wake; a deadline or cancellation leaves the wake armed until disarmed or delivered.
Never use yield_until for REPLs, sudo, ssh, password prompts,
dev servers, file watchers, debuggers, or any indefinite/interactive
session — it is only for finite commands that exit on their own.
During an absolute wait, the session machinery keeps working normally: the bounded head/tail buffer retains output, the rolling TUI tail updates (rate limited — no 250 ms heartbeat for hours), and every byte still lands in the log file. Internally the wall-clock deadline is converted once to a monotonic deadline, so NTP adjustments or manual clock changes cannot stretch or shrink an in-progress wait.
Wait/cap configuration
| Env var | Default | Notes |
|---|---|---|
PI_UNIFIED_EXEC_MAX_EMPTY_POLL_MS |
290_000 |
Cap for empty write_stdin polls. May be lowered but never raises the effective cache-friendly maximum above 290 s. Positive values below 5_000 are raised to 5_000; invalid values use the default. |
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 bytes
The two parameters are mutually exclusive — passing both rejects the call. Malformed base64 also rejects.
set_on_exit
Change on_exit policy without killing the process.
| Param | Type | Default | Notes |
|---|---|---|---|
session_id |
number | — | Required. |
on_exit |
"none" | "wake" |
— | "none": disarm pending wake (process keeps running; also works for coordinator tombstones after store eviction). "wake": arm auto-resume if still running in the store. Too late once the session has already exited unregistered / already notified. |
Status tokens in the result: disarmed, already_none, armed,
already_armed, too_late. Unknown ids return found: false.
Use this when wake was armed by mistake, the approach was abandoned, or the
human no longer wants auto-resume. Disarm cannot recall a follow-up that
was already queued to pi. kill_session still both kills and suppresses
wake.
on_exit: "wake" — completion notifications
Default is "none". exec_command(on_exit: "wake") arms a per-session
completion policy once the call commits to returning a background
session_id (a command that finishes inside the initial yield returns its
exit directly and never arms a wake). Prefer arming only when the human
explicitly wants auto-resume; use set_on_exit to disarm later.
The exactly-once invariant:
Terminal completion is delivered through a finalized tool result or it causes one synthetic model prompt — normally never both.
Mechanics:
- Any
write_stdincall that could return terminal status takes an observation lease. An exit that lands while an observer is attached is held and returned directly by that call; the wake is consumed when pi finalizes the tool result (tool_execution_end). A result finalized as error/cancelled releases the lease and keeps the wake eligible. - A relative/absolute deadline or a cancelled call releases the lease and leaves the wake armed; when the process later exits, exactly one follow-up prompt is sent.
- The prompt is delivered via
pi.sendMessage(…, { triggerTurn: true, deliverAs: "followUp" }): it starts a turn if pi is idle and is queued as a follow-up if a run is active — it never steers or interrupts the current turn. Simultaneous completions are debounced into one bounded prompt. - The prompt contains bounded execution metadata only (session id, exit
code/signal, sanitized one-line command, cwd, elapsed time, log path,
failure info) — never raw stdout/stderr. The exited session remains
drainable via an empty
write_stdinafterwards, and consuming that output never triggers a second wake. - Suppression:
kill_session, the/unified-exec-sessionscommand, LRU eviction of a live process, andsession_shutdownall suppress the wake (before signaling). A kill that fails to land restores eligibility. A naturally-exited wake session that gets evicted before notification keeps a bounded tombstone snapshot so its one wake (withlog_path) is still delivered — unlessset_on_exit … nonedisarms that tombstone by id.list_sessionsreporting the exit first counts as direct observation and suppresses a not-yet-queued wake.
Requires pi ≥ 0.80.5 (agent_settled extension event, used as a safe flush
point for pending/retried notifications).
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.
Each entry includes wake_armed: boolean (and the text listing marks
live armed sessions with wake) so the model can audit which sessions will
auto-resume on unobserved exit.
Command
/unified-exec-sessions— human-facing escape hatch: lists live sessions in a selector (armed wakes show⏰wake) 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.log
After yield, session still alive:
yielded 2.5s · session_id=2 · log: /tmp/pi-unified-exec-2-86b3f006.log
After process exits:
took 4.2s · exit_code=0 · log: /tmp/pi-unified-exec-1-5cc5e104.log
write_stdin:
⟳ poll → session_id=2 (yield 5.0s) # empty chars
⟳ poll → session_id=2 (2h40m later) # yield_until (live remaining)
» 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 payload
Absolute-wait footer (while still attached) shows remaining time such as
2h40m later (ISO deadline stays in tool details for the model). Cancelled
waits mark cancelled; armed wakes may show wake armed.
set_on_exit:
set_on_exit session_id=2 → none
Running-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 (with
⏰wake when auto-resume is armed) 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_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 — codex allows 30 min; capped at 290 s to stay under
# Anthropic's 5-minute prompt-cache TTL. The env override can only LOWER it;
# longer waits use write_stdin's yield_until (absolute deadline):
DEFAULT_MAX_BACKGROUND_POLL_MS = 290_000 (env: PI_UNIFIED_EXEC_MAX_EMPTY_POLL_MS, lower-only)
LONG_WAIT_UPDATE_INTERVAL_MS = 30_000 (rate limit for absolute-wait TUI updates)
MAX_TIMER_ARM_MS = 2^31-1 (setTimeout chunk size for multi-day yield_until)
# 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. For
yield_untilwaits the cancelled call reportswait_status: cancelled, does not drain buffered output, and leaves an armedon_exit: "wake"eligible. - Absolute wait races: if exit, cancellation, and deadline land almost simultaneously, actual process exit wins whenever the session is already terminal when the result is assembled.
- 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
├── long-wait.ts # event-driven absolute (yield_until) wait + rate-limited streaming
├── time.ts # strict RFC 3339 UTC parsing for yield_until
├── format-time.ts # shared elapsed / remaining human labels
├── completion.ts # CompletionCoordinator: on_exit "wake" scheduling (exactly-once)
├── 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`
Workspace docs (agent memory, not runtime):
docs/
├── DC-0001-agentic-workspace.md # IV/DC doctrine
├── IV-0001-long-wait-and-wake-control.md # long-wait / wake initiative
└── DEV.md # contributor onboarding
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=143
2. Long-running non-interactive job
> exec_command(cmd="npm test", yield_time_ms=1000, on_exit="wake")
[still running]
session_id: 2
completion_notification: armed
tool_time_utc: 2026-07-21T08:30:00.000Z
---
> test suite started
> write_stdin(session_id=2, yield_until="2026-07-21T10:30:00Z") # only if human asked for long attach
[exited]
exit_code: 0
wait_mode: absolute
wait_status: completed
completion_delivery: direct
on_exit_wake: consumed
---
> all tests passed
# If the job is abandoned before exit:
> set_on_exit(session_id=2, on_exit="none") # disarm; process keeps running
set_on_exit session_id=2 on_exit=none → disarmed …; wake not armed
> list_sessions()
# live armed sessions include wake_armed: true / "wake" in the text listing
Prefer repeated yield_time_ms ≤ 290 s polls for ordinary progress. Use
yield_until only when the human explicitly wants a long attached wait. If
the deadline arrives (or the wait is cancelled) while the process is still
running and wake is still armed, one follow-up prompt is delivered when the
process exits — unless you set_on_exit … none first.
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: 0
4. 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
---
root
Tests
From the repo root:
npm install
npx tsx --test tests/*.test.ts
Tests cover yield_until timestamp validation (strict RFC 3339 UTC subset,
impossible-date rejection, far-future acceptance, tool_time_utc on errors),
event-driven long-wait behavior (monotonic re-arm, multi-day timer chunking,
cancellation, timer/listener cleanup, rate-limited streaming with no idle
heartbeat), the CompletionCoordinator (exactly-once invariant, observation
leases, setOnExit disarm/re-arm including tombstones, kill/eviction/shutdown
suppression, batching, bounded sanitized wake content), wake + yield_until +
set_on_exit + wake_armed listing integration through the real tools, plus
the pre-existing
coverage: 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
set_on_exit,kill_session, andlist_sessionstools (codex has none of these).list_sessionsexposeswake_armed; the running-session widget and/unified-exec-sessionspicker mark armed wakes with⏰wake.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, plus human remaining-time labels foryield_untilwaits. - 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.