Skip to main content

agentmux_mcp/tools/
shell.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Shell, ShellStop, ShellInput, ShellStatus: pipe shells in the agent pane.
5//! ConnList: the connections those and PtyShell can run on.
6
7use super::*;
8
9pub(super) async fn call(name: &str, arguments: &Value, cx: &ToolCtx<'_>) -> Result<String> {
10    let ToolCtx { local_url, auth_key, block_id, client, .. } = *cx;
11    match name {
12        "Shell" => {
13            let cmd = arguments
14                .get("cmd")
15                .and_then(|v| v.as_str())
16                .ok_or_else(|| anyhow::anyhow!("missing required parameter: cmd"))?;
17
18            if local_url.is_empty() || auth_key.is_empty() {
19                anyhow::bail!(
20                    "AGENTMUX_LOCAL_URL and AGENTMUX_AUTH_KEY must be set. \
21                     Is this agent pane opened via AgentMux?"
22                );
23            }
24            if block_id.is_empty() {
25                anyhow::bail!(
26                    "neither AGENTMUX_AGENT_BUS_ID nor AGENTMUX_BLOCKID is set \
27                     — cannot associate the shell with a conversation pane. \
28                     Is this agent pane opened via AgentMux?"
29                );
30            }
31
32            let title = arguments
33                .get("title")
34                .and_then(|v| v.as_str())
35                .unwrap_or(cmd);
36            let cwd = arguments.get("cwd").and_then(|v| v.as_str()).map(str::to_string);
37            let env = arguments
38                .get("env")
39                .and_then(|v| serde_json::from_value(v.clone()).ok());
40
41            let url = format!(
42                "{}/api/v1/shell/create",
43                local_url.trim_end_matches('/')
44            );
45            let capture_stdin = arguments.get("capture_stdin").and_then(|v| v.as_bool());
46            let connection = arguments.get("connection").and_then(|v| v.as_str()).map(str::to_string);
47            let req = ShellCreateRequest {
48                agent_block_id: block_id.to_string(),
49                cmd: cmd.to_string(),
50                title: Some(title.to_string()),
51                cwd,
52                env,
53                capture_stdin,
54                connection,
55                // Proves which agent is asking; srv requires it for SSH. An
56                // agent without a key still runs locally and in WSL.
57                auth: sign_ui_automation_auth().ok(),
58            };
59
60            let resp = client
61                .post(&url)
62                .header(AUTH_KEY_HEADER, auth_key)
63                .timeout(crate::srv_http::SHELL_CREATE_TIMEOUT)
64                .json(&req)
65                .send()
66                .await
67                .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
68
69            if !resp.status().is_success() {
70                let status = resp.status();
71                let text = resp.text().await.unwrap_or_default();
72                anyhow::bail!("shell/create failed: HTTP {status} — {text}");
73            }
74
75            let result: ShellCreateResponse = resp
76                .json()
77                .await
78                .map_err(|e| anyhow::anyhow!("response parse failed: {e}"))?;
79
80            Ok(result.shell_id)
81        }
82        "ShellStop" => {
83            let shell_id = arguments
84                .get("shell_id")
85                .and_then(|v| v.as_str())
86                .ok_or_else(|| anyhow::anyhow!("missing required parameter: shell_id"))?;
87
88            if local_url.is_empty() || auth_key.is_empty() {
89                anyhow::bail!(
90                    "AGENTMUX_LOCAL_URL and AGENTMUX_AUTH_KEY must be set. \
91                     Is this agent pane opened via AgentMux?"
92                );
93            }
94
95            let url = format!("{}/api/v1/shell/stop", local_url.trim_end_matches('/'));
96            let resp = client
97                .post(&url)
98                .header(AUTH_KEY_HEADER, auth_key)
99                .json(&ShellStopRequest { shell_id: shell_id.to_string() })
100                .send()
101                .await
102                .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
103
104            if !resp.status().is_success() {
105                let status = resp.status();
106                let text = resp.text().await.unwrap_or_default();
107                anyhow::bail!("shell/stop failed: HTTP {status} — {text}");
108            }
109
110            let result: ShellStopResponse = resp
111                .json()
112                .await
113                .map_err(|e| anyhow::anyhow!("response parse failed: {e}"))?;
114            Ok(if result.stopped {
115                format!("stopped shell {shell_id}")
116            } else {
117                format!("shell {shell_id} was not running (unknown or already exited)")
118            })
119        }
120        "ShellInput" => {
121            let shell_id = arguments
122                .get("shell_id")
123                .and_then(|v| v.as_str())
124                .ok_or_else(|| anyhow::anyhow!("missing required parameter: shell_id"))?;
125            let text = arguments
126                .get("text")
127                .and_then(|v| v.as_str())
128                .ok_or_else(|| anyhow::anyhow!("missing required parameter: text"))?;
129
130            if local_url.is_empty() || auth_key.is_empty() {
131                anyhow::bail!(
132                    "AGENTMUX_LOCAL_URL and AGENTMUX_AUTH_KEY must be set. \
133                     Is this agent pane opened via AgentMux?"
134                );
135            }
136
137            let url = format!("{}/api/v1/shell/input", local_url.trim_end_matches('/'));
138            let resp = client
139                .post(&url)
140                .header(AUTH_KEY_HEADER, auth_key)
141                .json(&ShellInputRequest { shell_id: shell_id.to_string(), text: text.to_string() })
142                .send()
143                .await
144                .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
145
146            if !resp.status().is_success() {
147                let status = resp.status();
148                let body = resp.text().await.unwrap_or_default();
149                anyhow::bail!("shell/input failed: HTTP {status} — {body}");
150            }
151
152            let result: ShellInputResponse = resp
153                .json()
154                .await
155                .map_err(|e| anyhow::anyhow!("response parse failed: {e}"))?;
156            Ok(if result.written {
157                format!("wrote to shell {shell_id}")
158            } else {
159                match result.reason {
160                    Some(ShellInputFailure::StdinNotCaptured) => format!(
161                        "shell {shell_id} is running but was started without capture_stdin=true — \
162                         its stdin is /dev/null. Recreate the shell with Shell(..., capture_stdin=true) \
163                         to send input."
164                    ),
165                    Some(ShellInputFailure::WriteFailed) => format!(
166                        "shell {shell_id} closed its stdin — input discarded"
167                    ),
168                    Some(ShellInputFailure::NotRunning) | None => format!(
169                        "shell {shell_id} is not running — input discarded"
170                    ),
171                }
172            })
173        }
174        "ShellStatus" => {
175            let shell_id = arguments
176                .get("shell_id")
177                .and_then(|v| v.as_str())
178                .ok_or_else(|| anyhow::anyhow!("missing required parameter: shell_id"))?;
179
180            if local_url.is_empty() || auth_key.is_empty() {
181                anyhow::bail!(
182                    "AGENTMUX_LOCAL_URL and AGENTMUX_AUTH_KEY must be set. \
183                     Is this agent pane opened via AgentMux?"
184                );
185            }
186
187            let url = format!("{}/api/v1/shell/status", local_url.trim_end_matches('/'));
188            let resp = client
189                .post(&url)
190                .header(AUTH_KEY_HEADER, auth_key)
191                .json(&ShellStatusRequest { shell_id: shell_id.to_string() })
192                .send()
193                .await
194                .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
195
196            if !resp.status().is_success() {
197                let status = resp.status();
198                let body = resp.text().await.unwrap_or_default();
199                anyhow::bail!("shell/status failed: HTTP {status} — {body}");
200            }
201
202            let result: ShellStatusResponse = resp
203                .json()
204                .await
205                .map_err(|e| anyhow::anyhow!("response parse failed: {e}"))?;
206            Ok(if result.running {
207                format!("shell {shell_id} is running ({} lines so far)", result.line_count)
208            } else {
209                let code = result.exit_code.map(|c| c.to_string()).unwrap_or_else(|| "unknown".to_string());
210                format!("shell {shell_id} has exited — exit_code: {code}, {} lines total", result.line_count)
211            })
212        }
213        "ConnList" => {
214            crate::srv_http::require_agent_env(local_url, auth_key, block_id)?;
215            let url = format!("{}/api/v1/conn/list", local_url.trim_end_matches('/'));
216            let result = crate::srv_http::srv_get_json(client, &url, auth_key, &[], "ConnList").await?;
217            Ok(serde_json::to_string_pretty(&result)?)
218        }
219        _ => Err(not_in_family()),
220    }
221}