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.12.1- Published
- Sep 30, 2026
- Downloads
- 461/mo · 244/wk
- Author
- iamwrm
- License
- MIT
- Types
- extension
- Size
- 234.2 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).
[!IMPORTANT] Maintenance notice: We don't have the bandwidth to maintain this repository for the community. Issues are disabled and external pull requests are not accepted. If you'd like to improve or build on this project, please fork it — it's MIT licensed, so you're free to use, modify, and redistribute it.
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. - Independent process and attachment lifetimes. Initial
exec_commandand input-bearingwrite_stdincalls yield within 30 seconds. Empty polls default to five seconds and accept finite relative durations without a built-in maximum. Human-requestedyield_untilwaits use an absolute UTC deadline. Both return on exit, cancellation, or deadline; cancellation leaves the process alive. Multi-day waits re-arm timers safely and retain bounded output instead of accumulating it inside the waiting call. - 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]. - Compact, bounded output with complete recovery. Every output-bearing
result—including
kill_session—is tail-capped at 50 KiB / 2000 lines before it reaches model context or persisted details. All five tools have explicit TUI renderers; child output defaults to a five-visual-line tail and expands with Pi's configuredapp.tools.expandbinding. Model/result/TUI text strips terminal-control sequences; the complete raw stream remains available atlog_path. - Default-on compact codemode previews. A display-only wrapper bounds
collapsed codemode results to ten visual rows, including object/settled
output from any nested tool. Native execution and expansion are unchanged;
PI_UNIFIED_EXEC_COMPACT_CODEMODE=0opts out for other codemode replacements. - 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 suits commands that run and exit. It can stream output
and benefit from Pi's cache warmer during long calls, but cancellation kills
the process tree. This extension provides reusable process handles, later
input, PTYs, and observation that can end without terminating 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 keeps that session model while letting Pi own cache warming. Attachment durations are not tied to a provider's cache lifetime.
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). |
cols |
number | 120 |
PTY width in columns (tty: true only; ignored for pipes). Clamped to [20, 500]. |
rows |
number | 30 |
PTY height in rows (tty: true only; ignored for pipes). Clamped to [5, 300]. |
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
output_bytes_total: 148 (cumulative bytes since spawn — compare across polls to detect stalls)
omitted_bytes: 0 (only present when the 1 MiB in-memory retention dropped middle bytes)
tty: false
---
<captured stdout+stderr>
If a chatty process produces more than the 1 MiB in-memory retention between
polls, the middle is dropped and a marker is spliced at the exact omission
point (the full stream is always in log_path):
[... 3145728 bytes omitted here (output exceeded the 1048576-byte in-memory retention between polls; the full stream is in the session log file) ...]
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 with input; 5_000 empty |
Attachment window, not a process timeout. Input-bearing calls clamp to [250, 30_000]. Empty polls have a 5_000 minimum and no built-in maximum. Supply a non-negative finite number no greater than Number.MAX_SAFE_INTEGER; fractional milliseconds round down. An explicit operator cap rejects larger relative polls. 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 or wall-clock deadline. 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 a finite
yield_time_msbased on the expected job duration and how soon progress needs attention. Empty polls default to five seconds and have no built-in maximum. Keep waits short for interactive or indefinite processes. An attachment deadline never kills the process. - Use
yield_untilonly when the human explicitly asks for a long attached wait or a wall-clock deadline on a finite, non-interactive command. Omityield_time_msand pass a future UTC timestamp ending inZ, computed fromtool_time_utc. The call returns early on process exit; Esc leaves the process alive. 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.
Both empty-poll forms use the same event-driven wait. The bounded head/tail buffer retains output, the rolling TUI tail produces output-driven updates, and the log receives the raw stream. Short relative polls update at most four times per second; longer and absolute waits at most once per 30 seconds, plus initial/final updates. Quiet waits have no periodic output heartbeat. Absolute deadlines convert once to a duration; both forms then use monotonic elapsed time, so wall-clock changes cannot stretch or shrink a wait.
Pi owns cache warming independently. Its default streaming mode can refresh
eligible caches during attached tool waits; idle also permits warming after
the agent settles. Warming is best-effort and can stop because of economics,
missing cache metadata, safety horizons, errors, or extension vetoes. A long
wait works even when warming is off, but the next request may miss cache.
This release changes wait/output collection, not archive policy. Raw logs are still unbounded on disk; archive quotas and bounded log-write backlogs remain follow-up work.
Wait/cap configuration
| Env var | Default | Notes |
|---|---|---|
PI_UNIFIED_EXEC_MAX_EMPTY_POLL_MS |
unset, no cap | Optional limit for empty relative write_stdin polls. May exceed 290 seconds. Positive values below 5_000 become 5_000; fractions round down. Invalid, non-positive, non-finite, or unsafe values fail the relative poll instead of silently removing protection. Does not change human-explicit absolute waits. |
To retain the old limit, explicitly set PI_UNIFIED_EXEC_MAX_EMPTY_POLL_MS=290000.
Unset or blank now means no cap. Malformed explicit values previously fell back
to the built-in cap; they now report a configuration error.
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 and TUI ≥ 0.99.1 (agent_settled extension event, used as a safe flush
point for pending/retried notifications; outputSchema / structuredContent for
codemode scripts).
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.
The result uses the same bounded output envelope as exec_command and
write_stdin: details.output contains at most 50 KiB / 2000 lines of
terminal-inert plain text, truncation and omitted_bytes explain any loss,
and log_path points to the complete raw session stream. Explicit operation,
status, running, killed,
escalated, requested/actual signal, and process metadata distinguish session
identity from liveness. A failed kill remains registered and reports
running: true.
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.
The response also carries tool_time_utc (text trailer + details) so the
model can compute a yield_until deadline from the trustworthy host clock
without an extra probing call.
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).
Codemode scripts
Every tool declares an outputSchema and returns a matching
structuredContent, so Pi codemode scripts receive an object instead of the
text envelope the model sees:
const r = await tools.exec_command({ cmd: "npm test", yield_time_ms: 30000 });
if (r.running) text(`still running as session ${r.session_id}`);
else text(`exit ${r.exit_code}`);
text(r.output); // readable multiline output rather than the whole JSON object
exec_command and write_stdin resolve to { status, running, output, truncated, session_id?, exit_code?, signal?, failure_message?, log_path?, wall_time_seconds, note?, wait_status?, on_exit?, tool_time_utc? };
kill_session, set_on_exit, and list_sessions have matching typed shapes
(src/script-result.ts). output is the same bounded, terminal-inert tail as
details.output. Print r.output rather than the whole object: text(r)
serializes newlines as \n and turns the output into one long line.
Model-visible content and persisted details are unchanged.
Default-on codemode display fix (0.12.1)
Pi 0.99.1's collapsed codemode preview counts logical lines before wrapping.
text(r) or text({ i, ...settled }) creates one long JSON line, which can
wrap across the screen even though the result is collapsed. This package now
uses Pi's public codemode factory and changes only its result renderer:
after native wrapping, the collapsed result text component is capped at ten
visual rows, including nested-call summaries, spacers and footer. Small native
previews and expanded output stay unchanged. A configured expansion hint and
Full output: recovery footer remain visible when clipping; long footer paths
are width-truncated, with the complete path retained on expansion.
This applies to all codemode nested tools, not only unified-exec. It does not activate codemode, rewrite script/model results, change process output bounds, patch Pi, or write your settings. The script-call renderer and separate inline image components are outside this result-text cap.
One-time setup to silence the duplicate built-in warning: disable
codemode under Built-in in pi config, or add this exclusion to your settings
without dropping existing entries:
{ "extensions": ["-builtin:codemode"] }
The replacement still honors defaultTools: ["+codemode"], CLI tool selection,
codemode.mode, codemode.inlineBudget, native MCP activation and branch-local
store/load. Leaving both extensions enabled produces Pi's replacement warning;
only the replacement is retained. /reload or restart after configuration
changes.
Explicit opt-out, for a different codemode replacement or native display:
PI_UNIFIED_EXEC_COMPACT_CODEMODE=0 pi
When opting out, re-enable builtin:codemode (remove the exclusion above) unless
another extension supplies codemode. The display fix is on by default; no flag
or setting is needed to opt in.
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
kill_session (collapsed by default):
kill_session session_id=2 signal=SIGTERM
... (147 earlier lines, Ctrl+O to expand)
last output line 1
last output line 2
last output line 3
last output line 4
last output line 5
took 0.3s · killed session_id=2 · signal=SIGTERM · log: /tmp/...
list_sessions shows up to five compact entries while collapsed and all bounded entries plus log paths when expanded.
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, and tildified paths). Use the configured
app.tools.expand binding (Ctrl+O by default) to reveal the complete bounded
result. Expansion never reads an arbitrary-size log synchronously; use
log_path when the complete raw session stream is required. Raw PTY logs can
contain terminal mode/clipboard sequences, so inspect them through read, a
hex/escape visualizer, or another non-executing viewer rather than cat.
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
# Empty relative polls have no built-in cap. Optional operator limit:
PI_UNIFIED_EXEC_MAX_EMPTY_POLL_MS = unset
LONG_WAIT_UPDATE_INTERVAL_MS = 30_000 (long/absolute empty-poll update rate limit)
MAX_TIMER_ARM_MS = 2^31-1 (timer chunk size for either empty-poll form)
# 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 (exec/input cadence; short empty-poll rate limit)
PREVIEW_LINES = 5 (visual TUI lines before app.tools.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. - Shell-exit vs pipe-close: a session reports "running" until the output
pipe reaches EOF — the shell's own exit is not enough. Background
processes that inherited stdout/stderr (or a backgrounded
cd … && cmd &chain whose subshell keeps waiting) hold the pipe open, so the session stays[still running]after its shell is gone. Such results carry anoteexplaining the state and how to detach ((cd dir && cmd >log 2>&1 </dev/null) &,setsid cmd >log 2>&1 </dev/null &) or end it withkill_session;list_sessionsmarks them(shell exited, pipe held). - 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 either empty-poll form the
cancelled call reports
wait_status: cancelled, does not drain buffered output, and leaves an armedon_exit: "wake"eligible. - Empty-poll 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 # shared event-driven empty-poll wait + rate-limited streaming
├── time.ts # relative duration/operator limit + strict UTC deadline parsing
├── 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)
├── output-safety.ts # terminal-control stripping (raw bytes stay in logs)
├── tool-result.ts # bounded process/kill envelopes + model-visible text
├── render.ts # explicit renderCall / renderResult for all five tools
├── codemode-render.ts # default-on native codemode visual-row preview wrapper
└── 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
├── IV-0002-output-lifecycle-and-rendering.md # bounded output / compact TUI
└── 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
For ordinary progress, choose a finite relative wait such as
write_stdin(session_id=2, yield_time_ms=900000). It returns early on exit;
there is no need to split it into 290-second polls. Use yield_until only
when the human explicitly wants a long attached wait or wall-clock deadline. 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
npm test # strict typecheck + unit/integration + offline actual-CLI tests
npm run test:tui # isolated tmux acceptance, skips if tmux is unavailable
The CLI/TUI fixtures use a local scripted provider, not a live model. Set
PI_UNIFIED_EXEC_TEST_CLI=/path/to/pi/dist/cli.js to exercise another published
Pi version without changing package dependency pins.
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 (45+ scenarios incl. log-file retention, byte/line truncation,
a delayed 4000-line kill drain, renderer registration, spawn-failure
diagnostics, EPIPE safety, exited-session reporting, shutdown SIGKILL
escalation, onUpdate streaming, and the /unified-exec-sessions command), pure
output-envelope, terminal-control, cache-refresh, and collapse/expand/re-collapse
renderer coverage, 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), and codemode
script results (outputSchema/structuredContent for all five tools), native
codemode renderer cache/width/expansion, and offline actual-CLI parity for
settings, default activation, explicit opt-out, store/load and local-stdio MCP.
Real-tmux acceptance also exercises object/settled/multiline script output at
40/80/100 columns, regular/fullscreen, three themes and spill-path recovery.
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. - Explicit
renderCall/renderResultcoverage for all five tools, mirroring Pi bash's styling: command banners, five-visual-line output tails, configuredapp.tools.expandhints, compact session inventories, live elapsed counters, process/kill status footers, and⟳ poll/» inputbanners. 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 live PTY resize (SIGWINCH) handling —
cols/rowsare fixed at spawn.
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 -NonInteractive -NoLogo -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.