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::process::{Command, Stdio};
14use std::sync::{Arc, Mutex as StdMutex};
15use std::time::{Duration, Instant};
16use tokio::sync::Mutex;
17use tokio::time::sleep;
18use tracing::{debug, error, info, warn};
19
20use crate::errors::{Result, WinxError};
21use crate::state::bash_state::BashState;
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/// Chunk size for sending commands (characters) - matches WCGW Python (64 chars)
40const COMMAND_CHUNK_SIZE: usize = 64;
41
42/// Chunk size for sending text input (characters) - matches WCGW Python (128 chars)
43const TEXT_CHUNK_SIZE: usize = 128;
44
45/// Maximum output length to prevent excessive responses
46const MAX_OUTPUT_LENGTH: usize = 100_000;
47
48/// Message when a command is already running - matches WCGW Python `WAITING_INPUT_MESSAGE`
49const 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.
501. Get its output using status check.
512. Use `send_ascii` or `send_specials` to give inputs to the running program OR
523. kill the previous program by sending ctrl+c first using `send_ascii` or `send_specials`
534. Interrupt and run the process in background
54";
55
56// ==================== Background Shell Manager ====================
57
58/// Snapshot of a background shell that has exited but whose final output has not
59/// yet been consumed by the caller. We keep it around so the next call (typically
60/// a `status_check`) can return the trailing output before the entry is gone.
61#[derive(Debug, Clone)]
62pub struct ExitedShellInfo {
63    pub last_command: String,
64    pub final_output: String,
65    pub exited_at: Instant,
66}
67
68/// Manages background shell sessions - matches WCGW Python's `background_shells` dict
69#[derive(Debug, Default)]
70pub struct BackgroundShellManager {
71    shells: HashMap<String, SharedPtyShell>,
72    /// Recently exited shells that still owe their final output to the caller.
73    /// Entries are consumed the first time the caller queries the id, then dropped.
74    tombstones: HashMap<String, ExitedShellInfo>,
75}
76
77impl BackgroundShellManager {
78    /// Tombstones older than this are garbage-collected on the next prune pass.
79    const TOMBSTONE_TTL: Duration = Duration::from_secs(300);
80
81    /// Create a new background shell manager
82    pub fn new() -> Self {
83        Self { shells: HashMap::new(), tombstones: HashMap::new() }
84    }
85
86    /// Start a new background shell and return its command ID
87    pub fn start_new_shell(&mut self, working_dir: &Path, restricted_mode: bool) -> Result<String> {
88        let cid = format!("{:010x}", rand::rng().random::<u32>());
89
90        let shell = PtyShell::new(working_dir, restricted_mode).map_err(|e| {
91            WinxError::CommandExecutionError(format!("Failed to start background shell: {e}"))
92        })?;
93
94        self.shells.insert(cid.clone(), Arc::new(Mutex::new(Some(shell))));
95
96        info!("Started background shell with id: {}", cid);
97        Ok(cid)
98    }
99
100    /// Get a background shell by its command ID
101    pub fn get_shell(&self, bg_command_id: &str) -> Option<SharedPtyShell> {
102        self.shells.get(bg_command_id).cloned()
103    }
104
105    /// Remove and cleanup a background shell
106    pub fn remove_shell(&mut self, bg_command_id: &str) -> bool {
107        if let Some(shell_arc) = self.shells.remove(bg_command_id) {
108            if let Ok(mut guard) = shell_arc.try_lock() {
109                *guard = None;
110            }
111            info!("Removed background shell: {}", bg_command_id);
112            true
113        } else {
114            false
115        }
116    }
117
118    fn prune_finished_shells(&mut self) {
119        // GC old tombstones first.
120        let now = Instant::now();
121        self.tombstones.retain(|_, info| now.duration_since(info.exited_at) < Self::TOMBSTONE_TTL);
122
123        let mut finished: Vec<(String, Option<ExitedShellInfo>)> = Vec::new();
124
125        for (id, shell_arc) in &self.shells {
126            let Ok(mut guard) = shell_arc.try_lock() else {
127                continue;
128            };
129
130            let Some(shell) = guard.as_mut() else {
131                finished.push((id.clone(), None));
132                continue;
133            };
134
135            if !shell.is_alive() {
136                let tombstone = ExitedShellInfo {
137                    last_command: shell.last_command.clone(),
138                    final_output: shell.output_buffer.clone(),
139                    exited_at: now,
140                };
141                finished.push((id.clone(), Some(tombstone)));
142                continue;
143            }
144
145            // Never prune shells that haven't received a command yet.
146            // The global BG_SHELL_MANAGER is shared across parallel tests; a freshly
147            // spawned shell would otherwise be evicted between start_new_shell and
148            // the first send_command, leading to "Failed to get background shell".
149            if shell.last_command.is_empty() {
150                continue;
151            }
152
153            if shell.command_running {
154                let _ = shell.read_output(0.1);
155            }
156
157            if !shell.command_running {
158                let tombstone = ExitedShellInfo {
159                    last_command: shell.last_command.clone(),
160                    final_output: shell.output_buffer.clone(),
161                    exited_at: now,
162                };
163                finished.push((id.clone(), Some(tombstone)));
164            }
165        }
166
167        for (id, tombstone) in finished {
168            self.remove_shell(&id);
169            if let Some(info) = tombstone {
170                self.tombstones.insert(id, info);
171            }
172        }
173    }
174
175    /// Look up the tombstone for a recently-exited shell, if any.
176    ///
177    /// The entry stays in the map until the TTL expires (see
178    /// `prune_finished_shells`), so repeated `status_check` calls on the same
179    /// `bg_command_id` keep returning the cached final output instead of
180    /// flipping to "shell not found" after the first read.
181    pub fn peek_tombstone(&self, bg_command_id: &str) -> Option<ExitedShellInfo> {
182        self.tombstones.get(bg_command_id).cloned()
183    }
184
185    /// Get info about all running background shells - matches WCGW Python `get_bg_running_commandsinfo`
186    pub fn get_running_info(&mut self) -> String {
187        self.prune_finished_shells();
188
189        if self.shells.is_empty() {
190            return "No command running in background.\n".to_string();
191        }
192
193        let mut running = Vec::new();
194        for (id, shell_arc) in &self.shells {
195            if let Ok(guard) = shell_arc.try_lock() {
196                if let Some(bash) = guard.as_ref() {
197                    if bash.command_running {
198                        running
199                            .push(format!("Command: {}, bg_command_id: {}", bash.last_command, id));
200                    }
201                }
202            } else {
203                running.push(format!("Command: <busy>, bg_command_id: {id}"));
204            }
205        }
206
207        if running.is_empty() {
208            "No command running in background.\n".to_string()
209        } else {
210            format!("Following background commands are attached:\n{}\n", running.join("\n"))
211        }
212    }
213}
214
215// Global background shell manager (thread-safe) - matches WCGW Python's BashState.background_shells
216lazy_static::lazy_static! {
217    static ref BG_SHELL_MANAGER: StdMutex<BackgroundShellManager> = StdMutex::new(BackgroundShellManager::new());
218}
219
220// ==================== WCGW-Style Helper Functions ====================
221
222/// Get WCGW-style status string - matches WCGW Python's `get_status()`
223fn get_status(
224    bash_state: &BashState,
225    is_bg: bool,
226    bg_id: Option<&str>,
227    is_running: bool,
228    running_for: Option<&str>,
229) -> String {
230    let mut status = "\n\n---\n\n".to_string();
231
232    if is_bg {
233        if let Some(id) = bg_id {
234            let _ = writeln!(status, "bg_command_id = {id}");
235        }
236    }
237
238    if is_running {
239        status.push_str("status = still running\n");
240        if let Some(duration) = running_for {
241            let _ = writeln!(status, "running for = {duration}");
242        }
243    } else {
244        status.push_str("status = process exited\n");
245    }
246
247    let _ = writeln!(status, "cwd = {}", bash_state.cwd.display());
248
249    if !is_bg {
250        // Add background shell info for main shell - matches WCGW Python
251        if let Ok(mut manager) = BG_SHELL_MANAGER.lock() {
252            status.push_str("This is the main shell. ");
253            status.push_str(&manager.get_running_info());
254        }
255    }
256
257    status.trim_end().to_string()
258}
259
260/// Process output with WCGW-style incremental text handling - matches WCGW Python _`incremental_text`
261fn wcgw_incremental_text(text: &str, last_pending_output: &str) -> String {
262    let text =
263        if text.len() > MAX_OUTPUT_LENGTH { &text[text.len() - MAX_OUTPUT_LENGTH..] } else { text };
264
265    if last_pending_output.is_empty() {
266        let rendered = render_terminal_output(text);
267        return rstrip_lines(&rendered).trim_start().to_string();
268    }
269
270    let last_rendered = render_terminal_output(last_pending_output);
271    if last_rendered.is_empty() {
272        return rstrip_lines(&render_terminal_output(text));
273    }
274
275    // Get text after last pending output
276    let text_after_last = if text.len() > last_pending_output.len() {
277        &text[last_pending_output.len()..]
278    } else {
279        text
280    };
281
282    let combined = format!("{}\n{}", last_rendered.join("\n"), text_after_last);
283    let new_rendered = render_terminal_output(&combined);
284
285    // Get incremental part - matches WCGW Python get_incremental_output
286    let incremental = get_incremental_output(&last_rendered, &new_rendered);
287    rstrip_lines(&incremental)
288}
289
290fn extract_prompt_cwd(output: &str) -> Option<PathBuf> {
291    let stripped = strip_ansi_codes(output);
292    let prompt_regex = Regex::new(r"◉ (?P<cwd>[^\r\n]*?)──➤").ok()?;
293
294    prompt_regex
295        .captures_iter(&stripped)
296        .filter_map(|captures| captures.name("cwd").map(|cwd| cwd.as_str().trim()))
297        .filter(|cwd| !cwd.is_empty())
298        .last()
299        .map(PathBuf::from)
300}
301
302/// Right-strip each line and join - matches WCGW Python rstrip
303fn rstrip_lines(lines: &[String]) -> String {
304    lines.iter().map(|line| line.trim_end()).collect::<Vec<_>>().join("\n")
305}
306
307/// Get incremental output between old and new - matches WCGW Python `get_incremental_output`
308fn get_incremental_output(old_output: &[String], new_output: &[String]) -> Vec<String> {
309    if old_output.is_empty() {
310        return new_output.to_vec();
311    }
312
313    let nold = old_output.len();
314    let nnew = new_output.len();
315
316    // Find where old output ends in new output
317    for i in (0..nnew).rev() {
318        if new_output[i] != old_output[nold - 1] {
319            continue;
320        }
321
322        let mut matched = true;
323        for j in (0..i).rev() {
324            let old_idx = (nold as i64 - 1 + j as i64 - i as i64) as isize;
325            if old_idx < 0 {
326                break;
327            }
328            if new_output[j] != old_output[old_idx as usize] {
329                matched = false;
330                break;
331            }
332        }
333
334        if matched {
335            return new_output[i + 1..].to_vec();
336        }
337    }
338
339    new_output.to_vec()
340}
341
342fn send_utf8_in_byte_chunks(shell: &mut PtyShell, text: &str, chunk_size: usize) -> Result<()> {
343    let mut start = 0;
344
345    while start < text.len() {
346        let mut end = (start + chunk_size).min(text.len());
347        while !text.is_char_boundary(end) {
348            end -= 1;
349        }
350        if end == start {
351            end = text[start..].char_indices().nth(1).map_or(text.len(), |(idx, _)| start + idx);
352        }
353
354        shell.send_text(&text[start..end]).map_err(|e| {
355            WinxError::CommandExecutionError(format!("Failed to write PTY input: {e}"))
356        })?;
357        start = end;
358    }
359
360    Ok(())
361}
362
363/// Check if action is effectively a status check - matches WCGW Python `is_status_check`
364#[allow(dead_code)]
365fn is_status_check_action(action: &BashCommandAction) -> bool {
366    match action {
367        BashCommandAction::StatusCheck { .. } => true,
368        BashCommandAction::SendSpecials { send_specials, .. } => {
369            send_specials.len() == 1 && send_specials[0] == SpecialKey::Enter
370        }
371        BashCommandAction::SendAscii { send_ascii, .. } => {
372            send_ascii.len() == 1 && send_ascii[0] == 10 // newline
373        }
374        _ => false,
375    }
376}
377
378// ==================== Main Tool Handler ====================
379
380/// Handles the `BashCommand` tool call with WCGW parity
381///
382/// This function processes the `BashCommand` tool call following WCGW Python's
383/// `execute_bash()` function behavior exactly.
384#[tracing::instrument(level = "info", skip(bash_state_arc, bash_command))]
385pub async fn handle_tool_call(
386    bash_state_arc: &Arc<Mutex<Option<BashState>>>,
387    bash_command: BashCommand,
388) -> Result<String> {
389    info!("BashCommand tool called with: {:?}", bash_command);
390
391    let thread_id = normalize_thread_id(&bash_command.thread_id);
392
393    // Check if thread_id is empty
394    if thread_id.is_empty() {
395        error!("Empty thread_id provided in BashCommand");
396        return Err(WinxError::ThreadIdMismatch(
397            "Error: No saved bash state found for thread ID \"\". Please initialize first with this ID.".to_string()
398        ));
399    }
400
401    // Extract bash_state data
402    let mut bash_state: BashState;
403    {
404        let bash_state_guard = bash_state_arc.lock().await;
405
406        let Some(state) = &*bash_state_guard else {
407            error!("BashState not initialized");
408            return Err(WinxError::BashStateNotInitialized);
409        };
410
411        bash_state = state.clone();
412    }
413
414    // Verify thread ID matches - matches WCGW Python thread_id check
415    if thread_id != bash_state.current_thread_id {
416        // Try to load state from thread_id - matches WCGW Python load_state_from_thread_id
417        if !bash_state.load_state_from_disk(&thread_id).unwrap_or(false) {
418            return Err(WinxError::ThreadIdMismatch(format!(
419                "Error: No saved bash state found for thread_id `{thread_id}`. Please initialize first with this ID."
420            )));
421        }
422    }
423
424    // Calculate effective timeout - matches WCGW Python
425    // SECURITY: Ensure timeout is not negative to prevent unexpected behavior
426    let timeout_s = bash_command
427        .wait_for_seconds
428        .map_or(DEFAULT_TIMEOUT, |t| f64::from(t).max(0.0))
429        .min(TIMEOUT_WHILE_OUTPUT);
430
431    // Execute the action based on type - matches WCGW Python's _execute_bash()
432    let result = execute_bash_action(&mut bash_state, &bash_command.action_json, timeout_s).await;
433
434    {
435        let mut bash_state_guard = bash_state_arc.lock().await;
436        if let Some(state) = bash_state_guard.as_mut() {
437            state.cwd.clone_from(&bash_state.cwd);
438        }
439    }
440
441    // Remove echo if it's a command - matches WCGW Python
442    match result {
443        Ok(mut output) => {
444            if let BashCommandAction::Command { ref command, .. } = bash_command.action_json {
445                let cmd_trimmed = command.trim();
446                if output.starts_with(cmd_trimmed) {
447                    output = output[cmd_trimmed.len()..].to_string();
448                }
449            }
450            Ok(output)
451        }
452        Err(e) => Err(e),
453    }
454}
455
456/// Execute a bash action - matches WCGW Python's _`execute_bash()` function
457async fn execute_bash_action(
458    bash_state: &mut BashState,
459    action: &BashCommandAction,
460    timeout_s: f64,
461) -> Result<String> {
462    let mut is_bg = false;
463    let mut bg_id: Option<String> = None;
464
465    // Handle bg_command_id routing - matches WCGW Python
466    let bg_shell: Option<SharedPtyShell> = match action {
467        BashCommandAction::Command { .. } => None, // Commands don't use bg_command_id for routing
468        BashCommandAction::StatusCheck { bg_command_id, .. }
469        | BashCommandAction::SendText { bg_command_id, .. }
470        | BashCommandAction::SendSpecials { bg_command_id, .. }
471        | BashCommandAction::SendAscii { bg_command_id, .. } => {
472            if let Some(id) = bg_command_id {
473                let mut manager = BG_SHELL_MANAGER.lock().map_err(|e| {
474                    WinxError::BashStateLockError(format!("Failed to lock bg manager: {e}"))
475                })?;
476                manager.prune_finished_shells();
477
478                if let Some(shell) = manager.get_shell(id) {
479                    is_bg = true;
480                    bg_id = Some(id.clone());
481                    Some(shell)
482                } else if let Some(tombstone) = manager.peek_tombstone(id) {
483                    // Shell already exited. For a status check we can hand back the
484                    // final cached output exactly once. For anything else (send_text,
485                    // send_specials, send_ascii) tell the caller the shell is gone
486                    // and include the captured output so they can recover state.
487                    drop(manager);
488                    return finalize_tombstone(&bash_state.cwd, id, tombstone, action);
489                } else {
490                    // Error message matches WCGW Python
491                    let error = format!(
492                        "No shell found running with command id {}.\n{}",
493                        id,
494                        manager.get_running_info()
495                    );
496                    return Err(WinxError::CommandExecutionError(error));
497                }
498            } else {
499                None
500            }
501        }
502    };
503
504    // Process based on action type - matches WCGW Python _execute_bash dispatch
505    match action {
506        BashCommandAction::Command { command, is_background } => {
507            execute_command(bash_state, command, *is_background, timeout_s).await
508        }
509        BashCommandAction::StatusCheck { .. } => {
510            execute_status_check(bash_state, bg_shell, is_bg, bg_id.as_deref(), timeout_s).await
511        }
512        BashCommandAction::SendText { send_text, submit, .. } => {
513            execute_send_text(
514                bash_state,
515                send_text,
516                *submit,
517                bg_shell,
518                is_bg,
519                bg_id.as_deref(),
520                timeout_s,
521            )
522            .await
523        }
524        BashCommandAction::SendSpecials { send_specials, submit, .. } => {
525            execute_send_specials(
526                bash_state,
527                send_specials,
528                *submit,
529                bg_shell,
530                is_bg,
531                bg_id.as_deref(),
532                timeout_s,
533            )
534            .await
535        }
536        BashCommandAction::SendAscii { send_ascii, submit, .. } => {
537            execute_send_ascii(
538                bash_state,
539                send_ascii,
540                *submit,
541                bg_shell,
542                is_bg,
543                bg_id.as_deref(),
544                timeout_s,
545            )
546            .await
547        }
548    }
549}
550
551/// Execute a command - matches WCGW Python's Command handling in _`execute_bash`
552async fn execute_command(
553    bash_state: &mut BashState,
554    command: &str,
555    is_background: bool,
556    timeout_s: f64,
557) -> Result<String> {
558    debug!("Processing Command action: {}", command);
559
560    // Check mode permissions - matches WCGW Python bash_command_mode check
561    if !bash_state.is_command_allowed(command) {
562        error!("Command '{}' not allowed in current mode", command);
563        return Err(WinxError::CommandNotAllowed(
564            "Error: BashCommand not allowed in current mode".to_string(),
565        ));
566    }
567
568    // Validate single statement - matches WCGW Python assert_single_statement
569    let command = command.trim();
570    crate::utils::bash_parser::assert_single_statement(command)?;
571
572    // If background execution requested, start new shell - matches WCGW Python is_background handling
573    if is_background {
574        return execute_in_background(bash_state, command, timeout_s).await;
575    }
576
577    // Check if a command is already running - matches WCGW Python state check
578    {
579        let bash_guard = bash_state.pty_shell.lock().await;
580
581        if let Some(ref bash) = *bash_guard {
582            if bash.command_running {
583                return Err(WinxError::CommandExecutionError(WAITING_INPUT_MESSAGE.to_string()));
584            }
585        }
586    }
587
588    // Initialize bash if needed
589    if bash_state.pty_shell.lock().await.is_none() {
590        bash_state
591            .init_pty_shell()
592            .await
593            .map_err(|e| WinxError::CommandExecutionError(format!("Failed to init bash: {e}")))?;
594    }
595
596    // Clear prompt before sending - matches WCGW Python clear_to_run
597    // (simplified version - WCGW does more complex clearing)
598
599    // Send command in chunks of 64 characters - matches WCGW Python exactly
600    {
601        let mut bash_guard = bash_state.pty_shell.lock().await;
602
603        let bash = bash_guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
604
605        bash.output_buffer.clear();
606        bash.output_truncated = false;
607        // Send in chunks - matches WCGW Python: for i in range(0, len(command), 64)
608        send_utf8_in_byte_chunks(bash, command, COMMAND_CHUNK_SIZE)?;
609
610        // Send linesep to execute - matches WCGW Python bash_state.send(bash_state.linesep, ...)
611        bash.send_special_key("Enter").map_err(|e| {
612            WinxError::CommandExecutionError(format!("Failed to send newline: {e}"))
613        })?;
614
615        bash.last_command = command.to_string();
616        bash.command_running = true;
617    }
618
619    // Wait for output with WCGW-style patience handling
620    let shell_arc = bash_state.pty_shell.clone();
621    wait_for_output(bash_state, &shell_arc, timeout_s, false, None, false).await
622}
623
624/// Wait for command output with WCGW-style patience handling - matches WCGW Python expect/wait logic.
625///
626/// `shell_arc` selects which shell to read from (main shell or a bg shell handle).
627async fn wait_for_output(
628    bash_state: &mut BashState,
629    shell_arc: &SharedPtyShell,
630    timeout_s: f64,
631    is_bg: bool,
632    bg_id: Option<&str>,
633    is_status_check: bool,
634) -> Result<String> {
635    let start = Instant::now();
636    let wait = timeout_s.min(TIMEOUT_WHILE_OUTPUT);
637    let mut last_pending_output = String::new();
638    let mut complete = false;
639
640    // Initial wait - matches WCGW Python wait = min(timeout_s or CONFIG.timeout, CONFIG.timeout_while_output)
641    sleep(Duration::from_secs_f64(wait.min(DEFAULT_TIMEOUT))).await;
642
643    // Read initial output
644    let mut output = {
645        let mut bash_guard = shell_arc.lock().await;
646
647        if let Some(bash) = bash_guard.as_mut() {
648            let (out, done) = bash.read_output(0.5).map_err(|e| {
649                WinxError::CommandExecutionError(format!("Failed to read output: {e}"))
650            })?;
651            complete = done;
652            out
653        } else {
654            String::new()
655        }
656    };
657
658    // If not complete and this is a status check, use WCGW-style patience waiting
659    // Matches WCGW Python: if is_status_check(bash_arg) block
660    if !complete && is_status_check {
661        let mut remaining = TIMEOUT_WHILE_OUTPUT - wait;
662        let mut patience = OUTPUT_WAIT_PATIENCE;
663
664        let incremental = wcgw_incremental_text(&output, &last_pending_output);
665        if incremental.is_empty() {
666            patience -= 1;
667        }
668
669        let mut last_incremental = incremental;
670
671        while remaining > 0.0 && patience > 0 {
672            sleep(Duration::from_secs_f64(wait.min(remaining))).await;
673
674            let (new_output, done) = {
675                let mut bash_guard = shell_arc.lock().await;
676
677                if let Some(bash) = bash_guard.as_mut() {
678                    bash.read_output(0.5).map_err(|e| {
679                        WinxError::CommandExecutionError(format!("Failed to read output: {e}"))
680                    })?
681                } else {
682                    (String::new(), true)
683                }
684            };
685
686            if done {
687                complete = true;
688                output = new_output;
689                break;
690            }
691
692            // Check if output changed - matches WCGW Python patience logic
693            let new_incremental = wcgw_incremental_text(&new_output, &last_pending_output);
694            if new_incremental == last_incremental {
695                patience -= 1;
696            } else {
697                patience = OUTPUT_WAIT_PATIENCE; // Reset patience on new output
698            }
699            last_incremental = new_incremental;
700
701            output = new_output;
702            remaining -= wait;
703        }
704
705        if !complete {
706            // Update pending output - matches WCGW Python bash_state.set_pending(text)
707            last_pending_output = output.clone();
708        }
709    }
710
711    if complete {
712        if let Some(cwd) = extract_prompt_cwd(&output) {
713            bash_state.cwd = cwd;
714        }
715    }
716
717    // Process output through terminal emulation - matches WCGW Python _incremental_text
718    let rendered = wcgw_incremental_text(&output, &last_pending_output);
719
720    // Truncate if needed - matches WCGW Python token truncation
721    let rendered = if rendered.len() > MAX_OUTPUT_LENGTH {
722        format!("(...truncated)\n{}", &rendered[rendered.len() - MAX_OUTPUT_LENGTH..])
723    } else {
724        rendered
725    };
726
727    // Calculate running duration for status
728    let running_for = if complete {
729        None
730    } else {
731        Some(format!("{} seconds", (start.elapsed().as_secs() + timeout_s as u64)))
732    };
733
734    // Add status - matches WCGW Python get_status
735    let status = get_status(bash_state, is_bg, bg_id, !complete, running_for.as_deref());
736    Ok(format!("{rendered}{status}"))
737}
738
739/// Render the final cached output of an exited background shell.
740///
741/// `status_check` is allowed to "consume" the tombstone and return the trailing
742/// output exactly once. Send-style actions (`send_text`, `send_specials`,
743/// `send_ascii`) cannot interact with a dead shell, so we return an explicit
744/// error that still includes the captured output so the agent can recover state.
745fn finalize_tombstone(
746    cwd: &Path,
747    id: &str,
748    tombstone: ExitedShellInfo,
749    action: &BashCommandAction,
750) -> Result<String> {
751    let ExitedShellInfo { last_command, final_output, .. } = tombstone;
752    match action {
753        BashCommandAction::StatusCheck { .. } => {
754            let rendered = wcgw_incremental_text(&final_output, "");
755            let rendered = if rendered.len() > MAX_OUTPUT_LENGTH {
756                format!("(...truncated)\n{}", &rendered[rendered.len() - MAX_OUTPUT_LENGTH..])
757            } else {
758                rendered
759            };
760            // Build a compact status block matching `get_status` for a finished bg shell.
761            let mut status = "\n\n---\n\n".to_string();
762            let _ = writeln!(status, "bg_command_id = {id}");
763            status.push_str("status = process exited\n");
764            let _ = writeln!(status, "cwd = {}", cwd.display());
765            Ok(format!("{rendered}{}", status.trim_end()))
766        }
767        BashCommandAction::SendText { .. }
768        | BashCommandAction::SendSpecials { .. }
769        | BashCommandAction::SendAscii { .. } => Err(WinxError::CommandExecutionError(format!(
770            "Background shell {id} already exited (last command: {last_command}).\nFinal captured output:\n{final_output}"
771        ))),
772        BashCommandAction::Command { .. } => {
773            // We only enter `finalize_tombstone` from the bg routing path, which
774            // never matches Command. Treat this as a programmer error.
775            unreachable!("finalize_tombstone called for non-bg action")
776        }
777    }
778}
779
780/// Execute a status check - matches WCGW Python's `StatusCheck` handling
781async fn execute_status_check(
782    bash_state: &mut BashState,
783    bg_shell: Option<SharedPtyShell>,
784    is_bg: bool,
785    bg_id: Option<&str>,
786    timeout_s: f64,
787) -> Result<String> {
788    debug!("Processing StatusCheck action");
789
790    // Pick the shell we're going to inspect: bg shell when bg_command_id was provided,
791    // otherwise fall back to the main interactive shell.
792    let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
793
794    // Check if there's a running command - matches WCGW Python state check
795    let is_running = {
796        let guard = shell_arc.lock().await;
797        if let Some(ref bash) = *guard {
798            bash.command_running
799        } else {
800            false
801        }
802    };
803
804    // If no command running and not background, return error - matches WCGW Python
805    if !is_running && !is_bg {
806        let mut manager = BG_SHELL_MANAGER.lock().map_err(|e| {
807            WinxError::BashStateLockError(format!("Failed to lock bg manager: {e}"))
808        })?;
809        let error =
810            format!("No running command to check status of.\n{}", manager.get_running_info());
811        return Err(WinxError::CommandExecutionError(error));
812    }
813
814    // Read output with patience handling - this IS a status check
815    wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, true).await
816}
817
818/// Execute `send_text` - matches WCGW Python's `SendText` handling
819async fn execute_send_text(
820    bash_state: &mut BashState,
821    text: &str,
822    submit: bool,
823    bg_shell: Option<SharedPtyShell>,
824    is_bg: bool,
825    bg_id: Option<&str>,
826    timeout_s: f64,
827) -> Result<String> {
828    debug!("Processing SendText action: {text:?} (submit={submit})");
829
830    // Validate - matches WCGW Python
831    if text.is_empty() {
832        return Err(WinxError::CommandExecutionError(
833            "Failure: send_text cannot be empty".to_string(),
834        ));
835    }
836
837    // Get the target shell
838    let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
839
840    // Send text in chunks of 128 characters - matches WCGW Python exactly
841    {
842        let mut guard = shell_arc.lock().await;
843
844        let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
845
846        // Send in chunks - matches WCGW Python: for i in range(0, len(command_data.send_text), 128)
847        send_utf8_in_byte_chunks(bash, text, TEXT_CHUNK_SIZE)?;
848
849        // Only append Enter when the caller explicitly asks to submit. Many TUIs
850        // (e.g., Claude Code) treat a bare CR as a soft newline inside the input
851        // box, so blindly auto-Entering interferes with multi-step interaction.
852        if submit {
853            bash.send_special_key("Enter").map_err(|e| {
854                WinxError::CommandExecutionError(format!("Failed to send newline: {e}"))
855            })?;
856        }
857    }
858
859    // Wait for output
860    wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await
861}
862
863/// Execute `send_specials` - matches WCGW Python's `SendSpecials` handling exactly
864async fn execute_send_specials(
865    bash_state: &mut BashState,
866    keys: &[SpecialKey],
867    submit: bool,
868    bg_shell: Option<SharedPtyShell>,
869    is_bg: bool,
870    bg_id: Option<&str>,
871    timeout_s: f64,
872) -> Result<String> {
873    debug!("Processing SendSpecials action: {keys:?} (submit={submit})");
874
875    // Validate - matches WCGW Python
876    if keys.is_empty() {
877        return Err(WinxError::CommandExecutionError(
878            "Failure: send_specials cannot be empty".to_string(),
879        ));
880    }
881
882    let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
883    let mut is_interrupt = false;
884
885    {
886        let mut guard = shell_arc.lock().await;
887
888        let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
889
890        // Send each special key - matches WCGW Python exactly
891        for key in keys {
892            match key {
893                SpecialKey::KeyUp => {
894                    // matches WCGW Python: bash_state.send("\033[A", ...)
895                    bash.send_special_key("KeyUp").map_err(|e| {
896                        WinxError::CommandExecutionError(format!("Failed to send KeyUp: {e}"))
897                    })?;
898                }
899                SpecialKey::KeyDown => {
900                    // matches WCGW Python: bash_state.send("\033[B", ...)
901                    bash.send_special_key("KeyDown").map_err(|e| {
902                        WinxError::CommandExecutionError(format!("Failed to send KeyDown: {e}"))
903                    })?;
904                }
905                SpecialKey::KeyLeft => {
906                    // matches WCGW Python: bash_state.send("\033[D", ...)
907                    bash.send_special_key("KeyLeft").map_err(|e| {
908                        WinxError::CommandExecutionError(format!("Failed to send KeyLeft: {e}"))
909                    })?;
910                }
911                SpecialKey::KeyRight => {
912                    // matches WCGW Python: bash_state.send("\033[C", ...)
913                    bash.send_special_key("KeyRight").map_err(|e| {
914                        WinxError::CommandExecutionError(format!("Failed to send KeyRight: {e}"))
915                    })?;
916                }
917                SpecialKey::Enter => {
918                    // matches WCGW Python: bash_state.send("\x0d", ...) - carriage return
919                    bash.send_special_key("Enter").map_err(|e| {
920                        WinxError::CommandExecutionError(format!("Failed to send Enter: {e}"))
921                    })?;
922                }
923                SpecialKey::CtrlC => {
924                    // matches WCGW Python: bash_state.sendintr()
925                    bash.send_interrupt().map_err(|e| {
926                        WinxError::CommandExecutionError(format!("Failed to send interrupt: {e}"))
927                    })?;
928                    is_interrupt = true;
929                }
930                SpecialKey::CtrlD => {
931                    // matches WCGW Python: bash_state.sendintr() - same as Ctrl+C in WCGW
932                    bash.send_eof().map_err(|e| {
933                        WinxError::CommandExecutionError(format!("Failed to send Ctrl+D: {e}"))
934                    })?;
935                    is_interrupt = true;
936                }
937                SpecialKey::CtrlZ => {
938                    // Ctrl+Z = SIGTSTP (suspend) - ASCII 0x1a
939                    bash.send_suspend().map_err(|e| {
940                        WinxError::CommandExecutionError(format!("Failed to send Ctrl+Z: {e}"))
941                    })?;
942                }
943            }
944        }
945        // Submit (append Enter) only when explicitly requested by the caller.
946        if submit {
947            bash.send_special_key("Enter")
948                .map_err(|e| WinxError::CommandExecutionError(format!("Failed to submit: {e}")))?;
949        }
950    }
951
952    // Wait for output
953    let mut output =
954        wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await?;
955
956    // Add interrupt failure message if still running - matches WCGW Python exactly
957    if is_interrupt && output.contains("status = still running") {
958        output.push_str("\n---\n----\nFailure interrupting.\nYou may want to try Ctrl-c again or program specific exit interactive commands.\n");
959    }
960
961    Ok(output)
962}
963
964/// Execute `send_ascii` - matches WCGW Python's `SendAscii` handling
965async fn execute_send_ascii(
966    bash_state: &mut BashState,
967    ascii_codes: &[u8],
968    submit: bool,
969    bg_shell: Option<SharedPtyShell>,
970    is_bg: bool,
971    bg_id: Option<&str>,
972    timeout_s: f64,
973) -> Result<String> {
974    debug!("Processing SendAscii action: {ascii_codes:?} (submit={submit})");
975
976    // Validate - matches WCGW Python
977    if ascii_codes.is_empty() {
978        return Err(WinxError::CommandExecutionError(
979            "Failure: send_ascii cannot be empty".to_string(),
980        ));
981    }
982
983    let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
984    let mut is_interrupt = false;
985
986    {
987        let mut guard = shell_arc.lock().await;
988
989        let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
990
991        // Send each ASCII code - matches WCGW Python
992        for &code in ascii_codes {
993            // matches WCGW Python: bash_state.send(chr(ascii_char), ...)
994            bash.send_bytes(&[code]).map_err(|e| {
995                WinxError::CommandExecutionError(format!("Failed to write ASCII code: {e}"))
996            })?;
997
998            // Check for interrupt - matches WCGW Python: if ascii_char == 3: is_interrupt = True
999            if code == 3 {
1000                is_interrupt = true;
1001            }
1002        }
1003        // Submit (append Enter) only when explicitly requested by the caller.
1004        if submit {
1005            bash.send_special_key("Enter")
1006                .map_err(|e| WinxError::CommandExecutionError(format!("Failed to submit: {e}")))?;
1007        }
1008    }
1009
1010    // Wait for output
1011    let mut output =
1012        wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await?;
1013
1014    // Add interrupt failure message if still running - matches WCGW Python
1015    if is_interrupt && output.contains("status = still running") {
1016        output.push_str("\n---\n----\nFailure interrupting.\nYou may want to try Ctrl-c again or program specific exit interactive commands.\n");
1017    }
1018
1019    Ok(output)
1020}
1021
1022/// Execute command in background - matches WCGW Python's `is_background` handling
1023async fn execute_in_background(
1024    bash_state: &mut BashState,
1025    command: &str,
1026    timeout_s: f64,
1027) -> Result<String> {
1028    debug!("Executing command in background: {}", command);
1029
1030    // Start a new background shell - matches WCGW Python bash_state.start_new_bg_shell
1031    let restricted_mode =
1032        matches!(bash_state.bash_command_mode.bash_mode, crate::types::BashMode::RestrictedMode);
1033
1034    let bg_id = {
1035        let mut manager = BG_SHELL_MANAGER.lock().map_err(|e| {
1036            WinxError::BashStateLockError(format!("Failed to lock bg manager: {e}"))
1037        })?;
1038        manager.start_new_shell(&bash_state.cwd, restricted_mode)?
1039    };
1040
1041    // Get the shell
1042    let shell_arc = {
1043        let manager = BG_SHELL_MANAGER.lock().map_err(|e| {
1044            WinxError::BashStateLockError(format!("Failed to lock bg manager: {e}"))
1045        })?;
1046        manager.get_shell(&bg_id).ok_or_else(|| {
1047            WinxError::CommandExecutionError("Failed to get background shell".to_string())
1048        })?
1049    };
1050
1051    // Send command via the same PTY path used by foreground execute_command.
1052    {
1053        let mut guard = shell_arc.lock().await;
1054        let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1055        bash.send_command(command).map_err(|e| {
1056            WinxError::CommandExecutionError(format!("Failed to send bg command: {e}"))
1057        })?;
1058    }
1059    debug!("bg[{}]: send_command returned, replying with bg_command_id", bg_id);
1060
1061    let _ = timeout_s;
1062    let _ = shell_arc;
1063    Ok(get_status(bash_state, true, Some(&bg_id), true, None))
1064}
1065
1066// ==================== Legacy Screen-based Functions (kept for backward compatibility) ====================
1067
1068/// Process simple command execution for a bash command (legacy)
1069#[allow(dead_code)]
1070#[tracing::instrument(level = "debug", skip(command, cwd))]
1071async fn execute_simple_command(command: &str, cwd: &Path) -> Result<String> {
1072    debug!("Executing command: {}", command);
1073
1074    let start_time = Instant::now();
1075    let mut cmd = Command::new("sh");
1076    cmd.arg("-c")
1077        .arg(command)
1078        .current_dir(cwd)
1079        .stdin(Stdio::null())
1080        .stdout(Stdio::piped())
1081        .stderr(Stdio::piped());
1082
1083    let output = cmd.output().context("Failed to execute command")?;
1084    let elapsed = start_time.elapsed();
1085
1086    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
1087    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
1088
1089    let raw_result = format!("{stdout}{stderr}");
1090    let mut result = raw_result.clone();
1091    if !raw_result.is_empty() {
1092        let rendered_lines = render_terminal_output(&raw_result);
1093        if rendered_lines.is_empty() {
1094            // Fallback: just strip ANSI codes if rendering failed or wasn't needed
1095            result = strip_ansi_codes(&raw_result);
1096        } else {
1097            result = rendered_lines.join("\n");
1098        }
1099    }
1100
1101    if result.len() > MAX_OUTPUT_LENGTH {
1102        result = format!("(...truncated)\n{}", &result[result.len() - MAX_OUTPUT_LENGTH..]);
1103    }
1104
1105    let exit_status = if output.status.success() {
1106        "Command completed successfully".to_string()
1107    } else {
1108        format!("Command failed with status: {}", output.status)
1109    };
1110
1111    let current_dir = std::env::current_dir()
1112        .map_or_else(|_| "Unknown".to_string(), |p| p.to_string_lossy().into_owned());
1113
1114    debug!("Command executed in {:.2?}", elapsed);
1115    Ok(format!("{result}\n\n---\n\nstatus = {exit_status}\ncwd = {current_dir}\n"))
1116}
1117
1118/// Execute command in screen (legacy)
1119#[allow(dead_code)]
1120#[tracing::instrument(level = "debug", skip(command, cwd, screen_name))]
1121async fn execute_in_screen(command: &str, cwd: &Path, screen_name: &str) -> Result<String> {
1122    debug!("Executing command in screen session '{}': {}", screen_name, command);
1123
1124    let screen_check = Command::new("which")
1125        .arg("screen")
1126        .output()
1127        .context("Failed to check for screen command")?;
1128
1129    if !screen_check.status.success() {
1130        warn!("Screen command not found, falling back to direct execution");
1131        return execute_simple_command(command, cwd).await;
1132    }
1133
1134    let _cleanup = Command::new("screen").args(["-X", "-S", screen_name, "quit"]).output();
1135
1136    let screen_cmd = format!(
1137        "screen -dmS {} bash -c '{} ; ec=$? ; echo \"Command completed with exit code: $ec\" ; sleep 1 ; exit $ec'",
1138        screen_name,
1139        command.replace('\'', "'\\''")
1140    );
1141
1142    let screen_start = Command::new("sh")
1143        .arg("-c")
1144        .arg(&screen_cmd)
1145        .current_dir(cwd)
1146        .output()
1147        .context("Failed to start screen session")?;
1148
1149    if !screen_start.status.success() {
1150        let stderr = String::from_utf8_lossy(&screen_start.stderr).to_string();
1151        error!("Failed to start screen session: {}", stderr);
1152        return Err(WinxError::CommandExecutionError(format!(
1153            "Failed to start screen session: {stderr}"
1154        )));
1155    }
1156
1157    sleep(Duration::from_millis(300)).await;
1158
1159    let screen_check =
1160        Command::new("screen").args(["-ls"]).output().context("Failed to list screen sessions")?;
1161
1162    let screen_list = String::from_utf8_lossy(&screen_check.stdout).to_string();
1163
1164    let current_dir = std::env::current_dir()
1165        .map_or_else(|_| "Unknown".to_string(), |p| p.to_string_lossy().into_owned());
1166
1167    Ok(format!(
1168        "Started command in background screen session '{screen_name}'.\n\
1169        Use status_check to get output.\n\n\
1170        Screen sessions:\n{screen_list}\n\
1171        ---\n\n\
1172        status = running in background\n\
1173        cwd = {current_dir}\n"
1174    ))
1175}
1176
1177/// Converts a `SpecialKey` to its screen stuff input representation (legacy)
1178#[allow(dead_code)]
1179fn special_key_to_screen_input(key: SpecialKey) -> String {
1180    match key {
1181        SpecialKey::Enter => String::from("\r"),
1182        SpecialKey::KeyUp => String::from("\x1b[A"),
1183        SpecialKey::KeyDown => String::from("\x1b[B"),
1184        SpecialKey::KeyLeft => String::from("\x1b[D"),
1185        SpecialKey::KeyRight => String::from("\x1b[C"),
1186        SpecialKey::CtrlC => String::from("\x03"),
1187        SpecialKey::CtrlD => String::from("\x04"),
1188        SpecialKey::CtrlZ => String::from("\x1a"),
1189    }
1190}