Skip to main content

agentmux_bashwrap/
bash_wrap.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! `exec` subcommand — runs the user's bash command and streams chunks.
5//!
6//! Invariants:
7//! - The user's command is decoded from `--b64-cmd` so quoting /
8//!   multi-line / shell-metachar content survives the rewrite.
9//! - The inner command runs under `bash -c` inside a PTY by default
10//!   (`run_via_pty`), with a pipe-stdio fallback (`run_via_pipes`)
11//!   if PTY allocation fails. The PTY path keeps glibc's stdout
12//!   line-buffered so partial chunks reach the overlay in real time;
13//!   ConPTY's startup DSR (`\x1b[6n`, Windows only) is satisfied by
14//!   pre-loading the master writer with `\x1b[1;1R`; the writer is held alive until
15//!   after child.wait() (dropping it earlier sends CTRL_C_EVENT on
16//!   Windows — ConPTY CONIN lifetime invariant, same as pair.master).
17//!   The command is wrapped in a brace group redirected from /dev/null
18//!   (`{ <cmd>; } </dev/null`) so stdin-reading children see EOF instead
19//!   of blocking on the live PTY slave. We must NOT use `exec </dev/null`
20//!   for this: running it inside bash closes the child's ConPTY console
21//!   input from the child side, which ConPTY reports as a CTRL_C_EVENT,
22//!   killing every command with exit 130 before it ran. The group
23//!   redirect points only the group's fd 0 at /dev/null while bash's own
24//!   console fd stays open, so children get EOF and ConPTY never fires.
25//!   The pipe path remains as a safety net and is the only path that
26//!   preserves the stdout/stderr split — PTY collapses both onto
27//!   one stream. See `docs/specs/SPEC_LIVE_LOG_PTY_REWORK_2026_05_16.md`.
28//! - Bash is located via `$AGENTMUX_BASH` → `$BASH` → PATH search →
29//!   well-known Windows locations (Git Bash). Fails loud if missing.
30//! - Each line is published as its own chunk with `kind` set to
31//!   `"stdout"` (PTY path always; pipe path for stdout reads) or
32//!   `"stderr"` (pipe path only — PTY merges both onto one stream).
33//!   Aggregated output (with stderr lines prefixed `[stderr] ` in the
34//!   pipe path) is captured into a buffer; on exit, a formatted summary
35//!   lands on this process's stdout for Claude's native Bash tool to
36//!   harvest as the `tool_result` content. Truncated head/tail at 50KB
37//!   each per `docs/specs/SPEC_STREAMING_BASH_RUNNER_2026_05_11.md` §4.5.
38//! - If MPS publish fails (missing env, sidecar down, etc.), the
39//!   command still runs to completion — output just doesn't stream.
40//!   The model-visible blob is unaffected. A `kind: "system"` chunk
41//!   announces the degradation at startup.
42
43#[cfg(windows)]
44use agentmux_common::win32::NoWindow;
45use anyhow::{Context, Result, anyhow};
46use clap::Parser;
47use portable_pty::{CommandBuilder, PtySize, native_pty_system};
48use std::path::{Path, PathBuf};
49use std::process::Stdio;
50use std::sync::Arc;
51use std::time::Duration;
52use tokio::io::AsyncReadExt;
53use tokio::process::Command;
54use tokio::sync::{Mutex, mpsc, oneshot};
55
56use crate::mps_client::WpsClient;
57
58/// CLI args for `exec`. `command` carried as base64 to sidestep every
59/// quoting concern in the shell that invokes us.
60#[derive(Parser, Debug)]
61pub struct Args {
62    /// Tool-use id from Claude's `tool_use` event; threaded back as
63    /// the MPS subject suffix so the frontend correlates chunks with
64    /// the matching ToolNode.
65    #[arg(long)]
66    pub tool_id: String,
67
68    /// URL-safe base64 (no padding) of the original command string.
69    #[arg(long)]
70    pub b64_cmd: String,
71
72    /// Optional block id — used as the `block:<id>` MPS scope so
73    /// chunks only reach subscribers watching this block. When
74    /// omitted, chunks publish unscoped.
75    #[arg(long)]
76    pub block_id: Option<String>,
77
78    /// True when the CLI's own Bash tool call was made with
79    /// `run_in_background: true` (threaded through by
80    /// `hook.rs::build_response` from the PreToolUse payload's
81    /// `tool_input.run_in_background`). A declared-background task may
82    /// legitimately produce zero output forever (a GUI window, a dev
83    /// server once its logs go quiet) — the idle-kill heuristic exists for
84    /// commands that SHOULD exit and are stuck, not for this class. See
85    /// `effective_idle_timeout` below, issue #2491, and
86    /// docs/retro/RETRO_TASK_DEV_IDLE_KILL_FALSE_POSITIVE_2026_07_31.md.
87    #[arg(long, default_value_t = false)]
88    pub declared_background: bool,
89}
90
91// ─────────────────────────────────────────────────────────────────────────────
92// Cwd persistence across one-shot `exec` invocations
93//
94// Every Bash tool call the PreToolUse hook rewrites spawns a brand-new,
95// disposable `agentmux-bashwrap exec` process (see main.rs's module doc —
96// there is no daemon). Historically the inner bash's starting directory was
97// seeded from `std::env::current_dir()`, i.e. THIS process's own inherited
98// cwd, which is always the agent's fixed home directory (nothing in this
99// process tree ever chdirs it). A `cd` run by the wrapped command only ever
100// affected that one throwaway inner bash; the moment it exited, the change
101// was gone, and the NEXT call started over at the same fixed directory again
102// — every time, not intermittently. Claude Code's own Bash tool expects `cd`
103// continuity across calls within a session and (at least on Windows, where
104// this rewrite always applies) surfaces the mismatch as a
105// "Shell cwd was reset to ..." notice on effectively every call. See
106// docs/retro/RETRO_BASH_CWD_RESET_NOTICE_WINDOWS_2026_08_02.md for the full
107// investigation.
108//
109// The fix: persist the shell's ending `$PWD` to a small per-agent state file
110// after each call, and restore it as the starting directory of the next
111// call. This doesn't change what Claude Code itself believes (that's opaque,
112// out of process) but it does make the ACTUAL directory a `cd` in one Bash
113// call leaves the agent in survive to the next call, which is the part that
114// was silently broken.
115// ─────────────────────────────────────────────────────────────────────────────
116
117/// Env var bashwrap sets on the *child* bash process (never interpolated
118/// into the script text, so no path-quoting concerns) pointing at the state
119/// file `append_cwd_capture`'s appended script writes the ending `$PWD` to.
120const CWD_STATE_ENV: &str = "AGENTMUX_BASHWRAP_CWD_STATE";
121
122/// Optional override of the state file's own path, read from bashwrap's own
123/// process env (distinct from `CWD_STATE_ENV`, which bashwrap sets rather
124/// than reads). Exists so tests can point persistence at a tempdir instead
125/// of a real agent's `~/.agentmux` state, and as an escape hatch for manual
126/// debugging.
127const CWD_STATE_FILE_OVERRIDE_ENV: &str = "AGENTMUX_BASHWRAP_CWD_STATE_FILE";
128
129/// Resolves the cwd-persistence file location and any previously-restored
130/// starting directory once per `exec` invocation.
131struct CwdState {
132    /// Where the ending `$PWD` gets written after this command runs, for the
133    /// *next* `exec` invocation to pick up. `None` if we couldn't resolve a
134    /// location or create its parent dir — persistence is then simply
135    /// skipped for this call (never a hard failure; falls back to today's
136    /// pre-fix behavior).
137    path: Option<PathBuf>,
138    /// The directory to actually start this command's shell in: the
139    /// previously-persisted directory if one was found and still exists,
140    /// otherwise this process's own (fixed, inherited) cwd — the same
141    /// default used before this fix existed.
142    start_dir: Option<PathBuf>,
143}
144
145impl CwdState {
146    fn load() -> Self {
147        let mut path = cwd_state_path();
148        if let Some(dir) = path.as_deref().and_then(Path::parent) {
149            if let Err(e) = std::fs::create_dir_all(dir) {
150                tracing::warn!(
151                    target: "bashwrap",
152                    error = %e,
153                    dir = %dir.display(),
154                    "failed to create cwd-state dir; cwd persistence disabled for this call",
155                );
156                // reagentx P2 (re-review): the log line above claims
157                // persistence is disabled, but `path` used to stay `Some`
158                // here regardless — restore_cwd below would still attempt
159                // (and silently fail) a read, and the child would still get
160                // CWD_STATE_ENV pointed at a directory that doesn't exist,
161                // so append_cwd_capture's write would silently fail on
162                // every single call forever instead of being skipped once,
163                // as documented. Actually clear it so both sides honor the
164                // "disabled" contract `path`'s own doc comment promises.
165                path = None;
166            }
167        }
168        let start_dir = path
169            .as_deref()
170            .and_then(restore_cwd)
171            .or_else(|| std::env::current_dir().ok());
172        Self { path, start_dir }
173    }
174}
175
176/// Per-*instance* state file path. Keyed by `AGENTMUX_INSTANCE_SLUG` — set by
177/// `agent-model.ts` to a per-launch slug that also seeds that instance's own
178/// unique working directory (`${agentmuxHome()}/agents/${instanceSlug}`) —
179/// NOT `AGENTMUX_AGENT_ID`, which is the definition-wide slug shared by every
180/// named instance launched from the same agent definition (codex P1 on this
181/// PR: keying by `AGENTMUX_AGENT_ID` made two concurrently-running instances
182/// of the same definition silently share one cwd file, so one instance's
183/// `cd` could redirect the other's next command into the wrong worktree).
184/// Falls back to `AGENTMUX_AGENT_ID` for older callers that only set that,
185/// then to a sanitized form of this process's own cwd (always the instance's
186/// fixed home directory either way) for the rare case neither is set, e.g. a
187/// manual invocation outside AgentMux.
188fn cwd_state_path() -> Option<PathBuf> {
189    if let Ok(over) = std::env::var(CWD_STATE_FILE_OVERRIDE_ENV) {
190        if !over.is_empty() {
191            return Some(PathBuf::from(over));
192        }
193    }
194    let dir = dirs::home_dir()?
195        .join(".agentmux")
196        .join("state")
197        .join("bashwrap-cwd");
198    let non_empty = |s: String| (!s.is_empty()).then_some(s);
199    let key = std::env::var("AGENTMUX_INSTANCE_SLUG")
200        .ok()
201        .and_then(non_empty)
202        .or_else(|| std::env::var("AGENTMUX_AGENT_ID").ok().and_then(non_empty))
203        .or_else(|| {
204            std::env::current_dir()
205                .ok()
206                .map(|p| p.to_string_lossy().into_owned())
207        })?;
208    Some(dir.join(format!("{}.cwd", sanitize_state_key(&key))))
209}
210
211/// Filesystem-safe form of an arbitrary key for use as a filename component.
212fn sanitize_state_key(raw: &str) -> String {
213    raw.chars()
214        .map(|c| {
215            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
216                c
217            } else {
218                '_'
219            }
220        })
221        .collect()
222}
223
224/// Read back a previously-persisted cwd, if the file exists, is non-empty,
225/// and still names a real directory (it may not — the directory could have
226/// been deleted since, or this is the first call for this agent).
227fn restore_cwd(state_path: &Path) -> Option<PathBuf> {
228    let contents = std::fs::read_to_string(state_path).ok()?;
229    let trimmed = contents.trim();
230    if trimmed.is_empty() {
231        return None;
232    }
233    let path = PathBuf::from(trimmed);
234    path.is_dir().then_some(path)
235}
236
237/// Appends bookkeeping to `command_block` that (1) captures its real exit
238/// code before running anything else, (2) persists the shell's ending
239/// `$PWD` to `$AGENTMUX_BASHWRAP_CWD_STATE` if that env var is set (skipped
240/// entirely otherwise — e.g. when `CwdState::path` was `None`), written via
241/// a temp-file-then-rename so a mid-write crash can't leave a half-written
242/// state file, and (3) re-exits with the captured code so this bookkeeping
243/// never changes the command's real exit status. `command_block` must be a
244/// brace group or bare command, not something that already ends in its own
245/// `exit`.
246///
247/// The temp file is suffixed with `$$` (this bash's own PID), not a fixed
248/// name — reagentx P2 on this PR: two Bash tool calls for the same
249/// agent/instance can run concurrently (Claude issues parallel tool calls),
250/// and a shared fixed temp name meant two concurrent writers could
251/// interleave writes to the *same* temp file before either renamed it,
252/// corrupting the persisted cwd. Per-PID temp names make each writer's
253/// temp-then-rename independent; the only remaining race is which
254/// completed rename lands last, which is an inherent, expected ambiguity
255/// for genuinely concurrent `cd`s (no different from two parallel shells
256/// racing to `cd` a shared session) — not data corruption.
257fn append_cwd_capture(command_block: &str) -> String {
258    // `pwd` alone prints MSYS-style paths under Git Bash (`/c/Users/...`),
259    // which Rust's `std::fs`/`Path::is_dir()` on native Windows can't
260    // resolve (it looks for a literal `\c\Users\...` under the current
261    // drive root). `-W` is Git Bash's own flag for "print the Windows-
262    // native form instead" (`C:/Users/...`), which both `restore_cwd`'s
263    // `is_dir()` check and `cmd.cwd()`/`cmd.current_dir()` understand
264    // correctly. Not a POSIX `pwd` flag — Unix has no such mismatch to
265    // work around, so plain `pwd` is correct there.
266    let pwd_cmd = if cfg!(windows) { "pwd -W" } else { "pwd" };
267    format!(
268        "{command_block}\n\
269__agentmux_bashwrap_rc=$?\n\
270if [ -n \"${CWD_STATE_ENV}\" ]; then\n\
271  __agentmux_bashwrap_cwd_tmp=\"${CWD_STATE_ENV}.tmp.$$\"\n\
272  {pwd_cmd} > \"$__agentmux_bashwrap_cwd_tmp\" 2>/dev/null && mv -f \"$__agentmux_bashwrap_cwd_tmp\" \"${CWD_STATE_ENV}\" 2>/dev/null\n\
273fi\n\
274exit $__agentmux_bashwrap_rc"
275    )
276}
277
278/// Cap the model-visible head/tail sections of the aggregated blob.
279const MODEL_BLOB_HEAD_BYTES: usize = 50_000;
280const MODEL_BLOB_TAIL_BYTES: usize = 50_000;
281
282/// Flush pending bytes to the publisher when buffered without a
283/// newline AND the buffer reaches this size — so long lines and
284/// newline-free output (minified JSON, CR-only progress bars) still
285/// stream live instead of accumulating in memory unbounded.
286const FLUSH_BYTES: usize = 4096;
287
288/// Size of the PTY commands run in. Wider than the classic 80 so that
289/// width-aware programs (tables, `--help`, `ls` columns) don't fold their
290/// output with hard line breaks; previews scroll long lines instead. Bash
291/// derives `COLUMNS` / `LINES` from it.
292const PTY_COLS: u16 = 200;
293const PTY_ROWS: u16 = 50;
294
295/// Flush pending bytes after this much idle time even if no newline /
296/// size threshold has been hit — keeps slow-trickle output visible
297/// without waiting for the next byte that would have triggered a
298/// natural flush.
299const FLUSH_QUIET_WINDOW: Duration = Duration::from_millis(50);
300
301/// If a wrapped command's PTY produces zero bytes of output for this long,
302/// assume it's blocked waiting for interactive input that will never come
303/// (e.g. `less`/`more` invoked as a pager, since the PTY deliberately makes
304/// the child see `isatty(stdout) == true` and this wrapper never writes to
305/// the PTY again after the startup DSR response) and forcibly kill it
306/// rather than leak this whole process forever. See
307/// docs/retro/RETRO_BASHWRAP_PAGER_HANG_LEAK_2026_07_14.md.
308///
309/// This is an IDLE timeout, not a total-runtime timeout: a command that's
310/// silent for under this long but runs far longer overall (e.g. a build
311/// with continuous compiler output over several minutes) is unaffected —
312/// only a command producing literally zero bytes for the full window trips
313/// it. Overridable via `AGENTMUX_BASHWRAP_IDLE_TIMEOUT_SECS`.
314const DEFAULT_IDLE_KILL_TIMEOUT: Duration = Duration::from_secs(600);
315
316fn idle_kill_timeout() -> Duration {
317    std::env::var("AGENTMUX_BASHWRAP_IDLE_TIMEOUT_SECS")
318        .ok()
319        .and_then(|s| s.parse::<u64>().ok())
320        .map(Duration::from_secs)
321        .unwrap_or(DEFAULT_IDLE_KILL_TIMEOUT)
322}
323
324/// The idle-kill timeout to actually apply for this invocation. A
325/// declared-background call (`args.declared_background`, threaded from the
326/// CLI's own `run_in_background: true`) is exempt from the idle-kill safety
327/// net entirely — it may legitimately go silent forever (a GUI window, a dev
328/// server) without that being a stuck/pager-hung command. Everything else
329/// keeps the ordinary `idle_kill_timeout()`. See issue #2491.
330fn effective_idle_timeout(args: &Args) -> Duration {
331    if args.declared_background {
332        Duration::from_secs(u64::MAX)
333    } else {
334        idle_kill_timeout()
335    }
336}
337
338/// For a `--declared-background` invocation, detach this process into a
339/// brand-new session (`setsid()`) before doing anything else, so it has no
340/// controlling terminal at all.
341///
342/// Without this, this process (and anything it spawns — the wrapped
343/// command's own PTY-hosted child, e.g. `task dev`) stays in the SAME
344/// session as the `claude` CLI process that invoked it (an ordinary child
345/// inherits its parent's session/process group unless it explicitly
346/// detaches). Two independent POSIX mechanisms then both threaten it the
347/// moment that session's leader (`claude`) is killed for ANY reason,
348/// including a clean single-PID kill that never touches this process
349/// directly:
350/// 1. If `claude`'s own PTY master is closed (agentmux-srv's normal
351///    stop/replace path drops its input channel, which drops the PTY
352///    writer+master — see `blockcontroller/shell/lifecycle.rs`), the
353///    kernel hangs up the terminal, sending SIGHUP to the whole foreground
354///    process group of that terminal.
355/// 2. Independently of (1): POSIX also sends SIGHUP to a session's
356///    foreground process group when the session LEADER terminates — this
357///    fires purely from `claude` exiting, with no PTY-close step required
358///    at all.
359/// Either one, by default, terminates this process (SIGHUP's default
360/// disposition) — and if this process is what's directly holding open the
361/// wrapped command's own PTY master (the common case, via `run_via_pty`),
362/// this process dying closes that PTY too, cascading the exact same
363/// hangup one level deeper to the wrapped command itself. `setsid()`
364/// makes this process (and, transitively, anything it PTY-spawns
365/// afterward, which becomes associated with THIS new session rather than
366/// `claude`'s) immune to both — a session with no controlling terminal at
367/// all has no foreground process group for the kernel to signal. See
368/// docs/specs/SPEC_BACKGROUND_TASK_TEARDOWN_SURVIVAL_2026_08_20.md and the
369/// Codex finding on PR #2683 this was written to address.
370///
371/// A plain child (not already a process group leader — always true here,
372/// since this process was just `exec`'d as an ordinary child of `claude`)
373/// can always call `setsid()` successfully; a failure would be a genuine
374/// platform anomaly, logged but not fatal — worst case this invocation
375/// keeps the pre-existing (already-buggy, already being fixed elsewhere in
376/// this same PR) exposure, rather than losing the command's actual output.
377/// No-op on Windows (no session/controlling-terminal concept to detach
378/// from) and for an ordinary, non-backgrounded invocation.
379#[cfg(unix)]
380fn detach_declared_background_session(args: &Args) {
381    if !args.declared_background {
382        return;
383    }
384    // SAFETY: setsid() is a well-defined POSIX syscall; this process is
385    // not a process group leader (an ordinary freshly-exec'd child never
386    // is), so EPERM (the only failure mode) cannot occur in practice.
387    let rc = unsafe { libc::setsid() };
388    if rc == -1 {
389        tracing::warn!(
390            target: "bashwrap",
391            tool_id = %args.tool_id,
392            error = %std::io::Error::last_os_error(),
393            "setsid() failed for a declared-background invocation — this task remains exposed to SIGHUP if the owning agent session is torn down",
394        );
395    }
396}
397
398#[cfg(not(unix))]
399fn detach_declared_background_session(_args: &Args) {}
400
401/// Kill `pid` AND every descendant it spawned, not just the one process.
402/// Supplements `ChildKiller::kill()` (portable-pty's Windows impl is a bare
403/// `TerminateProcess` on a single handle, no job object — see the caller's
404/// comment) — without this, a wrapped command whose direct child forks
405/// further (a pipeline, or `git` spawning `less` as a child) can leave an
406/// orphaned grandchild running and still attached to the PTY slave after
407/// the "kill" (reagent P1, PR #2156).
408///
409/// `agentmux_common::process::force_kill_tree` does the killing, by PID only:
410///
411/// Windows: `taskkill /F /T /PID <pid>` (console flash suppressed) walks the
412/// OS-level parent-PID tree, independent of shell job control, and
413/// force-kills every process in it.
414///
415/// Unix: SIGKILL to the child's process group. portable-pty makes the PTY
416/// child a session and group leader (`setsid`), so its pgid is its pid and
417/// the group holds the command, its pipeline and what it backgrounded with
418/// `&`. portable-pty's own `ChildKiller` only SIGHUPs the one pid, which a
419/// `nohup`'d child ignores. A child that started its own session is out of
420/// reach here; the agent's process tracker ends it with the agent.
421fn kill_process_tree(pid: u32) {
422    match agentmux_common::process::force_kill_tree(pid) {
423        Ok(()) => tracing::info!(target: "bashwrap", pid, "kill_process_tree: tree killed"),
424        // Non-fatal: the direct-handle kill above may have already won the
425        // race (the tree is then "not found"), which is fine — best-effort
426        // supplemental cleanup either way.
427        Err(e) => tracing::warn!(target: "bashwrap", pid, error = %e, "kill_process_tree: kill did not report success"),
428    }
429}
430
431/// Internal channel payload: a single line tagged with its source.
432/// Bytes are raw — UTF-8 conversion is deferred to the publish /
433/// aggregation site so non-UTF-8 output (binary `cat`, Windows
434/// legacy-encoded tools) doesn't abort the reader.
435#[derive(Debug)]
436struct LineEvent {
437    kind: ToolChunkKind, // Stdout or Stderr
438    /// Raw bytes including the trailing `\n` if present. May be the
439    /// final fragment before EOF without a newline, in which case it
440    /// has no trailing delimiter.
441    bytes: Vec<u8>,
442}
443
444/// Returns the inner command's exit code so main.rs can mirror it as
445/// the wrapper's own process exit. Without this, Claude's native Bash
446/// tool would see success for every wrapped command regardless of the
447/// actual outcome.
448pub async fn run(args: Args) -> Result<i32> {
449    let command = decode_command(&args.b64_cmd)?;
450    run_command(args, command).await
451}
452
453pub(crate) async fn run_command(mut args: Args, command: String) -> Result<i32> {
454    detach_declared_background_session(&args);
455    log_relevant_env();
456
457    // `block_id` controls the MPS publish scope. Prefer the explicit
458    // CLI arg, but fall back to AGENTMUX_BLOCKID env (set by
459    // agentmux-srv when spawning Claude) so the hook doesn't have to
460    // pass it explicitly. Without a scope, the frontend's per-block
461    // subscription won't receive the events.
462    if args.block_id.as_deref().filter(|s| !s.is_empty()).is_none() {
463        if let Ok(v) = std::env::var("AGENTMUX_BLOCKID") {
464            if !v.is_empty() {
465                args.block_id = Some(v);
466            }
467        }
468    }
469
470    let mps = WpsClient::from_env();
471    let degraded = mps.is_none();
472
473    let buffered = Arc::new(Mutex::new(Vec::<u8>::with_capacity(64 * 1024)));
474
475    tracing::info!(
476        target: "bashwrap",
477        tool_id = %args.tool_id,
478        block_id = %args.block_id.as_deref().unwrap_or(""),
479        degraded,
480        command_len = command.len(),
481        "exec start"
482    );
483
484    // Join handle for the fire-and-forget pid publish below, if spawned.
485    // `main.rs` calls `std::process::exit()` immediately once `run()`
486    // returns — which tears down every still-in-flight spawned task with
487    // no Drop glue, silently. Every OTHER spawned task in this file
488    // (`idle_watcher`, `publisher_handle`, `wait_task`, the
489    // `kill_process_tree` spawn_blocking) is explicitly joined with a
490    // bounded timeout before `run()` returns for exactly this reason; this
491    // one must be too (reagentx P1, PR #2681) — joined below, after
492    // `run_proc` completes, so it still doesn't block a genuinely
493    // long-running declared-background command's own startup (by the time
494    // a long-running command's `run_proc` finally returns, this task has
495    // long since finished on its own — the join below is only ever a real
496    // wait in the narrow case `run_proc` returns quickly, e.g. the wrapped
497    // command fails fast).
498    let mut pid_publish: Option<tokio::task::JoinHandle<()>> = None;
499
500    if degraded {
501        // Surface degradation as a real chunk on stdout (we'll prefix
502        // it on the model side too) so the user sees a clear "no
503        // streaming this turn" message in the overlay. MPS publish
504        // would no-op, so we skip it.
505        let warn = b"[bashwrap] warning: streaming disabled (auth/url env missing); command output will only appear on completion\n";
506        buffered.lock().await.extend_from_slice(warn);
507    } else if let Some(client) = mps.as_ref() {
508        match publish_system(
509            client,
510            &args.tool_id,
511            args.block_id.as_deref(),
512            &format!("[bashwrap] starting: {} chars", command.len()),
513        )
514        .await
515        {
516            Ok(()) => tracing::info!(target: "bashwrap", tool_id = %args.tool_id, "initial publish ok"),
517            Err(e) => tracing::warn!(target: "bashwrap", tool_id = %args.tool_id, error = %e, "initial publish failed"),
518        }
519        if args.declared_background {
520            // Fire-and-forget, NOT awaited inline: publish_pid's own doc
521            // comment promises best-effort semantics, and awaiting it
522            // right here (as an earlier version of this code did) put its
523            // up-to-~10s worst case (two publish_chunk attempts, each up
524            // to the 5s WpsClient::from_env default, plus the retry's own
525            // 250ms sleep) directly on this declared-background command's
526            // startup path — measurably delaying the actual work (e.g.
527            // `task dev`) behind a degraded MPS endpoint, exactly what
528            // "best-effort" is supposed to avoid (reagentx P1, PR #2681).
529            // Still joined (bounded) below before `run()` returns, so a
530            // fast-exiting wrapped command can't silently abandon it.
531            let client = client.clone();
532            let tool_id = args.tool_id.clone();
533            let block_id = args.block_id.clone();
534            pid_publish = Some(tokio::spawn(async move {
535                publish_pid(&client, &tool_id, block_id.as_deref(), std::process::id()).await;
536            }));
537        }
538    }
539
540    let start = std::time::Instant::now();
541    // NOT `?` here — captured instead of propagated immediately. `run_proc`
542    // can fail fast (e.g. `locate_bash()`/PTY-or-pipe spawn failure) and an
543    // early `?`-return would skip the pid_publish join below entirely,
544    // reproducing the exact abandonment bug the join exists to prevent, just
545    // via the Err path instead of a fast Ok exit (reagentx P1, round 3 on
546    // this PR — the round-2 fix only covered the Ok(status) fast-exit case).
547    let run_result = run_proc(&args, &command, mps.as_ref(), buffered.clone()).await;
548    let elapsed = start.elapsed();
549
550    if let Some(handle) = pid_publish {
551        // Bound slightly above publish_pid's own worst case (~10.25s: two
552        // publish attempts at up to 5s each + the 250ms retry sleep) so
553        // this only ever cuts it short in a genuinely wedged case, not a
554        // normal slow-but-still-within-its-own-bounds one.
555        match tokio::time::timeout(Duration::from_secs(11), handle).await {
556            Ok(Ok(())) => {}
557            Ok(Err(join_err)) => {
558                tracing::warn!(target: "bashwrap", tool_id = %args.tool_id, error = %join_err, "pid publish task panicked");
559            }
560            Err(_elapsed) => {
561                tracing::warn!(target: "bashwrap", tool_id = %args.tool_id, "pid publish task still running after its own bound — abandoning rather than delaying exit further");
562            }
563        }
564    }
565
566    let status = run_result?;
567
568    if let Some(client) = mps.as_ref() {
569        let _ = client
570            .publish_chunk(
571                args.block_id.as_deref(),
572                &ToolChunk::Terminal {
573                    tool_id: args.tool_id.clone(),
574                    exit_code: status,
575                    timestamp: now_ms(),
576                },
577            )
578            .await;
579    }
580
581    let buf = buffered.lock().await;
582    let mut model_blob = format_model_blob(&buf, status, elapsed);
583    if let Some(hint) = access_denied_hint(&buf, status) {
584        model_blob.push_str(hint);
585    }
586    print!("{}", model_blob);
587    Ok(status)
588}
589
590fn decode_command(b64: &str) -> Result<String> {
591    use base64::Engine as _;
592    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
593    let bytes = URL_SAFE_NO_PAD
594        .decode(b64.as_bytes())
595        .context("decoding --b64-cmd")?;
596    Ok(String::from_utf8(bytes).context("--b64-cmd is not valid UTF-8")?)
597}
598
599use agentmux_common::client_events::{ToolChunk, ToolChunkKind};
600use agentmux_common::time::now_ms_u64 as now_ms;
601
602/// Log which streaming-relevant env vars the wrapper actually received,
603/// so the sidecar log tells us at a glance whether the env-propagation
604/// chain from agentmux-srv → claude → bash → hook → here is intact.
605/// Values are not logged (auth key is sensitive); presence/absence is
606/// the only signal we need to triage the propagation gap.
607fn log_relevant_env() {
608    let has = |k: &str| std::env::var(k).is_ok();
609    tracing::info!(
610        target: "bashwrap",
611        auth_key = has("AGENTMUX_AUTH_KEY"),
612        local_url = has("AGENTMUX_LOCAL_URL"),
613        agent_id = has("AGENTMUX_AGENT_ID"),
614        block_id = has("AGENTMUX_BLOCKID"),
615        bash = std::env::var("BASH").ok(),
616        agentmux_bash = std::env::var("AGENTMUX_BASH").ok(),
617        "bashwrap exec env snapshot"
618    );
619}
620
621async fn publish_system(
622    client: &WpsClient,
623    tool_id: &str,
624    block_id: Option<&str>,
625    msg: &str,
626) -> Result<()> {
627    client
628        .publish_chunk(
629            block_id,
630            &ToolChunk::Chunk {
631                tool_id: tool_id.to_string(),
632                kind: ToolChunkKind::System,
633                content: msg.to_string(),
634                timestamp: now_ms(),
635            },
636        )
637        .await
638}
639
640/// Publish this process's own pid, once, for a declared-background
641/// invocation. Called via `tokio::spawn` (never awaited inline) — see the
642/// call site's comment for why blocking startup on this was a real
643/// latency bug (reagentx P1, PR #2681).
644///
645/// The single retry here is insurance against a transient MPS/HTTP
646/// publish failure ONLY — it does not, and cannot, wait for
647/// `db_background_tasks`'s row to exist first (this function has no
648/// visibility into srv's database state). This publish routinely arrives
649/// at srv before that row exists at all (this call fires at process
650/// start, before the frontend has even seen an accepted-background tool
651/// result to create the row from) — that ordering race is handled
652/// entirely server-side, by stashing an early pid and applying it once
653/// the row is created (`pending_background_pids.rs`,
654/// `websocket.rs`'s `COMMAND_BACKGROUND_TASK_PID`/`COMMAND_DOCK_NODE_STATUS`
655/// handlers). An earlier version of this comment claimed this retry
656/// covered that race — it never did (Codex P1, reagentx P2, PR #2681).
657async fn publish_pid(client: &WpsClient, tool_id: &str, block_id: Option<&str>, pid: u32) {
658    let msg = ToolChunk::Pid {
659        tool_id: tool_id.to_string(),
660        pid,
661        timestamp: now_ms(),
662    };
663    if client.publish_chunk(block_id, &msg).await.is_ok() {
664        return;
665    }
666    tokio::time::sleep(Duration::from_millis(250)).await;
667    if let Err(e) = client.publish_chunk(block_id, &msg).await {
668        tracing::warn!(target: "bashwrap", tool_id, pid, error = %e, "pid publish failed after retry");
669    }
670}
671
672async fn publish_line(
673    client: &WpsClient,
674    tool_id: &str,
675    block_id: Option<&str>,
676    kind: ToolChunkKind,
677    line: &str,
678) -> Result<()> {
679    client
680        .publish_chunk(
681            block_id,
682            &ToolChunk::Chunk {
683                tool_id: tool_id.to_string(),
684                kind,
685                content: line.to_string(),
686                timestamp: now_ms(),
687            },
688        )
689        .await
690}
691
692/// Locate bash. Order:
693/// 1. `$AGENTMUX_BASH` — explicit override from agentmux-srv config.
694/// 2. `$BASH` — set by bash itself when it spawns a child; reliable
695///    inside Claude Code's hook context because Claude shells out to
696///    bash to run the hook.
697/// 3. `which bash` on PATH.
698/// 4. Well-known Windows paths (Git Bash, msys2). Skipped on Unix.
699///
700/// Fail-loud if not found — pass-through fallback would hide the issue.
701pub(crate) fn locate_bash() -> Result<PathBuf> {
702    if let Ok(p) = std::env::var("AGENTMUX_BASH") {
703        if !p.is_empty() {
704            return Ok(PathBuf::from(p));
705        }
706    }
707    if let Ok(p) = std::env::var("BASH") {
708        if !p.is_empty() && PathBuf::from(&p).exists() {
709            return Ok(PathBuf::from(p));
710        }
711    }
712    if let Ok(p) = which::which("bash") {
713        return Ok(p);
714    }
715    #[cfg(windows)]
716    {
717        for candidate in [
718            r"C:\Program Files\Git\bin\bash.exe",
719            r"C:\Program Files\Git\usr\bin\bash.exe",
720            r"C:\Program Files (x86)\Git\bin\bash.exe",
721            r"C:\msys64\usr\bin\bash.exe",
722            r"C:\msys2\usr\bin\bash.exe",
723        ] {
724            let p = PathBuf::from(candidate);
725            if p.exists() {
726                return Ok(p);
727            }
728        }
729    }
730    Err(anyhow!(
731        "bash not found — set AGENTMUX_BASH, install Git Bash, or add bash to PATH"
732    ))
733}
734
735/// Read raw bytes from a child stdio handle and forward chunks to
736/// the publisher with three flush triggers:
737///
738/// 1. **Newline.** As soon as a `\n` is in the buffer, drain everything
739///    up to and including that newline as one chunk. Loop, since a
740///    single read can contain multiple complete lines.
741/// 2. **Size threshold** (`FLUSH_BYTES`). If the buffer accumulates
742///    without a newline (minified JSON output, long progress lines,
743///    `printf` without `\n`), flush as soon as it exceeds the
744///    threshold. Prevents unbounded memory growth and keeps streaming
745///    live for newline-free output.
746/// 3. **Quiet-window** (`FLUSH_QUIET_WINDOW`). After this much idle
747///    time with non-empty pending bytes, flush. Surfaces slow-trickle
748///    output (one byte at a time) that would otherwise wait for the
749///    next byte to trigger a natural flush.
750async fn stream_reader<R>(
751    mut reader: R,
752    kind: ToolChunkKind,
753    tx: mpsc::Sender<LineEvent>,
754    last_activity: Arc<std::sync::Mutex<std::time::Instant>>,
755)
756where
757    R: tokio::io::AsyncRead + Unpin,
758{
759    let mut pending: Vec<u8> = Vec::with_capacity(8192);
760    let mut buf = [0u8; 8192];
761    // P1b: CR override slot — when the quiet window expires and `pending`
762    // starts with `\r`, hold it here rather than flushing immediately.
763    // The next read prepends it so collapse_cr can overwrite it with the
764    // new frame, collapsing throttled spinner frames (>50 ms apart) before
765    // they become separate LineEvents.
766    let mut pending_cr_override: Option<Vec<u8>> = None;
767    // A1: speculative one-tick hold for a pending line with no leading OR
768    // trailing `\r` yet. Mirrors the same slot in `pty_reader_loop`. See
769    // docs/specs/SPEC_TOOL_LOG_UNIVERSAL_ANIMATION_COLLAPSE_2026_07_27.md §A1.
770    let mut deferred_pending: Option<Vec<u8>> = None;
771    loop {
772        match tokio::time::timeout(FLUSH_QUIET_WINDOW, reader.read(&mut buf)).await {
773            Ok(Ok(0)) => {
774                // EOF: flush any held CR override or A1 deferred line, then
775                // drain remainder, stripping a dangling lone \r (stream
776                // ended mid-spinner or mid-CRLF pair).
777                if let Some(mut held) = pending_cr_override.take() {
778                    if held.last() == Some(&b'\r') {
779                        held.pop();
780                    }
781                    if !held.is_empty() {
782                        let _ = tx.send(LineEvent { kind, bytes: held }).await;
783                    }
784                }
785                if let Some(deferred) = deferred_pending.take() {
786                    let _ = tx.send(LineEvent { kind, bytes: deferred }).await;
787                }
788                if !pending.is_empty() {
789                    if pending.last() == Some(&b'\r') {
790                        pending.pop();
791                    }
792                    if !pending.is_empty() {
793                        let _ = tx
794                            .send(LineEvent { kind, bytes: std::mem::take(&mut pending) })
795                            .await;
796                    }
797                }
798                return;
799            }
800            Ok(Ok(n)) => {
801                // Idle-kill tracking (mirrors the PTY path's pty_reader_loop):
802                // reset on ANY bytes read, regardless of whether they become
803                // a published LineEvent — see run_via_pipes's idle watcher.
804                *last_activity.lock().unwrap_or_else(|e| e.into_inner()) = std::time::Instant::now();
805                // P1b: prepend any held CR override so collapse_cr can
806                // overwrite it with the new frame content.
807                if let Some(held) = pending_cr_override.take() {
808                    let mut combined = held;
809                    combined.extend_from_slice(&buf[..n]);
810                    pending.splice(0..0, combined);
811                } else if let Some(deferred) = deferred_pending.take() {
812                    // A1: resolve last tick's speculative hold. A leading
813                    // `\r` means this is the overwrite we were hoping for;
814                    // otherwise the deferred line was genuinely final and
815                    // unrelated — flush it on its own first.
816                    if buf[..n].first() == Some(&b'\r') {
817                        let mut combined = deferred;
818                        combined.extend_from_slice(&buf[..n]);
819                        pending.splice(0..0, combined);
820                    } else {
821                        if tx.send(LineEvent { kind, bytes: deferred }).await.is_err() {
822                            return;
823                        }
824                        pending.extend_from_slice(&buf[..n]);
825                    }
826                } else {
827                    pending.extend_from_slice(&buf[..n]);
828                }
829                collapse_cr(&mut pending);
830                // Drain every complete line.
831                while let Some(nl_pos) = pending.iter().position(|&b| b == b'\n') {
832                    let line: Vec<u8> = pending.drain(..=nl_pos).collect();
833                    if tx.send(LineEvent { kind, bytes: line }).await.is_err() {
834                        return;
835                    }
836                }
837                // P2a: newline-free residue past the size threshold — flush
838                // unconditionally to keep memory bounded. Prevents unbounded
839                // accumulation from a trailing-\r spinner that never emits \n.
840                if pending.len() >= FLUSH_BYTES {
841                    if tx
842                        .send(LineEvent { kind, bytes: std::mem::take(&mut pending) })
843                        .await
844                        .is_err()
845                    {
846                        return;
847                    }
848                }
849            }
850            Ok(Err(e)) => {
851                tracing::warn!(target: "bashwrap", error = %e, ?kind, "read error");
852                return;
853            }
854            Err(_elapsed) => {
855                // P1b/A1: quiet-window expiry.
856                //
857                // If a line was already speculatively deferred last tick
858                // (A1) and nothing new arrived since — deferred_pending
859                // being Some implies pending is empty, since any new data
860                // would have resolved it in the Ok(Ok(n)) branch above —
861                // give up waiting for a `\r`-prefixed overwrite and flush
862                // it now.
863                //
864                // If `pending` starts with `\r`, it is a leading-\r spinner
865                // frame. Stash it in the CR override slot so the next read
866                // prepends it and collapse_cr can overwrite it with the new
867                // frame.
868                //
869                // If `pending` ends with `\r` (but not starts), hold it —
870                // a following `\n` will form a complete CRLF.
871                //
872                // Otherwise (non-`\r` partial output, e.g. `printf
873                // 'Building...'`): first quiet window with this content —
874                // defer it one tick (A1) instead of flushing immediately,
875                // in case the very next read starts with `\r`. See
876                // docs/specs/SPEC_TOOL_LOG_UNIVERSAL_ANIMATION_COLLAPSE_2026_07_27.md §A1.
877                //
878                // Note: pending_cr_override / deferred_pending are always
879                // None here at this point (after the take() above). Each is
880                // set only by take(&mut pending), which empties pending; the
881                // only way to reach this branch with non-empty pending is
882                // after an Ok(Ok(n)) that already consumed and cleared both.
883                if let Some(deferred) = deferred_pending.take() {
884                    if tx.send(LineEvent { kind, bytes: deferred }).await.is_err() {
885                        return;
886                    }
887                }
888                if !pending.is_empty() {
889                    if pending.first() == Some(&b'\r') {
890                        pending_cr_override = Some(std::mem::take(&mut pending));
891                    } else if pending.last() != Some(&b'\r') {
892                        deferred_pending = Some(std::mem::take(&mut pending));
893                    }
894                    // else: trailing-\r hold — do nothing, next read resolves CRLF.
895                }
896            }
897        }
898    }
899}
900
901/// Spawn the bash child and stream its lines. PTY by default with a
902/// pipe fallback if PTY allocation fails (G6 in
903/// docs/specs/SPEC_LIVE_LOG_PTY_REWORK_2026_05_16.md).
904///
905/// Why PTY: when the child sees its stdout as a pipe, glibc switches
906/// libc stdio from line-buffered to block-buffered (~4 KB). External
907/// programs (grep, npm, cargo, python, ...) accumulate output in the
908/// stdio buffer until flush, defeating the live-log overlay's premise.
909/// PTY makes `isatty(STDOUT_FILENO) == 1` so glibc stays line-buffered.
910///
911/// CRITICAL ConPTY lifetime contract (Windows): `pair.master` MUST
912/// stay alive across `child.wait()`. Dropping master during child
913/// startup tears down the pseudoconsole anchor and produces
914/// `STATUS_DLL_INIT_FAILED` (the β.A wedge). See retro §4.2.
915///
916/// HEADLESS PTY contract (Windows): ConPTY queries the terminal at
917/// startup with `\x1b[6n` (DSR — request cursor position) and blocks
918/// waiting for a `\x1b[r;cR` response. A headless PTY (no real terminal
919/// behind it) never answers — the child never proceeds — so
920/// `child.wait()` would never return. We write a synthetic `\x1b[1;1R`
921/// response via the master writer, and the reader strips the query from
922/// the output. This is what xterm.js does for VS Code's agent-mode
923/// terminal; here we do the minimum subset for non-interactive `bash -c`.
924/// Verified via agentmux-pty-repro V2 before this landed.
925///
926/// Unix PTYs never ask (checked with `bash -c` 3.2 and 5.x on macOS: no
927/// `\x1b[6n` in the output, and the command exits without a reply). There
928/// the reply would only be echoed back by the line discipline as the
929/// printable text `^[[1;1R` at the start of every result, which no escape
930/// stripping can recognise, and left in the child's stdin. So it is sent on
931/// Windows only.
932///
933/// The PTY is [`PTY_COLS`] wide, not the classic 80: width-aware programs
934/// (tables, help text, column listings) fold their output at the terminal
935/// width with real line breaks, which no preview can undo.
936async fn run_proc(
937    args: &Args,
938    command: &str,
939    mps: Option<&WpsClient>,
940    buffered: Arc<Mutex<Vec<u8>>>,
941) -> Result<i32> {
942    let bash = locate_bash()?;
943    let pty_system = native_pty_system();
944    match pty_system.openpty(PtySize {
945        rows: PTY_ROWS,
946        cols: PTY_COLS,
947        pixel_width: 0,
948        pixel_height: 0,
949    }) {
950        Ok(pair) => {
951            tracing::info!(
952                target: "bashwrap",
953                bash = %bash.display(),
954                "spawning bash -c via PTY (live-buffered, headless DSR responder active)",
955            );
956            run_via_pty(args, command, mps, buffered, &bash, pair).await
957        }
958        Err(e) => {
959            tracing::warn!(
960                target: "bashwrap",
961                bash = %bash.display(),
962                error = %e,
963                "PTY allocation failed — falling back to pipes (output may buffer at child level)",
964            );
965            run_via_pipes(args, command, mps, buffered, &bash).await
966        }
967    }
968}
969
970/// PTY-backed run path. Holds master alive across child.wait() and
971/// answers DSR queries from bash at startup.
972async fn run_via_pty(
973    args: &Args,
974    command: &str,
975    mps: Option<&WpsClient>,
976    buffered: Arc<Mutex<Vec<u8>>>,
977    bash: &std::path::Path,
978    pair: portable_pty::PtyPair,
979) -> Result<i32> {
980    let mut cmd = CommandBuilder::new(bash.as_os_str());
981    // -c <cmd> for non-interactive run, WITHOUT -l. Login shell
982    // startup costs ~1 second (sources /etc/profile, /etc/bashrc,
983    // ~/.bash_profile, etc.) which the user sees as a "Running..."
984    // delay before any chunk arrives. We pre-prepend the MSYS2 bin
985    // dirs to PATH manually (see below) so external commands like
986    // `date`, `grep`, `awk` resolve without needing rc-script setup.
987    cmd.arg("-c");
988    // Give stdin-reading children (`cat`, `read`, prompted scripts) EOF
989    // instead of leaving them blocked on the live PTY slave, but do NOT use
990    // `exec </dev/null` — running that inside bash closes the child's ConPTY
991    // console-input handle from the child side, which ConPTY reports as a
992    // CTRL_C_EVENT and kills the command with exit 130 before it runs (the
993    // bug this wrapper exists to fix). A brace-group redirect points the
994    // group's fd 0 at /dev/null for the command's duration while bash's own
995    // fd 0 (the console) stays open, so children see EOF and ConPTY never
996    // fires ctrl-c. The leading newline terminates the group list cleanly
997    // regardless of how `command` ends (trailing comment, no newline, etc.).
998    let cwd_state = CwdState::load();
999    // Wrapped a second time by `append_cwd_capture` so the ending `$PWD`
1000    // survives this disposable process — see the cwd-persistence block
1001    // above `Args` for why that's necessary.
1002    cmd.arg(append_cwd_capture(&format!("{{\n{}\n}} </dev/null", command)));
1003
1004    // Disable pagers. The PTY above deliberately makes the child see
1005    // `isatty(stdout) == true` so external tools stay line-buffered (see
1006    // the module doc comment) — but that's exactly what `git`
1007    // (diff/log/show/branch/...) uses to decide whether to auto-invoke
1008    // `core.pager` (`less` by default). We never write to the PTY again
1009    // after the startup DSR response below, so a pager waiting for a
1010    // keystroke blocks forever and leaks this whole process. `cat` passes
1011    // content straight through with no paging. See
1012    // docs/retro/RETRO_BASHWRAP_PAGER_HANG_LEAK_2026_07_14.md.
1013    cmd.env("GIT_PAGER", "cat");
1014    cmd.env("PAGER", "cat");
1015
1016    // PATH fix-up: bashwrap is a Windows exe, so when MSYS2/Git Bash
1017    // spawned us it converted PATH to Windows form, which has
1018    // /c/Windows/system32, /c/Program Files/nodejs, etc. but NOT
1019    // /usr/bin, /usr/local/bin, /mingw64/bin where coreutils lives.
1020    // Without -l (above), the child bash inherits the Windows PATH
1021    // and every external command becomes "command not found".
1022    //
1023    // Derive MSYS2 bin dirs from bash's own location — typically
1024    // bash sits at `C:\Program Files\Git\usr\bin\bash.exe`, so the
1025    // three dirs we need are reachable as siblings/cousins of that.
1026    // Pre-pend them to the inherited PATH; the existing Windows
1027    // entries stay so platform-specific tools (cmd, powershell, gh,
1028    // ...) still resolve.
1029    if let Some(usr_bin) = bash.parent() {
1030        let mut prefix_dirs: Vec<std::ffi::OsString> = vec![usr_bin.to_path_buf().into()];
1031        if let Some(usr) = usr_bin.parent() {
1032            // /usr/local/bin sibling of /usr/bin.
1033            let local_bin = usr.join("local").join("bin");
1034            if local_bin.exists() {
1035                prefix_dirs.push(local_bin.into());
1036            }
1037            // /mingw64/bin sibling of /usr (Git for Windows layout).
1038            if let Some(git_root) = usr.parent() {
1039                let mingw64_bin = git_root.join("mingw64").join("bin");
1040                if mingw64_bin.exists() {
1041                    prefix_dirs.push(mingw64_bin.into());
1042                }
1043            }
1044        }
1045        let existing_path = std::env::var_os("PATH").unwrap_or_default();
1046        let mut all_dirs: Vec<std::path::PathBuf> =
1047            prefix_dirs.iter().map(std::path::PathBuf::from).collect();
1048        if !existing_path.is_empty() {
1049            for p in std::env::split_paths(&existing_path) {
1050                all_dirs.push(p);
1051            }
1052        }
1053        let new_path = std::env::join_paths(&all_dirs)
1054            .context("joining PATH entries for child bash")?;
1055        cmd.env("PATH", &new_path);
1056        tracing::info!(
1057            target: "bashwrap",
1058            prepended_dirs = ?prefix_dirs,
1059            "PATH fix-up for child bash (no login-shell startup needed)",
1060        );
1061    }
1062    if let Some(dir) = &cwd_state.start_dir {
1063        cmd.cwd(dir);
1064    }
1065    if let Some(state_path) = &cwd_state.path {
1066        cmd.env(CWD_STATE_ENV, state_path);
1067    }
1068
1069    let child = pair
1070        .slave
1071        .spawn_command(cmd)
1072        .with_context(|| format!("PTY spawn of bash at {}", bash.display()))?;
1073
1074    // Split out a killer before `child` moves into the wait task below, so
1075    // an idle-timeout detected on the reader side (a different task) can
1076    // still terminate it. See `ChildKiller::clone_killer`'s doc comment —
1077    // this is exactly the "send it signals independently from a thread
1078    // that may be blocked in `.wait`" case it exists for.
1079    //
1080    // `killer.kill()` alone only terminates THIS ONE process (portable-pty's
1081    // Windows impl is a bare `TerminateProcess` on the direct handle, no
1082    // job object) — it does not reach descendants bash forked (a pipeline,
1083    // or `git` spawning `less` as a child). An orphaned grandchild can
1084    // survive the kill, stay attached to the PTY slave, and keep blocking
1085    // forever — reproducing this exact leak one process removed instead of
1086    // eliminating it (reagent P1, PR #2156). `child_pid` + `kill_process_tree`
1087    // below is the supplemental fix: a full tree-kill by PID.
1088    let mut killer = child.clone_killer();
1089    let child_pid = child.process_id();
1090
1091    let reader = pair.master.try_clone_reader().context("PTY try_clone_reader")?;
1092
1093    // Write the DSR response into the master writer (Windows), then hold
1094    // the writer alive until after child.wait(). On Windows, closing the
1095    // CONIN pipe write-end while the pseudoconsole is still attached
1096    // sends CTRL_C_EVENT to the child (exit 130 / SIGINT). This is
1097    // the same ConPTY-lifetime invariant as pair.master itself — both
1098    // must outlive child.wait(). After the DSR response we never write
1099    // again; the handle is kept open only to hold CONIN alive.
1100    #[allow(unused_mut)]
1101    let mut writer = pair.master.take_writer().context("PTY take_writer")?;
1102    // ConPTY only: a Unix PTY never asks, and would echo this back into the
1103    // output as `^[[1;1R` (see run_proc's doc comment).
1104    #[cfg(windows)]
1105    {
1106        use std::io::Write as _;
1107        let _ = writer.write_all(b"\x1b[1;1R");
1108        let _ = writer.flush();
1109    }
1110
1111    let (tx, rx) = mpsc::channel::<LineEvent>(1024);
1112    let tx_reader = tx.clone();
1113    let (idle_tx, idle_rx) = oneshot::channel::<()>();
1114    let pty_idle_timeout = effective_idle_timeout(args);
1115    tokio::task::spawn_blocking(move || {
1116        pty_reader_loop(reader, tx_reader, idle_tx, pty_idle_timeout);
1117    });
1118    drop(tx);
1119
1120    let publisher_handle = spawn_publisher_loop(args, mps.cloned(), buffered.clone(), rx);
1121
1122    // Move pair AND writer into the wait task — both must outlive
1123    // child.wait() to satisfy the ConPTY lifetime contract on Windows.
1124    let mut wait_task = tokio::task::spawn_blocking(move || -> Result<i32> {
1125        let mut child = child;
1126        let status = child.wait().context("PTY child wait")?;
1127        let code = status.exit_code() as i32;
1128        tracing::info!(target: "bashwrap", exit_code = code, "PTY child exited");
1129        drop(writer);
1130        drop(pair);
1131        Ok(code)
1132    });
1133
1134    // `idle_rx` resolves for TWO distinct reasons, and they must not be
1135    // conflated (reagent P1, PR #2156): either `pty_reader_loop` really
1136    // did send the idle-timeout signal (`Ok(())`), OR its `idle_tx` was
1137    // simply DROPPED WITHOUT SENDING because the reader hit a normal EOF
1138    // and returned — which is exactly what happens on ordinary, fast,
1139    // successful command completion. A oneshot receiver's `.await`
1140    // resolves (with `Err`) in that drop case too, and `wait_task`
1141    // finishing is an independent race on a separate spawn_blocking
1142    // thread — under scheduling variance (or blocking-pool contention from
1143    // concurrent bashwrap invocations, common per this PR's own retro doc)
1144    // the reader's drop can resolve first even for a command that ran to
1145    // completion normally. Treating ANY `idle_rx` resolution as "idle
1146    // timeout fired" would misclassify and kill perfectly successful fast
1147    // commands. Only `Ok(())` is a real signal; `Err` just means "not an
1148    // idle timeout, keep waiting on wait_task directly."
1149    let mut idle_killed = false;
1150    let exit_code = tokio::select! {
1151        res = &mut wait_task => {
1152            res.context("PTY wait task join")??
1153        }
1154        idle_signal = idle_rx => {
1155            if idle_signal.is_err() {
1156                // idle_tx dropped without sending — not a real timeout.
1157                // idle_rx is now consumed either way, so just await the
1158                // real completion directly instead of re-entering select.
1159                wait_task.await.context("PTY wait task join")??
1160            } else {
1161                idle_killed = true;
1162                tracing::warn!(
1163                    target: "bashwrap",
1164                    tool_id = %args.tool_id,
1165                    idle_secs = idle_kill_timeout().as_secs(),
1166                    "child produced no PTY output for the idle timeout — likely \
1167                     blocked waiting for interactive input (e.g. a pager); killing",
1168                );
1169                // Tree-kill FIRST, `killer.kill()` second — order matters.
1170                // `taskkill /T` walks descendants by their recorded parent PID,
1171                // which only works while the root (bash) is still enumerable;
1172                // killing bash first (via killer.kill()) and THEN tree-killing
1173                // races taskkill against an already-dead root and can leave
1174                // grandchildren behind (verified empirically: an earlier version
1175                // of this fix that called killer.kill() first left 1 of 2
1176                // backgrounded test children alive — the test below is what
1177                // caught it). killer.kill() still runs afterward as a cheap,
1178                // harmless belt-and-suspenders in case kill_process_tree itself
1179                // didn't fully land (e.g. taskkill unavailable).
1180                if let Some(pid) = child_pid {
1181                    // spawn_blocking: kill_process_tree shells out synchronously
1182                    // (std::process::Command::output()) — running it inline here
1183                    // would block this tokio worker thread for the taskkill
1184                    // round-trip. Bounded (reagent P1, PR #2156): `taskkill /T`
1185                    // itself can hang (a documented Windows failure mode —
1186                    // unresponsive process enumeration) — without this timeout,
1187                    // an unresponsive taskkill reintroduces the exact unbounded
1188                    // hang this whole fix exists to eliminate.
1189                    let _ = tokio::time::timeout(
1190                        Duration::from_secs(5),
1191                        tokio::task::spawn_blocking(move || kill_process_tree(pid)),
1192                    )
1193                    .await;
1194                }
1195                let _ = killer.kill();
1196                // Bounded grace period for the kill to actually land and for
1197                // the wait task to finish (which drops writer/pair, releasing
1198                // the PTY). If it still doesn't, abandon the wait rather than
1199                // block forever — main()'s process::exit() tears down every
1200                // thread in this process regardless, once run() returns.
1201                match tokio::time::timeout(Duration::from_secs(5), &mut wait_task).await {
1202                    Ok(Ok(Ok(code))) => code,
1203                    _ => 124, // matches the conventional `timeout` command's exit code
1204                }
1205            }
1206        }
1207    };
1208
1209    // `buffered` (read by the caller for the model blob) is populated
1210    // only by the publisher loop, so drain it BEFORE appending the
1211    // idle-kill diagnostic (reagent P2, PR #2156) — otherwise genuine
1212    // pre-kill output still queued in the channel could flush into
1213    // `buffered` after the diagnostic note, interleaving it ahead of
1214    // trailing real output instead of strictly after it. Bounded for the
1215    // same reason as the wait task above: if a surviving grandchild still
1216    // holds the PTY slave open after the kill, the reader thread's
1217    // blocking read may never see EOF.
1218    if tokio::time::timeout(Duration::from_secs(5), publisher_handle)
1219        .await
1220        .is_err()
1221    {
1222        tracing::warn!(target: "bashwrap", tool_id = %args.tool_id, "publisher drain timed out — proceeding without it");
1223    }
1224
1225    if idle_killed {
1226        buffered.lock().await.extend_from_slice(
1227            b"\n[bashwrap] command produced no output for the idle timeout and was \
1228terminated automatically (likely blocked on a pager or other interactive \
1229prompt this wrapper can never answer, e.g. `git diff`/`log`/`show` auto- \
1230paging output that doesn't fit one screen). Try `git --no-pager <cmd>` or \
1231`| cat` on future invocations.\n",
1232        );
1233    }
1234
1235    Ok(exit_code)
1236}
1237
1238/// Pipe-backed run path. Safety net for environments where PTY
1239/// allocation fails (CI, sandboxes). Same byte semantics as the old
1240/// pipe-only wrapper.
1241async fn run_via_pipes(
1242    args: &Args,
1243    command: &str,
1244    mps: Option<&WpsClient>,
1245    buffered: Arc<Mutex<Vec<u8>>>,
1246    bash: &std::path::Path,
1247) -> Result<i32> {
1248    // Same cwd-persistence rationale as run_via_pty — see the block above
1249    // `Args`.
1250    let cwd_state = CwdState::load();
1251    let mut cmd = Command::new(bash);
1252    cmd.arg("-c")
1253        .arg(append_cwd_capture(command))
1254        .stdin(Stdio::null())
1255        .stdout(Stdio::piped())
1256        .stderr(Stdio::piped())
1257        .kill_on_drop(true)
1258        // Defense-in-depth, matching run_via_pty: stdout is a plain pipe
1259        // here (isatty == false), so git shouldn't auto-page in this path
1260        // at all — but set it anyway in case some tool pages on a
1261        // different heuristic. See
1262        // docs/retro/RETRO_BASHWRAP_PAGER_HANG_LEAK_2026_07_14.md.
1263        .env("GIT_PAGER", "cat")
1264        .env("PAGER", "cat");
1265    if let Some(dir) = &cwd_state.start_dir {
1266        cmd.current_dir(dir);
1267    }
1268    if let Some(state_path) = &cwd_state.path {
1269        cmd.env(CWD_STATE_ENV, state_path);
1270    }
1271    // Windows only: bash.exe is a console-subsystem binary, so spawning it
1272    // without CREATE_NO_WINDOW pops a new visible (and orphaned-looking)
1273    // console window for every fallback exec — this path only runs when
1274    // PTY allocation fails, which is why it slipped past
1275    // SPEC_ELIMINATE_BASHWRAP_CONSOLE_WINDOWS_2026_06_20's audit (that pass
1276    // covered bashwrap's own process subsystem and shell_node.rs, not this
1277    // rarely-exercised inner spawn). Same fix as shell_node.rs.
1278    #[cfg(windows)]
1279    {
1280        // `cmd` is `tokio::process::Command`; `NoWindow` covers both it and
1281        // `std::process::Command`, so no `CommandExt` import is needed.
1282        cmd.no_window();
1283    }
1284    let mut child = cmd
1285        .spawn()
1286        .with_context(|| format!("pipe spawn of bash at {}", bash.display()))?;
1287    // Captured before anything else can consume/reap the child — see the
1288    // idle-kill branch below for why this matters (reagent P1, PR #2156).
1289    let child_pid = child.id();
1290
1291    let stdout = child.stdout.take().ok_or_else(|| anyhow!("no stdout"))?;
1292    let stderr = child.stderr.take().ok_or_else(|| anyhow!("no stderr"))?;
1293
1294    // Idle-kill safety net (reagent P1, PR #2156): this path has no PTY, so
1295    // it can't hit the pager-hang mechanism specifically (isatty(stdout) ==
1296    // false here, the whole reason git wouldn't auto-page) — but nothing
1297    // else bounded child.wait() either, so ANY other hang cause still
1298    // leaked the wrapper forever, reproducing the root bug this PR exists
1299    // to fix. Mirrors run_via_pty's idle (not total-runtime) timeout: a
1300    // shared last-activity clock updated by both stdout/stderr readers on
1301    // every byte read, watched by a lightweight polling task.
1302    let last_activity = Arc::new(std::sync::Mutex::new(std::time::Instant::now()));
1303
1304    let (tx, rx) = mpsc::channel::<LineEvent>(1024);
1305    let stdout_reader = tokio::spawn(stream_reader(stdout, ToolChunkKind::Stdout, tx.clone(), last_activity.clone()));
1306    let stderr_reader = tokio::spawn(stream_reader(stderr, ToolChunkKind::Stderr, tx.clone(), last_activity.clone()));
1307    drop(tx);
1308
1309    let publisher_handle = spawn_publisher_loop(args, mps.cloned(), buffered.clone(), rx);
1310
1311    let (idle_tx, idle_rx) = oneshot::channel::<()>();
1312    let idle_timeout = effective_idle_timeout(args);
1313    let watcher_activity = last_activity.clone();
1314    let idle_watcher = tokio::spawn(async move {
1315        let mut idle_tx = Some(idle_tx);
1316        loop {
1317            tokio::time::sleep(Duration::from_millis(200)).await;
1318            let elapsed = watcher_activity
1319                .lock()
1320                .unwrap_or_else(|e| e.into_inner())
1321                .elapsed();
1322            if elapsed >= idle_timeout {
1323                if let Some(tx) = idle_tx.take() {
1324                    let _ = tx.send(());
1325                }
1326                return;
1327            }
1328        }
1329    });
1330
1331    // Same Ok/Err distinction as run_via_pty's select (reagent P1 follow-up):
1332    // idle_rx resolving with Err just means pty_reader_loop-equivalent
1333    // cleanup dropped it without signaling (not applicable on this path
1334    // directly, but kept for structural symmetry and defensiveness) — not
1335    // a real signal either way.
1336    let mut idle_killed = false;
1337    let exit_code: i32 = tokio::select! {
1338        res = child.wait() => {
1339            idle_watcher.abort();
1340            res.context("waiting for bash child")?.code().unwrap_or(-1)
1341        }
1342        idle_signal = idle_rx => {
1343            if idle_signal.is_err() {
1344                child.wait().await.context("waiting for bash child")?.code().unwrap_or(-1)
1345            } else {
1346                idle_killed = true;
1347                tracing::warn!(
1348                    target: "bashwrap",
1349                    tool_id = %args.tool_id,
1350                    idle_secs = idle_kill_timeout().as_secs(),
1351                    "child produced no output for the idle timeout (pipe path) — killing",
1352                );
1353                // Same tree-kill-first ordering and reasoning as run_via_pty
1354                // (reagent P1, PR #2156): child.start_kill() alone only
1355                // terminates the direct bash process, not descendants it
1356                // forked (a backgrounded `&` child, or a pipeline segment) —
1357                // those would otherwise survive, still attached to the
1358                // stdout/stderr pipes, leaving the reader tasks waiting for
1359                // an EOF that never comes. Bounded: taskkill /T can itself
1360                // hang (a documented Windows failure mode).
1361                if let Some(pid) = child_pid {
1362                    let _ = tokio::time::timeout(
1363                        Duration::from_secs(5),
1364                        tokio::task::spawn_blocking(move || kill_process_tree(pid)),
1365                    )
1366                    .await;
1367                }
1368                let _ = child.start_kill();
1369                match tokio::time::timeout(Duration::from_secs(5), child.wait()).await {
1370                    Ok(Ok(status)) => status.code().unwrap_or(-1),
1371                    _ => 124, // matches the conventional `timeout` command's exit code
1372                }
1373            }
1374        }
1375    };
1376
1377    // Reader tasks: bound-JOIN, never `.abort()`. Empirically, aborting a
1378    // task that's inside `tokio::time::timeout(_, AsyncRead::read(..))` on
1379    // a just-killed child's Windows pipe leaves something in a state that
1380    // hangs this whole test/runtime's shutdown — reproduced in isolation
1381    // while building this fix (a bare `.read().await` with no per-call
1382    // timeout wrapper was fine to abort; adding the 50ms quiet-window
1383    // timeout wrapper, matching `stream_reader`'s real structure, is what
1384    // triggered it). A `JoinHandle` that's simply *dropped* without abort,
1385    // by contrast, does not cancel the task — it keeps running fully
1386    // detached and harmlessly finishes reading to EOF on its own once the
1387    // pipe closes, which is exactly what timing out on the join and
1388    // moving on (rather than aborting) achieves here.
1389    if tokio::time::timeout(
1390        Duration::from_secs(5),
1391        async { let _ = tokio::join!(stdout_reader, stderr_reader); },
1392    )
1393    .await
1394    .is_err()
1395    {
1396        tracing::warn!(target: "bashwrap", tool_id = %args.tool_id, "reader tasks did not finish within the grace period — leaving them to finish in the background rather than aborting (see run_via_pipes's comment)");
1397    }
1398
1399    // Bounded for the same reason as run_via_pty: proceed rather than block
1400    // forever if a hung descendant somehow keeps a pipe end open.
1401    if tokio::time::timeout(Duration::from_secs(5), publisher_handle)
1402        .await
1403        .is_err()
1404    {
1405        tracing::warn!(target: "bashwrap", tool_id = %args.tool_id, "publisher drain timed out — proceeding without it");
1406    }
1407
1408    if idle_killed {
1409        buffered.lock().await.extend_from_slice(
1410            b"\n[bashwrap] command produced no output for the idle timeout and was \
1411terminated automatically.\n",
1412        );
1413    }
1414
1415    Ok(exit_code)
1416}
1417
1418/// PTY reader loop: drains bytes from the master, strips DSR / ANSI
1419/// control sequences, and forwards line-split chunks to the publisher.
1420///
1421/// Uses a thread-bridge + `recv_timeout` to add a quiet-window flush
1422/// identical to `stream_reader`'s `tokio::time::timeout` approach, but
1423/// for the blocking sync I/O that the PTY master reader requires.
1424///
1425/// **P1a — `collapse_cr`:** called on the `pending` accumulator after
1426/// each PTY read (after `strip_ansi`), collapsing mid-buffer lone-`\r`
1427/// overwrites (trailing-`\r` convention, e.g. `"frame\r"`) and embedded
1428/// multi-frame chunks (e.g. `printf 'f1\rf2\rfinal\n'`). Previously the
1429/// PTY path passed `\r` bytes intact and relied on `spawn_publisher_loop`'s
1430/// `pending_cr_line` slot; that slot only fires per-LineEvent so embedded
1431/// `\r`s in a single write survived uncollapsed.
1432///
1433/// **P1b — leading-`\r` spinner collapse:** throttled spinners (npm,
1434/// cargo, ora, tqdm at ~80-100 ms/frame, above the 50 ms quiet window)
1435/// arrive as separate reads. When the quiet window expires and `pending`
1436/// starts with `\r`, the frame is stashed in a `pending_cr_override` slot
1437/// rather than flushed. The next read prepends it so `collapse_cr`
1438/// overwrites it with the new frame. The slot is flushed when a
1439/// `\n`-terminated line arrives or at EOF.
1440///
1441/// **P2a — `FLUSH_BYTES` guard:** flushed unconditionally when accumulated
1442/// size exceeds `FLUSH_BYTES`, preventing unbounded accumulation from a
1443/// trailing-`\r` spinner that never emits `\n`.
1444fn pty_reader_loop(
1445    reader: Box<dyn std::io::Read + Send>,
1446    tx: mpsc::Sender<LineEvent>,
1447    idle_tx: oneshot::Sender<()>,
1448    idle_timeout: Duration,
1449) {
1450    use std::sync::mpsc as std_mpsc;
1451    // PTY collapses stdout + stderr onto one stream — the slave's
1452    // terminal device is a single FD shared by both. Chunks are
1453    // labelled "stdout" because there is no way to recover the
1454    // original FD distinction from the master read. The pipe path
1455    // (`run_via_pipes`) preserves the split.
1456    let kind = ToolChunkKind::Stdout;
1457
1458    // Offload the blocking read to a dedicated thread so the main
1459    // loop can use recv_timeout for the quiet-window flush. Bound the
1460    // channel (64 slots ≈ 512 KiB headroom) so a flooding child can't
1461    // grow it unbounded — the SyncSender blocks when full, propagating
1462    // backpressure to the PTY buffer and ultimately to the child process.
1463    let (data_tx, data_rx) = std_mpsc::sync_channel::<Option<Vec<u8>>>(64);
1464    std::thread::spawn(move || {
1465        use std::io::Read;
1466        let mut reader = reader;
1467        let mut buf = [0u8; 8192];
1468        loop {
1469            match reader.read(&mut buf) {
1470                Ok(0) => {
1471                    let _ = data_tx.send(None); // EOF sentinel
1472                    return;
1473                }
1474                Ok(n) => {
1475                    if data_tx.send(Some(buf[..n].to_vec())).is_err() {
1476                        return; // consumer gone
1477                    }
1478                }
1479                Err(e) => {
1480                    tracing::warn!(target: "bashwrap", error = %e, "PTY read error");
1481                    return;
1482                }
1483            }
1484        }
1485    });
1486
1487    let mut pending: Vec<u8> = Vec::with_capacity(8192);
1488    // P1b: CR override slot — mirrors the same slot in stream_reader.
1489    // When the quiet window expires and `pending` starts with `\r`, hold
1490    // it here so throttled spinner frames collapse before becoming separate
1491    // LineEvents.
1492    let mut pending_cr_override: Option<Vec<u8>> = None;
1493    // A1: speculative one-tick hold for a pending line that has NO leading
1494    // OR trailing `\r` yet (the overwhelmingly common real pattern: print a
1495    // static label once, then every subsequent update is `\r`-prefixed).
1496    // Distinct from `pending_cr_override` above — that slot only ever holds
1497    // content that already starts with `\r`. See
1498    // docs/specs/SPEC_TOOL_LOG_UNIVERSAL_ANIMATION_COLLAPSE_2026_07_27.md §A1.
1499    let mut deferred_pending: Option<Vec<u8>> = None;
1500    // A2: tracks whether this tool call's output has scrolled past its
1501    // first visual line yet — see `normalize_csi_overwrites`'s doc comment
1502    // for why CUP-to-home (`\x1b[H`) is only a `\r`-equivalent while this
1503    // is still true.
1504    let mut still_on_first_line = true;
1505    // A2: tracks whether the cursor is currently at column 0, across PTY
1506    // reads — see `normalize_csi_overwrites`'s doc comment for why a
1507    // standalone EL is only safe to treat as `\r` while this is true, and
1508    // why it must persist across calls rather than reset per-chunk.
1509    let mut at_col0 = true;
1510    // Idle-kill tracking: `last_activity` resets on every byte read from
1511    // the PTY (regardless of whether it becomes a published LineEvent —
1512    // even control-sequence-only output means the child is still doing
1513    // something). `idle_tx` fires exactly once, on the first quiet-window
1514    // timeout after `idle_kill_timeout()` has elapsed with zero activity.
1515    // See the constant's doc comment for why this is idle-based rather
1516    // than a total-runtime cap. `idle_timeout` is passed in by the caller
1517    // (`effective_idle_timeout`) rather than read here, so a declared-
1518    // background call's exemption applies without this loop needing its
1519    // own copy of `Args`.
1520    let mut last_activity = std::time::Instant::now();
1521    let mut idle_tx = Some(idle_tx);
1522    loop {
1523        match data_rx.recv_timeout(FLUSH_QUIET_WINDOW) {
1524            Ok(Some(mut chunk)) => {
1525                last_activity = std::time::Instant::now();
1526                let raw_n = chunk.len();
1527                strip_dsr(&mut chunk);
1528                // Phase γ (partial): convert the CHA(col-1)/EL cursor-
1529                // repositioning idioms into `\r` bytes BEFORE strip_ansi
1530                // deletes them outright, so collapse_cr (below) can treat
1531                // CSI-based progress-bar redraws as overwrites the same way
1532                // it already does for bare `\r`. See
1533                // docs/specs/SPEC_TOOL_LOG_UNIVERSAL_ANIMATION_COLLAPSE_2026_07_27.md §A2.
1534                normalize_csi_overwrites(&mut chunk, &mut still_on_first_line, &mut at_col0);
1535                // Phase β: strip remaining ANSI control sequences so
1536                // the plain-text chunk list doesn't render them as
1537                // garbled literal characters. Bash emits terminal-
1538                // init bytes (`\x1b[m`, `\x1b]0;<title>\x07`,
1539                // `\x1b[?25h`) on every `-c` invocation through a
1540                // PTY; without this strip every chunk list starts
1541                // with garbage.
1542                strip_ansi(&mut chunk);
1543                tracing::debug!(target: "bashwrap", raw_bytes = raw_n, post_strip = chunk.len(), "PTY read");
1544                if chunk.is_empty() {
1545                    continue;
1546                }
1547                // P1b: prepend any held CR override so collapse_cr can
1548                // overwrite it with the new frame content.
1549                if let Some(held) = pending_cr_override.take() {
1550                    let mut combined = held;
1551                    combined.extend_from_slice(&chunk);
1552                    pending.splice(0..0, combined);
1553                } else if let Some(deferred) = deferred_pending.take() {
1554                    // A1: the new chunk resolves last tick's speculative
1555                    // hold. A leading `\r` means it's the overwrite we were
1556                    // hoping for — merge and let collapse_cr do the rest,
1557                    // same as the pending_cr_override case above. Anything
1558                    // else means the deferred line was genuinely final and
1559                    // unrelated to this new content — flush it as its own
1560                    // line first so the two are never wrongly concatenated.
1561                    if chunk.first() == Some(&b'\r') {
1562                        let mut combined = deferred;
1563                        combined.extend_from_slice(&chunk);
1564                        pending.splice(0..0, combined);
1565                    } else {
1566                        if tx.blocking_send(LineEvent { kind, bytes: deferred }).is_err() {
1567                            return;
1568                        }
1569                        pending.extend_from_slice(&chunk);
1570                    }
1571                } else {
1572                    pending.extend_from_slice(&chunk);
1573                }
1574                // P1a: collapse lone \r in the accumulated pending buffer,
1575                // normalising mid-buffer overwrites and embedded multi-frame
1576                // chunks identically to the pipe path.
1577                collapse_cr(&mut pending);
1578                // Any held CR override was already consumed and prepended into
1579                // `pending` above, so it never survives to this drain loop —
1580                // mirrors the pipe path in `stream_reader` which omits a
1581                // take() here for the same reason.
1582                while let Some(nl_pos) = pending.iter().position(|&b| b == b'\n') {
1583                    let line: Vec<u8> = pending.drain(..=nl_pos).collect();
1584                    if tx.blocking_send(LineEvent { kind, bytes: line }).is_err() {
1585                        return;
1586                    }
1587                }
1588                // P2a: size threshold — flush unconditionally to keep memory
1589                // bounded. Prevents unbounded accumulation from a trailing-\r
1590                // spinner that never emits \n.
1591                if pending.len() >= FLUSH_BYTES {
1592                    if tx.blocking_send(LineEvent {
1593                        kind,
1594                        bytes: std::mem::take(&mut pending),
1595                    }).is_err() {
1596                        return;
1597                    }
1598                }
1599            }
1600            Ok(None) => {
1601                // EOF: flush any held CR override or A1 deferred line first,
1602                // then drain remainder, stripping a dangling lone \r.
1603                if let Some(mut held) = pending_cr_override.take() {
1604                    if held.last() == Some(&b'\r') {
1605                        held.pop();
1606                    }
1607                    if !held.is_empty() {
1608                        let _ = tx.blocking_send(LineEvent { kind, bytes: held });
1609                    }
1610                }
1611                if let Some(deferred) = deferred_pending.take() {
1612                    let _ = tx.blocking_send(LineEvent { kind, bytes: deferred });
1613                }
1614                if !pending.is_empty() {
1615                    if pending.last() == Some(&b'\r') {
1616                        pending.pop();
1617                    }
1618                    if !pending.is_empty() {
1619                        let _ = tx.blocking_send(LineEvent {
1620                            kind,
1621                            bytes: std::mem::take(&mut pending),
1622                        });
1623                    }
1624                }
1625                return;
1626            }
1627            Err(std_mpsc::RecvTimeoutError::Timeout) => {
1628                if idle_tx.is_some() && last_activity.elapsed() >= idle_timeout {
1629                    // Fires once (idle_tx.take() leaves None behind), even
1630                    // though this branch keeps running every quiet-window
1631                    // tick after that — the async side kills the child on
1632                    // receipt, which will eventually produce real EOF/error
1633                    // here and end the loop normally.
1634                    if let Some(sender) = idle_tx.take() {
1635                        let _ = sender.send(());
1636                    }
1637                }
1638                // P1b/A1: quiet-window expiry.
1639                //
1640                // If a line was ALREADY speculatively deferred last tick
1641                // (A1) and nothing new has arrived since — deferred_pending
1642                // being Some implies pending is empty, since any new data
1643                // would have resolved it in the Ok(Some) branch above — give
1644                // up waiting for a `\r`-prefixed overwrite and flush it now.
1645                //
1646                // If `pending` starts with `\r`, it is a leading-\r spinner
1647                // frame. Stash it in the CR override slot so the next read
1648                // prepends it and collapse_cr overwrites it with the new frame.
1649                //
1650                // If `pending` ends with `\r` (but not starts), hold it —
1651                // a following `\n` can form a complete CRLF.
1652                //
1653                // Otherwise (non-`\r` partial output, e.g. `printf
1654                // 'Building...'`): this is the FIRST quiet window with this
1655                // content, so defer it one tick (A1) rather than flushing
1656                // immediately — in case the very next read starts with `\r`
1657                // and this was actually the first frame of an overwrite
1658                // sequence (the overwhelmingly common real pattern: print a
1659                // static label once, then every subsequent update is
1660                // `\r`-prefixed). See
1661                // docs/specs/SPEC_TOOL_LOG_UNIVERSAL_ANIMATION_COLLAPSE_2026_07_27.md §A1.
1662                if let Some(deferred) = deferred_pending.take() {
1663                    if tx.blocking_send(LineEvent { kind, bytes: deferred }).is_err() {
1664                        return;
1665                    }
1666                }
1667                if !pending.is_empty() {
1668                    if pending.first() == Some(&b'\r') {
1669                        // Leading-\r spinner frame: stash in the override slot so
1670                        // the next Ok(Some) prepends it and collapse_cr can overwrite
1671                        // it with the incoming frame.
1672                        //
1673                        // Note: pending_cr_override is always None here. The override
1674                        // is set only by take(&mut pending), which empties pending.
1675                        // The only way to reach this branch with non-empty pending is
1676                        // after an Ok(Some) that already consumed and cleared the
1677                        // override. No flush-prior is needed or reachable.
1678                        pending_cr_override = Some(std::mem::take(&mut pending));
1679                    } else if pending.last() != Some(&b'\r') {
1680                        // First quiet window with this non-\r content: defer
1681                        // instead of flushing (A1). deferred_pending is
1682                        // always None here for the same reason as above.
1683                        deferred_pending = Some(std::mem::take(&mut pending));
1684                    }
1685                    // else: trailing-\r hold — do nothing, next read resolves CRLF.
1686                }
1687            }
1688            Err(std_mpsc::RecvTimeoutError::Disconnected) => {
1689                // Reader thread died without EOF — flush CR override / A1
1690                // deferred line, then drain.
1691                if let Some(mut held) = pending_cr_override.take() {
1692                    if held.last() == Some(&b'\r') {
1693                        held.pop();
1694                    }
1695                    if !held.is_empty() {
1696                        let _ = tx.blocking_send(LineEvent { kind, bytes: held });
1697                    }
1698                }
1699                if let Some(deferred) = deferred_pending.take() {
1700                    let _ = tx.blocking_send(LineEvent { kind, bytes: deferred });
1701                }
1702                if !pending.is_empty() {
1703                    if pending.last() == Some(&b'\r') {
1704                        pending.pop();
1705                    }
1706                    if !pending.is_empty() {
1707                        let _ = tx.blocking_send(LineEvent {
1708                            kind,
1709                            bytes: std::mem::take(&mut pending),
1710                        });
1711                    }
1712                }
1713                return;
1714            }
1715        }
1716    }
1717}
1718
1719/// Normalize CSI cursor-repositioning idioms that map cleanly onto the
1720/// existing `\r`-based overwrite model, converting them to a literal `\r`
1721/// byte so `collapse_cr` (which already understands `\r`) collapses them
1722/// too, with no new cases needed there. Runs on the raw `chunk` BEFORE
1723/// `strip_ansi`, which otherwise deletes CSI sequences unconditionally with
1724/// no overwrite-awareness — the "Phase γ" gap `strip_ansi`'s own doc
1725/// comment flags ("Cursor positioning escapes within the same line").
1726/// See docs/specs/SPEC_TOOL_LOG_UNIVERSAL_ANIMATION_COLLAPSE_2026_07_27.md §A2.
1727///
1728/// `still_on_first_line` and `at_col0` are both caller-owned state that
1729/// persists across calls for the lifetime of one reader loop — this
1730/// function is invoked once per PTY read, and a chunk boundary is not a
1731/// terminal state boundary: a read ending mid-line (no trailing `\n`/`\r`)
1732/// must carry "cursor is NOT at column 0" into the next call, otherwise a
1733/// standalone EL split across two reads (`"prefix"` in one chunk,
1734/// `"\x1b[2Ksuffix"` in the next) would see a freshly-reset `at_col0 ==
1735/// true` and wrongly convert that EL to `\r`, reproducing the exact
1736/// mid-line truncation bug this function exists to prevent, just moved
1737/// from a mid-chunk split to a cross-chunk-read split (reagent P1, PR
1738/// #2330, caught after the first, chunk-internal-only version of this fix).
1739///
1740/// `still_on_first_line`: `true` until the first `\n` is
1741/// seen in this tool call's output, `false` forever after. Needed because
1742/// **Windows ConPTY re-serializes a literal `\r` as CUP-to-home
1743/// (`\x1b[H`)** rather than passing it through unchanged — verified
1744/// empirically via `a1_e2e_static_label_then_delayed_cr_overwrite_collapses_to_one_line`
1745/// capturing the raw PTY bytes: a child process writing `\rdone` produces
1746/// `\x1b[?25l\x1b[Hdone...\x1b[?25h` on the wire, not a literal `\r`. CUP
1747/// "home" (row 1, col 1) is unambiguous evidence of an overwrite ONLY when
1748/// nothing has scrolled the visual line off row 1 yet; once real multi-line
1749/// output exists, `\x1b[H` genuinely means "go back to the first of several
1750/// lines" (a multi-line redraw, spec §A3, deliberately out of scope) and
1751/// must NOT be treated as "restart the current line".
1752///
1753/// Cases handled, chosen because each means exactly "move to the start of
1754/// the (only) line so far" without needing true per-column tracking:
1755///   - CUP to row 1 col 1 (`\x1b[H`, `\x1b[1H`, `\x1b[1;1H`, and the
1756///     omitted-field variants of each), gated on `still_on_first_line` per
1757///     above — the ConPTY-observed idiom.
1758///   - CHA to column 1 (`\x1b[1G` / `\x1b[G`, "cursor to column 1") is
1759///     literally "move to start of line" — identical to `\r`. Unix PTYs
1760///     (and any tool not going through ConPTY's re-serialization) may use
1761///     this form directly.
1762///   - EL (`\x1b[2K` erase whole line, `\x1b[K`/`\x1b[0K` erase to end of
1763///     line) is treated as "reset to start of line" too, but ONLY when the
1764///     cursor is already known to be at column 0 (`*at_col0`) — i.e. the
1765///     dominant real-world `\r` + EL idiom (`\r\x1b[2K<text>`), where it's
1766///     genuinely redundant with the `\r` already just emitted. A
1767///     STANDALONE EL, mid-line (cursor NOT at column 0 — e.g.
1768///     `prefix\x1b[Ksuffix`), is a real, different case: EL only erases
1769///     from the cursor onward and never moves it, so collapsing it to `\r`
1770///     there would make `collapse_cr` truncate `prefix` even though EL
1771///     never asked for that. That standalone case is left unconverted
1772///     (falls through to the "unrecognized" branch below, so `strip_ansi`
1773///     removes the escape byte-for-byte without touching `prefix`) — a
1774///     bug reagent caught on this exact line (PR #2330): the prior,
1775///     unconditional version silently discarded `prefix` in
1776///     `prefix\x1b[Ksuffix\n`.
1777///
1778/// CUB (`\x1b[<n>D`, "cursor back n columns") is intentionally NOT handled
1779/// here — a faithful implementation needs true per-column tracking (a raw
1780/// byte position isn't a faithful proxy once `n` varies per frame), which
1781/// `collapse_cr`'s line-offset model doesn't have. Left as a documented
1782/// follow-up alongside CUU/multi-line redraw (spec §A3) rather than
1783/// guessed at with an approximation that could truncate the wrong amount.
1784fn normalize_csi_overwrites(chunk: &mut Vec<u8>, still_on_first_line: &mut bool, at_col0: &mut bool) {
1785    let mut out: Vec<u8> = Vec::with_capacity(chunk.len());
1786    let mut i = 0;
1787    while i < chunk.len() {
1788        let b = chunk[i];
1789        if b == b'\n' {
1790            *still_on_first_line = false;
1791        }
1792        if b == 0x1b && i + 1 < chunk.len() && chunk[i + 1] == b'[' {
1793            let mut j = i + 2;
1794            while j < chunk.len() && (0x30..=0x3F).contains(&chunk[j]) {
1795                j += 1;
1796            }
1797            if j < chunk.len() && (0x40..=0x7E).contains(&chunk[j]) {
1798                let params = &chunk[i + 2..j];
1799                let final_byte = chunk[j];
1800                let is_cha_col1 = final_byte == b'G' && (params.is_empty() || params == b"1");
1801                let is_el = final_byte == b'K' && *at_col0;
1802                let is_cup_home = *still_on_first_line
1803                    && final_byte == b'H'
1804                    && is_cup_row1_col1(params);
1805                if is_cha_col1 || is_el || is_cup_home {
1806                    out.push(b'\r');
1807                    *at_col0 = true;
1808                    i = j + 1;
1809                    continue;
1810                }
1811                // Unrecognized/CUB/mid-line-EL/out-of-scope CSI sequence:
1812                // fall through untouched, byte by byte, so strip_ansi
1813                // (which runs next) still recognizes and removes it as a
1814                // whole — this function only ever substitutes the matched
1815                // idioms above. Each of those bytes then runs through the
1816                // ordinary `*at_col0 = b == b'\n' || b == b'\r'` update
1817                // below like any other byte, which forces `at_col0` to
1818                // `false` (none of an escape sequence's bytes are `\n`/
1819                // `\r`) — conservative and safe (errs toward NOT
1820                // collapsing a subsequent EL), not "left as-is".
1821            }
1822        }
1823        out.push(b);
1824        *at_col0 = b == b'\n' || b == b'\r';
1825        i += 1;
1826    }
1827    *chunk = out;
1828}
1829
1830/// Does a CUP (`\x1b[<params>H`) params string address row 1, column 1
1831/// ("home")? Each field defaults to 1 when empty, per ECMA-48 — so `""`,
1832/// `"1"`, `"1;1"`, `";1"`, `"1;"`, and `";"` are all row-1-col-1.
1833fn is_cup_row1_col1(params: &[u8]) -> bool {
1834    let field_is_one_or_empty = |f: &[u8]| f.is_empty() || f == b"1";
1835    match params.iter().position(|&b| b == b';') {
1836        None => field_is_one_or_empty(params),
1837        Some(idx) => {
1838            field_is_one_or_empty(&params[..idx]) && field_is_one_or_empty(&params[idx + 1..])
1839        }
1840    }
1841}
1842
1843/// Scan `chunk` for DSR `\x1b[6n` sequences and remove them in place
1844/// so they don't leak into the model-visible blob or overlay log.
1845fn strip_dsr(chunk: &mut Vec<u8>) {
1846    const DSR: &[u8] = b"\x1b[6n";
1847    let mut i = 0;
1848    while i + DSR.len() <= chunk.len() {
1849        if &chunk[i..i + DSR.len()] == DSR {
1850            chunk.drain(i..i + DSR.len());
1851        } else {
1852            i += 1;
1853        }
1854    }
1855}
1856
1857/// Phase β (lite): strip ANSI control sequences from a PTY chunk so
1858/// the overlay's plain-text ChunkList doesn't render them as garbled
1859/// literal characters.
1860///
1861/// Three families to handle (everything bash emits during init and
1862/// most programs emit during normal output):
1863///
1864/// 1. **CSI sequences** — `ESC [ <params> <final>` where params are
1865///    `0x30..=0x3F` (digits, `;`, `?`, etc.) and final is
1866///    `0x40..=0x7E` (letters + punctuation). Covers `\x1b[m` (reset),
1867///    `\x1b[?25h/l` (cursor visibility), color codes, cursor moves.
1868///
1869/// 2. **OSC sequences** — `ESC ] <text> BEL` (or `ESC ] <text> ESC \`).
1870///    Covers `\x1b]0;<title>\x07` (set window title — emitted by
1871///    Git Bash on every command).
1872///
1873/// 3. **Lone control chars** — `\r` (carriage return without
1874///    newline), `\x07` (bell), `\x08` (backspace). CRLF → LF for
1875///    the chunk list. Lone `\r` passes through so `collapse_cr` can
1876///    operate on the `pending` buffer across PTY reads (see below).
1877///
1878/// Things we DON'T handle yet (Phase γ territory):
1879/// - Alt-screen apps (`\x1b[?1049h`) — left in the stream.
1880/// - Cursor positioning escapes within the same line.
1881/// - OSC 633 (shell integration markers).
1882fn strip_ansi(chunk: &mut Vec<u8>) {
1883    let mut out: Vec<u8> = Vec::with_capacity(chunk.len());
1884    let mut i = 0;
1885    while i < chunk.len() {
1886        let b = chunk[i];
1887        if b == 0x1b && i + 1 < chunk.len() {
1888            match chunk[i + 1] {
1889                b'[' => {
1890                    // CSI: scan params (0x30..=0x3F) then final (0x40..=0x7E).
1891                    let mut j = i + 2;
1892                    while j < chunk.len() && (0x30..=0x3F).contains(&chunk[j]) {
1893                        j += 1;
1894                    }
1895                    // Intermediate bytes (rare for our case).
1896                    while j < chunk.len() && (0x20..=0x2F).contains(&chunk[j]) {
1897                        j += 1;
1898                    }
1899                    if j < chunk.len() && (0x40..=0x7E).contains(&chunk[j]) {
1900                        // Consume the whole CSI sequence.
1901                        i = j + 1;
1902                        continue;
1903                    }
1904                    // Malformed — keep ESC, skip past.
1905                    out.push(b);
1906                    i += 1;
1907                    continue;
1908                }
1909                b']' => {
1910                    // OSC: scan until BEL (0x07) or ST (`ESC \`).
1911                    let mut j = i + 2;
1912                    while j < chunk.len() {
1913                        if chunk[j] == 0x07 {
1914                            i = j + 1;
1915                            break;
1916                        }
1917                        if chunk[j] == 0x1b && j + 1 < chunk.len() && chunk[j + 1] == b'\\' {
1918                            i = j + 2;
1919                            break;
1920                        }
1921                        j += 1;
1922                    }
1923                    if i <= j {
1924                        // Reached end of chunk without terminator —
1925                        // drop the rest as a partial OSC. (Next read
1926                        // will resume with the trailing bytes; if it
1927                        // doesn't start with the terminator we'll
1928                        // mis-render once, but that's better than
1929                        // emitting garbage.)
1930                        i = chunk.len();
1931                    }
1932                    continue;
1933                }
1934                _ => {
1935                    // ESC followed by some other byte (e.g. `ESC c` =
1936                    // reset). Consume both.
1937                    i += 2;
1938                    continue;
1939                }
1940            }
1941        }
1942        if b == 0x07 {
1943            // Bare BEL — drop.
1944            i += 1;
1945            continue;
1946        }
1947        if b == b'\r' && i + 1 < chunk.len() && chunk[i + 1] == b'\n' {
1948            // CRLF → LF: drop the \r, keep the \n on the next pass.
1949            i += 1;
1950            continue;
1951        }
1952        out.push(b);
1953        i += 1;
1954    }
1955    *chunk = out;
1956}
1957
1958/// Collapse lone carriage-returns in the `pending` byte buffer, simulating
1959/// terminal overwrite: each `\r` NOT followed by `\n` discards the current
1960/// visual line back to column 0.
1961///
1962/// **Why here and not in `strip_ansi`:** real spinner animations emit each
1963/// frame as a separate `write()` syscall, so the preceding frame content and
1964/// the overwriting `\r` arrive in different PTY reads. Running collapse on
1965/// the `pending` accumulator (which spans reads for the current visual line)
1966/// catches those cross-read overwrites.
1967///
1968/// **Leading `\r` is preserved — including through mid-buffer overwrites.**
1969/// When a lone `\r` appears at the very start of a visual line, it is emitted
1970/// as-is rather than discarded. When a subsequent lone `\r` overwrites a line
1971/// that *itself* started with `\r` (e.g. `"\rframe1\rframe2"`), the leading
1972/// `\r` is preserved in the result (`"\rframe2"`, not `"frame2"`). This lets
1973/// the caller's `pending_cr_override` slot chain-collapse throttled spinner
1974/// frames on successive reads: the collapsed result still starts with `\r`, so
1975/// the slot engages again on the next quiet-window expiry.
1976///
1977/// **Trailing `\r` is left in place.** A `\r` at the very end of `pending`
1978/// might be the first byte of a CRLF pair split across reads. Collapsing it
1979/// immediately would discard the line's content before the `\n` arrives,
1980/// causing silent data loss. The caller suppresses the eager partial-line
1981/// flush when `pending` ends with `\r` so the next read can disambiguate.
1982fn collapse_cr(pending: &mut Vec<u8>) {
1983    if !pending.contains(&b'\r') {
1984        return; // fast-path: no CR at all
1985    }
1986    let mut out: Vec<u8> = Vec::with_capacity(pending.len());
1987    let mut line_start = 0usize;
1988    let mut i = 0;
1989    while i < pending.len() {
1990        let b = pending[i];
1991        if b == b'\n' {
1992            out.push(b);
1993            line_start = out.len();
1994            i += 1;
1995        } else if b == b'\r' {
1996            if i + 1 < pending.len() {
1997                if pending[i + 1] == b'\n' {
1998                    // CRLF → LF (strip_ansi already handles this for
1999                    // within-chunk pairs, but CRLF can straddle reads).
2000                    out.push(b'\n');
2001                    line_start = out.len();
2002                    i += 2;
2003                } else if out.len() == line_start {
2004                    // Leading \r: nothing to overwrite on this line.
2005                    // Preserve it so the caller's pending_cr_override slot
2006                    // can recognise and hold leading-\r spinner frames
2007                    // ("\rframe", npm/ora/tqdm style).
2008                    out.push(b);
2009                    i += 1;
2010                } else {
2011                    // Lone \r mid-buffer: discard current line back to column 0.
2012                    // If the line opened with a leading \r (spinner convention),
2013                    // preserve that leading \r so the pending_cr_override slot in
2014                    // the reader can still recognise the result as a spinner frame
2015                    // after collapsing "\rframe1\rframe2" → "\rframe2".
2016                    let keep =
2017                        usize::from(out.len() > line_start && out[line_start] == b'\r');
2018                    out.truncate(line_start + keep);
2019                    i += 1;
2020                }
2021            } else {
2022                // Trailing \r — can't disambiguate yet; leave for next read.
2023                out.push(b);
2024                i += 1;
2025            }
2026        } else {
2027            out.push(b);
2028            i += 1;
2029        }
2030    }
2031    *pending = out;
2032}
2033
2034/// Shared publisher loop — drains the LineEvent channel, aggregates
2035/// into the model-visible buffer, and publishes each line via MPS.
2036///
2037/// **Leading-`\r` spinner handling:** throttled spinner frames (npm, cargo,
2038/// ora, tqdm at >50 ms/frame) are collapsed upstream — in the
2039/// `pending_cr_override` slot inside `stream_reader` (pipe path) and
2040/// `pty_reader_loop` (PTY path) — before reaching this loop. The
2041/// `pending_cr_line` slot here remains as a secondary layer: when a frame
2042/// arrives with a leading `\r`, the prior stored frame is published first
2043/// (so no frame is silently dropped), then the new frame is stored and
2044/// flushed at the next non-`\r` line or EOF. Consecutive spinner frames
2045/// therefore appear as sequential published chunks in the live log.
2046///
2047/// The model-visible buffer (`buffered`) receives the post-collapse LineEvent
2048/// bytes. Intermediate spinner frames collapsed by `pending_cr_override` never
2049/// become LineEvents and are not present in `buffered`; the model sees only the
2050/// surviving final frame for each overwrite sequence.
2051fn spawn_publisher_loop(
2052    args: &Args,
2053    mps: Option<WpsClient>,
2054    buffered: Arc<Mutex<Vec<u8>>>,
2055    mut rx: mpsc::Receiver<LineEvent>,
2056) -> tokio::task::JoinHandle<()> {
2057    let tool_id = args.tool_id.clone();
2058    let block_id = args.block_id.clone();
2059    tokio::spawn(async move {
2060        let mut chunks_published = 0u64;
2061        let mut chunks_failed = 0u64;
2062
2063        // Pending leading-\r spinner frame: holds (content_after_cr, kind).
2064        // Flushed on the next non-\r line or at EOF.
2065        let mut pending_cr_line: Option<(String, ToolChunkKind)> = None;
2066
2067        while let Some(event) = rx.recv().await {
2068            {
2069                let mut buf = buffered.lock().await;
2070                if event.kind == ToolChunkKind::Stderr {
2071                    buf.extend_from_slice(b"[stderr] ");
2072                }
2073                // Strip a single leading `\r` carried by a collapsed leading-\r
2074                // spinner frame (e.g. a final "\r✔ done\n"), matching the MPS
2075                // live path's `strip_prefix('\r')` below, so the model-visible
2076                // blob and the live log agree byte-for-byte. The trailing `\n`
2077                // is preserved here — it separates lines in the blob.
2078                let body: &[u8] = match event.bytes.first() {
2079                    Some(&b'\r') => &event.bytes[1..],
2080                    _ => &event.bytes,
2081                };
2082                buf.extend_from_slice(body);
2083            }
2084            if let Some(client) = mps.as_ref() {
2085                let mut line_bytes: &[u8] = &event.bytes;
2086                if line_bytes.last() == Some(&b'\n') {
2087                    line_bytes = &line_bytes[..line_bytes.len() - 1];
2088                }
2089                let line_str = String::from_utf8_lossy(line_bytes);
2090
2091                if let Some(stripped) = line_str.strip_prefix('\r') {
2092                    // Leading-\r spinner frame: publish any prior pending frame
2093                    // before storing the new one, so consecutive \r-prefixed
2094                    // lines are not silently dropped from the live MPS log.
2095                    // (This pending slot only affects the live MPS log; the
2096                    // model-visible blob already received this frame's bytes
2097                    // above, with the leading \r stripped to match.)
2098                    if let Some((prior_text, prior_kind)) = pending_cr_line.take() {
2099                        match publish_line(
2100                            client,
2101                            &tool_id,
2102                            block_id.as_deref(),
2103                            prior_kind,
2104                            &prior_text,
2105                        )
2106                        .await
2107                        {
2108                            Ok(()) => chunks_published += 1,
2109                            Err(e) => {
2110                                chunks_failed += 1;
2111                                tracing::warn!(
2112                                    target: "bashwrap",
2113                                    tool_id = %tool_id,
2114                                    error = %e,
2115                                    "MPS publish failed (prior pending_cr_line)"
2116                                );
2117                            }
2118                        }
2119                    }
2120                    pending_cr_line = Some((stripped.to_owned(), event.kind));
2121                } else {
2122                    // Non-\r line: flush any pending spinner frame first.
2123                    if let Some((cr_text, cr_kind)) = pending_cr_line.take() {
2124                        match publish_line(
2125                            client,
2126                            &tool_id,
2127                            block_id.as_deref(),
2128                            cr_kind,
2129                            &cr_text,
2130                        )
2131                        .await
2132                        {
2133                            Ok(()) => chunks_published += 1,
2134                            Err(e) => {
2135                                chunks_failed += 1;
2136                                tracing::warn!(
2137                                    target: "bashwrap",
2138                                    tool_id = %tool_id,
2139                                    error = %e,
2140                                    "MPS publish failed"
2141                                );
2142                            }
2143                        }
2144                    }
2145                    match publish_line(
2146                        client,
2147                        &tool_id,
2148                        block_id.as_deref(),
2149                        event.kind,
2150                        &line_str,
2151                    )
2152                    .await
2153                    {
2154                        Ok(()) => chunks_published += 1,
2155                        Err(e) => {
2156                            chunks_failed += 1;
2157                            tracing::warn!(
2158                                target: "bashwrap",
2159                                tool_id = %tool_id,
2160                                error = %e,
2161                                "MPS publish failed"
2162                            );
2163                        }
2164                    }
2165                }
2166            }
2167        }
2168
2169        // EOF: flush any remaining pending spinner frame.
2170        if let Some((cr_text, cr_kind)) = pending_cr_line.take() {
2171            if let Some(client) = mps.as_ref() {
2172                match publish_line(client, &tool_id, block_id.as_deref(), cr_kind, &cr_text).await
2173                {
2174                    Ok(()) => chunks_published += 1,
2175                    Err(e) => {
2176                        chunks_failed += 1;
2177                        tracing::warn!(
2178                            target: "bashwrap",
2179                            tool_id = %tool_id,
2180                            error = %e,
2181                            "MPS publish failed (EOF flush)"
2182                        );
2183                    }
2184                }
2185            }
2186        }
2187
2188        tracing::info!(
2189            target: "bashwrap",
2190            tool_id = %tool_id,
2191            chunks_published,
2192            chunks_failed,
2193            "publisher done"
2194        );
2195    })
2196}
2197
2198/// Snap `idx` down to the nearest UTF-8 character boundary (or 0).
2199/// A byte is a UTF-8 char boundary iff it's either ASCII (< 0x80) or
2200/// a leading byte (>= 0xC0). Continuation bytes are in 0x80..=0xBF.
2201/// `idx == buf.len()` is always a valid boundary (one past the end).
2202fn snap_to_char_boundary_floor(buf: &[u8], idx: usize) -> usize {
2203    let mut i = idx.min(buf.len());
2204    while i > 0 && i < buf.len() && (buf[i] & 0xC0) == 0x80 {
2205        i -= 1;
2206    }
2207    i
2208}
2209
2210/// Snap `idx` up to the nearest UTF-8 character boundary (or buf.len()).
2211fn snap_to_char_boundary_ceil(buf: &[u8], idx: usize) -> usize {
2212    let mut i = idx.min(buf.len());
2213    while i < buf.len() && (buf[i] & 0xC0) == 0x80 {
2214        i += 1;
2215    }
2216    i
2217}
2218
2219/// Build the aggregated, model-visible blob from the captured bytes.
2220/// Truncates with head/tail markers if it exceeds 100KB.
2221///
2222/// **UTF-8 safety**: head/tail slice boundaries are snapped to the
2223/// nearest UTF-8 character boundary so non-ASCII output (emoji,
2224/// accented characters, CJK) doesn't get corrupted into `�`
2225/// replacement characters at the cut point — naive fixed-byte slicing
2226/// splits multi-byte sequences and the lossy decode emits replacement
2227/// chars.
2228pub(crate) fn format_model_blob(
2229    buf: &[u8],
2230    exit_code: i32,
2231    elapsed: std::time::Duration,
2232) -> String {
2233    let body = if buf.len() <= MODEL_BLOB_HEAD_BYTES + MODEL_BLOB_TAIL_BYTES {
2234        String::from_utf8_lossy(buf).into_owned()
2235    } else {
2236        let head_end = snap_to_char_boundary_floor(buf, MODEL_BLOB_HEAD_BYTES);
2237        let tail_start = snap_to_char_boundary_ceil(buf, buf.len() - MODEL_BLOB_TAIL_BYTES);
2238        let head = String::from_utf8_lossy(&buf[..head_end]);
2239        let tail = String::from_utf8_lossy(&buf[tail_start..]);
2240        let elided = tail_start - head_end;
2241        format!(
2242            "{}\n... [{} bytes elided — see streaming log for full output] ...\n{}",
2243            head, elided, tail
2244        )
2245    };
2246    format!(
2247        "<exited {} in {:.2}s>\n{}",
2248        exit_code,
2249        elapsed.as_secs_f64(),
2250        body
2251    )
2252}
2253
2254/// Output fragments that mean an API refused the caller rather than the
2255/// command being wrong: AWS (`AccessDenied`, `AccessDeniedException`,
2256/// `... is not authorized to perform: ...`, EC2's `UnauthorizedOperation`)
2257/// and the GitHub API as an App (`Resource not accessible by integration`).
2258const ACCESS_DENIED_MARKERS: &[&str] = &[
2259    "AccessDenied",
2260    "is not authorized to perform",
2261    "UnauthorizedOperation",
2262    "Resource not accessible by integration",
2263];
2264
2265/// Appended after a failed command whose output shows an access denial.
2266/// Generic on purpose: how to escalate differs per deployment. Conditional
2267/// on purpose: in a compound command or a test suite the denial text may
2268/// come from a step that isn't the one that failed.
2269pub(crate) const ACCESS_DENIED_HINT: &str = "\n[agentmux] The output above includes an access denial. If that denial \
2270is why the command failed, don't work around it with another credential or by skipping the step: use this \
2271environment's documented escalation path (check your Global Memory and CLAUDE.md) with a stated reason, or ask the \
2272human operator if there is none.\n";
2273
2274/// The hint for the model, if the command failed and its output shows an
2275/// access denial. The whole buffer is searched, including any part the
2276/// model blob elides. A successful command never gets the hint, so a `grep`
2277/// or a log that merely mentions `AccessDenied` stays untouched.
2278pub(crate) fn access_denied_hint(buf: &[u8], exit_code: i32) -> Option<&'static str> {
2279    if exit_code == 0 {
2280        return None;
2281    }
2282    let denied = ACCESS_DENIED_MARKERS.iter().any(|marker| {
2283        let needle = marker.as_bytes();
2284        buf.windows(needle.len()).any(|window| window == needle)
2285    });
2286    denied.then_some(ACCESS_DENIED_HINT)
2287}
2288
2289#[cfg(test)]
2290mod tests {
2291    use super::*;
2292    use std::sync::Mutex;
2293
2294    // `std::env::set_var` / `remove_var` mutate process-global state;
2295    // cargo test runs tests in parallel by default, so without a
2296    // serial lock tests that touch the env race each other. Mirrors
2297    // the same pattern in `mps_client::tests`.
2298    static ENV_LOCK: Mutex<()> = Mutex::new(());
2299
2300    #[test]
2301    fn decodes_b64_command() {
2302        // base64("echo hi") = ZWNobyBoaQ (no padding, url-safe)
2303        let s = decode_command("ZWNobyBoaQ").unwrap();
2304        assert_eq!(s, "echo hi");
2305    }
2306
2307    #[test]
2308    fn decodes_multi_line_with_quotes() {
2309        use base64::Engine as _;
2310        use base64::engine::general_purpose::URL_SAFE_NO_PAD;
2311        let original = "echo \"line one\"\necho 'line two'";
2312        let b64 = URL_SAFE_NO_PAD.encode(original.as_bytes());
2313        let s = decode_command(&b64).unwrap();
2314        assert_eq!(s, original);
2315    }
2316
2317    #[test]
2318    fn rejects_malformed_b64() {
2319        assert!(decode_command("not-valid-base64===").is_err());
2320    }
2321
2322    // ── cwd persistence tests ───────────────────────────────────────────────
2323    // See docs/retro/RETRO_BASH_CWD_RESET_NOTICE_WINDOWS_2026_08_02.md for
2324    // the bug this exists to fix: each `exec` invocation used to always seed
2325    // its inner bash's cwd from this process's own (fixed) cwd, silently
2326    // discarding whatever directory a previous call's `cd` left the agent
2327    // in.
2328
2329    /// Unique-per-call scratch directory under the OS temp dir, so parallel
2330    /// test runs (cargo's default) never share state. Mirrors the marker-tag
2331    /// pattern `run_via_pty_kills_idle_child_and_returns_promptly` already
2332    /// uses for the same reason.
2333    fn unique_temp_dir(tag: &str) -> PathBuf {
2334        use std::sync::atomic::{AtomicU64, Ordering};
2335        static COUNTER: AtomicU64 = AtomicU64::new(0);
2336        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
2337        let dir = std::env::temp_dir().join(format!(
2338            "agentmux-bashwrap-test-{tag}-{}-{n}",
2339            std::process::id()
2340        ));
2341        std::fs::create_dir_all(&dir).expect("create unique temp dir");
2342        dir
2343    }
2344
2345    #[test]
2346    fn sanitize_state_key_replaces_unsafe_chars() {
2347        assert_eq!(sanitize_state_key("agent3-0630k"), "agent3-0630k");
2348        assert_eq!(sanitize_state_key("Agent With Spaces"), "Agent_With_Spaces");
2349        assert_eq!(sanitize_state_key(r"C:\Users\user"), "C__Users_user");
2350    }
2351
2352    #[test]
2353    fn cwd_state_path_honors_explicit_override() {
2354        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2355        let scratch = unique_temp_dir("override");
2356        let target = scratch.join("explicit.cwd");
2357        unsafe {
2358            std::env::set_var(CWD_STATE_FILE_OVERRIDE_ENV, &target);
2359        }
2360        let resolved = cwd_state_path();
2361        unsafe {
2362            std::env::remove_var(CWD_STATE_FILE_OVERRIDE_ENV);
2363        }
2364        let _ = std::fs::remove_dir_all(&scratch);
2365        assert_eq!(resolved, Some(target));
2366    }
2367
2368    /// Save-and-restore guard for an env var, so tests can freely
2369    /// set/remove it and always leave the ambient environment exactly as
2370    /// they found it, even on an early return or panic mid-test — every
2371    /// cwd-state-key test needs this for at least `AGENTMUX_AGENT_ID` and
2372    /// `AGENTMUX_INSTANCE_SLUG` simultaneously, so a bespoke prev/restore
2373    /// dance per test (the pattern used elsewhere in this file for a single
2374    /// var) would otherwise multiply combinatorially.
2375    struct EnvVarGuard {
2376        key: &'static str,
2377        prev: Option<String>,
2378    }
2379
2380    impl EnvVarGuard {
2381        fn set(key: &'static str, value: &str) -> Self {
2382            let prev = std::env::var(key).ok();
2383            unsafe {
2384                std::env::set_var(key, value);
2385            }
2386            Self { key, prev }
2387        }
2388
2389        fn unset(key: &'static str) -> Self {
2390            let prev = std::env::var(key).ok();
2391            unsafe {
2392                std::env::remove_var(key);
2393            }
2394            Self { key, prev }
2395        }
2396    }
2397
2398    impl Drop for EnvVarGuard {
2399        fn drop(&mut self) {
2400            unsafe {
2401                match &self.prev {
2402                    Some(v) => std::env::set_var(self.key, v),
2403                    None => std::env::remove_var(self.key),
2404                }
2405            }
2406        }
2407    }
2408
2409    #[test]
2410    fn cwd_state_path_derives_from_agent_id_when_no_override_or_instance_slug() {
2411        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2412        let _override = EnvVarGuard::unset(CWD_STATE_FILE_OVERRIDE_ENV);
2413        let _instance = EnvVarGuard::unset("AGENTMUX_INSTANCE_SLUG");
2414        let _agent = EnvVarGuard::set("AGENTMUX_AGENT_ID", "Test-Agent-42");
2415
2416        let resolved = cwd_state_path().expect("home dir should resolve in any test environment");
2417        assert!(resolved.ends_with("Test-Agent-42.cwd"), "{resolved:?}");
2418        assert!(resolved.to_string_lossy().contains(".agentmux"));
2419    }
2420
2421    /// codex P1 on this PR: keying solely by `AGENTMUX_AGENT_ID` (the
2422    /// definition-wide slug) made two concurrently-running *instances* of
2423    /// the same agent definition share one cwd file — a `cd` in instance A
2424    /// would redirect instance B's next command into A's directory, even
2425    /// though each instance has its own distinct working directory. The
2426    /// fix: prefer `AGENTMUX_INSTANCE_SLUG` (per-launch, unique per running
2427    /// instance — see `agent-model.ts`) whenever it's set.
2428    #[test]
2429    fn cwd_state_path_prefers_instance_slug_over_agent_id() {
2430        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2431        let _override = EnvVarGuard::unset(CWD_STATE_FILE_OVERRIDE_ENV);
2432        let _agent = EnvVarGuard::set("AGENTMUX_AGENT_ID", "shared-definition-slug");
2433        let _instance_a = EnvVarGuard::set("AGENTMUX_INSTANCE_SLUG", "shared-definition-slug-20260101-000000");
2434
2435        let resolved_a =
2436            cwd_state_path().expect("home dir should resolve in any test environment");
2437        assert!(
2438            resolved_a.ends_with("shared-definition-slug-20260101-000000.cwd"),
2439            "{resolved_a:?}"
2440        );
2441
2442        // A second, concurrently-running instance of the SAME definition
2443        // (same AGENTMUX_AGENT_ID, different AGENTMUX_INSTANCE_SLUG) must
2444        // resolve to a DIFFERENT file.
2445        let _instance_b = EnvVarGuard::set("AGENTMUX_INSTANCE_SLUG", "shared-definition-slug-20260101-000001");
2446        let resolved_b =
2447            cwd_state_path().expect("home dir should resolve in any test environment");
2448        assert_ne!(resolved_a, resolved_b);
2449        assert!(
2450            resolved_b.ends_with("shared-definition-slug-20260101-000001.cwd"),
2451            "{resolved_b:?}"
2452        );
2453    }
2454
2455    /// reagentx re-review finding: when the state dir can't be created,
2456    /// `CwdState::load()` used to leave `path` as `Some` anyway, so
2457    /// `restore_cwd` and the child's write would both silently keep failing
2458    /// against a directory that doesn't exist on every subsequent call,
2459    /// instead of cleanly disabling persistence once as the doc comment
2460    /// promises. Forces the failure by pointing the override at a path
2461    /// whose *parent* is a plain file, not a directory — `create_dir_all`
2462    /// cannot create a directory there.
2463    #[test]
2464    fn cwd_state_load_clears_path_when_state_dir_cannot_be_created() {
2465        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2466        let scratch = unique_temp_dir("uncreatable-parent");
2467        let blocking_file = scratch.join("not-a-directory");
2468        std::fs::write(&blocking_file, b"blocks create_dir_all").unwrap();
2469        let unreachable_state_file = blocking_file.join("state.cwd");
2470
2471        let _override = EnvVarGuard::set(
2472            CWD_STATE_FILE_OVERRIDE_ENV,
2473            unreachable_state_file.to_str().unwrap(),
2474        );
2475        let state = CwdState::load();
2476        let _ = std::fs::remove_dir_all(&scratch);
2477
2478        assert_eq!(
2479            state.path, None,
2480            "path must be cleared (not just logged-as-disabled) when its parent dir can't be created"
2481        );
2482        assert!(state.start_dir.is_some(), "must still fall back to a start dir");
2483    }
2484
2485    #[test]
2486    fn restore_cwd_none_when_file_missing() {
2487        let scratch = unique_temp_dir("missing");
2488        let missing = scratch.join("does-not-exist.cwd");
2489        assert_eq!(restore_cwd(&missing), None);
2490        let _ = std::fs::remove_dir_all(&scratch);
2491    }
2492
2493    #[test]
2494    fn restore_cwd_none_when_persisted_dir_no_longer_exists() {
2495        let scratch = unique_temp_dir("stale");
2496        let state_file = scratch.join("state.cwd");
2497        let gone = scratch.join("deleted-dir");
2498        std::fs::create_dir_all(&gone).unwrap();
2499        std::fs::write(&state_file, gone.to_string_lossy().as_bytes()).unwrap();
2500        std::fs::remove_dir_all(&gone).unwrap();
2501        assert_eq!(restore_cwd(&state_file), None);
2502        let _ = std::fs::remove_dir_all(&scratch);
2503    }
2504
2505    #[test]
2506    fn restore_cwd_some_when_persisted_dir_exists() {
2507        let scratch = unique_temp_dir("valid");
2508        let state_file = scratch.join("state.cwd");
2509        std::fs::write(&state_file, scratch.to_string_lossy().as_bytes()).unwrap();
2510        assert_eq!(restore_cwd(&state_file), Some(scratch.clone()));
2511        let _ = std::fs::remove_dir_all(&scratch);
2512    }
2513
2514    #[test]
2515    fn restore_cwd_ignores_blank_file() {
2516        let scratch = unique_temp_dir("blank");
2517        let state_file = scratch.join("state.cwd");
2518        std::fs::write(&state_file, b"   \n").unwrap();
2519        assert_eq!(restore_cwd(&state_file), None);
2520        let _ = std::fs::remove_dir_all(&scratch);
2521    }
2522
2523    #[test]
2524    fn append_cwd_capture_preserves_exit_code_and_gates_on_env() {
2525        let script = append_cwd_capture("false");
2526        // The real command's exit code must survive the appended
2527        // bookkeeping regardless of whether the state env var is set.
2528        assert!(script.contains("__agentmux_bashwrap_rc=$?"));
2529        assert!(script.trim_end().ends_with("exit $__agentmux_bashwrap_rc"));
2530        // The write is gated on the env var being non-empty, and uses a
2531        // per-PID temp-then-rename so a crash mid-write can't corrupt the
2532        // file, and two concurrent invocations never write the same temp
2533        // file (reagentx P2 on this PR).
2534        assert!(script.contains(&format!("-n \"${CWD_STATE_ENV}\"")));
2535        assert!(script.contains(&format!("${CWD_STATE_ENV}.tmp.$$")));
2536        assert!(script.contains("mv -f"));
2537    }
2538
2539    /// reagentx P2 on this PR: a fixed (non-unique) temp filename meant two
2540    /// concurrent Bash tool calls for the same instance could both write to
2541    /// the *same* temp file before either renamed it, corrupting the
2542    /// persisted cwd with interleaved bytes. Proves two real, genuinely
2543    /// concurrent writers (via `$$`, each process's own PID) never
2544    /// interleave — the file always ends up as one writer's complete,
2545    /// valid output, never a mix of both.
2546    #[tokio::test]
2547    async fn run_via_pipes_concurrent_writers_never_corrupt_state_file() {
2548        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2549        let scratch = unique_temp_dir("e2e-concurrent");
2550        let state_file = scratch.join("state.cwd");
2551        let dir_a = scratch.join("dir-a");
2552        let dir_b = scratch.join("dir-b");
2553        std::fs::create_dir_all(&dir_a).unwrap();
2554        std::fs::create_dir_all(&dir_b).unwrap();
2555
2556        let _override =
2557            EnvVarGuard::set(CWD_STATE_FILE_OVERRIDE_ENV, state_file.to_str().unwrap());
2558
2559        let bash = locate_bash().expect("locate_bash for test — same dependency the whole binary needs");
2560        let args_a = Args {
2561            tool_id: "test-concurrent-a".to_string(),
2562            b64_cmd: String::new(),
2563            block_id: None,
2564            declared_background: false,
2565        };
2566        let args_b = Args {
2567            tool_id: "test-concurrent-b".to_string(),
2568            b64_cmd: String::new(),
2569            block_id: None,
2570            declared_background: false,
2571        };
2572        let cd_a = format!("cd \"{}\"", dir_a.display());
2573        let cd_b = format!("cd \"{}\"", dir_b.display());
2574        let buffered_a = Arc::new(tokio::sync::Mutex::new(Vec::<u8>::new()));
2575        let buffered_b = Arc::new(tokio::sync::Mutex::new(Vec::<u8>::new()));
2576
2577        let (status_a, status_b) = tokio::join!(
2578            run_via_pipes(&args_a, &cd_a, None, buffered_a, &bash),
2579            run_via_pipes(&args_b, &cd_b, None, buffered_b, &bash),
2580        );
2581        assert_eq!(status_a.unwrap(), 0);
2582        assert_eq!(status_b.unwrap(), 0);
2583
2584        // Whichever writer's rename landed last, the file must contain
2585        // exactly ONE valid, complete directory path — never a truncated
2586        // or interleaved mix of both writers' output.
2587        let final_contents = std::fs::read_to_string(&state_file).unwrap();
2588        let trimmed = final_contents.trim();
2589        let a_str = dir_a.to_string_lossy();
2590        let b_str = dir_b.to_string_lossy();
2591        assert!(
2592            trimmed.ends_with(dir_a.file_name().unwrap().to_str().unwrap())
2593                || trimmed.ends_with(dir_b.file_name().unwrap().to_str().unwrap()),
2594            "expected a clean write of either {a_str:?} or {b_str:?}, got {trimmed:?}"
2595        );
2596        assert!(
2597            !trimmed.contains('\n'),
2598            "a corrupted/interleaved write would likely contain embedded newlines: {trimmed:?}"
2599        );
2600
2601        let _ = std::fs::remove_dir_all(&scratch);
2602    }
2603
2604    /// End-to-end proof of the actual fix: running a `cd` in one `exec`
2605    /// invocation must change the starting directory of the *next*
2606    /// invocation, via the persisted state file — not just leave a
2607    /// same-process illusion of persistence.
2608    #[tokio::test]
2609    async fn run_via_pipes_persists_cwd_across_invocations() {
2610        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2611        let scratch = unique_temp_dir("e2e-pipes");
2612        let state_file = scratch.join("state.cwd");
2613        let target_dir = scratch.join("moved-here");
2614        std::fs::create_dir_all(&target_dir).unwrap();
2615
2616        let prev = std::env::var(CWD_STATE_FILE_OVERRIDE_ENV).ok();
2617        unsafe {
2618            std::env::set_var(CWD_STATE_FILE_OVERRIDE_ENV, &state_file);
2619        }
2620
2621        let bash = locate_bash().expect("locate_bash for test — same dependency the whole binary needs");
2622        let args = Args {
2623            tool_id: "test-cwd-persist".to_string(),
2624            b64_cmd: String::new(),
2625            block_id: None,
2626            declared_background: false,
2627        };
2628
2629        // Call 1: cd into target_dir. Nothing about this process's own env
2630        // changes — the fix must work purely through the state file.
2631        let cd_cmd = format!("cd \"{}\"", target_dir.display());
2632        let buffered1 = Arc::new(tokio::sync::Mutex::new(Vec::<u8>::new()));
2633        let status1 = run_via_pipes(&args, &cd_cmd, None, buffered1, &bash)
2634            .await
2635            .expect("call 1 should succeed");
2636        assert_eq!(status1, 0, "cd should succeed");
2637
2638        // Call 2: a fresh, unrelated invocation with no cd of its own —
2639        // `pwd` should report target_dir, proving the restored starting
2640        // directory came from call 1's persisted state, not from this
2641        // process's own (unchanged) cwd.
2642        let buffered2 = Arc::new(tokio::sync::Mutex::new(Vec::<u8>::new()));
2643        let status2 = run_via_pipes(&args, "pwd", None, buffered2.clone(), &bash)
2644            .await
2645            .expect("call 2 should succeed");
2646        assert_eq!(status2, 0);
2647
2648        unsafe {
2649            match prev {
2650                Some(v) => std::env::set_var(CWD_STATE_FILE_OVERRIDE_ENV, v),
2651                None => std::env::remove_var(CWD_STATE_FILE_OVERRIDE_ENV),
2652            }
2653        }
2654
2655        let pwd_output = String::from_utf8_lossy(&buffered2.lock().await).trim().to_string();
2656        let expected_name = target_dir
2657            .file_name()
2658            .and_then(|n| n.to_str())
2659            .unwrap()
2660            .to_string();
2661        let _ = std::fs::remove_dir_all(&scratch);
2662
2663        // Compare by trailing path component only — Windows `pwd` under Git
2664        // Bash reports an MSYS-style path (`/c/...`), not the Windows-style
2665        // path this test constructed the `cd` from.
2666        assert!(
2667            pwd_output.ends_with(&expected_name),
2668            "expected pwd output to end in {expected_name:?}, got {pwd_output:?}"
2669        );
2670    }
2671
2672    #[test]
2673    fn format_model_blob_under_cap_passes_body_through() {
2674        let body = b"hello world\n";
2675        let out = format_model_blob(body, 0, std::time::Duration::from_millis(123));
2676        assert!(out.starts_with("<exited 0 in 0.12s>\n"));
2677        assert!(out.contains("hello world"));
2678    }
2679
2680    #[test]
2681    fn access_denied_hint_on_failed_denials() {
2682        // Real outputs: the AWS CLI, CDK, EC2 and the GitHub API as an App.
2683        for output in [
2684            "An error occurred (AccessDeniedException) when calling the GetCostAndUsage operation: User: arn:aws:sts::1:assumed-role/R/s is not authorized to perform: ce:GetCostAndUsage",
2685            "User: arn:aws:sts::1:assumed-role/R/s is not authorized to perform: cloudformation:DescribeStacks on resource: arn:aws:cloudformation:us-east-1:1:stack/x/y",
2686            "An error occurred (UnauthorizedOperation) when calling the DescribeInstances operation",
2687            "An error occurred (AccessDenied) when calling the ListBuckets operation: Access Denied",
2688            "gh: Resource not accessible by integration (HTTP 403)",
2689        ] {
2690            assert_eq!(access_denied_hint(output.as_bytes(), 254), Some(ACCESS_DENIED_HINT), "{output}");
2691        }
2692    }
2693
2694    #[test]
2695    fn access_denied_hint_not_on_success_or_other_failures() {
2696        // Exit 0: a grep or a log that only mentions a denial.
2697        assert_eq!(access_denied_hint(b"docs/x.md:12: AccessDenied means escalate", 0), None);
2698        // Failed, but not a denial.
2699        assert_eq!(access_denied_hint(b"error: could not compile `x`", 101), None);
2700        assert_eq!(access_denied_hint(b"", 1), None);
2701    }
2702
2703    #[test]
2704    fn access_denied_hint_searches_output_the_blob_elides() {
2705        // The denial sits in the middle of output over the model blob's cap.
2706        let mut buf = vec![b'x'; MODEL_BLOB_HEAD_BYTES + 10];
2707        buf.extend_from_slice(b" is not authorized to perform: s3:PutObject ");
2708        buf.extend(vec![b'y'; MODEL_BLOB_TAIL_BYTES + 10]);
2709        let blob = format_model_blob(&buf, 1, std::time::Duration::from_millis(1));
2710        assert!(!blob.contains("is not authorized"), "precondition: the blob elides the denial");
2711        assert_eq!(access_denied_hint(&buf, 1), Some(ACCESS_DENIED_HINT));
2712    }
2713
2714    #[test]
2715    fn access_denied_hint_is_generic_and_says_not_to_work_around() {
2716        // AgentMux ships to every deployment: no deployment's own commands.
2717        for specific in ["secrets escalate", "gh-agent", "a5af"] {
2718            assert!(!ACCESS_DENIED_HINT.contains(specific), "{specific}");
2719        }
2720        assert!(ACCESS_DENIED_HINT.contains("don't work around it"));
2721        assert!(ACCESS_DENIED_HINT.contains("ask the human operator"));
2722        // The denial may come from an earlier step than the one that failed.
2723        assert!(ACCESS_DENIED_HINT.contains("If that denial is why the command failed"));
2724    }
2725
2726    #[test]
2727    fn format_model_blob_truncates_over_cap_at_char_boundary() {
2728        // 200KB of '🌟' (4-byte emoji) > 100KB cap → truncates.
2729        let s = "🌟".repeat(50_000);
2730        let bytes = s.as_bytes();
2731        let out = format_model_blob(bytes, 0, std::time::Duration::from_secs(1));
2732        // No replacement char `�` (U+FFFD) — boundary snap must keep
2733        // multi-byte sequences whole at the cut point.
2734        assert!(
2735            !out.contains('\u{FFFD}'),
2736            "found replacement char (lossy decode at boundary)"
2737        );
2738        assert!(out.contains("bytes elided"));
2739    }
2740
2741    #[test]
2742    fn snap_floor_is_idempotent_at_ascii() {
2743        let buf = b"abcdef";
2744        assert_eq!(snap_to_char_boundary_floor(buf, 3), 3);
2745        assert_eq!(snap_to_char_boundary_floor(buf, 0), 0);
2746        assert_eq!(snap_to_char_boundary_floor(buf, 6), 6);
2747    }
2748
2749    #[test]
2750    fn snap_floor_steps_back_off_continuation_bytes() {
2751        // "é" = [0xC3, 0xA9]; index 1 is a continuation byte.
2752        let buf = b"\xc3\xa9x";
2753        assert_eq!(snap_to_char_boundary_floor(buf, 1), 0);
2754    }
2755
2756    // ── collapse_cr tests ────────────────────────────────────────────────────
2757
2758    fn cr(input: &[u8]) -> Vec<u8> {
2759        let mut v = input.to_vec();
2760        collapse_cr(&mut v);
2761        v
2762    }
2763
2764    #[test]
2765    fn collapse_cr_no_cr_is_noop() {
2766        assert_eq!(cr(b"hello\nworld\n"), b"hello\nworld\n");
2767    }
2768
2769    #[test]
2770    fn collapse_cr_trailing_cr_preserved() {
2771        // Trailing \r must not be collapsed — may be first byte of CRLF split
2772        // across reads. The caller suppresses the partial flush in this case.
2773        assert_eq!(cr(b"frame1\r"), b"frame1\r");
2774    }
2775
2776    #[test]
2777    fn collapse_cr_crlf_within_buffer_becomes_lf() {
2778        assert_eq!(cr(b"line\r\n"), b"line\n");
2779    }
2780
2781    #[test]
2782    fn collapse_cr_crlf_straddle_resolved_on_second_call() {
2783        // Simulate two reads: first leaves trailing \r, second sees \n next.
2784        // collapse_cr is called after each extend.
2785        let mut pending = b"line1\r".to_vec();
2786        collapse_cr(&mut pending);
2787        assert_eq!(pending, b"line1\r"); // trailing \r preserved
2788        pending.extend_from_slice(b"\nmore");
2789        collapse_cr(&mut pending);
2790        assert_eq!(pending, b"line1\nmore"); // CRLF resolved, "more" accumulated
2791    }
2792
2793    #[test]
2794    fn collapse_cr_trailing_spinner_frames_collapse() {
2795        // Trailing-\r convention: "frame\r" per write, accumulated before \n.
2796        let mut pending = b"frame1\r".to_vec();
2797        collapse_cr(&mut pending);
2798        // trailing \r kept, frame1 still present
2799        assert_eq!(pending, b"frame1\r");
2800        pending.extend_from_slice(b"frame2\r");
2801        collapse_cr(&mut pending);
2802        // \r mid-buffer (pos 6, followed by 'f'): frame1 collapsed, trailing \r kept
2803        assert_eq!(pending, b"frame2\r");
2804        pending.extend_from_slice(b"done\n");
2805        collapse_cr(&mut pending);
2806        // \r mid-buffer (followed by 'd'): frame2 collapsed, done\n remains
2807        assert_eq!(pending, b"done\n");
2808    }
2809
2810    #[test]
2811    fn collapse_cr_leading_spinner_frames_collapse() {
2812        // Leading-\r convention: "\rframe" per write (npm/ora/gh style).
2813        // Case 1: non-\r line overwritten by \r-prefixed content (normal path).
2814        let mut pending = b"frame1".to_vec();
2815        collapse_cr(&mut pending);
2816        assert_eq!(pending, b"frame1"); // no \r, unchanged
2817        pending.extend_from_slice(b"\rframe2");
2818        collapse_cr(&mut pending);
2819        // "frame1\rframe2": \r at pos 6 overwrites non-\r line → "frame2"
2820        assert_eq!(pending, b"frame2");
2821        pending.extend_from_slice(b"\rdone\n");
2822        collapse_cr(&mut pending);
2823        // "frame2\rdone\n": \r at pos 6 overwrites non-\r line, done\n remains
2824        assert_eq!(pending, b"done\n");
2825    }
2826
2827    #[test]
2828    fn collapse_cr_leading_cr_multi_frame_preserves_leading_cr() {
2829        // Case 2: \r-prefixed line overwritten by another \r-prefixed line.
2830        // "\rframe1\rframe2" must collapse to "\rframe2", NOT "frame2".
2831        // Without the leading-\r preservation, the mid-buffer \r at pos 7
2832        // would truncate to line_start=0, discarding the leading \r, and
2833        // pending_cr_override can no longer detect the result as a spinner.
2834        assert_eq!(cr(b"\rframe1\rframe2"), b"\rframe2");
2835        // Three frames: only last survives, leading \r preserved.
2836        assert_eq!(cr(b"\rframe1\rframe2\rframe3"), b"\rframe3");
2837        // Normal (non-leading-\r) line overwritten: no leading \r in result.
2838        assert_eq!(cr(b"frame1\rframe2"), b"frame2");
2839    }
2840
2841    #[test]
2842    fn collapse_cr_preserves_prior_lines() {
2843        // \r only rewinds to the start of the current visual line, not past \n.
2844        assert_eq!(cr(b"line1\npartial\rreplace\n"), b"line1\nreplace\n");
2845    }
2846
2847    // ── normalize_csi_overwrites tests (spec §A2) ──────────────────────────
2848
2849    fn csi(input: &[u8]) -> Vec<u8> {
2850        let mut v = input.to_vec();
2851        let mut still_on_first_line = true;
2852        let mut at_col0 = true;
2853        normalize_csi_overwrites(&mut v, &mut still_on_first_line, &mut at_col0);
2854        v
2855    }
2856
2857    #[test]
2858    fn normalize_csi_cha_col1_becomes_cr() {
2859        assert_eq!(csi(b"progress\x1b[1Gdone"), b"progress\rdone");
2860        // Bare \x1b[G (no param) means column 1 too, per ECMA-48 default.
2861        assert_eq!(csi(b"progress\x1b[Gdone"), b"progress\rdone");
2862    }
2863
2864    #[test]
2865    fn normalize_csi_cha_other_column_left_untouched() {
2866        // Only column 1 maps cleanly onto "\r" (start of line); other
2867        // columns are intentionally out of scope (need true column
2868        // tracking) and must survive unmangled for strip_ansi to remove.
2869        let out = csi(b"progress\x1b[10Gdone");
2870        assert!(!out.contains(&b'\r'), "unrelated CHA column must not synthesize a \\r");
2871        assert_eq!(out, b"progress\x1b[10Gdone");
2872    }
2873
2874    #[test]
2875    fn normalize_csi_el_at_line_start_becomes_cr() {
2876        // EL at the very start of a chunk (nothing written on this line
2877        // yet) is safe: there's nothing to lose by treating it as \r.
2878        assert_eq!(csi(b"\x1b[2Kfresh"), b"\rfresh");
2879        assert_eq!(csi(b"\x1b[Kfresh"), b"\rfresh");
2880        assert_eq!(csi(b"\x1b[0Kfresh"), b"\rfresh");
2881        // Same, after a real newline -- also column 0.
2882        assert_eq!(csi(b"line1\n\x1b[2Kfresh"), b"line1\n\rfresh");
2883    }
2884
2885    #[test]
2886    fn normalize_csi_standalone_mid_line_el_is_left_untouched_not_synthesized_to_cr() {
2887        // reagent P1, PR #2330: EL only erases from the cursor onward and
2888        // never moves it -- it is NOT equivalent to \r when real content
2889        // ("stale") already precedes it on the line. The previous,
2890        // unconditional version turned this into "stale\rfresh", which
2891        // collapse_cr then truncated to just "fresh", silently discarding
2892        // "stale" even though EL never asked for that. Leaving it
2893        // unconverted here means strip_ansi removes only the escape bytes
2894        // afterward, preserving "stale" + "fresh" concatenated.
2895        let out = csi(b"stale\x1b[2Kfresh");
2896        assert!(!out.contains(&b'\r'), "mid-line EL must not synthesize a \\r: {out:?}");
2897        assert_eq!(out, b"stale\x1b[2Kfresh");
2898
2899        let out_k = csi(b"stale\x1b[Kfresh");
2900        assert!(!out_k.contains(&b'\r'));
2901        assert_eq!(out_k, b"stale\x1b[Kfresh");
2902    }
2903
2904    #[test]
2905    fn normalize_csi_at_col0_persists_across_calls_mid_line_el_split_across_reads_stays_untouched() {
2906        // reagent P1, PR #2330 (round 2): normalize_csi_overwrites is called
2907        // once per PTY read in the real reader loop, with at_col0 threaded
2908        // in by &mut reference across calls -- NOT reset per call. A prior
2909        // version of this fix declared at_col0 as a local reset to `true`
2910        // on every call, so a standalone EL split across two PTY reads
2911        // ("prefix" in one read, "\x1b[2Ksuffix" in the next) would see a
2912        // freshly-true at_col0 on the second call and wrongly convert the
2913        // EL to \r, reproducing the exact mid-line truncation bug the first
2914        // round of this fix addressed -- just moved from a mid-chunk split
2915        // to a cross-chunk-read split.
2916        let mut still_on_first_line = true;
2917        let mut at_col0 = true;
2918
2919        let mut first_read = b"prefix".to_vec();
2920        normalize_csi_overwrites(&mut first_read, &mut still_on_first_line, &mut at_col0);
2921        assert_eq!(first_read, b"prefix");
2922        assert!(!at_col0, "cursor is mid-line after a read with no trailing \\n/\\r");
2923
2924        let mut second_read = b"\x1b[2Ksuffix".to_vec();
2925        normalize_csi_overwrites(&mut second_read, &mut still_on_first_line, &mut at_col0);
2926        assert!(
2927            !second_read.contains(&b'\r'),
2928            "EL split across a PTY-read boundary, still mid-line, must not synthesize a \\r: {second_read:?}"
2929        );
2930        assert_eq!(second_read, b"\x1b[2Ksuffix");
2931
2932        // Simulate the reader loop's actual buffering: both reads accumulate
2933        // into one pending buffer before collapse_cr/strip_ansi run on it.
2934        let mut pending = first_read;
2935        pending.extend_from_slice(&second_read);
2936        assert_eq!(pending, b"prefix\x1b[2Ksuffix");
2937    }
2938
2939    #[test]
2940    fn normalize_csi_at_col0_persists_across_calls_leading_cr_read_then_bare_el_read_becomes_cr() {
2941        // Complement of the above: a read ending in \r (cursor genuinely at
2942        // column 0) followed by a read that STARTS with EL must still
2943        // convert -- persisted state must correctly stay `true` too, not
2944        // just correctly go false.
2945        let mut still_on_first_line = true;
2946        let mut at_col0 = true;
2947
2948        let mut first_read = b"frame1\r".to_vec();
2949        normalize_csi_overwrites(&mut first_read, &mut still_on_first_line, &mut at_col0);
2950        assert!(at_col0, "trailing \\r puts the cursor back at column 0");
2951
2952        let mut second_read = b"\x1b[2Kframe2".to_vec();
2953        normalize_csi_overwrites(&mut second_read, &mut still_on_first_line, &mut at_col0);
2954        assert_eq!(second_read, b"\rframe2");
2955    }
2956
2957    #[test]
2958    fn normalize_csi_el_immediately_after_real_cr_still_becomes_cr() {
2959        // The dominant real-world idiom (\r\x1b[2K<text>): the \r already
2960        // puts the cursor at column 0, so a following EL is genuinely
2961        // redundant and safe to also represent as \r.
2962        assert_eq!(csi(b"\r\x1b[2Kfresh"), b"\r\rfresh");
2963    }
2964
2965    #[test]
2966    fn normalize_csi_cup_home_becomes_cr_on_first_line() {
2967        // Windows ConPTY re-serializes a literal \r as CUP-to-home rather
2968        // than passing it through -- verified empirically via the e2e test
2969        // below. All the omitted-field variants mean row 1, col 1.
2970        assert_eq!(csi(b"progress\x1b[Hdone"), b"progress\rdone");
2971        assert_eq!(csi(b"progress\x1b[1Hdone"), b"progress\rdone");
2972        assert_eq!(csi(b"progress\x1b[1;1Hdone"), b"progress\rdone");
2973        assert_eq!(csi(b"progress\x1b[;1Hdone"), b"progress\rdone");
2974        assert_eq!(csi(b"progress\x1b[1;Hdone"), b"progress\rdone");
2975    }
2976
2977    #[test]
2978    fn normalize_csi_cup_other_position_left_untouched() {
2979        // Row/col other than 1,1 isn't "start of line" and must survive
2980        // unmangled.
2981        let out = csi(b"progress\x1b[2;1Hdone");
2982        assert!(!out.contains(&b'\r'));
2983        assert_eq!(out, b"progress\x1b[2;1Hdone");
2984    }
2985
2986    #[test]
2987    fn normalize_csi_cup_home_ignored_once_output_has_scrolled_past_first_line() {
2988        // Once real multi-line output exists, \x1b[H means "go back to the
2989        // FIRST of several lines" (a multi-line redraw, spec §A3,
2990        // deliberately out of scope) -- must NOT be treated as "restart
2991        // the current line", which would corrupt the multi-line content.
2992        let mut v = b"line1\nline2\n\x1b[Hline1-updated".to_vec();
2993        let mut still_on_first_line = true;
2994        let mut at_col0 = true;
2995        normalize_csi_overwrites(&mut v, &mut still_on_first_line, &mut at_col0);
2996        assert!(!still_on_first_line, "must flip false after the first \\n");
2997        assert!(
2998            !v.contains(&b'\r'),
2999            "CUP-home after multi-line output must not synthesize a \\r: {:?}",
3000            String::from_utf8_lossy(&v)
3001        );
3002        assert_eq!(v, b"line1\nline2\n\x1b[Hline1-updated");
3003    }
3004
3005    #[test]
3006    fn normalize_csi_cub_left_untouched() {
3007        // CUB (cursor-back N) is documented out-of-scope for this pass
3008        // (spec §A2) -- needs true per-column tracking. Must survive
3009        // unmangled here so strip_ansi still strips it as a whole sequence
3010        // afterward (not left as garbage).
3011        let out = csi(b"progress\x1b[5Ddone");
3012        assert!(!out.contains(&b'\r'));
3013        assert_eq!(out, b"progress\x1b[5Ddone");
3014    }
3015
3016    #[test]
3017    fn normalize_csi_dominant_cr_plus_el_idiom_is_redundant_but_correct() {
3018        // The overwhelmingly common real-world idiom: \r\x1b[2K<text>. The
3019        // \r is already present; normalize_csi_overwrites additionally
3020        // turning \x1b[2K into a second \r is harmless -- collapse_cr
3021        // treats consecutive \r as idempotent resets to the same line
3022        // start.
3023        let normalized = csi(b"\r\x1b[2Knew frame");
3024        let mut pending = normalized;
3025        collapse_cr(&mut pending);
3026        assert_eq!(pending, b"\rnew frame");
3027    }
3028
3029    #[test]
3030    fn normalize_csi_composes_with_collapse_cr_for_a_full_overwrite() {
3031        // End-to-end of the two functions together, as the reader loop
3032        // actually calls them: a progress line first written plainly, then
3033        // rewritten via CHA(1) on the next PTY read, must collapse exactly
3034        // like a \r-based rewrite would.
3035        let mut pending = b"Downloading 10%".to_vec();
3036        collapse_cr(&mut pending); // no-op, no \r yet
3037
3038        let mut next_chunk = b"\x1b[1GDownloading 45%".to_vec();
3039        let mut still_on_first_line = true;
3040        let mut at_col0 = true;
3041        normalize_csi_overwrites(&mut next_chunk, &mut still_on_first_line, &mut at_col0);
3042        pending.extend_from_slice(&next_chunk);
3043        collapse_cr(&mut pending);
3044
3045        assert_eq!(pending, b"Downloading 45%");
3046    }
3047
3048    /// Verify that the quiet-window semantics are: hold when pending ends
3049    /// with \r; flush when it doesn't. These are unit tests of the policy
3050    /// (not of the reader loop directly) — they encode the contract so a
3051    /// future refactor can't accidentally revert to pop-and-flush.
3052    #[test]
3053    fn quiet_window_holds_when_trailing_cr() {
3054        // A spinner frame "⠋ Loading...\r" should NOT be flushed by the
3055        // quiet-window — the \r signals that the next frame will overwrite.
3056        let pending = b"Loading...\r".to_vec();
3057        assert!(
3058            pending.last() == Some(&b'\r'),
3059            "quiet-window must hold pending ending with \\r"
3060        );
3061        // Simulate what collapse_cr does when the next frame arrives.
3062        let mut combined = pending.clone();
3063        combined.extend_from_slice(b"Done!\n");
3064        collapse_cr(&mut combined);
3065        assert_eq!(combined, b"Done!\n");
3066    }
3067
3068    #[test]
3069    fn quiet_window_defers_once_when_no_leading_or_trailing_cr() {
3070        // "Building..." (no \r at either end) is a candidate for A1's
3071        // one-tick speculative defer, not an immediate flush — see
3072        // docs/specs/SPEC_TOOL_LOG_UNIVERSAL_ANIMATION_COLLAPSE_2026_07_27.md §A1.
3073        let pending = b"Building...".to_vec();
3074        assert!(
3075            pending.first() != Some(&b'\r') && pending.last() != Some(&b'\r'),
3076            "quiet-window must defer (not flush) pending with no leading/trailing \\r"
3077        );
3078    }
3079
3080    /// A1: the exact real-world bug this closes — a static label is
3081    /// printed once with no `\r`, THEN (after the label has already been
3082    /// speculatively deferred) a `\r`-prefixed overwrite arrives. Before
3083    /// A1, the label would already have been flushed as its own permanent
3084    /// LineEvent by the time the overwrite showed up, producing two lines
3085    /// instead of one settled line.
3086    #[test]
3087    fn a1_deferred_line_merges_with_a_following_cr_prefixed_overwrite() {
3088        // What deferred_pending holds after tick 1 (no \r either end).
3089        let deferred = b"Installing deps...".to_vec();
3090        // What arrives on the next read: a \r-prefixed overwrite.
3091        let next_chunk = b"\rInstalling deps... done\n".to_vec();
3092        // Mirrors the reader's merge step: prepend deferred, let
3093        // collapse_cr do the rest.
3094        let mut combined = deferred;
3095        combined.extend_from_slice(&next_chunk);
3096        collapse_cr(&mut combined);
3097        assert_eq!(
3098            combined, b"Installing deps... done\n",
3099            "deferred label + \\r-overwrite must collapse to ONE line, not two"
3100        );
3101    }
3102
3103    /// A1: the deferred line must NOT be merged with unrelated content
3104    /// that arrives without a leading `\r` — that's two genuinely
3105    /// different lines and must stay two LineEvents.
3106    #[test]
3107    fn a1_deferred_line_is_not_merged_with_unrelated_non_cr_content() {
3108        let deferred = b"Installing deps...".to_vec();
3109        let next_chunk = b"Running tests...\n".to_vec();
3110        // Mirrors the reader's policy: no leading \r on the new chunk means
3111        // flush `deferred` as its own event, then start fresh with the new
3112        // chunk (never concatenate the two).
3113        assert_ne!(next_chunk.first(), Some(&b'\r'));
3114        let mut fresh = Vec::new();
3115        fresh.extend_from_slice(&next_chunk);
3116        collapse_cr(&mut fresh);
3117        assert_eq!(fresh, b"Running tests...\n");
3118        // The deferred line, flushed separately, is untouched by the above.
3119        assert_eq!(deferred, b"Installing deps...");
3120    }
3121
3122    #[test]
3123    fn eof_strips_trailing_cr() {
3124        // At EOF, a dangling \r (stream ended mid-spinner or mid-CRLF) is
3125        // stripped before the final flush so the rendered chunk is clean.
3126        let mut pending = b"partial\r".to_vec();
3127        if pending.last() == Some(&b'\r') {
3128            pending.pop();
3129        }
3130        assert_eq!(pending, b"partial");
3131    }
3132
3133    #[test]
3134    fn locate_bash_via_explicit_env() {
3135        // Use the binary that runs the test as a stand-in for "bash"
3136        // — we only assert that the override is honored, not that the
3137        // path is a real bash. ENV_LOCK serializes against any future
3138        // env-touching test (and against `BASH` reads in the same fn).
3139        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3140        let exe = std::env::current_exe().expect("current_exe");
3141        let prev_agentmux_bash = std::env::var("AGENTMUX_BASH").ok();
3142        let prev_bash = std::env::var("BASH").ok();
3143        // Clear BASH so the override path is the one being asserted.
3144        unsafe {
3145            std::env::set_var("AGENTMUX_BASH", &exe);
3146            std::env::remove_var("BASH");
3147        }
3148        let found = locate_bash().expect("locate_bash with override");
3149        assert_eq!(found, exe);
3150        unsafe {
3151            match prev_agentmux_bash {
3152                Some(v) => std::env::set_var("AGENTMUX_BASH", v),
3153                None => std::env::remove_var("AGENTMUX_BASH"),
3154            }
3155            if let Some(v) = prev_bash {
3156                std::env::set_var("BASH", v);
3157            }
3158        }
3159    }
3160
3161    // ── idle_kill_timeout tests ─────────────────────────────────────────────
3162    // RETRO_BASHWRAP_PAGER_HANG_LEAK_2026_07_14.md: these cover the env-var
3163    // override for the idle-kill timeout. The kill/PTY behavior itself isn't
3164    // unit-testable without a real PTY (see the retro's verification section
3165    // for the manual repro this was checked against instead).
3166
3167    #[test]
3168    fn idle_kill_timeout_defaults_when_unset() {
3169        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3170        let prev = std::env::var("AGENTMUX_BASHWRAP_IDLE_TIMEOUT_SECS").ok();
3171        unsafe {
3172            std::env::remove_var("AGENTMUX_BASHWRAP_IDLE_TIMEOUT_SECS");
3173        }
3174        assert_eq!(idle_kill_timeout(), DEFAULT_IDLE_KILL_TIMEOUT);
3175        unsafe {
3176            if let Some(v) = prev {
3177                std::env::set_var("AGENTMUX_BASHWRAP_IDLE_TIMEOUT_SECS", v);
3178            }
3179        }
3180    }
3181
3182    #[test]
3183    fn idle_kill_timeout_honors_env_override() {
3184        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3185        let prev = std::env::var("AGENTMUX_BASHWRAP_IDLE_TIMEOUT_SECS").ok();
3186        unsafe {
3187            std::env::set_var("AGENTMUX_BASHWRAP_IDLE_TIMEOUT_SECS", "5");
3188        }
3189        assert_eq!(idle_kill_timeout(), Duration::from_secs(5));
3190        unsafe {
3191            match prev {
3192                Some(v) => std::env::set_var("AGENTMUX_BASHWRAP_IDLE_TIMEOUT_SECS", v),
3193                None => std::env::remove_var("AGENTMUX_BASHWRAP_IDLE_TIMEOUT_SECS"),
3194            }
3195        }
3196    }
3197
3198    #[test]
3199    fn idle_kill_timeout_falls_back_on_unparseable_value() {
3200        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3201        let prev = std::env::var("AGENTMUX_BASHWRAP_IDLE_TIMEOUT_SECS").ok();
3202        unsafe {
3203            std::env::set_var("AGENTMUX_BASHWRAP_IDLE_TIMEOUT_SECS", "not-a-number");
3204        }
3205        assert_eq!(idle_kill_timeout(), DEFAULT_IDLE_KILL_TIMEOUT);
3206        unsafe {
3207            match prev {
3208                Some(v) => std::env::set_var("AGENTMUX_BASHWRAP_IDLE_TIMEOUT_SECS", v),
3209                None => std::env::remove_var("AGENTMUX_BASHWRAP_IDLE_TIMEOUT_SECS"),
3210            }
3211        }
3212    }
3213
3214    /// End-to-end proof that the idle-kill mechanism actually fires AND
3215    /// reaches the whole process tree, not just the direct PTY child.
3216    ///
3217    /// Uses two backgrounded `sleep` grandchildren (`bash -c '{ sleep
3218    /// <marker>1 & sleep <marker>2 & wait; } </dev/null'`) rather than a
3219    /// single-process command: killing only the direct `bash` child (what
3220    /// `ChildKiller::kill()` alone does — see `kill_process_tree`'s doc
3221    /// comment) would leave these two running as orphans, exactly
3222    /// reproducing the leak one process removed instead of fixing it
3223    /// (reagent P1, PR #2156). The marker values are unique per test run
3224    /// (derived from the current PID) so a leftover orphan from a
3225    /// *previous* failed run of this same test, or an unrelated `sleep`
3226    /// elsewhere on a shared dev machine, can't produce a false pass.
3227    ///
3228    /// The `sleep <marker>` argument doubles as the zero-PTY-output
3229    /// condition this exists to catch (a clean, portable stand-in for "any
3230    /// command silently blocked forever," the same shape as the pager hang
3231    /// — see docs/retro/RETRO_BASHWRAP_PAGER_HANG_LEAK_2026_07_14.md).
3232    /// Without the fix, this test would hang until the outer
3233    /// `tokio::time::timeout` fires and fails it.
3234    #[tokio::test]
3235    async fn run_via_pty_kills_idle_child_and_returns_promptly() {
3236        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3237        let prev = std::env::var("AGENTMUX_BASHWRAP_IDLE_TIMEOUT_SECS").ok();
3238        unsafe {
3239            std::env::set_var("AGENTMUX_BASHWRAP_IDLE_TIMEOUT_SECS", "1");
3240        }
3241
3242        let bash = locate_bash().expect("locate_bash for test — same dependency the whole binary needs");
3243        let pty_system = native_pty_system();
3244        let pair = match pty_system.openpty(PtySize {
3245            rows: 24,
3246            cols: 80,
3247            pixel_width: 0,
3248            pixel_height: 0,
3249        }) {
3250            Ok(p) => p,
3251            Err(e) => {
3252                unsafe {
3253                    match prev {
3254                        Some(v) => std::env::set_var("AGENTMUX_BASHWRAP_IDLE_TIMEOUT_SECS", v),
3255                        None => std::env::remove_var("AGENTMUX_BASHWRAP_IDLE_TIMEOUT_SECS"),
3256                    }
3257                }
3258                eprintln!("skipping: PTY unavailable in this environment: {e}");
3259                return;
3260            }
3261        };
3262
3263        let args = Args {
3264            tool_id: "test-idle-kill".to_string(),
3265            b64_cmd: String::new(),
3266            block_id: None,
3267            declared_background: false,
3268        };
3269        // `Mutex` in this module scope resolves to `std::sync::Mutex`
3270        // (shadowed for `ENV_LOCK` above) — `run_via_pty` needs the async
3271        // `tokio::sync::Mutex` its `buffered: Arc<Mutex<Vec<u8>>>` param
3272        // expects, so spell it out.
3273        let buffered = Arc::new(tokio::sync::Mutex::new(Vec::<u8>::new()));
3274
3275        // Unique-per-run, but still a valid numeric duration for `sleep`
3276        // (GNU coreutils sleep accepts fractional seconds and arbitrarily
3277        // large values). A non-numeric marker string would make `sleep`
3278        // error out instantly instead of blocking — caught by this test's
3279        // own first run failing with exit code 0 (~0.1s elapsed) instead
3280        // of the expected kill.
3281        //
3282        // The `100` tag (vs. `bashwrap_binary_idle_kill_cleans_up_full_process_tree`'s
3283        // `300`) is NOT cosmetic: `std::process::id()` is the SAME across
3284        // every test in this binary (they're threads in one process, not
3285        // separate processes), and Rust's test harness runs tests in
3286        // parallel by default — so a bare `pid.to_string()` marker would
3287        // let this test's sleep processes get miscounted as "survivors" by
3288        // that other test's WMI substring search (and vice versa) if both
3289        // happen to overlap in time. Found this the hard way: the other
3290        // test failed with "3 survivors" once this test's marker collided
3291        // with its own. The tag makes the two tests' marker strings
3292        // non-overlapping substrings of each other.
3293        let pid = std::process::id();
3294        let marker = format!("{pid}100");
3295        let command = format!("sleep {marker}.001 & sleep {marker}.002 & wait");
3296
3297        let start = std::time::Instant::now();
3298        let result = tokio::time::timeout(
3299            Duration::from_secs(20),
3300            run_via_pty(&args, &command, None, buffered.clone(), &bash, pair),
3301        )
3302        .await
3303        .expect("run_via_pty must not hang past the outer test timeout — idle-kill should have fired well before this")
3304        .expect("run_via_pty should return Ok even when it had to kill the child");
3305
3306        // The exact code is platform/kill-mechanism-specific (portable-pty's
3307        // Windows ChildKiller calls TerminateProcess with code 127, but the
3308        // wrapping `bash -c` process's own reported code after being killed
3309        // is what we actually observe here, not necessarily 127 — verified
3310        // empirically as 1 on this Windows dev machine). `124` is only the
3311        // fallback sentinel used if the wait task doesn't resolve within
3312        // the post-kill grace period at all. What actually matters: it's
3313        // never the clean-success `0`, and (checked below) it returns
3314        // promptly instead of hanging.
3315        assert_ne!(
3316            result, 0,
3317            "an idle-killed child must not report a clean success exit code"
3318        );
3319        assert!(
3320            start.elapsed() < Duration::from_secs(15),
3321            "should return well within the 1s idle timeout + grace periods, not the 20s outer bound — got {:?}",
3322            start.elapsed()
3323        );
3324
3325        let blob = buffered.lock().await;
3326        assert!(
3327            String::from_utf8_lossy(&blob).contains("terminated automatically"),
3328            "model-visible blob should explain why the command was cut short, got: {:?}",
3329            String::from_utf8_lossy(&blob)
3330        );
3331        drop(blob);
3332
3333        // NOTE on what this test does NOT prove: calling `run_via_pty`
3334        // in-process (as a library function inside `cargo test`'s own
3335        // process) returning does not, by itself, guarantee every
3336        // descendant is gone yet — see
3337        // `bashwrap_binary_idle_kill_cleans_up_full_process_tree` below for
3338        // why, and for the test that actually proves full-tree cleanup.
3339        let _ = &marker; // used by the binary-level test below, not here
3340
3341        unsafe {
3342            match prev {
3343                Some(v) => std::env::set_var("AGENTMUX_BASHWRAP_IDLE_TIMEOUT_SECS", v),
3344                None => std::env::remove_var("AGENTMUX_BASHWRAP_IDLE_TIMEOUT_SECS"),
3345            }
3346        }
3347    }
3348
3349    /// Reagent P1 follow-up, PR #2156: `idle_rx` resolves (with `Err`) not
3350    /// only when `pty_reader_loop` really signals an idle timeout, but also
3351    /// whenever it returns normally (EOF on a fast, successful command) and
3352    /// drops the still-unused `idle_tx`. The original `tokio::select! { _ =
3353    /// idle_rx => {...} }` treated BOTH cases identically, so a fast
3354    /// command completing could race `wait_task` and get spuriously
3355    /// killed + tagged "terminated automatically" even though it ran to
3356    /// completion normally. Fixed by branching on `idle_signal.is_err()`
3357    /// inside that arm instead of ignoring the value.
3358    ///
3359    /// A single run of a fast command isn't a reliable regression test for
3360    /// a race in general, so this runs `echo` 30 times with a very short
3361    /// idle timeout to widen the chance of catching it.
3362    ///
3363    /// Honesty check performed while writing this test: temporarily forced
3364    /// the OLD (buggy) unconditional-kill behavior back in and re-ran this
3365    /// exact test — it still passed 30/30. On this machine, `wait_task`
3366    /// (`child.wait()` resolving) appears to reliably win the race against
3367    /// `pty_reader_loop`'s EOF-then-drop for a trivially fast command like
3368    /// `echo hello`, so this test does NOT reliably fail on the unfixed
3369    /// code and can't be trusted as sole proof the bug is gone. muxreview's
3370    /// code-level analysis of the race is still correct — a oneshot
3371    /// receiver genuinely does resolve identically for "sent" and
3372    /// "dropped without sending," and the fix (branching on
3373    /// `idle_signal.is_err()`) is the structurally correct response
3374    /// regardless of whether this specific test can force the window open.
3375    /// The scenario muxreview named as the realistic trigger — blocking
3376    /// thread-pool contention from concurrent bashwrap invocations —
3377    /// wasn't reproduced here; doing so reliably would need deliberately
3378    /// saturating tokio's blocking pool, not attempted given time spent on
3379    /// this investigation already. This test is kept as basic coverage of
3380    /// the fast-success path (asserts real output, exit 0, no spurious
3381    /// diagnostic) — a real regression guard for *some* bugs in this area,
3382    /// just not proven to be one for this exact race.
3383    #[tokio::test]
3384    async fn run_via_pty_does_not_misclassify_fast_success_as_idle_timeout() {
3385        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3386        // reagentx P2 on PR #2569 (re-review): a manual save-now/restore-
3387        // at-the-end dance doesn't run on an early exit — the `panic!`s in
3388        // the loop below (via `unwrap_or_else`) and the `assert_eq!`/
3389        // `assert!` calls can all unwind straight past the restore code
3390        // that used to sit at the bottom of this function, leaking this env
3391        // var's override to every subsequent test in the process. Same bug
3392        // class already fixed via `EnvVarGuard` (defined above, used
3393        // elsewhere in this file for exactly this reason) for the
3394        // neighboring `a1_e2e` test — applied here too.
3395        //
3396        // 1s was tight enough that a loaded CI runner's PTY-reader-thread
3397        // scheduling delay alone could exceed it before `echo hello` ever
3398        // produced output — a genuine idle-kill by the test's own
3399        // (deliberately short) timeout, not the idle_rx/wait_task
3400        // misclassification race this test exists to catch. 3s keeps the
3401        // race window this test targets while giving scheduling enough
3402        // slack not to trip the timeout on its own.
3403        let _idle_timeout_guard = EnvVarGuard::set("AGENTMUX_BASHWRAP_IDLE_TIMEOUT_SECS", "3");
3404
3405        let bash = locate_bash().expect("locate_bash for test — same dependency the whole binary needs");
3406
3407        for i in 0..30 {
3408            let pty_system = native_pty_system();
3409            let pair = match pty_system.openpty(PtySize {
3410                rows: 24,
3411                cols: 80,
3412                pixel_width: 0,
3413                pixel_height: 0,
3414            }) {
3415                Ok(p) => p,
3416                Err(e) => {
3417                    eprintln!("skipping: PTY unavailable in this environment: {e}");
3418                    break;
3419                }
3420            };
3421            let args = Args {
3422                tool_id: format!("test-fast-success-{i}"),
3423                b64_cmd: String::new(),
3424                block_id: None,
3425                declared_background: false,
3426            };
3427            let buffered = Arc::new(tokio::sync::Mutex::new(Vec::<u8>::new()));
3428
3429            let result = tokio::time::timeout(
3430                Duration::from_secs(10),
3431                run_via_pty(&args, "echo hello", None, buffered.clone(), &bash, pair),
3432            )
3433            .await
3434            .unwrap_or_else(|_| panic!("iteration {i}: run_via_pty must not hang on a trivial fast command"))
3435            .unwrap_or_else(|e| panic!("iteration {i}: run_via_pty errored: {e}"));
3436
3437            assert_eq!(
3438                result, 0,
3439                "iteration {i}: a fast, genuinely successful `echo` must report clean exit 0, \
3440                 not get spuriously classified as idle-killed by the idle_rx/wait_task race"
3441            );
3442            let blob = String::from_utf8_lossy(&buffered.lock().await).into_owned();
3443            assert!(
3444                !blob.contains("terminated automatically"),
3445                "iteration {i}: fast successful command must not carry the idle-kill \
3446                 diagnostic — got blob: {blob:?}"
3447            );
3448            assert!(
3449                blob.contains("hello"),
3450                "iteration {i}: expected the command's real output in the blob, got: {blob:?}"
3451            );
3452        }
3453    }
3454
3455    /// Issue #2491: a declared-background call (`declared_background:
3456    /// true`, threaded from the CLI's own `run_in_background: true`) must
3457    /// be exempt from the idle-kill safety net — it may legitimately go
3458    /// silent past the timeout window (a GUI window, a dev server) without
3459    /// that meaning it's stuck. Uses a 1s idle timeout and a command silent
3460    /// for 3s (3x the timeout) so a non-exempt call would provably be
3461    /// killed well before this test's own bound — proves the exemption is
3462    /// doing something, not merely that idle-kill didn't happen to fire.
3463    #[tokio::test]
3464    async fn run_via_pty_skips_idle_kill_when_declared_background() {
3465        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3466        let _idle_timeout_guard = EnvVarGuard::set("AGENTMUX_BASHWRAP_IDLE_TIMEOUT_SECS", "1");
3467
3468        let bash = locate_bash().expect("locate_bash for test — same dependency the whole binary needs");
3469        let pty_system = native_pty_system();
3470        let pair = match pty_system.openpty(PtySize {
3471            rows: 24,
3472            cols: 80,
3473            pixel_width: 0,
3474            pixel_height: 0,
3475        }) {
3476            Ok(p) => p,
3477            Err(e) => {
3478                eprintln!("skipping: PTY unavailable in this environment: {e}");
3479                return;
3480            }
3481        };
3482        let args = Args {
3483            tool_id: "test-declared-background".to_string(),
3484            b64_cmd: String::new(),
3485            block_id: None,
3486            declared_background: true,
3487        };
3488        let buffered = Arc::new(tokio::sync::Mutex::new(Vec::<u8>::new()));
3489
3490        let result = tokio::time::timeout(
3491            Duration::from_secs(10),
3492            run_via_pty(&args, "sleep 3 && echo done", None, buffered.clone(), &bash, pair),
3493        )
3494        .await
3495        .expect("a declared-background call must not hang past the outer test timeout")
3496        .expect("run_via_pty should return Ok");
3497
3498        assert_eq!(
3499            result, 0,
3500            "a declared-background command silent past the idle-timeout window must still run \
3501             to completion, not get idle-killed — exit code was {result}"
3502        );
3503        let blob = String::from_utf8_lossy(&buffered.lock().await).into_owned();
3504        assert!(
3505            blob.contains("done"),
3506            "expected the command's real output after surviving the idle window, got: {blob:?}"
3507        );
3508        assert!(
3509            !blob.contains("terminated automatically"),
3510            "declared-background call must never carry the idle-kill diagnostic — got: {blob:?}"
3511        );
3512    }
3513
3514    /// End-to-end proof of A1 against a REAL PTY + bash, reproducing the
3515    /// exact bug SPEC_TOOL_LOG_UNIVERSAL_ANIMATION_COLLAPSE_2026_07_27.md
3516    /// closes: a static label printed with no `\r`, a pause past the
3517    /// quiet-window, then a `\r`-prefixed overwrite. Before A1, the label
3518    /// was already flushed as its own permanent LineEvent by the time the
3519    /// overwrite arrived, so the model-visible blob contained the label
3520    /// TWICE ("Installing deps..." followed by "Installing deps... done").
3521    ///
3522    /// The pause is deliberately tuned to land INSIDE A1's one-extra-tick
3523    /// window: greater than one `FLUSH_QUIET_WINDOW` (50ms, so the label is
3524    /// actually deferred rather than merged inline on the same read) but
3525    /// less than two (100ms, the point at which A1 gives up waiting and
3526    /// flushes the deferred line anyway per the spec's own "no added
3527    /// latency beyond one extra tick" scope). 70ms centers comfortably
3528    /// inside that ~50ms window in isolation, but real OS thread scheduling
3529    /// still occasionally pushes either boundary under load (observed: this
3530    /// test flaked ~1 run in 6 in the full-suite parallel run, though never
3531    /// in isolation) — `FLUSH_QUIET_WINDOW` is a hardcoded const with no
3532    /// test-only override, so retrying the wall-clock scenario a few times
3533    /// (same "widen the chance" philosophy already used by
3534    /// `run_via_pty_does_not_misclassify_fast_success_as_idle_timeout`
3535    /// above, just applied to reliably demonstrate a real behavior instead
3536    /// of reliably catching a rare regression) is more honest than either a
3537    /// single flaky assertion or silently loosening what's being proven.
3538    #[tokio::test]
3539    async fn a1_e2e_static_label_then_delayed_cr_overwrite_collapses_to_one_line() {
3540        let bash = locate_bash().expect("locate_bash for test — same dependency the whole binary needs");
3541        let command = "printf 'Installing deps...'; sleep 0.07; printf '\\rInstalling deps... done\\n'";
3542
3543        // reagentx P2 on PR #2569: a hung/slow attempt used to be retried by
3544        // wrapping `run_via_pty` in an EXTERNAL `tokio::time::timeout` and
3545        // `continue`ing past the deadline. That leaked a process: dropping
3546        // the outer future does NOT abort `wait_task`/`pty_reader_loop`
3547        // (independent `spawn_blocking` tasks already running by the time
3548        // the timeout could preempt anything), so the underlying bash/PTY
3549        // child from the timed-out attempt kept running detached — up to
3550        // MAX_ATTEMPTS - 1 of them per flaky run, none ever cleaned up.
3551        //
3552        // `run_via_pty` already has a CORRECT internal kill path for exactly
3553        // "this attempt is stuck": its idle-timeout branch tree-kills the
3554        // child (via `kill_process_tree` + `clone_killer`) and returns exit
3555        // code 124 BEFORE returning at all. Rather than fight that with a
3556        // second, incomplete external timeout, lower its threshold for this
3557        // test (well above what this ~70ms command legitimately needs, but
3558        // short enough to bound a genuinely stuck attempt) and let
3559        // `run_via_pty` clean up after itself before this test ever sees
3560        // control back — no external timeout, no orphan.
3561        let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3562        // reagentx P2 on PR #2569 (re-review): a manual save-now/restore-
3563        // at-the-end dance doesn't run on an early exit — `Err(e) =>
3564        // panic!(...)` and the `assert_eq!` below can both unwind straight
3565        // out of the loop, skipping the restore and leaving this env var
3566        // permanently set to "15" for the rest of the test binary process.
3567        // `EnvVarGuard` (used elsewhere in this file for exactly this
3568        // reason) restores it in `Drop`, which runs on every exit path
3569        // including a panic unwind.
3570        let _idle_timeout_guard = EnvVarGuard::set("AGENTMUX_BASHWRAP_IDLE_TIMEOUT_SECS", "15");
3571
3572        const MAX_ATTEMPTS: u32 = 5;
3573        // reagentx P2 on PR #2559 (re-review): must be updated on EVERY
3574        // retry path, not just the occurrence-mismatch one — the idle-kill
3575        // branch below used to leave this at its initial empty value, so if
3576        // every attempt got idle-killed the final panic misleadingly
3577        // reported an empty blob instead of indicating a timeout occurred.
3578        let mut last_outcome = String::new();
3579        let mut collapsed = false;
3580        let mut skipped = false;
3581        for attempt in 1..=MAX_ATTEMPTS {
3582            let pty_system = native_pty_system();
3583            let pair = match pty_system.openpty(PtySize {
3584                rows: 24,
3585                cols: 80,
3586                pixel_width: 0,
3587                pixel_height: 0,
3588            }) {
3589                Ok(p) => p,
3590                Err(e) => {
3591                    eprintln!("skipping: PTY unavailable in this environment: {e}");
3592                    skipped = true;
3593                    break;
3594                }
3595            };
3596            let args = Args {
3597                tool_id: format!("test-a1-e2e-{attempt}"),
3598                b64_cmd: String::new(),
3599                block_id: None,
3600                declared_background: false,
3601            };
3602            let buffered = Arc::new(tokio::sync::Mutex::new(Vec::<u8>::new()));
3603
3604            let result = match run_via_pty(&args, command, None, buffered.clone(), &bash, pair).await {
3605                Ok(r) => r,
3606                Err(e) => panic!("run_via_pty should return Ok, got: {e}"),
3607            };
3608            if result == 124 {
3609                // Idle-killed by run_via_pty's own internal mechanism — the
3610                // process tree is already cleaned up by the time this
3611                // returns, nothing left behind to leak.
3612                last_outcome = format!("attempt {attempt}/{MAX_ATTEMPTS}: idle-killed by run_via_pty \
3613                    (likely a loaded CI runner delaying process spawn/scheduling), no output captured");
3614                eprintln!(
3615                    "{last_outcome} — retrying, already cleaned up"
3616                );
3617                continue;
3618            }
3619            if result != 0 {
3620                // Retried for the same reason 124 is, immediately above: on a
3621                // loaded runner bash can fail to start cleanly, and from here
3622                // that is indistinguishable from being idle-killed.
3623                //
3624                // This was `assert_eq!(result, 0, "command should exit cleanly")`,
3625                // which failed the whole test on attempt 1 and bypassed
3626                // MAX_ATTEMPTS entirely. The retry loop existed but covered only
3627                // two of the three flaky outcomes, and the one it missed is the
3628                // one that fired — exit 1, failing a docs-only PR that touched
3629                // no Rust at all (issue #2916). A retry loop that does not cover
3630                // the case that actually happens is decoration.
3631                //
3632                // A genuinely broken build still fails: every one of
3633                // MAX_ATTEMPTS must exit nonzero, and the panic below reports
3634                // the last outcome, which a bare assert_eq! never did.
3635                last_outcome = format!(
3636                    "attempt {attempt}/{MAX_ATTEMPTS}: command exited {result}, expected 0 \
3637                     (likely a loaded CI runner failing to spawn bash cleanly)"
3638                );
3639                eprintln!("{last_outcome} — retrying");
3640                continue;
3641            }
3642
3643            let blob = String::from_utf8_lossy(&buffered.lock().await).into_owned();
3644            let occurrences = blob.matches("Installing deps").count();
3645            if occurrences == 1 && blob.contains("Installing deps... done") {
3646                collapsed = true; // demonstrated: the collapse happened as designed
3647                break;
3648            }
3649            last_outcome = format!(
3650                "attempt {attempt}/{MAX_ATTEMPTS}: expected 1 occurrence + the settled frame, \
3651                 got {occurrences} occurrence(s) — blob: {blob:?}"
3652            );
3653            eprintln!(
3654                "{last_outcome} (retrying: real OS scheduling can occasionally push the 70ms \
3655                 pause outside A1's ~50-100ms defer window)"
3656            );
3657        }
3658
3659        if skipped || collapsed {
3660            return;
3661        }
3662        panic!(
3663            "the static label + its delayed \\r-overwrite must collapse to ONE occurrence across \
3664             {MAX_ATTEMPTS} attempts — last outcome: {last_outcome}"
3665        );
3666    }
3667}