# 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.rs` - `crates/pi-shell/src/shell.rs` - `crates/pi-shell/src/fixup.rs` - `crates/pi-shell/src/windows.rs` (Windows-only PATH enrichment) - `crates/pi-shell/src/process.rs` - `crates/pi-natives/src/pty.rs` - `crates/pi-natives/src/ps.rs` - `crates/pi-natives/src/keys.rs` - `crates/pi-natives/src/task.rs` - `packages/natives/native/index.d.ts` ## Layer ownership - **Package entrypoint** (`packages/natives/native/index.js`): loads the `.node` addon 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: 1. **One-shot** via `executeShell(options, onChunk?)`. 2. **Persistent session** via `new Shell(options?)` then `shell.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`/`| tail` pipeline caps and redundant trailing `2>&1` according to `pi_shell::fixup` rules. 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 `exec` and `suspend` disabled, - native `sleep`, `timeout`, and `nohup` builtins registered, - skip-list for shell-sensitive vars (`PS1`, `PWD`, `SHLVL`, bash function exports, etc.), - a non-exported `env="$env"` fallback so PowerShell-style `$env:NAME` survives brush parameter expansion unless the user shadows `env`. Session env behavior: - `ShellOptions.sessionEnv` / one-shot `sessionEnv` is applied at session creation. - `ShellRunOptions.env` / one-shot `env` is command-scoped (`EnvironmentScope::Command`) and popped after the command. - `PATH` is 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+FFFD` replacement 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 `minimized` with 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 - `CancelToken` is constructed from `timeoutMs` and optional `AbortSignal`, then converted into the shared `pi_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 -> `exitCode` omitted, `timedOut: true`. - abort signal / `Shell.abort()` -> `exitCode` omitted, `cancelled: true`. `Shell.abort()` behavior: - aborts the current running command for that `Shell` instance through the stored `AbortToken`, - 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, so `write/resize/kill` become 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**: `core` is always reset to `None` after 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 with `PTY creation timed out (5s). ConPTY may be unavailable on this system.` - Command runs through the configured shell: - `cmd.exe`/`cmd` gets `/c`, - `powershell`/`pwsh` gets `-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+FFFD` for invalid bytes, - chunks forwarded through N-API threadsafe callback. Termination path: - `terminate_pty_processes` targets the PTY process group when available and the child pid when available. - It sends the platform `TERM_SIGNAL`, calls `child.kill()`, then sends the platform `KILL_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 - `timeoutMs` and `AbortSignal` feed a `CancelToken`. - 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/kill` return `PTY session is not running`. ## Process subsystem (`ps`) ### API model Current JS surface is the `Process` class: - `Process.fromPid(pid) -> Process | null` - `Process.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 via `TerminateProcess`. - `terminate(options?)` is async. By default it uses a 1000ms graceful phase and a 5000ms post-hard-kill wait. Passing `gracefulMs < 0` skips the graceful phase. - `waitForExit(options?)` resolves `true` when the process exits and `false` on 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+`, printable ASCII), - O(1) legacy escape-sequence lookup (PHF map), - xterm `modifyOtherKeys` parsing, - Kitty protocol parsing (`CSI u`, `CSI ~`, `CSI 1;...`), - normalization to key IDs (`ctrl+c`, `shift+tab`, `pageUp`, `f5`, etc.). Modifier handling: - only shift/alt/ctrl/super 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 `null` from parse functions. - Match functions return `false` on 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**: `core` is always cleared after `start()` 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()`, or `pty.kill()` for deterministic teardown.