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