From 87d67cdf1c4fc44c450bf890ece3e6f4dcfaaa1f Mon Sep 17 00:00:00 2001 From: brettchien Date: Fri, 14 Aug 2026 13:42:37 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(remote):=20activate=20reverse-MCP=20co?= =?UTF-8?q?nnection=20=E2=80=94=20config=20+=20WS=20transport=20+=20comman?= =?UTF-8?q?ds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The operator-triggered remote reverse-MCP-over-ACP connection (reverse-MCP client ADR, Part B): Studio dials the /acp endpoint, declares its `oab` server, and relays tunnelled MCP to the running oab-mcp sidecar. - acp-tunnel::config::RemoteConfig — the remote.toml schema (url/token/cwd), parse/serialize/validate. Unit-tested (5 cases; empty=not-configured, bad scheme rejected, cwd defaults). aws-free, runs locally. - src-tauri/remote.rs — the WS transport: dial /acp (tokio-tungstenite, bearer sub-protocol), drive the acp-tunnel Session (initialize → session/new declaring oab → resume on reconnect), and serve the gateway-initiated tunnel by relaying initialize (answered locally) / tools/list / tools/call to the sidecar McpClient. Reconnect with backoff; abort on disconnect. Config file IO with validate-before-write. - commands: remote_config / remote_config_write (editable remote.toml, same pattern as fleets.toml) / remote_connect (the explicit Activate) / remote_disconnect; `remote-status` events. McpClient::request made pub so the tunnel relays raw MCP results. Verification: `cargo test -p acp-tunnel` 18/18 (13 protocol + 5 config) locally. The src-tauri transport compiles under desktop.yml (macOS); its end-to-end wire behaviour is validated against the live gateway once the §5 endpoint exists (exact ACP initialize/session params + TLS provider are live-validation items). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- crates/acp-tunnel/Cargo.toml | 1 + crates/acp-tunnel/src/config.rs | 155 ++++++++++++++++++ crates/acp-tunnel/src/lib.rs | 2 + src-tauri/Cargo.toml | 11 +- src-tauri/src/lib.rs | 74 +++++++++ src-tauri/src/mcp.rs | 5 +- src-tauri/src/remote.rs | 268 ++++++++++++++++++++++++++++++++ 7 files changed, 514 insertions(+), 2 deletions(-) create mode 100644 crates/acp-tunnel/src/config.rs create mode 100644 src-tauri/src/remote.rs diff --git a/crates/acp-tunnel/Cargo.toml b/crates/acp-tunnel/Cargo.toml index 976b4f7..c1a63b8 100644 --- a/crates/acp-tunnel/Cargo.toml +++ b/crates/acp-tunnel/Cargo.toml @@ -8,3 +8,4 @@ license = "MIT" [dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1" +toml = "0.8" diff --git a/crates/acp-tunnel/src/config.rs b/crates/acp-tunnel/src/config.rs new file mode 100644 index 0000000..620e8d1 --- /dev/null +++ b/crates/acp-tunnel/src/config.rs @@ -0,0 +1,155 @@ +//! The reverse-MCP **remote-connection config** — the `/acp` endpoint Studio +//! dials to attach to an agent runtime (reverse-MCP client ADR §5). +//! +//! Stored as a TOML file the operator can **edit in-app** (the same editor +//! pattern as `fleets.toml`), at `~/.config/oab-studio/remote.toml`. Parsing, +//! serialization, and validation are pure (here); the file IO + the actual dial +//! live in the transport (`src-tauri`). +//! +//! ⚠️ The `token` is a `/acp` bearer — a **secret** that lives in this file. The +//! app surfaces it in the editor (like any editable config) but must never log +//! it. Prefer the loopback/keyless posture when the gateway allows it. + +use serde::{Deserialize, Serialize}; + +fn default_cwd() -> String { + "/".to_string() +} + +/// The declarative remote-connection config. All fields default to empty so a +/// partial / not-yet-configured file still parses (the app shows it as +/// "not configured" rather than erroring). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RemoteConfig { + /// The `/acp` WSS endpoint (e.g. `wss://gateway.example/acp`). + #[serde(default)] + pub url: String, + /// The `/acp` bearer token (secret; rides the WS sub-protocol on dial). + #[serde(default)] + pub token: String, + /// The session `cwd` sent on `session/new` (defaults to `/`). + #[serde(default = "default_cwd")] + pub cwd: String, +} + +impl Default for RemoteConfig { + fn default() -> Self { + RemoteConfig { + url: String::new(), + token: String::new(), + cwd: default_cwd(), + } + } +} + +impl RemoteConfig { + /// Parse from the TOML file text. An empty file yields the default (empty) + /// config rather than an error, so a fresh install is "not configured". + pub fn parse(text: &str) -> Result { + toml::from_str(text) + } + + /// Serialize to TOML for writing the file back (round-trips the fields; the + /// editor holds the operator's raw text, so this is only used when the app + /// writes structured changes rather than raw text). + pub fn to_toml(&self) -> String { + toml::to_string_pretty(self).unwrap_or_default() + } + + /// Whether enough is set to attempt a connection: a URL **and** a token. + /// (Drives whether the "Activate" button is enabled.) + pub fn is_configured(&self) -> bool { + !self.url.trim().is_empty() && !self.token.trim().is_empty() + } + + /// Validate before dialing. Checks the URL is present and a WebSocket scheme; + /// a bad edit is rejected **without** connecting (mirroring how the fleet + /// config editor rejects bad TOML without writing). The token's validity is + /// only knowable at the handshake, so it is not checked here beyond presence. + pub fn validate(&self) -> Result<(), String> { + let url = self.url.trim(); + if url.is_empty() { + return Err("url is required (the /acp WSS endpoint)".to_string()); + } + if !(url.starts_with("ws://") || url.starts_with("wss://")) { + return Err(format!( + "url must be a WebSocket endpoint (ws:// or wss://), got {url:?}" + )); + } + if self.token.trim().is_empty() { + return Err("token is required (the /acp bearer)".to_string()); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_a_full_config() { + let cfg = RemoteConfig::parse( + r#" +url = "wss://gw.example/acp" +token = "sekret" +cwd = "/work" +"#, + ) + .expect("parse"); + assert_eq!(cfg.url, "wss://gw.example/acp"); + assert_eq!(cfg.token, "sekret"); + assert_eq!(cfg.cwd, "/work"); + assert!(cfg.is_configured()); + assert!(cfg.validate().is_ok()); + } + + #[test] + fn empty_file_is_the_default_not_configured() { + let cfg = RemoteConfig::parse("").expect("empty parses"); + assert_eq!(cfg, RemoteConfig::default()); + assert_eq!(cfg.cwd, "/"); // cwd defaults even when absent + assert!(!cfg.is_configured()); + assert!(cfg.validate().is_err()); + } + + #[test] + fn cwd_defaults_when_omitted() { + let cfg = RemoteConfig::parse(r#"url = "wss://x/acp" +token = "t""#) + .expect("parse"); + assert_eq!(cfg.cwd, "/"); + } + + #[test] + fn validate_rejects_missing_url_bad_scheme_and_missing_token() { + assert!(RemoteConfig { url: "".into(), token: "t".into(), cwd: "/".into() } + .validate() + .is_err()); + assert!(RemoteConfig { + url: "http://x/acp".into(), + token: "t".into(), + cwd: "/".into() + } + .validate() + .unwrap_err() + .contains("WebSocket")); + assert!(RemoteConfig { + url: "wss://x/acp".into(), + token: "".into(), + cwd: "/".into() + } + .validate() + .is_err()); + } + + #[test] + fn to_toml_round_trips() { + let cfg = RemoteConfig { + url: "wss://x/acp".into(), + token: "t".into(), + cwd: "/w".into(), + }; + assert_eq!(RemoteConfig::parse(&cfg.to_toml()).unwrap(), cfg); + } +} diff --git a/crates/acp-tunnel/src/lib.rs b/crates/acp-tunnel/src/lib.rs index c315fb4..52e89ba 100644 --- a/crates/acp-tunnel/src/lib.rs +++ b/crates/acp-tunnel/src/lib.rs @@ -24,6 +24,8 @@ use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; +pub mod config; + /// The ACP sub-protocol token the server echoes back on the `/acp` upgrade. pub const ACP_SUBPROTOCOL: &str = "acp.v1"; diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 1ae1d7f..ea4ea35 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -26,7 +26,16 @@ tauri-plugin-log = "2" # studio-cp / aws-config. Shell plugin bundles + spawns the sidecar; tokio sync # primitives multiplex JSON-RPC requests over its stdio. tauri-plugin-shell = "2" -tokio = { version = "1", features = ["sync", "rt", "macros"] } +tokio = { version = "1", features = ["sync", "rt", "macros", "net", "time"] } + +# Reverse-MCP-over-ACP (reverse-MCP client ADR, Part B): the pure protocol + +# session state machine + remote-connection config live in `acp-tunnel`; the WS +# transport here dials `/acp` and relays tunnelled MCP to the sidecar `McpClient`. +acp-tunnel = { path = "../crates/acp-tunnel" } +tokio-tungstenite = { version = "0.23", features = ["rustls-tls-webpki-roots"] } +futures-util = "0.3" +uuid = { version = "1", features = ["v4"] } +dirs = "5" # Remote upgrade (ADR: remote-upgrade): the installed app pulls a signed nightly # bundle from the GitHub release and replaces itself in place. Driven from Rust diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6d8c955..85ef090 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ mod mcp; +mod remote; use mcp::McpClient; use serde_json::{json, Value}; @@ -203,6 +204,74 @@ async fn deploy_scale( } } +// ---- Remote reverse-MCP connection (reverse-MCP client ADR, Part B) --------- + +/// The remote-connection view the panel renders: the config file path + raw text +/// (for the editor), the parsed URL, whether it is configured, and the live +/// connection status. Parse is best-effort so a malformed file still opens in the +/// editor (shown as not-configured) rather than blanking the panel. +async fn remote_view(remote: &remote::Remote) -> Result { + let text = remote::read_config_text()?; + let cfg = acp_tunnel::config::RemoteConfig::parse(&text).unwrap_or_default(); + let path = remote::config_path() + .map(|p| p.display().to_string()) + .unwrap_or_default(); + let status = remote.0.lock().await.status.clone(); + Ok(json!({ + "path": path, + "text": text, + "url": cfg.url, + "configured": cfg.is_configured(), + "status": if status.is_empty() { "disconnected".to_string() } else { status }, + })) +} + +/// The remote-connection config + status (the "declare" side of the remote panel). +#[tauri::command] +async fn remote_config(remote: tauri::State<'_, remote::Remote>) -> Result { + remote_view(&remote).await +} + +/// Persist the edited `remote.toml` (validates it parses before writing) and +/// return the refreshed view. +#[tauri::command] +async fn remote_config_write( + remote: tauri::State<'_, remote::Remote>, + text: String, +) -> Result { + remote::write_config_text(&text)?; + remote_view(&remote).await +} + +/// Activate the remote connection (the explicit "Activate" button): dial `/acp` +/// using the saved config and publish the `oab` tools to the attached agent. The +/// core sidecar must be started first (it is what the tunnel relays to). +#[tauri::command] +async fn remote_connect( + app: tauri::AppHandle, + core: tauri::State<'_, Core>, + remote: tauri::State<'_, remote::Remote>, +) -> Result<(), String> { + let client = { + let guard = core.0.lock().await; + guard + .as_ref() + .cloned() + .ok_or_else(|| "core not started yet — start the core before connecting".to_string())? + }; + remote::connect(app, &remote, client).await +} + +/// Deactivate the remote connection. +#[tauri::command] +async fn remote_disconnect( + app: tauri::AppHandle, + remote: tauri::State<'_, remote::Remote>, +) -> Result<(), String> { + remote::disconnect(&app, &remote).await; + Ok(()) +} + /// What the frontend needs to render the "update available" state: the version /// on the release vs. what's running, plus the release notes. #[derive(serde::Serialize)] @@ -265,6 +334,7 @@ pub fn run() { )?; } app.manage(Core::default()); + app.manage(remote::Remote::default()); Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -274,6 +344,10 @@ pub fn run() { fleet_config, fleet_config_write, deploy_scale, + remote_config, + remote_config_write, + remote_connect, + remote_disconnect, check_update, install_update ]) diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs index 6d8a77f..52dfeec 100644 --- a/src-tauri/src/mcp.rs +++ b/src-tauri/src/mcp.rs @@ -157,7 +157,10 @@ impl McpClient { } /// Send a request and await the correlated `result` (or a formatted error). - async fn request(&self, method: &str, params: Value) -> Result { + /// Public so the reverse-MCP tunnel can relay an inner `tools/list` / + /// `tools/call` to the sidecar and return its **raw** MCP result verbatim + /// (unlike `call_tool`, which decodes the payload for the desktop UI). + pub async fn request(&self, method: &str, params: Value) -> Result { let id = self.inner.next_id.fetch_add(1, Ordering::Relaxed); let (tx, rx) = oneshot::channel(); self.inner.pending.lock().await.insert(id, tx); diff --git a/src-tauri/src/remote.rs b/src-tauri/src/remote.rs new file mode 100644 index 0000000..b520c0e --- /dev/null +++ b/src-tauri/src/remote.rs @@ -0,0 +1,268 @@ +//! Remote reverse-MCP-over-ACP connection (reverse-MCP client ADR, Part B). +//! +//! Studio dials the `/acp` endpoint from [`RemoteConfig`], creates a session +//! declaring its `oab` server, and then **relays** the gateway's tunnelled MCP +//! (`initialize` / `tools/list` / `tools/call`) to the already-running `oab-mcp` +//! **sidecar** (`McpClient`) — no in-process aws link (ADR §4.3). Connection is +//! **operator-triggered** (the "Activate remote connection" button), not on boot. +//! +//! The exact ACP `initialize` / `session/new` params and the end-to-end behaviour +//! are validated against the live gateway once the §5 endpoint exists; this +//! module is structurally complete and compiles under `desktop.yml`. + +use std::path::PathBuf; + +use acp_tunnel as acp; +use acp_tunnel::config::RemoteConfig; +use acp_tunnel::{Inbound, Session}; +use futures_util::{Sink, SinkExt, StreamExt}; +use serde_json::{json, Value}; +use tauri::{AppHandle, Emitter, Runtime}; +use tokio::sync::Mutex as AsyncMutex; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::http::HeaderValue; +use tokio_tungstenite::tungstenite::Message as WsMessage; + +use crate::mcp::McpClient; + +/// Managed state: the running connection task (abort to disconnect) plus the last +/// status string the UI renders. +#[derive(Default)] +pub struct Remote(pub AsyncMutex); + +#[derive(Default)] +pub struct RemoteState { + task: Option>, + /// `"disconnected"` | `"connecting"` | `"connected"` | `"error: …"`. + pub status: String, +} + +/// `~/.config/oab-studio/remote.toml` — beside `fleets.toml`. +pub fn config_path() -> Result { + dirs::config_dir() + .map(|d| d.join("oab-studio").join("remote.toml")) + .ok_or_else(|| "no config directory resolved".to_string()) +} + +/// Raw file text for the editor; a missing file is empty ("not configured"). +pub fn read_config_text() -> Result { + let p = config_path()?; + match std::fs::read_to_string(&p) { + Ok(s) => Ok(s), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(String::new()), + Err(e) => Err(format!("read {}: {e}", p.display())), + } +} + +/// Persist edited text, **validating it parses first** (a bad edit never lands), +/// mirroring the fleets.toml editor. +pub fn write_config_text(text: &str) -> Result<(), String> { + RemoteConfig::parse(text).map_err(|e| format!("invalid TOML: {e}"))?; + let p = config_path()?; + if let Some(dir) = p.parent() { + std::fs::create_dir_all(dir).map_err(|e| format!("create {}: {e}", dir.display()))?; + } + std::fs::write(&p, text).map_err(|e| format!("write {}: {e}", p.display())) +} + +fn load_config() -> Result { + RemoteConfig::parse(&read_config_text()?).map_err(|e| format!("invalid remote.toml: {e}")) +} + +fn emit_status(app: &AppHandle, status: &str) { + let _ = app.emit("remote-status", json!({ "status": status })); +} + +/// Activate the remote connection: validate config, spawn the connection task. +/// Idempotent — a no-op if already connected. +pub async fn connect( + app: AppHandle, + remote: &Remote, + client: McpClient, +) -> Result<(), String> { + let cfg = load_config()?; + cfg.validate()?; + + let mut guard = remote.0.lock().await; + if guard.task.is_some() { + return Ok(()); + } + let app_task = app.clone(); + let task = tauri::async_runtime::spawn(async move { + run_reconnecting(app_task, cfg, client).await; + }); + guard.task = Some(task); + guard.status = "connecting".to_string(); + emit_status(&app, "connecting"); + Ok(()) +} + +/// Deactivate: abort the connection task. +pub async fn disconnect(app: &AppHandle, remote: &Remote) { + let mut guard = remote.0.lock().await; + if let Some(t) = guard.task.take() { + t.abort(); + } + guard.status = "disconnected".to_string(); + emit_status(app, "disconnected"); +} + +/// Reconnect loop: one attempt, then back off and retry until the task is +/// aborted (by [`disconnect`]). +async fn run_reconnecting(app: AppHandle, cfg: RemoteConfig, client: McpClient) { + loop { + if let Err(e) = run_once(&app, &cfg, &client).await { + emit_status(&app, &format!("error: {e}")); + let _ = app.emit( + "app-log", + json!({ "level": "error", "msg": format!("remote: {e}") }), + ); + } + emit_status(&app, "connecting"); + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + } +} + +/// One connection: dial, run the `initialize` → `session/new` handshake, then +/// serve the gateway-initiated tunnel until the socket closes. +async fn run_once( + app: &AppHandle, + cfg: &RemoteConfig, + client: &McpClient, +) -> Result<(), String> { + let mut req = cfg + .url + .as_str() + .into_client_request() + .map_err(|e| format!("bad url: {e}"))?; + // Bearer rides the WS sub-protocol offer (contract §1). Never logged. + let proto = HeaderValue::from_str(&acp::bearer_subprotocol(&cfg.token)) + .map_err(|_| "invalid token in sub-protocol header".to_string())?; + req.headers_mut().insert("Sec-WebSocket-Protocol", proto); + + let (ws, _resp) = tokio_tungstenite::connect_async(req) + .await + .map_err(|e| format!("dial {}: {e}", cfg.url))?; + let (mut write, mut read) = ws.split(); + + // Per-connection server id (contract §6.1); the stable name is "oab" (D1). + let conn_id = uuid::Uuid::new_v4().to_string(); + let mut session = Session::new(vec![acp::oab_server(&conn_id)]); + + // Drive the client handshake: initialize, then session/new declaring "oab". + let (_id, init) = session.initialize(json!({ + "protocolVersion": "2024-11-05", + "clientCapabilities": {}, + "clientInfo": { "name": "oab-studio", "version": env!("CARGO_PKG_VERSION") } + })); + send(&mut write, &init).await?; + + // Read loop. Handshake responses (to our initialize / session/new) advance + // the session; gateway-initiated method frames are the tunnel. + while let Some(msg) = read.next().await { + let msg = msg.map_err(|e| format!("ws read: {e}"))?; + let text = match msg { + WsMessage::Text(t) => t, + WsMessage::Binary(b) => String::from_utf8_lossy(&b).into_owned(), + WsMessage::Close(_) => return Ok(()), + WsMessage::Ping(_) | WsMessage::Pong(_) | WsMessage::Frame(_) => continue, + }; + if text.len() > acp::limits::MAX_FRAME_BYTES { + return Err("inbound frame exceeds 8 MiB".to_string()); + } + let frame: Value = match serde_json::from_str(&text) { + Ok(v) => v, + Err(_) => continue, // ignore non-JSON keepalives + }; + + // A response (no `method`) is the ack to one of our handshake requests. + if frame.get("method").is_none() { + if session.phase() == acp::Phase::Initializing { + session.on_initialized(); + let (_id, new) = session.open_session(&cfg.cwd); + send(&mut write, &new).await?; + } else if session.phase() == acp::Phase::Initialized { + let sid = frame + .get("result") + .and_then(|r| r.get("sessionId")) + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + session.on_session_created(sid); + emit_status(app, "connected"); + let _ = app.emit( + "app-log", + json!({ "level": "info", "msg": "remote: session active — oab tools published" }), + ); + } + continue; + } + + // Otherwise it is a gateway-initiated tunnel frame. + match acp::parse_inbound(&frame) { + Inbound::Connect { id, .. } => { + send(&mut write, &acp::connect_reply(id, &conn_id)).await?; + } + Inbound::Message { + id, + method, + params, + .. + } => { + if let Some(reply) = handle_inner(client, id, &method, params).await { + send(&mut write, &reply).await?; + } + } + Inbound::Disconnect { id, .. } => { + send(&mut write, &acp::disconnect_reply(id)).await?; + } + // Cancellation is best-effort; relays are short and not tracked here. + Inbound::Cancel { .. } | Inbound::Other => {} + } + } + Ok(()) +} + +/// Answer one inner MCP method. `initialize` is answered locally (the sidecar is +/// already initialized); `tools/list` / `tools/call` are relayed to the sidecar +/// and returned **verbatim**. Returns `None` for a notification (no reply owed). +async fn handle_inner( + client: &McpClient, + id: Option, + method: &str, + params: Value, +) -> Option { + match method { + "notifications/initialized" => None, + "initialize" => id.map(|i| { + acp::message_reply( + i, + json!({ + "protocolVersion": "2024-11-05", + "capabilities": { "tools": {} }, + "serverInfo": { "name": "oab-studio", "version": env!("CARGO_PKG_VERSION") } + }), + ) + }), + "tools/list" | "tools/call" => { + let id = id?; + Some(match client.request(method, params).await { + Ok(result) => acp::message_reply(id, result), + Err(e) => acp::message_error(id, -32000, &e), + }) + } + _ => id.map(|i| acp::message_error(i, -32601, &format!("method not found: {method}"))), + } +} + +async fn send(write: &mut S, frame: &Value) -> Result<(), String> +where + S: Sink + Unpin, + S::Error: std::fmt::Display, +{ + let text = serde_json::to_string(frame).map_err(|e| format!("encode: {e}"))?; + write + .send(WsMessage::Text(text)) + .await + .map_err(|e| format!("ws write: {e}")) +} From 44862c9ba881bea6433afd73b2f4525d0d1fb266 Mon Sep 17 00:00:00 2001 From: brettchien Date: Fri, 14 Aug 2026 14:19:49 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(console):=20remote=20connection=20pane?= =?UTF-8?q?l=20=E2=80=94=20Activate=20button=20+=20remote.toml=20editor=20?= =?UTF-8?q?(Part=20B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UI for the operator-triggered reverse-MCP connection: - A `remote` panel: live status badge (disconnected/connecting/connected/error), the /acp endpoint, and an explicit **Activate remote connection** / Disconnect button (Activate disabled until url+token are configured). - Editing: the existing CodeMirror TOML editor is generalized to two targets (fleets.toml + remote.toml); "Edit config" on the panel opens remote.toml. - Live status: subscribes to the backend `remote-status` event so the badge reflects the real transport state without polling. - `RemoteConfig` view type + Source methods (remoteConfig / writeRemoteConfig / remoteConnect / remoteDisconnect), with MockSource for the browser build. Verification: tsc clean; vitest 41/41 (7 new remote-panel cases: configured vs not, connected→Disconnect, error, edit action, escaping); vite build clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- console/index.html | 3 +- console/src/fixtures.ts | 22 ++++++- console/src/main.ts | 121 ++++++++++++++++++++++++++++++++----- console/src/render.test.ts | 59 ++++++++++++++++++ console/src/render.ts | 54 +++++++++++++++++ console/src/source.ts | 35 ++++++++++- console/src/styles.css | 77 +++++++++++++++++++++++ console/src/types.ts | 12 ++++ 8 files changed, 365 insertions(+), 18 deletions(-) diff --git a/console/index.html b/console/index.html index b2f33a9..f762355 100644 --- a/console/index.html +++ b/console/index.html @@ -21,7 +21,7 @@
+