1use anyhow::Context as AnyhowContext;
8use rand::RngExt;
9use regex::Regex;
10use std::collections::HashMap;
11use std::fmt::Write as FmtWrite;
12use std::path::{Path, PathBuf};
13use std::process::{Command, Stdio};
14use std::sync::{Arc, Mutex as StdMutex};
15use std::time::{Duration, Instant};
16use tokio::sync::Mutex;
17use tokio::time::sleep;
18use tracing::{debug, error, info, warn};
19
20use crate::errors::{Result, WinxError};
21use crate::state::bash_state::BashState;
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
28const DEFAULT_TIMEOUT: f64 = 5.0;
32
33const TIMEOUT_WHILE_OUTPUT: f64 = 20.0;
35
36const OUTPUT_WAIT_PATIENCE: i32 = 3;
38
39const POLL_SLICE_SECS: f64 = 0.5;
42
43const COMMAND_CHUNK_SIZE: usize = 64;
45
46const TEXT_CHUNK_SIZE: usize = 128;
48
49const MAX_OUTPUT_LENGTH: usize = 100_000;
53
54const MAX_OUTPUT_TOKENS: usize = 25_000;
59
60fn truncate_to_token_budget(text: &str, max_tokens: usize) -> std::borrow::Cow<'_, str> {
68 if text.len() <= MAX_OUTPUT_LENGTH {
69 return std::borrow::Cow::Borrowed(text);
70 }
71
72 let Some(tokens) = crate::utils::encoder::encode_ids(text) else {
73 return std::borrow::Cow::Owned(format!(
75 "(...truncated)\n{}",
76 &text[text.len() - MAX_OUTPUT_LENGTH..]
77 ));
78 };
79
80 if tokens.len() <= max_tokens {
81 return std::borrow::Cow::Borrowed(text);
82 }
83
84 let keep = max_tokens.saturating_sub(1);
86 let tail = &tokens[tokens.len() - keep..];
87 let decoded = crate::utils::encoder::decode_ids(tail).unwrap_or_else(|| {
88 text[text.len() - MAX_OUTPUT_LENGTH.min(text.len())..].to_string()
90 });
91 std::borrow::Cow::Owned(format!("(...truncated)\n{decoded}"))
92}
93
94const 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.
961. Get its output using status check.
972. Use `send_ascii` or `send_specials` to give inputs to the running program OR
983. kill the previous program by sending ctrl+c first using `send_ascii` or `send_specials`
994. Interrupt and run the process in background
100";
101
102#[derive(Debug, Clone)]
108pub struct ExitedShellInfo {
109 pub last_command: String,
110 pub final_output: String,
111 pub exited_at: Instant,
112}
113
114#[derive(Debug, Default)]
116pub struct BackgroundShellManager {
117 shells: HashMap<String, SharedPtyShell>,
118 tombstones: HashMap<String, ExitedShellInfo>,
121}
122
123impl BackgroundShellManager {
124 const TOMBSTONE_TTL: Duration = Duration::from_secs(300);
126
127 pub fn new() -> Self {
129 Self { shells: HashMap::new(), tombstones: HashMap::new() }
130 }
131
132 pub fn start_new_shell(&mut self, working_dir: &Path, restricted_mode: bool) -> Result<String> {
134 let cid = format!("{:010x}", rand::rng().random::<u32>());
135
136 let shell = PtyShell::new(working_dir, restricted_mode).map_err(|e| {
137 WinxError::CommandExecutionError(format!("Failed to start background shell: {e}"))
138 })?;
139
140 self.shells.insert(cid.clone(), Arc::new(Mutex::new(Some(shell))));
141
142 info!("Started background shell with id: {}", cid);
143 Ok(cid)
144 }
145
146 pub fn get_shell(&self, bg_command_id: &str) -> Option<SharedPtyShell> {
148 self.shells.get(bg_command_id).cloned()
149 }
150
151 pub fn remove_shell(&mut self, bg_command_id: &str) -> bool {
153 if let Some(shell_arc) = self.shells.remove(bg_command_id) {
154 if let Ok(mut guard) = shell_arc.try_lock() {
155 *guard = None;
156 }
157 info!("Removed background shell: {}", bg_command_id);
158 true
159 } else {
160 false
161 }
162 }
163
164 fn prune_finished_shells(&mut self) {
165 let now = Instant::now();
167 self.tombstones.retain(|_, info| now.duration_since(info.exited_at) < Self::TOMBSTONE_TTL);
168
169 let mut finished: Vec<(String, Option<ExitedShellInfo>)> = Vec::new();
170
171 for (id, shell_arc) in &self.shells {
172 let Ok(mut guard) = shell_arc.try_lock() else {
173 continue;
174 };
175
176 let Some(shell) = guard.as_mut() else {
177 finished.push((id.clone(), None));
178 continue;
179 };
180
181 if !shell.is_alive() {
182 let tombstone = ExitedShellInfo {
183 last_command: shell.last_command.clone(),
184 final_output: shell.output_buffer.clone(),
185 exited_at: now,
186 };
187 finished.push((id.clone(), Some(tombstone)));
188 continue;
189 }
190
191 if shell.last_command.is_empty() {
196 continue;
197 }
198
199 if shell.command_running {
200 let _ = shell.read_output(0.1);
201 }
202
203 if !shell.command_running {
204 let tombstone = ExitedShellInfo {
205 last_command: shell.last_command.clone(),
206 final_output: shell.output_buffer.clone(),
207 exited_at: now,
208 };
209 finished.push((id.clone(), Some(tombstone)));
210 }
211 }
212
213 for (id, tombstone) in finished {
214 self.remove_shell(&id);
215 if let Some(info) = tombstone {
216 self.tombstones.insert(id, info);
217 }
218 }
219 }
220
221 pub fn peek_tombstone(&self, bg_command_id: &str) -> Option<ExitedShellInfo> {
228 self.tombstones.get(bg_command_id).cloned()
229 }
230
231 pub fn get_running_info(&mut self) -> String {
233 self.prune_finished_shells();
234
235 if self.shells.is_empty() {
236 return "No command running in background.\n".to_string();
237 }
238
239 let mut running = Vec::new();
240 for (id, shell_arc) in &self.shells {
241 if let Ok(guard) = shell_arc.try_lock() {
242 if let Some(bash) = guard.as_ref() {
243 if bash.command_running {
244 running
245 .push(format!("Command: {}, bg_command_id: {}", bash.last_command, id));
246 }
247 }
248 } else {
249 running.push(format!("Command: <busy>, bg_command_id: {id}"));
250 }
251 }
252
253 if running.is_empty() {
254 "No command running in background.\n".to_string()
255 } else {
256 format!("Following background commands are attached:\n{}\n", running.join("\n"))
257 }
258 }
259}
260
261lazy_static::lazy_static! {
263 static ref BG_SHELL_MANAGER: StdMutex<BackgroundShellManager> = StdMutex::new(BackgroundShellManager::new());
264}
265
266fn get_status(
270 bash_state: &BashState,
271 is_bg: bool,
272 bg_id: Option<&str>,
273 is_running: bool,
274 running_for: Option<&str>,
275) -> String {
276 let mut status = "\n\n---\n\n".to_string();
277
278 if is_bg {
279 if let Some(id) = bg_id {
280 let _ = writeln!(status, "bg_command_id = {id}");
281 }
282 }
283
284 if is_running {
285 status.push_str("status = still running\n");
286 if let Some(duration) = running_for {
287 let _ = writeln!(status, "running for = {duration}");
288 }
289 } else {
290 status.push_str("status = process exited\n");
291 }
292
293 let _ = writeln!(status, "cwd = {}", bash_state.cwd.display());
294
295 if !is_bg {
296 if let Ok(mut manager) = BG_SHELL_MANAGER.lock() {
298 status.push_str("This is the main shell. ");
299 status.push_str(&manager.get_running_info());
300 }
301 }
302
303 status.trim_end().to_string()
304}
305
306fn wcgw_incremental_text(text: &str, last_pending_output: &str) -> String {
308 let truncated = truncate_to_token_budget(text, MAX_OUTPUT_TOKENS);
309 let text = truncated.as_ref();
310
311 if last_pending_output.is_empty() {
312 let rendered = render_terminal_output(text);
313 return rstrip_lines(&rendered).trim_start().to_string();
314 }
315
316 let last_rendered = render_terminal_output(last_pending_output);
317 if last_rendered.is_empty() {
318 return rstrip_lines(&render_terminal_output(text));
319 }
320
321 let text_after_last = if text.len() > last_pending_output.len() {
323 &text[last_pending_output.len()..]
324 } else {
325 text
326 };
327
328 let combined = format!("{}\n{}", last_rendered.join("\n"), text_after_last);
329 let new_rendered = render_terminal_output(&combined);
330
331 let incremental = get_incremental_output(&last_rendered, &new_rendered);
333 rstrip_lines(&incremental)
334}
335
336fn extract_prompt_cwd(output: &str) -> Option<PathBuf> {
337 let stripped = strip_ansi_codes(output);
338 let prompt_regex = Regex::new(r"◉ (?P<cwd>[^\r\n]*?)──➤").ok()?;
339
340 prompt_regex
341 .captures_iter(&stripped)
342 .filter_map(|captures| captures.name("cwd").map(|cwd| cwd.as_str().trim()))
343 .filter(|cwd| !cwd.is_empty())
344 .last()
345 .map(PathBuf::from)
346}
347
348fn rstrip_lines(lines: &[String]) -> String {
350 lines.iter().map(|line| line.trim_end()).collect::<Vec<_>>().join("\n")
351}
352
353fn get_incremental_output(old_output: &[String], new_output: &[String]) -> Vec<String> {
355 if old_output.is_empty() {
356 return new_output.to_vec();
357 }
358
359 let nold = old_output.len();
360 let nnew = new_output.len();
361
362 for i in (0..nnew).rev() {
364 if new_output[i] != old_output[nold - 1] {
365 continue;
366 }
367
368 let mut matched = true;
369 for j in (0..i).rev() {
370 let old_idx = (nold as i64 - 1 + j as i64 - i as i64) as isize;
371 if old_idx < 0 {
372 break;
373 }
374 if new_output[j] != old_output[old_idx as usize] {
375 matched = false;
376 break;
377 }
378 }
379
380 if matched {
381 return new_output[i + 1..].to_vec();
382 }
383 }
384
385 new_output.to_vec()
386}
387
388fn send_utf8_in_byte_chunks(shell: &mut PtyShell, text: &str, chunk_size: usize) -> Result<()> {
389 let mut start = 0;
390
391 while start < text.len() {
392 let mut end = (start + chunk_size).min(text.len());
393 while !text.is_char_boundary(end) {
394 end -= 1;
395 }
396 if end == start {
397 end = text[start..].char_indices().nth(1).map_or(text.len(), |(idx, _)| start + idx);
398 }
399
400 shell.send_text(&text[start..end]).map_err(|e| {
401 WinxError::CommandExecutionError(format!("Failed to write PTY input: {e}"))
402 })?;
403 start = end;
404 }
405
406 Ok(())
407}
408
409#[allow(dead_code)]
411fn is_status_check_action(action: &BashCommandAction) -> bool {
412 match action {
413 BashCommandAction::StatusCheck { .. } => true,
414 BashCommandAction::SendSpecials { send_specials, .. } => {
415 send_specials.len() == 1 && send_specials[0] == SpecialKey::Enter
416 }
417 BashCommandAction::SendAscii { send_ascii, .. } => {
418 send_ascii.len() == 1 && send_ascii[0] == 10 }
420 _ => false,
421 }
422}
423
424#[tracing::instrument(level = "info", skip(bash_state_arc, bash_command))]
431pub async fn handle_tool_call(
432 bash_state_arc: &Arc<Mutex<Option<BashState>>>,
433 bash_command: BashCommand,
434) -> Result<String> {
435 info!("BashCommand tool called with: {:?}", bash_command);
436
437 let thread_id = normalize_thread_id(&bash_command.thread_id);
438
439 if thread_id.is_empty() {
441 error!("Empty thread_id provided in BashCommand");
442 return Err(WinxError::ThreadIdMismatch(
443 "Error: No saved bash state found for thread ID \"\". Please initialize first with this ID.".to_string()
444 ));
445 }
446
447 let mut bash_state: BashState;
449 {
450 let bash_state_guard = bash_state_arc.lock().await;
451
452 let Some(state) = &*bash_state_guard else {
453 error!("BashState not initialized");
454 return Err(WinxError::BashStateNotInitialized);
455 };
456
457 bash_state = state.clone();
458 }
459
460 if thread_id != bash_state.current_thread_id {
462 if !bash_state.load_state_from_disk(&thread_id).unwrap_or(false) {
464 return Err(WinxError::ThreadIdMismatch(format!(
465 "Error: No saved bash state found for thread_id `{thread_id}`. Please initialize first with this ID."
466 )));
467 }
468 }
469
470 let timeout_s = bash_command
473 .wait_for_seconds
474 .map_or(DEFAULT_TIMEOUT, |t| f64::from(t).max(0.0))
475 .min(TIMEOUT_WHILE_OUTPUT);
476
477 let result = execute_bash_action(&mut bash_state, &bash_command.action_json, timeout_s).await;
479
480 {
481 let mut bash_state_guard = bash_state_arc.lock().await;
482 if let Some(state) = bash_state_guard.as_mut() {
483 state.cwd.clone_from(&bash_state.cwd);
484 }
485 }
486
487 match result {
489 Ok(mut output) => {
490 if let BashCommandAction::Command { ref command, .. } = bash_command.action_json {
491 let cmd_trimmed = command.trim();
492 if output.starts_with(cmd_trimmed) {
493 output = output[cmd_trimmed.len()..].to_string();
494 }
495 }
496 Ok(output)
497 }
498 Err(e) => Err(e),
499 }
500}
501
502async fn execute_bash_action(
504 bash_state: &mut BashState,
505 action: &BashCommandAction,
506 timeout_s: f64,
507) -> Result<String> {
508 let mut is_bg = false;
509 let mut bg_id: Option<String> = None;
510
511 let bg_shell: Option<SharedPtyShell> = match action {
513 BashCommandAction::Command { .. } => None, BashCommandAction::StatusCheck { bg_command_id, .. }
515 | BashCommandAction::SendText { bg_command_id, .. }
516 | BashCommandAction::SendSpecials { bg_command_id, .. }
517 | BashCommandAction::SendAscii { bg_command_id, .. } => {
518 if let Some(id) = bg_command_id {
519 let mut manager = BG_SHELL_MANAGER.lock().map_err(|e| {
520 WinxError::BashStateLockError(format!("Failed to lock bg manager: {e}"))
521 })?;
522 manager.prune_finished_shells();
523
524 if let Some(shell) = manager.get_shell(id) {
525 is_bg = true;
526 bg_id = Some(id.clone());
527 Some(shell)
528 } else if let Some(tombstone) = manager.peek_tombstone(id) {
529 drop(manager);
534 return finalize_tombstone(&bash_state.cwd, id, tombstone, action);
535 } else {
536 let error = format!(
538 "No shell found running with command id {}.\n{}",
539 id,
540 manager.get_running_info()
541 );
542 return Err(WinxError::CommandExecutionError(error));
543 }
544 } else {
545 None
546 }
547 }
548 };
549
550 match action {
552 BashCommandAction::Command { command, is_background, allow_multi } => {
553 execute_command(bash_state, command, *is_background, *allow_multi, timeout_s).await
554 }
555 BashCommandAction::StatusCheck { scrollback_lines, verbose, .. } => {
556 execute_status_check(
557 bash_state,
558 bg_shell,
559 is_bg,
560 bg_id.as_deref(),
561 timeout_s,
562 *scrollback_lines,
563 *verbose,
564 )
565 .await
566 }
567 BashCommandAction::SendText { send_text, submit, .. } => {
568 execute_send_text(
569 bash_state,
570 send_text,
571 *submit,
572 bg_shell,
573 is_bg,
574 bg_id.as_deref(),
575 timeout_s,
576 )
577 .await
578 }
579 BashCommandAction::SendSpecials { send_specials, submit, .. } => {
580 execute_send_specials(
581 bash_state,
582 send_specials,
583 *submit,
584 bg_shell,
585 is_bg,
586 bg_id.as_deref(),
587 timeout_s,
588 )
589 .await
590 }
591 BashCommandAction::SendAscii { send_ascii, submit, .. } => {
592 execute_send_ascii(
593 bash_state,
594 send_ascii,
595 *submit,
596 bg_shell,
597 is_bg,
598 bg_id.as_deref(),
599 timeout_s,
600 )
601 .await
602 }
603 }
604}
605
606fn strip_tail_pipe(command: &str) -> String {
616 strip_tail_pipe_impl(command, keep_tail_pipe())
617}
618
619fn strip_tail_pipe_impl(command: &str, keep: bool) -> String {
622 static RE: std::sync::OnceLock<Option<regex::Regex>> = std::sync::OnceLock::new();
623 if keep {
624 return command.to_string();
625 }
626 let re = RE.get_or_init(|| regex::Regex::new(r"\|\s*tail(?:\s+(?:-n\s*|-)?(\d+))?\s*$").ok());
627 match re.as_ref().and_then(|re| re.find(command)) {
628 Some(matched) => command[..matched.start()].trim_end().to_string(),
629 None => command.to_string(),
630 }
631}
632
633fn keep_tail_pipe() -> bool {
635 std::env::var("WINX_KEEP_TAIL_PIPE").is_ok_and(|value| {
636 let value = value.trim();
637 !value.is_empty() && value != "0" && !value.eq_ignore_ascii_case("false")
638 })
639}
640
641async fn execute_command(
643 bash_state: &mut BashState,
644 command: &str,
645 is_background: bool,
646 allow_multi: bool,
647 timeout_s: f64,
648) -> Result<String> {
649 let stripped_command = strip_tail_pipe(command);
651 let command = stripped_command.as_str();
652 debug!("Processing Command action: {command:?} (allow_multi={allow_multi})");
653
654 if !bash_state.is_command_allowed(command) {
656 error!("Command '{}' not allowed in current mode", command);
657 return Err(WinxError::CommandNotAllowed(
658 "Error: BashCommand not allowed in current mode".to_string(),
659 ));
660 }
661
662 let command = command.trim();
666 if !allow_multi {
667 crate::utils::bash_parser::assert_single_statement(command)?;
668 }
669
670 if is_background {
672 return execute_in_background(bash_state, command, timeout_s).await;
673 }
674
675 {
677 let bash_guard = bash_state.pty_shell.lock().await;
678
679 if let Some(ref bash) = *bash_guard {
680 if bash.command_running {
681 return Err(WinxError::CommandExecutionError(WAITING_INPUT_MESSAGE.to_string()));
682 }
683 }
684 }
685
686 if bash_state.pty_shell.lock().await.is_none() {
688 bash_state
689 .init_pty_shell()
690 .await
691 .map_err(|e| WinxError::CommandExecutionError(format!("Failed to init bash: {e}")))?;
692 }
693
694 {
699 let mut bash_guard = bash_state.pty_shell.lock().await;
700 if let Some(bash) = bash_guard.as_mut() {
701 if let Err(e) = bash.clear_to_run(DEFAULT_TIMEOUT as f32) {
702 warn!("clear_to_run failed before send: {e}");
703 }
704 }
705 }
706
707 {
709 let mut bash_guard = bash_state.pty_shell.lock().await;
710
711 let bash = bash_guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
712
713 bash.output_buffer.clear();
714 bash.output_truncated = false;
715 send_utf8_in_byte_chunks(bash, command, COMMAND_CHUNK_SIZE)?;
717
718 bash.send_special_key("Enter").map_err(|e| {
720 WinxError::CommandExecutionError(format!("Failed to send newline: {e}"))
721 })?;
722
723 bash.last_command = command.to_string();
724 bash.command_running = true;
725 }
726
727 let shell_arc = bash_state.pty_shell.clone();
729 wait_for_output(bash_state, &shell_arc, timeout_s, false, None, false).await
730}
731
732async fn wait_for_output(
736 bash_state: &mut BashState,
737 shell_arc: &SharedPtyShell,
738 timeout_s: f64,
739 is_bg: bool,
740 bg_id: Option<&str>,
741 is_status_check: bool,
742) -> Result<String> {
743 let start = Instant::now();
744 let wait = timeout_s.min(TIMEOUT_WHILE_OUTPUT);
745 let mut last_pending_output = String::new();
746 let mut complete = false;
747
748 let mut output = String::new();
756 loop {
757 let elapsed = start.elapsed().as_secs_f64();
758 if elapsed >= wait {
759 break;
760 }
761 let slice = (wait - elapsed).clamp(0.1, POLL_SLICE_SECS);
762 let (out, done) = {
763 let mut bash_guard = shell_arc.lock().await;
764 match bash_guard.as_mut() {
765 Some(bash) => bash.read_output(slice as f32).map_err(|e| {
766 WinxError::CommandExecutionError(format!("Failed to read output: {e}"))
767 })?,
768 None => (String::new(), true),
769 }
770 };
771 output = out;
772 complete = done;
773 if complete {
774 break;
775 }
776 }
777
778 if !complete && is_status_check {
787 let budget_secs = timeout_s.min(TIMEOUT_WHILE_OUTPUT);
788 let iter_wait_secs = 0.5_f64;
789 let mut patience = OUTPUT_WAIT_PATIENCE;
790
791 let incremental = wcgw_incremental_text(&output, &last_pending_output);
792 if incremental.is_empty() {
793 patience -= 1;
794 }
795
796 let mut last_incremental = incremental;
797
798 while start.elapsed().as_secs_f64() < budget_secs && patience > 0 {
799 let remaining = (budget_secs - start.elapsed().as_secs_f64()).max(0.0);
800 if remaining < 0.1 {
801 break;
802 }
803 sleep(Duration::from_secs_f64(iter_wait_secs.min(remaining))).await;
804
805 let (new_output, done) = {
806 let mut bash_guard = shell_arc.lock().await;
807
808 if let Some(bash) = bash_guard.as_mut() {
809 bash.read_output(0.5).map_err(|e| {
810 WinxError::CommandExecutionError(format!("Failed to read output: {e}"))
811 })?
812 } else {
813 (String::new(), true)
814 }
815 };
816
817 if done {
818 complete = true;
819 output = new_output;
820 break;
821 }
822
823 let new_incremental = wcgw_incremental_text(&new_output, &last_pending_output);
825 if new_incremental == last_incremental {
826 patience -= 1;
827 } else {
828 patience = OUTPUT_WAIT_PATIENCE; }
830 last_incremental = new_incremental;
831
832 output = new_output;
833 }
834
835 if !complete {
836 last_pending_output = output.clone();
838 }
839 }
840
841 if complete {
842 if let Some(cwd) = extract_prompt_cwd(&output) {
843 bash_state.cwd = cwd;
844 }
845 }
846
847 let rendered = wcgw_incremental_text(&output, &last_pending_output);
849
850 let rendered = crate::utils::output_compress::compress_output(&rendered).unwrap_or(rendered);
854
855 let rendered = truncate_to_token_budget(&rendered, MAX_OUTPUT_TOKENS).into_owned();
857
858 let running_for = if complete {
860 None
861 } else {
862 Some(format!("{} seconds", (start.elapsed().as_secs() + timeout_s as u64)))
863 };
864
865 let status = get_status(bash_state, is_bg, bg_id, !complete, running_for.as_deref());
867 Ok(format!("{rendered}{status}"))
868}
869
870fn finalize_tombstone(
877 cwd: &Path,
878 id: &str,
879 tombstone: ExitedShellInfo,
880 action: &BashCommandAction,
881) -> Result<String> {
882 let ExitedShellInfo { last_command, final_output, .. } = tombstone;
883 match action {
884 BashCommandAction::StatusCheck { .. } => {
885 let rendered = wcgw_incremental_text(&final_output, "");
886 let rendered = truncate_to_token_budget(&rendered, MAX_OUTPUT_TOKENS).into_owned();
887 let mut status = "\n\n---\n\n".to_string();
889 let _ = writeln!(status, "bg_command_id = {id}");
890 status.push_str("status = process exited\n");
891 let _ = writeln!(status, "cwd = {}", cwd.display());
892 Ok(format!("{rendered}{}", status.trim_end()))
893 }
894 BashCommandAction::SendText { .. }
895 | BashCommandAction::SendSpecials { .. }
896 | BashCommandAction::SendAscii { .. } => Err(WinxError::CommandExecutionError(format!(
897 "Background shell {id} already exited (last command: {last_command}).\nFinal captured output:\n{final_output}"
898 ))),
899 BashCommandAction::Command { .. } => {
900 unreachable!("finalize_tombstone called for non-bg action")
903 }
904 }
905}
906
907async fn execute_status_check(
916 bash_state: &mut BashState,
917 bg_shell: Option<SharedPtyShell>,
918 is_bg: bool,
919 bg_id: Option<&str>,
920 timeout_s: f64,
921 scrollback_lines: Option<usize>,
922 verbose: bool,
923) -> Result<String> {
924 debug!("Processing StatusCheck action (verbose={verbose}, scrollback={scrollback_lines:?})");
925
926 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
929
930 let is_running = {
932 let guard = shell_arc.lock().await;
933 if let Some(ref bash) = *guard {
934 bash.command_running
935 } else {
936 false
937 }
938 };
939
940 if !is_running && !is_bg {
942 let mut manager = BG_SHELL_MANAGER.lock().map_err(|e| {
943 WinxError::BashStateLockError(format!("Failed to lock bg manager: {e}"))
944 })?;
945 let error = format!(
946 "No command is currently running, so there's nothing to check. The previous \
947 command already finished and its output was returned when it completed. Start a \
948 new command, or pass a bg_command_id if you launched one in the background.\n{}",
949 manager.get_running_info()
950 );
951 return Err(WinxError::CommandExecutionError(error));
952 }
953
954 let response = wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, true).await?;
956
957 let body = response.split("\n\n---\n").next().unwrap_or(&response);
961 if !verbose && scrollback_lines.is_none() {
962 let mut guard = shell_arc.lock().await;
963 if let Some(bash) = guard.as_mut() {
964 let fingerprint = PtyShell::fingerprint(body);
965 if Some(fingerprint) == bash.last_returned_hash {
966 let status = get_status(bash_state, is_bg, bg_id, is_running, None);
967 return Ok(format!("no new output since last check{status}"));
968 }
969 bash.last_returned_hash = Some(fingerprint);
970 }
971 } else if !verbose {
972 let mut guard = shell_arc.lock().await;
974 if let Some(bash) = guard.as_mut() {
975 bash.last_returned_hash = Some(PtyShell::fingerprint(body));
976 }
977 }
978
979 if let Some(lines) = scrollback_lines {
981 if lines > 0 {
982 let scrollback = {
983 let guard = shell_arc.lock().await;
984 guard.as_ref().map(|s| s.collect_scrollback(lines)).unwrap_or_default()
985 };
986 if !scrollback.is_empty() {
987 let count = scrollback.lines().count();
988 return Ok(format!(
989 "--- scrollback ({count} lines) ---\n{scrollback}\n--- latest ---\n{response}"
990 ));
991 }
992 }
993 }
994
995 Ok(response)
996}
997
998async fn execute_send_text(
1000 bash_state: &mut BashState,
1001 text: &str,
1002 submit: bool,
1003 bg_shell: Option<SharedPtyShell>,
1004 is_bg: bool,
1005 bg_id: Option<&str>,
1006 timeout_s: f64,
1007) -> Result<String> {
1008 debug!("Processing SendText action: {text:?} (submit={submit})");
1009
1010 if text.is_empty() {
1012 return Err(WinxError::CommandExecutionError(
1013 "Failure: send_text cannot be empty".to_string(),
1014 ));
1015 }
1016
1017 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1019
1020 {
1022 let mut guard = shell_arc.lock().await;
1023
1024 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1025
1026 send_utf8_in_byte_chunks(bash, text, TEXT_CHUNK_SIZE)?;
1028
1029 if submit {
1033 bash.send_special_key("Enter").map_err(|e| {
1034 WinxError::CommandExecutionError(format!("Failed to send newline: {e}"))
1035 })?;
1036 }
1037 }
1038
1039 wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await
1041}
1042
1043async fn execute_send_specials(
1045 bash_state: &mut BashState,
1046 keys: &[SpecialKey],
1047 submit: bool,
1048 bg_shell: Option<SharedPtyShell>,
1049 is_bg: bool,
1050 bg_id: Option<&str>,
1051 timeout_s: f64,
1052) -> Result<String> {
1053 debug!("Processing SendSpecials action: {keys:?} (submit={submit})");
1054
1055 if keys.is_empty() {
1057 return Err(WinxError::CommandExecutionError(
1058 "Failure: send_specials cannot be empty".to_string(),
1059 ));
1060 }
1061
1062 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1063 let mut is_interrupt = false;
1064
1065 {
1066 let mut guard = shell_arc.lock().await;
1067
1068 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1069
1070 for key in keys {
1072 match key {
1073 SpecialKey::KeyUp => {
1074 bash.send_special_key("KeyUp").map_err(|e| {
1076 WinxError::CommandExecutionError(format!("Failed to send KeyUp: {e}"))
1077 })?;
1078 }
1079 SpecialKey::KeyDown => {
1080 bash.send_special_key("KeyDown").map_err(|e| {
1082 WinxError::CommandExecutionError(format!("Failed to send KeyDown: {e}"))
1083 })?;
1084 }
1085 SpecialKey::KeyLeft => {
1086 bash.send_special_key("KeyLeft").map_err(|e| {
1088 WinxError::CommandExecutionError(format!("Failed to send KeyLeft: {e}"))
1089 })?;
1090 }
1091 SpecialKey::KeyRight => {
1092 bash.send_special_key("KeyRight").map_err(|e| {
1094 WinxError::CommandExecutionError(format!("Failed to send KeyRight: {e}"))
1095 })?;
1096 }
1097 SpecialKey::Enter => {
1098 bash.send_special_key("Enter").map_err(|e| {
1100 WinxError::CommandExecutionError(format!("Failed to send Enter: {e}"))
1101 })?;
1102 }
1103 SpecialKey::CtrlC => {
1104 bash.send_interrupt().map_err(|e| {
1106 WinxError::CommandExecutionError(format!("Failed to send interrupt: {e}"))
1107 })?;
1108 is_interrupt = true;
1109 }
1110 SpecialKey::CtrlD => {
1111 bash.send_eof().map_err(|e| {
1113 WinxError::CommandExecutionError(format!("Failed to send Ctrl+D: {e}"))
1114 })?;
1115 is_interrupt = true;
1116 }
1117 SpecialKey::CtrlZ => {
1118 bash.send_suspend().map_err(|e| {
1120 WinxError::CommandExecutionError(format!("Failed to send Ctrl+Z: {e}"))
1121 })?;
1122 }
1123 }
1124 }
1125 if submit {
1127 bash.send_special_key("Enter")
1128 .map_err(|e| WinxError::CommandExecutionError(format!("Failed to submit: {e}")))?;
1129 }
1130 }
1131
1132 let mut output =
1140 wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await?;
1141
1142 if is_interrupt && output.contains("status = still running") {
1144 output.push_str("\n---\n----\nFailure interrupting.\nYou may want to try Ctrl-c again or program specific exit interactive commands.\n");
1145 }
1146
1147 Ok(output)
1148}
1149
1150async fn execute_send_ascii(
1152 bash_state: &mut BashState,
1153 ascii_codes: &[u8],
1154 submit: bool,
1155 bg_shell: Option<SharedPtyShell>,
1156 is_bg: bool,
1157 bg_id: Option<&str>,
1158 timeout_s: f64,
1159) -> Result<String> {
1160 debug!("Processing SendAscii action: {ascii_codes:?} (submit={submit})");
1161
1162 if ascii_codes.is_empty() {
1164 return Err(WinxError::CommandExecutionError(
1165 "Failure: send_ascii cannot be empty".to_string(),
1166 ));
1167 }
1168
1169 let shell_arc = bg_shell.unwrap_or_else(|| bash_state.pty_shell.clone());
1170 let mut is_interrupt = false;
1171
1172 {
1173 let mut guard = shell_arc.lock().await;
1174
1175 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1176
1177 for &code in ascii_codes {
1179 bash.send_bytes(&[code]).map_err(|e| {
1181 WinxError::CommandExecutionError(format!("Failed to write ASCII code: {e}"))
1182 })?;
1183
1184 if code == 3 {
1186 is_interrupt = true;
1187 }
1188 }
1189 if submit {
1191 bash.send_special_key("Enter")
1192 .map_err(|e| WinxError::CommandExecutionError(format!("Failed to submit: {e}")))?;
1193 }
1194 }
1195
1196 let mut output =
1202 wait_for_output(bash_state, &shell_arc, timeout_s, is_bg, bg_id, false).await?;
1203
1204 if is_interrupt && output.contains("status = still running") {
1206 output.push_str("\n---\n----\nFailure interrupting.\nYou may want to try Ctrl-c again or program specific exit interactive commands.\n");
1207 }
1208
1209 Ok(output)
1210}
1211
1212async fn execute_in_background(
1214 bash_state: &mut BashState,
1215 command: &str,
1216 timeout_s: f64,
1217) -> Result<String> {
1218 debug!("Executing command in background: {}", command);
1219
1220 let restricted_mode =
1222 matches!(bash_state.bash_command_mode.bash_mode, crate::types::BashMode::RestrictedMode);
1223
1224 let bg_id = {
1225 let mut manager = BG_SHELL_MANAGER.lock().map_err(|e| {
1226 WinxError::BashStateLockError(format!("Failed to lock bg manager: {e}"))
1227 })?;
1228 manager.start_new_shell(&bash_state.cwd, restricted_mode)?
1229 };
1230
1231 let shell_arc = {
1233 let manager = BG_SHELL_MANAGER.lock().map_err(|e| {
1234 WinxError::BashStateLockError(format!("Failed to lock bg manager: {e}"))
1235 })?;
1236 manager.get_shell(&bg_id).ok_or_else(|| {
1237 WinxError::CommandExecutionError("Failed to get background shell".to_string())
1238 })?
1239 };
1240
1241 {
1243 let mut guard = shell_arc.lock().await;
1244 let bash = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
1245 bash.send_command(command).map_err(|e| {
1246 WinxError::CommandExecutionError(format!("Failed to send bg command: {e}"))
1247 })?;
1248 }
1249 debug!("bg[{}]: send_command returned, replying with bg_command_id", bg_id);
1250
1251 let _ = timeout_s;
1252 let _ = shell_arc;
1253 Ok(get_status(bash_state, true, Some(&bg_id), true, None))
1254}
1255
1256#[allow(dead_code)]
1260#[tracing::instrument(level = "debug", skip(command, cwd))]
1261async fn execute_simple_command(command: &str, cwd: &Path) -> Result<String> {
1262 debug!("Executing command: {}", command);
1263
1264 let start_time = Instant::now();
1265 let mut cmd = Command::new("sh");
1266 cmd.arg("-c")
1267 .arg(command)
1268 .current_dir(cwd)
1269 .stdin(Stdio::null())
1270 .stdout(Stdio::piped())
1271 .stderr(Stdio::piped());
1272
1273 let output = cmd.output().context("Failed to execute command")?;
1274 let elapsed = start_time.elapsed();
1275
1276 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
1277 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
1278
1279 let raw_result = format!("{stdout}{stderr}");
1280 let mut result = raw_result.clone();
1281 if !raw_result.is_empty() {
1282 let rendered_lines = render_terminal_output(&raw_result);
1283 if rendered_lines.is_empty() {
1284 result = strip_ansi_codes(&raw_result);
1286 } else {
1287 result = rendered_lines.join("\n");
1288 }
1289 }
1290
1291 result = truncate_to_token_budget(&result, MAX_OUTPUT_TOKENS).into_owned();
1292
1293 let exit_status = if output.status.success() {
1294 "Command completed successfully".to_string()
1295 } else {
1296 format!("Command failed with status: {}", output.status)
1297 };
1298
1299 let current_dir = std::env::current_dir()
1300 .map_or_else(|_| "Unknown".to_string(), |p| p.to_string_lossy().into_owned());
1301
1302 debug!("Command executed in {:.2?}", elapsed);
1303 Ok(format!("{result}\n\n---\n\nstatus = {exit_status}\ncwd = {current_dir}\n"))
1304}
1305
1306#[allow(dead_code)]
1308#[tracing::instrument(level = "debug", skip(command, cwd, screen_name))]
1309async fn execute_in_screen(command: &str, cwd: &Path, screen_name: &str) -> Result<String> {
1310 debug!("Executing command in screen session '{}': {}", screen_name, command);
1311
1312 let screen_check = Command::new("which")
1313 .arg("screen")
1314 .output()
1315 .context("Failed to check for screen command")?;
1316
1317 if !screen_check.status.success() {
1318 warn!("Screen command not found, falling back to direct execution");
1319 return execute_simple_command(command, cwd).await;
1320 }
1321
1322 let _cleanup = Command::new("screen").args(["-X", "-S", screen_name, "quit"]).output();
1323
1324 let screen_cmd = format!(
1325 "screen -dmS {} bash -c '{} ; ec=$? ; echo \"Command completed with exit code: $ec\" ; sleep 1 ; exit $ec'",
1326 screen_name,
1327 command.replace('\'', "'\\''")
1328 );
1329
1330 let screen_start = Command::new("sh")
1331 .arg("-c")
1332 .arg(&screen_cmd)
1333 .current_dir(cwd)
1334 .output()
1335 .context("Failed to start screen session")?;
1336
1337 if !screen_start.status.success() {
1338 let stderr = String::from_utf8_lossy(&screen_start.stderr).to_string();
1339 error!("Failed to start screen session: {}", stderr);
1340 return Err(WinxError::CommandExecutionError(format!(
1341 "Failed to start screen session: {stderr}"
1342 )));
1343 }
1344
1345 sleep(Duration::from_millis(300)).await;
1346
1347 let screen_check =
1348 Command::new("screen").args(["-ls"]).output().context("Failed to list screen sessions")?;
1349
1350 let screen_list = String::from_utf8_lossy(&screen_check.stdout).to_string();
1351
1352 let current_dir = std::env::current_dir()
1353 .map_or_else(|_| "Unknown".to_string(), |p| p.to_string_lossy().into_owned());
1354
1355 Ok(format!(
1356 "Started command in background screen session '{screen_name}'.\n\
1357 Use status_check to get output.\n\n\
1358 Screen sessions:\n{screen_list}\n\
1359 ---\n\n\
1360 status = running in background\n\
1361 cwd = {current_dir}\n"
1362 ))
1363}
1364
1365#[allow(dead_code)]
1367fn special_key_to_screen_input(key: SpecialKey) -> String {
1368 match key {
1369 SpecialKey::Enter => String::from("\r"),
1370 SpecialKey::KeyUp => String::from("\x1b[A"),
1371 SpecialKey::KeyDown => String::from("\x1b[B"),
1372 SpecialKey::KeyLeft => String::from("\x1b[D"),
1373 SpecialKey::KeyRight => String::from("\x1b[C"),
1374 SpecialKey::CtrlC => String::from("\x03"),
1375 SpecialKey::CtrlD => String::from("\x04"),
1376 SpecialKey::CtrlZ => String::from("\x1a"),
1377 }
1378}
1379
1380#[cfg(test)]
1381mod tests {
1382 use super::strip_tail_pipe_impl;
1383
1384 #[test]
1385 fn strips_trailing_tail_by_default() {
1386 assert_eq!(strip_tail_pipe_impl("seq 1 5 | tail -2", false), "seq 1 5");
1387 assert_eq!(strip_tail_pipe_impl("cat log | tail -n 20", false), "cat log");
1388 assert_eq!(strip_tail_pipe_impl("cat log | tail", false), "cat log");
1389 assert_eq!(strip_tail_pipe_impl("ls -la|tail -5", false), "ls -la");
1390 }
1391
1392 #[test]
1393 fn keeps_command_without_trailing_tail() {
1394 assert_eq!(strip_tail_pipe_impl("tail -f log | grep err", false), "tail -f log | grep err");
1396 assert_eq!(strip_tail_pipe_impl("echo hi", false), "echo hi");
1397 assert_eq!(
1398 strip_tail_pipe_impl("cat a | tail -5 | wc -l", false),
1399 "cat a | tail -5 | wc -l"
1400 );
1401 }
1402
1403 #[test]
1404 fn keep_mode_preserves_tail_pipe() {
1405 assert_eq!(strip_tail_pipe_impl("seq 1 5 | tail -2", true), "seq 1 5 | tail -2");
1407 assert_eq!(strip_tail_pipe_impl("cat log | tail -n 20", true), "cat log | tail -n 20");
1408 }
1409}