stynx_code_tools/infrastructure/
bash_tool.rs1use std::sync::Arc;
2use std::time::Duration;
3
4use stynx_code_errors::{AppError, AppResult};
5use stynx_code_types::{InterruptBehavior, PermissionLevel, Tool};
6use serde_json::{Value, json};
7
8use super::persistent_shell::ShellRegistry;
9
10const MAX_OUTPUT_BYTES: usize = 100_000;
11const DEFAULT_TIMEOUT_SECS: u64 = 120;
12
13pub struct BashTool {
14 shells: Arc<ShellRegistry>,
15}
16
17impl BashTool {
18 pub fn new() -> Self {
19 Self { shells: Arc::new(ShellRegistry::new()) }
20 }
21
22 pub fn shells(&self) -> Arc<ShellRegistry> {
23 self.shells.clone()
24 }
25}
26
27impl Default for BashTool {
28 fn default() -> Self { Self::new() }
29}
30
31#[async_trait::async_trait]
32impl Tool for BashTool {
33 fn name(&self) -> &str { "bash" }
34
35 fn description(&self) -> &str {
36 "Run a bash command in a persistent shell session.\n\
37\n\
38Foreground (default): commands share a single long-lived bash process, so \
39cwd, exported env vars, and shell aliases persist across calls. `cd subdir` \
40in one call is visible to the next call.\n\
41\n\
42Long-running processes (dev servers, watchers, log tails): pass \
43`background: true`. You get back a handle like `bg1`. Read its output with \
44`{\"status\": \"bg1\"}` (returns only new output since last read), or \
45`{\"status\": \"bg1\", \"full\": true}` for everything. Stop it with \
46`{\"kill\": \"bg1\"}`. List all background processes with `{\"list\": true}`.\n\
47\n\
48Use `timeout` (seconds) to cap a foreground command — default 120s. \
49If a foreground command times out, the shell may be in an unknown state; \
50prefer `background: true` for anything that legitimately runs longer."
51 }
52
53 fn input_schema(&self) -> Value {
54 json!({
55 "type": "object",
56 "properties": {
57 "command": {
58 "type": "string",
59 "description": "Bash command to run. Required unless using status/kill/list."
60 },
61 "background": {
62 "type": "boolean",
63 "description": "If true, spawn the command detached. Returns a handle like 'bg1' instead of waiting for completion."
64 },
65 "status": {
66 "type": "string",
67 "description": "Handle of a background process to read new output from."
68 },
69 "full": {
70 "type": "boolean",
71 "description": "When reading status, return all accumulated output instead of only new output since last read."
72 },
73 "kill": {
74 "type": "string",
75 "description": "Handle of a background process to terminate."
76 },
77 "list": {
78 "type": "boolean",
79 "description": "If true, list all background processes."
80 },
81 "timeout": {
82 "type": "integer",
83 "description": "Foreground command timeout in seconds (default 120)."
84 }
85 }
86 })
87 }
88
89 fn permission_level(&self) -> PermissionLevel { PermissionLevel::Dangerous }
90
91 fn interrupt_behavior(&self) -> InterruptBehavior { InterruptBehavior::Cancel }
92
93 async fn execute(&self, input: Value) -> AppResult<String> {
94 if input.get("list").and_then(|v| v.as_bool()).unwrap_or(false) {
95 return Ok(self.shells.list_background().await);
96 }
97
98 if let Some(handle) = input.get("kill").and_then(|v| v.as_str()) {
99 return self.shells.kill_background(handle).await;
100 }
101
102 if let Some(handle) = input.get("status").and_then(|v| v.as_str()) {
103 let full = input.get("full").and_then(|v| v.as_bool()).unwrap_or(false);
104 return self.shells.read_background(handle, full).await;
105 }
106
107 let command = input
108 .get("command")
109 .and_then(|v| v.as_str())
110 .ok_or_else(|| AppError::Tool("missing 'command' field".into()))?;
111
112 let background = input.get("background").and_then(|v| v.as_bool()).unwrap_or(false);
113 if background {
114 tracing::info!(command, "starting background process");
115 let handle = self.shells.run_background(command).await?;
116 return Ok(format!(
117 "started background process '{handle}'.\n\
118read with {{\"status\":\"{handle}\"}}, stop with {{\"kill\":\"{handle}\"}}."
119 ));
120 }
121
122 let timeout_secs = input
123 .get("timeout")
124 .and_then(|v| v.as_u64())
125 .unwrap_or(DEFAULT_TIMEOUT_SECS);
126 tracing::info!(command, timeout_secs, "executing bash (persistent)");
127
128 let mut result = self
129 .shells
130 .run_sync(command, Some(Duration::from_secs(timeout_secs)))
131 .await?;
132
133 if result.len() > MAX_OUTPUT_BYTES {
134 result.truncate(MAX_OUTPUT_BYTES);
135 result.push_str("\n... (truncated)");
136 }
137 Ok(result)
138 }
139}