winx-code-agent 0.2.349

Remote-first Rust MCP runtime with Streamable HTTP, durable PTYs, and guarded agent tools
Documentation
use std::time::Duration;

use tokio::time::sleep;
use tracing::{debug, error, warn};

use super::output::{clear_to_run_async, render_status, status_state, wait_for_output};
use super::{
    main_shell, runtime_rendered, send_utf8_in_byte_chunks, ShellDeliveryCursor, DEFAULT_TIMEOUT,
};
use crate::errors::{Result, WinxError};
use crate::runtime::{lock_session_store, BashCommandRuntimeResult, ShellActionOptions};
use crate::state::bash_state::BashState;
use crate::state::pty::PtyShell;

const COMMAND_CHUNK_SIZE: usize = 64;

pub(super) struct CommandExecutionContext<'a> {
    pub(super) options: &'a ShellActionOptions,
    pub(super) foreground_preflight_complete: bool,
}

fn spawn_background_reaper(owner_thread_id: String, background_id: String) {
    tokio::spawn(async move {
        loop {
            sleep(Duration::from_millis(100)).await;
            let shell = {
                let manager = lock_session_store();
                manager.get_shell(&owner_thread_id, &background_id)
            };
            let Some(shell) = shell else { return };

            let finished = {
                let mut guard = shell.lock().await;
                match guard.as_mut() {
                    Some(shell) => shell.poll_output_nonblocking() || !shell.is_alive(),
                    None => true,
                }
            };
            if finished {
                drop(shell);
                let removed = {
                    let mut manager = lock_session_store();
                    manager.prune_finished_shells();
                    manager.get_shell(&owner_thread_id, &background_id).is_none()
                };
                if removed {
                    return;
                }
            }
        }
    });
}

fn strip_tail_pipe(command: &str) -> String {
    strip_tail_pipe_impl(command, keep_tail_pipe())
}

fn strip_tail_pipe_impl(command: &str, keep: bool) -> String {
    static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
    if keep {
        return command.to_string();
    }
    #[allow(clippy::expect_used)]
    let regex = RE.get_or_init(|| {
        regex::Regex::new(r"\|\s*tail(?:\s+(?:-n\s*|-)?(\d+))?\s*$")
            .expect("tail-pipe regex must compile")
    });
    match regex.find(command) {
        Some(matched) => command[..matched.start()].trim_end().to_string(),
        None => command.to_string(),
    }
}

fn keep_tail_pipe() -> bool {
    crate::config::env_flag("WINX_KEEP_TAIL_PIPE")
}

#[allow(clippy::too_many_lines)]
pub(super) async fn execute_command(
    bash_state: &mut BashState,
    command: &str,
    is_background: bool,
    allow_multi: bool,
    timeout_secs: f64,
    delivery_cursor: Option<&mut ShellDeliveryCursor>,
    context: CommandExecutionContext<'_>,
) -> Result<BashCommandRuntimeResult> {
    let CommandExecutionContext { options, foreground_preflight_complete } = context;
    let stripped_command = strip_tail_pipe(command);
    let command = stripped_command.as_str();
    debug!(bytes = command.len(), allow_multi, "Processing Command action");

    if !bash_state.is_command_allowed(command) {
        error!(bytes = command.len(), "Command not allowed in current mode");
        return Err(WinxError::CommandNotAllowed(
            "Error: BashCommand not allowed in current mode".to_string(),
        ));
    }

    let command = command.trim();
    if !allow_multi {
        let allow_shell_probe = matches!(bash_state.mode, crate::types::Modes::Wcgw);
        crate::utils::bash_parser::assert_single_statement(command, allow_shell_probe)?;
    }
    crate::utils::agent_temp::validate_bash_command(
        &bash_state.workspace_root,
        &bash_state.cwd,
        &bash_state.current_thread_id,
        command,
    )?;
    if options.is_launch_cancelled() {
        return Err(WinxError::CommandExecutionError(
            "task was cancelled before command launch".to_string(),
        ));
    }
    if is_background {
        return execute_in_background(
            bash_state,
            command,
            timeout_secs,
            delivery_cursor,
            options.compact_output,
        )
        .await;
    }

    let foreground_gate = bash_state.foreground_command_gate.clone();
    let _foreground_guard = foreground_gate.lock_owned().await;
    if options.is_launch_cancelled() {
        return Err(WinxError::CommandExecutionError(
            "task was cancelled before command launch".to_string(),
        ));
    }
    let shell = main_shell(bash_state);
    {
        let guard = shell.lock().await;
        if let Some(shell) = guard.as_ref().filter(|shell| shell.command_running) {
            return Err(WinxError::CommandAlreadyRunning {
                current_command: shell.last_command.clone(),
                duration_seconds: shell
                    .command_elapsed()
                    .map_or(0.0, |elapsed| elapsed.as_secs_f64()),
            });
        }
    }

    if shell.lock().await.is_none() {
        bash_state.init_pty_shell().await.map_err(|error| {
            WinxError::CommandExecutionError(format!("Failed to init bash: {error}"))
        })?;
    }

    let needs_reset = if foreground_preflight_complete {
        false
    } else if shell.lock().await.is_some() {
        let cleared = clear_to_run_async(&shell, DEFAULT_TIMEOUT).await;
        #[cfg(test)]
        let cleared = cleared && !options.force_clear_to_run_failure;
        if cleared {
            false
        } else {
            warn!("clear_to_run: shell still busy after Ctrl-C, resetting it");
            true
        }
    } else {
        false
    };
    if needs_reset {
        if let Err(error) = bash_state.init_pty_shell().await {
            warn!("Failed to reset shell after clear_to_run: {error}");
        }
    }

    let scratch_root = bash_state.workspace_root.clone();
    {
        let mut guard = shell.lock().await;
        let shell = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
        shell.output_buffer.clear();
        shell.output_truncated = false;
        shell.reset_scratch();
        shell.set_scratch_root(&scratch_root);
        shell.last_exit_code = None;
        shell.last_returned_hash = None;
        shell.mark_output_delivered("");
        send_utf8_in_byte_chunks(shell, command, COMMAND_CHUNK_SIZE)?;
        shell.send_special_key("Enter").map_err(|error| {
            WinxError::CommandExecutionError(format!("Failed to send newline: {error}"))
        })?;
        shell.last_command = command.to_string();
        shell.command_running = true;
        shell.mark_command_started();
    }

    wait_for_output(
        bash_state,
        &shell,
        timeout_secs,
        false,
        None,
        false,
        delivery_cursor,
        options.compact_output,
        None,
    )
    .await
}

async fn execute_in_background(
    bash_state: &mut BashState,
    command: &str,
    timeout_secs: f64,
    delivery_cursor: Option<&mut ShellDeliveryCursor>,
    compact_output: bool,
) -> Result<BashCommandRuntimeResult> {
    debug!(bytes = command.len(), "Executing command in background");
    let restricted_mode =
        matches!(bash_state.bash_command_mode.bash_mode, crate::types::BashMode::RestrictedMode);

    let background_id = {
        let cwd = bash_state.cwd.clone();
        let workspace_root = bash_state.workspace_root.clone();
        let temporary_artifact_dir =
            crate::utils::agent_temp::session_info(&workspace_root, &bash_state.current_thread_id)
                .directory;
        let shell = tokio::task::spawn_blocking(move || {
            PtyShell::new_with_agent_paths(
                &cwd,
                restricted_mode,
                Some(&workspace_root),
                Some(&temporary_artifact_dir),
            )
        })
        .await
        .map_err(|error| {
            WinxError::CommandExecutionError(format!("bg shell init task failed: {error}"))
        })?
        .map_err(|error| {
            WinxError::CommandExecutionError(format!("Failed to start background shell: {error}"))
        })?;
        lock_session_store().register_shell(&bash_state.current_thread_id, shell)?
    };

    let shell = {
        let manager = lock_session_store();
        manager.get_shell(&bash_state.current_thread_id, &background_id).ok_or_else(|| {
            WinxError::CommandExecutionError("Failed to get background shell".to_string())
        })?
    };

    let scratch_root = bash_state.workspace_root.clone();
    let send_result = {
        let mut guard = shell.lock().await;
        guard.as_mut().map(|shell| {
            shell.set_scratch_root(&scratch_root);
            shell.send_command(command)
        })
    };
    let Some(send_result) = send_result else {
        lock_session_store().remove_shell(&background_id);
        return Err(WinxError::BashStateNotInitialized);
    };
    if let Err(error) = send_result {
        lock_session_store().remove_shell(&background_id);
        return Err(WinxError::CommandExecutionError(format!(
            "Failed to send bg command: {error}"
        )));
    }
    debug!("bg[{background_id}]: send_command returned, replying with bg_command_id");
    spawn_background_reaper(bash_state.current_thread_id.clone(), background_id.clone());

    let _ = (timeout_secs, delivery_cursor);
    let generation = {
        let guard = shell.lock().await;
        guard.as_ref().map(PtyShell::command_generation)
    };
    let state = status_state(Some(&background_id), true, None, None, &bash_state.cwd, None);
    let output = render_status(bash_state, &state);
    Ok(runtime_rendered(String::new(), &output, state, compact_output, generation, false))
}

#[cfg(test)]
mod tests {
    use super::strip_tail_pipe_impl;

    #[test]
    fn strips_trailing_tail_by_default() {
        assert_eq!(strip_tail_pipe_impl("seq 1 5 | tail -2", false), "seq 1 5");
        assert_eq!(strip_tail_pipe_impl("cat log | tail -n 20", false), "cat log");
        assert_eq!(strip_tail_pipe_impl("cat log | tail", false), "cat log");
        assert_eq!(strip_tail_pipe_impl("ls -la|tail -5", false), "ls -la");
    }

    #[test]
    fn keeps_command_without_trailing_tail() {
        assert_eq!(strip_tail_pipe_impl("tail -f log | grep err", false), "tail -f log | grep err");
        assert_eq!(strip_tail_pipe_impl("echo hi", false), "echo hi");
        assert_eq!(
            strip_tail_pipe_impl("cat a | tail -5 | wc -l", false),
            "cat a | tail -5 | wc -l"
        );
    }

    #[test]
    fn keep_mode_preserves_tail_pipe() {
        assert_eq!(strip_tail_pipe_impl("seq 1 5 | tail -2", true), "seq 1 5 | tail -2");
        assert_eq!(strip_tail_pipe_impl("cat log | tail -n 20", true), "cat log | tail -n 20");
    }
}