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