Skip to main content

winx_code_agent/tools/
bash_command.rs

1//! `BashCommand` facade and action dispatcher.
2//!
3//! Execution, output/status rendering, interactive input, and TUI driving live
4//! in focused submodules under `tools/bash_command/`.
5
6mod execution;
7mod interaction;
8mod output;
9mod tui;
10
11use std::path::PathBuf;
12use std::sync::Arc;
13
14use serde::{Deserialize, Serialize};
15use tokio::sync::Mutex;
16use tracing::{error, info};
17
18pub use super::background_shell::{BackgroundShellManager, ExitedShellInfo};
19use crate::errors::{Result, WinxError};
20use crate::runtime::{
21    lock_session_store, BashCommandRuntimeResult, EmbeddedShellRuntime, ShellActionOptions,
22    ShellExecutionToken, ShellRuntime, ShellTarget,
23};
24use crate::state::bash_state::BashState;
25use crate::state::live_terminal::ScreenUpdate;
26use crate::state::pty::PtyShell;
27use crate::types::{normalize_thread_id, BashCommand, BashCommandAction};
28
29use execution::{execute_command, CommandExecutionContext};
30use interaction::{execute_send_ascii, execute_send_specials, execute_send_text};
31use output::{execute_status_check, finalize_tombstone};
32use tui::{execute_screen, execute_wait_for_turn};
33
34type SharedPtyShell = Arc<Mutex<Option<PtyShell>>>;
35
36/// Authoritative process state produced by the shell runtime. It is transported
37/// separately from human-readable terminal output, so command text cannot spoof
38/// orchestration state by printing Winx-looking markers.
39#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
40#[serde(rename_all = "snake_case")]
41pub enum BashProcessStatus {
42    Running,
43    Exited,
44}
45
46/// Machine-readable state of one `BashCommand` action.
47#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
48pub struct BashCommandState {
49    pub process_status: BashProcessStatus,
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub background_id: Option<String>,
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub running_for_seconds: Option<u64>,
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub exit_code: Option<i32>,
56    pub cwd: PathBuf,
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub turn_state: Option<crate::state::turn::TurnState>,
59}
60
61impl BashCommandState {
62    pub const fn is_running(&self) -> bool {
63        matches!(self.process_status, BashProcessStatus::Running)
64    }
65}
66
67/// Human-readable output plus runtime-owned state. Only `state` drives MCP
68/// orchestration; `output` is presentation and may contain arbitrary child text.
69#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
70pub struct BashCommandResult {
71    pub output: String,
72    pub state: BashCommandState,
73}
74
75pub(crate) fn runtime_rendered(
76    mut body: String,
77    legacy_status: &str,
78    state: BashCommandState,
79    compact_requested: bool,
80    command_generation: Option<u64>,
81    output_truncated: bool,
82) -> BashCommandRuntimeResult {
83    let compact_output = if compact_requested {
84        Some(std::mem::take(&mut body))
85    } else {
86        body.push_str(legacy_status);
87        None
88    };
89    BashCommandRuntimeResult {
90        result: BashCommandResult { output: body, state },
91        compact_output,
92        command_generation,
93        execution_token: None,
94        generation_bound_actions: true,
95        output_truncated,
96    }
97}
98
99/// Per-adapter delivery state used by daemon guardians. Embedded callers keep
100/// using the cursor stored directly on `PtyShell`, preserving legacy behavior.
101#[derive(Debug, Default)]
102pub(crate) struct ShellDeliveryCursor {
103    generation: Option<u64>,
104    delivered_output: String,
105    last_returned_hash: Option<u64>,
106    screen_snapshot: Option<Vec<String>>,
107}
108
109impl ShellDeliveryCursor {
110    fn sync_generation(&mut self, generation: u64) {
111        if self.generation != Some(generation) {
112            self.generation = Some(generation);
113            self.delivered_output.clear();
114            self.last_returned_hash = None;
115            self.screen_snapshot = None;
116        }
117    }
118
119    fn screen_update(&mut self, current: Vec<String>, threshold: usize) -> ScreenUpdate {
120        let update = match self.screen_snapshot.as_ref() {
121            Some(previous) => {
122                let rows = previous.len().max(current.len());
123                let changed = (0..rows)
124                    .filter_map(|index| {
125                        let before = previous.get(index).map_or("", String::as_str);
126                        let after = current.get(index).map_or("", String::as_str);
127                        (before != after).then(|| (index + 1, after.to_string()))
128                    })
129                    .collect::<Vec<_>>();
130                if changed.is_empty() {
131                    ScreenUpdate::Unchanged
132                } else if changed.len() <= threshold {
133                    ScreenUpdate::Diff(changed)
134                } else {
135                    ScreenUpdate::Full(current.clone())
136                }
137            }
138            None => ScreenUpdate::Full(current.clone()),
139        };
140        self.screen_snapshot = Some(current);
141        update
142    }
143}
144
145fn main_shell(bash_state: &BashState) -> SharedPtyShell {
146    let mut store = lock_session_store();
147    store.bind_main(&bash_state.current_thread_id, &bash_state.pty_shell);
148    store
149        .resolve(&bash_state.current_thread_id, &ShellTarget::Main)
150        .unwrap_or_else(|| bash_state.pty_shell.clone())
151}
152
153// Default block window when the client omits `wait_for_seconds`. Usage logs
154// showed remote clients omit it and then burn one full LLM round-trip per 5 s
155// re-check on long commands (runs of up to 18 consecutive polls); a larger
156// window resolves most commands in a single call while staying far below the
157// 120 s HTTP request timeout.
158const DEFAULT_TIMEOUT: f64 = 15.0;
159fn effective_wait_for_seconds(wait_for_seconds: Option<f32>) -> f64 {
160    wait_for_seconds.map_or(DEFAULT_TIMEOUT, |seconds| f64::from(seconds).max(0.0))
161}
162
163/// Guardian foreground preflight. The caller owns the session operation
164/// writer, so this never races token validation and runs before delivery cursor
165/// or foreground-command gates are acquired.
166pub(crate) async fn guardian_foreground_shell_needs_reset(
167    bash_state: &mut BashState,
168    options: &ShellActionOptions,
169) -> anyhow::Result<bool> {
170    let shell = main_shell(bash_state);
171    let (missing, running) = {
172        let guard = shell.lock().await;
173        (guard.is_none(), guard.as_ref().is_some_and(|shell| shell.command_running))
174    };
175    if running {
176        return Ok(false);
177    }
178    if missing {
179        bash_state.init_pty_shell().await?;
180        return Ok(false);
181    }
182    let cleared = output::clear_to_run_async(&shell, DEFAULT_TIMEOUT).await;
183    #[cfg(test)]
184    let cleared = cleared && !options.force_clear_to_run_failure;
185    #[cfg(not(test))]
186    let _ = options;
187    Ok(!cleared)
188}
189
190fn send_utf8_in_byte_chunks(shell: &mut PtyShell, text: &str, chunk_size: usize) -> Result<()> {
191    let mut start = 0;
192    while start < text.len() {
193        let mut end = (start + chunk_size).min(text.len());
194        while !text.is_char_boundary(end) {
195            end -= 1;
196        }
197        if end == start {
198            end =
199                text[start..].char_indices().nth(1).map_or(text.len(), |(index, _)| start + index);
200        }
201        shell.send_text(&text[start..end]).map_err(|error| {
202            WinxError::CommandExecutionError(format!("Failed to write PTY input: {error}"))
203        })?;
204        start = end;
205    }
206    Ok(())
207}
208
209/// Handle a `BashCommand` using the embedded runtime.
210pub async fn handle_tool_call(
211    bash_state: &Arc<Mutex<Option<BashState>>>,
212    command: BashCommand,
213) -> Result<String> {
214    handle_tool_call_with_runtime(&EmbeddedShellRuntime, bash_state, command).await
215}
216
217/// Backwards-compatible text-only API used by direct library callers.
218pub async fn handle_tool_call_with_runtime(
219    runtime: &dyn ShellRuntime,
220    bash_state: &Arc<Mutex<Option<BashState>>>,
221    command: BashCommand,
222) -> Result<String> {
223    Ok(handle_tool_call_with_runtime_detailed(runtime, bash_state, command).await?.output)
224}
225
226/// Execute a `BashCommand` while preserving the runtime-owned typed state.
227pub async fn handle_tool_call_with_runtime_detailed(
228    runtime: &dyn ShellRuntime,
229    bash_state: &Arc<Mutex<Option<BashState>>>,
230    command: BashCommand,
231) -> Result<BashCommandResult> {
232    runtime.run_action(bash_state, command).await
233}
234
235pub(crate) async fn handle_embedded_tool_call(
236    bash_state: &Arc<Mutex<Option<BashState>>>,
237    command: BashCommand,
238) -> Result<BashCommandResult> {
239    Ok(handle_embedded_tool_call_inner(
240        bash_state,
241        command,
242        None,
243        ShellActionOptions::default(),
244        false,
245    )
246    .await?
247    .result)
248}
249
250pub(crate) async fn handle_embedded_tool_call_detailed(
251    bash_state: &Arc<Mutex<Option<BashState>>>,
252    command: BashCommand,
253    options: ShellActionOptions,
254) -> Result<BashCommandRuntimeResult> {
255    handle_embedded_tool_call_inner(bash_state, command, None, options, false).await
256}
257
258pub(crate) async fn handle_embedded_tool_call_with_cursor_detailed(
259    bash_state: &Arc<Mutex<Option<BashState>>>,
260    command: BashCommand,
261    delivery_cursor: &Arc<Mutex<ShellDeliveryCursor>>,
262    options: ShellActionOptions,
263    foreground_preflight_complete: bool,
264) -> Result<BashCommandRuntimeResult> {
265    let mut delivery_cursor = delivery_cursor.lock().await;
266    handle_embedded_tool_call_inner(
267        bash_state,
268        command,
269        Some(&mut delivery_cursor),
270        options,
271        foreground_preflight_complete,
272    )
273    .await
274}
275
276async fn capture_embedded_state(
277    bash_state: &Arc<Mutex<Option<BashState>>>,
278    thread_id: &str,
279) -> Result<(BashState, tokio::sync::OwnedRwLockReadGuard<()>)> {
280    let guard = bash_state.lock().await;
281    let state = guard.as_ref().ok_or(WinxError::BashStateNotInitialized)?;
282    let operation_barrier = lock_session_store().operation_barrier(thread_id);
283    let operation_guard = operation_barrier.read_owned().await;
284    Ok((state.clone(), operation_guard))
285}
286
287#[tracing::instrument(level = "info", skip(bash_state, command, delivery_cursor))]
288async fn handle_embedded_tool_call_inner(
289    bash_state: &Arc<Mutex<Option<BashState>>>,
290    command: BashCommand,
291    delivery_cursor: Option<&mut ShellDeliveryCursor>,
292    options: ShellActionOptions,
293    foreground_preflight_complete: bool,
294) -> Result<BashCommandRuntimeResult> {
295    let action_kind = match &command.action_json {
296        BashCommandAction::Command { .. } => "command",
297        BashCommandAction::StatusCheck { .. } => "status_check",
298        BashCommandAction::SendText { .. } => "send_text",
299        BashCommandAction::SendSpecials { .. } => "send_specials",
300        BashCommandAction::SendAscii { .. } => "send_ascii",
301        BashCommandAction::Screen { .. } => "screen",
302        BashCommandAction::WaitForTurn { .. } => "wait_for_turn",
303    };
304    info!(thread_id = %command.thread_id, action = action_kind, "BashCommand tool called");
305
306    let thread_id = normalize_thread_id(&command.thread_id);
307    if thread_id.is_empty() {
308        error!("Empty thread_id provided in BashCommand");
309        return Err(WinxError::ThreadIdMismatch(
310            "Error: No saved bash state found for thread ID \"\". Please initialize first with this ID."
311                .to_string(),
312        ));
313    }
314
315    let mut local_state = {
316        let guard = bash_state.lock().await;
317        let Some(state) = &*guard else {
318            error!("BashState not initialized");
319            return Err(WinxError::BashStateNotInitialized);
320        };
321        state.clone()
322    };
323
324    if thread_id != local_state.current_thread_id {
325        let loaded = local_state.load_state_from_disk(&thread_id).map_err(|error| {
326            WinxError::CommandExecutionError(format!(
327                "Failed to load saved bash state for thread_id `{thread_id}`: {error}"
328            ))
329        })?;
330        if !loaded {
331            return Err(WinxError::ThreadIdMismatch(format!(
332                "Error: No saved bash state found for thread_id `{thread_id}`. Please initialize first with this ID."
333            )));
334        }
335        if let Some(state) = bash_state.lock().await.as_mut() {
336            *state = local_state.clone();
337        }
338    }
339
340    // BashState -> session barrier is the same order used by
341    // configure_session. Keep BashState locked until the owned read guard is
342    // acquired so reset cannot replace the incarnation after capture but
343    // before token validation/action execution.
344    let (captured_state, operation_guard) = capture_embedded_state(bash_state, &thread_id).await?;
345    local_state = captured_state;
346
347    if let Some(expected) = options.expected_execution.as_ref() {
348        if expected.guardian_epoch == "embedded" {
349            let current_epoch = local_state
350                .pty_shell
351                .lock()
352                .await
353                .as_ref()
354                .map(|shell| format!("{:016x}", shell.incarnation()));
355            if Some(expected.session_epoch.as_str()) != current_epoch.as_deref() {
356                return Err(WinxError::InvalidInput(
357                    "execution token belongs to a previous embedded shell incarnation".to_string(),
358                ));
359            }
360        }
361    }
362
363    lock_session_store().bind_main(&local_state.current_thread_id, &local_state.pty_shell);
364    let timeout_secs = effective_wait_for_seconds(command.wait_for_seconds);
365    let mut result = execute_bash_action(
366        &mut local_state,
367        &command.action_json,
368        timeout_secs,
369        delivery_cursor,
370        options,
371        foreground_preflight_complete,
372    )
373    .await;
374
375    let completed_session_epoch = local_state.pty_shell.lock().await.as_ref().map_or_else(
376        || "uninitialized".to_string(),
377        |shell| format!("{:016x}", shell.incarnation()),
378    );
379    if let Ok(outcome) = &mut result {
380        if let Some(generation) = outcome.command_generation {
381            outcome.execution_token = Some(ShellExecutionToken {
382                guardian_epoch: "embedded".to_string(),
383                session_epoch: completed_session_epoch.clone(),
384                generation,
385            });
386        }
387        if let BashCommandAction::Command { ref command, .. } = command.action_json {
388            let command = command.trim();
389            if outcome.result.output.starts_with(command) {
390                outcome.result.output = outcome.result.output[command.len()..].to_string();
391            }
392            if outcome.compact_output.as_ref().is_some_and(|output| output.starts_with(command)) {
393                let output = outcome.compact_output.take().unwrap_or_default();
394                outcome.compact_output = Some(output[command.len()..].to_string());
395            }
396        }
397    }
398
399    drop(operation_guard);
400    sync_embedded_cwd_if_current(bash_state, &local_state, &completed_session_epoch).await;
401
402    result
403}
404
405async fn sync_embedded_cwd_if_current(
406    bash_state: &Arc<Mutex<Option<BashState>>>,
407    local_state: &BashState,
408    completed_session_epoch: &str,
409) {
410    // configure_session callers own BashState before taking the operation
411    // barrier. Acquire BashState only after releasing the read side above and
412    // never let a completed old incarnation overwrite reset's cwd.
413    if let Some(state) = bash_state.lock().await.as_mut() {
414        let current_session_epoch = state.pty_shell.lock().await.as_ref().map_or_else(
415            || "uninitialized".to_string(),
416            |shell| format!("{:016x}", shell.incarnation()),
417        );
418        if current_session_epoch == completed_session_epoch {
419            state.cwd.clone_from(&local_state.cwd);
420        }
421    }
422}
423
424#[allow(clippy::too_many_lines)]
425async fn execute_bash_action(
426    bash_state: &mut BashState,
427    action: &BashCommandAction,
428    timeout_secs: f64,
429    delivery_cursor: Option<&mut ShellDeliveryCursor>,
430    options: ShellActionOptions,
431    foreground_preflight_complete: bool,
432) -> Result<BashCommandRuntimeResult> {
433    let mut is_background = false;
434    let mut background_id: Option<String> = None;
435
436    let background_shell = match action {
437        BashCommandAction::Command { .. } => None,
438        BashCommandAction::StatusCheck { bg_command_id, .. }
439        | BashCommandAction::SendText { bg_command_id, .. }
440        | BashCommandAction::SendSpecials { bg_command_id, .. }
441        | BashCommandAction::SendAscii { bg_command_id, .. }
442        | BashCommandAction::Screen { bg_command_id, .. }
443        | BashCommandAction::WaitForTurn { bg_command_id, .. } => {
444            if let Some(id) = bg_command_id {
445                let mut manager = lock_session_store();
446                manager.prune_finished_shells();
447                if let Some(shell) = manager.get_shell(&bash_state.current_thread_id, id) {
448                    is_background = true;
449                    background_id = Some(id.clone());
450                    Some(shell)
451                } else if let Some(tombstone) =
452                    manager.peek_tombstone(&bash_state.current_thread_id, id)
453                {
454                    drop(manager);
455                    return finalize_tombstone(
456                        bash_state,
457                        id,
458                        tombstone,
459                        action,
460                        options.compact_output,
461                    );
462                } else {
463                    let error = format!(
464                        "No shell found running with command id {}.\n{}",
465                        id,
466                        manager.get_running_info(&bash_state.current_thread_id)
467                    );
468                    return Err(WinxError::InvalidInput(error));
469                }
470            } else {
471                None
472            }
473        }
474    };
475
476    match action {
477        BashCommandAction::Command { command, is_background, allow_multi } => {
478            execute_command(
479                bash_state,
480                command,
481                *is_background,
482                *allow_multi,
483                timeout_secs,
484                delivery_cursor,
485                CommandExecutionContext { options: &options, foreground_preflight_complete },
486            )
487            .await
488        }
489        BashCommandAction::StatusCheck { scrollback_lines, verbose, .. } => {
490            execute_status_check(
491                bash_state,
492                background_shell,
493                is_background,
494                background_id.as_deref(),
495                timeout_secs,
496                *scrollback_lines,
497                *verbose,
498                delivery_cursor,
499                options,
500            )
501            .await
502        }
503        BashCommandAction::SendText { send_text, submit, .. } => {
504            execute_send_text(
505                bash_state,
506                send_text,
507                *submit,
508                background_shell,
509                is_background,
510                background_id.as_deref(),
511                timeout_secs,
512                delivery_cursor,
513                options.compact_output,
514            )
515            .await
516        }
517        BashCommandAction::SendSpecials { send_specials, submit, .. } => {
518            execute_send_specials(
519                bash_state,
520                send_specials,
521                *submit,
522                background_shell,
523                is_background,
524                background_id.as_deref(),
525                timeout_secs,
526                delivery_cursor,
527                options.compact_output,
528            )
529            .await
530        }
531        BashCommandAction::SendAscii { send_ascii, submit, .. } => {
532            execute_send_ascii(
533                bash_state,
534                send_ascii,
535                *submit,
536                background_shell,
537                is_background,
538                background_id.as_deref(),
539                timeout_secs,
540                delivery_cursor,
541                options.compact_output,
542            )
543            .await
544        }
545        BashCommandAction::Screen { lines, diff, .. } => {
546            execute_screen(
547                bash_state,
548                background_shell,
549                is_background,
550                background_id.as_deref(),
551                *lines,
552                *diff,
553                delivery_cursor,
554                options.compact_output,
555            )
556            .await
557        }
558        BashCommandAction::WaitForTurn {
559            recognizer,
560            quiet_ms,
561            timeout_seconds,
562            lines,
563            wait_through_busy,
564            ..
565        } => {
566            execute_wait_for_turn(
567                bash_state,
568                background_shell,
569                is_background,
570                background_id.as_deref(),
571                recognizer.as_deref(),
572                *quiet_ms,
573                *timeout_seconds,
574                *lines,
575                *wait_through_busy,
576                options.compact_output,
577            )
578            .await
579        }
580    }
581}
582
583#[cfg(test)]
584mod tests {
585    use super::{
586        effective_wait_for_seconds, runtime_rendered, BashCommandState, BashProcessStatus,
587    };
588
589    fn exited_state() -> BashCommandState {
590        BashCommandState {
591            process_status: BashProcessStatus::Exited,
592            background_id: None,
593            running_for_seconds: None,
594            exit_code: Some(0),
595            cwd: "/workspace".into(),
596            turn_state: None,
597        }
598    }
599
600    #[test]
601    fn legacy_rendering_does_not_build_a_compact_payload_without_negotiation() {
602        let result = runtime_rendered(
603            "child output".to_string(),
604            "\nstatus = process exited",
605            exited_state(),
606            false,
607            Some(1),
608            false,
609        );
610        assert_eq!(result.result.output, "child output\nstatus = process exited");
611        assert_eq!(result.compact_output, None);
612    }
613
614    #[test]
615    fn compact_rendering_is_built_directly_from_runtime_body() {
616        let result = runtime_rendered(
617            "child output".to_string(),
618            "\nstatus = process exited",
619            exited_state(),
620            true,
621            Some(1),
622            false,
623        );
624        assert_eq!(result.compact_output.as_deref(), Some("child output"));
625        assert!(result.result.output.is_empty());
626    }
627
628    #[test]
629    fn requested_wait_is_not_silently_capped() {
630        assert!((effective_wait_for_seconds(Some(120.0)) - 120.0).abs() < f64::EPSILON);
631        assert!((effective_wait_for_seconds(Some(150.0)) - 150.0).abs() < f64::EPSILON);
632    }
633
634    #[test]
635    fn omitted_wait_uses_the_larger_default_window() {
636        assert!((effective_wait_for_seconds(None) - 15.0).abs() < f64::EPSILON);
637    }
638}