Skip to main content

agentmux_mcp/tools/
pty_shell.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! PtyShell*: interactive PTY shells.
5
6use super::*;
7
8pub(super) async fn call(name: &str, arguments: &Value, cx: &ToolCtx<'_>) -> Result<String> {
9    let ToolCtx { local_url, auth_key, block_id, client, .. } = *cx;
10    match name {
11        "PtyShell" => {
12            if local_url.is_empty() || auth_key.is_empty() {
13                anyhow::bail!(
14                    "AGENTMUX_LOCAL_URL and AGENTMUX_AUTH_KEY must be set. \
15                     Is this agent pane opened via AgentMux?"
16                );
17            }
18            if block_id.is_empty() {
19                anyhow::bail!(
20                    "neither AGENTMUX_AGENT_BUS_ID nor AGENTMUX_BLOCKID is set \
21                     — cannot associate the shell with a conversation pane. \
22                     Is this agent pane opened via AgentMux?"
23                );
24            }
25
26            let cwd = arguments.get("cwd").and_then(|v| v.as_str()).map(str::to_string);
27            let rows = arguments.get("rows").and_then(|v| v.as_u64()).map(|n| n as u16);
28            let cols = arguments.get("cols").and_then(|v| v.as_u64()).map(|n| n as u16);
29
30            let url = format!("{}/api/v1/ptyshell/create", local_url.trim_end_matches('/'));
31            let connection = arguments.get("connection").and_then(|v| v.as_str()).map(str::to_string);
32            let req = PtyShellCreateRequest {
33                agent_block_id: block_id.to_string(),
34                cwd,
35                rows,
36                cols,
37                connection,
38                auth: sign_ui_automation_auth().ok(),
39            };
40            let resp = client
41                .post(&url)
42                .header(AUTH_KEY_HEADER, auth_key)
43                .timeout(crate::srv_http::SHELL_CREATE_TIMEOUT)
44                .json(&req)
45                .send()
46                .await
47                .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
48
49            if !resp.status().is_success() {
50                let status = resp.status();
51                let text = resp.text().await.unwrap_or_default();
52                anyhow::bail!("ptyshell/create failed: HTTP {status} — {text}");
53            }
54
55            let result: PtyShellCreateResponse = resp
56                .json()
57                .await
58                .map_err(|e| anyhow::anyhow!("response parse failed: {e}"))?;
59            Ok(result.shell_id)
60        }
61        "PtyShellInput" => {
62            let shell_id = arguments
63                .get("shell_id")
64                .and_then(|v| v.as_str())
65                .ok_or_else(|| anyhow::anyhow!("missing required parameter: shell_id"))?;
66            let text = arguments
67                .get("text")
68                .and_then(|v| v.as_str())
69                .ok_or_else(|| anyhow::anyhow!("missing required parameter: text"))?;
70
71            if local_url.is_empty() || auth_key.is_empty() {
72                anyhow::bail!(
73                    "AGENTMUX_LOCAL_URL and AGENTMUX_AUTH_KEY must be set. \
74                     Is this agent pane opened via AgentMux?"
75                );
76            }
77
78            let url = format!("{}/api/v1/ptyshell/input", local_url.trim_end_matches('/'));
79            let resp = client
80                .post(&url)
81                .header(AUTH_KEY_HEADER, auth_key)
82                .json(&PtyShellInputRequest {
83                    shell_id: shell_id.to_string(),
84                    agent_block_id: block_id.to_string(),
85                    text: text.to_string(),
86                })
87                .send()
88                .await
89                .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
90
91            if !resp.status().is_success() {
92                let status = resp.status();
93                let body = resp.text().await.unwrap_or_default();
94                anyhow::bail!("ptyshell/input failed: HTTP {status} — {body}");
95            }
96
97            let result: PtyShellInputResponse = resp
98                .json()
99                .await
100                .map_err(|e| anyhow::anyhow!("response parse failed: {e}"))?;
101            Ok(if result.written {
102                format!("wrote to shell {shell_id}")
103            } else {
104                format!(
105                    "shell {shell_id}: write failed — {}",
106                    result.error.unwrap_or_else(|| "unknown reason".to_string())
107                )
108            })
109        }
110        "PtyShellResize" => {
111            let shell_id = arguments
112                .get("shell_id")
113                .and_then(|v| v.as_str())
114                .ok_or_else(|| anyhow::anyhow!("missing required parameter: shell_id"))?;
115            let rows = arguments
116                .get("rows")
117                .and_then(|v| v.as_u64())
118                .ok_or_else(|| anyhow::anyhow!("missing required parameter: rows"))? as u16;
119            let cols = arguments
120                .get("cols")
121                .and_then(|v| v.as_u64())
122                .ok_or_else(|| anyhow::anyhow!("missing required parameter: cols"))? as u16;
123
124            if local_url.is_empty() || auth_key.is_empty() {
125                anyhow::bail!(
126                    "AGENTMUX_LOCAL_URL and AGENTMUX_AUTH_KEY must be set. \
127                     Is this agent pane opened via AgentMux?"
128                );
129            }
130
131            let url = format!("{}/api/v1/ptyshell/resize", local_url.trim_end_matches('/'));
132            let resp = client
133                .post(&url)
134                .header(AUTH_KEY_HEADER, auth_key)
135                .json(&PtyShellResizeRequest {
136                    shell_id: shell_id.to_string(),
137                    agent_block_id: block_id.to_string(),
138                    rows,
139                    cols,
140                })
141                .send()
142                .await
143                .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
144
145            if !resp.status().is_success() {
146                let status = resp.status();
147                let body = resp.text().await.unwrap_or_default();
148                anyhow::bail!("ptyshell/resize failed: HTTP {status} — {body}");
149            }
150
151            let result: PtyShellResizeResponse = resp
152                .json()
153                .await
154                .map_err(|e| anyhow::anyhow!("response parse failed: {e}"))?;
155            Ok(if result.resized {
156                format!("resized shell {shell_id} to {rows}x{cols}")
157            } else {
158                format!(
159                    "shell {shell_id}: resize failed — {}",
160                    result.error.unwrap_or_else(|| "unknown reason".to_string())
161                )
162            })
163        }
164        "PtyShellRead" => {
165            let shell_id = arguments
166                .get("shell_id")
167                .and_then(|v| v.as_str())
168                .ok_or_else(|| anyhow::anyhow!("missing required parameter: shell_id"))?;
169            let tail_lines = arguments.get("tail_lines").and_then(|v| v.as_u64()).map(|n| n as u32);
170
171            if local_url.is_empty() || auth_key.is_empty() {
172                anyhow::bail!(
173                    "AGENTMUX_LOCAL_URL and AGENTMUX_AUTH_KEY must be set. \
174                     Is this agent pane opened via AgentMux?"
175                );
176            }
177
178            let url = format!("{}/api/v1/ptyshell/read", local_url.trim_end_matches('/'));
179            let resp = client
180                .post(&url)
181                .header(AUTH_KEY_HEADER, auth_key)
182                .json(&PtyShellReadRequest {
183                    shell_id: shell_id.to_string(),
184                    agent_block_id: block_id.to_string(),
185                    tail_lines,
186                })
187                .send()
188                .await
189                .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
190
191            if !resp.status().is_success() {
192                let status = resp.status();
193                let body = resp.text().await.unwrap_or_default();
194                anyhow::bail!("ptyshell/read failed: HTTP {status} — {body}");
195            }
196
197            let result: PtyShellReadResponse = resp
198                .json()
199                .await
200                .map_err(|e| anyhow::anyhow!("response parse failed: {e}"))?;
201            let prefix = if result.truncated { "[...output truncated...]\n" } else { "" };
202            Ok(format!("{prefix}{}", result.content))
203        }
204        "PtyShellStatus" => {
205            let shell_id = arguments
206                .get("shell_id")
207                .and_then(|v| v.as_str())
208                .ok_or_else(|| anyhow::anyhow!("missing required parameter: shell_id"))?;
209
210            if local_url.is_empty() || auth_key.is_empty() {
211                anyhow::bail!(
212                    "AGENTMUX_LOCAL_URL and AGENTMUX_AUTH_KEY must be set. \
213                     Is this agent pane opened via AgentMux?"
214                );
215            }
216
217            let url = format!("{}/api/v1/ptyshell/status", local_url.trim_end_matches('/'));
218            let resp = client
219                .post(&url)
220                .header(AUTH_KEY_HEADER, auth_key)
221                .json(&PtyShellStatusRequest {
222                    shell_id: shell_id.to_string(),
223                    agent_block_id: block_id.to_string(),
224                })
225                .send()
226                .await
227                .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
228
229            if !resp.status().is_success() {
230                let status = resp.status();
231                let body = resp.text().await.unwrap_or_default();
232                anyhow::bail!("ptyshell/status failed: HTTP {status} — {body}");
233            }
234
235            let result: PtyShellStatusResponse = resp
236                .json()
237                .await
238                .map_err(|e| anyhow::anyhow!("response parse failed: {e}"))?;
239            Ok(if result.running {
240                format!("shell {shell_id} is running")
241            } else {
242                let code = result.exit_code.map(|c| c.to_string()).unwrap_or_else(|| "unknown".to_string());
243                format!("shell {shell_id} is not running — exit_code: {code}")
244            })
245        }
246        "PtyShellStop" => {
247            let shell_id = arguments
248                .get("shell_id")
249                .and_then(|v| v.as_str())
250                .ok_or_else(|| anyhow::anyhow!("missing required parameter: shell_id"))?;
251
252            if local_url.is_empty() || auth_key.is_empty() {
253                anyhow::bail!(
254                    "AGENTMUX_LOCAL_URL and AGENTMUX_AUTH_KEY must be set. \
255                     Is this agent pane opened via AgentMux?"
256                );
257            }
258
259            let url = format!("{}/api/v1/ptyshell/stop", local_url.trim_end_matches('/'));
260            let resp = client
261                .post(&url)
262                .header(AUTH_KEY_HEADER, auth_key)
263                .json(&PtyShellStopRequest {
264                    shell_id: shell_id.to_string(),
265                    agent_block_id: block_id.to_string(),
266                })
267                .send()
268                .await
269                .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
270
271            if !resp.status().is_success() {
272                let status = resp.status();
273                let body = resp.text().await.unwrap_or_default();
274                anyhow::bail!("ptyshell/stop failed: HTTP {status} — {body}");
275            }
276
277            let result: PtyShellStopResponse = resp
278                .json()
279                .await
280                .map_err(|e| anyhow::anyhow!("response parse failed: {e}"))?;
281            Ok(if result.released {
282                format!("released the agent lock on shell {shell_id} (the shell itself keeps running)")
283            } else {
284                format!("shell {shell_id}: no active agent lock to release (unrecognized id, not yours, or already unlocked)")
285            })
286        }
287        _ => Err(not_in_family()),
288    }
289}