Skip to main content

winx_code_agent/tools/
bash_command.rs

1//! Implementation of the `BashCommand` tool with WCGW parity.
2//!
3//! This module provides the implementation for the `BashCommand` tool, which is used
4//! to execute shell commands, check command status, and interact with the shell.
5//! Matches the behavior of wcgw Python implementation 1:1.
6
7use anyhow::Context as AnyhowContext;
8use rand::RngExt;
9use regex::Regex;
10use std::collections::HashMap;
11use std::fmt::Write as FmtWrite;
12use std::path::{Path, PathBuf};
13use std::sync::{Arc, Mutex as StdMutex};
14use std::time::{Duration, Instant};
15use tokio::sync::Mutex;
16use tokio::time::sleep;
17use tracing::{debug, error, info, warn};
18
19use crate::errors::{Result, WinxError};
20use crate::state::bash_state::BashState;
21use crate::state::live_terminal::ScreenUpdate;
22use crate::state::pty::PtyShell;
23use crate::state::terminal::{render_terminal_output, strip_ansi_codes};
24use crate::types::{normalize_thread_id, BashCommand, BashCommandAction, SpecialKey};
25
26type SharedPtyShell = Arc<Mutex<Option<PtyShell>>>;
27
28// ==================== WCGW-Style Constants ====================
29
30/// Default timeout for command execution (seconds) - matches WCGW Python Config.timeout
31const DEFAULT_TIMEOUT: f64 = 5.0;
32
33/// Extended timeout while output is still being produced - matches WCGW Python `Config.timeout_while_output`
34const TIMEOUT_WHILE_OUTPUT: f64 = 20.0;
35
36/// Number of iterations to wait without new output before giving up - matches WCGW Python `Config.output_wait_patience`
37const OUTPUT_WAIT_PATIENCE: i32 = 3;
38
39/// Async poll interval for adaptive output reads. We drain whatever the reader
40/// thread has queued (non-blocking), release the shell lock, then `await` this
41/// long — yielding the executor instead of pinning it on a blocking sleep.
42const POLL_INTERVAL_MS: u64 = 20;
43
44/// Grace period after the prompt is seen, to capture bytes that land just after
45/// it (mirrors the old `read_output` post-prompt drain) — awaited, not blocked.
46const POST_PROMPT_DRAIN_MS: u64 = 100;
47
48/// Chunk size for sending commands (characters) - matches WCGW Python (64 chars)
49const COMMAND_CHUNK_SIZE: usize = 64;
50
51/// Chunk size for sending text input (characters) - matches WCGW Python (128 chars)
52const TEXT_CHUNK_SIZE: usize = 128;
53
54/// Cheap byte-level safety net. We never even consider token counting if the
55/// raw payload is smaller than this — tokenizing is fast but not free, and
56/// the vast majority of responses are tiny status updates.
57const MAX_OUTPUT_LENGTH: usize = 100_000;
58
59/// Token budget reserved for a single PTY response when token-aware truncation
60/// kicks in. Picked to leave plenty of room for the surrounding context — most
61/// frontier models have 128k+ windows, so 25k for one shell payload is generous
62/// without monopolizing the conversation.
63const MAX_OUTPUT_TOKENS: usize = 25_000;
64
65/// Delay between typing text and the submitting Enter. Ink-based TUIs (Claude
66/// Code) collapse a burst of bytes into a single render tick and drop a CR that
67/// arrives glued to the text — the input box updates but never submits. Sending
68/// the Enter as a separate write after this short pause lets the TUI commit the
69/// typed text first, so `submit: true` reliably submits. ~40ms is imperceptible
70/// yet enough for the event loop to flush; harmless for plain shells.
71const SUBMIT_NUDGE_DELAY: Duration = Duration::from_millis(40);
72
73/// `screen` diff mode emits a per-line delta only when at most this many lines
74/// changed; beyond that the full frame is cheaper to read than a giant diff.
75const SCREEN_DIFF_THRESHOLD: usize = 10;
76
77/// Tail of `text` at most `max_len` bytes long, snapped up to a char boundary so
78/// we never slice through a multibyte UTF-8 sequence (which would panic).
79fn char_safe_tail(text: &str, max_len: usize) -> &str {
80    if text.len() <= max_len {
81        return text;
82    }
83    let mut start = text.len() - max_len;
84    while start < text.len() && !text.is_char_boundary(start) {
85        start += 1;
86    }
87    &text[start..]
88}
89
90/// Truncate `text` so its Claude token count stays under `max_tokens`.
91///
92/// We tokenize the tail of the string only when the raw byte length already
93/// exceeds the byte cap; otherwise we trust the byte budget and return as-is.
94/// When the tail still overshoots, we keep the last `max_tokens - reserve`
95/// tokens and prepend a "(...truncated)" marker — exactly what wcgw does in
96/// `_incremental_text`.
97fn truncate_to_token_budget(text: &str, max_tokens: usize) -> std::borrow::Cow<'_, str> {
98    if text.len() <= MAX_OUTPUT_LENGTH {
99        return std::borrow::Cow::Borrowed(text);
100    }
101
102    let Some(tokens) = crate::utils::encoder::encode_ids(text) else {
103        // Fallback to the byte-based truncation we used before the tokenizer.
104        return std::borrow::Cow::Owned(format!(
105            "(...truncated)\n{}",
106            char_safe_tail(text, MAX_OUTPUT_LENGTH)
107        ));
108    };
109
110    if tokens.len() <= max_tokens {
111        return std::borrow::Cow::Borrowed(text);
112    }
113
114    // Reserve one token slot for the marker overhead.
115    let keep = max_tokens.saturating_sub(1);
116    let tail = &tokens[tokens.len() - keep..];
117    let decoded = crate::utils::encoder::decode_ids(tail).unwrap_or_else(|| {
118        // Tokenizer present but decode failed: fall back to a byte tail.
119        char_safe_tail(text, MAX_OUTPUT_LENGTH).to_string()
120    });
121    std::borrow::Cow::Owned(format!("(...truncated)\n{decoded}"))
122}
123
124/// Message when a command is already running - matches WCGW Python `WAITING_INPUT_MESSAGE`
125const WAITING_INPUT_MESSAGE: &str = "A command is already running. NOTE: You can't run multiple shell commands in main shell, likely a previous program hasn't exited.
1261. Get its output using status check.
1272. Use `send_ascii` or `send_specials` to give inputs to the running program OR
1283. kill the previous program by sending ctrl+c first using `send_ascii` or `send_specials`
1294. Interrupt and run the process in background
130";
131
132// ==================== Background Shell Manager ====================
133
134/// Snapshot of a background shell that has exited but whose final output has not
135/// yet been consumed by the caller. We keep it around so the next call (typically
136/// a `status_check`) can return the trailing output before the entry is gone.
137#[derive(Debug, Clone)]
138pub struct ExitedShellInfo {
139    pub owner_thread_id: String,
140    pub last_command: String,
141    pub final_output: String,
142    pub exited_at: Instant,
143}
144
145#[derive(Debug)]
146struct BackgroundShellEntry {
147    owner_thread_id: String,
148    shell: SharedPtyShell,
149}
150
151/// Manages background shell sessions - matches WCGW Python's `background_shells` dict
152#[derive(Debug, Default)]
153pub struct BackgroundShellManager {
154    shells: HashMap<String, BackgroundShellEntry>,
155    /// Recently exited shells that still owe their final output to the caller.
156    /// Entries are consumed the first time the caller queries the id, then dropped.
157    tombstones: HashMap<String, ExitedShellInfo>,
158}
159
160impl BackgroundShellManager {
161    /// Tombstones older than this are garbage-collected on the next prune pass.
162    const TOMBSTONE_TTL: Duration = Duration::from_secs(300);
163    const MAX_SHELLS: usize = 32;
164    const MAX_SHELLS_PER_THREAD: usize = 8;
165
166    /// Create a new background shell manager
167    pub fn new() -> Self {
168        Self { shells: HashMap::new(), tombstones: HashMap::new() }
169    }
170
171    /// Start a new background shell and return its command ID
172    /// Register an already-built shell and return its id.
173    ///
174    /// The caller builds the `PtyShell` BEFORE taking the manager lock —
175    /// `PtyShell::new` forks+execs and does a ~300ms blocking prompt init, which
176    /// must not run under the global `std::Mutex` (it would serialize every other
177    /// background-shell op behind one slow spawn).
178    pub fn register_shell(&mut self, owner_thread_id: &str, shell: PtyShell) -> Result<String> {
179        self.prune_finished_shells();
180        self.ensure_capacity(owner_thread_id)?;
181
182        let cid = format!("{:016x}", rand::rng().random::<u64>());
183        self.shells.insert(
184            cid.clone(),
185            BackgroundShellEntry {
186                owner_thread_id: owner_thread_id.to_string(),
187                shell: Arc::new(Mutex::new(Some(shell))),
188            },
189        );
190        info!("Started background shell with id: {}", cid);
191        Ok(cid)
192    }
193
194    fn ensure_capacity(&self, owner_thread_id: &str) -> Result<()> {
195        let owned =
196            self.shells.values().filter(|entry| entry.owner_thread_id == owner_thread_id).count();
197        if owned >= Self::MAX_SHELLS_PER_THREAD {
198            return Err(WinxError::CommandExecutionError(format!(
199                "Background shell limit reached for this thread ({}). Reuse or stop an existing shell.",
200                Self::MAX_SHELLS_PER_THREAD
201            )));
202        }
203        if self.shells.len() >= Self::MAX_SHELLS {
204            return Err(WinxError::CommandExecutionError(format!(
205                "Global background shell limit reached ({}). Wait for an existing shell to exit.",
206                Self::MAX_SHELLS
207            )));
208        }
209        Ok(())
210    }
211
212    /// Get a background shell by its command ID
213    pub fn get_shell(&self, owner_thread_id: &str, bg_command_id: &str) -> Option<SharedPtyShell> {
214        self.shells
215            .get(bg_command_id)
216            .filter(|entry| entry.owner_thread_id == owner_thread_id)
217            .map(|entry| entry.shell.clone())
218    }
219
220    /// Remove and cleanup a background shell
221    pub fn remove_shell(&mut self, bg_command_id: &str) -> bool {
222        if let Some(entry) = self.shells.remove(bg_command_id) {
223            if let Ok(mut guard) = entry.shell.try_lock() {
224                *guard = None;
225            }
226            info!("Removed background shell: {}", bg_command_id);
227            true
228        } else {
229            false
230        }
231    }
232
233    fn prune_finished_shells(&mut self) {
234        // GC old tombstones first.
235        let now = Instant::now();
236        self.tombstones.retain(|_, info| now.duration_since(info.exited_at) < Self::TOMBSTONE_TTL);
237
238        let mut finished: Vec<(String, Option<ExitedShellInfo>)> = Vec::new();
239
240        for (id, entry) in &self.shells {
241            let Ok(mut guard) = entry.shell.try_lock() else {
242                continue;
243            };
244
245            let Some(shell) = guard.as_mut() else {
246                finished.push((id.clone(), None));
247                continue;
248            };
249
250            if !shell.is_alive() {
251                let tombstone = ExitedShellInfo {
252                    owner_thread_id: entry.owner_thread_id.clone(),
253                    last_command: shell.last_command.clone(),
254                    final_output: std::mem::take(&mut shell.output_buffer),
255                    exited_at: now,
256                };
257                finished.push((id.clone(), Some(tombstone)));
258                continue;
259            }
260
261            // Never prune shells that haven't received a command yet.
262            // The global BG_SHELL_MANAGER is shared across parallel tests; a freshly
263            // spawned shell would otherwise be evicted between start_new_shell and
264            // the first send_command, leading to "Failed to get background shell".
265            if shell.last_command.is_empty() {
266                continue;
267            }
268
269            if shell.command_running {
270                // Non-blocking: draining here must NOT hold the global manager
271                // lock across a 100ms blocking read (that serializes every other
272                // background-shell op behind a single slow PTY).
273                shell.poll_output_nonblocking();
274            }
275
276            if !shell.command_running {
277                let tombstone = ExitedShellInfo {
278                    owner_thread_id: entry.owner_thread_id.clone(),
279                    last_command: shell.last_command.clone(),
280                    final_output: std::mem::take(&mut shell.output_buffer),
281                    exited_at: now,
282                };
283                finished.push((id.clone(), Some(tombstone)));
284            }
285        }
286
287        for (id, tombstone) in finished {
288            self.remove_shell(&id);
289            if let Some(info) = tombstone {
290                self.tombstones.insert(id, info);
291            }
292        }
293    }
294
295    /// Look up the tombstone for a recently-exited shell, if any.
296    ///
297    /// The entry stays in the map until the TTL expires (see
298    /// `prune_finished_shells`), so repeated `status_check` calls on the same
299    /// `bg_command_id` keep returning the cached final output instead of
300    /// flipping to "shell not found" after the first read.
301    pub fn peek_tombstone(
302        &self,
303        owner_thread_id: &str,
304        bg_command_id: &str,
305    ) -> Option<ExitedShellInfo> {
306        self.tombstones
307            .get(bg_command_id)
308            .filter(|entry| entry.owner_thread_id == owner_thread_id)
309            .cloned()
310    }
311
312    /// Get info about all running background shells - matches WCGW Python `get_bg_running_commandsinfo`
313    pub fn get_running_info(&mut self, owner_thread_id: &str) -> String {
314        self.prune_finished_shells();
315
316        if !self.shells.values().any(|entry| entry.owner_thread_id == owner_thread_id) {
317            return "No command running in background.\n".to_string();
318        }
319
320        let mut running = Vec::new();
321        for (id, entry) in &self.shells {
322            if entry.owner_thread_id != owner_thread_id {
323                continue;
324            }
325            if let Ok(guard) = entry.shell.try_lock() {
326                if let Some(bash) = guard.as_ref() {
327                    if bash.command_running {
328                        running
329                            .push(format!("Command: {}, bg_command_id: {}", bash.last_command, id));
330                    }
331                }
332            } else {
333                running.push(format!("Command: <busy>, bg_command_id: {id}"));
334            }
335        }
336
337        if running.is_empty() {
338            "No command running in background.\n".to_string()
339        } else {
340            format!("Following background commands are attached:\n{}\n", running.join("\n"))
341        }
342    }
343}
344
345// Global background shell manager (thread-safe) - matches WCGW Python's BashState.background_shells
346lazy_static::lazy_static! {
347    static ref BG_SHELL_MANAGER: StdMutex<BackgroundShellManager> = StdMutex::new(BackgroundShellManager::new());
348}
349
350/// Lock the global background-shell manager, recovering from poisoning.
351///
352/// A panic while holding this lock (e.g. in the rendering path during a prune)
353/// must NOT permanently brick all background-shell functionality for the rest of
354/// the server's lifetime. The manager's data stays consistent across a panic, so
355/// recovering the inner guard is safe.
356fn lock_bg_manager() -> std::sync::MutexGuard<'static, BackgroundShellManager> {
357    BG_SHELL_MANAGER.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
358}
359
360/// Drain one background shell even when the client never polls it, then turn it
361/// into a short-lived tombstone. Without this task, a completed `is_background`
362/// command leaves a bash process, PTY and reader thread alive until some later
363/// foreground status happens to prune the global manager.
364fn spawn_background_reaper(owner_thread_id: String, bg_command_id: String) {
365    tokio::spawn(async move {
366        loop {
367            sleep(Duration::from_millis(100)).await;
368            let shell_arc = {
369                let manager = lock_bg_manager();
370                manager.get_shell(&owner_thread_id, &bg_command_id)
371            };
372            let Some(shell_arc) = shell_arc else {
373                return;
374            };
375
376            let finished = {
377                let mut guard = shell_arc.lock().await;
378                match guard.as_mut() {
379                    Some(shell) => shell.poll_output_nonblocking() || !shell.is_alive(),
380                    None => true,
381                }
382            };
383            if finished {
384                lock_bg_manager().prune_finished_shells();
385                return;
386            }
387        }
388    });
389}
390
391// ==================== WCGW-Style Helper Functions ====================
392
393/// Get WCGW-style status string - matches WCGW Python's `get_status()`
394fn get_status(
395    bash_state: &BashState,
396    is_bg: bool,
397    bg_id: Option<&str>,
398    is_running: bool,
399    running_for: Option<&str>,
400    exit_code: Option<i32>,
401) -> String {
402    let mut status = "\n\n---\n\n".to_string();
403
404    if is_bg {
405        if let Some(id) = bg_id {
406            let _ = writeln!(status, "bg_command_id = {id}");
407        }
408    }
409
410    if is_running {
411        status.push_str("status = still running\n");
412        if let Some(duration) = running_for {
413            let _ = writeln!(status, "running for = {duration}");
414        }
415    } else {
416        status.push_str("status = process exited\n");
417        // Exit code of the just-finished command, parsed from the prompt marker
418        // (`──➤<nonce>:<code>`). Lets the agent see failure without grepping stderr.
419        if let Some(code) = exit_code {
420            let _ = writeln!(status, "exit code = {code}");
421        }
422    }
423
424    let _ = writeln!(status, "cwd = {}", bash_state.cwd.display());
425
426    if !is_bg {
427        // Add background shell info for main shell - matches WCGW Python
428        {
429            let mut manager = lock_bg_manager();
430            status.push_str("This is the main shell. ");
431            status.push_str(&manager.get_running_info(&bash_state.current_thread_id));
432        }
433    }
434
435    status.trim_end().to_string()
436}
437
438/// Process output with WCGW-style incremental text handling - matches WCGW Python _`incremental_text`
439fn wcgw_incremental_text(text: &str, last_pending_output: &str) -> String {
440    let truncated = truncate_to_token_budget(text, MAX_OUTPUT_TOKENS);
441    let text = truncated.as_ref();
442
443    if last_pending_output.is_empty() {
444        let rendered = render_terminal_output(text);
445        return rstrip_lines(&rendered).trim_start().to_string();
446    }
447
448    let last_rendered = render_terminal_output(last_pending_output);
449    if last_rendered.is_empty() {
450        return rstrip_lines(&render_terminal_output(text));
451    }
452
453    // Get text after last pending output. Snap the offset down to a char
454    // boundary: `last_pending_output.len()` is a byte count and may land inside
455    // a multibyte code point of `text`, which would panic on the slice.
456    let text_after_last = if text.len() > last_pending_output.len() {
457        let cut = crate::utils::floor_char_boundary(text, last_pending_output.len());
458        &text[cut..]
459    } else {
460        text
461    };
462
463    let combined = format!("{}\n{}", last_rendered.join("\n"), text_after_last);
464    let new_rendered = render_terminal_output(&combined);
465
466    // Get incremental part - matches WCGW Python get_incremental_output
467    let incremental = get_incremental_output(&last_rendered, &new_rendered);
468    rstrip_lines(&incremental)
469}
470
471fn extract_prompt_cwd(output: &str) -> Option<PathBuf> {
472    static PROMPT_RE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
473    // `.expect`, not `.ok()`: a compile-time-literal regex that fails to build is a
474    // dev bug, not a runtime condition. The old `OnceLock<Option<Regex>>` froze
475    // `None` forever on the first failure, silently disabling all CWD tracking for
476    // the rest of the process. Fail loud instead.
477    #[allow(clippy::expect_used)]
478    let prompt_regex = PROMPT_RE
479        .get_or_init(|| Regex::new(r"◉ (?P<cwd>[^\r\n]*?)──➤").expect("prompt regex must compile"));
480    let stripped = strip_ansi_codes(output);
481
482    prompt_regex
483        .captures_iter(&stripped)
484        .filter_map(|captures| captures.name("cwd").map(|cwd| cwd.as_str().trim()))
485        .filter(|cwd| !cwd.is_empty())
486        .last()
487        .map(PathBuf::from)
488}
489
490/// Right-strip each line and join - matches WCGW Python rstrip
491fn rstrip_lines(lines: &[String]) -> String {
492    lines.iter().map(|line| line.trim_end()).collect::<Vec<_>>().join("\n")
493}
494
495/// Get incremental output between old and new - matches WCGW Python `get_incremental_output`
496fn get_incremental_output(old_output: &[String], new_output: &[String]) -> Vec<String> {
497    if old_output.is_empty() {
498        return new_output.to_vec();
499    }
500
501    let nold = old_output.len();
502    let nnew = new_output.len();
503
504    // Find where old output ends in new output
505    for i in (0..nnew).rev() {
506        if new_output[i] != old_output[nold - 1] {
507            continue;
508        }
509
510        let mut matched = true;
511        for j in (0..i).rev() {
512            let old_idx = (nold as i64 - 1 + j as i64 - i as i64) as isize;
513            if old_idx < 0 {
514                break;
515            }
516            if new_output[j] != old_output[old_idx as usize] {
517                matched = false;
518                break;
519            }
520        }
521
522        if matched {
523            return new_output[i + 1..].to_vec();
524        }
525    }
526
527    new_output.to_vec()
528}
529
530fn send_utf8_in_byte_chunks(shell: &mut PtyShell, text: &str, chunk_size: usize) -> Result<()> {
531    let mut start = 0;
532
533    while start < text.len() {
534        let mut end = (start + chunk_size).min(text.len());
535        while !text.is_char_boundary(end) {
536            end -= 1;
537        }
538        if end == start {
539            end = text[start..].char_indices().nth(1).map_or(text.len(), |(idx, _)| start + idx);
540        }
541
542        shell.send_text(&text[start..end]).map_err(|e| {
543            WinxError::CommandExecutionError(format!("Failed to write PTY input: {e}"))
544        })?;
545        start = end;
546    }
547
548    Ok(())
549}
550
551/// Send the submitting Enter as a separate, slightly-delayed write so an
552/// Ink-based TUI doesn't swallow a CR glued to the preceding input. Briefly
553/// drops the shell lock during the pause so the reader thread keeps draining.
554/// See [`SUBMIT_NUDGE_DELAY`].
555async fn submit_enter(shell_arc: &SharedPtyShell) -> Result<()> {
556    tokio::time::sleep(SUBMIT_NUDGE_DELAY).await;
557    let mut guard = shell_arc.lock().await;
558    let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
559    ensure_interactive_target(bash)?;
560    bash.send_special_key("Enter")
561        .map_err(|e| WinxError::CommandExecutionError(format!("Failed to submit: {e}")))?;
562    Ok(())
563}
564
565/// Interactive bytes are input for an already-running foreground program, not
566/// an alternate command-execution API. Drain first so a prompt that arrived just
567/// before this call marks the command complete; otherwise text submitted to an
568/// idle shell would bypass the mode's command allowlist entirely.
569fn ensure_interactive_target(shell: &mut PtyShell) -> Result<()> {
570    shell.poll_output_nonblocking();
571    if shell.command_running {
572        Ok(())
573    } else {
574        Err(WinxError::CommandExecutionError(
575            "No interactive command is running. Start an allowed command first; send_text/send_ascii/send_specials cannot execute commands in an idle shell."
576                .to_string(),
577        ))
578    }
579}
580
581// ==================== Main Tool Handler ====================
582
583/// Handles the `BashCommand` tool call with WCGW parity
584///
585/// This function processes the `BashCommand` tool call following WCGW Python's
586/// `execute_bash()` function behavior exactly.
587#[tracing::instrument(level = "info", skip(bash_state_arc, bash_command))]
588pub async fn handle_tool_call(
589    bash_state_arc: &Arc<Mutex<Option<BashState>>>,
590    bash_command: BashCommand,
591) -> Result<String> {
592    let action_kind = match &bash_command.action_json {
593        BashCommandAction::Command { .. } => "command",
594        BashCommandAction::StatusCheck { .. } => "status_check",
595        BashCommandAction::SendText { .. } => "send_text",
596        BashCommandAction::SendSpecials { .. } => "send_specials",
597        BashCommandAction::SendAscii { .. } => "send_ascii",
598        BashCommandAction::Screen { .. } => "screen",
599        BashCommandAction::WaitForTurn { .. } => "wait_for_turn",
600    };
601    info!(thread_id = %bash_command.thread_id, action = action_kind, "BashCommand tool called");
602
603    let thread_id = normalize_thread_id(&bash_command.thread_id);
604
605    // Check if thread_id is empty
606    if thread_id.is_empty() {
607        error!("Empty thread_id provided in BashCommand");
608        return Err(WinxError::ThreadIdMismatch(
609            "Error: No saved bash state found for thread ID \"\". Please initialize first with this ID.".to_string()
610        ));
611    }
612
613    // Extract bash_state data
614    let mut bash_state: BashState;
615    {
616        let bash_state_guard = bash_state_arc.lock().await;
617
618        let Some(state) = &*bash_state_guard else {
619            error!("BashState not initialized");
620            return Err(WinxError::BashStateNotInitialized);
621        };
622
623        bash_state = state.clone();
624    }
625
626    // Verify thread ID matches - matches WCGW Python thread_id check
627    if thread_id != bash_state.current_thread_id {
628        // Try to load state from thread_id - matches WCGW Python load_state_from_thread_id
629        // Distinguish "no state for this id" (Ok(false)) from a real load failure
630        // (Err: permission denied, corrupt JSON). The old `.unwrap_or(false)`
631        // collapsed both into the misleading "initialize first" message.
632        let loaded = bash_state.load_state_from_disk(&thread_id).map_err(|e| {
633            WinxError::CommandExecutionError(format!(
634                "Failed to load saved bash state for thread_id `{thread_id}`: {e}"
635            ))
636        })?;
637        if !loaded {
638            return Err(WinxError::ThreadIdMismatch(format!(
639                "Error: No saved bash state found for thread_id `{thread_id}`. Please initialize first with this ID."
640            )));
641        }
642        // Promote the loaded state back to the shared slot. The old code only wrote
643        // `cwd` back at the end, so switching thread_id silently dropped the loaded
644        // whitelist/mode/thread_id — the next command re-cloned the stale state.
645        // load_state_from_disk doesn't touch pty_shell (shells aren't serialized),
646        // so the live shell Arc is preserved through the clone.
647        if let Some(state) = bash_state_arc.lock().await.as_mut() {
648            *state = bash_state.clone();
649        }
650    }
651
652    // Calculate effective timeout - matches WCGW Python
653    // SECURITY: Ensure timeout is not negative to prevent unexpected behavior
654    let timeout_s = bash_command
655        .wait_for_seconds
656        .map_or(DEFAULT_TIMEOUT, |t| f64::from(t).max(0.0))
657        .min(TIMEOUT_WHILE_OUTPUT);
658
659    // Execute the action based on type - matches WCGW Python's _execute_bash()
660    let result = execute_bash_action(&mut bash_state, &bash_command.action_json, timeout_s).await;
661
662    {
663        let mut bash_state_guard = bash_state_arc.lock().await;
664        if let Some(state) = bash_state_guard.as_mut() {
665            state.cwd.clone_from(&bash_state.cwd);
666        }
667    }
668
669    // Remove echo if it's a command - matches WCGW Python
670    match result {
671        Ok(mut output) => {
672            if let BashCommandAction::Command { ref command, .. } = bash_command.action_json {
673                let cmd_trimmed = command.trim();
674                if output.starts_with(cmd_trimmed) {
675                    output = output[cmd_trimmed.len()..].to_string();
676                }
677            }
678            Ok(output)
679        }
680        Err(e) => Err(e),
681    }
682}
683
684/// Execute a bash action - matches WCGW Python's _`execute_bash()` function
685#[allow(clippy::too_many_lines)]
686async fn execute_bash_action(
687    bash_state: &mut BashState,
688    action: &BashCommandAction,
689    timeout_s: f64,
690) -> Result<String> {
691    let mut is_bg = false;
692    let mut bg_id: Option<String> = None;
693
694    // Handle bg_command_id routing - matches WCGW Python
695    let bg_shell: Option<SharedPtyShell> = match action {
696        BashCommandAction::Command { .. } => None, // Commands don't use bg_command_id for routing
697        BashCommandAction::StatusCheck { bg_command_id, .. }
698        | BashCommandAction::SendText { bg_command_id, .. }
699        | BashCommandAction::SendSpecials { bg_command_id, .. }
700        | BashCommandAction::SendAscii { bg_command_id, .. }
701        | BashCommandAction::Screen { bg_command_id, .. }
702        | BashCommandAction::WaitForTurn { bg_command_id, .. } => {
703            if let Some(id) = bg_command_id {
704                // Use the recovery helper (poison -> into_inner) like every other
705                // call site, rather than hard-failing this one path on poison.
706                let mut manager = lock_bg_manager();
707                manager.prune_finished_shells();
708
709                if let Some(shell) = manager.get_shell(&bash_state.current_thread_id, id) {
710                    is_bg = true;
711                    bg_id = Some(id.clone());
712                    Some(shell)
713                } else if let Some(tombstone) =
714                    manager.peek_tombstone(&bash_state.current_thread_id, id)
715                {
716                    // Shell already exited. For a status check we can hand back the
717                    // final cached output exactly once. For anything else (send_text,
718                    // send_specials, send_ascii) tell the caller the shell is gone
719                    // and include the captured output so they can recover state.
720                    drop(manager);
721                    return finalize_tombstone(&bash_state.cwd, id, tombstone, action);
722                } else {
723                    // Error message matches WCGW Python
724                    let error = format!(
725                        "No shell found running with command id {}.\n{}",
726                        id,
727                        manager.get_running_info(&bash_state.current_thread_id)
728                    );
729                    return Err(WinxError::CommandExecutionError(error));
730                }
731            } else {
732                None
733            }
734        }
735    };
736
737    // Process based on action type - matches WCGW Python _execute_bash dispatch
738    match action {
739        BashCommandAction::Command { command, is_background, allow_multi } => {
740            execute_command(bash_state, command, *is_background, *allow_multi, timeout_s).await
741        }
742        BashCommandAction::StatusCheck { scrollback_lines, verbose, .. } => {
743            execute_status_check(
744                bash_state,
745                bg_shell,
746                is_bg,
747                bg_id.as_deref(),
748                timeout_s,
749                *scrollback_lines,
750                *verbose,
751            )
752            .await
753        }
754        BashCommandAction::SendText { send_text, submit, .. } => {
755            execute_send_text(
756                bash_state,
757                send_text,
758                *submit,
759                bg_shell,
760                is_bg,
761                bg_id.as_deref(),
762                timeout_s,
763            )
764            .await
765        }
766        BashCommandAction::SendSpecials { send_specials, submit, .. } => {
767            execute_send_specials(
768                bash_state,
769                send_specials,
770                *submit,
771                bg_shell,
772                is_bg,
773                bg_id.as_deref(),
774                timeout_s,
775            )
776            .await
777        }
778        BashCommandAction::SendAscii { send_ascii, submit, .. } => {
779            execute_send_ascii(
780                bash_state,
781                send_ascii,
782                *submit,
783                bg_shell,
784                is_bg,
785                bg_id.as_deref(),
786                timeout_s,
787            )
788            .await
789        }
790        BashCommandAction::Screen { lines, diff, .. } => {
791            execute_screen(bash_state, bg_shell, is_bg, bg_id.as_deref(), *lines, *diff).await
792        }
793        BashCommandAction::WaitForTurn {
794            recognizer,
795            quiet_ms,
796            timeout_seconds,
797            lines,
798            wait_through_busy,
799            ..
800        } => {
801            execute_wait_for_turn(
802                bash_state,
803                bg_shell,
804                is_bg,
805                bg_id.as_deref(),
806                recognizer.as_deref(),
807                *quiet_ms,
808                *timeout_seconds,
809                *lines,
810                *wait_through_busy,
811            )
812            .await
813        }
814    }
815}
816
817/// Strip a trailing `| tail ...` from a command (wcgw parity, `strip_tail_pipe`).
818///
819/// LLMs habitually pipe output through `tail`, but we already truncate output
820/// server-side — stripping the pipe avoids hiding the earlier output the model
821/// usually wants. Only a `tail` at the very end of the command is removed.
822///
823/// This matches wcgw by default. Set `WINX_KEEP_TAIL_PIPE=1` to preserve the
824/// pipe instead (winx's original behavior), e.g. when you deliberately want only
825/// the tail of a huge log rather than the server-side truncation.
826fn strip_tail_pipe(command: &str) -> String {
827    strip_tail_pipe_impl(command, keep_tail_pipe())
828}
829
830/// Pure core of [`strip_tail_pipe`], split out so both modes are unit-testable
831/// without touching process-wide env vars (tests run concurrently).
832fn strip_tail_pipe_impl(command: &str, keep: bool) -> String {
833    static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
834    if keep {
835        return command.to_string();
836    }
837    // `.expect`, not `.ok()`: a compile-time-literal regex that fails to build is
838    // a dev bug. The old `OnceLock<Option<Regex>>` + `.ok()` would freeze `None`
839    // and silently stop stripping `| tail` for the whole process.
840    #[allow(clippy::expect_used)]
841    let re = RE.get_or_init(|| {
842        regex::Regex::new(r"\|\s*tail(?:\s+(?:-n\s*|-)?(\d+))?\s*$")
843            .expect("tail-pipe regex must compile")
844    });
845    match re.find(command) {
846        Some(matched) => command[..matched.start()].trim_end().to_string(),
847        None => command.to_string(),
848    }
849}
850
851/// Whether the user opted out of `| tail` stripping via `WINX_KEEP_TAIL_PIPE`.
852fn keep_tail_pipe() -> bool {
853    std::env::var("WINX_KEEP_TAIL_PIPE").is_ok_and(|value| {
854        let value = value.trim();
855        !value.is_empty() && value != "0" && !value.eq_ignore_ascii_case("false")
856    })
857}
858
859/// Execute a command - matches WCGW Python's Command handling in _`execute_bash`
860async fn execute_command(
861    bash_state: &mut BashState,
862    command: &str,
863    is_background: bool,
864    allow_multi: bool,
865    timeout_s: f64,
866) -> Result<String> {
867    // wcgw strips a trailing `| tail` before anything else (model_validator).
868    let stripped_command = strip_tail_pipe(command);
869    let command = stripped_command.as_str();
870    debug!(bytes = command.len(), allow_multi, "Processing Command action");
871
872    // Check mode permissions - matches WCGW Python bash_command_mode check
873    if !bash_state.is_command_allowed(command) {
874        error!(bytes = command.len(), "Command not allowed in current mode");
875        return Err(WinxError::CommandNotAllowed(
876            "Error: BashCommand not allowed in current mode".to_string(),
877        ));
878    }
879
880    // Single-statement guard (wcgw parity). Callers can opt out via
881    // `allow_multi: true` when they knowingly want to chain commands
882    // without wrapping in `bash -lc '...'`.
883    let command = command.trim();
884    if !allow_multi {
885        // The `bash -n -c` fallback inside the parser spawns a shell; only allow
886        // that probe in trusted (wcgw) mode. In restricted modes tree-sitter is
887        // the sole arbiter so we never shell out to vet a command.
888        let allow_shell_probe = matches!(bash_state.mode, crate::types::Modes::Wcgw);
889        crate::utils::bash_parser::assert_single_statement(command, allow_shell_probe)?;
890    }
891
892    // If background execution requested, start new shell - matches WCGW Python is_background handling
893    if is_background {
894        return execute_in_background(bash_state, command, timeout_s).await;
895    }
896
897    // Check if a command is already running - matches WCGW Python state check
898    {
899        let bash_guard = bash_state.pty_shell.lock().await;
900
901        if let Some(ref bash) = *bash_guard {
902            if bash.command_running {
903                return Err(WinxError::CommandExecutionError(WAITING_INPUT_MESSAGE.to_string()));
904            }
905        }
906    }
907
908    // Initialize bash if needed
909    if bash_state.pty_shell.lock().await.is_none() {
910        bash_state
911            .init_pty_shell()
912            .await
913            .map_err(|e| WinxError::CommandExecutionError(format!("Failed to init bash: {e}")))?;
914    }
915
916    // Clear prompt before sending - matches WCGW Python clear_to_run.
917    // Drain any leftover output and, if the shell still looks busy, send
918    // Ctrl-C so the new command lands on a fresh prompt instead of being
919    // appended to whatever was hanging on stdin.
920    {
921        let needs_reset = {
922            // Only when a shell exists. clear_to_run_async drains/interrupts WITHOUT
923            // holding the lock across a blocking sleep (was bash.clear_to_run, which
924            // slept up to DEFAULT_TIMEOUT seconds inside the tokio mutex).
925            if bash_state.pty_shell.lock().await.is_some() {
926                if clear_to_run_async(&bash_state.pty_shell, DEFAULT_TIMEOUT).await {
927                    false
928                } else {
929                    warn!("clear_to_run: shell still busy after Ctrl-C, resetting it");
930                    true
931                }
932            } else {
933                false
934            }
935        };
936        // wcgw parity: a shell that won't return to a prompt even after Ctrl-C is
937        // recreated, so the new command lands on a fresh prompt instead of being
938        // appended to a hung shell. init_pty_shell rebuilds at the same cwd/mode.
939        if needs_reset {
940            if let Err(e) = bash_state.init_pty_shell().await {
941                warn!("Failed to reset shell after clear_to_run: {e}");
942            }
943        }
944    }
945
946    // Send command in chunks of 64 characters - matches WCGW Python exactly
947    let scratch_root = bash_state.workspace_root.clone();
948    {
949        let mut bash_guard = bash_state.pty_shell.lock().await;
950
951        let bash = bash_guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
952
953        bash.output_buffer.clear();
954        bash.output_truncated = false;
955        // Offload an over-long output's dropped head to a scratch file under the
956        // current workspace so the agent can recover it via ReadFiles.
957        bash.reset_scratch();
958        bash.set_scratch_root(&scratch_root);
959        // Mirror send_command's full reset. This foreground path hand-rolls the
960        // field mutation (it chunks the send for WCGW parity) and used to drop
961        // these two: a stale exit code or dedup hash would then leak into the
962        // next status_check — wrong `exit code`, or a false "no new output".
963        bash.last_exit_code = None;
964        bash.last_returned_hash = None;
965        // Send in chunks - matches WCGW Python: for i in range(0, len(command), 64)
966        send_utf8_in_byte_chunks(bash, command, COMMAND_CHUNK_SIZE)?;
967
968        // Send linesep to execute - matches WCGW Python bash_state.send(bash_state.linesep, ...)
969        bash.send_special_key("Enter").map_err(|e| {
970            WinxError::CommandExecutionError(format!("Failed to send newline: {e}"))
971        })?;
972
973        bash.last_command = command.to_string();
974        bash.command_running = true;
975    }
976
977    // Wait for output with WCGW-style patience handling
978    let shell_arc = bash_state.pty_shell.clone();
979    wait_for_output(bash_state, &shell_arc, timeout_s, false, None, false).await
980}
981
982/// Wait for command output with WCGW-style patience handling - matches WCGW Python expect/wait logic.
983///
984/// Non-blocking drain of queued shell output under the lock; returns whether the
985/// command has completed. The lock is released before returning so the caller can
986/// `await` a poll interval without pinning the executor — unlike the old
987/// `read_output`, which slept (blocking) inside this lock.
988async fn poll_shell(shell_arc: &SharedPtyShell) -> bool {
989    let mut guard = shell_arc.lock().await;
990    match guard.as_mut() {
991        Some(bash) => bash.poll_output_nonblocking(),
992        None => true,
993    }
994}
995
996/// Snapshot the shell's accumulated output buffer in one lock acquisition.
997async fn snapshot_shell(shell_arc: &SharedPtyShell) -> String {
998    let mut guard = shell_arc.lock().await;
999    guard.as_mut().map_or_else(String::new, |bash| bash.output_snapshot())
1000}
1001
1002/// Poll-drain the shell until its prompt returns or `budget_secs` elapses; returns
1003/// whether the prompt was seen (i.e. the shell is idle). The lock is released
1004/// between polls and the wait is awaited, so the executor is never blocked.
1005async fn drain_until_prompt(shell_arc: &SharedPtyShell, budget_secs: f64) -> bool {
1006    let start = Instant::now();
1007    loop {
1008        if poll_shell(shell_arc).await {
1009            return true;
1010        }
1011        if start.elapsed().as_secs_f64() >= budget_secs {
1012            return false;
1013        }
1014        sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
1015    }
1016}
1017
1018/// Async replacement for `PtyShell::clear_to_run`: drain leftover output, and if
1019/// the shell still looks busy, send Ctrl-C and re-drain — returning whether the
1020/// shell reached an idle prompt. The old sync method ran `read_output` (a blocking
1021/// `thread::sleep` loop, up to `DEFAULT_TIMEOUT` seconds) WHILE the caller held the
1022/// tokio mutex, pinning the worker on every foreground command. This holds the
1023/// lock only for the instantaneous poll/interrupt, never across a wait.
1024async fn clear_to_run_async(shell_arc: &SharedPtyShell, max_wait_secs: f64) -> bool {
1025    // Phase 1: a quick drain — return the moment the prompt is already back.
1026    if drain_until_prompt(shell_arc, max_wait_secs.min(0.5)).await {
1027        return true;
1028    }
1029    // Still busy: interrupt (lock held only for the send), then re-drain.
1030    {
1031        let mut guard = shell_arc.lock().await;
1032        if let Some(bash) = guard.as_mut() {
1033            if let Err(e) = bash.send_interrupt() {
1034                warn!("clear_to_run: failed to send Ctrl-C: {e}");
1035            }
1036        }
1037    }
1038    drain_until_prompt(shell_arc, max_wait_secs).await
1039}
1040
1041/// `shell_arc` selects which shell to read from (main shell or a bg shell handle).
1042async fn wait_for_output(
1043    bash_state: &mut BashState,
1044    shell_arc: &SharedPtyShell,
1045    timeout_s: f64,
1046    is_bg: bool,
1047    bg_id: Option<&str>,
1048    is_status_check: bool,
1049) -> Result<String> {
1050    let start = Instant::now();
1051    let wait = timeout_s.min(TIMEOUT_WHILE_OUTPUT);
1052    let mut last_pending_output = String::new();
1053    let mut complete = false;
1054
1055    // Adaptive polling instead of a blind sleep. wcgw sleeps the full `wait`
1056    // budget before reading even once, so a `pwd` that finishes in 10ms still
1057    // costs ~5s. Instead we read in short slices and return the moment the
1058    // prompt comes back (`read_output` already exits early on prompt + drain),
1059    // dropping fast-command latency from seconds to ~100ms. Long-running
1060    // commands still consume the whole budget, since we loop until `complete`
1061    // or `wait` elapses — identical upper-bound behavior, far snappier floor.
1062    // Drain non-blocking, release the lock, then `await` the poll interval. The
1063    // old `read_output(slice)` slept up to ~0.5s of blocking `thread::sleep` WHILE
1064    // holding this tokio mutex — pinning the worker thread (starving every other
1065    // task on it) and the shell lock the whole time. poll + `await` frees both.
1066    let mut output = String::new();
1067    loop {
1068        if start.elapsed().as_secs_f64() >= wait {
1069            break;
1070        }
1071        complete = poll_shell(shell_arc).await;
1072        if complete {
1073            break;
1074        }
1075        sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
1076    }
1077    // Post-prompt grace drain (awaited, no lock held), then one buffer snapshot —
1078    // the old read_output cloned the whole buffer on every slice (performance-2).
1079    if complete {
1080        sleep(Duration::from_millis(POST_PROMPT_DRAIN_MS)).await;
1081        poll_shell(shell_arc).await;
1082    }
1083    output = snapshot_shell(shell_arc).await;
1084
1085    // If not complete and this is a status check, use WCGW-style patience waiting.
1086    //
1087    // Treat `timeout_s` (== caller's `wait_for_seconds`, capped at
1088    // `TIMEOUT_WHILE_OUTPUT`) as the hard upper bound on the TOTAL wall-clock
1089    // spent inside this call. wcgw computes `remaining = TIMEOUT_WHILE_OUTPUT
1090    // - wait`, which makes a 2-second `wait_for_seconds` block for almost 20s
1091    // on a TUI that keeps emitting spinner frames. We diverge from wcgw here
1092    // because driving agents expect their wait budget to be respected.
1093    if !complete && is_status_check {
1094        let budget_secs = timeout_s.min(TIMEOUT_WHILE_OUTPUT);
1095        let iter_wait_secs = 0.5_f64;
1096        let mut patience = OUTPUT_WAIT_PATIENCE;
1097
1098        let incremental = wcgw_incremental_text(&output, &last_pending_output);
1099        if incremental.is_empty() {
1100            patience -= 1;
1101        }
1102
1103        let mut last_incremental = incremental;
1104
1105        while start.elapsed().as_secs_f64() < budget_secs && patience > 0 {
1106            let remaining = (budget_secs - start.elapsed().as_secs_f64()).max(0.0);
1107            if remaining < 0.1 {
1108                break;
1109            }
1110            sleep(Duration::from_secs_f64(iter_wait_secs.min(remaining))).await;
1111
1112            // The patience `sleep(iter_wait_secs)` above already elapsed, so the
1113            // last half-second of output is queued — drain it non-blocking (was
1114            // read_output(0.5) holding the lock across its own internal sleep).
1115            let done = poll_shell(shell_arc).await;
1116            let new_output = snapshot_shell(shell_arc).await;
1117
1118            if done {
1119                complete = true;
1120                output = new_output;
1121                break;
1122            }
1123
1124            // Check if output changed - matches WCGW Python patience logic
1125            let new_incremental = wcgw_incremental_text(&new_output, &last_pending_output);
1126            if new_incremental == last_incremental {
1127                patience -= 1;
1128            } else {
1129                patience = OUTPUT_WAIT_PATIENCE; // Reset patience on new output
1130            }
1131            last_incremental = new_incremental;
1132
1133            output = new_output;
1134        }
1135
1136        if !complete {
1137            // Update pending output - matches WCGW Python bash_state.set_pending(text)
1138            last_pending_output = output.clone();
1139        }
1140    }
1141
1142    if complete {
1143        if let Some(cwd) = extract_prompt_cwd(&output) {
1144            bash_state.cwd = cwd;
1145        }
1146    }
1147
1148    // Process output through terminal emulation - matches WCGW Python _incremental_text
1149    let rendered = wcgw_incremental_text(&output, &last_pending_output);
1150
1151    // Conscious compression: collapse mechanical repetition (identical line runs,
1152    // blank-line blocks) before truncating, to save tokens without dropping any
1153    // unique context. Falls back to the raw text when nothing is safe to collapse.
1154    let rendered = {
1155        let before_lines = rendered.lines().count();
1156        match crate::utils::output_compress::compress_output(&rendered) {
1157            Some(compressed) => {
1158                debug!(
1159                    from_lines = before_lines,
1160                    to_lines = compressed.lines().count(),
1161                    "winx collapsed mechanical repetition in shell output"
1162                );
1163                compressed
1164            }
1165            None => rendered,
1166        }
1167    };
1168
1169    // Truncate if needed - matches WCGW Python token truncation
1170    let rendered = truncate_to_token_budget(&rendered, MAX_OUTPUT_TOKENS).into_owned();
1171
1172    // Calculate running duration for status
1173    let running_for =
1174        if complete { None } else { Some(format!("{} seconds", start.elapsed().as_secs())) };
1175
1176    // Surface the just-finished command's exit code (parsed from the prompt
1177    // marker) so the agent sees failure without grepping stderr.
1178    // Exit code (parsed from the prompt) plus a pointer to any offloaded output
1179    // scratch file, read in one lock acquisition.
1180    let (exit_code, scratch_pointer) = read_completion_extras(shell_arc, complete).await;
1181
1182    // Add status - matches WCGW Python get_status
1183    let status = get_status(bash_state, is_bg, bg_id, !complete, running_for.as_deref(), exit_code);
1184    Ok(format!("{rendered}{status}{scratch_pointer}"))
1185}
1186
1187/// Pull the just-finished command's exit code and, if its output overflowed and
1188/// the dropped head was offloaded, a pointer message to the scratch file - in a
1189/// single lock acquisition so callers don't lock the shell twice.
1190async fn read_completion_extras(
1191    shell_arc: &SharedPtyShell,
1192    complete: bool,
1193) -> (Option<i32>, String) {
1194    let guard = shell_arc.lock().await;
1195    let Some(shell) = guard.as_ref() else {
1196        return (None, String::new());
1197    };
1198    let exit_code = if complete { shell.last_exit_code } else { None };
1199    let pointer = match (shell.output_truncated, shell.scratch_path()) {
1200        (true, Some(path)) => format!(
1201            "\n\n---\n[Output was truncated to fit context. The earlier (dropped) output was \
1202             saved to:\n{}\nRead it with ReadFiles or grep it via BashCommand.]\n---",
1203            path.display()
1204        ),
1205        _ => String::new(),
1206    };
1207    (exit_code, pointer)
1208}
1209
1210/// Render the final cached output of an exited background shell.
1211///
1212/// `status_check` is allowed to "consume" the tombstone and return the trailing
1213/// output exactly once. Send-style actions (`send_text`, `send_specials`,
1214/// `send_ascii`) cannot interact with a dead shell, so we return an explicit
1215/// error that still includes the captured output so the agent can recover state.
1216fn finalize_tombstone(
1217    cwd: &Path,
1218    id: &str,
1219    tombstone: ExitedShellInfo,
1220    action: &BashCommandAction,
1221) -> Result<String> {
1222    let ExitedShellInfo { last_command, final_output, .. } = tombstone;
1223    match action {
1224        // A dead shell's turn is over: Screen/WaitForTurn hand back the final
1225        // captured output as the snapshot, exactly like a status check.
1226        BashCommandAction::StatusCheck { .. }
1227        | BashCommandAction::Screen { .. }
1228        | BashCommandAction::WaitForTurn { .. } => {
1229            let rendered = wcgw_incremental_text(&final_output, "");
1230            let rendered = truncate_to_token_budget(&rendered, MAX_OUTPUT_TOKENS).into_owned();
1231            // Build a compact status block matching `get_status` for a finished bg shell.
1232            let mut status = "\n\n---\n\n".to_string();
1233            let _ = writeln!(status, "bg_command_id = {id}");
1234            status.push_str("status = process exited\n");
1235            let _ = writeln!(status, "cwd = {}", cwd.display());
1236            Ok(format!("{rendered}{}", status.trim_end()))
1237        }
1238        BashCommandAction::SendText { .. }
1239        | BashCommandAction::SendSpecials { .. }
1240        | BashCommandAction::SendAscii { .. } => Err(WinxError::CommandExecutionError(format!(
1241            "Background shell {id} already exited (last command: {last_command}).\nFinal captured output:\n{final_output}"
1242        ))),
1243        BashCommandAction::Command { .. } => {
1244            // We only enter `finalize_tombstone` from the bg routing path, which
1245            // never matches Command. Treat this as a programmer error.
1246            unreachable!("finalize_tombstone called for non-bg action")
1247        }
1248    }
1249}
1250
1251/// Execute a status check - matches WCGW Python's `StatusCheck` handling.
1252///
1253/// New behavior (v0.2.308):
1254/// - Deduplicates against the last response by fingerprint; when nothing
1255///   changed and `verbose=false`, returns a compact "no new output" payload
1256///   instead of resending the same screen.
1257/// - Optional `scrollback_lines` pulls bounded history from the `PtyShell`
1258///   ringbuffer so agents can reorient after a long pause.
1259async fn execute_status_check(
1260    bash_state: &mut BashState,
1261    bg_shell: Option<SharedPtyShell>,
1262    is_bg: bool,
1263    bg_id: Option<&str>,
1264    timeout_s: f64,
1265    scrollback_lines: Option<usize>,
1266    verbose: bool,
1267) -> Result<String> {
1268    debug!("Processing StatusCheck action (verbose={verbose}, scrollback={scrollback_lines:?})");
1269
1270    // Pick the shell we're going to inspect: bg shell when bg_command_id was provided,
1271    // otherwise fall back to the main interactive shell.
1272    let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1273
1274    // Check if there's a running command - matches WCGW Python state check
1275    let is_running = {
1276        let guard = shell_arc.lock().await;
1277        if let Some(ref bash) = *guard {
1278            bash.command_running
1279        } else {
1280            false
1281        }
1282    };
1283
1284    // If no command running and not background, return error - matches WCGW Python
1285    if !is_running && !is_bg {
1286        let mut manager = lock_bg_manager();
1287        let error = format!(
1288            "No command is currently running, so there's nothing to check. The previous \
1289             command already finished and its output was returned when it completed. Start a \
1290             new command, or pass a bg_command_id if you launched one in the background.\n{}",
1291            manager.get_running_info(&bash_state.current_thread_id)
1292        );
1293        return Err(WinxError::CommandExecutionError(error));
1294    }
1295
1296    // Read output with patience handling - this IS a status check
1297    let response = wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, true).await?;
1298
1299    // Inter-call dedup: hash only the response *body* (the chunk before the
1300    // `\n\n---\n` status footer). The footer contains a live "running for"
1301    // counter that would otherwise defeat the comparison.
1302    let body = response.split("\n\n---\n").next().unwrap_or(&response);
1303    if !verbose && scrollback_lines.is_none() {
1304        let mut guard = shell_arc.lock().await;
1305        if let Some(bash) = guard.as_mut() {
1306            let fingerprint = PtyShell::fingerprint(body);
1307            if Some(fingerprint) == bash.last_returned_hash {
1308                let status = get_status(bash_state, is_bg, bg_id, is_running, None, None);
1309                return Ok(format!("no new output since last check{status}"));
1310            }
1311            bash.last_returned_hash = Some(fingerprint);
1312        }
1313    } else if !verbose {
1314        // Still record the hash so subsequent non-scrollback calls can dedup.
1315        let mut guard = shell_arc.lock().await;
1316        if let Some(bash) = guard.as_mut() {
1317            bash.last_returned_hash = Some(PtyShell::fingerprint(body));
1318        }
1319    }
1320
1321    // Optional scrollback prefix — only ever pulled when the caller asks for it.
1322    if let Some(lines) = scrollback_lines {
1323        if lines > 0 {
1324            let scrollback = {
1325                let guard = shell_arc.lock().await;
1326                guard.as_ref().map(|s| s.collect_scrollback(lines)).unwrap_or_default()
1327            };
1328            if !scrollback.is_empty() {
1329                let count = scrollback.lines().count();
1330                return Ok(format!(
1331                    "--- scrollback ({count} lines) ---\n{scrollback}\n--- latest ---\n{response}"
1332                ));
1333            }
1334        }
1335    }
1336
1337    Ok(response)
1338}
1339
1340/// Execute `Screen` — a stable, point-in-time snapshot of a shell's live
1341/// terminal screen (consolidated grid, ANSI stripped). No waiting, no dedup;
1342/// the foundation for reading an interactive TUI's current frame.
1343async fn execute_screen(
1344    bash_state: &BashState,
1345    bg_shell: Option<SharedPtyShell>,
1346    is_bg: bool,
1347    bg_id: Option<&str>,
1348    lines: Option<usize>,
1349    diff: bool,
1350) -> Result<String> {
1351    let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1352    let max_lines = lines.unwrap_or(0);
1353
1354    if diff {
1355        let (update, is_running, in_alt, cursor) = {
1356            let mut guard = shell_arc.lock().await;
1357            match guard.as_mut() {
1358                Some(bash) => {
1359                    bash.poll_output_nonblocking();
1360                    (
1361                        bash.live_snapshot_diff(max_lines, SCREEN_DIFF_THRESHOLD),
1362                        bash.command_running,
1363                        bash.live_in_alt_screen(),
1364                        bash.live_cursor_position(),
1365                    )
1366                }
1367                None => (ScreenUpdate::Full(Vec::new()), false, false, (0, 0)),
1368            }
1369        };
1370        let (crow, ccol) = cursor;
1371        let alt = if in_alt { " [alt-screen]" } else { "" };
1372        let status = get_status(bash_state, is_bg, bg_id, is_running, None, None);
1373        let body = match update {
1374            ScreenUpdate::Unchanged => "(no change since last screen)".to_string(),
1375            ScreenUpdate::Diff(changed) => {
1376                let mut out = String::from("(changed lines only)\n");
1377                for (row, content) in changed {
1378                    let _ = writeln!(out, "{row:>4}: {content}");
1379                }
1380                out
1381            }
1382            ScreenUpdate::Full(snap) => {
1383                let joined = snap.join("\n");
1384                if joined.trim().is_empty() {
1385                    "(screen is empty)".to_string()
1386                } else {
1387                    truncate_to_token_budget(&joined, MAX_OUTPUT_TOKENS).into_owned()
1388                }
1389            }
1390        };
1391        return Ok(format!(
1392            "--- live screen{alt} [cursor row={crow} col={ccol}] (diff) ---\n{body}{status}"
1393        ));
1394    }
1395
1396    let (snapshot, is_running, in_alt, cursor) = {
1397        let mut guard = shell_arc.lock().await;
1398        match guard.as_mut() {
1399            Some(bash) => {
1400                bash.poll_output_nonblocking();
1401                (
1402                    bash.live_snapshot(max_lines),
1403                    bash.command_running,
1404                    bash.live_in_alt_screen(),
1405                    bash.live_cursor_position(),
1406                )
1407            }
1408            None => (Vec::new(), false, false, (0, 0)),
1409        }
1410    };
1411
1412    let joined = snapshot.join("\n");
1413    let body = if joined.trim().is_empty() {
1414        "(screen is empty)".to_string()
1415    } else {
1416        truncate_to_token_budget(&joined, MAX_OUTPUT_TOKENS).into_owned()
1417    };
1418    let alt = if in_alt { " [alt-screen]" } else { "" };
1419    let (crow, ccol) = cursor;
1420    let status = get_status(bash_state, is_bg, bg_id, is_running, None, None);
1421    Ok(format!("--- live screen{alt} [cursor row={crow} col={ccol}] ---\n{body}{status}"))
1422}
1423
1424/// Why [`execute_wait_for_turn`] should stop polling, or `None` to keep waiting.
1425///
1426/// Extracted as a pure function so the (subtle) exit logic is unit-testable
1427/// without driving a real PTY. `busy_for` is how long we've read `Busy`
1428/// *continuously*: a TUI that is actively working never holds a stable screen
1429/// (its spinner repaints every frame), so we confirm `Busy` by elapsed duration,
1430/// not by `stable_for` — that was exactly why a busy child used to pin the caller
1431/// for the whole `hard_cap` (up to 600s).
1432#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
1433fn wait_turn_outcome(
1434    state: crate::state::turn::TurnState,
1435    alive: bool,
1436    activity: bool,
1437    seen_busy: bool,
1438    stable_for: Duration,
1439    busy_for: Duration,
1440    settle: Duration,
1441    quiet: Duration,
1442    timed_out: bool,
1443    wait_through_busy: bool,
1444) -> Option<&'static str> {
1445    use crate::state::turn::TurnState;
1446    if !alive {
1447        return Some("exited");
1448    }
1449    let ready = match state {
1450        TurnState::Busy => false,
1451        // A short settle is enough once we saw it go Busy; otherwise wait the
1452        // full quiet window so a slow first token isn't read as "done".
1453        TurnState::AwaitingInput | TurnState::AwaitingApproval => {
1454            activity && stable_for >= if seen_busy { settle } else { quiet }
1455        }
1456        TurnState::Unknown => activity && stable_for >= quiet,
1457    };
1458    if ready {
1459        return Some("ready");
1460    }
1461    // Early-out on a confirmed-busy turn instead of blocking until the hard cap.
1462    // A settled `busy` reading is a valid answer on its own (the tool documents
1463    // `busy` as a return state); the caller can poll again to keep watching. This
1464    // is the fix for a parent that "waits forever" on a long-running child.
1465    if !wait_through_busy && state == TurnState::Busy && busy_for >= settle {
1466        return Some("busy");
1467    }
1468    if timed_out {
1469        return Some("timeout");
1470    }
1471    None
1472}
1473
1474/// Execute `WaitForTurn` — wait for an interactive TUI's turn.
1475///
1476/// Polls the live screen, combining a per-app recognizer (claude/codex/auto)
1477/// with a generic quiescence window: the turn is "ready" when the recognizer
1478/// reports awaiting-input/approval (after a short settle) or, for an unknown
1479/// TUI, when the screen simply stops changing for `quiet`. By default it also
1480/// returns as soon as `Busy` is confirmed (see [`wait_turn_outcome`]); pass
1481/// `wait_through_busy` to block through busy until ready. Returns the stable
1482/// snapshot plus the detected state.
1483#[allow(clippy::too_many_arguments)]
1484async fn execute_wait_for_turn(
1485    bash_state: &BashState,
1486    bg_shell: Option<SharedPtyShell>,
1487    is_bg: bool,
1488    bg_id: Option<&str>,
1489    recognizer_hint: Option<&str>,
1490    quiet_ms: Option<u64>,
1491    timeout_seconds: Option<f32>,
1492    lines: Option<usize>,
1493    wait_through_busy: bool,
1494) -> Result<String> {
1495    use crate::state::turn::{recognizer_for, TurnState};
1496
1497    let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1498    let recognizer = recognizer_for(recognizer_hint.unwrap_or("auto"));
1499    let quiet = Duration::from_millis(quiet_ms.unwrap_or(600).clamp(50, 10_000));
1500    let settle = quiet.min(Duration::from_millis(300));
1501    let hard_cap =
1502        Duration::from_secs_f64(f64::from(timeout_seconds.unwrap_or(30.0)).clamp(0.5, 600.0));
1503    let max_lines = lines.unwrap_or(0);
1504    let poll = Duration::from_millis(120);
1505    // Grace period so a freshly-prompted app has time to react before we'd
1506    // otherwise mistake the *pre-input* idle screen for a finished turn.
1507    let warmup = Duration::from_millis(2500);
1508
1509    let start = Instant::now();
1510    let mut last_hash: Option<u64> = None;
1511    let mut initial_hash: Option<u64> = None;
1512    let mut stable_since = Instant::now();
1513    let mut seen_busy = false;
1514    let mut busy_since: Option<Instant> = None;
1515
1516    loop {
1517        let (snapshot, in_alt, alive, running) = {
1518            let mut guard = shell_arc.lock().await;
1519            match guard.as_mut() {
1520                Some(bash) => (
1521                    {
1522                        bash.poll_output_nonblocking();
1523                        bash.live_snapshot(max_lines)
1524                    },
1525                    bash.live_in_alt_screen(),
1526                    bash.is_alive(),
1527                    bash.command_running,
1528                ),
1529                None => (Vec::new(), false, false, false),
1530            }
1531        };
1532
1533        let hash = PtyShell::fingerprint(&snapshot.join("\n"));
1534        if initial_hash.is_none() {
1535            initial_hash = Some(hash);
1536        }
1537        if Some(hash) != last_hash {
1538            last_hash = Some(hash);
1539            stable_since = Instant::now();
1540        }
1541
1542        let state = recognizer.detect(&snapshot);
1543        if state == TurnState::Busy {
1544            seen_busy = true;
1545            if busy_since.is_none() {
1546                busy_since = Some(Instant::now());
1547            }
1548        } else {
1549            busy_since = None;
1550        }
1551        let busy_for = busy_since.map_or(Duration::ZERO, |since| since.elapsed());
1552        let stable_for = stable_since.elapsed();
1553        // Don't call it "done" until something actually happened since we began
1554        // waiting: the app went Busy, the screen changed from the first frame we
1555        // saw, or the warmup elapsed (instant reply / nothing to do).
1556        let activity = seen_busy || Some(hash) != initial_hash || start.elapsed() >= warmup;
1557        let timed_out = start.elapsed() >= hard_cap;
1558        if let Some(reason) = wait_turn_outcome(
1559            state,
1560            alive,
1561            activity,
1562            seen_busy,
1563            stable_for,
1564            busy_for,
1565            settle,
1566            quiet,
1567            timed_out,
1568            wait_through_busy,
1569        ) {
1570            let joined = snapshot.join("\n");
1571            let body = if joined.trim().is_empty() {
1572                "(screen is empty)".to_string()
1573            } else {
1574                truncate_to_token_budget(&joined, MAX_OUTPUT_TOKENS).into_owned()
1575            };
1576            let alt = if in_alt { " [alt-screen]" } else { "" };
1577            let header = format!(
1578                "--- turn: {} ({}, recognizer={}, waited {:.1}s){} ---",
1579                state.as_str(),
1580                reason,
1581                recognizer.name(),
1582                start.elapsed().as_secs_f64(),
1583                alt
1584            );
1585            let status = get_status(bash_state, is_bg, bg_id, running, None, None);
1586            return Ok(format!("{header}\n{body}{status}"));
1587        }
1588
1589        tokio::time::sleep(poll).await;
1590    }
1591}
1592
1593/// Execute `send_text` - matches WCGW Python's `SendText` handling
1594async fn execute_send_text(
1595    bash_state: &mut BashState,
1596    text: &str,
1597    submit: bool,
1598    bg_shell: Option<SharedPtyShell>,
1599    is_bg: bool,
1600    bg_id: Option<&str>,
1601    timeout_s: f64,
1602) -> Result<String> {
1603    debug!(bytes = text.len(), submit, "Processing SendText action");
1604
1605    // Validate - matches WCGW Python
1606    if text.is_empty() {
1607        return Err(WinxError::CommandExecutionError(
1608            "Failure: send_text cannot be empty".to_string(),
1609        ));
1610    }
1611
1612    // Get the target shell
1613    let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1614
1615    // Send text in chunks of 128 characters - matches WCGW Python exactly
1616    {
1617        let mut guard = shell_arc.lock().await;
1618
1619        let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1620        ensure_interactive_target(bash)?;
1621
1622        // Send in chunks - matches WCGW Python: for i in range(0, len(command_data.send_text), 128)
1623        send_utf8_in_byte_chunks(bash, text, TEXT_CHUNK_SIZE)?;
1624    }
1625
1626    // Only submit when explicitly asked. The Enter goes as a separate, delayed
1627    // write so Ink TUIs (Claude Code) don't swallow a CR glued to the text — a
1628    // bare CR there is otherwise treated as a soft newline and never submits.
1629    if submit {
1630        submit_enter(&shell_arc).await?;
1631    }
1632
1633    // Wait for output
1634    wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await
1635}
1636
1637/// Execute `send_specials` - matches WCGW Python's `SendSpecials` handling exactly
1638async fn execute_send_specials(
1639    bash_state: &mut BashState,
1640    keys: &[SpecialKey],
1641    submit: bool,
1642    bg_shell: Option<SharedPtyShell>,
1643    is_bg: bool,
1644    bg_id: Option<&str>,
1645    timeout_s: f64,
1646) -> Result<String> {
1647    debug!("Processing SendSpecials action: {keys:?} (submit={submit})");
1648
1649    // Validate - matches WCGW Python
1650    if keys.is_empty() {
1651        return Err(WinxError::CommandExecutionError(
1652            "Failure: send_specials cannot be empty".to_string(),
1653        ));
1654    }
1655
1656    let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1657    let mut is_interrupt = false;
1658
1659    {
1660        let mut guard = shell_arc.lock().await;
1661
1662        let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1663        ensure_interactive_target(bash)?;
1664
1665        // Send each special key - matches WCGW Python exactly
1666        for key in keys {
1667            match key {
1668                SpecialKey::KeyUp => {
1669                    // matches WCGW Python: bash_state.send("\033[A", ...)
1670                    bash.send_special_key("KeyUp").map_err(|e| {
1671                        WinxError::CommandExecutionError(format!("Failed to send KeyUp: {e}"))
1672                    })?;
1673                }
1674                SpecialKey::KeyDown => {
1675                    // matches WCGW Python: bash_state.send("\033[B", ...)
1676                    bash.send_special_key("KeyDown").map_err(|e| {
1677                        WinxError::CommandExecutionError(format!("Failed to send KeyDown: {e}"))
1678                    })?;
1679                }
1680                SpecialKey::KeyLeft => {
1681                    // matches WCGW Python: bash_state.send("\033[D", ...)
1682                    bash.send_special_key("KeyLeft").map_err(|e| {
1683                        WinxError::CommandExecutionError(format!("Failed to send KeyLeft: {e}"))
1684                    })?;
1685                }
1686                SpecialKey::KeyRight => {
1687                    // matches WCGW Python: bash_state.send("\033[C", ...)
1688                    bash.send_special_key("KeyRight").map_err(|e| {
1689                        WinxError::CommandExecutionError(format!("Failed to send KeyRight: {e}"))
1690                    })?;
1691                }
1692                SpecialKey::Enter => {
1693                    // matches WCGW Python: bash_state.send("\x0d", ...) - carriage return
1694                    bash.send_special_key("Enter").map_err(|e| {
1695                        WinxError::CommandExecutionError(format!("Failed to send Enter: {e}"))
1696                    })?;
1697                }
1698                SpecialKey::CtrlC => {
1699                    // matches WCGW Python: bash_state.sendintr()
1700                    bash.send_interrupt().map_err(|e| {
1701                        WinxError::CommandExecutionError(format!("Failed to send interrupt: {e}"))
1702                    })?;
1703                    is_interrupt = true;
1704                }
1705                SpecialKey::CtrlD => {
1706                    // matches WCGW Python: bash_state.sendintr() - same as Ctrl+C in WCGW
1707                    bash.send_eof().map_err(|e| {
1708                        WinxError::CommandExecutionError(format!("Failed to send Ctrl+D: {e}"))
1709                    })?;
1710                    is_interrupt = true;
1711                }
1712                SpecialKey::CtrlZ => {
1713                    // Ctrl+Z = SIGTSTP (suspend) - ASCII 0x1a
1714                    bash.send_suspend().map_err(|e| {
1715                        WinxError::CommandExecutionError(format!("Failed to send Ctrl+Z: {e}"))
1716                    })?;
1717                }
1718            }
1719        }
1720    }
1721
1722    // Submit as a separate, slightly-delayed write (see `submit_enter`) so an
1723    // Ink TUI doesn't drop the CR.
1724    if submit {
1725        submit_enter(&shell_arc).await?;
1726    }
1727
1728    // NOTE: wcgw treats a bare Enter as a status check and applies its
1729    // patience loop. We deliberately diverge: for a driving agent (e.g.,
1730    // pushing Enter to submit text in a TUI) the patience loop swallows the
1731    // immediate response. Callers that want patience semantics should use the
1732    // explicit `status_check` action instead.
1733
1734    // Wait for output
1735    let mut output =
1736        wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await?;
1737
1738    // Add interrupt failure message if still running - matches WCGW Python exactly
1739    if is_interrupt && output.contains("status = still running") {
1740        output.push_str("\n---\n----\nFailure interrupting.\nYou may want to try Ctrl-c again or program specific exit interactive commands.\n");
1741    }
1742
1743    Ok(output)
1744}
1745
1746/// Execute `send_ascii` - matches WCGW Python's `SendAscii` handling
1747async fn execute_send_ascii(
1748    bash_state: &mut BashState,
1749    ascii_codes: &[u8],
1750    submit: bool,
1751    bg_shell: Option<SharedPtyShell>,
1752    is_bg: bool,
1753    bg_id: Option<&str>,
1754    timeout_s: f64,
1755) -> Result<String> {
1756    debug!(bytes = ascii_codes.len(), submit, "Processing SendAscii action");
1757
1758    // Validate - matches WCGW Python
1759    if ascii_codes.is_empty() {
1760        return Err(WinxError::CommandExecutionError(
1761            "Failure: send_ascii cannot be empty".to_string(),
1762        ));
1763    }
1764
1765    let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1766    let mut is_interrupt = false;
1767
1768    {
1769        let mut guard = shell_arc.lock().await;
1770
1771        let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1772        ensure_interactive_target(bash)?;
1773
1774        // Send each ASCII code - matches WCGW Python
1775        for &code in ascii_codes {
1776            // matches WCGW Python: bash_state.send(chr(ascii_char), ...)
1777            bash.send_bytes(&[code]).map_err(|e| {
1778                WinxError::CommandExecutionError(format!("Failed to write ASCII code: {e}"))
1779            })?;
1780
1781            // Check for interrupt - matches WCGW Python: if ascii_char == 3: is_interrupt = True
1782            if code == 3 {
1783                is_interrupt = true;
1784            }
1785        }
1786    }
1787
1788    // Submit as a separate, slightly-delayed write (see `submit_enter`) so an
1789    // Ink TUI doesn't drop the CR.
1790    if submit {
1791        submit_enter(&shell_arc).await?;
1792    }
1793
1794    // Same divergence from wcgw as in `execute_send_specials`: send_ascii [10]
1795    // or [13] is treated as a direct write, not a status check. Callers that
1796    // need patience-aware reads should use `status_check`.
1797
1798    // Wait for output
1799    let mut output =
1800        wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await?;
1801
1802    // Add interrupt failure message if still running - matches WCGW Python
1803    if is_interrupt && output.contains("status = still running") {
1804        output.push_str("\n---\n----\nFailure interrupting.\nYou may want to try Ctrl-c again or program specific exit interactive commands.\n");
1805    }
1806
1807    Ok(output)
1808}
1809
1810/// Execute command in background - matches WCGW Python's `is_background` handling
1811async fn execute_in_background(
1812    bash_state: &mut BashState,
1813    command: &str,
1814    timeout_s: f64,
1815) -> Result<String> {
1816    debug!(bytes = command.len(), "Executing command in background");
1817
1818    // Start a new background shell - matches WCGW Python bash_state.start_new_bg_shell
1819    let restricted_mode =
1820        matches!(bash_state.bash_command_mode.bash_mode, crate::types::BashMode::RestrictedMode);
1821
1822    // Build the shell OFF the tokio worker AND outside the manager lock:
1823    // PtyShell::new forks+execs and blocks ~300ms on prompt init. The old
1824    // start_new_shell did all of that under the std::Mutex on the executor thread.
1825    let bg_id = {
1826        let cwd = bash_state.cwd.clone();
1827        let shell = tokio::task::spawn_blocking(move || PtyShell::new(&cwd, restricted_mode))
1828            .await
1829            .map_err(|e| {
1830                WinxError::CommandExecutionError(format!("bg shell init task failed: {e}"))
1831            })?
1832            .map_err(|e| {
1833                WinxError::CommandExecutionError(format!("Failed to start background shell: {e}"))
1834            })?;
1835        lock_bg_manager().register_shell(&bash_state.current_thread_id, shell)?
1836    };
1837
1838    // Get the shell
1839    let shell_arc = {
1840        let manager = lock_bg_manager();
1841        manager.get_shell(&bash_state.current_thread_id, &bg_id).ok_or_else(|| {
1842            WinxError::CommandExecutionError("Failed to get background shell".to_string())
1843        })?
1844    };
1845
1846    // Send command via the same PTY path used by foreground execute_command.
1847    let scratch_root = bash_state.workspace_root.clone();
1848    {
1849        let mut guard = shell_arc.lock().await;
1850        let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1851        // Offload an over-long bg output's dropped head too (send_command resets
1852        // the per-command scratch state; this points it at the workspace).
1853        bash.set_scratch_root(&scratch_root);
1854        bash.send_command(command).map_err(|e| {
1855            WinxError::CommandExecutionError(format!("Failed to send bg command: {e}"))
1856        })?;
1857    }
1858    debug!("bg[{}]: send_command returned, replying with bg_command_id", bg_id);
1859
1860    spawn_background_reaper(bash_state.current_thread_id.clone(), bg_id.clone());
1861
1862    let _ = timeout_s;
1863    let _ = shell_arc;
1864    Ok(get_status(bash_state, true, Some(&bg_id), true, None, None))
1865}
1866
1867#[cfg(test)]
1868mod wait_turn_tests {
1869    use super::wait_turn_outcome;
1870    use crate::state::turn::TurnState;
1871    use std::time::Duration;
1872
1873    const SETTLE: Duration = Duration::from_millis(300);
1874    const QUIET: Duration = Duration::from_millis(600);
1875
1876    fn call(
1877        state: TurnState,
1878        busy_for: Duration,
1879        stable_for: Duration,
1880        seen_busy: bool,
1881        timed_out: bool,
1882        wait_through_busy: bool,
1883    ) -> Option<&'static str> {
1884        // `activity` is true in these cases (something happened) unless noted.
1885        wait_turn_outcome(
1886            state,
1887            true,
1888            true,
1889            seen_busy,
1890            stable_for,
1891            busy_for,
1892            SETTLE,
1893            QUIET,
1894            timed_out,
1895            wait_through_busy,
1896        )
1897    }
1898
1899    #[test]
1900    fn confirmed_busy_returns_early_instead_of_blocking_to_timeout() {
1901        // The bug: a busy child used to pin the caller until hard_cap. Now a
1902        // busy reading held for >= settle returns "busy" with NO timeout.
1903        let out = call(TurnState::Busy, SETTLE, Duration::ZERO, true, false, false);
1904        assert_eq!(out, Some("busy"));
1905    }
1906
1907    #[test]
1908    fn busy_not_yet_confirmed_keeps_waiting() {
1909        // Seen busy for less than settle (one-frame flicker): keep polling.
1910        let out =
1911            call(TurnState::Busy, Duration::from_millis(100), Duration::ZERO, true, false, false);
1912        assert_eq!(out, None);
1913    }
1914
1915    #[test]
1916    fn wait_through_busy_blocks_through_busy_until_timeout() {
1917        // Opt-out preserves the old contract: busy never returns early...
1918        let out = call(TurnState::Busy, SETTLE * 10, Duration::ZERO, true, false, true);
1919        assert_eq!(out, None);
1920        // ...only the hard cap ends it.
1921        let out = call(TurnState::Busy, SETTLE * 10, Duration::ZERO, true, true, true);
1922        assert_eq!(out, Some("timeout"));
1923    }
1924
1925    #[test]
1926    fn awaiting_input_after_busy_is_ready_on_short_settle() {
1927        let out = call(TurnState::AwaitingInput, Duration::ZERO, SETTLE, true, false, false);
1928        assert_eq!(out, Some("ready"));
1929    }
1930
1931    #[test]
1932    fn awaiting_input_without_prior_busy_needs_full_quiet() {
1933        // No prior busy: a short settle is not enough, must wait the quiet window.
1934        let short = call(TurnState::AwaitingInput, Duration::ZERO, SETTLE, false, false, false);
1935        assert_eq!(short, None);
1936        let full = call(TurnState::AwaitingInput, Duration::ZERO, QUIET, false, false, false);
1937        assert_eq!(full, Some("ready"));
1938    }
1939
1940    #[test]
1941    fn dead_shell_reports_exited_even_if_busy() {
1942        let out = wait_turn_outcome(
1943            TurnState::Busy,
1944            false,
1945            true,
1946            true,
1947            Duration::ZERO,
1948            SETTLE,
1949            SETTLE,
1950            QUIET,
1951            false,
1952            false,
1953        );
1954        assert_eq!(out, Some("exited"));
1955    }
1956
1957    #[test]
1958    fn nothing_happening_keeps_waiting() {
1959        let out = call(TurnState::Unknown, Duration::ZERO, Duration::ZERO, false, false, false);
1960        assert_eq!(out, None);
1961    }
1962}
1963
1964#[cfg(test)]
1965mod tests {
1966    use super::{strip_tail_pipe_impl, BackgroundShellEntry, BackgroundShellManager};
1967    use crate::errors::WinxError;
1968    use std::sync::Arc;
1969    use tokio::sync::Mutex;
1970
1971    fn empty_entry(owner_thread_id: &str) -> BackgroundShellEntry {
1972        BackgroundShellEntry {
1973            owner_thread_id: owner_thread_id.to_string(),
1974            shell: Arc::new(Mutex::new(None)),
1975        }
1976    }
1977
1978    #[test]
1979    fn background_capacity_is_bounded_per_thread_and_globally() {
1980        let mut manager = BackgroundShellManager::new();
1981        for index in 0..BackgroundShellManager::MAX_SHELLS_PER_THREAD {
1982            manager.shells.insert(format!("owned-{index}"), empty_entry("owner"));
1983        }
1984        assert!(matches!(
1985            manager.ensure_capacity("owner"),
1986            Err(WinxError::CommandExecutionError(message)) if message.contains("this thread")
1987        ));
1988        assert!(manager.ensure_capacity("another-owner").is_ok());
1989
1990        for index in manager.shells.len()..BackgroundShellManager::MAX_SHELLS {
1991            manager
1992                .shells
1993                .insert(format!("global-{index}"), empty_entry(&format!("owner-{index}")));
1994        }
1995        assert!(matches!(
1996            manager.ensure_capacity("new-owner"),
1997            Err(WinxError::CommandExecutionError(message)) if message.contains("Global")
1998        ));
1999    }
2000
2001    #[test]
2002    fn strips_trailing_tail_by_default() {
2003        assert_eq!(strip_tail_pipe_impl("seq 1 5 | tail -2", false), "seq 1 5");
2004        assert_eq!(strip_tail_pipe_impl("cat log | tail -n 20", false), "cat log");
2005        assert_eq!(strip_tail_pipe_impl("cat log | tail", false), "cat log");
2006        assert_eq!(strip_tail_pipe_impl("ls -la|tail -5", false), "ls -la");
2007    }
2008
2009    #[test]
2010    fn keeps_command_without_trailing_tail() {
2011        // tail not at the end, or piped further, must be left alone.
2012        assert_eq!(strip_tail_pipe_impl("tail -f log | grep err", false), "tail -f log | grep err");
2013        assert_eq!(strip_tail_pipe_impl("echo hi", false), "echo hi");
2014        assert_eq!(
2015            strip_tail_pipe_impl("cat a | tail -5 | wc -l", false),
2016            "cat a | tail -5 | wc -l"
2017        );
2018    }
2019
2020    #[test]
2021    fn keep_mode_preserves_tail_pipe() {
2022        // WINX_KEEP_TAIL_PIPE behavior: command passes through untouched.
2023        assert_eq!(strip_tail_pipe_impl("seq 1 5 | tail -2", true), "seq 1 5 | tail -2");
2024        assert_eq!(strip_tail_pipe_impl("cat log | tail -n 20", true), "cat log | tail -n 20");
2025    }
2026}