Skip to main content

agentmux_mcp/tools/
panes.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Panes, tabs and windows: open, name, arrange, focus, close; the agent's own identity and lifecycle.
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        "OpenEditor" => {
12            let connection = connection_arg(arguments);
13            let file = arguments
14                .get("file")
15                .and_then(|v| v.as_str())
16                .filter(|s| !s.is_empty())
17                .ok_or_else(|| anyhow::anyhow!("missing required parameter: file"))?;
18
19            if local_url.is_empty() || auth_key.is_empty() {
20                anyhow::bail!(
21                    "AGENTMUX_LOCAL_URL and AGENTMUX_AUTH_KEY must be set. \
22                     Is this agent pane opened via AgentMux?"
23                );
24            }
25
26            let split = arguments
27                .get("split")
28                .and_then(|v| v.as_str())
29                .filter(|s| matches!(*s, "right" | "left" | "down" | "up"))
30                .unwrap_or("right");
31
32            let url = format!("{}/api/v1/pane/open", local_url.trim_end_matches('/'));
33            // Place the editor relative to the calling agent pane when we know
34            // its block id (AGENTMUX_BLOCKID); otherwise the sidecar inserts it
35            // at the tab root.
36            let (split_direction, split_reference_block_id) = if block_id.is_empty() {
37                (None, None)
38            } else {
39                (Some(split.to_string()), Some(block_id.to_string()))
40            };
41            let req = PaneOpenRequest {
42                view: "editor".to_string(),
43                file: Some(file.to_string()),
44                focus: Some(true),
45                split_direction,
46                split_reference_block_id,
47                title: arguments.get("title").and_then(|v| v.as_str()).map(str::to_string),
48                // `collapse_tree: true` → open with the file-tree sidebar collapsed.
49                // Maps to the editor's `tree_expanded` meta (collapsed == not expanded).
50                tree_expanded: if arguments.get("collapse_tree").and_then(|v| v.as_bool()) == Some(true) {
51                    Some(false)
52                } else {
53                    None
54                },
55                // `floating: true` → open in a floating window instead of a docked split.
56                floating: if arguments.get("floating").and_then(|v| v.as_bool()) == Some(true) {
57                    Some(true)
58                } else {
59                    None
60                },
61                url: None,
62                cwd: None,
63                tab_id: None,
64                // Reuse an already-open Editor pane in this agent's own tab
65                // instead of always spawning a new one — the explicit opt-in
66                // only OpenEditor sets (see reuse_editor_pane's doc comment on
67                // PaneOpenRequest for why this can't be inferred from
68                // split_reference_block_id alone).
69                reuse_editor_pane: Some(true),
70                select: None,
71                // On an SSH host: srv asks the user to allow it, checking this
72                // agent's signed identity (remote terminals spec §8.2).
73                auth: if connection.is_some() { Some(sign_ui_automation_auth()?) } else { None },
74                connection,
75            };
76
77            let resp = client
78                .post(&url)
79                .header(AUTH_KEY_HEADER, auth_key)
80                .json(&req)
81                .send()
82                .await
83                .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
84
85            if !resp.status().is_success() {
86                let status = resp.status();
87                let text = resp.text().await.unwrap_or_default();
88                anyhow::bail!("pane.open failed: HTTP {status} — {text}");
89            }
90
91            let result: PaneOpenResponse = resp
92                .json()
93                .await
94                .map_err(|e| anyhow::anyhow!("response parse failed: {e}"))?;
95
96            Ok(format!("Opened {file} in editor pane (block {})", result.block_id))
97        }
98        "OpenMedia" => {
99            let file = arguments
100                .get("file")
101                .and_then(|v| v.as_str())
102                .filter(|s| !s.is_empty())
103                .ok_or_else(|| anyhow::anyhow!("missing required parameter: file"))?;
104
105            if local_url.is_empty() || auth_key.is_empty() {
106                anyhow::bail!(
107                    "AGENTMUX_LOCAL_URL and AGENTMUX_AUTH_KEY must be set. \
108                     Is this agent pane opened via AgentMux?"
109                );
110            }
111
112            let split = arguments
113                .get("split")
114                .and_then(|v| v.as_str())
115                .filter(|s| matches!(*s, "right" | "left" | "down" | "up"))
116                .unwrap_or("right");
117
118            let url = format!("{}/api/v1/pane/open", local_url.trim_end_matches('/'));
119            // Place the media pane relative to the calling agent pane when we
120            // know its block id (AGENTMUX_BLOCKID); otherwise the sidecar
121            // inserts it at the tab root.
122            let (split_direction, split_reference_block_id) = if block_id.is_empty() {
123                (None, None)
124            } else {
125                (Some(split.to_string()), Some(block_id.to_string()))
126            };
127            let req = PaneOpenRequest {
128                view: "media".to_string(),
129                file: Some(file.to_string()),
130                focus: Some(true),
131                split_direction,
132                split_reference_block_id,
133                title: arguments.get("title").and_then(|v| v.as_str()).map(str::to_string),
134                // The Media pane has no file-tree sidebar, unlike Editor.
135                tree_expanded: None,
136                // `floating: true` → open in a floating window instead of a docked split.
137                floating: if arguments.get("floating").and_then(|v| v.as_bool()) == Some(true) {
138                    Some(true)
139                } else {
140                    None
141                },
142                url: None,
143                cwd: None,
144                tab_id: None,
145                reuse_editor_pane: None, // view != "editor" — irrelevant here
146                select: None,
147                connection: None,
148                auth: None,
149            };
150
151            let resp = client
152                .post(&url)
153                .header(AUTH_KEY_HEADER, auth_key)
154                .json(&req)
155                .send()
156                .await
157                .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
158
159            if !resp.status().is_success() {
160                let status = resp.status();
161                let text = resp.text().await.unwrap_or_default();
162                anyhow::bail!("pane.open failed: HTTP {status} — {text}");
163            }
164
165            let result: PaneOpenResponse = resp
166                .json()
167                .await
168                .map_err(|e| anyhow::anyhow!("response parse failed: {e}"))?;
169
170            Ok(format!("Opened {file} in media pane (block {})", result.block_id))
171        }
172        // A Files pane on a folder, optionally with entries selected.
173        // Navigation only (SPEC_FILE_BROWSER_PANE_2026_10_01.md §8.1, §9:
174        // agents don't mutate through this pane).
175        "OpenFiles" => {
176            let connection = connection_arg(arguments);
177            let path = arguments
178                .get("path")
179                .and_then(|v| v.as_str())
180                .filter(|s| !s.is_empty())
181                .ok_or_else(|| anyhow::anyhow!("missing required parameter: path"))?;
182            let select: Vec<String> = arguments
183                .get("select")
184                .and_then(|v| v.as_array())
185                .map(|a| a.iter().filter_map(|v| v.as_str()).filter(|s| !s.is_empty()).map(str::to_string).collect())
186                .unwrap_or_default();
187
188            if local_url.is_empty() || auth_key.is_empty() {
189                anyhow::bail!(
190                    "AGENTMUX_LOCAL_URL and AGENTMUX_AUTH_KEY must be set. \
191                     Is this agent pane opened via AgentMux?"
192                );
193            }
194
195            let split = arguments
196                .get("split")
197                .and_then(|v| v.as_str())
198                .filter(|s| matches!(*s, "right" | "left" | "down" | "up"))
199                .unwrap_or("right");
200
201            let url = format!("{}/api/v1/pane/open", local_url.trim_end_matches('/'));
202            let (split_direction, split_reference_block_id) = if block_id.is_empty() {
203                (None, None)
204            } else {
205                (Some(split.to_string()), Some(block_id.to_string()))
206            };
207            let req = PaneOpenRequest {
208                view: "files".to_string(),
209                file: Some(path.to_string()),
210                focus: Some(true),
211                split_direction,
212                split_reference_block_id,
213                title: arguments.get("title").and_then(|v| v.as_str()).map(str::to_string),
214                tree_expanded: None,
215                floating: if arguments.get("floating").and_then(|v| v.as_bool()) == Some(true) {
216                    Some(true)
217                } else {
218                    None
219                },
220                url: None,
221                cwd: None,
222                tab_id: None,
223                reuse_editor_pane: None, // view != "editor" — irrelevant here
224                select: if select.is_empty() { None } else { Some(select) },
225                // On an SSH host: srv asks the user to allow it, checking this
226                // agent's signed identity (remote terminals spec §8.2).
227                auth: if connection.is_some() { Some(sign_ui_automation_auth()?) } else { None },
228                connection,
229            };
230
231            let resp = client
232                .post(&url)
233                .header(AUTH_KEY_HEADER, auth_key)
234                .json(&req)
235                .send()
236                .await
237                .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
238
239            if !resp.status().is_success() {
240                let status = resp.status();
241                let text = resp.text().await.unwrap_or_default();
242                anyhow::bail!("pane.open failed: HTTP {status} — {text}");
243            }
244
245            let result: PaneOpenResponse = resp
246                .json()
247                .await
248                .map_err(|e| anyhow::anyhow!("response parse failed: {e}"))?;
249
250            Ok(format!("Opened {path} in a Files pane (block {})", result.block_id))
251        }
252        "OpenAgent" => {
253            let agent_id = arguments
254                .get("agent_id")
255                .and_then(|v| v.as_str())
256                .filter(|s| !s.is_empty())
257                .ok_or_else(|| anyhow::anyhow!("missing required parameter: agent_id"))?;
258            let tab_id = arguments.get("tab_id").and_then(|v| v.as_str()).map(str::to_string);
259            let focus = arguments.get("focus").and_then(|v| v.as_bool());
260
261            if local_url.is_empty() || auth_key.is_empty() {
262                anyhow::bail!(
263                    "AGENTMUX_LOCAL_URL and AGENTMUX_AUTH_KEY must be set. \
264                     Is this agent pane opened via AgentMux?"
265                );
266            }
267
268            let url = format!("{}/api/v1/agent/open", local_url.trim_end_matches('/'));
269            let body = json!({
270                "agent_id": agent_id,
271                "tab_id": tab_id,
272                "focus": focus,
273            });
274            let resp = client
275                .post(&url)
276                .header(AUTH_KEY_HEADER, auth_key)
277                .json(&body)
278                .send()
279                .await
280                .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
281
282            if !resp.status().is_success() {
283                let status = resp.status();
284                let text = resp.text().await.unwrap_or_default();
285                anyhow::bail!("agent open failed: HTTP {status} — {text}");
286            }
287
288            let result: Value = resp
289                .json()
290                .await
291                .map_err(|e| anyhow::anyhow!("response parse failed: {e}"))?;
292
293            Ok(serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string()))
294        }
295        "WhoAmI" => {
296            if local_url.is_empty() || auth_key.is_empty() {
297                anyhow::bail!(
298                    "AGENTMUX_LOCAL_URL and AGENTMUX_AUTH_KEY must be set. \
299                     Is this agent pane opened via AgentMux?"
300                );
301            }
302            if block_id.is_empty() {
303                anyhow::bail!(
304                    "neither AGENTMUX_AGENT_BUS_ID nor AGENTMUX_BLOCKID is set \
305                     — cannot resolve this agent's pane. Is this agent pane opened via AgentMux?"
306                );
307            }
308
309            let url = format!("{}/api/v1/self", local_url.trim_end_matches('/'));
310            let resp = client
311                .get(&url)
312                .header(AUTH_KEY_HEADER, auth_key)
313                .query(&[("block_id", block_id)])
314                .send()
315                .await
316                .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
317
318            if !resp.status().is_success() {
319                let status = resp.status();
320                let text = resp.text().await.unwrap_or_default();
321                anyhow::bail!("self lookup failed: HTTP {status} — {text}");
322            }
323
324            let result: Value = resp
325                .json()
326                .await
327                .map_err(|e| anyhow::anyhow!("response parse failed: {e}"))?;
328
329            Ok(serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string()))
330        }
331        "SetName" => {
332            let target = arguments
333                .get("target")
334                .and_then(|v| v.as_str())
335                .map(|s| s.trim())
336                .filter(|s| !s.is_empty())
337                .ok_or_else(|| anyhow::anyhow!("missing required parameter: target"))?;
338            let new_name = arguments
339                .get("name")
340                .and_then(|v| v.as_str())
341                .map(|s| s.trim())
342                .filter(|s| !s.is_empty())
343                .ok_or_else(|| anyhow::anyhow!("missing required parameter: name"))?;
344            let target_id = arguments
345                .get("target_id")
346                .and_then(|v| v.as_str())
347                .filter(|s| !s.is_empty())
348                .map(str::to_string);
349
350            if local_url.is_empty() || auth_key.is_empty() {
351                anyhow::bail!(
352                    "AGENTMUX_LOCAL_URL and AGENTMUX_AUTH_KEY must be set. \
353                     Is this agent pane opened via AgentMux?"
354                );
355            }
356            // block_id is only needed for own-context resolution; when
357            // target_id is explicit we can reach any element without it.
358            if target_id.is_none() && block_id.is_empty() {
359                anyhow::bail!(
360                    "neither AGENTMUX_AGENT_BUS_ID nor AGENTMUX_BLOCKID is set \
361                     — pass target_id to name a specific element, or open this \
362                     agent in an AgentMux pane to default to your own."
363                );
364            }
365
366            // window/tab/workspace POST {"name": …} to /…/name; pane POSTs
367            // {"title": …} to /pane/title. `label` and `resp_field` are the
368            // human-facing echo and the response key used to surface the
369            // server-applied value (e.g. a window name clamped to 64 chars).
370            let own_block = if block_id.is_empty() { None } else { Some(block_id.to_string()) };
371            let (path, label, resp_field, body) = match target {
372                "window" => ("window/name", "Window name", "name", serde_json::to_value(WindowNameRequest {
373                    block_id: own_block,
374                    name: new_name.to_string(),
375                    window_id: target_id,
376                })?),
377                "tab" => ("tab/name", "Tab name", "name", serde_json::to_value(TabNameRequest {
378                    block_id: own_block,
379                    tab_id: target_id,
380                    name: new_name.to_string(),
381                })?),
382                "workspace" => ("workspace/name", "Workspace name", "name", serde_json::to_value(WorkspaceNameRequest {
383                    block_id: own_block,
384                    workspace_id: target_id,
385                    name: new_name.to_string(),
386                })?),
387                "pane" => ("pane/title", "Pane title", "title", serde_json::to_value(PaneTitleRequest {
388                    block_id: target_id.or(own_block),
389                    title: new_name.to_string(),
390                })?),
391                other => anyhow::bail!(
392                    "invalid target '{other}' — expected one of: window, tab, pane, workspace"
393                ),
394            };
395            let url = format!("{}/api/v1/{path}", local_url.trim_end_matches('/'));
396            let resp = client
397                .post(&url)
398                .header(AUTH_KEY_HEADER, auth_key)
399                .json(&body)
400                .send()
401                .await
402                .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
403            if !resp.status().is_success() {
404                let status = resp.status();
405                let text = resp.text().await.unwrap_or_default();
406                anyhow::bail!("{label} change failed: HTTP {status} — {text}");
407            }
408            // Surface the server-applied value (e.g. a window name clamped to 64
409            // chars) when the endpoint echoes it back; fall back to the request.
410            let applied = resp
411                .json::<Value>()
412                .await
413                .ok()
414                .and_then(|v| v.get(resp_field).and_then(|s| s.as_str()).map(str::to_string))
415                .unwrap_or_else(|| new_name.to_string());
416            Ok(format!("{label} set to \"{applied}\""))
417        }
418        "Layout" => {
419            if local_url.is_empty() || auth_key.is_empty() {
420                anyhow::bail!(
421                    "AGENTMUX_LOCAL_URL and AGENTMUX_AUTH_KEY must be set. \
422                     Is this agent pane opened via AgentMux?"
423                );
424            }
425            let query = arguments
426                .get("query")
427                .and_then(|v| v.as_str())
428                .map(|s| s.trim())
429                .filter(|s| !s.is_empty())
430                .unwrap_or("layout");
431            let path = match query {
432                "layout" => "layout",
433                "windows" => "windows",
434                "workspaces" => "workspaces",
435                "tabs" => "tabs",
436                other => anyhow::bail!(
437                    "invalid query '{other}' — expected one of: layout, windows, workspaces, tabs"
438                ),
439            };
440            let url = format!("{}/api/v1/{path}", local_url.trim_end_matches('/'));
441            let mut reqb = client.get(&url).header(AUTH_KEY_HEADER, auth_key);
442            // The "tabs" query scopes to the caller's own workspace when we know it.
443            if query == "tabs" && !block_id.is_empty() {
444                reqb = reqb.query(&[("block_id", block_id)]);
445            }
446            let resp = reqb
447                .send()
448                .await
449                .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
450            if !resp.status().is_success() {
451                let status = resp.status();
452                let text = resp.text().await.unwrap_or_default();
453                anyhow::bail!("Layout({query}) failed: HTTP {status} — {text}");
454            }
455            let result: Value = resp
456                .json()
457                .await
458                .map_err(|e| anyhow::anyhow!("response parse failed: {e}"))?;
459            Ok(serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string()))
460        }
461        "SetActiveTab" => {
462            let tab_id = arguments
463                .get("tab_id")
464                .and_then(|v| v.as_str())
465                .map(|s| s.trim())
466                .filter(|s| !s.is_empty())
467                .ok_or_else(|| anyhow::anyhow!("missing required parameter: tab_id"))?;
468            require_agent_env(local_url, auth_key, block_id)?;
469            let url = format!("{}/api/v1/tab/activate", local_url.trim_end_matches('/'));
470            let resp = client
471                .post(&url)
472                .header(AUTH_KEY_HEADER, auth_key)
473                .json(&TabActivateRequest { tab_id: tab_id.to_string() })
474                .send()
475                .await
476                .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
477            if !resp.status().is_success() {
478                let status = resp.status();
479                let text = resp.text().await.unwrap_or_default();
480                anyhow::bail!("set active tab failed: HTTP {status} — {text}");
481            }
482            Ok(format!("Switched to tab {tab_id}"))
483        }
484        "NewTab" => {
485            require_agent_env(local_url, auth_key, block_id)?;
486            let name = arguments.get("name").and_then(|v| v.as_str()).unwrap_or("");
487            let url = format!("{}/api/v1/tab/new", local_url.trim_end_matches('/'));
488            let resp = client
489                .post(&url)
490                .header(AUTH_KEY_HEADER, auth_key)
491                .json(&TabNewRequest {
492                    block_id: Some(block_id.to_string()),
493                    workspace_id: None,
494                    name: if name.is_empty() { None } else { Some(name.to_string()) },
495                })
496                .send()
497                .await
498                .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
499            if !resp.status().is_success() {
500                let status = resp.status();
501                let text = resp.text().await.unwrap_or_default();
502                anyhow::bail!("new tab failed: HTTP {status} — {text}");
503            }
504            Ok(if name.is_empty() {
505                "Opened a new tab".to_string()
506            } else {
507                format!("Opened new tab \"{name}\"")
508            })
509        }
510        "FocusWindow" => {
511            require_agent_env(local_url, auth_key, block_id)?;
512            let window_id = arguments.get("window_id").and_then(|v| v.as_str()).unwrap_or("");
513            let url = format!("{}/api/v1/window/focus", local_url.trim_end_matches('/'));
514            let resp = client
515                .post(&url)
516                .header(AUTH_KEY_HEADER, auth_key)
517                .json(&WindowFocusRequest {
518                    block_id: Some(block_id.to_string()),
519                    window_id: if window_id.is_empty() { None } else { Some(window_id.to_string()) },
520                })
521                .send()
522                .await
523                .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
524            if !resp.status().is_success() {
525                let status = resp.status();
526                let text = resp.text().await.unwrap_or_default();
527                anyhow::bail!("focus window failed: HTTP {status} — {text}");
528            }
529            Ok("Focused window".to_string())
530        }
531        "ClosePane" => {
532            require_agent_env(local_url, auth_key, block_id)?;
533            let auth = sign_ui_automation_auth()?;
534            let target_block_id = arguments.get("block_id").and_then(|v| v.as_str()).map(str::to_string);
535            let reason = arguments.get("reason").and_then(|v| v.as_str()).map(str::to_string);
536            let url = format!("{}/api/v1/agent/pane/close", local_url.trim_end_matches('/'));
537            let resp = client
538                .post(&url)
539                .header(AUTH_KEY_HEADER, auth_key)
540                .json(&ClosePaneRequest { auth, block_id: target_block_id.clone(), reason })
541                .send()
542                .await
543                .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
544            if !resp.status().is_success() {
545                let status = resp.status();
546                let text = resp.text().await.unwrap_or_default();
547                anyhow::bail!("close failed: HTTP {status} — {text}");
548            }
549            if resp.status().as_u16() == 202 {
550                let body: Value = resp.json().await.unwrap_or(Value::Null);
551                let request_id = body["request_id"].as_str().unwrap_or_default();
552                return match target_block_id.as_deref() {
553                    // Another agent's pane: its user had 15 s to keep it (§6.5).
554                    Some(b) => {
555                        let now = await_shutdown(client, local_url, auth_key, request_id, true).await;
556                        close_pane_outcome(b, &now)
557                    }
558                    // Yourself (§7): as QuitSelf, done once it's proceeding —
559                    // the quit waits for this very turn to end.
560                    None => {
561                        let now = await_shutdown(client, local_url, auth_key, request_id, false).await;
562                        match now["status"].as_str().unwrap_or("") {
563                            "" | "pending" => anyhow::bail!("ClosePane: no answer on the pending shutdown {request_id}"),
564                            state => quit_self_outcome(state, &now),
565                        }
566                    }
567                };
568            }
569            match target_block_id {
570                Some(b) => Ok(format!("Closed pane {b:?}")),
571                // srv's only 200 for the no-argument form (§7): a quit already
572                // scheduled or under way.
573                None => quit_self_result(200, &Value::Null),
574            }
575        }
576        "QuitSelf" => {
577            require_agent_env(local_url, auth_key, block_id)?;
578            let field = |k: &str| {
579                arguments
580                    .get(k)
581                    .and_then(|v| v.as_str())
582                    .map(str::trim)
583                    .filter(|s| !s.is_empty())
584                    .map(str::to_string)
585                    .ok_or_else(|| anyhow::anyhow!("missing required parameter: {k}"))
586            };
587            let (reason, user_instruction) = (field("reason")?, field("user_instruction")?);
588            let auth = sign_ui_automation_auth()?;
589            let url = format!("{}/api/v1/agent/self/quit", local_url.trim_end_matches('/'));
590            let resp = client
591                .post(&url)
592                .header(AUTH_KEY_HEADER, auth_key)
593                .json(&QuitSelfRequest { auth, reason, user_instruction })
594                .send()
595                .await
596                .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
597            let status = resp.status();
598            let body: Value = resp.json().await.unwrap_or(Value::Null);
599            if status.as_u16() != 202 || body["status"] != "pending_user_override" {
600                return quit_self_result(status.as_u16(), &body);
601            }
602            // Not the user's own ask: the user has 15 s to keep this agent
603            // (SPEC_AGENT_SELF_QUIT_2026_09_24.md §6.5). Poll for the answer —
604            // one long request would outlast the client's timeout.
605            let request_id = body["request_id"].as_str().unwrap_or_default();
606            // `proceeding` is final for QuitSelf: the shutdown waits for this
607            // very turn to end, so waiting on here would hold it up.
608            let now = await_shutdown(client, local_url, auth_key, request_id, false).await;
609            match now["status"].as_str().unwrap_or("") {
610                "" | "pending" => anyhow::bail!("QuitSelf: no answer on the pending shutdown {request_id}"),
611                state => quit_self_outcome(state, &now),
612            }
613        }
614        "RegisterDevServer" => {
615            require_agent_env(local_url, auth_key, block_id)?;
616            let auth = sign_ui_automation_auth()?;
617            let project = arguments
618                .get("project")
619                .and_then(|v| v.as_str())
620                .filter(|s| !s.is_empty())
621                .ok_or_else(|| anyhow::anyhow!("missing required parameter: project"))?
622                .to_string();
623            let port = arguments
624                .get("port")
625                .and_then(|v| v.as_u64())
626                .filter(|p| *p > 0 && *p <= u16::MAX as u64)
627                .ok_or_else(|| anyhow::anyhow!("missing or invalid required parameter: port (must be 1-65535)"))?
628                as u16;
629            let url = format!(
630                "{}/api/v1/agent/dev_server/register",
631                local_url.trim_end_matches('/')
632            );
633            let resp = client
634                .post(&url)
635                .header(AUTH_KEY_HEADER, auth_key)
636                .json(&RegisterDevServerRequest { auth, project, port })
637                .send()
638                .await
639                .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
640            if !resp.status().is_success() {
641                let status = resp.status();
642                let text = resp.text().await.unwrap_or_default();
643                anyhow::bail!("RegisterDevServer failed: HTTP {status} — {text}");
644            }
645            let body: RegisterDevServerResponse = resp
646                .json()
647                .await
648                .map_err(|e| anyhow::anyhow!("response parse failed: {e}"))?;
649            Ok(format!("Registered. Browse to: {}", body.url))
650        }
651        _ => Err(not_in_family()),
652    }
653}
654
655
656/// The `connection` an Open tool was given: an SSH host or WSL name, trimmed;
657/// `None` for absent, empty or "local" (this machine).
658fn connection_arg(arguments: &Value) -> Option<String> {
659    arguments
660        .get("connection")
661        .and_then(|v| v.as_str())
662        .map(str::trim)
663        .filter(|c| !c.is_empty() && *c != "local")
664        .map(str::to_string)
665}
666
667#[cfg(test)]
668mod connection_arg_tests {
669    use super::*;
670
671    #[test]
672    fn only_a_named_connection_is_one() {
673        let c = |v: Value| connection_arg(&serde_json::json!({ "connection": v }));
674        assert_eq!(c(serde_json::json!(" user@box ")).as_deref(), Some("user@box"));
675        assert_eq!(c(serde_json::json!("wsl://Ubuntu")).as_deref(), Some("wsl://Ubuntu"));
676        assert_eq!(c(serde_json::json!("local")), None);
677        assert_eq!(c(serde_json::json!("")), None);
678        assert_eq!(connection_arg(&serde_json::json!({})), None);
679    }
680}