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