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