Skip to main content

Module bash_wrap

Module bash_wrap 

Source
Expand description

exec subcommand — runs the user’s bash command and streams chunks.

Invariants:

  • The user’s command is decoded from --b64-cmd so quoting / multi-line / shell-metachar content survives the rewrite.
  • The inner command runs under bash -c inside 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 use exec </dev/null for 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. See docs/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 kind set 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 the tool_result content. Truncated head/tail at 50KB each per docs/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. command carried 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 exec invocation.
LineEvent 🔒
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’s UnauthorizedOperation) 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 $PWD to.
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 ~/.agentmux state, 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/more invoked as a pager, since the PTY deliberately makes the child see isatty(stdout) == true and 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, ls columns) don’t fold their output with hard line breaks; previews scroll long lines instead. Bash derives COLUMNS / LINES from 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 grep or a log that merely mentions AccessDenied stays untouched.
append_cwd_capture 🔒
Appends bookkeeping to command_block that (1) captures its real exit code before running anything else, (2) persists the shell’s ending $PWD to $AGENTMUX_BASHWRAP_CWD_STATE if that env var is set (skipped entirely otherwise — e.g. when CwdState::path was None), 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_block must be a brace group or bare command, not something that already ends in its own exit.
collapse_cr 🔒
Collapse lone carriage-returns in the pending byte buffer, simulating terminal overwrite: each \r NOT followed by \n discards the current visual line back to column 0.
cwd_state_path 🔒
Per-instance state file path. Keyed by AGENTMUX_INSTANCE_SLUG — set by agent-model.ts to a per-launch slug that also seeds that instance’s own unique working directory (${agentmuxHome()}/agents/${instanceSlug}) — NOT AGENTMUX_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 by AGENTMUX_AGENT_ID made two concurrently-running instances of the same definition silently share one cwd file, so one instance’s cd could redirect the other’s next command into the wrong worktree). Falls back to AGENTMUX_AGENT_ID for 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-background invocation, 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 own run_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 ordinary idle_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 pid AND every descendant it spawned, not just the one process. Supplements ChildKiller::kill() (portable-pty’s Windows impl is a bare TerminateProcess on a single handle, no job object — see the caller’s comment) — without this, a wrapped command whose direct child forks further (a pipeline, or git spawning less as 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 \r byte so collapse_cr (which already understands \r) collapses them too, with no new cases needed there. Runs on the raw chunk BEFORE strip_ansi, which otherwise deletes CSI sequences unconditionally with no overwrite-awareness — the “Phase γ” gap strip_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 idx up to the nearest UTF-8 character boundary (or buf.len()).
snap_to_char_boundary_floor 🔒
Snap idx down 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 chunk for DSR \x1b[6n sequences and remove them in place so they don’t leak into the model-visible blob or overlay log.