Expand description
exec subcommand — runs the user’s bash command and streams chunks.
Invariants:
- The user’s command is decoded from
--b64-cmdso quoting / multi-line / shell-metachar content survives the rewrite. - The inner command runs under
bash -cinside a PTY by default (run_via_pty), with a pipe-stdio fallback (run_via_pipes) if PTY allocation fails. The PTY path keeps glibc’s stdout line-buffered so partial chunks reach the overlay in real time; ConPTY’s startup DSR (\x1b[6n, Windows only) is satisfied by pre-loading the master writer with\x1b[1;1R; the writer is held alive until after child.wait() (dropping it earlier sends CTRL_C_EVENT on Windows — ConPTY CONIN lifetime invariant, same as pair.master). The command is wrapped in a brace group redirected from /dev/null ({ <cmd>; } </dev/null) so stdin-reading children see EOF instead of blocking on the live PTY slave. We must NOT useexec </dev/nullfor this: running it inside bash closes the child’s ConPTY console input from the child side, which ConPTY reports as a CTRL_C_EVENT, killing every command with exit 130 before it ran. The group redirect points only the group’s fd 0 at /dev/null while bash’s own console fd stays open, so children get EOF and ConPTY never fires. The pipe path remains as a safety net and is the only path that preserves the stdout/stderr split — PTY collapses both onto one stream. Seedocs/specs/SPEC_LIVE_LOG_PTY_REWORK_2026_05_16.md. - Bash is located via
$AGENTMUX_BASH→$BASH→ PATH search → well-known Windows locations (Git Bash). Fails loud if missing. - Each line is published as its own chunk with
kindset to"stdout"(PTY path always; pipe path for stdout reads) or"stderr"(pipe path only — PTY merges both onto one stream). Aggregated output (with stderr lines prefixed[stderr]in the pipe path) is captured into a buffer; on exit, a formatted summary lands on this process’s stdout for Claude’s native Bash tool to harvest as thetool_resultcontent. Truncated head/tail at 50KB each perdocs/specs/SPEC_STREAMING_BASH_RUNNER_2026_05_11.md§4.5. - If MPS publish fails (missing env, sidecar down, etc.), the
command still runs to completion — output just doesn’t stream.
The model-visible blob is unaffected. A
kind: "system"chunk announces the degradation at startup.
Structs§
- Args
- CLI args for
exec.commandcarried as base64 to sidestep every quoting concern in the shell that invokes us. - CwdState 🔒
- Resolves the cwd-persistence file location and any previously-restored
starting directory once per
execinvocation. - Line
Event 🔒 - Internal channel payload: a single line tagged with its source.
Bytes are raw — UTF-8 conversion is deferred to the publish /
aggregation site so non-UTF-8 output (binary
cat, Windows legacy-encoded tools) doesn’t abort the reader.
Constants§
- ACCESS_
DENIED_ 🔒HINT - Appended after a failed command whose output shows an access denial. Generic on purpose: how to escalate differs per deployment. Conditional on purpose: in a compound command or a test suite the denial text may come from a step that isn’t the one that failed.
- ACCESS_
DENIED_ 🔒MARKERS - Output fragments that mean an API refused the caller rather than the
command being wrong: AWS (
AccessDenied,AccessDeniedException,... is not authorized to perform: ..., EC2’sUnauthorizedOperation) and the GitHub API as an App (Resource not accessible by integration). - CWD_
STATE_ 🔒ENV - Env var bashwrap sets on the child bash process (never interpolated
into the script text, so no path-quoting concerns) pointing at the state
file
append_cwd_capture’s appended script writes the ending$PWDto. - CWD_
STATE_ 🔒FILE_ OVERRIDE_ ENV - Optional override of the state file’s own path, read from bashwrap’s own
process env (distinct from
CWD_STATE_ENV, which bashwrap sets rather than reads). Exists so tests can point persistence at a tempdir instead of a real agent’s~/.agentmuxstate, and as an escape hatch for manual debugging. - DEFAULT_
IDLE_ 🔒KILL_ TIMEOUT - If a wrapped command’s PTY produces zero bytes of output for this long,
assume it’s blocked waiting for interactive input that will never come
(e.g.
less/moreinvoked as a pager, since the PTY deliberately makes the child seeisatty(stdout) == trueand this wrapper never writes to the PTY again after the startup DSR response) and forcibly kill it rather than leak this whole process forever. See docs/retro/RETRO_BASHWRAP_PAGER_HANG_LEAK_2026_07_14.md. - FLUSH_
BYTES 🔒 - Flush pending bytes to the publisher when buffered without a newline AND the buffer reaches this size — so long lines and newline-free output (minified JSON, CR-only progress bars) still stream live instead of accumulating in memory unbounded.
- FLUSH_
QUIET_ 🔒WINDOW - Flush pending bytes after this much idle time even if no newline / size threshold has been hit — keeps slow-trickle output visible without waiting for the next byte that would have triggered a natural flush.
- MODEL_
BLOB_ 🔒HEAD_ BYTES - Cap the model-visible head/tail sections of the aggregated blob.
- MODEL_
BLOB_ 🔒TAIL_ BYTES - PTY_
COLS 🔒 - Size of the PTY commands run in. Wider than the classic 80 so that
width-aware programs (tables,
--help,lscolumns) don’t fold their output with hard line breaks; previews scroll long lines instead. Bash derivesCOLUMNS/LINESfrom it. - PTY_
ROWS 🔒
Functions§
- access_
denied_ 🔒hint - The hint for the model, if the command failed and its output shows an
access denial. The whole buffer is searched, including any part the
model blob elides. A successful command never gets the hint, so a
grepor a log that merely mentionsAccessDeniedstays untouched. - append_
cwd_ 🔒capture - Appends bookkeeping to
command_blockthat (1) captures its real exit code before running anything else, (2) persists the shell’s ending$PWDto$AGENTMUX_BASHWRAP_CWD_STATEif that env var is set (skipped entirely otherwise — e.g. whenCwdState::pathwasNone), written via a temp-file-then-rename so a mid-write crash can’t leave a half-written state file, and (3) re-exits with the captured code so this bookkeeping never changes the command’s real exit status.command_blockmust be a brace group or bare command, not something that already ends in its ownexit. - collapse_
cr 🔒 - Collapse lone carriage-returns in the
pendingbyte buffer, simulating terminal overwrite: each\rNOT followed by\ndiscards the current visual line back to column 0. - cwd_
state_ 🔒path - Per-instance state file path. Keyed by
AGENTMUX_INSTANCE_SLUG— set byagent-model.tsto a per-launch slug that also seeds that instance’s own unique working directory (${agentmuxHome()}/agents/${instanceSlug}) — NOTAGENTMUX_AGENT_ID, which is the definition-wide slug shared by every named instance launched from the same agent definition (codex P1 on this PR: keying byAGENTMUX_AGENT_IDmade two concurrently-running instances of the same definition silently share one cwd file, so one instance’scdcould redirect the other’s next command into the wrong worktree). Falls back toAGENTMUX_AGENT_IDfor older callers that only set that, then to a sanitized form of this process’s own cwd (always the instance’s fixed home directory either way) for the rare case neither is set, e.g. a manual invocation outside AgentMux. - decode_
command 🔒 - detach_
declared_ 🔒background_ session - For a
--declared-backgroundinvocation, detach this process into a brand-new session (setsid()) before doing anything else, so it has no controlling terminal at all. - effective_
idle_ 🔒timeout - The idle-kill timeout to actually apply for this invocation. A
declared-background call (
args.declared_background, threaded from the CLI’s ownrun_in_background: true) is exempt from the idle-kill safety net entirely — it may legitimately go silent forever (a GUI window, a dev server) without that being a stuck/pager-hung command. Everything else keeps the ordinaryidle_kill_timeout(). See issue #2491. - format_
model_ 🔒blob - Build the aggregated, model-visible blob from the captured bytes. Truncates with head/tail markers if it exceeds 100KB.
- idle_
kill_ 🔒timeout - is_
cup_ 🔒row1_ col1 - Does a CUP (
\x1b[<params>H) params string address row 1, column 1 (“home”)? Each field defaults to 1 when empty, per ECMA-48 — so"","1","1;1",";1","1;", and";"are all row-1-col-1. - kill_
process_ 🔒tree - Kill
pidAND every descendant it spawned, not just the one process. SupplementsChildKiller::kill()(portable-pty’s Windows impl is a bareTerminateProcesson a single handle, no job object — see the caller’s comment) — without this, a wrapped command whose direct child forks further (a pipeline, orgitspawninglessas a child) can leave an orphaned grandchild running and still attached to the PTY slave after the “kill” (reagent P1, PR #2156). - locate_
bash 🔒 - Locate bash. Order:
- log_
relevant_ 🔒env - Log which streaming-relevant env vars the wrapper actually received, so the sidecar log tells us at a glance whether the env-propagation chain from agentmux-srv → claude → bash → hook → here is intact. Values are not logged (auth key is sensitive); presence/absence is the only signal we need to triage the propagation gap.
- normalize_
csi_ 🔒overwrites - Normalize CSI cursor-repositioning idioms that map cleanly onto the
existing
\r-based overwrite model, converting them to a literal\rbyte socollapse_cr(which already understands\r) collapses them too, with no new cases needed there. Runs on the rawchunkBEFOREstrip_ansi, which otherwise deletes CSI sequences unconditionally with no overwrite-awareness — the “Phase γ” gapstrip_ansi’s own doc comment flags (“Cursor positioning escapes within the same line”). See docs/specs/SPEC_TOOL_LOG_UNIVERSAL_ANIMATION_COLLAPSE_2026_07_27.md §A2. - pty_
reader_ 🔒loop - PTY reader loop: drains bytes from the master, strips DSR / ANSI control sequences, and forwards line-split chunks to the publisher.
- publish_
line 🔒 - publish_
pid 🔒 - Publish this process’s own pid, once, for a declared-background
invocation. Called via
tokio::spawn(never awaited inline) — see the call site’s comment for why blocking startup on this was a real latency bug (reagentx P1, PR #2681). - publish_
system 🔒 - restore_
cwd 🔒 - Read back a previously-persisted cwd, if the file exists, is non-empty, and still names a real directory (it may not — the directory could have been deleted since, or this is the first call for this agent).
- run
- Returns the inner command’s exit code so main.rs can mirror it as the wrapper’s own process exit. Without this, Claude’s native Bash tool would see success for every wrapped command regardless of the actual outcome.
- run_
command 🔒 - run_
proc 🔒 - Spawn the bash child and stream its lines. PTY by default with a pipe fallback if PTY allocation fails (G6 in docs/specs/SPEC_LIVE_LOG_PTY_REWORK_2026_05_16.md).
- run_
via_ 🔒pipes - Pipe-backed run path. Safety net for environments where PTY allocation fails (CI, sandboxes). Same byte semantics as the old pipe-only wrapper.
- run_
via_ 🔒pty - PTY-backed run path. Holds master alive across child.wait() and answers DSR queries from bash at startup.
- sanitize_
state_ 🔒key - Filesystem-safe form of an arbitrary key for use as a filename component.
- snap_
to_ 🔒char_ boundary_ ceil - Snap
idxup to the nearest UTF-8 character boundary (or buf.len()). - snap_
to_ 🔒char_ boundary_ floor - Snap
idxdown to the nearest UTF-8 character boundary (or 0). A byte is a UTF-8 char boundary iff it’s either ASCII (< 0x80) or a leading byte (>= 0xC0). Continuation bytes are in 0x80..=0xBF.idx == buf.len()is always a valid boundary (one past the end). - spawn_
publisher_ 🔒loop - Shared publisher loop — drains the LineEvent channel, aggregates into the model-visible buffer, and publishes each line via MPS.
- stream_
reader 🔒 - Read raw bytes from a child stdio handle and forward chunks to the publisher with three flush triggers:
- strip_
ansi 🔒 - Phase β (lite): strip ANSI control sequences from a PTY chunk so the overlay’s plain-text ChunkList doesn’t render them as garbled literal characters.
- strip_
dsr 🔒 - Scan
chunkfor DSR\x1b[6nsequences and remove them in place so they don’t leak into the model-visible blob or overlay log.