1mod 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#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
40#[serde(rename_all = "snake_case")]
41pub enum BashProcessStatus {
42 Running,
43 Exited,
44}
45
46#[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#[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#[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
146fn 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
154const 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
164pub(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
210pub 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
218pub 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
227pub 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 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 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#[allow(clippy::too_many_lines)]
426async fn execute_bash_action(
427 bash_state: &mut BashState,
428 action: &BashCommandAction,
429 timeout_secs: f64,
430 delivery_cursor: Option<&mut ShellDeliveryCursor>,
431 options: ShellActionOptions,
432 foreground_preflight_complete: bool,
433) -> Result<BashCommandRuntimeResult> {
434 let mut is_background = false;
435 let mut background_id: Option<String> = None;
436
437 let background_shell = match action {
438 BashCommandAction::Command { .. } => None,
439 BashCommandAction::StatusCheck { bg_command_id, .. }
440 | BashCommandAction::SendText { bg_command_id, .. }
441 | BashCommandAction::SendSpecials { bg_command_id, .. }
442 | BashCommandAction::SendAscii { bg_command_id, .. }
443 | BashCommandAction::Screen { bg_command_id, .. }
444 | BashCommandAction::WaitForTurn { bg_command_id, .. } => {
445 if let Some(id) = bg_command_id {
446 let mut manager = lock_session_store();
447 manager.prune_finished_shells();
448 if let Some(shell) = manager.get_shell(&bash_state.current_thread_id, id) {
449 is_background = true;
450 background_id = Some(id.clone());
451 Some(shell)
452 } else if let Some(tombstone) =
453 manager.peek_tombstone(&bash_state.current_thread_id, id)
454 {
455 drop(manager);
456 return finalize_tombstone(
457 bash_state,
458 id,
459 tombstone,
460 action,
461 options.compact_output,
462 );
463 } else {
464 let error = format!(
465 "No shell found running with command id {}.\n{}",
466 id,
467 manager.get_running_info(&bash_state.current_thread_id)
468 );
469 return Err(WinxError::InvalidInput(error));
470 }
471 } else {
472 None
473 }
474 }
475 };
476
477 match action {
478 BashCommandAction::Command { command, is_background, allow_multi } => {
479 execute_command(
480 bash_state,
481 command,
482 *is_background,
483 *allow_multi,
484 timeout_secs,
485 delivery_cursor,
486 CommandExecutionContext { options: &options, foreground_preflight_complete },
487 )
488 .await
489 }
490 BashCommandAction::StatusCheck { scrollback_lines, verbose, .. } => {
491 execute_status_check(
492 bash_state,
493 background_shell,
494 is_background,
495 background_id.as_deref(),
496 timeout_secs,
497 *scrollback_lines,
498 *verbose,
499 delivery_cursor,
500 options,
501 )
502 .await
503 }
504 BashCommandAction::SendText { send_text, submit, .. } => {
505 execute_send_text(
506 bash_state,
507 send_text,
508 *submit,
509 background_shell,
510 is_background,
511 background_id.as_deref(),
512 timeout_secs,
513 delivery_cursor,
514 options.compact_output,
515 )
516 .await
517 }
518 BashCommandAction::SendSpecials { send_specials, submit, .. } => {
519 execute_send_specials(
520 bash_state,
521 send_specials,
522 *submit,
523 background_shell,
524 is_background,
525 background_id.as_deref(),
526 timeout_secs,
527 delivery_cursor,
528 options.compact_output,
529 )
530 .await
531 }
532 BashCommandAction::SendAscii { send_ascii, submit, .. } => {
533 execute_send_ascii(
534 bash_state,
535 send_ascii,
536 *submit,
537 background_shell,
538 is_background,
539 background_id.as_deref(),
540 timeout_secs,
541 delivery_cursor,
542 options.compact_output,
543 )
544 .await
545 }
546 BashCommandAction::Screen { lines, diff, .. } => {
547 execute_screen(
548 bash_state,
549 background_shell,
550 is_background,
551 background_id.as_deref(),
552 *lines,
553 *diff,
554 delivery_cursor,
555 options.compact_output,
556 )
557 .await
558 }
559 BashCommandAction::WaitForTurn {
560 recognizer,
561 quiet_ms,
562 timeout_seconds,
563 lines,
564 wait_through_busy,
565 ..
566 } => {
567 execute_wait_for_turn(
568 bash_state,
569 background_shell,
570 is_background,
571 background_id.as_deref(),
572 recognizer.as_deref(),
573 *quiet_ms,
574 *timeout_seconds,
575 *lines,
576 *wait_through_busy,
577 options.compact_output,
578 )
579 .await
580 }
581 }
582}
583
584#[cfg(test)]
585mod tests {
586 use super::{
587 effective_wait_for_seconds, runtime_rendered, BashCommandState, BashProcessStatus,
588 };
589
590 fn exited_state() -> BashCommandState {
591 BashCommandState {
592 process_status: BashProcessStatus::Exited,
593 background_id: None,
594 running_for_seconds: None,
595 exit_code: Some(0),
596 cwd: "/workspace".into(),
597 turn_state: None,
598 }
599 }
600
601 #[test]
602 fn legacy_rendering_does_not_build_a_compact_payload_without_negotiation() {
603 let result = runtime_rendered(
604 "child output".to_string(),
605 "\nstatus = process exited",
606 exited_state(),
607 false,
608 Some(1),
609 false,
610 );
611 assert_eq!(result.result.output, "child output\nstatus = process exited");
612 assert_eq!(result.compact_output, None);
613 }
614
615 #[test]
616 fn compact_rendering_is_built_directly_from_runtime_body() {
617 let result = runtime_rendered(
618 "child output".to_string(),
619 "\nstatus = process exited",
620 exited_state(),
621 true,
622 Some(1),
623 false,
624 );
625 assert_eq!(result.compact_output.as_deref(), Some("child output"));
626 assert!(result.result.output.is_empty());
627 }
628
629 #[test]
630 fn requested_wait_is_not_silently_capped() {
631 assert!((effective_wait_for_seconds(Some(120.0)) - 120.0).abs() < f64::EPSILON);
632 assert!((effective_wait_for_seconds(Some(150.0)) - 150.0).abs() < f64::EPSILON);
633 }
634
635 #[test]
636 fn omitted_wait_uses_the_larger_default_window() {
637 assert!((effective_wait_for_seconds(None) - 15.0).abs() < f64::EPSILON);
638 }
639}