Natives Shell, PTY, Process, and Key Internals
This document covers execution/process/terminal primitives in @oh-my-pi/pi-natives: shell, pty, ps, and keys, using the architecture terms from docs/natives-architecture.md.
Implementation files
crates/pi-natives/src/shell.rscrates/pi-shell/src/shell.rscrates/pi-shell/src/fixup.rscrates/pi-shell/src/windows.rs(Windows-only PATH enrichment)crates/pi-shell/src/process.rscrates/pi-natives/src/pty.rscrates/pi-natives/src/ps.rscrates/pi-natives/src/keys.rscrates/pi-natives/src/task.rspackages/natives/native/index.d.ts
Layer ownership
- Package entrypoint (
packages/natives/native/index.js): loads the.nodeaddon and exports generated N-API bindings. - Rust N-API module layer (
crates/pi-natives/src/*): JS-facing shell/PTY/process/key exports and callback bridging. - Runtime core (
crates/pi-shell/src/*): brush shell execution, cancellation cleanup, minimizer integration, command fixups, and cross-platform process references. - Consumers (
packages/coding-agent,packages/tui): higher-level session policy, output artifact/minimizer handling, render policy, and UI key handling.
Shell subsystem (shell)
API model
Shell execution modes:
- One-shot via
executeShell(options, onChunk?). - Persistent session via
new Shell(options?)thenshell.run(...)repeatedly.
Both stream merged stdout/stderr text through a threadsafe callback and return { exitCode?, cancelled, timedOut, minimized? }.
Related synchronous helper:
applyBashFixups(command)strips safe trailing| head/| tailpipeline caps and redundant trailing2>&1according topi_shell::fixuprules. It returns{ command, stripped }and does not execute anything.
ShellOptions supports sessionEnv, snapshotPath, and optional output minimizer. ShellExecuteOptions supports command-scoped env, session-level sessionEnv, snapshotPath, timeout/signal, and optional minimizer. ShellRunOptions supports command, cwd, command-scoped env, timeout, and signal.
Session creation and environment model
Rust creates brush_core::Shell with:
- inherited environment disabled (
do_not_inherit_env: true), followed by explicit environment reconstruction from host env, - profile and rc loading skipped,
- bash-mode builtins, with
execandsuspenddisabled, - native
sleepandtimeoutbuiltins registered, - skip-list for shell-sensitive vars (
PS1,PWD,SHLVL, bash function exports, etc.), - a non-exported
env="$env"fallback so PowerShell-style$env:NAMEsurvives brush parameter expansion unless the user shadowsenv.
Session env behavior:
ShellOptions.sessionEnv/ one-shotsessionEnvis applied at session creation.ShellRunOptions.env/ one-shotenvis command-scoped (EnvironmentScope::Command) and popped after the command.PATHis merged specially on Windows with case-insensitive dedupe.- Windows-only path enrichment (
pi-shell/src/windows.rs) appends discovered Git-for-Windows paths when present and not already included. snapshotPath, when present, is sourced during session creation with stdout/stderr/stdin wired to null files.
Runtime lifecycle and state transitions
Persistent shell (Shell.run) uses this state machine:
- Idle/Uninitialized:
session: None. - Running: first
run()lazily creates a session, stores an abort token, executes command. - Completed + keepalive: if execution control flow is normal, abort state is cleared and session is reused.
- Completed + teardown: if control flow is loop/script/shell-exit related, session is dropped.
- Cancelled/Timed out: Tokio cancellation token is triggered, descendants started after the baseline snapshot receive termination waves, a 2-second graceful wait is allowed, the task may be aborted, and the persistent session is dropped if the lock can be acquired.
- Error: session is dropped.
One-shot shell (executeShell) always creates and drops a fresh session per call.
Streaming/output and minimizer behavior
- Stdout/stderr are routed into a shared pipe and read concurrently.
- Reader decodes UTF-8 incrementally; invalid byte sequences emit
U+FFFDreplacement chunks. - The command runs with
ProcessGroupPolicy::NewProcessGroup. - After the foreground command completes, the reader drains until EOF, 250ms of idle output, or 2s maximum; reader shutdown then gets a 250ms timeout.
- Optional minimizer configuration can capture and rewrite output. When minimization occurs, the result includes
minimizedwith filter name, replacement text, original text, and byte counts. - Consumers are responsible for persisting or displaying minimizer artifacts; the native result only carries the data.
Cancellation, timeout, and abort
CancelTokenis constructed fromtimeoutMsand optionalAbortSignal, then converted into the sharedpi_shell::cancel::CancelToken.- On cancellation/timeout, shell cancellation token is triggered, descendant cleanup runs, then the task gets a 2-second graceful window before forced abort.
- Structured result flags are used:
- timeout ->
exitCodeomitted,timedOut: true. - abort signal /
Shell.abort()->exitCodeomitted,cancelled: true.
- timeout ->
Shell.abort() behavior:
- aborts the current running command for that
Shellinstance through the storedAbortToken, - resolves successfully even when nothing is running.
Failure behavior
Common surfaced errors include:
- session init failures (
Failed to initialize shell), - cwd errors (
Failed to set cwd), - env set/pop failures,
- snapshot source failures (
Failed to source snapshot), - pipe creation/clone failures,
- execution failure (
Shell execution failed: ...), - task wrapper failures (
Shell execution task failed: ...).
PTY subsystem (pty)
API model
new PtySession() exposes:
start(options, onChunk?) -> Promise<{ exitCode?, cancelled, timedOut }>write(data)resize(cols, rows)kill()
PtyStartOptions supports command, optional cwd, optional env, timeoutMs, signal, cols, rows, and optional shell. The default shell is sh.
Runtime lifecycle and state transitions
PtySession state machine:
- Idle:
core: None. - Reserved:
start()installs control channel synchronously (core: Some) before async work begins, sowrite/resize/killbecome immediately valid. - Running: blocking PTY loop handles child state, reader events, cancellation heartbeat, and control messages.
- Terminal closed / drain: child exit or cancellation starts a short reader drain window.
- Finalized:
coreis always reset toNoneafter start task completion (success or error).
Concurrency guard:
- starting while already running returns
PTY session already running.
Spawn/attach/write/read/terminate patterns
- PTY opened via
portable_pty::native_pty_system().openpty(...). - On Windows,
openpty()is run on a helper thread with a 5s startup timeout; timeout rejects withPTY creation timed out (5s). ConPTY may be unavailable on this system. - Command runs through the configured shell:
cmd.exe/cmdgets/c,powershell/pwshgets-Command,- other shells get
-lc.
- Default size is
120x40; dimensions are clamped (cols 20..400,rows 5..200) on start and resize. write()sends raw bytes to PTY stdin.resize()sends a control message and clamps dimensions again.kill()sends a control message that marks the run cancelled and terminates PTY process targets.
Output path:
- dedicated reader thread reads master stream,
- incremental UTF-8 decode emits
U+FFFDfor invalid bytes, - chunks forwarded through N-API threadsafe callback.
Termination path:
terminate_pty_processestargets the PTY process group when available and the child pid when available.- It sends the platform
TERM_SIGNAL, callschild.kill(), then sends the platformKILL_SIGNAL. - On Windows, ConPTY input is closed before dropping the master; master drop is offloaded to a background thread and waited for up to 2s to avoid deadlock.
Cancellation and timeout semantics
timeoutMsandAbortSignalfeed aCancelToken.- Loop calls
ct.heartbeat()periodically with a 16ms maximum wait cadence. - Timeout classification is based on the heartbeat error string containing
Timeout. - Cancellation/kill starts a 300ms post-cancel drain window; normal child exit starts a 300ms post-exit drain window.
- Final reader drain is 50ms on non-Windows and 500ms on Windows.
Failure behavior
Error surfaces include:
- PTY allocation/open failure,
- Windows PTY startup timeout,
- PTY spawn failure,
- writer/reader acquisition failure,
- child status/wait failures,
- lock poisoning,
- control-channel disconnection (
PTY session is no longer available).
Control call failures when not running:
write/resize/killreturnPTY session is not running.
Process subsystem (ps)
API model
Current JS surface is the Process class:
Process.fromPid(pid) -> Process | nullProcess.fromPath(path) -> Process[]- getters:
pid,ppid - methods:
args(),killTree(signal?),terminate(options?),waitForExit(options?),groupId(),children(),status()
ProcessTerminateOptions supports { group?, gracefulMs?, timeoutMs?, signal? }. ProcessWaitOptions supports { timeoutMs?, signal? }.
Behavior
killTree(signal?)sends the requested signal to the process and descendants, children first; on Windows the signal argument is ignored and processes are terminated viaTerminateProcess.terminate(options?)is async. By default it uses a 1000ms graceful phase and a 5000ms post-hard-kill wait. PassinggracefulMs < 0skips the graceful phase.waitForExit(options?)resolvestruewhen the process exits andfalseon timeout.status()returns"running"or"exited".
The platform-specific implementation lives in pi_shell::process; crates/pi-natives/src/ps.rs is a N-API shim plus re-exports used by PTY termination.
Key parsing subsystem (keys)
API model
Exposed helpers:
parseKey(data, kittyProtocolActive)matchesKey(data, keyId, kittyProtocolActive)parseKittySequence(data)matchesKittySequence(data, expectedCodepoint, expectedModifier)matchesLegacySequence(data, keyName)
Parsing model
The parser combines:
- direct single-byte mappings (
enter,tab,ctrl+<letter>, printable ASCII), - O(1) legacy escape-sequence lookup (PHF map),
- xterm
modifyOtherKeysparsing, - Kitty protocol parsing (
CSI u,CSI ~,CSI 1;...<letter>), - normalization to key IDs (
ctrl+c,shift+tab,pageUp,f5, etc.).
Modifier handling:
- only shift/alt/ctrl bits are compared for key matching,
- lock bits are masked out before comparisons.
Layout behavior:
- base-layout fallback is intentionally constrained so remapped layouts do not create false matches for ASCII letters/symbols.
Failure behavior
- Unrecognized or invalid sequences produce
nullfrom parse functions. - Match functions return
falseon parse failure or mismatch. - No thrown error surface for malformed key input.
JS API ↔ Rust export mapping
Shell + PTY + Process
| JS API | Rust N-API export | Notes |
|---|---|---|
executeShell(options, onChunk?) |
executeShell (execute_shell) |
One-shot shell execution |
new Shell(options?) |
Shell class |
Persistent shell session |
shell.run(options, onChunk?) |
Shell::run |
Reuses session on keepalive control flow |
shell.abort() |
Shell::abort |
Aborts active run for that shell instance |
applyBashFixups(command) |
applyBashFixups (apply_bash_fixups) |
Synchronous command rewrite helper |
new PtySession() |
PtySession class |
Stateful PTY session |
pty.start(options, onChunk?) |
PtySession::start |
Interactive PTY run |
pty.write(data) |
PtySession::write |
Raw stdin passthrough |
pty.resize(cols, rows) |
PtySession::resize |
Clamped terminal dimensions |
pty.kill() |
PtySession::kill |
Terminates active PTY child/targets |
Process.fromPid(pid) |
Process::from_pid |
Stable process reference lookup |
Process.fromPath(path) |
Process::from_path |
Executable-path process lookup |
process.killTree(signal?) |
Process::kill_tree |
Children-first process tree termination |
process.terminate(options?) |
Process::terminate |
Graceful then hard process termination |
process.waitForExit(options?) |
Process::wait_for_exit |
Async exit wait |
process.children() |
Process::children |
Direct children as Process[] |
process.status() |
Process::status |
running / exited |
Keys
| JS API | Rust N-API export | Notes |
|---|---|---|
matchesKittySequence(data, cp, mod) |
matchesKittySequence (matches_kitty_sequence) |
Kitty codepoint+modifier match |
parseKey(data, kittyProtocolActive) |
parseKey (parse_key) |
Normalized key-id parser |
matchesLegacySequence(data, keyName) |
matchesLegacySequence (matches_legacy_sequence) |
Exact legacy sequence map check |
parseKittySequence(data) |
parseKittySequence (parse_kitty_sequence) |
Structured Kitty parse result |
matchesKey(data, keyId, kittyProtocolActive) |
matchesKey (matches_key) |
High-level key matcher |
Abandoned session cleanup and finalization notes
- Shell persistent session: if a run is cancelled/timed out/errors/non-keepalive control flow, Rust drops the internal session state. Successful normal runs keep the session for reuse.
- PTY session:
coreis always cleared afterstart()finishes, including failure paths. - No explicit JS finalizer-driven kill contract is exposed by wrappers; cleanup is primarily tied to run completion/cancellation paths. Callers should use
timeoutMs,AbortSignal,shell.abort(), orpty.kill()for deterministic teardown.