pub mod background; pub mod ringbuffer; pub mod session; use std::collections::HashMap; use std::io::Read; use std::path::PathBuf; use std::process::{Command, Stdio}; use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::{mpsc, Arc, RwLock}; use std::thread; use std::time::{Duration, Instant}; use serde::Serialize; use background::{BackgroundLogResponse, BackgroundProc, BackgroundProcInfo}; use session::{SessionRunOutput, ShellSession}; const DEFAULT_TIMEOUT_SECS: u64 = 30; const MAX_TIMEOUT_SECS: u64 = 300; const MAX_OUTPUT_BYTES: usize = 256 * 1024; const POLL_INTERVAL: Duration = Duration::from_millis(50); #[derive(Serialize)] pub struct CommandOutput { pub stdout: String, pub stderr: String, pub exit_code: Option, pub timed_out: bool, pub truncated: bool, } /// Runs a one-shot command via the user's login shell. Output is capped and /// the process is force-killed on timeout. We deliberately do NOT pipe into /// the user's interactive PTY — that would fight their input. AI tool calls /// are presented in chat as their own structured result. #[tauri::command] pub async fn shell_run_command( command: String, cwd: Option, timeout_secs: Option, ) -> Result { let trimmed = command.trim().to_string(); if trimmed.is_empty() { return Err("empty command".into()); } let cwd_path = if let Some(dir) = cwd.as_deref().filter(|s| !s.is_empty()) { let p = PathBuf::from(dir); if !p.is_dir() { return Err(format!("cwd is not a directory: {}", p.display())); } Some(p) } else { None }; let dur = Duration::from_secs( timeout_secs .unwrap_or(DEFAULT_TIMEOUT_SECS) .clamp(1, MAX_TIMEOUT_SECS), ); // The blocking spawn + wait runs on a worker thread so the Tauri async // runtime stays unblocked. let (tx, rx) = mpsc::channel::>(); thread::spawn(move || { let _ = tx.send(run_blocking(trimmed, cwd_path, dur)); }); rx.recv().map_err(|e| e.to_string())? } pub(crate) fn run_blocking_inner( command: String, cwd: Option, dur: Duration, ) -> Result { run_blocking(command, cwd, dur) } fn run_blocking( command: String, cwd: Option, dur: Duration, ) -> Result { let mut cmd = build_oneshot_command(&command); if let Some(dir) = cwd { cmd.current_dir(dir); } cmd.stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); let mut child = cmd.spawn().map_err(|e| { log::warn!("shell_run_command spawn failed: {e}"); e.to_string() })?; let mut stdout_pipe = child.stdout.take().ok_or("no stdout pipe")?; let mut stderr_pipe = child.stderr.take().ok_or("no stderr pipe")?; // Drain stdout/stderr on background threads so a full pipe buffer can't // deadlock the child. let stdout_handle = thread::spawn(move || drain(&mut stdout_pipe)); let stderr_handle = thread::spawn(move || drain(&mut stderr_pipe)); let started = Instant::now(); let mut timed_out = false; let exit_code: Option = loop { match child.try_wait() { Ok(Some(status)) => break status.code(), Ok(None) => {} Err(e) => return Err(e.to_string()), } if started.elapsed() >= dur { let _ = child.kill(); let _ = child.wait(); timed_out = true; break None; } thread::sleep(POLL_INTERVAL); }; let (stdout_bytes, stdout_truncated) = stdout_handle.join().unwrap_or((Vec::new(), false)); let (stderr_bytes, stderr_truncated) = stderr_handle.join().unwrap_or((Vec::new(), false)); Ok(CommandOutput { stdout: String::from_utf8_lossy(&stdout_bytes).into_owned(), stderr: String::from_utf8_lossy(&stderr_bytes).into_owned(), exit_code, timed_out, truncated: stdout_truncated || stderr_truncated, }) } // ────────────────────────────────────────────────────────────────────────── // Persistent agent shell state + background process state. // ────────────────────────────────────────────────────────────────────────── pub struct ShellState { sessions: RwLock>>, bg: RwLock>>, next_session_id: AtomicU32, next_bg_id: AtomicU32, } impl Default for ShellState { fn default() -> Self { Self { sessions: RwLock::new(HashMap::new()), bg: RwLock::new(HashMap::new()), next_session_id: AtomicU32::new(1), next_bg_id: AtomicU32::new(1), } } } #[tauri::command] pub fn shell_session_open( state: tauri::State, cwd: Option, ) -> Result { let initial = match cwd.as_deref().filter(|s| !s.is_empty()) { Some(c) => { let p = PathBuf::from(c); if !p.is_dir() { return Err(format!("cwd is not a directory: {c}")); } p } None => dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")), }; let session = Arc::new(ShellSession::new(initial)); let id = state.next_session_id.fetch_add(1, Ordering::Relaxed); state.sessions.write().unwrap().insert(id, session); Ok(id) } #[tauri::command] pub async fn shell_session_run( state: tauri::State<'_, ShellState>, id: u32, command: String, cwd: Option, timeout_secs: Option, ) -> Result { let session = state .sessions .read() .unwrap() .get(&id) .cloned() .ok_or_else(|| "no shell session".to_string())?; let dur = Duration::from_secs( timeout_secs .unwrap_or(DEFAULT_TIMEOUT_SECS) .clamp(1, MAX_TIMEOUT_SECS), ); let (tx, rx) = mpsc::channel(); thread::spawn(move || { let _ = tx.send(session.run(command, cwd, dur)); }); rx.recv().map_err(|e| e.to_string())? } #[tauri::command] pub fn shell_session_close(state: tauri::State, id: u32) -> Result<(), String> { state.sessions.write().unwrap().remove(&id); Ok(()) } #[tauri::command] pub fn shell_bg_spawn( state: tauri::State, command: String, cwd: Option, ) -> Result { let proc = background::spawn(command, cwd)?; let id = state.next_bg_id.fetch_add(1, Ordering::Relaxed); state.bg.write().unwrap().insert(id, proc); Ok(id) } #[tauri::command] pub fn shell_bg_logs( state: tauri::State, handle: u32, since_offset: Option, ) -> Result { let proc = state .bg .read() .unwrap() .get(&handle) .cloned() .ok_or_else(|| "no background handle".to_string())?; Ok(proc.read_logs(since_offset.unwrap_or(0))) } #[tauri::command] pub fn shell_bg_kill(state: tauri::State, handle: u32) -> Result<(), String> { if let Some(proc) = state.bg.read().unwrap().get(&handle).cloned() { proc.kill(); } Ok(()) } #[tauri::command] pub fn shell_bg_list(state: tauri::State) -> Result, String> { let map = state.bg.read().unwrap(); let mut out = Vec::with_capacity(map.len()); for (id, p) in map.iter() { out.push(p.info(*id)); } out.sort_by_key(|i| i.handle); Ok(out) } pub(crate) fn build_oneshot_command(command: &str) -> Command { #[cfg(unix)] { let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()); let mut cmd = Command::new(shell); cmd.arg("-lc").arg(command); cmd } #[cfg(windows)] { let shell = crate::modules::pty::shell_init::windows_shell_path(); let mut cmd = Command::new(&shell); let is_cmd = shell .file_name() .and_then(|s| s.to_str()) .map(|s| s.eq_ignore_ascii_case("cmd.exe")) .unwrap_or(false); if is_cmd { cmd.arg("/C").arg(command); } else { cmd.arg("-NoProfile").arg("-Command").arg(command); } cmd } } fn drain(reader: &mut R) -> (Vec, bool) { let mut out = Vec::new(); let mut buf = [0u8; 8192]; let mut truncated = false; loop { match reader.read(&mut buf) { Ok(0) => break, Ok(n) => { if out.len() >= MAX_OUTPUT_BYTES { truncated = true; continue; } let take = (MAX_OUTPUT_BYTES - out.len()).min(n); out.extend_from_slice(&buf[..take]); if take < n { truncated = true; } } Err(_) => break, } } (out, truncated) }