Skip to main content

winx_code_agent/tools/
bash_command.rs

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