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::{debug, 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
146pub(crate) fn 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
425fn record_foreground_agent_launch(bash_state: &BashState, command: &str) {
432 use crate::state::agent_resume;
433 let thread_id = &bash_state.current_thread_id;
434 match agent_resume::detect_agent_launch(command) {
435 Some(agent) => {
436 let record = agent_resume::AgentSessionRecord {
437 agent: agent.to_string(),
438 command: command.to_string(),
439 cwd: bash_state.cwd.to_string_lossy().into_owned(),
440 launched_at_unix_ms: agent_resume::now_unix_ms(),
441 };
442 if let Err(error) = agent_resume::save_agent_session(thread_id, &record) {
443 debug!(%error, agent, "failed to persist agent-session record");
444 }
445 }
446 None => {
447 if let Err(error) = agent_resume::clear_agent_session(thread_id) {
448 debug!(%error, "failed to clear agent-session record");
449 }
450 }
451 }
452}
453
454#[allow(clippy::too_many_lines)]
455async fn execute_bash_action(
456 bash_state: &mut BashState,
457 action: &BashCommandAction,
458 timeout_secs: f64,
459 delivery_cursor: Option<&mut ShellDeliveryCursor>,
460 options: ShellActionOptions,
461 foreground_preflight_complete: bool,
462) -> Result<BashCommandRuntimeResult> {
463 let mut is_background = false;
464 let mut background_id: Option<String> = None;
465
466 let background_shell = match action {
467 BashCommandAction::Command { .. } => None,
468 BashCommandAction::StatusCheck { bg_command_id, .. }
469 | BashCommandAction::SendText { bg_command_id, .. }
470 | BashCommandAction::SendSpecials { bg_command_id, .. }
471 | BashCommandAction::SendAscii { bg_command_id, .. }
472 | BashCommandAction::Screen { bg_command_id, .. }
473 | BashCommandAction::WaitForTurn { bg_command_id, .. } => {
474 if let Some(id) = bg_command_id {
475 let mut manager = lock_session_store();
476 manager.prune_finished_shells();
477 if let Some(shell) = manager.get_shell(&bash_state.current_thread_id, id) {
478 is_background = true;
479 background_id = Some(id.clone());
480 Some(shell)
481 } else if let Some(tombstone) =
482 manager.peek_tombstone(&bash_state.current_thread_id, id)
483 {
484 drop(manager);
485 return finalize_tombstone(
486 bash_state,
487 id,
488 tombstone,
489 action,
490 options.compact_output,
491 );
492 } else {
493 let error = format!(
494 "No shell found running with command id {}.\n{}",
495 id,
496 manager.get_running_info(&bash_state.current_thread_id)
497 );
498 return Err(WinxError::InvalidInput(error));
499 }
500 } else {
501 None
502 }
503 }
504 };
505
506 match action {
507 BashCommandAction::Command { command, is_background, allow_multi } => {
508 let result = execute_command(
509 bash_state,
510 command,
511 *is_background,
512 *allow_multi,
513 timeout_secs,
514 delivery_cursor,
515 CommandExecutionContext { options: &options, foreground_preflight_complete },
516 )
517 .await;
518 if result.is_ok() && !*is_background {
519 record_foreground_agent_launch(bash_state, command);
520 }
521 result
522 }
523 BashCommandAction::StatusCheck { scrollback_lines, verbose, .. } => {
524 execute_status_check(
525 bash_state,
526 background_shell,
527 is_background,
528 background_id.as_deref(),
529 timeout_secs,
530 *scrollback_lines,
531 *verbose,
532 delivery_cursor,
533 options,
534 )
535 .await
536 }
537 BashCommandAction::SendText { send_text, submit, .. } => {
538 execute_send_text(
539 bash_state,
540 send_text,
541 *submit,
542 background_shell,
543 is_background,
544 background_id.as_deref(),
545 timeout_secs,
546 delivery_cursor,
547 options.compact_output,
548 )
549 .await
550 }
551 BashCommandAction::SendSpecials { send_specials, submit, .. } => {
552 execute_send_specials(
553 bash_state,
554 send_specials,
555 *submit,
556 background_shell,
557 is_background,
558 background_id.as_deref(),
559 timeout_secs,
560 delivery_cursor,
561 options.compact_output,
562 )
563 .await
564 }
565 BashCommandAction::SendAscii { send_ascii, submit, .. } => {
566 execute_send_ascii(
567 bash_state,
568 send_ascii,
569 *submit,
570 background_shell,
571 is_background,
572 background_id.as_deref(),
573 timeout_secs,
574 delivery_cursor,
575 options.compact_output,
576 )
577 .await
578 }
579 BashCommandAction::Screen { lines, diff, .. } => {
580 execute_screen(
581 bash_state,
582 background_shell,
583 is_background,
584 background_id.as_deref(),
585 *lines,
586 *diff,
587 delivery_cursor,
588 options.compact_output,
589 )
590 .await
591 }
592 BashCommandAction::WaitForTurn {
593 recognizer,
594 quiet_ms,
595 timeout_seconds,
596 lines,
597 wait_through_busy,
598 ..
599 } => {
600 execute_wait_for_turn(
601 bash_state,
602 background_shell,
603 is_background,
604 background_id.as_deref(),
605 recognizer.as_deref(),
606 *quiet_ms,
607 *timeout_seconds,
608 *lines,
609 *wait_through_busy,
610 options.compact_output,
611 )
612 .await
613 }
614 }
615}
616
617#[cfg(test)]
618mod tests {
619 use super::{
620 effective_wait_for_seconds, runtime_rendered, BashCommandState, BashProcessStatus,
621 };
622
623 fn exited_state() -> BashCommandState {
624 BashCommandState {
625 process_status: BashProcessStatus::Exited,
626 background_id: None,
627 running_for_seconds: None,
628 exit_code: Some(0),
629 cwd: "/workspace".into(),
630 turn_state: None,
631 }
632 }
633
634 #[test]
635 fn legacy_rendering_does_not_build_a_compact_payload_without_negotiation() {
636 let result = runtime_rendered(
637 "child output".to_string(),
638 "\nstatus = process exited",
639 exited_state(),
640 false,
641 Some(1),
642 false,
643 );
644 assert_eq!(result.result.output, "child output\nstatus = process exited");
645 assert_eq!(result.compact_output, None);
646 }
647
648 #[test]
649 fn compact_rendering_is_built_directly_from_runtime_body() {
650 let result = runtime_rendered(
651 "child output".to_string(),
652 "\nstatus = process exited",
653 exited_state(),
654 true,
655 Some(1),
656 false,
657 );
658 assert_eq!(result.compact_output.as_deref(), Some("child output"));
659 assert!(result.result.output.is_empty(), "expected empty result.result.output");
660 }
661
662 #[test]
663 fn requested_wait_is_not_silently_capped() {
664 assert!((effective_wait_for_seconds(Some(120.0)) - 120.0).abs() < f64::EPSILON);
665 assert!((effective_wait_for_seconds(Some(150.0)) - 150.0).abs() < f64::EPSILON);
666 }
667
668 #[test]
669 fn omitted_wait_uses_the_larger_default_window() {
670 assert!((effective_wait_for_seconds(None) - 15.0).abs() < f64::EPSILON);
671 }
672}