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