Skip to main content

agentmux_bashwrap/
main.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3// Suppress console windows on Windows — bashwrap communicates via pipes and
4// HTTP, never via a console. Without this attribute every Bash tool call
5// produces a blank transparent window (Windows auto-creates one for any
6// CUI-subsystem process unless CREATE_NO_WINDOW is passed by the spawner,
7// which Claude Code's hook mechanism does not do).
8#![cfg_attr(windows, windows_subsystem = "windows")]
9
10//! agentmux-bashwrap — streaming bash wrapper for AgentMux agents.
11//!
12//! Two subcommands:
13//!
14//! - `exec` — runs a user-supplied command inside an owned PTY,
15//!   streams stdout/stderr line-by-line to the AgentMux sidecar's
16//!   MPS broker (HTTP), and prints the aggregated output on its own
17//!   stdout for Claude's native Bash tool to capture as `tool_result`.
18//!
19//! - `hook` — reads a PreToolUse JSON payload on stdin. When the CLI's
20//!   shell prefix is this binary, it records the call for the prefix
21//!   process and changes nothing. Otherwise it emits a hook response that
22//!   rewrites the command so Claude's native Bash invokes
23//!   `agentmux-bashwrap exec` instead, the command base64-encoded into the
24//!   rewrite argv so quoting + multi-line bodies survive.
25//!
26//! - prefix mode (one argument: the CLI's script) — Claude Code's
27//!   `CLAUDE_CODE_SHELL_PREFIX`. Runs the script the way `exec` runs a
28//!   command, for the call the hook recorded. See `prefix.rs` and
29//!   `docs/specs/SPEC_BASH_STREAMING_VIA_SHELL_PREFIX_2026_10_10.md`.
30//!
31//! - `precompact` — registered as Claude Code's `PreCompact` hook.
32//!   Fires the instant compaction begins; pings the sidecar's MPS
33//!   broker with a `compaction_started` event so the UI can show
34//!   live status instead of a silent gap. See `precompact.rs` and
35//!   `docs/specs/SPEC_COMPACTION_DETECTION_AND_HANDLING_2026_07_31.md`.
36//!
37//! - askpass mode (no subcommand) — `ssh`'s `SSH_ASKPASS` when AgentMux
38//!   runs ssh for an agent: shows ssh's prompt to the user through srv and
39//!   prints the answer. See `askpass.rs`.
40//!
41//! - `sessionstart --part N` — registered as Claude Code's `SessionStart`
42//!   hook, once per part. Delivers part N of the agent's memory as the
43//!   hook's `additionalContext`. See `sessionstart.rs` and
44//!   `docs/specs/SPEC_GLOBAL_MEMORY_DELIVERY_2026_09_27.md` §7 P2.
45//!
46//! See `docs/specs/SPEC_STREAMING_BASH_RUNNER_2026_05_11.md` for the
47//! full design rationale (why command rewrite vs. MCP deny-redirect,
48//! why a separate binary vs. extending an MCP server, channel
49//! correlation by `tool_use_id`, etc).
50
51use anyhow::Result;
52use clap::{Parser, Subcommand};
53
54mod askpass;
55mod bash_wrap;
56mod hook;
57mod precompact;
58mod prefix;
59mod sessionstart;
60#[cfg(test)]
61mod test_env_lock;
62mod mps_client;
63
64#[derive(Parser)]
65#[command(name = "agentmux-bashwrap", version)]
66#[command(about = "AgentMux streaming bash wrapper + PreToolUse hook helper")]
67struct Cli {
68    #[command(subcommand)]
69    command: Command,
70}
71
72#[derive(Subcommand)]
73enum Command {
74    /// Run a bash command inside an owned PTY, stream its stdout/stderr
75    /// to the AgentMux sidecar's MPS broker, and print the aggregated
76    /// output on this process's stdout for Claude to capture.
77    Exec(bash_wrap::Args),
78    /// Read a PreToolUse JSON payload on stdin (from Claude Code) and
79    /// emit a hook response that rewrites the command to invoke `exec`.
80    Hook,
81    /// Registered as Claude Code's `PreCompact` hook. Publishes a
82    /// `compaction_started` MPS event and exits 0 with no stdout
83    /// output — observe-only, never blocks compaction.
84    Precompact(precompact::Args),
85    /// Registered as Claude Code's `SessionStart` hook, once per part.
86    /// Prints this part of the agent's memory as the hook's
87    /// `additionalContext` (or nothing), then acknowledges it.
88    Sessionstart(sessionstart::Args),
89}
90
91fn main() -> Result<()> {
92    init_tracing();
93
94    // ssh's askpass for an agent's SSH command: the prompt is the only
95    // argument, so this is decided before clap sees it (askpass.rs).
96    if let Some(code) = askpass::run_if_asked() {
97        std::process::exit(code);
98    }
99
100    // Claude Code's shell prefix: the CLI's script is the only argument, so
101    // this too is decided before clap sees it (prefix.rs).
102    let argv: Vec<std::ffi::OsString> = std::env::args_os().collect();
103    if let Some(script) = prefix::prefix_script(&argv) {
104        let rt = tokio::runtime::Runtime::new()?;
105        let exit_code = rt.block_on(prefix::run(script))?;
106        std::process::exit(exit_code);
107    }
108
109    let cli = Cli::parse();
110    let rt = tokio::runtime::Runtime::new()?;
111    match cli.command {
112        Command::Exec(args) => {
113            // Propagate the inner command's exit code as our own
114            // process exit. Without this the wrapper always exited 0
115            // and Claude's native Bash tool saw success for every
116            // wrapped command regardless of the actual outcome —
117            // codex P1 on PR #804.
118            let exit_code = rt.block_on(bash_wrap::run(args))?;
119            std::process::exit(exit_code);
120        }
121        Command::Hook => hook::run_pretooluse_bash(),
122        Command::Precompact(args) => rt.block_on(precompact::run(args)),
123        Command::Sessionstart(args) => rt.block_on(sessionstart::run(args)),
124    }
125}
126
127/// Initialize tracing to write to `~/.agentmux/logs/bashwrap-debug.log`
128/// at INFO by default. Writing to a file instead of stderr means:
129///
130/// 1. Diagnostics survive bash's stdio capture — Claude's tool_result
131///    `stderr` field is empty for successful commands, so anything we
132///    write to stderr is lost to us as developers.
133/// 2. We don't pollute the model's tool_result with bashwrap-internal
134///    noise (env snapshot, publish attempts, etc.).
135/// 3. We can tail the file from outside the agentmux process tree.
136///
137/// Falls back to stderr at WARN level if the file can't be opened.
138fn init_tracing() {
139    let log_path = dirs::home_dir()
140        .map(|h| h.join(".agentmux").join("logs").join("bashwrap-debug.log"));
141    if let Some(path) = log_path {
142        if let Some(parent) = path.parent() {
143            let _ = std::fs::create_dir_all(parent);
144        }
145        if let Ok(file) = std::fs::OpenOptions::new()
146            .append(true)
147            .create(true)
148            .open(&path)
149        {
150            let writer = std::sync::Mutex::new(file);
151            let _ = tracing_subscriber::fmt()
152                .with_writer(writer)
153                .with_ansi(false)
154                .with_env_filter(
155                    tracing_subscriber::EnvFilter::try_from_default_env()
156                        .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
157                )
158                .try_init();
159            return;
160        }
161    }
162    let _ = tracing_subscriber::fmt()
163        .with_writer(std::io::stderr)
164        .with_env_filter(
165            tracing_subscriber::EnvFilter::try_from_default_env()
166                .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")),
167        )
168        .try_init();
169}