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 output_truncated,
96 }
97}
98
99#[derive(Debug, Default)]
102pub(crate) struct ShellDeliveryCursor {
103 generation: Option<u64>,
104 delivered_output: String,
105 last_returned_hash: Option<u64>,
106 screen_snapshot: Option<Vec<String>>,
107}
108
109impl ShellDeliveryCursor {
110 fn sync_generation(&mut self, generation: u64) {
111 if self.generation != Some(generation) {
112 self.generation = Some(generation);
113 self.delivered_output.clear();
114 self.last_returned_hash = None;
115 self.screen_snapshot = None;
116 }
117 }
118
119 fn screen_update(&mut self, current: Vec<String>, threshold: usize) -> ScreenUpdate {
120 let update = match self.screen_snapshot.as_ref() {
121 Some(previous) => {
122 let rows = previous.len().max(current.len());
123 let changed = (0..rows)
124 .filter_map(|index| {
125 let before = previous.get(index).map_or("", String::as_str);
126 let after = current.get(index).map_or("", String::as_str);
127 (before != after).then(|| (index + 1, after.to_string()))
128 })
129 .collect::<Vec<_>>();
130 if changed.is_empty() {
131 ScreenUpdate::Unchanged
132 } else if changed.len() <= threshold {
133 ScreenUpdate::Diff(changed)
134 } else {
135 ScreenUpdate::Full(current.clone())
136 }
137 }
138 None => ScreenUpdate::Full(current.clone()),
139 };
140 self.screen_snapshot = Some(current);
141 update
142 }
143}
144
145fn main_shell(bash_state: &BashState) -> SharedPtyShell {
146 let mut store = lock_session_store();
147 store.bind_main(&bash_state.current_thread_id, &bash_state.pty_shell);
148 store
149 .resolve(&bash_state.current_thread_id, &ShellTarget::Main)
150 .unwrap_or_else(|| bash_state.pty_shell.clone())
151}
152
153const DEFAULT_TIMEOUT: f64 = 15.0;
159fn effective_wait_for_seconds(wait_for_seconds: Option<f32>) -> f64 {
160 wait_for_seconds.map_or(DEFAULT_TIMEOUT, |seconds| f64::from(seconds).max(0.0))
161}
162
163pub(crate) async fn guardian_foreground_shell_needs_reset(
167 bash_state: &mut BashState,
168 options: &ShellActionOptions,
169) -> anyhow::Result<bool> {
170 let shell = main_shell(bash_state);
171 let (missing, running) = {
172 let guard = shell.lock().await;
173 (guard.is_none(), guard.as_ref().is_some_and(|shell| shell.command_running))
174 };
175 if running {
176 return Ok(false);
177 }
178 if missing {
179 bash_state.init_pty_shell().await?;
180 return Ok(false);
181 }
182 let cleared = output::clear_to_run_async(&shell, DEFAULT_TIMEOUT).await;
183 #[cfg(test)]
184 let cleared = cleared && !options.force_clear_to_run_failure;
185 #[cfg(not(test))]
186 let _ = options;
187 Ok(!cleared)
188}
189
190fn send_utf8_in_byte_chunks(shell: &mut PtyShell, text: &str, chunk_size: usize) -> Result<()> {
191 let mut start = 0;
192 while start < text.len() {
193 let mut end = (start + chunk_size).min(text.len());
194 while !text.is_char_boundary(end) {
195 end -= 1;
196 }
197 if end == start {
198 end =
199 text[start..].char_indices().nth(1).map_or(text.len(), |(index, _)| start + index);
200 }
201 shell.send_text(&text[start..end]).map_err(|error| {
202 WinxError::CommandExecutionError(format!("Failed to write PTY input: {error}"))
203 })?;
204 start = end;
205 }
206 Ok(())
207}
208
209pub async fn handle_tool_call(
211 bash_state: &Arc<Mutex<Option<BashState>>>,
212 command: BashCommand,
213) -> Result<String> {
214 handle_tool_call_with_runtime(&EmbeddedShellRuntime, bash_state, command).await
215}
216
217pub async fn handle_tool_call_with_runtime(
219 runtime: &dyn ShellRuntime,
220 bash_state: &Arc<Mutex<Option<BashState>>>,
221 command: BashCommand,
222) -> Result<String> {
223 Ok(handle_tool_call_with_runtime_detailed(runtime, bash_state, command).await?.output)
224}
225
226pub async fn handle_tool_call_with_runtime_detailed(
228 runtime: &dyn ShellRuntime,
229 bash_state: &Arc<Mutex<Option<BashState>>>,
230 command: BashCommand,
231) -> Result<BashCommandResult> {
232 runtime.run_action(bash_state, command).await
233}
234
235pub(crate) async fn handle_embedded_tool_call(
236 bash_state: &Arc<Mutex<Option<BashState>>>,
237 command: BashCommand,
238) -> Result<BashCommandResult> {
239 Ok(handle_embedded_tool_call_inner(
240 bash_state,
241 command,
242 None,
243 ShellActionOptions::default(),
244 false,
245 )
246 .await?
247 .result)
248}
249
250pub(crate) async fn handle_embedded_tool_call_detailed(
251 bash_state: &Arc<Mutex<Option<BashState>>>,
252 command: BashCommand,
253 options: ShellActionOptions,
254) -> Result<BashCommandRuntimeResult> {
255 handle_embedded_tool_call_inner(bash_state, command, None, options, false).await
256}
257
258pub(crate) async fn handle_embedded_tool_call_with_cursor_detailed(
259 bash_state: &Arc<Mutex<Option<BashState>>>,
260 command: BashCommand,
261 delivery_cursor: &Arc<Mutex<ShellDeliveryCursor>>,
262 options: ShellActionOptions,
263 foreground_preflight_complete: bool,
264) -> Result<BashCommandRuntimeResult> {
265 let mut delivery_cursor = delivery_cursor.lock().await;
266 handle_embedded_tool_call_inner(
267 bash_state,
268 command,
269 Some(&mut delivery_cursor),
270 options,
271 foreground_preflight_complete,
272 )
273 .await
274}
275
276async fn capture_embedded_state(
277 bash_state: &Arc<Mutex<Option<BashState>>>,
278 thread_id: &str,
279) -> Result<(BashState, tokio::sync::OwnedRwLockReadGuard<()>)> {
280 let guard = bash_state.lock().await;
281 let state = guard.as_ref().ok_or(WinxError::BashStateNotInitialized)?;
282 let operation_barrier = lock_session_store().operation_barrier(thread_id);
283 let operation_guard = operation_barrier.read_owned().await;
284 Ok((state.clone(), operation_guard))
285}
286
287#[tracing::instrument(level = "info", skip(bash_state, command, delivery_cursor))]
288async fn handle_embedded_tool_call_inner(
289 bash_state: &Arc<Mutex<Option<BashState>>>,
290 command: BashCommand,
291 delivery_cursor: Option<&mut ShellDeliveryCursor>,
292 options: ShellActionOptions,
293 foreground_preflight_complete: bool,
294) -> Result<BashCommandRuntimeResult> {
295 let action_kind = match &command.action_json {
296 BashCommandAction::Command { .. } => "command",
297 BashCommandAction::StatusCheck { .. } => "status_check",
298 BashCommandAction::SendText { .. } => "send_text",
299 BashCommandAction::SendSpecials { .. } => "send_specials",
300 BashCommandAction::SendAscii { .. } => "send_ascii",
301 BashCommandAction::Screen { .. } => "screen",
302 BashCommandAction::WaitForTurn { .. } => "wait_for_turn",
303 };
304 info!(thread_id = %command.thread_id, action = action_kind, "BashCommand tool called");
305
306 let thread_id = normalize_thread_id(&command.thread_id);
307 if thread_id.is_empty() {
308 error!("Empty thread_id provided in BashCommand");
309 return Err(WinxError::ThreadIdMismatch(
310 "Error: No saved bash state found for thread ID \"\". Please initialize first with this ID."
311 .to_string(),
312 ));
313 }
314
315 let mut local_state = {
316 let guard = bash_state.lock().await;
317 let Some(state) = &*guard else {
318 error!("BashState not initialized");
319 return Err(WinxError::BashStateNotInitialized);
320 };
321 state.clone()
322 };
323
324 if thread_id != local_state.current_thread_id {
325 let loaded = local_state.load_state_from_disk(&thread_id).map_err(|error| {
326 WinxError::CommandExecutionError(format!(
327 "Failed to load saved bash state for thread_id `{thread_id}`: {error}"
328 ))
329 })?;
330 if !loaded {
331 return Err(WinxError::ThreadIdMismatch(format!(
332 "Error: No saved bash state found for thread_id `{thread_id}`. Please initialize first with this ID."
333 )));
334 }
335 if let Some(state) = bash_state.lock().await.as_mut() {
336 *state = local_state.clone();
337 }
338 }
339
340 let (captured_state, operation_guard) = capture_embedded_state(bash_state, &thread_id).await?;
345 local_state = captured_state;
346
347 if let Some(expected) = options.expected_execution.as_ref() {
348 if expected.guardian_epoch == "embedded" {
349 let current_epoch = local_state
350 .pty_shell
351 .lock()
352 .await
353 .as_ref()
354 .map(|shell| format!("{:016x}", shell.incarnation()));
355 if Some(expected.session_epoch.as_str()) != current_epoch.as_deref() {
356 return Err(WinxError::InvalidInput(
357 "execution token belongs to a previous embedded shell incarnation".to_string(),
358 ));
359 }
360 }
361 }
362
363 lock_session_store().bind_main(&local_state.current_thread_id, &local_state.pty_shell);
364 let timeout_secs = effective_wait_for_seconds(command.wait_for_seconds);
365 let mut result = execute_bash_action(
366 &mut local_state,
367 &command.action_json,
368 timeout_secs,
369 delivery_cursor,
370 options,
371 foreground_preflight_complete,
372 )
373 .await;
374
375 let completed_session_epoch = local_state.pty_shell.lock().await.as_ref().map_or_else(
376 || "uninitialized".to_string(),
377 |shell| format!("{:016x}", shell.incarnation()),
378 );
379 if let Ok(outcome) = &mut result {
380 if let Some(generation) = outcome.command_generation {
381 outcome.execution_token = Some(ShellExecutionToken {
382 guardian_epoch: "embedded".to_string(),
383 session_epoch: completed_session_epoch.clone(),
384 generation,
385 });
386 }
387 if let BashCommandAction::Command { ref command, .. } = command.action_json {
388 let command = command.trim();
389 if outcome.result.output.starts_with(command) {
390 outcome.result.output = outcome.result.output[command.len()..].to_string();
391 }
392 if outcome.compact_output.as_ref().is_some_and(|output| output.starts_with(command)) {
393 let output = outcome.compact_output.take().unwrap_or_default();
394 outcome.compact_output = Some(output[command.len()..].to_string());
395 }
396 }
397 }
398
399 drop(operation_guard);
400 sync_embedded_cwd_if_current(bash_state, &local_state, &completed_session_epoch).await;
401
402 result
403}
404
405async fn sync_embedded_cwd_if_current(
406 bash_state: &Arc<Mutex<Option<BashState>>>,
407 local_state: &BashState,
408 completed_session_epoch: &str,
409) {
410 if let Some(state) = bash_state.lock().await.as_mut() {
414 let current_session_epoch = state.pty_shell.lock().await.as_ref().map_or_else(
415 || "uninitialized".to_string(),
416 |shell| format!("{:016x}", shell.incarnation()),
417 );
418 if current_session_epoch == completed_session_epoch {
419 state.cwd.clone_from(&local_state.cwd);
420 }
421 }
422}
423
424#[allow(clippy::too_many_lines)]
425async fn execute_bash_action(
426 bash_state: &mut BashState,
427 action: &BashCommandAction,
428 timeout_secs: f64,
429 delivery_cursor: Option<&mut ShellDeliveryCursor>,
430 options: ShellActionOptions,
431 foreground_preflight_complete: bool,
432) -> Result<BashCommandRuntimeResult> {
433 let mut is_background = false;
434 let mut background_id: Option<String> = None;
435
436 let background_shell = match action {
437 BashCommandAction::Command { .. } => None,
438 BashCommandAction::StatusCheck { bg_command_id, .. }
439 | BashCommandAction::SendText { bg_command_id, .. }
440 | BashCommandAction::SendSpecials { bg_command_id, .. }
441 | BashCommandAction::SendAscii { bg_command_id, .. }
442 | BashCommandAction::Screen { bg_command_id, .. }
443 | BashCommandAction::WaitForTurn { bg_command_id, .. } => {
444 if let Some(id) = bg_command_id {
445 let mut manager = lock_session_store();
446 manager.prune_finished_shells();
447 if let Some(shell) = manager.get_shell(&bash_state.current_thread_id, id) {
448 is_background = true;
449 background_id = Some(id.clone());
450 Some(shell)
451 } else if let Some(tombstone) =
452 manager.peek_tombstone(&bash_state.current_thread_id, id)
453 {
454 drop(manager);
455 return finalize_tombstone(
456 bash_state,
457 id,
458 tombstone,
459 action,
460 options.compact_output,
461 );
462 } else {
463 let error = format!(
464 "No shell found running with command id {}.\n{}",
465 id,
466 manager.get_running_info(&bash_state.current_thread_id)
467 );
468 return Err(WinxError::InvalidInput(error));
469 }
470 } else {
471 None
472 }
473 }
474 };
475
476 match action {
477 BashCommandAction::Command { command, is_background, allow_multi } => {
478 execute_command(
479 bash_state,
480 command,
481 *is_background,
482 *allow_multi,
483 timeout_secs,
484 delivery_cursor,
485 CommandExecutionContext { options: &options, foreground_preflight_complete },
486 )
487 .await
488 }
489 BashCommandAction::StatusCheck { scrollback_lines, verbose, .. } => {
490 execute_status_check(
491 bash_state,
492 background_shell,
493 is_background,
494 background_id.as_deref(),
495 timeout_secs,
496 *scrollback_lines,
497 *verbose,
498 delivery_cursor,
499 options,
500 )
501 .await
502 }
503 BashCommandAction::SendText { send_text, submit, .. } => {
504 execute_send_text(
505 bash_state,
506 send_text,
507 *submit,
508 background_shell,
509 is_background,
510 background_id.as_deref(),
511 timeout_secs,
512 delivery_cursor,
513 options.compact_output,
514 )
515 .await
516 }
517 BashCommandAction::SendSpecials { send_specials, submit, .. } => {
518 execute_send_specials(
519 bash_state,
520 send_specials,
521 *submit,
522 background_shell,
523 is_background,
524 background_id.as_deref(),
525 timeout_secs,
526 delivery_cursor,
527 options.compact_output,
528 )
529 .await
530 }
531 BashCommandAction::SendAscii { send_ascii, submit, .. } => {
532 execute_send_ascii(
533 bash_state,
534 send_ascii,
535 *submit,
536 background_shell,
537 is_background,
538 background_id.as_deref(),
539 timeout_secs,
540 delivery_cursor,
541 options.compact_output,
542 )
543 .await
544 }
545 BashCommandAction::Screen { lines, diff, .. } => {
546 execute_screen(
547 bash_state,
548 background_shell,
549 is_background,
550 background_id.as_deref(),
551 *lines,
552 *diff,
553 delivery_cursor,
554 options.compact_output,
555 )
556 .await
557 }
558 BashCommandAction::WaitForTurn {
559 recognizer,
560 quiet_ms,
561 timeout_seconds,
562 lines,
563 wait_through_busy,
564 ..
565 } => {
566 execute_wait_for_turn(
567 bash_state,
568 background_shell,
569 is_background,
570 background_id.as_deref(),
571 recognizer.as_deref(),
572 *quiet_ms,
573 *timeout_seconds,
574 *lines,
575 *wait_through_busy,
576 options.compact_output,
577 )
578 .await
579 }
580 }
581}
582
583#[cfg(test)]
584mod tests {
585 use super::{
586 effective_wait_for_seconds, runtime_rendered, BashCommandState, BashProcessStatus,
587 };
588
589 fn exited_state() -> BashCommandState {
590 BashCommandState {
591 process_status: BashProcessStatus::Exited,
592 background_id: None,
593 running_for_seconds: None,
594 exit_code: Some(0),
595 cwd: "/workspace".into(),
596 turn_state: None,
597 }
598 }
599
600 #[test]
601 fn legacy_rendering_does_not_build_a_compact_payload_without_negotiation() {
602 let result = runtime_rendered(
603 "child output".to_string(),
604 "\nstatus = process exited",
605 exited_state(),
606 false,
607 Some(1),
608 false,
609 );
610 assert_eq!(result.result.output, "child output\nstatus = process exited");
611 assert_eq!(result.compact_output, None);
612 }
613
614 #[test]
615 fn compact_rendering_is_built_directly_from_runtime_body() {
616 let result = runtime_rendered(
617 "child output".to_string(),
618 "\nstatus = process exited",
619 exited_state(),
620 true,
621 Some(1),
622 false,
623 );
624 assert_eq!(result.compact_output.as_deref(), Some("child output"));
625 assert!(result.result.output.is_empty());
626 }
627
628 #[test]
629 fn requested_wait_is_not_silently_capped() {
630 assert!((effective_wait_for_seconds(Some(120.0)) - 120.0).abs() < f64::EPSILON);
631 assert!((effective_wait_for_seconds(Some(150.0)) - 150.0).abs() < f64::EPSILON);
632 }
633
634 #[test]
635 fn omitted_wait_uses_the_larger_default_window() {
636 assert!((effective_wait_for_seconds(None) - 15.0).abs() < f64::EPSILON);
637 }
638}