# shell: processes, boxes, live output, speech Scope: `src/shell/` (namespace `detz::shell`). Source of truth is `../tai.py`; this file states what the C++ port does today, how it is verified, and the gaps. Wiring for other modules: [wiring/shell.md](wiring/shell.md). ## Command Output Rule (repository AGENTS.md) Every command an agent runs for the user streams live and is returned to the agent. The window is a fixed-height message (`LiveStream`, or the chat TUI's `live` block through the `StreamSink` protocol). A command that stops on a prompt (sudo, ssh, y/n) runs on a pty and asks through `ask(text, secret)`; the answer is typed into the command, a decline (`nullopt`) sends end-of-file, and time spent waiting for the user never counts against the timeout. Anything that runs a process goes through `RunLive` with `ShellIo::Stream` and `ShellIo::Ask`. No bare `subprocess`-style calls for agent commands. ## Caps (constants, all from tai.py) | constant | value | where | |---|---|---| | `kShellTimeout` (SHELL_TIMEOUT) | 120 s | runlive.h | | `kRunOutputCap` (RUN_OUTPUT_CAP) | 4,000,000 chars, pty path only | runlive.h | | `kRunPromptIdle` (RUN_PROMPT_IDLE) | 1.0 s of silence before a prompt is asked | runlive.h | | `kRunMaxAsks` (RUN_MAX_ASKS) | 20 questions per command | runlive.h | | `kRunAnswerCap` (RUN_ANSWER_CAP) | 1000 chars typed per answer | runlive.h | | `kRunRows` x `kRunCols` | 24 x 120 pty window | pty.h | | `kStreamHeight` (STREAM_HEIGHT) | 4 lines | livestream.h | | `kShellSnapshotMaxFiles` | 25 files | targets.h | | ShellSession caps | SID_MAX 64, CHAIN_CAP 64, ROWS_CAP 1000, TREE_DEPTH 8, TREE_KIDS 10, TREE_LINES 40 | session.h | ## Parity table (tai.py symbol -> C++) | tai.py | C++ | notes | |---|---|---| | `run_live`, `run_live_pty` | `RunLive::Run`, `RunLive::RunPty` | pipe path with reader thread, pty path with prompt detection; `RunInterrupted` on Ctrl-C when `allow_interrupt` (foreground only), child group killed | | `KeyboardInterrupt` out of `run_live` | `RunInterrupted` (runlive.h) | converted to `TurnInterrupted` by `AgentReplHost::run_turn`, so the REPL prints `interrupted` and checkpoints | | `RUN_CTRL_RE`, `RUN_PROMPT_RE` | `RunLive::StripControl`, `RunLive::LooksLikePrompt` | hand-written scanners, same alternation | | `PtyProc` | `PtyProc` | posix_openpt, TIOCSCTTY, TIOCSWINSZ, poll reader, echo probe | | `subprocess.Popen/run`, `shutil.which` | `Popen`, `Subprocess::Run`, `Subprocess::Which` (proc.h) | errno reporting through a CLOEXEC pipe, Python `OSError`/`TimeoutExpired` texts | | `LiveStream`, `stream_status`, `human_size` | `LiveStream`, `StreamStatus`, `HumanSize` | | | `Spinner` | `Spinner` | stderr tty only, 80 ms frames, shimmer + clock when color is on | | `LaneBoard` | `LaneBoard` | `list_agents` injected as `AgentLister` | | `Glow` (every static method) | `Glow` (glow.h) | `describe_call` over `JsonValue`, `agent_row`/`lanes` over `AgentRow` | | `Sandbox`, `container_engine`, `box_state`, `update_box_caps`, `ensure_box`, `box_exec`, `box_python`, `box_transcribe`, `box_speak`, `box_opencode_binary`, `box_opencode_run` | `Sandbox` (sandbox.h) | `BoxOpencodeExec` is the exec half; the caller runs `opencode_parse_events` | | `BOX_CONTAINERFILE`, `BOX_STT`, `BOX_TTS`, `BOX_OPENCODE_INSTALL_SCRIPT` | `BoxData` (boxdata.cpp, generated from tai.py) | byte-identical, checked by the box parity case | | `curl_hint`, `CURL_HINT` | `CurlHint`, `kCurlHint` | | | `ShellSession` (current, register, tree_lines, report, ...) | `ShellSession` (session.h) | `SessionDb` interface replaces `store.db`; pid/boot/chain logic identical | | `shell_target_paths`, `segment_targets` | `ShellTargets::Paths`, `ShellTargets::Segment` | shlex, glob and os.walk ported (`Shlex`, `PyGlob`) | | `Tools.shell_stream`, `Tools.shell_ask` | `ShellIo::Stream`, `ShellIo::Ask` over `ShellApp` | | | `edge_token`, `edge_timestamp`, `edge_ssml`, `edge_chunks`, `ws_send`, `ws_recv_exact`, `ws_recv`, `edge_skew`, `edge_connect`, `edge_synthesize` | `EdgeSpeech` (edge.h) | TLS bytes via libcurl connect-only, `EdgeSocket` seam for tests | | `play_audio`, `listen_audio`, `RECORDERS`, `TRANSCRIBERS` | `Audio` (audio.h) | | Old invented API (`Sandbox::run(allow-list)`, `ShellSession::exec`, `PtyProc::spawn/read_all`, `Spinner::tick/done`, `LiveStream::push/drain/joined`, `LaneBoard::set/render`, `Glow::highlight`, `speak_text`) is gone; tai.py has none of it. Replacements are listed in wiring/shell.md. ## How it is verified - `tests/shell_parity.py` builds `tests/shell_parity_driver.cpp` and runs every case through the driver and through the same function of `../tai.py`, comparing bytes. Real processes, a real pty (including the real `su` password prompt), real files, a scripted fake `podman`, a fake `/proc`, a fake websocket server. Fuzzed inputs for markdown, wrap, truncate, diff, json. `python3 tests/shell_parity.py [--fuzz N] [--filter NAME] [--live] [-v]`. - `tests/shell_test.cpp`: unit tests, build line in its header. - Both build with `-Wall -Wextra` and must be warning-free; the parity run fails on any compiler warning. ## Gaps and deviations (stated, not hidden) - Unicode classes: `\w`, `\d`, `str.isalnum`, `str.lower` use hand-written ranges (exact for ASCII, Latin-1, Greek, Cyrillic basics; approximate beyond). Affects markdown italics boundaries, number tokens in `highlight_python`, `complete_files` case folding for exotic scripts. - The pipe path of `run_live` keeps all output in memory (no cap), exactly like tai.py; only the pty path is capped. - After a timeout only the shell is killed (`Popen.kill`), as in tai.py. A grandchild that keeps the pipe open makes `run_live` wait up to 10 s for the reader and leaves the grandchild running. - `Spinner` and `LaneBoard` are time based; the parity run compares the first frames, the final clear and the frame count within 2, not every frame. - Edge speech: wire handling is verified offline against tai.py with a scripted server. The live service answered `403 Forbidden` to tai.py and to the port alike when last tried (2026-10-25, this machine), so end-to-end audio was not verified. - `Sandbox::EnsureBox` image build and the opencode install were exercised only against a scripted fake engine, not a real multi-minute podman build. - `ShellIo::Ask` REPL fallback: Ctrl-C/EOF during the question maps to a decline through the shared fd-0 reader, like tai.py maps `KeyboardInterrupt`/`EOFError` (decline sends end-of-file to the command). - `BackendError` here is `detz::shell::BackendError`; the agent core converts it at the call site.