Skip to main content

stynx_code_tools/infrastructure/
bash_tool.rs

1use 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}