From e552012ae477229a241e0faff8c51afb50be7bc5 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Sat, 19 Sep 2026 01:28:39 -0500 Subject: [PATCH 01/37] Add native Claude Code delegation Let Maple tasks delegate to an installed Claude Code CLI through a Rust transport adapted from Goose. Share Codex's activity, approval, question, and cancellation controls while keeping process ownership in Maple. Cover streaming, resumption, provider isolation, permissions, failures, and process cleanup with native CLI fixtures. --- .../app/assets/icons/claude-mark.svg | 1 + apps/maple-agent/app/src/assets.rs | 2 + .../maple-agent/app/src/ui/chat/transcript.rs | 1 + apps/maple-agent/app/src/ui/settings.rs | 25 +- .../resources/skills/advisor/SKILL.md | 2 +- .../resources/skills/committee/SKILL.md | 2 +- .../resources/skills/handoff/SKILL.md | 6 +- .../maple-agent/src/agent/developer_tools.rs | 4 +- .../src/agent/external_agents/app_server.rs | 76 +- .../src/agent/external_agents/claude.rs | 647 ++++++++++++++++++ .../src/agent/external_agents/codex.rs | 2 +- .../src/agent/external_agents/mod.rs | 265 +++++-- .../src/agent/external_agents/tests.rs | 452 ++++++++++-- .../external_agents/tests/claude_fixture.rs | 235 +++++++ .../maple-agent/src/agent/integrations.rs | 164 ++++- 15 files changed, 1744 insertions(+), 140 deletions(-) create mode 100644 apps/maple-agent/app/assets/icons/claude-mark.svg create mode 100644 apps/maple-agent/crates/maple-agent/src/agent/external_agents/claude.rs create mode 100644 apps/maple-agent/crates/maple-agent/src/agent/external_agents/tests/claude_fixture.rs diff --git a/apps/maple-agent/app/assets/icons/claude-mark.svg b/apps/maple-agent/app/assets/icons/claude-mark.svg new file mode 100644 index 000000000..1beee8612 --- /dev/null +++ b/apps/maple-agent/app/assets/icons/claude-mark.svg @@ -0,0 +1 @@ +Claude \ No newline at end of file diff --git a/apps/maple-agent/app/src/assets.rs b/apps/maple-agent/app/src/assets.rs index c68107426..996d4507c 100644 --- a/apps/maple-agent/app/src/assets.rs +++ b/apps/maple-agent/app/src/assets.rs @@ -20,6 +20,8 @@ assets!( "icons/check.svg", "icons/chevron-down.svg", "icons/chevron-right.svg", + // Claude mark (simple-icons, CC0) for the Claude Code integration card. + "icons/claude-mark.svg", "icons/copy.svg", // Contrast-safe partner marks published at https://cua.ai/branding. "icons/cua-mark-black.svg", diff --git a/apps/maple-agent/app/src/ui/chat/transcript.rs b/apps/maple-agent/app/src/ui/chat/transcript.rs index f81b719be..2f9c54218 100644 --- a/apps/maple-agent/app/src/ui/chat/transcript.rs +++ b/apps/maple-agent/app/src/ui/chat/transcript.rs @@ -1501,6 +1501,7 @@ pub(super) fn render_waiting_indicator() -> gpui::Stateful
{ /// external agent whose request Maple relays. pub(super) fn permission_card_heading(tool_name: &str) -> &'static str { match tool_name { + "claude_tool" => "Claude Code wants to use a tool", "codex_command" => "Codex wants to run a command", "codex_file_change" => "Codex wants to change files", _ => "Permission required", diff --git a/apps/maple-agent/app/src/ui/settings.rs b/apps/maple-agent/app/src/ui/settings.rs index cd712a7dc..5b59f7e86 100644 --- a/apps/maple-agent/app/src/ui/settings.rs +++ b/apps/maple-agent/app/src/ui/settings.rs @@ -2559,9 +2559,13 @@ impl SettingsScreen { .size(px(28.)) .text_color(gpui::rgb(theme::text_primary())) .into_any_element() - } else if is_codex(&integration.id) { + } else if matches!(integration.id.as_str(), "codex" | "claude") { gpui::svg() - .path("icons/openai-mark.svg") + .path(if integration.id == "claude" { + "icons/claude-mark.svg" + } else { + "icons/openai-mark.svg" + }) .size(px(26.)) .text_color(gpui::rgb(theme::text_primary())) .into_any_element() @@ -2787,9 +2791,9 @@ fn plan_card(plan: &crate::billing::PlanUsage) -> Div { } fn integration_is_visible(integration: &AgentIntegration) -> bool { - // Codex is worth a row even before it is installed: the card says how + // External agents get a row even before installation: the card says how // to get it, where a hidden row would leave the feature undiscoverable. - if is_codex(&integration.id) { + if integration.is_external_agent() { return true; } if is_cua_driver(&integration.id) @@ -2944,10 +2948,6 @@ fn is_cua_driver(id: &str) -> bool { id == "cua-driver" } -fn is_codex(id: &str) -> bool { - id == "codex" -} - /// Small on/off pill used in list rows. fn pill_button( id: String, @@ -3430,6 +3430,15 @@ mod tests { codex_ready.id = "codex".to_string(); assert!(integration_can_enable(&codex_ready)); assert!(!integration_can_setup(&codex_ready)); + let mut claude_missing = integration(AgentIntegrationAvailability::NotDetected, false); + claude_missing.id = "claude".to_string(); + assert!(integration_is_visible(&claude_missing)); + assert!(!integration_can_enable(&claude_missing)); + let mut claude_needs_setup = + integration(AgentIntegrationAvailability::SetupRequired, false); + claude_needs_setup.id = "claude".to_string(); + assert!(integration_is_visible(&claude_needs_setup)); + assert!(!integration_can_enable(&claude_needs_setup)); let mut external = integration(AgentIntegrationAvailability::Available, true); external.backend = Some(AgentIntegrationBackend::External); diff --git a/apps/maple-agent/crates/maple-agent/resources/skills/advisor/SKILL.md b/apps/maple-agent/crates/maple-agent/resources/skills/advisor/SKILL.md index 55fd98d39..899892e52 100644 --- a/apps/maple-agent/crates/maple-agent/resources/skills/advisor/SKILL.md +++ b/apps/maple-agent/crates/maple-agent/resources/skills/advisor/SKILL.md @@ -1,6 +1,6 @@ --- name: advisor -description: Ask an external agent (Codex) for read-only analysis or review of code, a plan, or a problem, without letting it change anything. Use when the user wants a review, an audit, or advice from another model. +description: Ask an external agent (Codex or Claude Code) for read-only analysis or review of code, a plan, or a problem, without letting it change anything. Use when the user wants a review, an audit, or advice from another model. metadata: maple: external-agents argument-hint: "" diff --git a/apps/maple-agent/crates/maple-agent/resources/skills/committee/SKILL.md b/apps/maple-agent/crates/maple-agent/resources/skills/committee/SKILL.md index 3680818cc..1122e0b16 100644 --- a/apps/maple-agent/crates/maple-agent/resources/skills/committee/SKILL.md +++ b/apps/maple-agent/crates/maple-agent/resources/skills/committee/SKILL.md @@ -1,6 +1,6 @@ --- name: committee -description: Get several independent opinions on a question or design by asking external agents (Codex) and comparing them with your own analysis. Use when the user wants a second opinion, a review from another model, or a comparison of approaches. +description: Get several independent opinions on a question or design by asking external agents (Codex or Claude Code) and comparing them with your own analysis. Use when the user wants a second opinion, a review from another model, or a comparison of approaches. metadata: maple: external-agents argument-hint: "" diff --git a/apps/maple-agent/crates/maple-agent/resources/skills/handoff/SKILL.md b/apps/maple-agent/crates/maple-agent/resources/skills/handoff/SKILL.md index b673d1a80..75821ef6a 100644 --- a/apps/maple-agent/crates/maple-agent/resources/skills/handoff/SKILL.md +++ b/apps/maple-agent/crates/maple-agent/resources/skills/handoff/SKILL.md @@ -1,6 +1,6 @@ --- name: handoff -description: Hand a self-contained piece of work to an external coding agent (Codex) that runs in this project with its own context. Use when the user asks to delegate, hand off, or have Codex implement something. +description: Hand a self-contained piece of work to an external coding agent (Codex or Claude Code) that runs in this project with its own context. Use when the user asks to delegate, hand off, or have Codex or Claude Code implement something. metadata: maple: external-agents argument-hint: "" @@ -8,7 +8,7 @@ metadata: # Hand work to an external agent -Maple can start an external coding agent (Codex today) inside this project. +Maple can start an external coding agent (Codex or Claude Code) inside this project. The agent runs with its own context and its own account. It does not see this conversation. It runs under its own sandbox and approval settings; whatever it asks approval for comes to the user through Maple. @@ -16,7 +16,7 @@ whatever it asks approval for comes to the user through Maple. ## Steps 1. Call `list_agent_providers` first. If no provider is usable, tell the - user what is missing (install, PATH, or `codex login`) and stop. + user what is missing (install, PATH, or the provider’s sign-in command) and stop. 2. Write a self-contained briefing. The agent has zero context, so the briefing must carry everything: - **Task**: what to do, in one or two sentences. diff --git a/apps/maple-agent/crates/maple-agent/src/agent/developer_tools.rs b/apps/maple-agent/crates/maple-agent/src/agent/developer_tools.rs index 8ace1e25d..b4a1303ec 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/developer_tools.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/developer_tools.rs @@ -250,7 +250,7 @@ impl MapleDeveloperClient { Tool::new( AGENT_START_TOOL.to_string(), format!( - "Hand a self-contained piece of work to an external coding agent (an installed harness such as Codex) that runs in the project with its own context and its own account. \ + "Hand a self-contained piece of work to an external coding agent (an installed harness such as Codex or Claude Code) that runs in the project with its own context and its own account. \ The new agent knows nothing about this conversation: write a complete briefing with the task, relevant files, current state, what was tried, decisions made, acceptance criteria, and constraints. \ It runs under its own sandbox and approval settings; whatever it asks approval for comes to the user through Maple, and in Allow all Maple grants it. \ Blocking by default: the call returns the agent's result. With background=true the call returns at once and Maple tells you when the agent finishes; do not poll. \ @@ -261,7 +261,7 @@ Call {LIST_AGENT_PROVIDERS_TOOL} first when unsure what is installed." "properties": { "provider": { "type": "string", - "description": "Which external agent to use, from list_agent_providers (for example \"codex\")" + "description": "Which external agent to use, from list_agent_providers (for example \"codex\" or \"claude\")" }, "prompt": { "type": "string", diff --git a/apps/maple-agent/crates/maple-agent/src/agent/external_agents/app_server.rs b/apps/maple-agent/crates/maple-agent/src/agent/external_agents/app_server.rs index 1888c4ad2..70ac547b5 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/external_agents/app_server.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/external_agents/app_server.rs @@ -1,4 +1,4 @@ -//! JSON-RPC 2.0 over newline-delimited stdio, as `codex app-server` speaks it. +//! Codex app-server transport and the shared external-agent client interface. //! //! One reader task owns the child's stdout. It resolves responses to the //! requests this client sent, and hands notifications and server-initiated @@ -21,6 +21,30 @@ const MAX_LINE_BYTES: usize = 4 * 1024 * 1024; /// message for long: approvals run on their own tasks. const SERVER_MESSAGE_CAPACITY: usize = 256; +/// Operations supported by Maple's external-agent host. +#[derive(Clone, Copy, Debug)] +pub(super) enum RequestMethod { + Initialize, + ThreadStart, + ThreadResume, + TurnStart, + TurnSteer, + TurnInterrupt, +} + +impl RequestMethod { + fn codex_method(self) -> &'static str { + match self { + Self::Initialize => "initialize", + Self::ThreadStart => "thread/start", + Self::ThreadResume => "thread/resume", + Self::TurnStart => "turn/start", + Self::TurnSteer => "turn/steer", + Self::TurnInterrupt => "turn/interrupt", + } + } +} + /// A message the server initiated. #[derive(Debug)] pub(super) enum ServerMessage { @@ -36,6 +60,56 @@ pub(super) enum ServerMessage { }, } +/// Both transports expose Maple's existing activity and permission messages. +/// Claude translates these in Rust; its child speaks Claude's native protocol. +pub(super) enum AgentClient { + Codex(Arc), + Claude(Arc), +} + +impl AgentClient { + pub(super) fn closed(&self) -> &CancellationToken { + match self { + Self::Codex(client) => client.closed(), + Self::Claude(client) => client.closed(), + } + } + + pub(super) async fn request( + &self, + method: RequestMethod, + params: Value, + ) -> Result { + match self { + Self::Codex(client) => client.request(method.codex_method(), params).await, + Self::Claude(client) => client.request(method, params).await, + } + } + + pub(super) async fn initialized(&self) -> Result<(), String> { + match self { + Self::Codex(client) => client.notify("initialized", json!({})).await, + Self::Claude(_) => Ok(()), + } + } + + pub(super) async fn respond(&self, id: Value, result: Value) -> Result<(), String> { + match self { + Self::Codex(client) => client.respond(id, result).await, + Self::Claude(client) => client.respond(id, result).await, + } + } + + pub(super) async fn respond_error(&self, id: Value, message: &str) { + match self { + Self::Codex(client) => client.respond_error(id, message).await, + Self::Claude(client) => { + let _ = client.respond(id, json!({"decision": "cancel"})).await; + } + } + } +} + type PendingResponses = Arc>>>>; pub(super) struct AppServerClient { diff --git a/apps/maple-agent/crates/maple-agent/src/agent/external_agents/claude.rs b/apps/maple-agent/crates/maple-agent/src/agent/external_agents/claude.rs new file mode 100644 index 000000000..cc7d7a1ef --- /dev/null +++ b/apps/maple-agent/crates/maple-agent/src/agent/external_agents/claude.rs @@ -0,0 +1,647 @@ +//! Claude Code's native stream-json transport, adapted from Goose's +//! `crates/goose/src/providers/claude_code.rs` at 785d655d110746147117d23690e09cc7023aa9dc. +//! Source: https://github.com/AnthonyRonning/goose. +//! +//! The control request/response types and permission exchange originate in +//! Goose's Rust SDK protocol implementation. Maple adapts process ownership, +//! bounded concurrent reads, question answers, and activity projection here; +//! it does not instantiate Goose's provider, whose subprocess is private. +//! Unlike Goose's Auto mode, we never set --dangerously-skip-permissions. + +use super::super::developer_tools::{ + executable_in_search_path, executable_on_path, spawn_contained, +}; +use super::app_server::{RequestMethod, ServerMessage}; +use super::codex; +use futures_util::StreamExt; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex as StdMutex}; +use std::time::Duration; +use tokio::io::AsyncWriteExt; +use tokio::process::{ChildStdin, ChildStdout}; +use tokio::sync::{Mutex, mpsc, oneshot}; +use tokio_util::codec::{FramedRead, LinesCodec}; +use tokio_util::sync::CancellationToken; + +pub(crate) const PROVIDER_ID: &str = "claude"; +pub(crate) const PROVIDER_NAME: &str = "Claude Code"; +const MAX_LINE_BYTES: usize = 4 * 1024 * 1024; +const AUTH_PROBE_TIMEOUT: Duration = Duration::from_secs(3); +const MAX_AUTH_BYTES: usize = 16 * 1024; +const FAILURE: &str = + "Claude Code could not complete this request. Check its sign-in and configuration."; + +#[derive(Debug, Clone, Default)] +pub(crate) struct ClaudeDetection { + pub(crate) executable: Option, + pub(crate) version: Option, + /// `None` when the installed CLI cannot report its authentication state. + pub(crate) signed_in: Option, + pub(crate) problem: Option, +} + +pub(super) fn find_executable(search_path: Option<&str>) -> Option { + match search_path { + Some(path) => executable_in_search_path("claude", path), + None => executable_on_path("claude"), + } +} + +pub(crate) async fn detect(search_path: Option<&str>) -> ClaudeDetection { + let Some(executable) = find_executable(search_path) else { + return ClaudeDetection::default(); + }; + let mut detection = ClaudeDetection { + executable: Some(executable.clone()), + ..Default::default() + }; + match codex::probe_version(&executable).await { + Ok(version) => { + detection.version = Some(version); + detection.signed_in = probe_auth_status(&executable, search_path).await; + } + Err(_) => { + detection.problem = Some( + "Maple could not run `claude --version`. Check the Claude Code installation." + .into(), + ) + } + } + detection +} + +pub(crate) fn sign_in_hint() -> &'static str { + "Claude Code is not signed in. Run `claude auth login` in a terminal, then try again." +} + +async fn probe_auth_status(executable: &Path, search_path: Option<&str>) -> Option { + let mut command = tokio::process::Command::new(executable); + command + .args(["auth", "status", "--json"]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true); + if let Some(path) = search_path { + command.env("PATH", path); + } + let mut child = spawn_contained(command).ok()?; + let stdout = child.as_mut().stdout().take()?; + let result = tokio::time::timeout(AUTH_PROBE_TIMEOUT, async { + tokio::join!( + child.as_mut().wait(), + super::super::bounded_process::read_bounded_stdout( + stdout, + MAX_AUTH_BYTES, + "Claude authentication status", + ) + ) + }) + .await; + child.kill_and_wait().await; + let (status, output) = result.ok()?; + // Read only the boolean; never retain or log account details from the CLI. + #[derive(Deserialize)] + struct AuthStatus { + #[serde(rename = "loggedIn")] + logged_in: bool, + } + let auth: AuthStatus = serde_json::from_slice(&output.ok()?).ok()?; + match (status.ok()?.code(), auth.logged_in) { + (Some(0), true) => Some(true), + (Some(1), false) => Some(false), + _ => None, + } +} + +pub(super) fn new_session_id() -> String { + // Claude requires a UUID for --session-id. Generate RFC 4122 version 4 + // using the runtime's existing random source. + let value = (rand::random::() & !(0xf_u128 << 76 | 0x3_u128 << 62)) + | 0x4_u128 << 76 + | 0x2_u128 << 62; + let hex = format!("{value:032x}"); + format!( + "{}-{}-{}-{}-{}", + &hex[..8], + &hex[8..12], + &hex[12..16], + &hex[16..20], + &hex[20..] + ) +} + +pub(super) fn command_args( + session: &str, + resume: bool, + model: Option<&str>, + effort: Option<&str>, +) -> Vec { + let mut args: Vec = [ + "--input-format", + "stream-json", + "--output-format", + "stream-json", + "--verbose", + "--include-partial-messages", + "--permission-prompt-tool", + "stdio", + "--permission-mode", + "default", + if resume { "--resume" } else { "--session-id" }, + session, + ] + .into_iter() + .map(str::to_string) + .collect(); + if let Some(model) = model { + args.extend(["--model".into(), model.into()]); + } + if let Some(effort) = effort { + args.extend(["--effort".into(), effort.into()]); + } + args +} + +// Adapted from Goose's control protocol types for Claude's SDK wire format. +#[derive(Serialize)] +struct ControlResponse { + #[serde(rename = "type")] + msg_type: &'static str, + response: ControlResponseBody, +} + +#[derive(Serialize)] +struct ControlResponseBody { + subtype: &'static str, + request_id: String, + response: T, +} + +#[derive(Serialize)] +#[serde(tag = "behavior")] +enum PermissionResponse { + #[serde(rename = "allow")] + Allow { + #[serde(rename = "updatedInput")] + updated_input: serde_json::Map, + #[serde(rename = "toolUseID")] + tool_use_id: String, + }, + #[serde(rename = "deny")] + Deny { message: String }, +} + +#[derive(Serialize)] +struct ControlRequest { + #[serde(rename = "type")] + msg_type: &'static str, + request_id: String, + request: ControlRequestBody, +} + +#[derive(Serialize)] +#[serde(tag = "subtype", rename_all = "snake_case")] +enum ControlRequestBody { + Initialize, + Interrupt, +} + +#[derive(Deserialize)] +struct IncomingControlRequest { + request_id: String, + request: IncomingRequestBody, +} + +#[derive(Deserialize)] +#[serde(tag = "subtype")] +enum IncomingRequestBody { + #[serde(rename = "can_use_tool")] + CanUseTool { + tool_name: String, + #[serde(default)] + input: serde_json::Map, + #[serde(default)] + tool_use_id: String, + }, +} + +impl ControlResponse { + fn success(request_id: String, response: T) -> Self { + Self { + msg_type: "control_response", + response: ControlResponseBody { + subtype: "success", + request_id, + response, + }, + } + } +} + +type Pending = StdMutex>>>; + +pub(super) struct Client { + writer: Mutex>, + pending: Pending, + permissions: StdMutex>, + next_id: AtomicU64, + session: String, + interrupted: AtomicBool, + closed: CancellationToken, + sender: mpsc::Sender, +} + +impl Client { + pub(super) fn new( + stdin: ChildStdin, + stdout: ChildStdout, + session: String, + ) -> ( + Arc, + mpsc::Receiver, + tokio::task::JoinHandle<()>, + ) { + let (sender, receiver) = mpsc::channel(256); + let client = Arc::new(Self { + writer: Mutex::new(Some(stdin)), + pending: StdMutex::new(HashMap::new()), + permissions: StdMutex::new(HashMap::new()), + next_id: AtomicU64::new(1), + session, + interrupted: AtomicBool::new(false), + closed: CancellationToken::new(), + sender, + }); + let reader_client = Arc::clone(&client); + let reader = tokio::spawn(async move { + reader_client.read(stdout).await; + }); + (client, receiver, reader) + } + + pub(super) fn closed(&self) -> &CancellationToken { + &self.closed + } + + async fn write(&self, message: impl Serialize) -> Result<(), String> { + let mut bytes = serde_json::to_vec(&message).map_err(|_| FAILURE.to_string())?; + bytes.push(b'\n'); + let mut writer = self.writer.lock().await; + let writer = writer.as_mut().ok_or(FAILURE)?; + writer + .write_all(&bytes) + .await + .map_err(|_| FAILURE.to_string())?; + writer.flush().await.map_err(|_| FAILURE.to_string()) + } + + async fn control(&self, request: ControlRequestBody) -> Result { + let request_id = format!("req_{}", self.next_id.fetch_add(1, Ordering::Relaxed)); + let (tx, rx) = oneshot::channel(); + self.pending.lock().unwrap().insert(request_id.clone(), tx); + let result = async { + self.write(ControlRequest { + msg_type: "control_request", + request_id: request_id.clone(), + request, + }) + .await?; + tokio::select! { + result = rx => result.unwrap_or_else(|_| Err(FAILURE.into())), + _ = self.closed.cancelled() => Err(FAILURE.into()), + } + } + .await; + self.pending.lock().unwrap().remove(&request_id); + result + } + + pub(super) async fn request( + &self, + method: RequestMethod, + params: Value, + ) -> Result { + if self.closed.is_cancelled() { + return Err(FAILURE.into()); + } + match method { + RequestMethod::Initialize => self.control(ControlRequestBody::Initialize).await, + RequestMethod::ThreadStart | RequestMethod::ThreadResume => { + Ok(json!({"thread": {"id": self.session}})) + } + RequestMethod::TurnStart => { + self.notify("turn/started", json!({"turn": {"id": new_session_id()}})) + .await; + self.write(json!({ + "type": "user", "session_id": self.session, + "message": {"role": "user", "content": [{"type": "text", "text": params["input"][0]["text"]}]}, + })).await?; + Ok(json!({})) + } + RequestMethod::TurnInterrupt => { + self.interrupted.store(true, Ordering::Relaxed); + self.control(ControlRequestBody::Interrupt).await + } + RequestMethod::TurnSteer => { + Err("Claude does not support steering an active turn".into()) + } + } + } + + pub(super) async fn respond(&self, id: Value, result: Value) -> Result<(), String> { + let id = id.as_str().ok_or(FAILURE)?; + let request = self.permissions.lock().unwrap().remove(id).ok_or(FAILURE)?; + let IncomingRequestBody::CanUseTool { + tool_name, + mut input, + tool_use_id, + } = request; + let allow = if tool_name == "AskUserQuestion" { + let mut answers = serde_json::Map::new(); + for (i, question) in input + .get("questions") + .and_then(Value::as_array) + .into_iter() + .flatten() + .enumerate() + { + let Some(text) = question["question"].as_str() else { + continue; + }; + let answer = result["answers"][format!("q{i}")]["answers"] + .as_array() + .map(|values| { + values + .iter() + .filter_map(Value::as_str) + .collect::>() + .join(", ") + }) + .unwrap_or_default(); + answers.insert(text.into(), answer.into()); + } + let answered = answers + .values() + .any(|answer| answer.as_str().is_some_and(|text| !text.is_empty())); + input.insert("answers".into(), answers.into()); + answered + } else { + result["decision"] == "accept" + }; + let response = + if allow && !self.interrupted.load(Ordering::Relaxed) && !self.closed.is_cancelled() { + PermissionResponse::Allow { + updated_input: input, + tool_use_id, + } + } else { + PermissionResponse::Deny { + message: "Maple declined this action".into(), + } + }; + self.write(ControlResponse::success(id.into(), response)) + .await + } + + async fn notify(&self, method: &str, params: Value) { + let _ = self + .sender + .send(ServerMessage::Notification { + method: method.into(), + params, + }) + .await; + } + + async fn permission(&self, message: Value) -> Result<(), String> { + let request: IncomingControlRequest = + serde_json::from_value(message).map_err(|_| FAILURE)?; + let IncomingRequestBody::CanUseTool { + ref tool_name, + ref input, + ref tool_use_id, + } = request.request; + let (method, params) = if tool_name == "AskUserQuestion" { + let questions: Vec<_> = input + .get("questions") + .and_then(Value::as_array) + .into_iter() + .flatten() + .enumerate() + .filter(|(_, question)| question.is_object()) + .map(|(i, question)| { + let mut question = question.clone(); + question["id"] = format!("q{i}").into(); + question + }) + .collect(); + ( + "item/tool/requestUserInput", + json!({"questions": questions}), + ) + } else { + ( + "claude/tool/requestApproval", + json!({"tool": tool_name, "input": input, "itemId": tool_use_id}), + ) + }; + let id = request.request_id; + { + let mut pending = self.permissions.lock().unwrap(); + if pending.len() >= 64 || pending.contains_key(&id) { + return Err(FAILURE.into()); + } + pending.insert(id.clone(), request.request); + } + self.sender + .send(ServerMessage::Request { + id: id.into(), + method: method.into(), + params, + }) + .await + .map_err(|_| FAILURE.into()) + } + + async fn read(self: Arc, stdout: ChildStdout) { + // Even task abortion closes requests. No pending permission can carry + // into a replacement process or a subsequent turn. + struct Close(Arc); + impl Drop for Close { + fn drop(&mut self) { + self.0.closed.cancel(); + self.0.pending.lock().unwrap().clear(); + self.0.permissions.lock().unwrap().clear(); + } + } + let _close = Close(Arc::clone(&self)); + let mut lines = FramedRead::new(stdout, LinesCodec::new_with_max_length(MAX_LINE_BYTES)); + let mut activity = Activity::default(); + let mut completed = false; + while let Some(line) = lines.next().await { + let Ok(line) = line else { + break; + }; + if line.trim().is_empty() { + continue; + } + let Ok(message) = serde_json::from_str::(&line) else { + break; + }; + match message["type"].as_str() { + Some("control_response") => { + let response = &message["response"]; + if let Some(id) = response["request_id"].as_str() + && let Some(tx) = self.pending.lock().unwrap().remove(id) + { + let result = if response["subtype"] == "success" { + Ok(response["response"].clone()) + } else { + Err(FAILURE.into()) + }; + let _ = tx.send(result); + } + } + Some("control_request") => { + if self.permission(message).await.is_err() { + break; + } + } + _ => { + for (method, mut params) in activity.events(&message) { + if method == "turn/completed" { + completed = true; + // EOF lets Claude flush its persisted session and + // exit normally before the next turn resumes it. + self.writer.lock().await.take(); + self.permissions.lock().unwrap().clear(); + } + if method == "turn/completed" && self.interrupted.load(Ordering::Relaxed) { + params = json!({"turn": {"status": "interrupted"}}); + } + self.notify(method, params).await; + } + } + } + } + // The client retains a sender, so premature EOF must explicitly finish + // the turn. EOF after a result must not emit a second completion. + if completed { + return; + } + let turn = if self.interrupted.load(Ordering::Relaxed) { + json!({"status": "interrupted"}) + } else { + json!({"status": "failed", "error": {"message": FAILURE}}) + }; + self.notify("turn/completed", json!({"turn": turn})).await; + } +} + +#[derive(Default)] +struct Activity { + message_id: String, + tools: HashMap, +} + +impl Activity { + fn events(&mut self, message: &Value) -> Vec<(&'static str, Value)> { + let mut events = Vec::new(); + // Nested Claude agents keep their own transcript. Only project the + // delegated agent's top-level messages into Maple's activity row. + if !message["parent_tool_use_id"].is_null() { + return events; + } + match message["type"].as_str() { + Some("stream_event") => { + let event = &message["event"]; + if event["type"] == "message_start" { + self.message_id = event["message"]["id"].as_str().unwrap_or("message").into(); + } else if event["type"] == "content_block_delta" + && event["delta"]["type"] == "text_delta" + { + events.push(( + "item/agentMessage/delta", + json!({"itemId": self.message_id, "delta": event["delta"]["text"]}), + )); + } + } + Some("assistant") => { + let message = &message["message"]; + if let Some(id) = message["id"].as_str() { + self.message_id = id.into(); + } + let blocks = message["content"].as_array().into_iter().flatten(); + let mut text = Vec::new(); + for block in blocks { + if block["type"] == "text" { + if let Some(value) = block["text"].as_str() { + text.push(value); + } + } else if block["type"] == "tool_use" { + let Some(id) = block["id"].as_str() else { + continue; + }; + let name = block["name"].as_str().unwrap_or_default(); + if matches!(name, "Bash" | "Edit" | "Write" | "NotebookEdit") + && self.tools.len() < 1024 + { + self.tools + .insert(id.into(), (name.into(), block["input"].clone())); + } + match name { + "Bash" => events.push(("item/started", json!({"item": {"id": id, "type": "commandExecution", "command": block["input"]["command"]}}))), + "TodoWrite" => events.push(("item/started", json!({"item": {"id": id, "type": "todoList", "items": block["input"]["todos"]}}))), + _ => {} + } + } + } + if !text.is_empty() { + events.push(("item/completed", json!({"item": {"id": self.message_id, "type": "agentMessage", "text": text.join("\n")}}))); + } + } + Some("user") => { + for block in message["message"]["content"] + .as_array() + .into_iter() + .flatten() + { + if block["type"] != "tool_result" { + continue; + } + let Some(id) = block["tool_use_id"].as_str() else { + continue; + }; + let Some((name, args)) = self.tools.remove(id) else { + continue; + }; + let failed = block["is_error"] == true; + if name == "Bash" { + events.push(("item/completed", json!({"item": {"id": id, "type": "commandExecution", "command": args["command"], "status": if failed { "failed" } else { "completed" }}}))); + } else if matches!(name.as_str(), "Edit" | "Write" | "NotebookEdit") + && !failed + && let Some(path) = args["file_path"] + .as_str() + .or(args["notebook_path"].as_str()) + { + events.push(("item/completed", json!({"item": {"id": id, "type": "fileChange", "changes": [{"path": path, "kind": "update"}]}}))); + } + } + } + Some("result") | Some("error") => { + let success = message["type"] == "result" + && message["subtype"] == "success" + && message["is_error"] != true; + events.push(("turn/completed", json!({"turn": {"status": if success { "completed" } else { "failed" }, "error": if success { Value::Null } else { json!({"message": FAILURE}) }}}))); + } + _ => {} + } + events + } +} diff --git a/apps/maple-agent/crates/maple-agent/src/agent/external_agents/codex.rs b/apps/maple-agent/crates/maple-agent/src/agent/external_agents/codex.rs index e9e347386..01c86ffbc 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/external_agents/codex.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/external_agents/codex.rs @@ -78,7 +78,7 @@ pub(crate) fn find_executable(search_path: Option<&str>) -> Option { } } -async fn probe_version(executable: &Path) -> Result { +pub(super) async fn probe_version(executable: &Path) -> Result { let mut command = tokio::process::Command::new(executable); command .arg("--version") diff --git a/apps/maple-agent/crates/maple-agent/src/agent/external_agents/mod.rs b/apps/maple-agent/crates/maple-agent/src/agent/external_agents/mod.rs index a416cf428..bd2a7aea7 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/external_agents/mod.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/external_agents/mod.rs @@ -8,10 +8,11 @@ //! and its progress streams into the transcript row of the tool call that //! started the turn. //! -//! Codex is the only provider today. The provider layer is separate so a -//! second harness can slot in without changing the tool contract. +//! Codex uses its app-server; Claude Code uses Goose’s native SDK protocol implementation. +//! Both feed the same activity, permission, and lifecycle host. mod app_server; +pub(crate) mod claude; pub(crate) mod codex; #[cfg(test)] mod tests; @@ -22,7 +23,7 @@ use super::developer_tools::{ use super::image_mediation::error_result; use super::tool_context::AgentToolContextSnapshot; use super::*; -use app_server::{AppServerClient, ServerMessage}; +use app_server::{AgentClient, AppServerClient, RequestMethod, ServerMessage}; use codex::{CodexEvent, CodexItem, CodexServerRequest}; use goose::conversation::message::SystemNotificationContent; use std::fmt::Write as _; @@ -396,7 +397,7 @@ impl ExternalAgentRegistry { } fn require_provider(provider: &str) -> Result<(), String> { - if provider.trim() == codex::PROVIDER_ID { + if matches!(provider.trim(), codex::PROVIDER_ID | claude::PROVIDER_ID) { Ok(()) } else { Err(format!( @@ -448,37 +449,60 @@ impl ExternalAgentRegistry { call: &ExternalAgentCall, providers: &[String], ) -> CallToolResult { - if !providers - .iter() - .any(|provider| provider == codex::PROVIDER_ID) - { + if providers.is_empty() { return text_result("No external agent providers are enabled for this task."); } - let detection = codex::detect(call.login_path.as_deref()).await; - let mut out = String::new(); - let _ = writeln!(out, "Agent providers available to this task:"); - match (&detection.executable, &detection.problem) { - (None, _) => { - let _ = writeln!( - out, - "- codex: not installed. Ask the user to install the Codex CLI and make sure `codex` is on PATH." - ); - } - (Some(_), Some(problem)) => { - let _ = writeln!(out, "- codex: unusable. {problem}"); - } - (Some(_), None) => { - let version = detection.version.as_deref().unwrap_or("unknown version"); + let mut out = String::from("Agent providers available to this task:\n"); + if providers + .iter() + .any(|provider| provider == claude::PROVIDER_ID) + { + let detection = claude::detect(call.login_path.as_deref()).await; + let detail = if detection.executable.is_none() { + "Install Claude Code and make sure `claude` is on PATH.".to_string() + } else if let Some(problem) = detection.problem { + problem + } else { let sign_in = match detection.signed_in { - Some(true) => "signed in".to_string(), - Some(false) => codex::sign_in_hint().to_string(), - None => "sign-in state unknown".to_string(), + Some(true) => "Signed in.", + Some(false) => claude::sign_in_hint(), + None => "Sign-in state unknown.", }; - let _ = writeln!( - out, - "- codex: {} {version}, {sign_in}. Runs `codex app-server` with the user's own Codex account and configuration; optional `model` and `effort` arguments override its defaults.", - codex::PROVIDER_NAME - ); + format!( + "{}; {sign_in} Supports model and effort overrides.", + detection.version.unwrap_or_default() + ) + }; + let _ = writeln!(out, "- claude: {detail}"); + } + if providers + .iter() + .any(|provider| provider == codex::PROVIDER_ID) + { + let detection = codex::detect(call.login_path.as_deref()).await; + match (&detection.executable, &detection.problem) { + (None, _) => { + let _ = writeln!( + out, + "- codex: not installed. Ask the user to install the Codex CLI and make sure `codex` is on PATH." + ); + } + (Some(_), Some(problem)) => { + let _ = writeln!(out, "- codex: unusable. {problem}"); + } + (Some(_), None) => { + let version = detection.version.as_deref().unwrap_or("unknown version"); + let sign_in = match detection.signed_in { + Some(true) => "signed in".to_string(), + Some(false) => codex::sign_in_hint().to_string(), + None => "sign-in state unknown".to_string(), + }; + let _ = writeln!( + out, + "- codex: {} {version}, {sign_in}. Runs `codex app-server` with the user's own Codex account and configuration; optional `model` and `effort` arguments override its defaults.", + codex::PROVIDER_NAME + ); + } } } let _ = writeln!( @@ -514,10 +538,11 @@ impl ExternalAgentRegistry { } let agent_id = format!( "{}-{}", - codex::PROVIDER_ID, + params.provider.trim(), self.next_agent.fetch_add(1, Ordering::Relaxed) ); let agent = Arc::new(ExternalAgent::new( + params.provider.trim().to_string(), agent_id.clone(), call.session_id.clone(), first_line_label(&prompt), @@ -556,6 +581,9 @@ impl ExternalAgentRegistry { let Some(agent) = self.agent(&call.session_id, ¶ms.agent_id).await else { return error_result(unknown_agent(¶ms.agent_id)); }; + if agent.provider != params.provider.trim() { + return error_result("This agent belongs to a different provider."); + } agent .run_turn( &call, @@ -580,6 +608,9 @@ impl ExternalAgentRegistry { let Some(agent) = self.agent(&call.session_id, ¶ms.agent_id).await else { return error_result(unknown_agent(¶ms.agent_id)); }; + if agent.provider != params.provider.trim() { + return error_result("This agent belongs to a different provider."); + } let activity = agent.activity().await; let guidance = if activity.status == "running" { background_guidance() @@ -599,6 +630,12 @@ impl ExternalAgentRegistry { if let Err(error) = Self::require_provider(¶ms.provider) { return error_result(error); } + let Some(agent) = self.agent(&call.session_id, ¶ms.agent_id).await else { + return error_result(unknown_agent(¶ms.agent_id)); + }; + if agent.provider != params.provider.trim() { + return error_result("This agent belongs to a different provider."); + } match self.cancel(&call.session_id, ¶ms.agent_id).await { Ok(activity) => { let mut result = @@ -731,7 +768,7 @@ struct StoredCall { struct AgentProcess { child: ArmedShellChild, - client: Arc, + client: Arc, reader: tokio::task::JoinHandle<()>, events: tokio::task::JoinHandle<()>, } @@ -755,6 +792,8 @@ struct AgentState { } struct ExternalAgent { + provider: String, + launch: Mutex<()>, agent_id: String, session_id: String, task: String, @@ -769,7 +808,15 @@ struct ExternalAgent { } impl ExternalAgent { + fn provider_name(&self) -> &str { + if self.provider == claude::PROVIDER_ID { + claude::PROVIDER_NAME + } else { + codex::PROVIDER_NAME + } + } fn new( + provider: String, agent_id: String, session_id: String, task: String, @@ -784,7 +831,7 @@ impl ExternalAgent { thread_id: None, turn: None, activity: ExternalAgentActivity { - provider: codex::PROVIDER_ID.to_string(), + provider: provider.clone(), agent_id: agent_id.clone(), status: "idle".to_string(), ..Default::default() @@ -795,6 +842,8 @@ impl ExternalAgent { last_row_emit: None, last_row_id: None, }), + provider, + launch: Mutex::new(()), agent_id, session_id, task, @@ -826,7 +875,7 @@ impl ExternalAgent { elapsed_ms: self.started.elapsed().as_millis().min(u64::MAX as u128) as u64, activity: latest_activity_label(&state.activity), external: Some(ExternalAgentRef { - provider: codex::PROVIDER_ID.to_string(), + provider: self.provider.clone(), agent_id: self.agent_id.clone(), }), }) @@ -839,10 +888,25 @@ impl ExternalAgent { call: &ExternalAgentCall, input: TurnInput, ) -> CallToolResult { + let launch = self.launch.lock().await; if self.cancel.is_cancelled() { return error_result("This external agent has been shut down."); } - if let Err(error) = self.ensure_process(call, input.model.as_deref()).await { + if self.state.lock().await.turn.is_some() { + return error_result(format!( + "Agent {} is still working on its previous turn. Wait for Maple's notice, or check with {AGENT_STATUS_TOOL}.", + self.agent_id + )); + } + let ready = tokio::select! { + biased; + _ = self.cancel.cancelled() => Err("This external agent has been shut down.".to_string()), + _ = call.cancel_token.cancelled() => Err("The external agent launch was cancelled.".to_string()), + _ = call.tool_context.revoked.cancelled() => Err("The external agent context was revoked.".to_string()), + result = tokio::time::timeout(Duration::from_secs(30), self.ensure_process(call, input.model.as_deref(), input.effort.as_deref())) => + result.unwrap_or_else(|_| Err("The external agent did not initialize in time.".to_string())), + }; + if let Err(error) = ready { return error_result(error); } let (client, thread_id, done_rx) = { @@ -900,12 +964,13 @@ impl ExternalAgent { model: input.model.as_deref(), effort: input.effort.as_deref(), }); - if let Err(error) = client.request("turn/start", params).await { + if let Err(error) = client.request(RequestMethod::TurnStart, params).await { self.finish_turn(TurnOutcome::Failed, Some(error.clone())) .await; return error_result(error); } + drop(launch); if input.background { let agent = Arc::clone(self); tokio::spawn(async move { @@ -961,6 +1026,7 @@ impl ExternalAgent { self: &Arc, call: &ExternalAgentCall, model: Option<&str>, + effort: Option<&str>, ) -> Result<(), String> { { let state = self.state.lock().await; @@ -970,17 +1036,40 @@ impl ExternalAgent { return Ok(()); } } - let executable = codex::find_executable(call.login_path.as_deref()).ok_or_else(|| { - "Codex is not installed, or `codex` is not on PATH. Ask the user to install the Codex CLI.".to_string() - })?; + let existing_thread = self.state.lock().await.thread_id.clone(); + let claude_thread = existing_thread + .clone() + .unwrap_or_else(claude::new_session_id); + let (executable, args) = if self.provider == claude::PROVIDER_ID { + let executable = claude::find_executable(call.login_path.as_deref()) + .ok_or("Install Claude Code and make sure `claude` is on PATH.")?; + ( + executable, + claude::command_args(&claude_thread, existing_thread.is_some(), model, effort), + ) + } else { + let executable = codex::find_executable(call.login_path.as_deref()).ok_or_else(|| { + "Codex is not installed, or `codex` is not on PATH. Ask the user to install the Codex CLI.".to_string() + })?; + ( + executable, + codex::app_server_args() + .iter() + .map(|arg| arg.to_string()) + .collect(), + ) + }; let mut command = build_external_agent_command( &executable, - &codex::app_server_args(), - &self.host.project_root, + &args.iter().map(String::as_str).collect::>(), + &self.cwd, call.login_path.as_deref(), Some(&self.session_id), &call.tool_context, )?; + if self.provider == claude::PROVIDER_ID { + command.env_remove("CLAUDECODE"); + } command .stdin(Stdio::piped()) .stdout(Stdio::piped()) @@ -988,41 +1077,54 @@ impl ExternalAgent { .kill_on_drop(true); let mut child = { let _launch = call.tool_context.begin_process_launch(&call.cancel_token)?; - spawn_contained(command).map_err(|error| format!("Failed to start Codex: {error}"))? + spawn_contained(command) + .map_err(|error| format!("Failed to start {}: {error}", self.provider_name()))? }; let stdin = child .as_mut() .stdin() .take() - .ok_or_else(|| "Failed to open Codex's stdin".to_string())?; + .ok_or_else(|| "Failed to open the agent stdin".to_string())?; let stdout = child .as_mut() .stdout() .take() - .ok_or_else(|| "Failed to open Codex's stdout".to_string())?; - let (client, receiver, reader) = AppServerClient::new(stdin, stdout); + .ok_or_else(|| "Failed to open the agent stdout".to_string())?; + let (client, receiver, reader) = if self.provider == claude::PROVIDER_ID { + let (client, receiver, reader) = claude::Client::new(stdin, stdout, claude_thread); + (Arc::new(AgentClient::Claude(client)), receiver, reader) + } else { + let (client, receiver, reader) = AppServerClient::new(stdin, stdout); + (Arc::new(AgentClient::Codex(client)), receiver, reader) + }; client - .request("initialize", codex::initialize_params()) + .request(RequestMethod::Initialize, codex::initialize_params()) .await?; - client.notify("initialized", json!({})).await?; + client.initialized().await?; let thread_id = { let existing = self.state.lock().await.thread_id.clone(); let response = match &existing { Some(thread_id) => { client - .request("thread/resume", codex::thread_resume_params(thread_id)) + .request( + RequestMethod::ThreadResume, + codex::thread_resume_params(thread_id), + ) .await? } None => { client - .request("thread/start", codex::thread_start_params(&self.cwd, model)) + .request( + RequestMethod::ThreadStart, + codex::thread_start_params(&self.cwd, model), + ) .await? } }; match existing { Some(thread_id) => thread_id, None => codex::thread_id_from_response(&response) - .ok_or_else(|| "Codex did not report a thread ID".to_string())?, + .ok_or_else(|| "The agent did not report a thread ID".to_string())?, } }; let events = tokio::spawn(Arc::clone(self).consume_server_messages(receiver)); @@ -1046,8 +1148,34 @@ impl ExternalAgent { while let Some(message) = receiver.recv().await { match message { ServerMessage::Notification { method, params } => { - self.handle_event(codex::parse_notification(&method, ¶ms)) - .await; + let event = codex::parse_notification(&method, ¶ms); + if self.provider == claude::PROVIDER_ID + && let CodexEvent::TurnCompleted { ref status, .. } = event + { + // A Claude process serves one turn. Reclaim it before + // reporting completion, including on protocol failure. + // On success stdin is closed: give session writes time + // to flush, then clean up any remaining descendants. + // Hold the lifecycle lock through cleanup so shutdown + // cannot return while this task still owns a child. + let mut state = self.state.lock().await; + if let Some(mut process) = state.process.take() { + if status == "completed" { + let _ = tokio::time::timeout( + Duration::from_secs(2), + process.child.as_mut().wait(), + ) + .await; + } + process.child.kill_and_wait().await; + process.reader.abort(); + // Do not abort process.events: it is this task. + } + drop(state); + self.handle_event(event).await; + return; + } + self.handle_event(event).await; } ServerMessage::Request { id, method, params } => { // An approval waits on the user. It must not stall the @@ -1225,6 +1353,21 @@ impl ExternalAgent { None => return, } }; + if self.provider == claude::PROVIDER_ID && method == "claude/tool/requestApproval" { + let tool = params["tool"].as_str().unwrap_or("tool"); + let arguments = params["input"].as_object().cloned().unwrap_or_default(); + let request = AgentPermissionRequest { + request_id: format!("{}-{}", self.agent_id, id.as_str().unwrap_or("request")), + tool_name: "claude_tool".into(), + arguments, + prompt: Some(format!("Claude Code wants to use {tool}")), + }; + let decision = self + .request_permission(request, format!("use {tool}")) + .await; + let _ = client.respond(id, codex::approval_response(decision)).await; + return; + } let response = match codex::parse_server_request(method, ¶ms) { CodexServerRequest::CommandApproval { item_id, @@ -1366,7 +1509,7 @@ impl ExternalAgent { if let Some((client, thread_id, turn_id)) = steer { match client .request( - "turn/steer", + RequestMethod::TurnSteer, codex::turn_steer_params(&thread_id, &turn_id, &prompt), ) .await @@ -1435,6 +1578,9 @@ impl ExternalAgent { request: AgentPermissionRequest, summary: String, ) -> AgentPermissionDecision { + if self.cancel.is_cancelled() || self.turn_ended().await.is_cancelled() { + return AgentPermissionDecision::Cancel; + } { let modes = self.host.permission_modes.lock().await; if modes @@ -1531,7 +1677,7 @@ impl ExternalAgent { return; }; let request = client.request( - "turn/interrupt", + RequestMethod::TurnInterrupt, codex::turn_interrupt_params(&thread_id, &turn_id), ); if let Ok(Err(error)) = tokio::time::timeout(INTERRUPT_REQUEST_TIMEOUT, request).await { @@ -1649,13 +1795,12 @@ impl ExternalAgent { let result_text = render_activity(&activity, &completion_guidance(&activity)); let row_id = self.last_row_id().await; let for_model = background_result_message( - &format!("external agent {} ({})", self.agent_id, codex::PROVIDER_ID), + &format!("external agent {} ({})", self.agent_id, self.provider), outcome.status(), &result_text, &format!( "Use {AGENT_STATUS_TOOL}(provider: \"{}\", agent_id: \"{}\") only if you need to inspect its current state again.", - codex::PROVIDER_ID, - self.agent_id + self.provider, self.agent_id ), ); let delivered = self @@ -1713,7 +1858,7 @@ impl ExternalAgent { format!( "External agent {} ({}) {}. {}", self.agent_id, - codex::PROVIDER_NAME, + self.provider_name(), match outcome { TurnOutcome::Completed => "finished", TurnOutcome::Failed => "failed", @@ -1779,7 +1924,7 @@ impl ExternalAgent { task: self.task.clone(), background, external: Some(ExternalAgentRef { - provider: codex::PROVIDER_ID.to_string(), + provider: self.provider.clone(), agent_id: self.agent_id.clone(), }), }, diff --git a/apps/maple-agent/crates/maple-agent/src/agent/external_agents/tests.rs b/apps/maple-agent/crates/maple-agent/src/agent/external_agents/tests.rs index d4f48c511..6ffb53385 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/external_agents/tests.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/external_agents/tests.rs @@ -1,7 +1,7 @@ -//! Driver tests against a fake `codex app-server`. +//! Driver tests against fake Codex and Claude Code CLIs. //! -//! The fixture is this test binary re-executed as an ignored test. A shell -//! shim named `codex` on a private PATH forwards to it, so the driver +//! Each fixture is this test binary re-executed as an ignored test. Shell +//! shims on a private PATH forward to them, so the driver //! resolves and spawns it exactly as it would the real CLI. Unix only until //! a `.cmd` shim exists for Windows. @@ -11,14 +11,16 @@ use super::*; use crate::agent::tool_context::default_tool_context_spec; use crate::agent::{AgentEventSink, AgentPathLayout, MapleAgentHostResources}; use std::io::{BufRead, Write}; +use std::os::fd::FromRawFd; use std::os::unix::fs::PermissionsExt; -const FIXTURE_TEST: &str = "agent::external_agents::tests::fake_codex_app_server"; -const FIXTURE_MARKER: &str = "MAPLE_FAKE_CODEX"; -const FIXTURE_ARGS: &str = "MAPLE_FAKE_CODEX_ARGS"; -const FIXTURE_MODE: &str = "MAPLE_FAKE_CODEX_MODE"; -const FIXTURE_PID_FILE: &str = "MAPLE_FAKE_CODEX_PID_FILE"; -const FIXTURE_LOG: &str = "MAPLE_FAKE_CODEX_LOG"; +mod claude_fixture; + +const FIXTURE_MARKER: &str = "MAPLE_FAKE_AGENT"; +const FIXTURE_ARGS: &str = "MAPLE_FAKE_AGENT_ARGS"; +const FIXTURE_MODE: &str = "MAPLE_FAKE_AGENT_MODE"; +const FIXTURE_PID_FILE: &str = "MAPLE_FAKE_AGENT_PID_FILE"; +const FIXTURE_LOG: &str = "MAPLE_FAKE_AGENT_LOG"; const WAIT: Duration = Duration::from_secs(20); #[derive(Default)] @@ -66,17 +68,8 @@ impl Harness { fs::create_dir_all(&history).unwrap(); let shim_dir = root.join("bin"); fs::create_dir_all(&shim_dir).unwrap(); - let pid_file = root.join("codex.pid"); - let log_file = root.join("codex.log"); - let shim = format!( - "#!/bin/sh\nexport {FIXTURE_MARKER}=1\nexport {FIXTURE_MODE}='{mode}'\nexport {FIXTURE_PID_FILE}='{}'\nexport {FIXTURE_LOG}='{}'\nexport {FIXTURE_ARGS}=\"$*\"\nexec '{}' '{FIXTURE_TEST}' --exact --ignored --nocapture --test-threads=1\n", - pid_file.display(), - log_file.display(), - std::env::current_exe().unwrap().display(), - ); - let shim_path = shim_dir.join("codex"); - fs::write(&shim_path, shim).unwrap(); - fs::set_permissions(&shim_path, fs::Permissions::from_mode(0o700)).unwrap(); + let pid_file = root.join("agent.pid"); + let log_file = root.join("agent.log"); let paths = AgentPathLayout::from_app_roots(root.join("config"), root.join("data")); let sink = Arc::new(RecordingSink::default()); @@ -100,7 +93,7 @@ impl Harness { lifetime: CancellationToken::new(), }; let registry = Arc::new(ExternalAgentRegistry::new(host.clone())); - Self { + let harness = Self { _temp: temp, project, shim_dir, @@ -110,7 +103,29 @@ impl Harness { registry, pid_file, log_file, - } + }; + harness.install_fixture("codex", mode); + harness.install_fixture("claude", mode); + harness + } + + fn install_fixture(&self, provider: &str, mode: &str) { + let test = match provider { + "codex" => "fake_codex_app_server", + "claude" => "claude_fixture::run", + _ => panic!("unknown fixture provider"), + }; + // Keep libtest's status output off both protocol pipes. Its output + // can race the fixture, so inserting a newline is not sufficient. + let shim = format!( + "#!/bin/sh\nexport {FIXTURE_MARKER}=1\nexport {FIXTURE_MODE}='{mode}'\nexport {FIXTURE_PID_FILE}='{}'\nexport {FIXTURE_LOG}='{}'\nexport {FIXTURE_ARGS}=\"$*\"\nexec '{}' 'agent::external_agents::tests::{test}' --exact --ignored --nocapture --test-threads=1 3>&1 1>/dev/null\n", + self.pid_file.display(), + self.log_file.display(), + std::env::current_exe().unwrap().display(), + ); + let path = self.shim_dir.join(provider); + fs::write(&path, shim).unwrap(); + fs::set_permissions(path, fs::Permissions::from_mode(0o700)).unwrap(); } fn call(&self, session_id: &str, row_id: &str) -> ExternalAgentCall { @@ -147,14 +162,18 @@ impl Harness { } } -async fn wait_for(mut probe: impl FnMut() -> Option) -> T { - let deadline = Instant::now() + WAIT; - loop { - if let Some(value) = probe() { - return value; +#[track_caller] +fn wait_for(mut probe: impl FnMut() -> Option) -> impl Future { + let caller = std::panic::Location::caller(); + async move { + let deadline = Instant::now() + WAIT; + loop { + if let Some(value) = probe() { + return value; + } + assert!(Instant::now() < deadline, "timed out waiting at {caller}"); + tokio::time::sleep(Duration::from_millis(25)).await; } - assert!(Instant::now() < deadline, "timed out waiting"); - tokio::time::sleep(Duration::from_millis(25)).await; } } @@ -171,6 +190,13 @@ fn process_alive(pid: i32) -> bool { unsafe { libc::kill(pid, 0) == 0 } } +fn fixture_output() -> fs::File { + // SAFETY: install_fixture's shim duplicates the protocol pipe to fd 3 + // before redirecting libtest stdout. Each fixture calls this once and + // this File is the sole owner of that descriptor in the child process. + unsafe { fs::File::from_raw_fd(3) } +} + /// The fake app-server. It answers the handshake, starts a thread, and /// on `turn/start` plays a short turn that asks for one command approval /// and reports what decision it got in its final message. In `slow` mode @@ -181,11 +207,7 @@ fn fake_codex_app_server() { if std::env::var_os(FIXTURE_MARKER).is_none() { return; } - let stdout = std::io::stdout(); - let mut out = stdout.lock(); - // libtest prints "test ... " with no newline before the test - // runs; end that line so the first protocol line stands alone. - writeln!(out).unwrap(); + let mut out = fixture_output(); let args = std::env::var(FIXTURE_ARGS).unwrap_or_default(); if args.contains("--version") { writeln!(out, "codex-cli 0.150.0").unwrap(); @@ -200,6 +222,10 @@ fn fake_codex_app_server() { let stdin = std::io::stdin(); let mut lines = stdin.lock().lines(); let mut send = |value: Value| { + // Reproduce libtest status text arriving after fixture startup, + // without a newline. It must not corrupt a protocol response. + print!("fixture status"); + std::io::stdout().flush().unwrap(); writeln!(out, "{value}").unwrap(); out.flush().unwrap(); }; @@ -527,7 +553,7 @@ async fn cancelling_the_run_interrupts_the_turn_and_shutdown_kills_the_process() let registry = Arc::clone(&harness.registry); let call = harness.call("session-3", "row-3"); let cancel = call.cancel_token.clone(); - let turn = tokio::spawn(async move { + let mut turn = tokio::spawn(async move { registry .start( call, @@ -542,19 +568,25 @@ async fn cancelling_the_run_interrupts_the_turn_and_shutdown_kills_the_process() ) .await }); - let pid = harness.fixture_pid().await; - // Wait until the turn is identified, so the interrupt can name it. - { - let sink = Arc::clone(&harness.sink); - wait_for(|| { - sink.events() - .iter() - .any(|event| matches!(event, AgentServiceEvent::TimelineItem { item, .. } if item.id == "row-3")) - .then_some(()) - }) - .await; - } - tokio::time::sleep(Duration::from_millis(200)).await; + let pid = tokio::select! { + pid = harness.fixture_pid() => pid, + result = &mut turn => panic!("Codex fixture stopped before startup: {}", result_text(&result.unwrap())), + }; + // The initial timeline row precedes turn/start. Wait for the actual + // turn ID instead of guessing when the notification has been consumed. + let agent = harness + .registry + .agent("session-3", "codex-1") + .await + .unwrap(); + wait_for(|| { + agent + .state + .try_lock() + .ok() + .and_then(|state| state.turn.as_ref()?.turn_id.as_ref().map(|_| ())) + }) + .await; cancel.cancel(); let result = turn.await.unwrap(); let text = result_text(&result); @@ -734,7 +766,7 @@ async fn registry_rejects_unknown_providers_bad_cwd_and_too_many_agents() { }; let unknown = harness .registry - .start(harness.call("session-5", "r"), start("claude", None)) + .start(harness.call("session-5", "r"), start("unknown", None)) .await; assert_eq!(unknown.is_error, Some(true)); assert!(result_text(&unknown).contains("Unknown agent provider")); @@ -769,6 +801,7 @@ async fn registry_rejects_unknown_providers_bad_cwd_and_too_many_agents() { session.agents.insert( agent_id.clone(), Arc::new(ExternalAgent::new( + "codex".into(), agent_id, "session-5".into(), "idle".into(), @@ -952,3 +985,324 @@ async fn an_async_question_is_answered_in_a_turn_of_maples_own() { ); harness.registry.shutdown_all(Duration::from_secs(5)).await; } + +fn claude_start(background: bool) -> AgentStartParams { + AgentStartParams { + provider: "claude".into(), + prompt: "Fix the fixture".into(), + background, + model: Some("sonnet".into()), + effort: Some("high".into()), + cwd: Some("sub".into()), + } +} + +#[tokio::test] +async fn claude_detection_distinguishes_sign_in_from_probe_failures() { + for (mode, expected) in [ + ("auth-in", Some(true)), + ("auth-out", Some(false)), + ("auth-error", None), + ("auth-inconsistent", None), + ("auth-missing", None), + ("auth-oversized", None), + ("auth-timeout", None), + ] { + let harness = Harness::new(mode); + let detection = claude::detect(harness.shim_dir.to_str()).await; + assert_eq!(detection.signed_in, expected, "{mode}"); + assert!(detection.version.is_some(), "{mode}"); + assert!(detection.problem.is_none(), "{mode}"); + assert!(!format!("{detection:?}").contains("secret-canary")); + let pid = harness.fixture_pid().await; + assert!( + !process_alive(pid), + "{mode}: authentication probe left running" + ); + } +} + +/// Exercises the native Rust transport against a deterministic CLI, with no inference. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn claude_native_streams_resumes_and_binds_the_provider() { + let harness = Harness::new("approve"); + fs::create_dir(harness.project.join("sub")).unwrap(); + harness.set_mode("claude-task", GooseMode::Auto).await; + let result = harness + .registry + .start(harness.call("claude-task", "r1"), claude_start(false)) + .await; + let activity = result + .structured_content + .as_ref() + .unwrap_or_else(|| panic!("{}", result_text(&result)))[ACTIVITY_KEY] + .clone(); + assert_eq!(activity["status"], "completed", "{activity}"); + assert_eq!(activity["provider"], "claude"); + assert_eq!(activity["text"], "Allowed"); + assert_eq!(activity["commands"][0]["status"], "completed"); + assert_eq!(activity["fileChanges"][0]["path"], "src/lib.rs"); + assert_eq!(activity["todos"][0]["completed"], true); + let agent_id = activity["agentId"].as_str().unwrap().to_string(); + let thread_id = activity["threadId"].as_str().unwrap().to_string(); + let result = harness + .registry + .send( + harness.call("claude-task", "r2"), + AgentSendParams { + provider: "claude".into(), + agent_id: agent_id.clone(), + prompt: "Continue".into(), + background: false, + model: None, + effort: None, + }, + ) + .await; + assert!( + result_text(&result).contains("Status: completed"), + "{}", + result_text(&result) + ); + let log = harness.log(); + assert!(log.contains("--resume")); + assert!( + log.contains("stdin_closed"), + "completed CLI should exit before resumption" + ); + assert!(log.contains(&thread_id)); + assert!(log.contains("--effort")); + assert!(log.contains(&harness.project.join("sub").to_string_lossy().into_owned())); + assert!(!log.contains("dangerously-skip-permissions")); + for provider in ["codex", "unknown"] { + let result = harness + .registry + .cancel_tool( + &harness.call("claude-task", "r3"), + AgentRefParams { + provider: provider.into(), + agent_id: agent_id.clone(), + }, + ) + .await; + assert_eq!(result.is_error, Some(true)); + } + let result = harness + .registry + .status( + &harness.call("another-task", "r4"), + AgentRefParams { + provider: "claude".into(), + agent_id, + }, + ) + .await; + assert_eq!(result.is_error, Some(true)); + let providers = harness + .registry + .list_providers(&harness.call("claude-task", "list"), &["claude".into()]) + .await; + assert!(result_text(&providers).contains("- claude:")); + assert!(!result_text(&providers).contains("- codex:")); + harness.registry.shutdown_all(Duration::from_secs(5)).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn claude_native_cancellation_kills_cli_and_descendants() { + let harness = Harness::new("slow"); + fs::create_dir(harness.project.join("sub")).unwrap(); + let result = harness + .registry + .start(harness.call("claude-stop", "r1"), claude_start(true)) + .await; + assert!( + result_text(&result).contains("claude-1"), + "{}", + result_text(&result) + ); + let pid = harness.fixture_pid().await; + let child_pid = wait_for(|| { + fs::read_to_string(harness.pid_file.with_extension("pid.child")) + .ok() + .and_then(|pid| pid.parse::().ok()) + }) + .await; + let activity = harness + .registry + .cancel("claude-stop", "claude-1") + .await + .unwrap(); + assert_eq!(activity.status, "cancelled"); + wait_for(|| (!process_alive(pid)).then_some(())).await; + // A killed orphan can briefly remain a zombie before PID 1 reaps it. + wait_for(|| { + (!process_alive(child_pid) + || fs::read_to_string(format!("/proc/{child_pid}/stat")) + .is_ok_and(|stat| stat.contains(") Z "))) + .then_some(()) + }) + .await; + // Reconnect through a new CLI process and resume the session saved before Stop. + harness.install_fixture("claude", "approve"); + harness.set_mode("claude-stop", GooseMode::Auto).await; + let result = harness + .registry + .send( + harness.call("claude-stop", "r2"), + AgentSendParams { + provider: "claude".into(), + agent_id: "claude-1".into(), + prompt: "Resume".into(), + background: false, + model: None, + effort: None, + }, + ) + .await; + assert!( + result_text(&result).contains("Status: completed"), + "{}", + result_text(&result) + ); + assert_eq!( + result.structured_content.unwrap()[ACTIVITY_KEY]["threadId"].as_str(), + activity.thread_id.as_deref() + ); + harness.registry.shutdown_all(Duration::from_secs(5)).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn claude_native_permission_denial_and_questions_use_maple_brokers() { + for mode in ["approve", "question"] { + let harness = Harness::new(mode); + fs::create_dir(harness.project.join("sub")).unwrap(); + let registry = harness.registry.clone(); + let call = harness.call("claude-input", "r1"); + let turn = tokio::spawn(async move { registry.start(call, claude_start(false)).await }); + if mode == "approve" { + let pending = wait_for(|| { + harness + .service + .pending_permissions + .try_lock() + .ok() + .and_then(|entries| { + entries + .iter() + .next() + .map(|(id, entry)| (id.clone(), entry.clone())) + }) + }) + .await; + let (key, entry) = pending; + assert_eq!(entry.request.tool_name, "claude_tool"); + assert_eq!(entry.request.arguments["command"], "cargo test"); + harness + .service + .pending_permissions + .lock() + .await + .remove(&key); + let PendingPermissionOrigin::ExternalAgent(responder) = entry.origin else { + panic!("external responder expected") + }; + assert!(responder.resolve(AgentPermissionDecision::DenyOnce)); + assert!(!responder.resolve(AgentPermissionDecision::AllowOnce)); + } else { + let (request_id, questions) = wait_for(|| { + harness.sink.events().iter().find_map(|event| match event { + AgentServiceEvent::Question { + session_id, + request_id, + questions, + } if session_id == "claude-input" => { + Some((request_id.clone(), questions.clone())) + } + _ => None, + }) + }) + .await; + assert_eq!(questions[0].question, "Tabs or spaces?"); + assert!( + harness + .service + .answer_question( + &request_id, + r#"{"answers":{"q0":{"answers":["Spaces"]}}}"#.into() + ) + .await + ); + } + let result = tokio::time::timeout(WAIT, turn).await.unwrap().unwrap(); + let text = result_text(&result); + assert!( + text.contains(if mode == "approve" { + "Denied" + } else { + "Spaces" + }), + "{text}" + ); + harness.registry.shutdown_all(Duration::from_secs(5)).await; + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn claude_native_failures_are_not_success_or_unsanitized_output() { + for mode in ["eof", "error", "init-error", "malformed", "oversized"] { + let harness = Harness::new(mode); + fs::create_dir(harness.project.join("sub")).unwrap(); + let result = harness + .registry + .start(harness.call("claude-fail", "r1"), claude_start(false)) + .await; + let text = result_text(&result); + assert!( + text.contains("Status: failed") || result.is_error == Some(true), + "{mode}: {text}" + ); + assert!(!text.contains("secret-canary")); + let pid = harness.fixture_pid().await; + wait_for(|| (!process_alive(pid)).then_some(())).await; + harness.registry.shutdown_all(Duration::from_secs(5)).await; + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn claude_native_stop_withdraws_pending_permission() { + let harness = Harness::new("approve"); + fs::create_dir(harness.project.join("sub")).unwrap(); + let result = harness + .registry + .start(harness.call("claude-pending", "r1"), claude_start(true)) + .await; + assert!(result_text(&result).contains("claude-1")); + let key = wait_for(|| { + harness + .service + .pending_permissions + .try_lock() + .ok() + .and_then(|pending| pending.keys().next().cloned()) + }) + .await; + let pid = harness.fixture_pid().await; + let activity = harness + .registry + .cancel("claude-pending", "claude-1") + .await + .unwrap(); + assert_eq!(activity.status, "cancelled"); + wait_for(|| (!process_alive(pid)).then_some(())).await; + wait_for(|| { + harness + .service + .pending_permissions + .try_lock() + .ok() + .and_then(|pending| (!pending.contains_key(&key)).then_some(())) + }) + .await; + assert!(!harness.log().contains("\"behavior\":\"allow\"")); + harness.registry.shutdown_all(Duration::from_secs(5)).await; +} diff --git a/apps/maple-agent/crates/maple-agent/src/agent/external_agents/tests/claude_fixture.rs b/apps/maple-agent/crates/maple-agent/src/agent/external_agents/tests/claude_fixture.rs new file mode 100644 index 000000000..f8397245f --- /dev/null +++ b/apps/maple-agent/crates/maple-agent/src/agent/external_agents/tests/claude_fixture.rs @@ -0,0 +1,235 @@ +//! Native CLI fixture for the Claude transport. Reuses the driver test binary. + +use super::{ + FIXTURE_ARGS, FIXTURE_LOG, FIXTURE_MARKER, FIXTURE_MODE, FIXTURE_PID_FILE, fixture_output, +}; +use serde_json::{Value, json}; +use std::fs::{self, File, OpenOptions}; +use std::io::{BufRead, Write}; +use std::process::Command; + +fn send(out: &mut File, message: Value) { + writeln!(out, "{message}").unwrap(); + out.flush().unwrap(); +} + +fn finish(out: &mut File, session: &str, text: &str) { + send( + out, + json!({"type": "assistant", "message": { + "id": "message-1", "role": "assistant", "model": "fixture", + "content": [{"type": "text", "text": text}], + }}), + ); + send( + out, + json!({"type": "result", "subtype": "success", "is_error": false, + "session_id": session, "result": text, + }), + ); +} + +#[test] +#[ignore = "fake Claude CLI run by the driver tests"] +fn run() { + if std::env::var_os(FIXTURE_MARKER).is_none() { + return; + } + let mut out = fixture_output(); + let args = std::env::var(FIXTURE_ARGS).unwrap(); + let args: Vec<_> = args.split_whitespace().collect(); + if args.contains(&"--version") { + writeln!(out, "2.1.270 (Claude Code)").unwrap(); + return; + } + let mode = std::env::var(FIXTURE_MODE).unwrap(); + if args == ["auth", "status", "--json"] { + fs::write( + std::env::var(FIXTURE_PID_FILE).unwrap(), + std::process::id().to_string(), + ) + .unwrap(); + let exit = match mode.as_str() { + "auth-out" => { + send(&mut out, json!({"loggedIn": false})); + 1 + } + "auth-error" => { + writeln!(out, "secret-canary: unsupported command").unwrap(); + 1 + } + "auth-inconsistent" => { + send(&mut out, json!({"loggedIn": true})); + 1 + } + "auth-missing" => { + send(&mut out, json!({"email": "secret-canary"})); + 0 + } + "auth-oversized" => { + send( + &mut out, + json!({"loggedIn": true, "extra": "x".repeat(20 * 1024)}), + ); + 0 + } + "auth-timeout" => { + std::thread::sleep(std::time::Duration::from_secs(30)); + 0 + } + _ => { + send( + &mut out, + json!({"loggedIn": true, "email": "secret-canary"}), + ); + 0 + } + }; + std::process::exit(exit); + } + let session = args + .windows(2) + .find_map(|pair| matches!(pair[0], "--session-id" | "--resume").then_some(pair[1])) + .expect("a new or resumed Claude session"); + let pid_file = std::env::var(FIXTURE_PID_FILE).unwrap(); + fs::write(&pid_file, std::process::id().to_string()).unwrap(); + let mut log = OpenOptions::new() + .create(true) + .append(true) + .open(std::env::var(FIXTURE_LOG).unwrap()) + .unwrap(); + writeln!( + log, + "{}", + json!({"args": args, "cwd": std::env::current_dir().unwrap()}) + ) + .unwrap(); + + for line in std::io::stdin().lock().lines() { + let message: Value = serde_json::from_str(&line.unwrap()).unwrap(); + writeln!(log, "{message}").unwrap(); + match message["type"].as_str() { + Some("control_request") => { + if mode == "init-error" { + send( + &mut out, + json!({"type": "control_response", "response": { + "subtype": "error", "request_id": message["request_id"], "error": "secret-canary", + }}), + ); + continue; + } + send( + &mut out, + json!({"type": "control_response", "response": { + "subtype": "success", "request_id": message["request_id"], "response": {}, + }}), + ); + if message["request"]["subtype"] == "interrupt" { + break; + } + } + Some("user") => { + send( + &mut out, + json!({"type": "system", "subtype": "init", "session_id": session}), + ); + match mode.as_str() { + "eof" => return, + "malformed" | "oversized" => { + let line = if mode == "malformed" { + "not-json".into() + } else { + "x".repeat(4 * 1024 * 1024 + 1) + }; + writeln!(out, "{line}").unwrap(); + out.flush().unwrap(); + continue; + } + "error" => { + send( + &mut out, + json!({"type": "result", "subtype": "error_during_execution", + "is_error": true, "session_id": session, "errors": ["secret-canary"], + }), + ); + continue; + } + "slow" => { + // Deliberately outlives the CLI to test Maple's process + // group cleanup, including descendants after interrupt. + #[allow(clippy::zombie_processes)] + let child = Command::new("/bin/sleep").arg("1000").spawn().unwrap(); + fs::write(format!("{pid_file}.child"), child.id().to_string()).unwrap(); + continue; + } + _ => {} + } + send( + &mut out, + json!({"type": "stream_event", "event": { + "type": "message_start", "message": {"id": "message-1"}, + }}), + ); + send( + &mut out, + json!({"type": "stream_event", "event": { + "type": "content_block_delta", "delta": {"type": "text_delta", "text": "Working"}, + }}), + ); + let (tool, input) = if mode == "question" { + ( + "AskUserQuestion", + json!({"questions": [{"header": "Style", "question": "Tabs or spaces?", + "options": [{"label": "Spaces", "description": "Use spaces"}], + }]}), + ) + } else { + ("Bash", json!({"command": "cargo test"})) + }; + send( + &mut out, + json!({"type": "control_request", "request_id": "permission-1", + "request": {"subtype": "can_use_tool", "tool_name": tool, "input": input, "tool_use_id": "tool-1"}, + }), + ); + } + Some("control_response") => { + let answer = &message["response"]["response"]; + if mode == "question" { + finish( + &mut out, + session, + &answer["updatedInput"]["answers"].to_string(), + ); + continue; + } + let allowed = answer["behavior"] == "allow"; + if allowed { + send( + &mut out, + json!({"type": "assistant", "message": {"id": "tools", "content": [ + {"type": "tool_use", "id": "c1", "name": "Bash", "input": {"command": "cargo test"}}, + {"type": "tool_use", "id": "f1", "name": "Edit", "input": {"file_path": "src/lib.rs"}}, + {"type": "tool_use", "id": "todo", "name": "TodoWrite", "input": {"todos": [{"content": "Test", "status": "completed"}]}}, + ]}}), + ); + send( + &mut out, + json!({"type": "user", "message": {"content": [ + {"type": "tool_result", "tool_use_id": "c1", "content": "ok"}, + {"type": "tool_result", "tool_use_id": "f1", "content": "ok"}, + ]}}), + ); + } + finish( + &mut out, + session, + if allowed { "Allowed" } else { "Denied" }, + ); + } + _ => panic!("unexpected Claude fixture input"), + } + } + writeln!(log, "{}", json!({"event": "stdin_closed"})).unwrap(); +} diff --git a/apps/maple-agent/crates/maple-agent/src/agent/integrations.rs b/apps/maple-agent/crates/maple-agent/src/agent/integrations.rs index 7b87464ff..ac31fc94d 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/integrations.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/integrations.rs @@ -5,6 +5,7 @@ //! Maple-hosted implementation. A task freezes that backend choice when it is //! created; account defaults never rewrite existing tasks. +use super::external_agents::claude::{self, ClaudeDetection}; use super::external_agents::codex::{self, CodexDetection}; use super::*; use std::collections::HashSet; @@ -44,13 +45,27 @@ pub(super) struct ExternalAgentIntegration { project: fn(&IntegrationDetections, Option<&StoredIntegration>) -> AgentIntegration, } -pub(super) const EXTERNAL_AGENT_INTEGRATIONS: &[ExternalAgentIntegration] = - &[ExternalAgentIntegration { +pub(super) const EXTERNAL_AGENT_INTEGRATIONS: &[ExternalAgentIntegration] = &[ + ExternalAgentIntegration { id: CODEX_INTEGRATION_ID, name: CODEX_CARD_NAME, description: CODEX_CARD_DESCRIPTION, project: |detections, stored| codex_public(&detections.codex, stored), - }]; + }, + ExternalAgentIntegration { + id: claude::PROVIDER_ID, + name: claude::PROVIDER_NAME, + description: "Let a task hand work to the Claude Code CLI installed on this computer, with its own account.", + project: |detections, stored| claude_public(&detections.claude, stored), + }, +]; + +impl AgentIntegration { + /// Whether this card is an external coding agent in the runtime catalog. + pub fn is_external_agent(&self) -> bool { + external_agent_selection(&self.id).is_some() + } +} pub(super) fn external_agent_selection(id: &str) -> Option<&'static ExternalAgentIntegration> { EXTERNAL_AGENT_INTEGRATIONS @@ -194,6 +209,7 @@ impl StoredIntegrationRegistry { pub(super) struct IntegrationDetections { pub(super) cua: CuaDetection, pub(super) codex: CodexDetection, + pub(super) claude: ClaudeDetection, } /// The Integrations card for Codex. Availability comes from the @@ -237,6 +253,40 @@ fn codex_public( } } +fn claude_public( + detection: &ClaudeDetection, + stored: Option<&StoredIntegration>, +) -> AgentIntegration { + let descriptor = external_agent_selection(claude::PROVIDER_ID).expect("Claude catalog entry"); + AgentIntegration { + id: descriptor.id.into(), + name: descriptor.name.into(), + description: descriptor.description.into(), + availability: match (&detection.executable, &detection.problem) { + (None, _) => AgentIntegrationAvailability::NotDetected, + (_, Some(_)) => AgentIntegrationAvailability::SetupRequired, + _ => AgentIntegrationAvailability::Available, + }, + backend: stored.map(|entry| entry.backend), + version: detection.version.clone(), + standalone_version: None, + permissions: None, + setup_available: false, + enabled_for_new_tasks: stored.is_some_and(|entry| entry.enabled), + detail: Some(if detection.executable.is_none() { + "Install Claude Code and make sure `claude` is on PATH, then reopen this page.".into() + } else if let Some(problem) = &detection.problem { + problem.clone() + } else { + match detection.signed_in { + Some(true) => "Signed in.".into(), + Some(false) => claude::sign_in_hint().into(), + None => "Could not check sign-in. Run `claude auth status` in a terminal.".into(), + } + }), + } +} + /// Whether tasks of this account may delegate to external agents. Read /// on a path that must keep working, so an unusable file reads as off. pub(super) fn external_agents_enabled(paths: &AgentPathLayout, user_id: &str) -> bool { @@ -438,13 +488,14 @@ fn embedded_cua_version() -> Option { None } -/// Discover every curated integration. `codex_search_path` is the PATH -/// to look on for the Codex CLI, when the host knows a fuller one than +/// Discover every curated integration. `search_path` is the PATH +/// to look on for external agents, when the host knows a fuller one than /// the process environment (a macOS GUI launch). -pub(super) async fn detect_integrations(codex_search_path: Option<&str>) -> IntegrationDetections { +pub(super) async fn detect_integrations(search_path: Option<&str>) -> IntegrationDetections { IntegrationDetections { cua: detect_cua_driver().await, - codex: codex::detect(codex_search_path).await, + codex: codex::detect(search_path).await, + claude: claude::detect(search_path).await, } } @@ -982,20 +1033,18 @@ fn validate_stored_registry( } let mut ids = HashSet::new(); for entry in ®istry.integrations { - if !matches!( - entry.id.as_str(), - CUA_DRIVER_INTEGRATION_ID | CODEX_INTEGRATION_ID - ) || !ids.insert(entry.id.as_str()) + if (entry.id != CUA_DRIVER_INTEGRATION_ID && external_agent_selection(&entry.id).is_none()) + || !ids.insert(entry.id.as_str()) { return Err( "Device-local integration settings contain an unknown or duplicate integration" .to_string(), ); } - if entry.id == CODEX_INTEGRATION_ID { + if external_agent_selection(&entry.id).is_some() { if entry.backend != AgentIntegrationBackend::Embedded || entry.external_server.is_some() { - return Err("Device-local Codex settings are invalid".to_string()); + return Err("Device-local external agent settings are invalid".to_string()); } continue; } @@ -1272,6 +1321,7 @@ mod tests { fn cua_only(cua: CuaDetection) -> IntegrationDetections { IntegrationDetections { + claude: ClaudeDetection::default(), cua, codex: CodexDetection::default(), } @@ -1307,10 +1357,14 @@ mod tests { } fn codex_registry(enabled: bool) -> StoredIntegrationRegistry { + provider_registry("codex", enabled) + } + + fn provider_registry(provider: &str, enabled: bool) -> StoredIntegrationRegistry { StoredIntegrationRegistry { version: INTEGRATIONS_FILE_VERSION, integrations: vec![StoredIntegration { - id: CODEX_INTEGRATION_ID.to_string(), + id: provider.to_string(), enabled, backend: AgentIntegrationBackend::Embedded, external_server: None, @@ -2005,6 +2059,7 @@ mod tests { ); let user = "codex-user"; let detections = IntegrationDetections { + claude: ClaudeDetection::default(), cua: CuaDetection::not_detected(), codex: CodexDetection { executable: Some(temporary.path().join("codex")), @@ -2080,6 +2135,7 @@ mod tests { enabled: false, }, &IntegrationDetections { + claude: ClaudeDetection::default(), cua: CuaDetection::not_detected(), codex: CodexDetection::default(), }, @@ -2114,6 +2170,7 @@ mod tests { enabled: true, }; let missing = IntegrationDetections { + claude: ClaudeDetection::default(), cua: CuaDetection::not_detected(), codex: CodexDetection::default(), }; @@ -2126,6 +2183,7 @@ mod tests { ); let old = IntegrationDetections { + claude: ClaudeDetection::default(), cua: CuaDetection::not_detected(), codex: CodexDetection { executable: Some(temporary.path().join("codex")), @@ -2145,4 +2203,82 @@ mod tests { ); assert!(!external_agents_enabled(&paths, user)); } + #[test] + fn claude_card_reports_authentication_without_gating_availability() { + for (signed_in, detail) in [ + (Some(true), "Signed in."), + (Some(false), claude::sign_in_hint()), + ( + None, + "Could not check sign-in. Run `claude auth status` in a terminal.", + ), + ] { + let card = claude_public( + &ClaudeDetection { + executable: Some(PathBuf::from("claude")), + signed_in, + ..Default::default() + }, + None, + ); + assert_eq!(card.detail.as_deref(), Some(detail)); + assert_eq!(card.availability, AgentIntegrationAvailability::Available); + } + } + + #[test] + fn claude_setup_and_defaults_keep_shared_skills_until_both_are_off() { + let temp = tempfile::tempdir().unwrap(); + let paths = + AgentPathLayout::from_app_roots(temp.path().join("config"), temp.path().join("data")); + let mut detections = cua_only(CuaDetection::not_detected()); + let request = AgentSetIntegrationEnabledRequest { + id: "claude".into(), + enabled: true, + }; + assert!( + set_integration_default(&paths, "user", &request, &detections) + .unwrap_err() + .contains("Install Claude Code") + ); + detections.claude = ClaudeDetection { + executable: Some(temp.path().join("claude")), + version: Some("2.1.270".into()), + signed_in: None, + problem: Some("Claude Code could not be started".into()), + }; + assert!( + set_integration_default(&paths, "user", &request, &detections) + .unwrap_err() + .contains("could not be started") + ); + detections.claude.problem = None; + save_stored_integrations(&paths, "user", &codex_registry(true)).unwrap(); + set_integration_default(&paths, "user", &request, &detections).unwrap(); + set_integration_default( + &paths, + "user", + &AgentSetIntegrationEnabledRequest { + id: "codex".into(), + enabled: false, + }, + &detections, + ) + .unwrap(); + assert!(external_agents_enabled(&paths, "user")); + let skills = external_agent_skills_dir(&paths, "user").unwrap(); + assert!(skills.join("handoff/SKILL.md").is_file()); + set_integration_default( + &paths, + "user", + &AgentSetIntegrationEnabledRequest { + id: "claude".into(), + enabled: false, + }, + &detections, + ) + .unwrap(); + assert!(!external_agents_enabled(&paths, "user")); + assert!(!skills.join("handoff/SKILL.md").exists()); + } } From 9450dbc70406daca353729219d064aac324e9c32 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Sat, 19 Sep 2026 01:28:59 -0500 Subject: [PATCH 02/37] Gate session agents on integration settings Show Codex and Claude in the composer only when enabled in Settings. Require each task to opt in, and block saved selections while Settings is disabled. Preserve task choices across restarts and re-enablement. Exercise admission, composer visibility, and cached tool refresh for both providers, including rejection of direct requests when disabled. --- apps/maple-agent/app/src/ui/settings.rs | 2 +- .../crates/maple-agent/src/agent.rs | 11 +- .../maple-agent/src/agent/integrations.rs | 142 +++++++++++++----- .../crates/maple-agent/src/agent/types.rs | 2 + 4 files changed, 117 insertions(+), 40 deletions(-) diff --git a/apps/maple-agent/app/src/ui/settings.rs b/apps/maple-agent/app/src/ui/settings.rs index 5b59f7e86..16562f048 100644 --- a/apps/maple-agent/app/src/ui/settings.rs +++ b/apps/maple-agent/app/src/ui/settings.rs @@ -2236,7 +2236,7 @@ impl SettingsScreen { div() .text_sm() .text_color(gpui::rgb(theme::text_muted())) - .child("Set integration defaults here. Choose integrations for each task in the composer."), + .child("Manage integrations here. Enable coding agents to make them available in the composer, then select them for each task."), ); if let Some(notice) = &self.integration_notice { diff --git a/apps/maple-agent/crates/maple-agent/src/agent.rs b/apps/maple-agent/crates/maple-agent/src/agent.rs index fa3483a97..5f1e840d2 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent.rs @@ -4209,6 +4209,12 @@ impl AgentRuntimeHandle { return Err("External agents are available only in desktop tasks".to_string()); } if request.enabled { + if !external_agent_enabled(&stored_integrations, provider.id) { + return Err( + "Enable this integration in Settings before selecting it for this task" + .to_string(), + ); + } let integrations = project_integrations(&state.host.paths, user_id, &detected)?; let integration = integrations .iter() @@ -8814,8 +8820,7 @@ async fn finish_session_agent( session, allow_embedded_cua, ); - // A task override can keep delegation enabled after the device default - // was disabled (and removed Maple's skills), including after a restart. + // Restore bundled skills for admitted providers, including after a restart. if !selected_external_providers.is_empty() && let Err(error) = sync_external_agent_skills(skills_scope.paths, skills_scope.user_id, true) @@ -8865,7 +8870,7 @@ async fn finish_session_agent( .with_attachment_store(attachment_store) .with_web_enabled(session_web_enabled(session)) .with_desktop_ui_tools(session.session_type != SessionType::Acp) - // Re-read inherited defaults and explicit task choices at every run, + // Re-read Settings gates and explicit task choices at every run, // including cold restores. The driving surface remains the authority: // leasing a desktop task to ACP must remove desktop-only capabilities. .with_external_agents(external_agents.cloned(), selected_external_providers); diff --git a/apps/maple-agent/crates/maple-agent/src/agent/integrations.rs b/apps/maple-agent/crates/maple-agent/src/agent/integrations.rs index ac31fc94d..a35ebc5ef 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/integrations.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/integrations.rs @@ -83,21 +83,24 @@ impl ExtensionState for TaskIntegrationOverrides { const VERSION: &'static str = "1"; } +pub(super) fn external_agent_enabled(stored: &StoredIntegrationRegistry, id: &str) -> bool { + stored + .integrations + .iter() + .any(|entry| entry.id == id && entry.enabled) +} + fn session_external_agent_enabled( stored: &StoredIntegrationRegistry, session: &Session, id: &str, ) -> bool { - // Missing metadata is inheritance, including tasks created before this - // integration existed. Never snapshot an inherited false into old tasks. - TaskIntegrationOverrides::from_extension_data(&session.extension_data) - .and_then(|state| state.enabled.get(id).copied()) - .unwrap_or_else(|| { - stored - .integrations - .iter() - .any(|entry| entry.id == id && entry.enabled) - }) + // Settings admits the provider; each task must also explicitly select it. + // A saved selection cannot bypass a disabled account/device integration. + external_agent_enabled(stored, id) + && TaskIntegrationOverrides::from_extension_data(&session.extension_data) + .and_then(|state| state.enabled.get(id).copied()) + .unwrap_or(false) } pub(super) fn session_external_agent_providers( @@ -856,6 +859,7 @@ pub(super) fn project_session_mcp_servers( servers.extend( EXTERNAL_AGENT_INTEGRATIONS .iter() + .filter(|entry| external_agent_enabled(stored, entry.id)) .map(|entry| AgentSessionMcpServer { name: entry.id.to_string(), kind: AgentSessionIntegrationKind::ExternalAgent, @@ -1373,7 +1377,7 @@ mod tests { } #[tokio::test] - async fn old_task_inherits_providers_until_explicitly_overridden() { + async fn tasks_require_both_settings_and_persisted_provider_selection() { let temp = tempfile::tempdir().unwrap(); let manager = SessionManager::new(temp.path().join("sessions")); let session = manager @@ -1388,10 +1392,7 @@ mod tests { assert!( session_external_agent_providers(&codex_registry(false), &session, true).is_empty() ); - assert_eq!( - session_external_agent_providers(&codex_registry(true), &session, true), - ["codex"] - ); + assert!(session_external_agent_providers(&codex_registry(true), &session, true).is_empty()); assert!( session_external_agent_providers(&codex_registry(true), &session, false).is_empty() ); @@ -1409,21 +1410,37 @@ mod tests { let enabled = persist_task_integration_override(&manager, &session.id, "codex", true) .await .unwrap(); + assert!( + session_external_agent_providers(&codex_registry(false), &enabled, true).is_empty() + ); assert_eq!( - session_external_agent_providers(&codex_registry(false), &enabled, true), + session_external_agent_providers(&codex_registry(true), &enabled, true), ["codex"] ); + let other_session = manager + .create_session( + temp.path().to_path_buf(), + "Other task".into(), + SessionType::User, + GooseMode::SmartApprove, + ) + .await + .unwrap(); + assert!( + session_external_agent_providers(&codex_registry(true), &other_session, true) + .is_empty() + ); drop(manager); let manager = SessionManager::new(temp.path().join("sessions")); let restored = manager.get_session(&session.id, false).await.unwrap(); assert_eq!( - session_external_agent_providers(&codex_registry(false), &restored, true), + session_external_agent_providers(&codex_registry(true), &restored, true), ["codex"] ); } #[test] - fn composer_lists_native_integrations_without_prior_setup() { + fn composer_lists_only_settings_enabled_providers_without_selecting_them() { let session = Session { session_type: SessionType::User, ..Session::default() @@ -1431,10 +1448,23 @@ mod tests { let rows = project_session_mcp_servers(&StoredIntegrationRegistry::default(), &[], &session) .unwrap(); - assert!(rows.iter().any(|row| row.name == "codex" - && row.kind == AgentSessionIntegrationKind::ExternalAgent - && row.display_name == "Codex" - && !row.enabled)); + assert!( + !rows + .iter() + .any(|row| row.kind == AgentSessionIntegrationKind::ExternalAgent) + ); + for provider in ["codex", "claude"] { + let rows = + project_session_mcp_servers(&provider_registry(provider, true), &[], &session) + .unwrap(); + let native: Vec<_> = rows + .iter() + .filter(|row| row.kind == AgentSessionIntegrationKind::ExternalAgent) + .collect(); + assert_eq!(native.len(), 1); + assert_eq!(native[0].name, provider); + assert!(!native[0].enabled); + } assert!( rows.iter() .any(|row| row.name == CUA_DRIVER_MCP_NAME && !row.enabled && !row.available) @@ -1456,7 +1486,7 @@ mod tests { assert!( codex_rows .iter() - .any(|row| row.kind == AgentSessionIntegrationKind::ExternalAgent && row.enabled) + .any(|row| row.kind == AgentSessionIntegrationKind::ExternalAgent && !row.enabled) ); let legacy_request: AgentSetSessionMcpServerRequest = serde_json::from_value(json!({ "sessionId": "s1", "name": "codex", "enabled": true, @@ -1472,6 +1502,11 @@ mod tests { /// external process is needed to inspect the model-facing tool catalog. #[tokio::test] async fn resumed_task_refreshes_external_tools_on_every_run() { + assert_resumed_task_refreshes_provider("codex").await; + assert_resumed_task_refreshes_provider("claude").await; + } + + async fn assert_resumed_task_refreshes_provider(provider: &str) { let fixture = super::super::test_support::started_agent_runtime("integration-refresh").await; let service = &fixture.handle.service; @@ -1513,20 +1548,24 @@ mod tests { ); let mut agent = Arc::new(Agent::with_config(config.clone())); let context = SharedAgentToolContext::new(AgentToolContextSpec::default()); - // Old task before enable, cached task after enable, cold restore after - // enable, explicit off, explicit on despite default off, and ACP lease. + // Settings alone never selects a provider. Revocation removes tools + // from warm and cold agents despite a saved selection; re-enabling + // Settings preserves the task's choice. ACP never receives the tools. for (default, override_value, cold, desktop, expected) in [ (false, None, false, true, false), - (true, None, true, true, true), + (true, None, true, true, false), (false, None, false, true, false), - (true, None, false, true, true), + (true, None, false, true, false), + (true, Some(true), false, true, true), + (false, Some(true), false, true, false), + (true, Some(true), true, true, true), + (false, Some(true), true, true, false), (true, Some(false), true, true, false), - (false, Some(true), true, true, true), - (true, None, false, false, false), + (true, Some(true), false, false, false), ] { - save_stored_integrations(paths, user, &codex_registry(default)).unwrap(); + save_stored_integrations(paths, user, &provider_registry(provider, default)).unwrap(); if let Some(enabled) = override_value { - persist_task_integration_override(&manager, &session.id, "codex", enabled) + persist_task_integration_override(&manager, &session.id, provider, enabled) .await .unwrap(); } @@ -1582,23 +1621,54 @@ mod tests { ); } } + save_stored_integrations(paths, user, &provider_registry(provider, false)).unwrap(); + let error = fixture + .handle + .set_session_mcp_server_enabled(AgentSetSessionMcpServerRequest { + session_id: session.id.clone(), + name: provider.to_string(), + kind: AgentSessionIntegrationKind::ExternalAgent, + enabled: true, + }) + .await + .unwrap_err(); + assert!( + error.contains("Enable this integration in Settings"), + "{error}" + ); + let hidden = fixture + .handle + .list_session_mcp_servers(session.id.clone()) + .await + .unwrap(); + assert!( + !hidden + .iter() + .any(|row| row.kind == AgentSessionIntegrationKind::ExternalAgent) + ); let rows = fixture .handle .set_session_mcp_server_enabled(AgentSetSessionMcpServerRequest { session_id: session.id.clone(), - name: "codex".to_string(), + name: provider.to_string(), kind: AgentSessionIntegrationKind::ExternalAgent, enabled: false, }) .await .unwrap(); assert!( - rows.iter() - .any(|row| row.kind == AgentSessionIntegrationKind::ExternalAgent && !row.enabled) + !rows + .iter() + .any(|row| row.kind == AgentSessionIntegrationKind::ExternalAgent) ); let stored_task = manager.get_session(&session.id, false).await.unwrap(); assert!( - session_external_agent_providers(&codex_registry(true), &stored_task, true).is_empty() + session_external_agent_providers( + &provider_registry(provider, true), + &stored_task, + true + ) + .is_empty() ); // A future provider selected alongside an installed but unselected // Codex must not grant Codex access through forged tool arguments. @@ -1622,7 +1692,7 @@ mod tests { .call_tool( &goose::agents::ToolCallContext::new(session.id.clone(), None, None), name, - Some(rmcp::object!({"provider": "codex"})), + Some(rmcp::object!({"provider": provider})), CancellationToken::new(), ) .await diff --git a/apps/maple-agent/crates/maple-agent/src/agent/types.rs b/apps/maple-agent/crates/maple-agent/src/agent/types.rs index 63a33b085..934836498 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/types.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/types.rs @@ -144,6 +144,8 @@ pub struct AgentIntegration { /// the desktop session, so the interface does not offer a button that /// repeats work the user already did. pub setup_available: bool, + /// Settings switch: external agents require this gate plus a per-task + /// selection. For CUA this remains the default for newly created tasks. pub enabled_for_new_tasks: bool, pub detail: Option, } From 5b259eec516cd4c28a0a5fccf34a8ffe5dcc79e4 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Sat, 19 Sep 2026 01:28:59 -0500 Subject: [PATCH 03/37] Document Claude setup and session selection Explain Claude CLI setup, native transport provenance, and the separate Settings and task controls. Record the fixture validation workflow so future provider changes preserve the shared lifecycle contract. --- .agents/skills/develop-maple-agent/SKILL.md | 13 ++- apps/maple-agent/README.md | 29 ++++--- apps/maple-agent/docs/external-agents.md | 87 +++++++++++++++++---- 3 files changed, 102 insertions(+), 27 deletions(-) diff --git a/.agents/skills/develop-maple-agent/SKILL.md b/.agents/skills/develop-maple-agent/SKILL.md index 6ba6ed51e..f18cb53d2 100644 --- a/.agents/skills/develop-maple-agent/SKILL.md +++ b/.agents/skills/develop-maple-agent/SKILL.md @@ -75,10 +75,17 @@ cleanup. Do not run raw `cargo clean` against the shared cache. For composer integrations and external providers, read `apps/maple-agent/docs/external-agents.md`. Keep provider metadata in the runtime catalog and pass typed selection kinds through the UI bridge so -user-controlled MCP names cannot shadow provider IDs. Preserve inherited -defaults for tasks without overrides and CUA's existing backend metadata. +user-controlled MCP names cannot shadow provider IDs. External providers must +be enabled in Settings before appearing in the composer, and each task must +explicitly select them. A saved task choice cannot bypass Settings. Preserve +CUA's existing backend metadata. Exercise warm and cold session tool catalogs and ACP exclusion when changing -run-boundary admission. +run-boundary admission. Claude Code uses a native Rust transport adapted from +the pinned Goose provider in `external_agents/claude.rs`. Preserve the source +attribution when changing that adapted code. Keep process ownership in Maple's +contained host. The `claude_native_*` tests re-execute the +Rust test binary as a CLI fixture and need no Claude account or inference +request. Keep fixture launch and environment setup shared with the Codex tests. ## Security and publication diff --git a/apps/maple-agent/README.md b/apps/maple-agent/README.md index 4999ad193..8121f334b 100644 --- a/apps/maple-agent/README.md +++ b/apps/maple-agent/README.md @@ -74,10 +74,10 @@ Cargo manifests and lockfile; Research has an independent dependency graph. keeps its row after the turn ends, and Maple tells the task when it finishes, with a bounded result in the running turn or a new turn Maple starts automatically. The task can use `load` to retrieve any truncated output. -- External agents: a task can hand work to the Codex CLI installed on +- External agents: a task can hand work to Codex or Claude Code installed on this computer with the `agent_start`, `agent_send`, `agent_status`, - `agent_cancel`, and `list_agent_providers` tools, once Codex is enabled - under Settings > Integrations. Codex runs in the project with its own + `agent_cancel`, and `list_agent_providers` tools, once the provider is enabled + under Settings > Integrations. Each agent runs in the project with its own account, context, and sandbox settings; whatever it asks approval for comes to you through Maple's permission card, and Allow all grants it. Its progress streams into the tool call's row and its row above the composer has a Stop @@ -158,16 +158,27 @@ account configuration that may roam between devices. The embedded design, migration rules, privacy boundary, and preview limits are documented in [`docs/embedded-cua.md`](docs/embedded-cua.md). +#### Claude Code + +Settings > Integrations lists Claude Code (`claude`) alongside Codex, with the +same per-task selection, streamed activity, permission cards, and Stop control. +Install the Claude Code CLI on the app's PATH and sign in using +`claude auth login`. Maple uses a Rust transport adapted from Goose's Claude +Code provider. The CLI is the only external runtime dependency. The integration +is off by default. Enable it in Settings to show it in the composer, then +select it for the tasks that should use it. See +[external agents](docs/external-agents.md#how-claude-code-is-driven). + #### Codex Settings > Integrations also lists the Codex CLI when `codex` is on the PATH (the login shell's PATH on macOS). The card shows the installed version and whether Codex is signed in; Maple never runs Codex's sign-in itself. The -toggle is off by default. The composer lists Codex alongside CUA and custom -MCP servers, with an independent choice for each task. Tasks without an -explicit Codex choice inherit the Settings default on every run, including -older tasks; composer overrides survive relaunches. Enabling it gives runs -the external-agent tools and installs the `handoff`, `committee`, and `advisor` skills into the +toggle is off by default. Enabling it makes Codex available in the composer +alongside CUA and custom MCP servers. Each task must select Codex explicitly; +that choice survives relaunches but only applies while Settings enables Codex. +Selecting it gives that task the external-agent tools. Enabling it in Settings +installs the `handoff`, `committee`, and `advisor` skills into the account's Goose skills directory; disabling removes only the files Maple wrote. Codex needs version 0.143 or newer. See [`docs/external-agents.md`](docs/external-agents.md). @@ -433,7 +444,7 @@ The roots follow the platform, the same way the Tauri app's | `/settings.json` | App settings. | | `/agent/accounts//config.json` | Per-account agent configuration (default root, model, custom MCP servers, project trust). May roam between machines. | | `/agent/accounts//goose/config/` | Goose permission file for the account. | -| `/agent/accounts//goose/config/skills/` | Skills the account's tasks can load, including the delegation skills Maple installs while Codex is enabled. | +| `/agent/accounts//goose/config/skills/` | Skills the account's tasks can load, including the delegation skills Maple installs while any external agent is enabled. | | `/agent/goose-runtime/` | Goose process configuration. | | `/auth.json` | Sign-in credentials (mode 0600). Device-local; never in a roaming profile. | | `/agent/accounts//integrations.json` | Per-account defaults and validated launch details for integrations detected on this device. | diff --git a/apps/maple-agent/docs/external-agents.md b/apps/maple-agent/docs/external-agents.md index f056b7e62..31a4e7cec 100644 --- a/apps/maple-agent/docs/external-agents.md +++ b/apps/maple-agent/docs/external-agents.md @@ -1,36 +1,39 @@ # External agents A Maple task can hand work to an external coding agent that is installed on -the same computer. Codex is the first provider. The tool contract is generic -so another harness can be added without changing what the task sees. +the same computer. Supported providers are Codex (`codex`) and Claude Code +(`claude`). Both use the same delegation tools and task controls. ## What the task sees Five tools appear when at least one external agent is selected in the -composer's Integrations menu. Settings > Integrations supplies the device -and account default. Tasks without an explicit choice inherit that default -on every run, including tasks created before the integration existed. -Choosing on or off in the composer persists an override for that task. +composer's Integrations menu. Settings > Integrations controls which providers +are available to select for this account on this device. Disabled providers +are hidden from the composer. Enabling a provider in Settings does not select +it for any task: each task starts with external agents off. +Choosing on or off in the composer persists that choice for that task. +Disabling a provider in Settings blocks saved task selections on the next run; +re-enabling it restores their availability without erasing those choices. Changes take effect on its next run; stop an active run before changing its selection. CUA and custom MCP servers have independent switches. -An unavailable integration remains visible and links to Settings for setup. +An integration enabled in Settings but unavailable on the device remains +visible in the composer and links to Settings for setup. The runtime checks installation before enabling a provider and again when launching it. External agents are desktop capabilities: an ACP caller cannot acquire them by resuming a desktop task. The tools are: - | Tool | Purpose | | --- | --- | -| `list_agent_providers` | Which providers are installed, their version, and whether they are signed in. | +| `list_agent_providers` | Selected providers, their installation status and version, and setup or sign-in guidance. | | `agent_start` | Start an agent on a self-contained briefing. Blocking by default; `background: true` returns at once. | | `agent_send` | Give a started agent more instructions in the same thread. | | `agent_status` | Read an agent's status, last message, changed files, and commands. | | `agent_cancel` | Stop an agent's current turn and its process. The thread stays on disk; the next `agent_send` resumes it in a fresh process. | -All tools except `list_agent_providers` take `provider` (`"codex"`). `agent_start` also takes optional +All tools except `list_agent_providers` take `provider` (`"codex"` or `"claude"`). `agent_start` also takes optional `model`, `effort`, and `cwd`. `cwd` must be inside the project root. A result has Paseo's shape: a status line, the agent ID and thread ID, the @@ -78,7 +81,7 @@ default mode, opens Maple's question card and the answer goes back in Codex's own shape; an asynchronous answer that arrives after the turn ended starts a follow-up turn on the same thread. -The child runs in the project root, on the user's login PATH, with the same +The child runs in the requested project directory, on the user's login PATH, with the same environment scrubbing as the shell tool, in its own process group or job so teardown reaches every descendant. It is killed when the runtime stops, on logout, and when its task is deleted. Threads are not ephemeral, so @@ -93,6 +96,51 @@ PATH or the user's shell configuration. The handshake reports the reserved client name `codex_app_server_daemon`, the same non-originating name Paseo uses. +## How Claude Code is driven + +Maple's native Rust transport is adapted from +[Goose's `ClaudeCodeProvider`](https://github.com/AnthonyRonning/goose/blob/785d655d110746147117d23690e09cc7023aa9dc/crates/goose/src/providers/claude_code.rs). +The control protocol types and permission exchange come from that implementation +of Claude's SDK protocol. The adapted source lives in `external_agents/claude.rs` +with its provenance. + +Goose keeps its transport private inside its provider. Maple adapts that code +so its existing host retains control of process launch, the working directory, +scoped environment, cancellation, and descendant cleanup. It also bounds +protocol lines, sanitizes errors, projects activity, and supplies question +answers through `updatedInput`. + +Install the Claude Code CLI and sign in with `claude auth login`. Detection +runs `claude --version` and `claude auth status --json`. The card shows the +CLI's sign-in status or login instructions; an unavailable, malformed, or +timed-out status check is reported as unknown. Maple reads only the sign-in +boolean, without retaining account details or reading credential files. +As with Codex, sign-in status does not gate enabling the integration. +The CLI is the only additional runtime dependency. + +Claude keeps its normal system prompt and configuration. Maple passes +`--permission-mode default` and `--permission-prompt-tool stdio`, never a +bypass-permissions flag. Claude's rules decide which actions need approval; +`can_use_tool` requests go to Maple's current permission mode. Allow all answers +those requests automatically, and Read only shows a one-shot permission card. +`AskUserQuestion` uses Maple's question card. Answers change only that call's +input; Maple never persists an allow rule in Claude's settings. + +Each turn starts a contained Claude CLI process using `--session-id` initially +and `--resume` thereafter. This allows optional `model` and `effort` launch +arguments to change on each turn. The process runs in the requested project +directory with the shell tool's scoped environment and process containment. +Stop, task deletion, logout, and runtime shutdown reclaim Claude and its +descendants. A subsequent `agent_send` resumes the saved session. You can also +use `claude --resume` from a terminal. + +Claude's text, Bash calls, successful Edit/Write/NotebookEdit calls, and +TodoWrite items feed the existing activity row. Other tools continue to run +under Claude's policy but do not yet have specialized activity summaries. +Command completion shows success or failure; tool-result messages do not +guarantee numeric exit codes. Protocol failures produce a generic error without +forwarding potentially sensitive exception text or CLI stderr. + ## Transcript The tool call's row streams the agent's text, its commands with exit @@ -123,7 +171,8 @@ approval therefore still needs `load` before it can finish; completion delivery alone cannot unblock it. Stopping an agent, from its row or with `agent_cancel`, sends -`turn/interrupt`, waits briefly for Codex to confirm, then kills the +`turn/interrupt` (translated to Claude’s native `interrupt` control request), waits +briefly for confirmation, then kills the process group. Codex does not always end a sandboxed command on interrupt, so the kill is what guarantees nothing keeps running. @@ -144,17 +193,18 @@ so the kill is what guarantees nothing keeps running. Register its stable ID, label, description, and Settings projection in `EXTERNAL_AGENT_INTEGRATIONS` in `agent/integrations.rs`, then add discovery to `IntegrationDetections` and a transport adapter under `agent/external_agents/`. -Settings defaults, composer rows, and task overrides use this catalog. +Settings gates, composer rows, and task choices use this catalog. No provider-specific composer branch or database migration is needed. Task overrides live in the versioned `maple_integrations` extension data, -keyed by provider ID. Missing entries mean inheritance, not disabled. CUA +keyed by provider ID. Missing entries mean disabled. A true entry grants access +only while Settings also enables that provider. CUA keeps its existing `maple_cua` backend metadata so an old external driver task cannot silently switch to the embedded backend. The selector carries a typed `kind` alongside `name` and `displayName`. MCP names and external provider IDs are separate domains; a custom MCP -server named `codex` cannot toggle the Codex provider. Older MCP requests +server named `codex` or `claude` cannot toggle either provider. Older MCP requests without `kind` continue to mean MCP. Provider transport adapters still own their protocol, discovery, progress, @@ -167,3 +217,10 @@ Keep listing filtered by that same selection when more adapters are added. run configuration and Goose tool cache with old persisted extension state, warm reuse, cold restore, explicit overrides, and a desktop task leased to ACP. Extend that regression with each adapter's admission behavior. + +The `claude_native_*` tests exercise the Rust transport against a deterministic +fake CLI, without network requests. Both providers' fixtures re-execute the +Rust test binary through CLI shims on a private search PATH. They cover +streamed activity, permissions, questions, resumption, +provider/session isolation, errors, and process-group cancellation. Live +inference and platform packaging remain separate checks. From 4cc8256ecf33b0c12e626dc04065cda26ed10768 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Sat, 19 Sep 2026 15:06:41 -0500 Subject: [PATCH 04/37] Extract the host seam behind the desktop UI The gpui UI drove the agent runtime through one concrete facade that also held the account: sign-in, billing, audio, and every task call went through the same struct, and the UI reached around it in four places to touch the host's filesystem (the folder picker's local path, the git branch and its watcher, the sessions database for the context ring, and the tool summary store). Remote development needs the task surface to stand on its own so a remote host can implement it. Introduce HostBackend in the runtime crate: everything a client drives on one host, with LocalHostBackend wrapping the in-process runtime. The account-level backend keeps sign-in, billing, and audio, and hands out the local host. Hosts push events through one fan-out hub so a server can project the same stream later. The four reach-arounds become host methods and events: directory suggestions, a host-side git watch that reports the branch, and SQLite readers for context usage, tool summaries, and the usage page. Session defaults (permission mode, web access, harness instructions) move from the app settings into the host's account config, since two hosts may differ, and older settings files are migrated once. Task and project state the settings keep is keyed by host id. Every wire-facing type now derives both serde halves, which freezes the vocabulary the protocol will carry. The design and the remaining steps are in docs/remote-development.md. Co-Authored-By: Claude Fable 5.1 --- apps/maple-agent/Cargo.lock | 6 +- apps/maple-agent/README.md | 26 +- apps/maple-agent/app/Cargo.toml | 4 +- apps/maple-agent/app/src/backend.rs | 995 ++---------------- apps/maple-agent/app/src/desktop.rs | 39 +- apps/maple-agent/app/src/main.rs | 27 +- apps/maple-agent/app/src/settings.rs | 379 +++---- apps/maple-agent/app/src/ui/chat/commands.rs | 8 +- apps/maple-agent/app/src/ui/chat/composer.rs | 6 +- apps/maple-agent/app/src/ui/chat/dialogs.rs | 23 +- apps/maple-agent/app/src/ui/chat/mod.rs | 544 ++++------ apps/maple-agent/app/src/ui/chat/queue.rs | 18 +- apps/maple-agent/app/src/ui/chat/sidebar.rs | 62 +- apps/maple-agent/app/src/ui/chat/summaries.rs | 35 +- apps/maple-agent/app/src/ui/chat/tests.rs | 267 ++--- apps/maple-agent/app/src/ui/login.rs | 6 +- apps/maple-agent/app/src/ui/settings.rs | 163 ++- .../app/src/ui/settings/navigation.rs | 30 +- .../maple-agent/crates/maple-agent/Cargo.toml | 7 + .../crates/maple-agent/src/agent.rs | 11 + .../maple-agent/src/agent/attachments.rs | 2 +- .../crates/maple-agent/src/agent/types.rs | 105 +- .../maple-agent/src/host/directories.rs | 140 +++ .../crates/maple-agent/src/host/git.rs | 343 ++++++ .../crates/maple-agent/src/host/local.rs | 813 ++++++++++++++ .../crates/maple-agent/src/host/mod.rs | 451 ++++++++ .../crates/maple-agent/src/host/store.rs | 332 ++++++ .../maple-agent/crates/maple-agent/src/lib.rs | 1 + apps/maple-agent/docs/remote-development.md | 549 ++++++++++ 29 files changed, 3557 insertions(+), 1835 deletions(-) create mode 100644 apps/maple-agent/crates/maple-agent/src/host/directories.rs create mode 100644 apps/maple-agent/crates/maple-agent/src/host/git.rs create mode 100644 apps/maple-agent/crates/maple-agent/src/host/local.rs create mode 100644 apps/maple-agent/crates/maple-agent/src/host/mod.rs create mode 100644 apps/maple-agent/crates/maple-agent/src/host/store.rs create mode 100644 apps/maple-agent/docs/remote-development.md diff --git a/apps/maple-agent/Cargo.lock b/apps/maple-agent/Cargo.lock index 6ca0b3d2e..1dec4bb04 100644 --- a/apps/maple-agent/Cargo.lock +++ b/apps/maple-agent/Cargo.lock @@ -5800,6 +5800,7 @@ dependencies = [ "chrono", "ciborium", "cua-driver-sdk", + "dirs 6.0.0", "futures-util", "goose", "goose-providers", @@ -5811,12 +5812,14 @@ dependencies = [ "log", "maple-proxy", "maple-sdk", + "notify", "once_cell", "process-wrap", "pulldown-cmark", "rand 0.8.7", "reqwest 0.13.4", "rmcp", + "rusqlite", "serde", "serde_json", "sha2 0.10.9", @@ -5841,6 +5844,7 @@ dependencies = [ name = "maple-gpui" version = "0.1.0" dependencies = [ + "async-trait", "axum", "base64 0.22.1", "chrono", @@ -5856,14 +5860,12 @@ dependencies = [ "maple-billing", "maple-proxy", "maple-sdk", - "notify", "parking_lot", "percent-encoding", "pulldown-cmark", "reqwest 0.13.4", "rodio", "rpassword", - "rusqlite", "semver", "serde", "serde_json", diff --git a/apps/maple-agent/README.md b/apps/maple-agent/README.md index 8121f334b..e2959af2e 100644 --- a/apps/maple-agent/README.md +++ b/apps/maple-agent/README.md @@ -27,13 +27,21 @@ scripts/ One maintainer helper: screenshot.py takes a desktop ### Backend / frontend boundary -`app/src/backend.rs` owns the runtime: it is the only file that drives -`maple_agent`'s services, holding a private Tokio runtime and exposing an -async facade (`AgentBackend`) plus one event stream. UI modules import data -types from `maple_agent` (timeline items, session summaries) but talk to the -running agent through that facade only. This mirrors Maple's own edge-adapter -pattern, so a future process split replaces the facade without touching UI -code. +The UI talks to two facades and never to the runtime directly. + +`app/src/backend.rs` (`AgentBackend`) holds the account: the private Tokio +runtime, sign-in and OAuth, billing, audio, and the in-process agent +service. Everything a client drives on a host (tasks, projects, runs, +permissions, integrations, session defaults) goes through the +`maple_agent::host::HostBackend` trait. `AgentBackend::local_host` hands out +the in-process implementation, `LocalHostBackend`, which wraps +`AgentRuntimeHandle` and owns the host-side pieces the UI must not reach +around it for: the git branch watch, directory suggestions, and the SQLite +readers for context usage, tool summaries, and the usage page. Hosts push +`HostEvent`s (runtime events plus branch reports) through one fan-out hub. +A remote host implements the same trait over the wire, so the UI never +branches on where a host runs. See +[`docs/remote-development.md`](docs/remote-development.md) for the plan. The runtime was originally copied from Research’s Tauri source (now `apps/maple-research/frontend/src-tauri/src`) (`agent.rs`, @@ -441,8 +449,8 @@ The roots follow the platform, the same way the Tauri app's | Path | Content | | --- | --- | -| `/settings.json` | App settings. | -| `/agent/accounts//config.json` | Per-account agent configuration (default root, model, custom MCP servers, project trust). May roam between machines. | +| `/settings.json` | Client-side app settings, plus per-host task and project state under `hosts`. | +| `/agent/accounts//config.json` | Per-account agent configuration (default root, model, custom MCP servers, project trust, session defaults: permission mode, web access, harness instructions). May roam between machines. | | `/agent/accounts//goose/config/` | Goose permission file for the account. | | `/agent/accounts//goose/config/skills/` | Skills the account's tasks can load, including the delegation skills Maple installs while any external agent is enabled. | | `/agent/goose-runtime/` | Goose process configuration. | diff --git a/apps/maple-agent/app/Cargo.toml b/apps/maple-agent/app/Cargo.toml index 84b93dd87..9243ca3b9 100644 --- a/apps/maple-agent/app/Cargo.toml +++ b/apps/maple-agent/app/Cargo.toml @@ -22,7 +22,6 @@ desktop = [ "dep:pulldown-cmark", "dep:cpal", "dep:rodio", - "dep:notify", "dep:spellbook", "dep:wayland-client", ] @@ -46,6 +45,7 @@ gpui-platform = { package = "gpui_platform", version = "0.1.0", git = "https://g image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"], optional = true } base64 = "0.22" tokio = { workspace = true } +async-trait = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } semver = "1" @@ -58,9 +58,7 @@ unicode-segmentation = { version = "1", optional = true } pulldown-cmark = { version = "0.13", default-features = false, optional = true } cpal = { version = "0.17", optional = true } rodio = { version = "0.22", default-features = false, features = ["playback", "wav"], optional = true } -notify = { version = "8", optional = true } spellbook = { version = "0.4", optional = true } -rusqlite = { version = "0.32", features = ["bundled"] } chrono = "0.4" clap = { version = "4", features = ["derive", "env"] } # Hidden password prompt for `maple-gpui login`. diff --git a/apps/maple-agent/app/src/backend.rs b/apps/maple-agent/app/src/backend.rs index e64ac58a0..b39754867 100644 --- a/apps/maple-agent/app/src/backend.rs +++ b/apps/maple-agent/app/src/backend.rs @@ -1,9 +1,11 @@ -//! Backend boundary for the gpui frontend. +//! Account-level backend for the gpui frontend. //! -//! This is the only module that imports `maple_agent`. It owns a private -//! Tokio runtime and exposes an async facade plus an event stream. The UI -//! never touches the agent runtime directly, so this seam can later be moved -//! behind a process or socket boundary without touching UI code. +//! This owns the private Tokio runtime, the OpenSecret sign-in, billing, +//! audio, and the in-process agent service. Everything a client drives on +//! a host (tasks, projects, runs, integrations) goes through +//! [`maple_agent::host::HostBackend`] instead; [`AgentBackend::local_host`] +//! hands out the in-process implementation. A remote host implements the +//! same trait over the wire, so the UI never branches on where a host runs. // This module is the desktop frontend's boundary. A headless build (no // `desktop` feature) uses only a few entry points, so the rest is unused @@ -18,21 +20,17 @@ use std::process::Command; use std::sync::Arc; use maple_agent::agent::{ - AgentCreateSessionRequest, AgentDesktopQueueSnapshot, AgentEventSink, AgentIntegration, - AgentIntegrationPermissionKind, AgentIntegrationPermissions, AgentProjectRootRegistration, - AgentProjectTrustStatus, AgentQueueControlRequest, AgentRenameSessionRequest, - AgentRuntimeStatus, AgentSendMessageRequest, AgentServiceEvent, AgentSessionDetail, - AgentSessionSummary, AgentSetIntegrationEnabledRequest, AgentSetupIntegrationRequest, - AgentSlashCommand, AgentStartRequest, AgentSubagent, MapleAgentHostResources, - MapleAgentService, RecentProjectRoot, + AgentIntegrationPermissionKind, AgentIntegrationPermissions, AgentSetupIntegrationRequest, + AgentStartRequest, MapleAgentHostResources, MapleAgentService, }; +use maple_agent::host::{HostEvent, HostEventHub, LocalHostAuth, LocalHostBackend}; use maple_agent::maple_api::{ MapleApiAuthEventSink, MapleApiAuthRequest, MapleApiAuthSnapshot, MapleApiAuthState, + MapleApiSession, }; use maple_agent::open_secret_config::configured_pcr0_environment; use maple_sdk::OpenSecretClient; use tokio::runtime::Runtime; -use tokio::sync::mpsc; use uuid::Uuid; #[derive(Debug, Clone)] @@ -80,17 +78,6 @@ pub enum RestoreOutcome { Unavailable, } -/// Everything the chat screen can show before any network call: the saved -/// project root, the task list, the recent roots, and the newest task's -/// transcript. Read in one backend call so it all lands before a runtime -/// start takes the lifecycle lock for its network round trips. -pub struct LocalBootstrap { - pub project_root: Option, - pub sessions: Vec, - pub recent_roots: Vec, - pub latest: Option, -} - pub struct AgentBackend { runtime: Runtime, service: MapleAgentService, @@ -99,17 +86,14 @@ pub struct AgentBackend { persisted_auth: Arc, pending_oauth: PendingOAuthStore, client_id: Uuid, - event_rx: tokio::sync::Mutex>>, + /// The local host's event stream. The runtime emits into it; every + /// subscriber receives every event. + events: Arc, + /// One in-process host per signed-in account, created on first use. + local_hosts: std::sync::Mutex>>, billing: crate::billing::BillingClient, /// Cached billing JWT per user id. Replaced after a 401. billing_tokens: tokio::sync::Mutex>, - /// Open handle to the usage ledger DB; the context ring polls it every - /// second during a run, so it is not reopened per query. Shared with - /// the blocking task that runs each query. - usage_db: Arc>>, - /// Open handle to the app-owned tool summary store; keyed by account - /// scope path so a user switch reopens it. - summary_db: std::sync::Mutex>, /// True while a background credential restore runs. Calls that need a /// validated session wait on it (see `session_for`); local reads do not. restore_pending: ( @@ -141,19 +125,6 @@ fn client_id_from(configured: Option<&str>) -> Uuid { } } -struct ChannelEventSink(mpsc::UnboundedSender); - -impl AgentEventSink for ChannelEventSink { - fn emit(&self, event: &AgentServiceEvent) { - // The channel is unbounded, so sends only fail after the UI dropped - // the receiver (window closed). The runtime tolerates missing - // notifications for that case; surface it once for diagnosis. - if self.0.send(event.clone()).is_err() { - log::debug!("agent event receiver is gone; dropping further events"); - } - } -} - pub(crate) const APP_DIR_NAME: &str = "maple-gpui"; /// Maple's public OpenSecret project id. The backend rejects unknown @@ -293,86 +264,11 @@ impl Drop for OAuthAttemptGuard<'_> { } } -impl AgentBackend { - /// The account scope (sha of the user id) used for on-disk layout. - pub fn account_scope(&self, user_id: &str) -> Option { - maple_agent::maple_api::account_scope(user_id).ok() - } -} - /// App configuration root (XDG-style), also used by the settings store. pub fn app_config_root() -> PathBuf { config_root() } -/// Path to the goose sessions database for one account scope. The agent -/// runtime owns and writes this file; the app only reads it. -pub fn account_session_db(account_scope: &str) -> PathBuf { - local_data_root() - .join("agent") - .join("accounts") - .join(account_scope) - .join("goose") - .join("data") - .join("sessions") - .join("sessions.db") -} - -/// Open the goose sessions database for reading. Returns `None` when the -/// file does not exist yet (read-only open never creates it). The busy -/// timeout covers the short locks goose takes for WAL checkpoints. -pub fn open_session_db_read_only(path: &std::path::Path) -> Option { - use rusqlite::OpenFlags; - let flags = OpenFlags::SQLITE_OPEN_READ_ONLY - | OpenFlags::SQLITE_OPEN_NO_MUTEX - | OpenFlags::SQLITE_OPEN_URI; - let conn = match rusqlite::Connection::open_with_flags(path, flags) { - Ok(conn) => conn, - Err(error) => { - if path.exists() { - log::warn!("Cannot open session db {}: {error}", path.display()); - } - return None; - } - }; - if let Err(error) = conn.busy_timeout(std::time::Duration::from_secs(5)) { - log::warn!("Cannot set busy timeout on {}: {error}", path.display()); - } - Some(conn) -} - -/// Path to the app-owned store of model-written tool call summaries for -/// one account scope. Lives next to the agent data so it is removed with -/// the account. -fn account_summary_db(account_scope: &str) -> PathBuf { - local_data_root() - .join("agent") - .join("accounts") - .join(account_scope) - .join("tool_summaries.db") -} - -/// Open (and create) the tool summary store. -fn open_summary_db(path: &std::path::Path) -> Result { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent) - .map_err(|error| format!("Cannot create {}: {error}", parent.display()))?; - } - let conn = rusqlite::Connection::open(path) - .map_err(|error| format!("Cannot open {}: {error}", path.display()))?; - conn.execute_batch( - "PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; \ - CREATE TABLE IF NOT EXISTS tool_summaries ( \ - session_id TEXT NOT NULL, \ - item_id TEXT NOT NULL, \ - summary TEXT NOT NULL, \ - PRIMARY KEY (session_id, item_id) \ - );", - ) - .map_err(|error| format!("Cannot init {}: {error}", path.display()))?; - Ok(conn) -} - /// Root for configuration that may roam between machines. Mirrors Tauri's /// `app_config_dir`: `~/.config` on Linux, `~/Library/Application Support` /// on macOS, `%APPDATA%` on Windows. `XDG_CONFIG_HOME` overrides it on @@ -401,30 +297,6 @@ fn env_dir(name: &str) -> Option { .filter(|path| path.is_absolute()) } -fn home_dir() -> Option { - std::env::var_os("HOME") - .map(PathBuf::from) - .filter(|path| path.is_absolute()) -} - -/// Root the desktop app opens when the account has no saved root. The GUI -/// must not depend on the directory it was launched from: that is the job of -/// the `maple acp` command, not a windowed app started from a launcher. -fn fallback_project_root() -> Option { - home_dir().map(|path| path.to_string_lossy().to_string()) -} - -/// Root for a GUI start: the saved default when it still is a folder, else -/// the home directory. Never the process working directory. -fn gui_project_root(config: &maple_agent::agent::AgentConfig) -> Option { - config - .default_project_root - .as_deref() - .filter(|path| !path.trim().is_empty() && std::path::Path::new(path).is_dir()) - .map(str::to_owned) - .or_else(fallback_project_root) -} - #[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] struct PersistedAuthRecord { user_id: String, @@ -624,6 +496,21 @@ impl PersistedAuthStore { } } +/// The local host's view of this backend's sign-in. Weak so the backend, +/// which owns the hosts, does not own itself through them. +struct BackendHostAuth(std::sync::Weak); + +#[async_trait::async_trait] +impl LocalHostAuth for BackendHostAuth { + async fn api_session(&self, user_id: &str) -> Result, String> { + let backend = self + .0 + .upgrade() + .ok_or_else(|| "The app is shutting down".to_string())?; + backend.session_for(user_id).await + } +} + struct PersistAuthSink { store: Arc, } @@ -643,20 +530,22 @@ impl MapleApiAuthEventSink for PersistAuthSink { } impl AgentBackend { - pub fn new(api_url: String, harness_instructions: String) -> Result { + pub fn new(api_url: String) -> Result { // Enforce the credential-bearing URL policy before any client is // built, including the login-time SDK client. let api_url = maple_agent::maple_api::validate_api_url(&api_url)?; - let (event_tx, event_rx) = mpsc::unbounded_channel(); + let events = Arc::new(HostEventHub::default()); let paths = maple_agent::agent::AgentPathLayout::from_app_roots(config_root(), local_data_root()); // Keeps ACP bridge credentials out of desktop tool environments. let default_tool_context = maple_agent::agent::default_tool_context_spec()?; + // The harness instructions are per account and reach the runtime + // through the local host once an account is bound. let service = MapleAgentService::new(MapleAgentHostResources::new( paths, - Arc::new(ChannelEventSink(event_tx)), + Arc::clone(&events) as Arc, default_tool_context, - harness_instructions, + maple_agent::host::DEFAULT_HARNESS_INSTRUCTIONS.to_string(), )); let runtime = Runtime::new().map_err(|error| format!("failed to start runtime: {error}"))?; @@ -675,20 +564,38 @@ impl AgentBackend { persisted_auth, pending_oauth: PendingOAuthStore::default(), client_id: configured_client_id(), - event_rx: tokio::sync::Mutex::new(Some(event_rx)), + events, + local_hosts: std::sync::Mutex::new(HashMap::new()), billing, billing_tokens: tokio::sync::Mutex::new(HashMap::new()), - usage_db: Arc::new(std::sync::Mutex::new(None)), - summary_db: std::sync::Mutex::new(None), restore_pending: tokio::sync::watch::channel(false), }) } - /// Replace the opening system prompt text for tasks this app hosts. - /// Applies to agents built after the call, so to a task's next fresh - /// agent, not to one already loaded. - pub fn set_harness_instructions(&self, harness_instructions: String) { - self.service.set_harness_instructions(harness_instructions); + /// A fresh subscription to the local host's events. The desktop event + /// pump takes one for the whole process. + pub fn subscribe_events(&self) -> tokio::sync::mpsc::UnboundedReceiver { + self.events.subscribe() + } + + /// The in-process host for `user_id`, created once per account. It + /// shares this backend's runtime and event stream. + pub fn local_host(self: &Arc, user_id: &str) -> Arc { + let mut hosts = self + .local_hosts + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(host) = hosts.get(user_id) { + return Arc::clone(host); + } + let host = LocalHostBackend::new( + self.service.clone(), + user_id.to_string(), + Arc::new(BackendHostAuth(Arc::downgrade(self))), + Arc::clone(&self.events), + ); + hosts.insert(user_id.to_string(), Arc::clone(&host)); + host } pub fn api_url(&self) -> &str { @@ -710,11 +617,6 @@ impl AgentBackend { self.runtime.spawn(future) } - /// Take the backend event stream. Only the first caller receives it. - pub async fn take_events(&self) -> Option> { - self.event_rx.lock().await.take() - } - fn normalize_email(email: &str) -> Result { let email = email.trim().to_ascii_lowercase(); if email.is_empty() { @@ -860,10 +762,7 @@ impl AgentBackend { /// The validated session for `user_id`, waiting first for a background /// credential restore that is still in flight. Local reads never call /// this; only backend requests that spend the credentials do. - async fn session_for( - &self, - user_id: &str, - ) -> Result, String> { + async fn session_for(&self, user_id: &str) -> Result, String> { self.wait_for_restore().await; self.auth.session_for(user_id).await } @@ -1123,7 +1022,7 @@ impl AgentBackend { return Err("Enter the confirmation code from the email".to_string()); } let session = self.session_for(user_id).await?; - self.stop_runtime(user_id).await?; + self.service.handle_for_user(user_id).await?.stop().await?; session .confirm_account_deletion(code, plaintext_secret) .await @@ -1291,7 +1190,7 @@ impl AgentBackend { async fn mint_billing_token( &self, - session: &Arc, + session: &Arc, user_id: &str, ) -> Result { let token = session @@ -1304,51 +1203,14 @@ impl AgentBackend { Ok(token) } - pub async fn start_runtime( - &self, - user_id: &str, - request: Option, - ) -> Result { - let handle = self.service.handle_for_user(user_id).await?; - let session = self.session_for(user_id).await?; - // The agent falls back to the process working directory when no root - // is given. That is right for `maple acp`, not for the GUI: pick the - // saved root or the home directory instead. - let request = match request { - Some(AgentStartRequest { - project_root: None, - model, - mode, - }) => { - let config = handle.load_config().await?; - Some(AgentStartRequest { - project_root: gui_project_root(&config), - model, - mode, - }) - } - other => other, - }; - // A wedged enclave connection must surface as an error, not an - // eternal spinner. - tokio::time::timeout( - std::time::Duration::from_secs(60), - handle.start(session, request), - ) - .await - .map_err(|_| "Runtime start timed out. Check your connection and retry.".to_string())? - } - - pub async fn stop_runtime(&self, user_id: &str) -> Result { - self.service.handle_for_user(user_id).await?.stop().await - } - /// Serve ACP on stdin/stdout for `user_id` until the peer closes stdin. /// Starts the runtime first, rooted at the process working directory, /// and stops it when the connection ends. #[cfg(feature = "acp")] - pub fn run_acp_stdio(&self, user_id: &str) -> Result<(), String> { + pub fn run_acp_stdio(self: &Arc, user_id: &str) -> Result<(), String> { + let host = self.local_host(user_id); self.runtime.block_on(async { + host.apply_saved_harness().await?; let handle = self.service.handle_for_user(user_id).await?; let session = self.session_for(user_id).await?; // Start the runtime concurrently instead of before the handshake: @@ -1367,306 +1229,6 @@ impl AgentBackend { }) } - /// Roots recently used by this account, most recent first. - pub async fn recent_project_roots( - &self, - user_id: &str, - ) -> Result, String> { - self.service - .handle_for_user(user_id) - .await? - .list_recent_project_roots() - .await - } - - /// Register and select the default root for new tasks. - /// - /// The account runtime is deliberately not restarted: existing tasks own - /// their persisted working directories and may keep running under other - /// roots while the UI moves between projects. - /// - /// Choosing a folder does not record a trust decision. Home and the - /// process launch directory are already trusted with no saved answer; - /// every other root keeps `None` until the one-time prompt. A saved - /// "do not trust" answer stays. - pub async fn select_project_root( - &self, - user_id: &str, - path: String, - ) -> Result { - self.service - .handle_for_user(user_id) - .await? - .save_recent_project_root(path) - .await - } - - pub async fn list_sessions( - &self, - user_id: &str, - project_root: Option, - ) -> Result, String> { - let mut sessions = self - .service - .handle_for_user(user_id) - .await? - .list_sessions(project_root) - .await?; - // Tasks that an ACP client created belong to that client's UI, not - // to the desktop task list. - sessions.retain(|session| !session.acp); - Ok(sessions) - } - - /// Read everything the chat screen can show without the network: the - /// saved project root, the task list, the recent roots, and the newest - /// task's transcript. Call it before `start_runtime`: the runtime start - /// holds the lifecycle lock across its network round trips, and these - /// reads would queue behind it. - pub async fn local_bootstrap(&self, user_id: &str) -> Result { - let handle = self.service.handle_for_user(user_id).await?; - let config = handle.load_config().await?; - let project_root = gui_project_root(&config); - let mut sessions = handle.list_sessions(None).await?; - // Tasks that an ACP client created belong to that client's UI, not - // to the desktop task list. - sessions.retain(|session| !session.acp); - let recent_roots = handle - .list_recent_project_roots() - .await? - .into_iter() - .map(|root| root.path) - .collect(); - // Same choice refresh_sessions makes: the newest unarchived task - // under the root that the runtime will start in. - let latest_id = sessions - .iter() - .find(|session| { - !session.archived && Some(&session.project_root) == project_root.as_ref() - }) - .map(|session| session.id.clone()); - let latest = match latest_id { - Some(id) => handle.load_session(id).await.ok(), - None => None, - }; - Ok(LocalBootstrap { - project_root, - sessions, - recent_roots, - latest, - }) - } - - pub async fn create_session( - &self, - user_id: &str, - request: Option, - ) -> Result { - self.service - .handle_for_user(user_id) - .await? - .create_session(request) - .await - } - - pub async fn load_session( - &self, - user_id: &str, - session_id: &str, - ) -> Result { - self.service - .handle_for_user(user_id) - .await? - .load_session(session_id.to_string()) - .await - } - - pub async fn set_session_archived( - &self, - user_id: &str, - session_id: &str, - archived: bool, - ) -> Result { - self.service - .handle_for_user(user_id) - .await? - .set_session_archived(session_id.to_string(), archived) - .await - } - - /// Drop a root from the recent list. `fallback` becomes the runtime - /// root when the removed one was current. - pub async fn remove_project_root( - &self, - user_id: &str, - path: String, - fallback: Option, - ) -> Result<(), String> { - self.service - .handle_for_user(user_id) - .await? - .remove_project_root(path, fallback) - .await - .map(|_| ()) - } - - pub async fn rename_session( - &self, - user_id: &str, - session_id: &str, - title: String, - ) -> Result { - let handle = self.service.handle_for_user(user_id).await?; - let session = self.session_for(user_id).await?; - handle - .rename_session( - session, - AgentRenameSessionRequest { - session_id: session_id.to_string(), - title, - }, - ) - .await - } - - /// Whether the project at `path` has skills or other guidance that - /// need a trust decision, and what the saved decision is. - pub async fn project_trust( - &self, - user_id: &str, - path: String, - ) -> Result { - self.service - .handle_for_user(user_id) - .await? - .get_project_trust(path) - .await - } - - pub async fn set_project_trust( - &self, - user_id: &str, - path: String, - trusted: bool, - ) -> Result { - self.service - .handle_for_user(user_id) - .await? - .set_project_trust(path, trusted) - .await - } - - /// Drop a message that waits behind the active run. - pub async fn cancel_queued_message( - &self, - user_id: &str, - session_id: &str, - queue_id: &str, - ) -> Result { - self.service - .handle_for_user(user_id) - .await? - .cancel_queued_message(AgentQueueControlRequest { - session_id: session_id.to_string(), - queue_id: queue_id.to_string(), - }) - .await - } - - /// Hold a queued message while the user edits it: it is not promoted - /// into the run until the edit ends. - pub async fn begin_queued_message_edit( - &self, - user_id: &str, - session_id: &str, - queue_id: &str, - ) -> Result<(), String> { - self.service - .handle_for_user(user_id) - .await? - .begin_queued_message_edit(AgentQueueControlRequest { - session_id: session_id.to_string(), - queue_id: queue_id.to_string(), - }) - .await - } - - /// Release a queued message held by [`Self::begin_queued_message_edit`] - /// without changing it. - pub async fn end_queued_message_edit( - &self, - user_id: &str, - session_id: &str, - queue_id: &str, - ) -> Result<(), String> { - self.service - .handle_for_user(user_id) - .await? - .end_queued_message_edit(AgentQueueControlRequest { - session_id: session_id.to_string(), - queue_id: queue_id.to_string(), - }) - .await - } - - pub async fn send_message( - &self, - user_id: &str, - request: AgentSendMessageRequest, - ) -> Result { - let run_id = self - .service - .handle_for_user(user_id) - .await? - .send_message(request) - .await? - .run_id; - Ok(run_id) - } - - /// Bytes of an image the user attached to a message in `session_id`. - pub async fn read_image_attachment( - &self, - user_id: &str, - session_id: &str, - attachment_id: &str, - ) -> Result, String> { - self.service - .handle_for_user(user_id) - .await? - .read_image_attachment(session_id.to_string(), attachment_id.to_string()) - .await - } - - pub async fn cancel_run(&self, user_id: &str, run_id: &str) -> Result<(), String> { - self.service - .handle_for_user(user_id) - .await? - .cancel_desktop_run(run_id.to_string()) - .await - } - - pub async fn available_model_ids(&self, user_id: &str) -> Result, String> { - self.service - .handle_for_user(user_id) - .await? - .available_model_ids() - .await - } - - /// Catalog vision flag for a model; None when unknown. - pub async fn model_supports_vision( - &self, - user_id: &str, - model: &str, - ) -> Result, String> { - self.service - .handle_for_user(user_id) - .await? - .model_supports_vision(model) - .await - } - /// Which voice endpoints the account offers. pub async fn audio_capabilities( &self, @@ -1703,73 +1265,16 @@ impl AgentBackend { .await } - /// MCP servers configured for the account, with the session's enabled - /// state for each. - pub async fn list_session_mcp_servers( - &self, - user_id: &str, - session_id: &str, - ) -> Result, String> { - self.service - .handle_for_user(user_id) - .await? - .list_session_mcp_servers(session_id.to_string()) - .await - } - - pub async fn set_session_mcp_server_enabled( - &self, - user_id: &str, - session_id: &str, - name: &str, - kind: maple_agent::agent::AgentSessionIntegrationKind, - enabled: bool, - ) -> Result, String> { - self.service - .handle_for_user(user_id) - .await? - .set_session_mcp_server_enabled(maple_agent::agent::AgentSetSessionMcpServerRequest { - session_id: session_id.to_string(), - name: name.to_string(), - kind, - enabled, - }) - .await - } - - pub async fn list_mcp_servers( - &self, - user_id: &str, - ) -> Result, String> { - self.service - .handle_for_user(user_id) - .await? - .list_mcp_servers() - .await - } - - pub async fn list_integrations(&self, user_id: &str) -> Result, String> { - self.service - .handle_for_user(user_id) - .await? - .list_integrations() - .await - } - - pub async fn set_integration_enabled( + /// Open the settings pane that grants the permission a curated + /// integration still needs, after [`Self::begin_integration_setup`] + /// ran. A local capability: it opens a pane on this machine's screen, + /// so it applies to the local host only. Persist the integration with + /// `HostBackend::setup_integration` afterwards. + pub async fn open_integration_setup_settings( &self, - user_id: &str, - id: &str, - enabled: bool, - ) -> Result, String> { - self.service - .handle_for_user(user_id) - .await? - .set_integration_enabled(AgentSetIntegrationEnabledRequest { - id: id.to_string(), - enabled, - }) - .await + permissions: &AgentIntegrationPermissions, + ) -> Result<(), String> { + open_integration_setup_settings(permissions).await } /// Start a curated integration's host-owned permission flow from the UI @@ -1783,335 +1288,6 @@ impl AgentBackend { }) } - /// Persist a curated integration after its host-owned permission flow. - pub async fn setup_integration( - &self, - user_id: &str, - id: &str, - permissions: AgentIntegrationPermissions, - ) -> Result, String> { - open_integration_setup_settings(&permissions).await?; - self.service - .handle_for_user(user_id) - .await? - .setup_integration(AgentSetupIntegrationRequest { id: id.to_string() }) - .await - } - - pub async fn save_mcp_servers( - &self, - user_id: &str, - servers: Vec, - ) -> Result, String> { - self.service - .handle_for_user(user_id) - .await? - .save_mcp_servers(servers) - .await - } - - /// Turn the web tools on or off for a session (next turn onward). - pub async fn set_session_web_enabled( - &self, - user_id: &str, - session_id: &str, - enabled: bool, - ) -> Result { - self.service - .handle_for_user(user_id) - .await? - .set_session_web_enabled(maple_agent::agent::AgentSetSessionWebRequest { - session_id: session_id.to_string(), - enabled, - }) - .await - } - - /// Set the permission policy for a session: "smart_approve" asks for - /// each gated tool, "auto" approves everything (bypass). - pub async fn set_permission_mode( - &self, - user_id: &str, - session_id: &str, - mode: &str, - ) -> Result<(), String> { - self.service - .handle_for_user(user_id) - .await? - .set_permission_mode(maple_agent::agent::AgentPermissionModeRequest { - session_id: session_id.to_string(), - mode: mode.to_string(), - }) - .await - } - - /// Compact a session's history now; reload the session afterwards. - pub async fn compact_session(&self, user_id: &str, session_id: &str) -> Result<(), String> { - self.service - .handle_for_user(user_id) - .await? - .compact_session(session_id.to_string()) - .await - } - - /// The subagents still working for a task. A task whose run ended can - /// still have a background subagent; this rebuilds the card for it. - pub async fn session_subagents( - &self, - user_id: &str, - session_id: &str, - ) -> Result, String> { - Ok(self - .service - .handle_for_user(user_id) - .await? - .session_subagents(session_id) - .await) - } - - /// Interrupt an external agent (Codex) from its row. The agent keeps - /// its thread so the task can continue it later. - pub async fn cancel_external_agent( - &self, - user_id: &str, - session_id: &str, - agent_id: &str, - ) -> Result<(), String> { - self.service - .handle_for_user(user_id) - .await? - .cancel_external_agent(session_id, agent_id) - .await - } - - /// Slash commands (installed skills) for a working directory. Filesystem - /// scan, so it runs on a blocking thread. - pub async fn list_slash_commands( - &self, - user_id: &str, - working_dir: Option, - ) -> Result, String> { - let service = self.service.clone(); - let user_id = user_id.to_string(); - tokio::task::spawn_blocking(move || { - service.list_slash_commands(Some(&user_id), working_dir.as_deref()) - }) - .await - .map_err(|error| format!("Slash command scan failed: {error}")) - } - - /// Expand `/command args` into the skill prompt; `None` when the command - /// matches no skill. - pub async fn resolve_slash_command( - &self, - working_dir: Option, - command: String, - args: String, - ) -> Result, String> { - let service = self.service.clone(); - tokio::task::spawn_blocking(move || { - service.resolve_slash_command(working_dir.as_deref(), &command, &args) - }) - .await - .map_err(|error| format!("Slash command resolve failed: {error}"))? - } - - /// One-line summary of a completed tool call from the cheap title model. - pub async fn summarize_tool_call( - &self, - user_id: &str, - session_id: &str, - tool_name: String, - input: Option, - output_text: String, - ) -> Result, String> { - self.service - .handle_for_user(user_id) - .await? - .summarize_tool_call(session_id, &tool_name, input.as_ref(), &output_text) - .await - } - - /// One-line summary of a finished thinking block from the cheap title - /// model. - pub async fn summarize_thinking( - &self, - user_id: &str, - session_id: &str, - thinking_text: String, - ) -> Result, String> { - self.service - .handle_for_user(user_id) - .await? - .summarize_thinking(session_id, &thinking_text) - .await - } - /// Stream the answer to a `/btw` side question; see - /// `AgentRuntimeHandle::ask_side_question`. - pub async fn ask_side_question( - &self, - user_id: &str, - session_id: &str, - request_id: String, - prior: Vec, - question: String, - ) -> Result<(), String> { - self.service - .handle_for_user(user_id) - .await? - .ask_side_question(session_id, request_id, prior, question) - .await - } - /// Run `f` against the summary store of `user_id`. Blocking: call from - /// `spawn_blocking`. - fn with_summary_db( - &self, - user_id: &str, - f: impl FnOnce(&rusqlite::Connection) -> Result, - ) -> Result { - let scope = self - .account_scope(user_id) - .ok_or_else(|| "No account scope".to_string())?; - let db = account_summary_db(&scope); - let mut guard = self.summary_db.lock().unwrap_or_else(|e| e.into_inner()); - if guard.as_ref().map(|(path, _)| path != &db).unwrap_or(true) { - *guard = Some((db.clone(), open_summary_db(&db)?)); - } - f(&guard.as_ref().expect("summary db opened above").1) - } - - /// Stored summaries for one session, keyed by timeline item id. - /// Blocking. - pub fn load_tool_summaries_blocking( - &self, - user_id: &str, - session_id: &str, - ) -> Result, String> { - self.with_summary_db(user_id, |conn| { - let mut stmt = conn - .prepare("SELECT item_id, summary FROM tool_summaries WHERE session_id = ?1") - .map_err(|error| error.to_string())?; - let rows = stmt - .query_map([session_id], |row| Ok((row.get(0)?, row.get(1)?))) - .map_err(|error| error.to_string())?; - rows.collect::, _>>() - .map_err(|error| error.to_string()) - }) - } - - /// Persist one summary. Blocking. - pub fn store_tool_summary_blocking( - &self, - user_id: &str, - session_id: &str, - item_id: &str, - summary: &str, - ) -> Result<(), String> { - self.with_summary_db(user_id, |conn| { - conn.execute( - "INSERT OR REPLACE INTO tool_summaries (session_id, item_id, summary) \ - VALUES (?1, ?2, ?3)", - [session_id, item_id, summary], - ) - .map(|_| ()) - .map_err(|error| error.to_string()) - }) - } - - /// Latest context usage for a session from the goose usage ledger: - /// (context tokens, context limit). The limit comes from the model - /// catalog for the selected model; MAPLE_CONTEXT_LIMIT is a manual - /// override; 200k is the fallback when the catalog lacks the model. - pub async fn context_usage( - &self, - user_id: &str, - session_id: &str, - model: Option<&str>, - ) -> Result, String> { - let Some(scope) = self.account_scope(user_id) else { - return Ok(None); - }; - let limit: i64 = match std::env::var("MAPLE_CONTEXT_LIMIT") - .ok() - .and_then(|value| value.parse().ok()) - { - Some(limit) if limit > 0 => limit, - _ => match model { - Some(model) => self - .service - .handle_for_user(user_id) - .await? - .context_limit_for_model(model) - .await? - .unwrap_or(200_000), - None => 200_000, - }, - }; - // SQLite is synchronous; keep it off the async workers. - let db = crate::backend::account_session_db(&scope); - let usage_db = self.usage_db.clone(); - let session_id = session_id.to_string(); - let tokens = tokio::task::spawn_blocking(move || { - let mut guard = usage_db.lock().unwrap_or_else(|e| e.into_inner()); - if guard.as_ref().map(|(path, _)| path != &db).unwrap_or(true) { - let conn = crate::backend::open_session_db_read_only(&db)?; - *guard = Some((db, conn)); - } - let conn = &guard.as_ref().expect("usage db opened above").1; - conn.query_row( - "SELECT COALESCE(input_tokens,0) + COALESCE(cache_read_tokens,0) \ - + COALESCE(cache_write_tokens,0) FROM usage_ledger \ - WHERE session_id = ?1 AND is_compaction = 0 \ - ORDER BY id DESC LIMIT 1", - [session_id], - |row| row.get::<_, i64>(0), - ) - .ok() - }) - .await - .map_err(|error| format!("Context usage query failed: {error}"))?; - Ok(tokens.map(|tokens| (tokens, limit))) - } - - /// Deliver the user's answer to an ask_user question. Returns false - /// when no question was pending. - pub async fn answer_question( - &self, - user_id: &str, - request_id: &str, - answer: String, - ) -> Result { - let _service = self.service.clone(); - let request_id = request_id.to_string(); - self.service - .handle_for_user(user_id) - .await? - .answer_question_via_handle(&request_id, answer) - .await - } - - pub async fn permission_respond( - &self, - user_id: &str, - session_id: &str, - request_id: &str, - allow: bool, - ) -> Result<(), String> { - self.service - .handle_for_user(user_id) - .await? - .permission_respond(maple_agent::agent::AgentPermissionResponse { - session_id: session_id.to_string(), - request_id: request_id.to_string(), - decision: if allow { - "allow_once".to_string() - } else { - "deny_once".to_string() - }, - }) - .await - } - /// Standard start request for this app: the saved project root (see /// `start_runtime`) with the configured model and the SmartApprove policy. pub fn default_start_request(&self) -> AgentStartRequest { @@ -2122,25 +1298,10 @@ impl AgentBackend { } } - /// Persist the UI's model choice as the account's default model. - pub async fn save_default_model(&self, user_id: &str, model: String) -> Result<(), String> { - let handle = self.service.handle_for_user(user_id).await?; - let mut config = handle.load_config().await?; - config.default_model = model; - handle.save_config(config).await - } - /// Model the UI should select initially: MAPLE_MODEL when set. pub fn configured_model(&self) -> Option { std::env::var("MAPLE_MODEL").ok() } - - /// The account's saved default model, if any. - pub async fn saved_model(&self, user_id: &str) -> Option { - let handle = self.service.handle_for_user(user_id).await.ok()?; - let config = handle.load_config().await.ok()?; - Some(config.default_model).filter(|model| !model.is_empty()) - } } const MACOS_ACCESSIBILITY_SETTINGS_URL: &str = diff --git a/apps/maple-agent/app/src/desktop.rs b/apps/maple-agent/app/src/desktop.rs index 1dea7a241..68db15271 100644 --- a/apps/maple-agent/app/src/desktop.rs +++ b/apps/maple-agent/app/src/desktop.rs @@ -48,13 +48,13 @@ struct MapleApp { impl MapleApp { /// Forward a batch of backend service events to the chat screen when /// one exists. One batch is one render, however many events arrived. - fn handle_service_events( + fn handle_host_events( &mut self, - events: Vec, + events: Vec, cx: &mut Context, ) { if let Screen::Chat(chat) = &self.screen { - chat.update(cx, |chat, cx| chat.handle_service_events(events, cx)); + chat.update(cx, |chat, cx| chat.handle_host_events(events, cx)); } } @@ -115,7 +115,8 @@ impl MapleApp { crate::startup_elapsed() ); let backend = self.backend.clone(); - let chat = cx.new(|cx| ChatScreen::new(backend, user_id.clone(), cx)); + let host = backend.local_host(&user_id); + let chat = cx.new(|cx| ChatScreen::new(backend, host, user_id.clone(), cx)); // The release check may have finished while the login screen was // up; the banner must not be lost with it. if let Some(info) = crate::update::available() { @@ -135,10 +136,19 @@ impl MapleApp { }; let backend = self.backend.clone(); let user_id = self.user_id.clone().unwrap_or_default(); + let host = backend.local_host(&user_id); let settings = self.settings.clone(); let shortcut_snapshot = self.shortcuts.snapshot(); let screen = cx.new(|cx| { - SettingsScreen::new(backend, user_id, settings, shortcut_snapshot, section, cx) + SettingsScreen::new( + backend, + host, + user_id, + settings, + shortcut_snapshot, + section, + cx, + ) }); cx.subscribe( &screen, @@ -375,13 +385,14 @@ pub fn run() { crate::startup_elapsed() ); let backend = Arc::new( - AgentBackend::new( - crate::configured_api_url(), - startup_settings.effective_harness_instructions(), - ) - .expect("failed to initialize agent backend"), + AgentBackend::new(crate::configured_api_url()).expect("failed to initialize agent backend"), ); log::debug!("startup: backend ready at {} ms", crate::startup_elapsed()); + // Session defaults an older version kept in settings.json belong to the + // account config now. Move them before the chat screen reads them. + if let Some(user_id) = backend.saved_user_id() { + crate::adopt_legacy_session_defaults(&backend, &user_id); + } gpui_platform::application() .with_assets(crate::assets::Assets) @@ -547,12 +558,8 @@ pub fn run() { // The event pump runs once for the whole process and routes events to // whichever screen is active. It exits when the root entity is gone. - let (spawn_backend, take_backend) = (backend.clone(), backend.clone()); - let rx = spawn_backend.spawn(async move { take_backend.take_events().await }); + let mut rx = backend.subscribe_events(); cx.spawn(async move |cx| { - let Some(mut rx) = rx.await.ok().flatten() else { - return; - }; while let Some(event) = rx.recv().await { // Drain whatever else is queued so a burst of streaming // chunks costs one update and one render, not one each. @@ -565,7 +572,7 @@ pub fn run() { } // This gpui's entity update is infallible; the pump ends // with the channel instead. - root.update(cx, |app, cx| app.handle_service_events(batch, cx)); + root.update(cx, |app, cx| app.handle_host_events(batch, cx)); } }) .detach(); diff --git a/apps/maple-agent/app/src/main.rs b/apps/maple-agent/app/src/main.rs index 7f490d468..b38bb0f05 100644 --- a/apps/maple-agent/app/src/main.rs +++ b/apps/maple-agent/app/src/main.rs @@ -281,19 +281,40 @@ fn configured_api_url() -> String { /// the desktop app does not need to run. #[cfg(feature = "acp")] fn run_acp() -> Result<(), String> { - let harness_instructions = settings::load_settings().effective_harness_instructions(); - let backend = AgentBackend::new(configured_api_url(), harness_instructions)?; + let backend = std::sync::Arc::new(AgentBackend::new(configured_api_url())?); let user_id = backend.restore_now().ok_or_else(|| { "No saved Maple sign-in. Open the desktop app and sign in first.".to_string() })?; + adopt_legacy_session_defaults(&backend, &user_id); backend.run_acp_stdio(&user_id) } +/// Session defaults an older version kept in settings.json move into the +/// account config the first time an account is bound. Both the window and +/// the `acp` command bind an account, so both run this; the host ignores +/// values the config already holds, and the next settings save drops the +/// old keys. +#[cfg(any(feature = "desktop", feature = "acp"))] +pub(crate) fn adopt_legacy_session_defaults(backend: &std::sync::Arc, user_id: &str) { + let legacy = settings::load_settings().legacy_session_defaults(); + if legacy.is_empty() { + return; + } + let host = backend.local_host(user_id); + match backend + .runtime_handle() + .block_on(host.migrate_session_defaults(legacy)) + { + Ok(()) => settings::update_settings_in_background(|_| {}), + Err(error) => log::warn!("session defaults were not migrated: {error}"), + } +} + /// `maple-gpui login`: sign in from a terminal. The saved session is the /// same one the desktop app writes, so `maple-gpui acp` can run on a /// machine that never opened the window. fn run_login(args: LoginArgs) -> Result<(), String> { - let backend = AgentBackend::new(configured_api_url(), String::new())?; + let backend = AgentBackend::new(configured_api_url())?; if let Some(user_id) = backend.saved_user_id() { eprintln!("A Maple sign-in is already saved; signing in again replaces it."); log::info!("login replaces the saved session for account {user_id}"); diff --git a/apps/maple-agent/app/src/settings.rs b/apps/maple-agent/app/src/settings.rs index 5728911ee..972b777ea 100644 --- a/apps/maple-agent/app/src/settings.rs +++ b/apps/maple-agent/app/src/settings.rs @@ -1,5 +1,10 @@ -//! App settings persisted to ~/.config/maple-gpui/settings.json and local -//! usage aggregation read from the goose usage ledger. +//! App settings persisted to ~/.config/maple-gpui/settings.json. +//! +//! These are client-side: how this window looks and behaves. Defaults a +//! host applies to new tasks (permission mode, web access, harness +//! instructions) live in the host's account config and are edited through +//! `HostBackend::session_defaults`. State about a host's tasks and +//! projects is kept here per host, keyed by host id. // This module is the desktop frontend's boundary. A headless build (no // `desktop` feature) uses only a few entry points, so the rest is unused @@ -8,17 +13,39 @@ use std::path::PathBuf; +/// Client-side state about one host's tasks and projects. Task ids and +/// project paths only mean something on the host they came from, so two +/// hosts never share an entry. +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct HostUiState { + /// Sidebar task ids the user pinned, in pin order. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub pinned_tasks: Vec, + /// Sidebar task ids the user settled away from the active section. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub settled_tasks: Vec, + /// Sidebar task ids the user moved back into the active section. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub unsettled_tasks: Vec, + /// Display names for project roots, keyed by absolute path on the host. + #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] + pub project_names: std::collections::BTreeMap, +} + +impl HostUiState { + fn is_empty(&self) -> bool { + self.pinned_tasks.is_empty() + && self.settled_tasks.is_empty() + && self.unsettled_tasks.is_empty() + && self.project_names.is_empty() + } +} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct AppSettings { - /// Default permission policy for new sessions; see [`PermissionMode`]. - #[serde(default)] - pub default_permission_mode: PermissionMode, /// Whether tool cards show input/output payloads by default. #[serde(default = "default_tool_details")] pub tool_details: bool, - /// Whether new tasks can use the web tools. - #[serde(default = "default_web_enabled")] - pub default_web_enabled: bool, /// Whether completed tool calls get a one-line model summary. #[serde(default = "default_tool_summaries")] pub tool_summaries: bool, @@ -33,20 +60,10 @@ pub struct AppSettings { /// disables that exact slot. Missing entries retain their shipped key. #[serde(default)] pub shortcut_overrides: std::collections::BTreeMap>, - #[serde(default)] - pub pinned_roots: Vec, - /// Sidebar task ids the user pinned, in pin order. - #[serde(default)] - pub pinned_tasks: Vec, - /// Sidebar task ids the user settled away from the active section. - #[serde(default)] - pub settled_tasks: Vec, - /// Sidebar task ids the user moved back into the active section. - #[serde(default)] - pub unsettled_tasks: Vec, - /// Display names for project roots, keyed by absolute path. - #[serde(default)] - pub project_names: std::collections::HashMap, + /// Per-host task and project state, keyed by host id. The local host + /// is [`maple_agent::host::HostId::LOCAL`]. + #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] + pub hosts: std::collections::BTreeMap, /// Whether run completion, permissions, and questions raise desktop /// notifications while the window is not focused. #[serde(default = "default_desktop_notifications")] @@ -55,10 +72,6 @@ pub struct AppSettings { /// this, so it is a Maple setting. #[serde(default)] pub reduce_motion: bool, - /// Opening system prompt text for agents this app hosts. Empty means - /// [`DEFAULT_HARNESS_INSTRUCTIONS`]. - #[serde(default)] - pub harness_instructions: String, /// Window size and state from the last run. #[serde(default)] pub window: Option, @@ -78,6 +91,35 @@ pub struct AppSettings { /// Text-to-speech speed multiplier; see [`TTS_SPEEDS`]. #[serde(default = "default_tts_speed")] pub tts_speed: f32, + + /// Fields older versions wrote at the top level. Read once and never + /// written back: `load_settings` moves the task and project state under + /// the local host, and the local host adopts the session defaults into + /// its account config (see [`Self::legacy_session_defaults`]). The next + /// save drops them. + #[doc(hidden)] + #[serde(flatten, default, skip_serializing)] + pub legacy: LegacyTopLevelSettings, +} + +/// See [`AppSettings::legacy`]. +#[doc(hidden)] +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] +pub struct LegacyTopLevelSettings { + #[serde(default, rename = "default_permission_mode")] + pub permission_mode: Option, + #[serde(default, rename = "default_web_enabled")] + pub web_enabled: Option, + #[serde(default)] + pub harness_instructions: Option, + #[serde(default)] + pub pinned_tasks: Vec, + #[serde(default)] + pub settled_tasks: Vec, + #[serde(default)] + pub unsettled_tasks: Vec, + #[serde(default)] + pub project_names: std::collections::BTreeMap, } /// Voxtral voice ids with their labels, in the order the settings menu @@ -230,21 +272,49 @@ impl WindowState { } } -/// Opening system prompt for agents this app hosts: the agent is Maple. -/// The runtime appends its tool and runtime guidance after this text. -pub const DEFAULT_HARNESS_INSTRUCTIONS: &str = - "You are a general-purpose AI agent called Maple, created by Maple AI. -You run in the Maple app's Agent Mode; users know you simply as Maple."; +/// Opening system prompt for agents a host runs. Kept with the host +/// vocabulary; re-exported here for the settings screen, which a headless +/// build does not have. +#[cfg_attr(not(feature = "desktop"), allow(unused_imports))] +pub use maple_agent::host::DEFAULT_HARNESS_INSTRUCTIONS; impl AppSettings { - /// The harness instructions to hand the runtime: the saved text, or the - /// default when nothing is saved. - pub fn effective_harness_instructions(&self) -> String { - let saved = self.harness_instructions.trim(); - if saved.is_empty() { - DEFAULT_HARNESS_INSTRUCTIONS.to_string() - } else { - saved.to_string() + /// Client-side state for one host, empty when none is saved. + pub fn host_state(&self, host: &maple_agent::host::HostId) -> HostUiState { + self.hosts.get(host.as_str()).cloned().unwrap_or_default() + } + + /// Mutable client-side state for one host, created on first use. + pub fn host_state_mut(&mut self, host: &maple_agent::host::HostId) -> &mut HostUiState { + self.hosts.entry(host.as_str().to_string()).or_default() + } + + /// Session defaults an older version saved here. The local host adopts + /// them into its account config once; see + /// [`maple_agent::host::LocalHostBackend::migrate_session_defaults`]. + pub fn legacy_session_defaults(&self) -> maple_agent::host::LegacySessionDefaults { + maple_agent::host::LegacySessionDefaults { + permission_mode: self.legacy.permission_mode.clone(), + web_enabled: self.legacy.web_enabled, + harness_instructions: self.legacy.harness_instructions.clone(), + } + } + + /// Move task and project state an older version kept at the top level + /// under the local host. Values already under the local host win. + fn adopt_legacy_host_state(&mut self) { + let legacy = HostUiState { + pinned_tasks: std::mem::take(&mut self.legacy.pinned_tasks), + settled_tasks: std::mem::take(&mut self.legacy.settled_tasks), + unsettled_tasks: std::mem::take(&mut self.legacy.unsettled_tasks), + project_names: std::mem::take(&mut self.legacy.project_names), + }; + if legacy.is_empty() { + return; + } + let local = self.host_state_mut(&maple_agent::host::HostId::local()); + if local.is_empty() { + *local = legacy; } } } @@ -261,10 +331,6 @@ fn default_chat_font_size() -> u8 { 14 } -fn default_web_enabled() -> bool { - true -} - fn default_tool_details() -> bool { false } @@ -280,27 +346,21 @@ fn default_tool_summaries() -> bool { impl Default for AppSettings { fn default() -> Self { Self { - default_permission_mode: PermissionMode::default(), tool_details: default_tool_details(), - default_web_enabled: default_web_enabled(), tool_summaries: default_tool_summaries(), composer_vim_enabled: false, application_vim_enabled: false, shortcut_overrides: std::collections::BTreeMap::new(), - pinned_roots: Vec::new(), - pinned_tasks: Vec::new(), - settled_tasks: Vec::new(), - unsettled_tasks: Vec::new(), - project_names: std::collections::HashMap::new(), + hosts: std::collections::BTreeMap::new(), desktop_notifications: default_desktop_notifications(), reduce_motion: false, - harness_instructions: String::new(), window: None, theme: default_theme(), chat_font_family: default_chat_font_family(), chat_font_size: default_chat_font_size(), tts_voice: default_tts_voice(), tts_speed: default_tts_speed(), + legacy: LegacyTopLevelSettings::default(), } } } @@ -350,13 +410,15 @@ pub fn load_settings() -> AppSettings { return AppSettings::default(); } }; - serde_json::from_str(&text).unwrap_or_else(|error| { + let mut settings: AppSettings = serde_json::from_str(&text).unwrap_or_else(|error| { log::warn!( "Settings at {} are not valid; using defaults: {error}", path.display() ); AppSettings::default() - }) + }); + settings.adopt_legacy_host_state(); + settings } /// Serializes tests that swap `XDG_CONFIG_HOME` process-wide: while a swap @@ -446,159 +508,10 @@ pub fn update_settings_and_wait(update: impl FnOnce(&mut AppSettings) + Send + ' let _ = rx.recv(); } -/// One aggregated usage row: per session or per model. -#[derive(Debug, Clone, Default)] -pub struct UsageRow { - pub label: String, - pub sessions: u64, - pub turns: u64, - pub total_tokens: i64, - pub cost: f64, -} - -#[derive(Debug, Clone, Default)] -pub struct UsageSummary { - pub totals: UsageRow, - pub by_model: Vec, - pub by_session: Vec, -} - -/// Read usage totals from the goose usage ledger for one account scope. -pub fn load_usage(account_scope: &str) -> UsageSummary { - let db = crate::backend::account_session_db(account_scope); - let Some(conn) = crate::backend::open_session_db_read_only(&db) else { - return UsageSummary::default(); - }; - usage_from_ledger(&conn) -} - -/// Aggregate one account's ledger. -/// -/// A subagent has a session of its own, and its provider calls land in -/// the ledger under it. Every row counts against the task that delegated -/// the work, so the reader sees what a task cost in total. Goose refuses -/// a subagent of a subagent, so resolving one parent is enough. -fn usage_from_ledger(conn: &rusqlite::Connection) -> UsageSummary { - let mut summary = UsageSummary::default(); - - if let Ok(mut stmt) = conn.prepare( - "SELECT COUNT(*), COALESCE(SUM(total_tokens),0), COALESCE(SUM(cost),0) \ - FROM usage_ledger", - ) && let Ok(row) = stmt.query_row([], |row| { - Ok(( - row.get::<_, i64>(0)?, - row.get::<_, i64>(1)?, - row.get::<_, f64>(2)?, - )) - }) { - summary.totals = UsageRow { - label: "All activity".to_string(), - sessions: 0, - turns: row.0.max(0) as u64, - total_tokens: row.1, - cost: row.2, - }; - } - - if let Ok(mut stmt) = conn.prepare( - "SELECT u.model, COUNT(DISTINCT COALESCE(s.parent_session_id, u.session_id)), COUNT(*), \ - COALESCE(SUM(u.total_tokens),0), COALESCE(SUM(u.cost),0) \ - FROM usage_ledger u LEFT JOIN sessions s ON s.id = u.session_id \ - GROUP BY u.model ORDER BY SUM(u.total_tokens) DESC", - ) && let Ok(rows) = stmt.query_map([], |row| { - Ok(UsageRow { - label: row - .get::<_, Option>(0)? - .unwrap_or_else(|| "unknown".into()), - sessions: row.get::<_, i64>(1)?.max(0) as u64, - turns: row.get::<_, i64>(2)?.max(0) as u64, - total_tokens: row.get::<_, i64>(3)?, - cost: row.get::<_, f64>(4)?, - }) - }) { - for row in rows.flatten() { - summary.totals.sessions += row.sessions; - summary.by_model.push(row); - } - } - - if let Ok(mut stmt) = conn.prepare( - "SELECT COALESCE(parent.name, s.name), COALESCE(s.parent_session_id, u.session_id) AS task, \ - COUNT(*), COALESCE(SUM(u.total_tokens),0), COALESCE(SUM(u.cost),0) \ - FROM usage_ledger u JOIN sessions s ON s.id = u.session_id \ - LEFT JOIN sessions parent ON parent.id = s.parent_session_id \ - GROUP BY task ORDER BY MAX(u.created_timestamp) DESC LIMIT 20", - ) && let Ok(rows) = stmt.query_map([], |row| { - Ok(UsageRow { - label: { - let name: String = row.get::<_, Option>(0)?.unwrap_or_default(); - let id: String = row.get(1)?; - if name.trim().is_empty() { id } else { name } - }, - sessions: 1, - turns: row.get::<_, i64>(2)?.max(0) as u64, - total_tokens: row.get::<_, i64>(3)?, - cost: row.get::<_, f64>(4)?, - }) - }) { - for row in rows.flatten() { - summary.by_session.push(row); - } - } - - summary -} - #[cfg(test)] mod tests { use super::*; - /// A subagent bills to the task that delegated the work, so the - /// usage screen shows one row per task and not one per subagent. - #[test] - fn subagent_usage_counts_against_its_parent_task() { - let conn = rusqlite::Connection::open_in_memory().unwrap(); - conn.execute_batch( - "CREATE TABLE sessions ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL DEFAULT '', - parent_session_id TEXT - ); - CREATE TABLE usage_ledger ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - session_id TEXT NOT NULL, - created_timestamp INTEGER NOT NULL, - model TEXT, - total_tokens INTEGER, - cost REAL - ); - INSERT INTO sessions VALUES ('task-1', 'Review the parser', NULL); - INSERT INTO sessions VALUES ('sub-1', 'Delegated task', 'task-1'); - INSERT INTO sessions VALUES ('task-2', 'Other work', NULL); - INSERT INTO usage_ledger (session_id, created_timestamp, model, total_tokens, cost) - VALUES ('task-1', 10, 'maple-1', 100, 1.0), - ('sub-1', 20, 'maple-1', 400, 4.0), - ('task-2', 30, 'maple-1', 700, 7.0);", - ) - .unwrap(); - - let usage = usage_from_ledger(&conn); - let rows = usage - .by_session - .iter() - .map(|row| (row.label.as_str(), row.turns, row.total_tokens)) - .collect::>(); - assert_eq!( - rows, - vec![("Other work", 1, 700), ("Review the parser", 2, 500)], - "the subagent's tokens belong to the task that delegated them" - ); - // Two tasks ran, not three sessions. - assert_eq!(usage.by_model.len(), 1); - assert_eq!(usage.by_model[0].sessions, 2); - assert_eq!(usage.totals.total_tokens, 1200); - } - #[test] fn permission_mode_round_trips_as_a_string() { for mode in [PermissionMode::SmartApprove, PermissionMode::Auto] { @@ -616,10 +529,66 @@ mod tests { assert_eq!(PermissionMode::default(), PermissionMode::SmartApprove); } + /// An older file kept task state and session defaults at the top + /// level. Loading moves the task state under the local host, hands the + /// session defaults to the local host once, and the next save drops + /// the old keys. + #[test] + fn legacy_top_level_state_moves_under_the_local_host() { + let mut settings: AppSettings = serde_json::from_str( + r#"{ + "default_permission_mode": "auto", + "default_web_enabled": false, + "harness_instructions": "custom", + "pinned_tasks": ["s1"], + "settled_tasks": ["s2"], + "project_names": {"/p": "Project"} + }"#, + ) + .expect("old file"); + settings.adopt_legacy_host_state(); + let local = settings.host_state(&maple_agent::host::HostId::local()); + assert_eq!(local.pinned_tasks, vec!["s1".to_string()]); + assert_eq!(local.settled_tasks, vec!["s2".to_string()]); + assert_eq!( + local.project_names.get("/p").map(String::as_str), + Some("Project") + ); + let legacy = settings.legacy_session_defaults(); + assert_eq!(legacy.permission_mode.as_deref(), Some("auto")); + assert_eq!(legacy.web_enabled, Some(false)); + assert_eq!(legacy.harness_instructions.as_deref(), Some("custom")); + + let json = serde_json::to_value(&settings).expect("serialize"); + for key in [ + "default_permission_mode", + "default_web_enabled", + "harness_instructions", + "pinned_tasks", + "settled_tasks", + "project_names", + ] { + assert!(json.get(key).is_none(), "{key} must not be written back"); + } + assert_eq!(json["hosts"]["local"]["pinned_tasks"][0], "s1"); + assert!( + settings + .host_state(&maple_agent::host::HostId::new("other")) + .pinned_tasks + .is_empty() + ); + } + #[test] - fn default_settings_keep_the_on_disk_permission_string() { - let json = serde_json::to_value(AppSettings::default()).expect("serialize"); - assert_eq!(json["default_permission_mode"], "smart_approve"); + fn a_file_with_no_legacy_keys_reports_nothing_to_migrate() { + let settings = AppSettings::default(); + assert!(settings.legacy_session_defaults().is_empty()); + assert!( + serde_json::to_value(&settings) + .unwrap() + .get("hosts") + .is_none() + ); } #[test] diff --git a/apps/maple-agent/app/src/ui/chat/commands.rs b/apps/maple-agent/app/src/ui/chat/commands.rs index 54e63cbff..605e1670e 100644 --- a/apps/maple-agent/app/src/ui/chat/commands.rs +++ b/apps/maple-agent/app/src/ui/chat/commands.rs @@ -243,10 +243,10 @@ mod tests { } fn screen(cx: &mut TestAppContext) -> gpui::Entity { - let backend = Arc::new( - AgentBackend::new("http://127.0.0.1:9".to_string(), String::new()).expect("backend"), - ); - cx.new(|cx| ChatScreen::new_inner(backend, "user".to_string(), cx)) + let backend = + Arc::new(AgentBackend::new("http://127.0.0.1:9".to_string()).expect("backend")); + let host = backend.local_host("user"); + cx.new(|cx| ChatScreen::new_inner(backend, host, "user".to_string(), cx)) } #[gpui::test] diff --git a/apps/maple-agent/app/src/ui/chat/composer.rs b/apps/maple-agent/app/src/ui/chat/composer.rs index a8c71bea1..64f007155 100644 --- a/apps/maple-agent/app/src/ui/chat/composer.rs +++ b/apps/maple-agent/app/src/ui/chat/composer.rs @@ -554,15 +554,13 @@ impl ChatScreen { input.set_placeholder(SIDE_THREAD_PLACEHOLDER, cx) }); } - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.host.clone(); let session_id = session_id.to_string(); let question = question.to_string(); let callback_id = request_id.clone(); self.call( async move { - backend - .ask_side_question(&user_id, &session_id, request_id, prior, question) + host.ask_side_question(session_id.clone(), request_id, prior, question) .await }, cx, diff --git a/apps/maple-agent/app/src/ui/chat/dialogs.rs b/apps/maple-agent/app/src/ui/chat/dialogs.rs index 87fd7797d..8569bd351 100644 --- a/apps/maple-agent/app/src/ui/chat/dialogs.rs +++ b/apps/maple-agent/app/src/ui/chat/dialogs.rs @@ -19,10 +19,9 @@ impl ChatScreen { let Some(root) = self.project_root.clone() else { return; }; - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.host.clone(); self.call( - async move { backend.project_trust(&user_id, root).await }, + async move { host.project_trust(root).await }, cx, |this, result, cx| { if let Ok(status) = result @@ -52,10 +51,9 @@ impl ChatScreen { } self.trust_saving = true; cx.notify(); - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.host.clone(); self.call( - async move { backend.set_project_trust(&user_id, path, trusted).await }, + async move { host.set_project_trust(path, trusted).await }, cx, move |this, result, cx| { this.trust_saving = false; @@ -349,14 +347,12 @@ impl ChatScreen { archived: bool, cx: &mut Context, ) { - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.host.clone(); let session_id = session_id.to_string(); let changed_id = session_id.clone(); self.call( async move { - backend - .set_session_archived(&user_id, &session_id, archived) + host.set_session_archived(session_id.clone(), archived) .await }, cx, @@ -392,8 +388,7 @@ impl ChatScreen { cx.notify(); return; } - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.host.clone(); let path = root.to_string(); let fallback = self .recent_roots @@ -418,9 +413,9 @@ impl ChatScreen { self.call( async move { for id in task_ids { - backend.set_session_archived(&user_id, &id, true).await?; + host.set_session_archived(id.clone(), true).await?; } - backend.remove_project_root(&user_id, path, fallback).await + host.remove_project_root(path, fallback).await }, cx, move |this, result, cx| { diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index 2688ab4fa..e646fa390 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -15,6 +15,7 @@ use maple_agent::agent::{ AgentSendMessageRequest, AgentServiceEvent, AgentSessionMcpServer, AgentSessionSummary, AgentSlashCommand, AgentSubagent, AgentTimelineItem, SideQuestionEvent, }; +use maple_agent::host::{HostBackend, HostEvent, HostSessionDefaults}; use crate::backend::{AgentBackend, PendingPermission, PendingQuestion}; use crate::ui::icons::{icon, spinner, wordmark}; @@ -169,7 +170,10 @@ pub(crate) struct SpeechState { } pub struct ChatScreen { + /// Account-level calls: sign-out, billing, audio. backend: Arc, + /// The host whose tasks this screen shows. + host: Arc, user_id: String, /// The task list; its own entity so it renders only when it changes. sidebar: Entity, @@ -293,11 +297,9 @@ pub struct ChatScreen { project_branch: Option, /// `project_branch` in parentheses, ready for the header. branch_label: Option, - /// Watches the git dir of the current root so a checkout by the agent - /// or from a terminal updates the branch. Replaced when the git dir - /// changes, dropped with the root. - branch_watcher: Option, - watched_git_dir: Option, + /// Root whose branch the host reports to this screen; the host owns + /// the git dir watch. Replaced when the root changes. + watched_root: Option, /// A native folder picker is open; more clicks must not open another. root_picker_open: bool, /// Sidebar hidden; a toggle in the main pane brings it back. @@ -472,8 +474,13 @@ const LOAD_RETRIES: u8 = 2; const FINISHED_RUNS_KEPT: usize = 64; impl ChatScreen { - pub fn new(backend: Arc, user_id: String, cx: &mut Context) -> Self { - let this = Self::new_mounted(backend, user_id, cx); + pub fn new( + backend: Arc, + host: Arc, + user_id: String, + cx: &mut Context, + ) -> Self { + let this = Self::new_mounted(backend, host, user_id, cx); this.start(cx); this } @@ -482,9 +489,14 @@ impl ChatScreen { /// Production immediately calls `start`; focused GPUI tests use the /// deterministic seam so unrelated Tokio scheduling cannot replace their /// fixture state mid-interaction. - fn new_mounted(backend: Arc, user_id: String, cx: &mut Context) -> Self { + fn new_mounted( + backend: Arc, + host: Arc, + user_id: String, + cx: &mut Context, + ) -> Self { let weak = cx.entity().downgrade(); - let mut this = Self::new_inner(backend, user_id, cx); + let mut this = Self::new_inner(backend, host, user_id, cx); this.attach_composer(weak.clone(), cx); this.markdown_cache.attach(weak.clone(), cx.to_async()); this.selection = Some(cx.new(|_| rich_text::TextSelection::default())); @@ -498,10 +510,11 @@ impl ChatScreen { #[cfg(test)] pub(crate) fn new_without_start( backend: Arc, + host: Arc, user_id: String, cx: &mut Context, ) -> Self { - Self::new_mounted(backend, user_id, cx) + Self::new_mounted(backend, host, user_id, cx) } /// What the sidebar asked for. Events arrive after the sidebar's own @@ -827,24 +840,26 @@ impl ChatScreen { /// Test seam: pure state without composer wiring or runtime start. pub(crate) fn new_inner( backend: Arc, + host: Arc, user_id: String, cx: &mut Context, ) -> Self { - Self::new_inner_with_placeholder(backend, user_id, cx) + Self::new_inner_with_placeholder(backend, host, user_id, cx) } fn new_inner_with_placeholder( backend: Arc, + host: Arc, user_id: String, cx: &mut Context, ) -> Self { let settings = crate::settings::load_settings(); let weak = cx.entity().downgrade(); - let sidebar = - cx.new(|cx| Sidebar::new(backend.clone(), user_id.clone(), weak, &settings, cx)); + let sidebar = cx.new(|cx| Sidebar::new(backend.clone(), host.clone(), weak, &settings, cx)); cx.subscribe(&sidebar, Self::on_sidebar_event).detach(); Self { backend, + host, user_id, sidebar, sessions: Vec::new(), @@ -884,12 +899,13 @@ impl ChatScreen { loading_session: None, list_state: transcript_list_state(), tool_details: settings.tool_details, - // An unset or unknown value in either place means "use the - // saved default", so only a known mode counts as an override. + // The host's saved default arrives with the bootstrap; until + // then the safer mode applies. An unknown value in the + // environment means "use the saved default", so only a known + // mode counts as an override. permission_mode: std::env::var("MAPLE_PERMISSION_MODE") .ok() .and_then(|mode| PermissionMode::from_str(&mode)) - .or(Some(settings.default_permission_mode)) .unwrap_or_default(), uses_default_permission_mode: std::env::var("MAPLE_PERMISSION_MODE").is_err(), project_root: None, @@ -911,7 +927,8 @@ impl ChatScreen { mcp_menu_open: false, composer_expanded: false, web_enabled: true, - default_web_enabled: settings.default_web_enabled, + // The host's saved default arrives with the bootstrap. + default_web_enabled: true, markdown_cache: MarkdownCache::default(), notice_dismiss_pending: std::cell::Cell::new(false), derived: DerivedCache::default(), @@ -923,8 +940,7 @@ impl ChatScreen { project_label: SharedString::from("Choose folder"), project_branch: None, branch_label: None, - branch_watcher: None, - watched_git_dir: None, + watched_root: None, root_picker_open: false, selection_generation: 0, reload_generation: 0, @@ -1013,25 +1029,18 @@ impl ChatScreen { /// the lifecycle lock across network round trips; issuing any local /// read after it would queue behind that lock. fn start(&self, cx: &mut Context) { - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.host.clone(); self.call( async move { - let boot = backend.local_bootstrap(&user_id).await?; + let boot = host.bootstrap().await?; let summaries = match &boot.latest { - Some(detail) => { - let store = backend.clone(); - let target = detail.session.id.clone(); - tokio::task::spawn_blocking(move || { - store.load_tool_summaries_blocking(&user_id, &target) - }) + Some(detail) => host + .tool_summaries(detail.session.id.clone()) .await - .map_err(|error| error.to_string())? .unwrap_or_else(|error| { log::warn!("Cannot load tool summaries: {error}"); HashMap::new() - }) - } + }), None => HashMap::new(), }; Ok::<_, String>((boot, summaries)) @@ -1045,6 +1054,7 @@ impl ChatScreen { crate::startup_elapsed(), boot.sessions.len() ); + this.apply_session_defaults(&boot.session_defaults, cx); this.project_root = boot.project_root; this.project_root_changed(cx); this.check_project_trust(cx); @@ -1077,14 +1087,13 @@ impl ChatScreen { /// Phase two of `start`: bring the agent runtime up and fill in what /// needs the network (models, plan, audio, trust of a changed root). fn start_runtime(&self, cx: &mut Context) { - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); - let request = backend.default_start_request(); + let host = self.host.clone(); + let request = self.backend.default_start_request(); // A task or project selected while the start is in flight owns the // visible project context. let generation = self.selection_generation; self.call( - async move { backend.start_runtime(&user_id, Some(request)).await }, + async move { host.start_runtime(Some(request)).await }, cx, move |this, result, cx| { match result { @@ -1140,10 +1149,9 @@ impl ChatScreen { } fn refresh_roots(&self, cx: &mut Context) { - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.host.clone(); self.call( - async move { backend.recent_project_roots(&user_id).await }, + async move { host.recent_project_roots().await }, cx, |this, result, cx| { if let Ok(roots) = result { @@ -1170,9 +1178,10 @@ impl ChatScreen { if self.root_selecting { return; } + // The host validates the path: it owns the filesystem. let path = path.trim().to_string(); - if path.is_empty() || !std::path::Path::new(&path).is_absolute() { - self.notice = Some("Enter an absolute directory path".into()); + if path.is_empty() { + self.notice = Some("Enter a directory path".into()); cx.notify(); return; } @@ -1186,10 +1195,9 @@ impl ChatScreen { self.root_input = None; self.notice = None; cx.notify(); - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.host.clone(); self.call( - async move { backend.select_project_root(&user_id, path).await }, + async move { host.select_project_root(path).await }, cx, move |this, result, cx| { this.root_selecting = false; @@ -1221,10 +1229,9 @@ impl ChatScreen { /// without changing what is on screen. Used when a task under another /// project is opened and when a project is archived. fn persist_project_root(&mut self, root: String, cx: &mut Context) { - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.host.clone(); self.call( - async move { backend.select_project_root(&user_id, root).await }, + async move { host.select_project_root(root).await }, cx, |this, result, cx| match result { Ok(registration) => { @@ -1299,116 +1306,75 @@ impl ChatScreen { crate::ui::task::retain(&self.bridged_tasks, bridge); } - /// The root changed: update the header label, then read its branch - /// and watch its git dir. + /// The root changed: update the header label, then ask the host for + /// its branch. fn project_root_changed(&mut self, cx: &mut Context) { self.project_label = SharedString::from(self.project_label()); self.refresh_branch(cx); } - /// Read the branch for the current root, or clear it when there is - /// no root. The read also resolves the git dir, and the watcher is - /// replaced when that dir changed. + /// Follow the branch of the current root, or clear it when there is + /// no root. The host owns the git dir watch and reports the branch + /// through [`HostEvent::ProjectBranch`]; this screen only tells it + /// which root to follow. fn refresh_branch(&mut self, cx: &mut Context) { - if self.project_root.is_some() { - self.read_branch(cx); + if self.watched_root == self.project_root { return; } - self.branch_watcher = None; - self.watched_git_dir = None; - self.set_branch(None, cx); - } - - fn set_branch(&mut self, branch: Option, cx: &mut Context) { - if self.project_branch == branch { - return; + let host = self.host.clone(); + let previous = self.watched_root.take(); + let next = self.project_root.clone(); + self.watched_root = next.clone(); + if next.is_none() { + self.set_branch(None, cx); } - self.branch_label = branch - .as_deref() - .map(|branch| SharedString::from(format!("({branch})"))); - self.project_branch = branch; - cx.notify(); - } - - /// Watch `git_dir` and re-read the branch when `HEAD` changes. The - /// watch is on the directory, not the file: git replaces `HEAD` by - /// rename, so a watch on the file itself is lost after the first - /// checkout. Non-recursive, so a busy `objects/` tree costs nothing. - /// Events arrive on the watcher's own thread and cross to the UI - /// through a channel, like backend events. Dropping the watcher - /// closes the channel, which ends the receiver task. - fn watch_branch(&mut self, git_dir: &std::path::Path, cx: &mut Context) { - use notify::Watcher as _; - let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); - let mut watcher = - match notify::recommended_watcher(move |event: notify::Result| { - let Ok(event) = event else { return }; - if head_change_event(&event) { - tx.send(()).ok(); + self.call( + async move { + if let Some(previous) = previous { + host.unwatch_project_root(previous).await?; } - }) { - Ok(watcher) => watcher, - Err(error) => { - log::debug!("branch watcher unavailable: {error}"); - return; + if let Some(next) = next { + host.watch_project_root(next).await?; } - }; - if let Err(error) = watcher.watch(git_dir, notify::RecursiveMode::NonRecursive) { - log::debug!("cannot watch {}: {error}", git_dir.display()); - return; - } - self.branch_watcher = Some(watcher); - cx.spawn(async move |this, cx| { - while rx.recv().await.is_some() { - // A rebase or a checkout touches HEAD several times in a - // row; one read per burst is enough. - while rx.try_recv().is_ok() {} - if this.update(cx, |this, cx| this.read_branch(cx)).is_err() { - break; + Ok(()) + }, + cx, + |_this, result: Result<(), String>, _cx| { + if let Err(message) = result { + log::debug!("branch watch unavailable: {message}"); } - } - }) - .detach(); + }, + ); } - /// Read the branch for the current root off the UI thread. The git - /// dir comes back with it so the watcher follows a root change without - /// a file stat on the UI thread. - fn read_branch(&mut self, cx: &mut Context) { - let Some(root) = self.project_root.clone() else { + /// Ask the host to report the current root's branch again. Re-watching + /// a root the host already watches re-sends its branch. + fn reread_branch(&self, cx: &mut Context) { + let Some(root) = self.watched_root.clone() else { return; }; + let host = self.host.clone(); self.call( async move { - tokio::task::spawn_blocking(move || { - let git_dir = git_dir(std::path::Path::new(&root)); - let branch = git_dir.as_deref().and_then(git_branch); - Ok((root, git_dir, branch)) - }) - .await - .map_err(|error| format!("Branch lookup failed: {error}"))? + host.watch_project_root(root.clone()).await?; + host.unwatch_project_root(root).await }, cx, - |this, result, cx| { - // Drop a late answer for a root that is no longer current. - let Ok((root, git_dir, branch)) = result else { - return; - }; - if this.project_root.as_deref() != Some(root.as_str()) { - return; - } - if this.watched_git_dir != git_dir { - this.branch_watcher = None; - if let Some(dir) = &git_dir { - this.watch_branch(dir, cx); - } - this.watched_git_dir = git_dir; - } - this.set_branch(branch, cx); - }, + |_this, _result: Result<(), String>, _cx| {}, ); } + fn set_branch(&mut self, branch: Option, cx: &mut Context) { + if self.project_branch == branch { + return; + } + self.branch_label = branch + .as_deref() + .map(|branch| SharedString::from(format!("({branch})"))); + self.project_branch = branch; + cx.notify(); + } + /// Claim the folder picker. One at a time: several at once each /// applied their own result and stalled the app. Returns `false` when /// a picker or a project selection is already in progress. @@ -1466,13 +1432,16 @@ impl ChatScreen { } fn refresh_models(&self, cx: &mut Context) { - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); - let env_model = backend.configured_model(); + let host = self.host.clone(); + let env_model = self.backend.configured_model(); self.call( async move { - let saved = backend.saved_model(&user_id).await; - let models = backend.available_model_ids(&user_id).await?; + let saved = host + .session_defaults() + .await + .ok() + .and_then(|defaults| defaults.default_model); + let models = host.available_model_ids().await?; Ok((models, saved)) }, cx, @@ -1493,13 +1462,12 @@ impl ChatScreen { } fn refresh_sessions(&self, cx: &mut Context) { - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.host.clone(); // The sidebar groups tasks by project, so list every root. Each task's // stored root remains authoritative when it is opened or run. let generation = self.selection_generation; self.call( - async move { backend.list_sessions(&user_id, None).await }, + async move { host.list_sessions(None).await }, cx, move |this, result, cx| { match result { @@ -1562,12 +1530,10 @@ impl ChatScreen { // eventual callback. let selection_generation = self.selection_generation; self.session_setup_pending = true; - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.host.clone(); self.call( async move { - backend - .create_session(&user_id, Some(request)) + host.create_session(Some(request)) .await .map(|detail| detail.session) }, @@ -1674,28 +1640,21 @@ impl ChatScreen { self.loading_session = Some(session_id.to_string()); cx.notify(); } - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.host.clone(); let session_id = session_id.to_string(); let target = session_id.clone(); self.call( async move { - // The stored summaries load off-thread while the runtime - // builds the session detail. - let store = backend.clone(); - let store_user = user_id.clone(); - let store_target = target.clone(); - let summaries = tokio::task::spawn_blocking(move || { - store.load_tool_summaries_blocking(&store_user, &store_target) + // The stored summaries load while the runtime builds the + // session detail. + let (detail, summaries) = tokio::join!( + host.load_session(target.clone()), + host.tool_summaries(target.clone()) + ); + let summaries = summaries.unwrap_or_else(|error| { + log::warn!("Cannot load tool summaries: {error}"); + HashMap::new() }); - let (detail, summaries) = - tokio::join!(backend.load_session(&user_id, &target), summaries); - let summaries = summaries - .map_err(|error| error.to_string())? - .unwrap_or_else(|error| { - log::warn!("Cannot load tool summaries: {error}"); - HashMap::new() - }); Ok::<_, String>((detail?, summaries)) }, cx, @@ -1824,16 +1783,47 @@ impl ChatScreen { self.set_plan(plan); } - /// Apply settings-default changes when returning from the settings - /// screen: tool verbosity updates live; the permission default only - /// affects sessions that still follow the default. + /// Apply the host's session defaults: web access for new tasks, and + /// the permission mode for sessions that still follow the default. + fn apply_session_defaults(&mut self, defaults: &HostSessionDefaults, cx: &mut Context) { + self.default_web_enabled = defaults.web_enabled; + if self.uses_default_permission_mode { + let mode = PermissionMode::parse(&defaults.permission_mode); + if mode != self.permission_mode { + self.permission_mode = mode; + self.apply_permission_mode(cx); + } + } + } + + /// Re-read the host's session defaults, after the settings screen + /// may have changed them. + fn refresh_session_defaults(&self, cx: &mut Context) { + let host = self.host.clone(); + self.call( + async move { host.session_defaults().await }, + cx, + |this, result, cx| match result { + Ok(defaults) => { + this.apply_session_defaults(&defaults, cx); + cx.notify(); + } + Err(message) => log::debug!("session defaults unavailable: {message}"), + }, + ); + } + + /// Apply settings changes when returning from the settings screen: + /// tool verbosity updates live; the host's session defaults are + /// re-read, and the permission default only affects sessions that + /// still follow the default. pub fn apply_defaults( &mut self, settings: &crate::settings::AppSettings, cx: &mut Context, ) { self.tool_details = settings.tool_details; - self.default_web_enabled = settings.default_web_enabled; + self.refresh_session_defaults(cx); self.notify_enabled = settings.desktop_notifications; self.summaries_enabled = settings.tool_summaries; self.composer_vim_enabled = settings.composer_vim_enabled; @@ -1857,10 +1847,6 @@ impl ChatScreen { self.screen_focus_pending = true; self.tts_voice.clone_from(&settings.tts_voice); self.tts_speed = settings.tts_speed; - if self.uses_default_permission_mode { - self.permission_mode = settings.default_permission_mode; - self.apply_permission_mode(cx); - } // Servers may have been added or removed in settings. self.refresh_session_mcp(cx); cx.notify(); @@ -1870,19 +1856,14 @@ impl ChatScreen { let Some(session_id) = self.selected_session.clone() else { return; }; - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.host.clone(); let model = self.selected_model.clone(); self.call( - async move { - backend - .context_usage(&user_id, &session_id, model.as_deref()) - .await - }, + async move { host.context_usage(session_id, model).await }, cx, |this, result, cx| { - if let Ok(Some((tokens, limit))) = result { - this.apply_context_usage(tokens, limit, cx); + if let Ok(Some(usage)) = result { + this.apply_context_usage(usage.tokens, usage.limit, cx); } }, ); @@ -1895,12 +1876,11 @@ impl ChatScreen { let Some(session_id) = self.selected_session.clone() else { return; }; - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.host.clone(); let requested = session_id.clone(); let epoch = self.subagent_epoch; self.call( - async move { backend.session_subagents(&user_id, &requested).await }, + async move { host.session_subagents(requested.clone()).await }, cx, move |this, result, cx| { let Ok(subagents) = result else { @@ -2033,13 +2013,12 @@ impl ChatScreen { let Some(session_id) = self.selected_session.clone() else { return; }; - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.host.clone(); let compacted = session_id.clone(); self.notice = Some("Compacting…".into()); cx.notify(); self.call( - async move { backend.compact_session(&user_id, &session_id).await }, + async move { host.compact_session(session_id.clone()).await }, cx, move |this, result, cx| match result { Ok(()) => { @@ -2064,13 +2043,11 @@ impl ChatScreen { let Some(session_id) = self.selected_session.clone() else { return; }; - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.host.clone(); let mode = self.permission_mode.as_str().to_string(); self.call( async move { - backend - .set_permission_mode(&user_id, &session_id, &mode) + host.set_permission_mode(session_id.clone(), mode.clone()) .await }, cx, @@ -2233,11 +2210,10 @@ impl ChatScreen { if self.model_vision.contains_key(&model) { return; } - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.host.clone(); let lookup = model.clone(); self.call( - async move { backend.model_supports_vision(&user_id, &lookup).await }, + async move { host.model_supports_vision(lookup.clone()).await }, cx, move |this, result, cx| { if let Ok(Some(vision)) = result { @@ -2257,12 +2233,11 @@ impl ChatScreen { } /// Reload the skill slash commands for the current project root. pub fn refresh_slash_commands(&mut self, cx: &mut Context) { - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.host.clone(); let working_dir = self.project_root.clone(); let requested_root = working_dir.clone(); self.call( - async move { backend.list_slash_commands(&user_id, working_dir).await }, + async move { host.list_slash_commands(working_dir).await }, cx, move |this, result, cx| { if this.project_root == requested_root @@ -2284,11 +2259,10 @@ impl ChatScreen { self.set_session_mcp(Vec::new()); return; }; - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.host.clone(); let target = session_id.clone(); self.call( - async move { backend.list_session_mcp_servers(&user_id, &target).await }, + async move { host.list_session_mcp_servers(target.clone()).await }, cx, move |this, result, cx| { if this.selected_session.as_deref() != Some(session_id.as_str()) { @@ -2313,13 +2287,11 @@ impl ChatScreen { let Some(session_id) = self.selected_session.clone() else { return; }; - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.host.clone(); let target = session_id.clone(); self.call( async move { - backend - .set_session_mcp_server_enabled(&user_id, &target, &name, kind, enabled) + host.set_session_mcp_server_enabled(target.clone(), name.clone(), kind, enabled) .await }, cx, @@ -2345,15 +2317,10 @@ impl ChatScreen { let previous = self.web_enabled; self.web_enabled = enabled; cx.notify(); - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.host.clone(); let target = session_id.clone(); self.call( - async move { - backend - .set_session_web_enabled(&user_id, &target, enabled) - .await - }, + async move { host.set_session_web_enabled(target.clone(), enabled).await }, cx, move |this, result, cx| { match result { @@ -2535,13 +2502,11 @@ impl ChatScreen { let Some(session_id) = self.selected_session.clone() else { return; }; - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.host.clone(); let agent_id = agent_id.to_string(); self.call( async move { - backend - .cancel_external_agent(&user_id, &session_id, &agent_id) + host.cancel_external_agent(session_id.clone(), agent_id.clone()) .await }, cx, @@ -3308,7 +3273,7 @@ impl ChatScreen { { return false; } - let backend = self.backend.clone(); + let host = self.host.clone(); let working_dir = self.project_root.clone(); let command = name.to_string(); let arguments = args.to_string(); @@ -3316,8 +3281,7 @@ impl ChatScreen { cx.notify(); self.call( async move { - backend - .resolve_slash_command(working_dir, command, arguments) + host.resolve_slash_command(working_dir, command, arguments) .await }, cx, @@ -3353,8 +3317,7 @@ impl ChatScreen { cx: &mut Context, ) { let session_id = session_id.to_string(); - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.host.clone(); let model = self.selected_model.clone(); let vision_capable = self.selected_model_supports_vision(); let run_active = self.active_runs.contains_key(&session_id); @@ -3413,7 +3376,7 @@ impl ChatScreen { } cx.notify(); self.call( - async move { backend.send_message(&user_id, request).await }, + async move { host.send_message(request).await }, cx, move |this, result, cx| match result { Ok(run_id) => { @@ -3480,10 +3443,9 @@ impl ChatScreen { let Some(run_id) = self.active_runs.get(&session_id).cloned() else { return; }; - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.host.clone(); self.call( - async move { backend.cancel_run(&user_id, &run_id).await }, + async move { host.cancel_run(run_id.clone()).await }, cx, |this, result, cx| { if let Err(message) = result { @@ -3596,8 +3558,7 @@ impl ChatScreen { } let request_id = question.request_id.clone(); let callback_request_id = request_id.clone(); - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.host.clone(); // Drop the answered question and its input so the next card starts // fresh; a queued question's event already fired, so the input is // recreated right away when one is showing. @@ -3606,7 +3567,7 @@ impl ChatScreen { self.reset_question_card(cx); cx.notify(); self.call( - async move { backend.answer_question(&user_id, &request_id, answer).await }, + async move { host.answer_question(request_id.clone(), answer).await }, cx, move |this, result, cx| match result { Ok(true) => {} @@ -3639,25 +3600,19 @@ impl ChatScreen { .retain(|queued| queued.request_id != question.request_id); self.reset_question_card(cx); cx.notify(); - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.host.clone(); { let request_id = question.request_id.clone(); - let answer_backend = backend.clone(); - let answer_user = user_id.clone(); + let answer_host = host.clone(); self.call( - async move { - answer_backend - .answer_question(&answer_user, &request_id, String::new()) - .await - }, + async move { answer_host.answer_question(request_id, String::new()).await }, cx, |_this, _result, _cx| {}, ); } if let Some(run_id) = self.active_runs.get(&question.session_id).cloned() { self.call( - async move { backend.cancel_run(&user_id, &run_id).await }, + async move { host.cancel_run(run_id).await }, cx, |this, result, cx| { if let Err(message) = result { @@ -3727,19 +3682,16 @@ impl ChatScreen { } self.permission_responding = true; cx.notify(); - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.host.clone(); let request_id = permission.request_id.clone(); self.call( async move { - backend - .permission_respond( - &user_id, - &permission.session_id, - &permission.request_id, - allow, - ) - .await + host.permission_respond( + permission.session_id.clone(), + permission.request_id.clone(), + allow, + ) + .await }, cx, move |this, result, cx| { @@ -3766,10 +3718,11 @@ impl ChatScreen { self.audio.cancel_recording(); } let backend = self.backend.clone(); + let host = self.host.clone(); let user_id = self.user_id.clone(); self.call( async move { - backend.stop_runtime(&user_id).await?; + host.stop_runtime().await?; backend.logout_and_clear(&user_id).await }, cx, @@ -3786,10 +3739,9 @@ impl ChatScreen { cx.notify(); self.refresh_vision(cx); // Remember the choice across launches via the agent config. - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.host.clone(); self.call( - async move { backend.save_default_model(&user_id, model).await }, + async move { host.save_default_model(model).await }, cx, |_this, _result, _cx| {}, ); @@ -3972,20 +3924,39 @@ impl ChatScreen { .find(|permission| permission.session_id == selected) } - pub fn handle_service_events( - &mut self, - events: Vec, - cx: &mut Context, - ) { + /// Apply a batch of host events in one update. + pub fn handle_host_events(&mut self, events: Vec, cx: &mut Context) { let mut changed = false; for event in events { - changed |= self.apply_service_event(event, cx); + changed |= match event { + HostEvent::Service(event) => self.apply_service_event(*event, cx), + HostEvent::ProjectBranch { + project_root, + branch, + } => self.apply_project_branch(&project_root, branch, cx), + }; } if changed { cx.notify(); } } + /// The host reported a branch. Only the current root's matters; a + /// late report for a root that is no longer current is dropped. + fn apply_project_branch( + &mut self, + project_root: &str, + branch: Option, + cx: &mut Context, + ) -> bool { + if self.project_root.as_deref() != Some(project_root) { + return false; + } + let changed = self.project_branch != branch; + self.set_branch(branch, cx); + changed + } + /// Route one backend service event into UI state. #[cfg(test)] pub fn handle_service_event(&mut self, event: AgentServiceEvent, cx: &mut Context) { @@ -4292,9 +4263,9 @@ impl ChatScreen { self.subagent_epoch += 1; } } - // The watcher covers checkouts; this catches a + // The host's watcher covers checkouts; this catches a // change that landed between two events. - self.read_branch(cx); + self.reread_branch(cx); // The run that asked is gone (stopped or failed): its // questions would block the composer forever. self.clear_session_questions(session_id, cx); @@ -4832,65 +4803,6 @@ impl ChatScreen { } } -/// The directory that holds `HEAD` for a checkout, or `None` when `root` -/// is not one. Supports worktrees, whose `.git` is a file that points at -/// the real git dir. -fn git_dir(root: &std::path::Path) -> Option { - let dot_git = root.join(".git"); - if dot_git.is_dir() { - return Some(dot_git); - } - let pointer = std::fs::read_to_string(&dot_git).ok()?; - let target = pointer.trim().strip_prefix("gitdir:")?.trim(); - let target = std::path::Path::new(target); - Some(if target.is_absolute() { - target.to_path_buf() - } else { - root.join(target) - }) -} - -/// Current git branch from a git dir, or the short commit id when HEAD -/// is detached. `None` when there is no readable `HEAD`. -fn git_branch(git_dir: &std::path::Path) -> Option { - let head = std::fs::read_to_string(git_dir.join("HEAD")).ok()?; - let head = head.trim(); - match head.strip_prefix("ref: ") { - Some(reference) => Some( - reference - .strip_prefix("refs/heads/") - .unwrap_or(reference) - .to_string(), - ), - // Detached: a hex id. Anything else is a corrupt HEAD. - None => head - .get(..7) - .filter(|id| id.bytes().all(|byte| byte.is_ascii_hexdigit())) - .map(str::to_string), - } -} - -/// True when a watcher event means the branch may have changed: a semantic -/// change to a `HEAD` path (write, create, remove, or the rename pair of an -/// atomic replacement), or a rescan the backend requires. Access-only events -/// (open, read, close) are dropped: the branch read they would trigger emits -/// those same events again under Linux inotify, looping the watcher at full -/// CPU while idle (#945). Real writes still arrive as `Modify` on every -/// backend, so no true change is lost; `Any`/`Other` stay forwarded for -/// backends that cannot classify. -fn head_change_event(event: ¬ify::Event) -> bool { - if event.need_rescan() { - return true; - } - if matches!(event.kind, notify::EventKind::Access(_)) { - return false; - } - event - .paths - .iter() - .any(|path| path.file_name().is_some_and(|name| name == "HEAD")) -} - /// Last path component of a project root, for chips and the sidebar. /// Upper-case section heading in the sidebar. fn section_label(text: &'static str) -> Div { diff --git a/apps/maple-agent/app/src/ui/chat/queue.rs b/apps/maple-agent/app/src/ui/chat/queue.rs index c1ff48d25..e11a33fa5 100644 --- a/apps/maple-agent/app/src/ui/chat/queue.rs +++ b/apps/maple-agent/app/src/ui/chat/queue.rs @@ -43,14 +43,12 @@ impl ChatScreen { return; } self.queue_busy = true; - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.host.clone(); let queue_id = queue_id.to_string(); let target = session_id.clone(); self.call( async move { - backend - .cancel_queued_message(&user_id, &session_id, &queue_id) + host.cancel_queued_message(session_id.clone(), queue_id.clone()) .await }, cx, @@ -83,15 +81,13 @@ impl ChatScreen { }; let text = item.text.clone(); self.queue_busy = true; - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.host.clone(); let queue_id = queue_id.to_string(); let target = session_id.clone(); let held_id = queue_id.clone(); self.call( async move { - backend - .begin_queued_message_edit(&user_id, &session_id, &queue_id) + host.begin_queued_message_edit(session_id.clone(), queue_id.clone()) .await }, cx, @@ -169,14 +165,12 @@ impl ChatScreen { } fn release_queue_hold(&self, session_id: &str, queue_id: &str, cx: &mut Context) { - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.host.clone(); let session_id = session_id.to_string(); let queue_id = queue_id.to_string(); self.call( async move { - backend - .end_queued_message_edit(&user_id, &session_id, &queue_id) + host.end_queued_message_edit(session_id.clone(), queue_id.clone()) .await }, cx, diff --git a/apps/maple-agent/app/src/ui/chat/sidebar.rs b/apps/maple-agent/app/src/ui/chat/sidebar.rs index 11227d546..5dbe63a62 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar.rs @@ -30,6 +30,7 @@ use crate::ui::text_input::TextInput; use crate::ui::theme; use crate::ui::titlebar; use crate::ui::widgets; +use maple_agent::host::{HostBackend, HostId}; /// What the sidebar asks the screen to do. #[derive(Clone, Debug)] @@ -210,8 +211,10 @@ pub(super) struct SwitcherRoot { } pub(super) struct Sidebar { + /// Runs backend futures; see [`Self::call`]. backend: Arc, - user_id: String, + /// The host whose tasks the rows show. + host: Arc, chat: WeakEntity, // What the screen pushes in. sessions: Vec, @@ -270,11 +273,12 @@ impl EventEmitter for Sidebar {} impl Sidebar { pub(super) fn new( backend: Arc, - user_id: String, + host: Arc, chat: WeakEntity, settings: &crate::settings::AppSettings, cx: &mut Context, ) -> Self { + let host_state = settings.host_state(host.id()); let application_vim_enabled = settings.application_vim_enabled; let search_chat = chat.clone(); let search_input = cx.new(move |cx| { @@ -293,7 +297,7 @@ impl Sidebar { .detach(); Self { backend, - user_id, + host, chat, sessions: Vec::new(), selected: None, @@ -326,10 +330,10 @@ impl Sidebar { rename: None, rename_input: None, rename_focus_pending: false, - pinned_tasks: settings.pinned_tasks.clone(), - settled_tasks: settings.settled_tasks.iter().cloned().collect(), - unsettled_tasks: settings.unsettled_tasks.iter().cloned().collect(), - project_names: settings.project_names.clone(), + pinned_tasks: host_state.pinned_tasks, + settled_tasks: host_state.settled_tasks.into_iter().collect(), + unsettled_tasks: host_state.unsettled_tasks.into_iter().collect(), + project_names: host_state.project_names.into_iter().collect(), vim_selected: None, vim_by_row: Vec::new(), vim_order: Vec::new(), @@ -995,7 +999,10 @@ impl Sidebar { self.rebuild_sections(); cx.notify(); let pinned = self.pinned_tasks.clone(); - persist_settings(move |settings| settings.pinned_tasks = pinned); + let host_id = self.host.id().clone(); + persist_settings(move |settings| { + settings.host_state_mut(&host_id).pinned_tasks = pinned; + }); } /// Move a task to the settled section: it leaves the active inbox @@ -1005,7 +1012,11 @@ impl Sidebar { if self.settled_tasks.insert(session_id.to_string()) { self.rebuild_sections(); cx.notify(); - persist_task_sets(self.settled_tasks.clone(), self.unsettled_tasks.clone()); + persist_task_sets( + self.host.id().clone(), + self.settled_tasks.clone(), + self.unsettled_tasks.clone(), + ); } } @@ -1035,7 +1046,11 @@ impl Sidebar { if self.unsettled_tasks.insert(session_id.to_string()) { self.rebuild_sections(); cx.notify(); - persist_task_sets(self.settled_tasks.clone(), self.unsettled_tasks.clone()); + persist_task_sets( + self.host.id().clone(), + self.settled_tasks.clone(), + self.unsettled_tasks.clone(), + ); } } @@ -1158,10 +1173,9 @@ impl Sidebar { } match target { RenameTarget::Task(session_id) => { - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.host.clone(); self.call( - async move { backend.rename_session(&user_id, &session_id, name).await }, + async move { host.rename_session(session_id.clone(), name).await }, cx, |_this, result, cx| match result { Ok(session) => cx.emit(SidebarEvent::SessionChanged(session)), @@ -1176,8 +1190,12 @@ impl Sidebar { self.project_names.insert(root.clone(), name); } self.rebuild_sections(); - let names = self.project_names.clone(); - persist_settings(move |settings| settings.project_names = names); + let names: std::collections::BTreeMap = + self.project_names.clone().into_iter().collect(); + let host_id = self.host.id().clone(); + persist_settings(move |settings| { + settings.host_state_mut(&host_id).project_names = names; + }); } } } @@ -1211,11 +1229,10 @@ impl Sidebar { self.project_menu = None; } else { self.project_menu = Some(root.to_string()); - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.host.clone(); let path = root.to_string(); self.call( - async move { backend.project_trust(&user_id, path).await }, + async move { host.project_trust(path).await }, cx, |this, result, cx| { if let Ok(status) = result @@ -2497,11 +2514,12 @@ fn persist_settings(update: impl FnOnce(&mut crate::settings::AppSettings) + Sen crate::settings::update_settings_in_background(update); } -/// Write the settle/unsettle sets in one background update. -fn persist_task_sets(settled: HashSet, unsettled: HashSet) { +/// Write the settle/unsettle sets for one host in one background update. +fn persist_task_sets(host_id: HostId, settled: HashSet, unsettled: HashSet) { crate::settings::update_settings_in_background(move |settings| { - settings.settled_tasks = settled.into_iter().collect(); - settings.unsettled_tasks = unsettled.into_iter().collect(); + let state = settings.host_state_mut(&host_id); + state.settled_tasks = settled.into_iter().collect(); + state.unsettled_tasks = unsettled.into_iter().collect(); }); } diff --git a/apps/maple-agent/app/src/ui/chat/summaries.rs b/apps/maple-agent/app/src/ui/chat/summaries.rs index 343e34c3a..2ee87a059 100644 --- a/apps/maple-agent/app/src/ui/chat/summaries.rs +++ b/apps/maple-agent/app/src/ui/chat/summaries.rs @@ -138,35 +138,24 @@ impl ChatScreen { } let tool_name = item.title.clone().unwrap_or_else(|| item.item_type.clone()); log::debug!("Requesting summary for {item_id} ({tool_name})"); - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.host.clone(); let generation = self.summary_generation; self.pending_summaries += 1; let store_id = item_id.clone(); self.call( async move { let summary = if thinking { - backend - .summarize_thinking(&user_id, &session_id, output) - .await? + host.summarize_thinking(session_id.clone(), output).await? } else { - backend - .summarize_tool_call(&user_id, &session_id, tool_name, input, output) + host.summarize_tool_call(session_id.clone(), tool_name, input, output) .await? }; - if let Some(summary) = &summary { - let summary = summary.clone(); - let store = backend.clone(); - tokio::task::spawn_blocking(move || { - if let Err(error) = store.store_tool_summary_blocking( - &user_id, - &session_id, - &store_id, - &summary, - ) { - log::warn!("Cannot store tool summary: {error}"); - } - }); + if let Some(summary) = &summary + && let Err(error) = host + .store_tool_summary(session_id.clone(), store_id, summary.clone()) + .await + { + log::warn!("Cannot store tool summary: {error}"); } Ok::<_, String>(summary) }, @@ -250,14 +239,12 @@ impl ChatScreen { }; for id in wanted { self.attachment_requests.insert(id.clone()); - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.host.clone(); let session = session_id.clone(); let attachment_id = id.clone(); self.call( async move { - backend - .read_image_attachment(&user_id, &session, &attachment_id) + host.read_image_attachment(session.clone(), attachment_id.clone()) .await }, cx, diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index b9aea2c75..1d89a5bf1 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -74,14 +74,18 @@ mod state_tests { fn screen(cx: &mut TestAppContext) -> Entity { let _guard = SETTINGS_LOCK.lock(); let backend = std::sync::Arc::new( - crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string(), String::new()) - .expect("backend"), + crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string()).expect("backend"), ); // This gpui's test scheduler flags activity on other threads unless // parking is allowed; the backend runtime and image encoder run on tokio. cx.executor().allow_parking(); let screen = cx.new(|cx| { - let mut screen = ChatScreen::new_inner(backend, "user".to_string(), cx); + let mut screen = ChatScreen::new_inner( + backend.clone(), + backend.local_host("user"), + "user".to_string(), + cx, + ); screen.selected_session = Some("s1".to_string()); screen }); @@ -359,28 +363,19 @@ mod state_tests { let _ = std::fs::remove_dir_all(&dir); let config = dir.join("maple-gpui"); std::fs::create_dir_all(&config).unwrap(); - std::fs::write( - config.join("settings.json"), - r#"{"tool_details":false,"default_web_enabled":false}"#, - ) - .unwrap(); + std::fs::write(config.join("settings.json"), r#"{"tool_details":false}"#).unwrap(); let previous = std::env::var_os("XDG_CONFIG_HOME"); unsafe { std::env::set_var("XDG_CONFIG_HOME", &dir) }; // This gpui's test scheduler flags activity on other threads unless // parking is allowed; the backend runtime and image encoder run on tokio. cx.executor().allow_parking(); let screen = cx.new(|cx| { - ChatScreen::new_inner( - std::sync::Arc::new( - crate::backend::AgentBackend::new( - "http://127.0.0.1:9".to_string(), - String::new(), - ) + let backend = std::sync::Arc::new( + crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string()) .expect("backend"), - ), - "user".to_string(), - cx, - ) + ); + let host = backend.local_host("user"); + ChatScreen::new_inner(backend, host, "user".to_string(), cx) }); match previous { Some(value) => unsafe { std::env::set_var("XDG_CONFIG_HOME", value) }, @@ -389,7 +384,6 @@ mod state_tests { let _ = std::fs::remove_dir_all(&dir); screen.update(cx, |this, _cx| { assert!(!this.tool_details); - assert!(!this.default_web_enabled); }); } @@ -947,11 +941,16 @@ mod state_tests { let chat = cx.new(|cx| { let _guard = SETTINGS_LOCK.lock(); let backend = std::sync::Arc::new( - crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string(), String::new()) + crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string()) .expect("backend"), ); crate::desktop::register_key_bindings(cx); - let mut chat = ChatScreen::new_without_start(backend, "user".to_string(), cx); + let mut chat = ChatScreen::new_without_start( + backend.clone(), + backend.local_host("user"), + "user".to_string(), + cx, + ); chat.selected_session = Some("s1".to_string()); chat.booting = false; chat.application_vim_enabled = false; @@ -1337,8 +1336,11 @@ mod state_tests { }); } + /// The host owns the filesystem, so it validates the path; the screen + /// only refuses an empty entry and a second selection while one is in + /// flight. #[gpui::test] - fn test_project_selection_rejects_reentry_and_relative_paths(cx: &mut TestAppContext) { + fn test_project_selection_rejects_reentry_and_blank_paths(cx: &mut TestAppContext) { cx.executor().allow_parking(); let screen = screen(cx); screen.update(cx, |this, cx| { @@ -1346,11 +1348,11 @@ mod state_tests { this.select_project_root(absolute_fixture_root("other"), cx); assert!(this.root_selecting); this.root_selecting = false; - this.select_project_root("relative".to_string(), cx); + this.select_project_root(" ".to_string(), cx); assert!(!this.root_selecting); assert_eq!( this.notice.as_ref().map(SharedString::as_ref), - Some("Enter an absolute directory path") + Some("Enter a directory path") ); }); } @@ -1443,102 +1445,6 @@ mod state_tests { }); } - #[test] - fn test_git_branch_reads_head_and_worktree_pointer() { - struct TempDir(std::path::PathBuf); - impl Drop for TempDir { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - } - } - let branch = |root: &std::path::Path| git_dir(root).as_deref().and_then(git_branch); - let guard = - TempDir(std::env::temp_dir().join(format!("maple-branch-{}", std::process::id()))); - let dir = &guard.0; - let repo = dir.join("repo"); - std::fs::create_dir_all(repo.join(".git")).unwrap(); - std::fs::write(repo.join(".git/HEAD"), "ref: refs/heads/feature/x\n").unwrap(); - assert_eq!(branch(&repo).as_deref(), Some("feature/x")); - - std::fs::write(repo.join(".git/HEAD"), "0123456789abcdef\n").unwrap(); - assert_eq!(branch(&repo).as_deref(), Some("0123456")); - - std::fs::write(repo.join(".git/HEAD"), "garbage-héad\n").unwrap(); - assert_eq!(branch(&repo), None); - std::fs::write(repo.join(".git/HEAD"), "0123456789abcdef\n").unwrap(); - - let worktree = dir.join("wt"); - std::fs::create_dir_all(&worktree).unwrap(); - std::fs::write( - worktree.join(".git"), - format!("gitdir: {}\n", repo.join(".git").display()), - ) - .unwrap(); - assert_eq!(branch(&worktree).as_deref(), Some("0123456")); - - let plain = dir.join("plain"); - std::fs::create_dir_all(&plain).unwrap(); - assert_eq!(branch(&plain), None); - } - - /// Issue #945: the branch watcher must ignore access-only HEAD events - /// (open, read, close). The branch read they trigger emits those same - /// events again under Linux inotify, looping at ~200% CPU while idle. - #[test] - fn test_head_watch_ignores_read_access_events() { - use notify::EventKind; - use notify::event::{ - AccessKind, AccessMode, CreateKind, DataChange, Flag, MetadataKind, ModifyKind, - RemoveKind, RenameMode, - }; - - let head = std::path::PathBuf::from("/repo/.git/HEAD"); - let event = |kind: EventKind| notify::Event::new(kind).add_path(head.clone()); - - // The read side of the loop, as emitted by Linux inotify. - for kind in [ - EventKind::Access(AccessKind::Open(AccessMode::Read)), - EventKind::Access(AccessKind::Read), - EventKind::Access(AccessKind::Close(AccessMode::Read)), - EventKind::Access(AccessKind::Close(AccessMode::Write)), - EventKind::Access(AccessKind::Any), - EventKind::Access(AccessKind::Other), - ] { - assert!(!head_change_event(&event(kind)), "access {kind:?}"); - } - - // Real changes still refresh the label: in-place write, create, - // remove, and the rename pair of an atomic replacement, plus the - // unclassified kinds imprecise backends emit for real changes. - for kind in [ - EventKind::Modify(ModifyKind::Data(DataChange::Content)), - EventKind::Modify(ModifyKind::Any), - EventKind::Modify(ModifyKind::Metadata(MetadataKind::Any)), - EventKind::Modify(ModifyKind::Name(RenameMode::From)), - EventKind::Modify(ModifyKind::Name(RenameMode::To)), - EventKind::Modify(ModifyKind::Name(RenameMode::Both)), - EventKind::Modify(ModifyKind::Name(RenameMode::Any)), - EventKind::Create(CreateKind::File), - EventKind::Create(CreateKind::Any), - EventKind::Remove(RemoveKind::File), - EventKind::Remove(RemoveKind::Any), - EventKind::Any, - EventKind::Other, - ] { - assert!(head_change_event(&event(kind)), "change {kind:?}"); - } - - // Unrelated paths never refresh, even with a change kind. - let unrelated = - notify::Event::new(EventKind::Modify(ModifyKind::Data(DataChange::Content))) - .add_path(std::path::PathBuf::from("/repo/.git/index")); - assert!(!head_change_event(&unrelated)); - - // A required rescan refreshes even without a HEAD path. - let event = notify::Event::new(EventKind::Other).set_flag(Flag::Rescan); - assert!(head_change_event(&event)); - } - #[gpui::test] fn test_escape_closes_root_menu(cx: &mut TestAppContext) { cx.executor().allow_parking(); @@ -2437,42 +2343,18 @@ mod state_tests { /// None mode here would restart every task at Ask First. #[gpui::test] fn test_new_task_takes_the_saved_permission_default(cx: &mut TestAppContext) { - let _guard = SETTINGS_LOCK.lock(); - let dir = std::env::temp_dir().join(format!( - "maple-gpui-test-permission-default-{}", - std::process::id() - )); - let _ = std::fs::remove_dir_all(&dir); - let config = dir.join("maple-gpui"); - std::fs::create_dir_all(&config).unwrap(); - std::fs::write( - config.join("settings.json"), - r#"{"default_permission_mode":"auto"}"#, - ) - .unwrap(); - let previous = std::env::var_os("XDG_CONFIG_HOME"); - unsafe { std::env::set_var("XDG_CONFIG_HOME", &dir) }; cx.executor().allow_parking(); - let screen = cx.new(|cx| { - ChatScreen::new_inner( - std::sync::Arc::new( - crate::backend::AgentBackend::new( - "http://127.0.0.1:9".to_string(), - String::new(), - ) - .expect("backend"), - ), - "user".to_string(), + let screen = screen(cx); + screen.update(cx, |this, cx| { + // The host's bootstrap carries its saved default. + this.apply_session_defaults( + &maple_agent::host::HostSessionDefaults { + permission_mode: "auto".to_string(), + ..Default::default() + }, cx, - ) - }); - match previous { - Some(value) => unsafe { std::env::set_var("XDG_CONFIG_HOME", value) }, - None => unsafe { std::env::remove_var("XDG_CONFIG_HOME") }, - } - let _ = std::fs::remove_dir_all(&dir); - - screen.update(cx, |this, _cx| { + ); + assert_eq!(this.permission_mode, PermissionMode::Auto); this.project_root = Some("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/work/beta".to_string()); let request = this.new_session_request().expect("explicit root request"); assert_eq!( @@ -2754,10 +2636,15 @@ mod state_tests { let chat = cx.new(|cx| { let _guard = SETTINGS_LOCK.lock(); let backend = std::sync::Arc::new( - crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string(), String::new()) + crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string()) .expect("backend"), ); - let mut this = ChatScreen::new_inner(backend, "user".to_string(), cx); + let mut this = ChatScreen::new_inner( + backend.clone(), + backend.local_host("user"), + "user".to_string(), + cx, + ); this.selected_session = Some("s1".to_string()); this.sessions = (0..200) .map(|n| summary(&format!("s{n}"), &format!("Task {n}"))) @@ -2865,10 +2752,15 @@ mod state_tests { let chat = cx.new(move |cx| { let _guard = SETTINGS_LOCK.lock(); let backend = std::sync::Arc::new( - crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string(), String::new()) + crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string()) .expect("backend"), ); - let mut this = ChatScreen::new_inner(backend, "user".to_string(), cx); + let mut this = ChatScreen::new_inner( + backend.clone(), + backend.local_host("user"), + "user".to_string(), + cx, + ); this.selected_session = Some("s1".to_string()); this.application_vim_enabled = application_vim_enabled; this.application_focus = application_vim_enabled.then(|| cx.focus_handle()); @@ -3010,10 +2902,11 @@ mod state_tests { let chat = cx.new(|cx| { let _guard = SETTINGS_LOCK.lock(); let backend = std::sync::Arc::new( - crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string(), String::new()) + crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string()) .expect("backend"), ); - ChatScreen::new(backend, "user".to_string(), cx) + let host = backend.local_host("user"); + ChatScreen::new(backend, host, "user".to_string(), cx) }); chat.update(cx, |this, _cx| { // The real constructor bootstraps and may open the project-trust @@ -3099,11 +2992,16 @@ mod state_tests { let chat = cx.new(|cx| { let _guard = SETTINGS_LOCK.lock(); let backend = std::sync::Arc::new( - crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string(), String::new()) + crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string()) .expect("backend"), ); crate::desktop::register_key_bindings(cx); - let mut chat = ChatScreen::new_inner(backend, "user".to_string(), cx); + let mut chat = ChatScreen::new_inner( + backend.clone(), + backend.local_host("user"), + "user".to_string(), + cx, + ); chat.selected_session = Some("s1".to_string()); chat.application_vim_enabled = true; chat.application_focus = Some(cx.focus_handle()); @@ -3200,11 +3098,16 @@ mod state_tests { let chat = cx.new(|cx| { let _guard = SETTINGS_LOCK.lock(); let backend = std::sync::Arc::new( - crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string(), String::new()) + crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string()) .expect("backend"), ); crate::desktop::register_key_bindings(cx); - let mut chat = ChatScreen::new_without_start(backend, "user".to_string(), cx); + let mut chat = ChatScreen::new_without_start( + backend.clone(), + backend.local_host("user"), + "user".to_string(), + cx, + ); chat.selected_session = Some("s1".to_string()); chat.booting = false; chat.application_vim_enabled = false; @@ -3290,11 +3193,16 @@ mod state_tests { let chat = cx.new(|cx| { let _guard = SETTINGS_LOCK.lock(); let backend = std::sync::Arc::new( - crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string(), String::new()) + crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string()) .expect("backend"), ); crate::desktop::register_key_bindings(cx); - let mut chat = ChatScreen::new_without_start(backend, "user".to_string(), cx); + let mut chat = ChatScreen::new_without_start( + backend.clone(), + backend.local_host("user"), + "user".to_string(), + cx, + ); chat.selected_session = Some("s1".to_string()); chat.booting = false; chat.application_vim_enabled = false; @@ -3483,11 +3391,16 @@ mod state_tests { let chat = cx.new(|cx| { let _guard = SETTINGS_LOCK.lock(); let backend = std::sync::Arc::new( - crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string(), String::new()) + crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string()) .expect("backend"), ); crate::desktop::register_key_bindings(cx); - let mut chat = ChatScreen::new_without_start(backend, "user".to_string(), cx); + let mut chat = ChatScreen::new_without_start( + backend.clone(), + backend.local_host("user"), + "user".to_string(), + cx, + ); chat.selected_session = Some("s1".to_string()); chat.recent_roots = vec![absolute_fixture_root("one"), absolute_fixture_root("two")]; chat.booting = false; @@ -3587,11 +3500,16 @@ mod state_tests { let chat = cx.new(|cx| { let _guard = SETTINGS_LOCK.lock(); let backend = std::sync::Arc::new( - crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string(), String::new()) + crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string()) .expect("backend"), ); crate::desktop::register_key_bindings(cx); - let mut chat = ChatScreen::new_without_start(backend, "user".to_string(), cx); + let mut chat = ChatScreen::new_without_start( + backend.clone(), + backend.local_host("user"), + "user".to_string(), + cx, + ); chat.selected_session = Some("s1".to_string()); chat.booting = false; chat.replace_timeline(vec![user_item("u1", "hello")]); @@ -3652,11 +3570,16 @@ mod state_tests { let chat = cx.new(|cx| { let _guard = SETTINGS_LOCK.lock(); let backend = std::sync::Arc::new( - crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string(), String::new()) + crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string()) .expect("backend"), ); crate::desktop::register_key_bindings(cx); - let mut chat = ChatScreen::new_without_start(backend, "user".to_string(), cx); + let mut chat = ChatScreen::new_without_start( + backend.clone(), + backend.local_host("user"), + "user".to_string(), + cx, + ); chat.selected_session = Some("s1".to_string()); chat.booting = false; chat.replace_timeline(vec![ diff --git a/apps/maple-agent/app/src/ui/login.rs b/apps/maple-agent/app/src/ui/login.rs index 16cad450a..ee1220400 100644 --- a/apps/maple-agent/app/src/ui/login.rs +++ b/apps/maple-agent/app/src/ui/login.rs @@ -414,8 +414,7 @@ mod tests { #[gpui::test] fn tab_reaches_password_and_enter_submits(cx: &mut TestAppContext) { cx.executor().allow_parking(); - let backend = - Arc::new(AgentBackend::new("http://127.0.0.1:9".to_string(), String::new()).unwrap()); + let backend = Arc::new(AgentBackend::new("http://127.0.0.1:9".to_string()).unwrap()); let screen = cx.new(|cx| { crate::desktop::register_key_bindings(cx); LoginScreen::new(backend, cx) @@ -481,8 +480,7 @@ mod tests { #[gpui::test] fn oauth_committed_success_is_delivered_when_back_precedes_ui_receipt(cx: &mut TestAppContext) { cx.executor().allow_parking(); - let backend = - Arc::new(AgentBackend::new("http://127.0.0.1:9".to_string(), String::new()).unwrap()); + let backend = Arc::new(AgentBackend::new("http://127.0.0.1:9".to_string()).unwrap()); let screen = cx.new(|cx| LoginScreen::new(backend, cx)); let signed_in = Rc::new(RefCell::new(Vec::new())); let observed = Rc::clone(&signed_in); diff --git a/apps/maple-agent/app/src/ui/settings.rs b/apps/maple-agent/app/src/ui/settings.rs index 16562f048..766bf2a6c 100644 --- a/apps/maple-agent/app/src/ui/settings.rs +++ b/apps/maple-agent/app/src/ui/settings.rs @@ -19,13 +19,14 @@ use crate::ui::icons::icon; use crate::ui::text_input::TextInput; use crate::backend::AgentBackend; -use crate::settings::{self, AppSettings, PermissionMode, UsageSummary}; +use crate::settings::{self, AppSettings, PermissionMode}; use crate::shortcuts::{ ShortcutConflict, ShortcutConflictKind, ShortcutContextOverlap, ShortcutOverrides, ShortcutSnapshot, }; use crate::ui::theme; use crate::ui::widgets; +use maple_agent::host::{HostBackend, HostSessionDefaults, UsageSummary}; mod account; mod api_keys; @@ -118,8 +119,14 @@ pub struct SettingsScreen { /// [`crate::ui::task::call`]. bridged_tasks: std::cell::RefCell>>, backend: Arc, + /// The host whose integrations, MCP servers, usage, and session + /// defaults the host-scoped sections show. + host: Arc, user_id: String, settings: AppSettings, + /// The host's defaults for new tasks; the built-in defaults until the + /// host answers. + defaults: HostSessionDefaults, /// `settings.theme` parsed once; render only reads the label. theme: theme::Preference, section: Section, @@ -235,13 +242,15 @@ impl EventEmitter for SettingsScreen {} impl SettingsScreen { pub fn new( backend: Arc, + host: Arc, user_id: String, settings: AppSettings, shortcut_snapshot: ShortcutSnapshot, section: Section, cx: &mut Context, ) -> Self { - let prompt_text = settings.effective_harness_instructions(); + let defaults = HostSessionDefaults::default(); + let prompt_text = defaults.effective_harness_instructions(); let application_vim_enabled = settings.application_vim_enabled; let application_focus = cx.focus_handle(); let prompt_application_focus = application_focus.clone(); @@ -283,9 +292,11 @@ impl SettingsScreen { let this = Self { bridged_tasks: std::cell::RefCell::new(Vec::new()), backend, + host, user_id, theme: theme::Preference::parse(&settings.theme), settings, + defaults, section, open_menu: None, menu_selected: None, @@ -321,6 +332,7 @@ impl SettingsScreen { this.load_account(cx); this.load_billing(cx); this.load_api_keys(cx); + this.load_session_defaults(cx); this.load_usage(cx); this.load_plan(cx); this.load_mcp_servers(cx); @@ -357,10 +369,9 @@ impl SettingsScreen { } fn load_mcp_servers(&self, cx: &mut Context) { - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.host.clone(); self.call( - async move { backend.list_mcp_servers(&user_id).await }, + async move { host.list_mcp_servers().await }, cx, |this, result, cx| { match result { @@ -376,10 +387,9 @@ impl SettingsScreen { } fn load_integrations(&self, cx: &mut Context) { - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.host.clone(); self.call( - async move { backend.list_integrations(&user_id).await }, + async move { host.list_integrations().await }, cx, |this, result, cx| { match result { @@ -432,13 +442,11 @@ impl SettingsScreen { self.integration_notice = None; cx.notify(); - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.host.clone(); let request_id = id.clone(); self.call( async move { - backend - .set_integration_enabled(&user_id, &request_id, enabled) + host.set_integration_enabled(request_id.clone(), enabled) .await }, cx, @@ -488,13 +496,14 @@ impl SettingsScreen { cx.notify(); let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.host.clone(); let request_id = id.clone(); self.call( async move { backend - .setup_integration(&user_id, &request_id, permissions) - .await + .open_integration_setup_settings(&permissions) + .await?; + host.setup_integration(request_id).await }, cx, move |this, result, cx| { @@ -530,10 +539,9 @@ impl SettingsScreen { self.mcp_saving = true; self.mcp_notice = None; cx.notify(); - let backend = self.backend.clone(); - let user_id = self.user_id.clone(); + let host = self.host.clone(); self.call( - async move { backend.save_mcp_servers(&user_id, servers).await }, + async move { host.save_mcp_servers(servers).await }, cx, |this, result, cx| { this.mcp_saving = false; @@ -708,21 +716,74 @@ impl SettingsScreen { } fn load_usage(&self, cx: &mut Context) { - let (spawn_backend, usage_backend) = (self.backend.clone(), self.backend.clone()); - let user_id = self.user_id.clone(); - let task = spawn_backend.spawn(async move { - let scope = usage_backend.account_scope(&user_id); - scope.map(|scope| settings::load_usage(&scope)) + let host = self.host.clone(); + self.call( + async move { host.usage_summary().await }, + cx, + |this, result, cx| { + this.usage = Some(result.unwrap_or_else(|error| { + log::debug!("usage summary unavailable: {error}"); + UsageSummary::default() + })); + cx.notify(); + }, + ); + } + + /// Read the host's defaults for new tasks and show them. + fn load_session_defaults(&self, cx: &mut Context) { + let host = self.host.clone(); + self.call( + async move { host.session_defaults().await }, + cx, + |this, result, cx| { + match result { + Ok(defaults) => this.apply_session_defaults(defaults, cx), + Err(message) => this.prompt_notice = Some(message), + } + cx.notify(); + }, + ); + } + + fn apply_session_defaults(&mut self, defaults: HostSessionDefaults, cx: &mut Context) { + let prompt_text = defaults.effective_harness_instructions(); + self.prompt_editor.update(cx, |input, cx| { + if input.text() != prompt_text { + input.set_text(&prompt_text, cx); + } }); - cx.spawn(async move |this, cx| { - let usage = task.await.ok().flatten().unwrap_or_default(); - this.update(cx, |this, cx| { - this.usage = Some(usage); + self.defaults = defaults; + } + + /// The host's permission default as the UI's mode. + fn permission_default(&self) -> PermissionMode { + PermissionMode::parse(&self.defaults.permission_mode) + } + + /// Change the host's session defaults: apply to the local copy, hand + /// the whole record to the host, and re-render. A rejected save puts + /// the host's answer back. + fn edit_session_defaults( + &mut self, + update: impl FnOnce(&mut HostSessionDefaults), + cx: &mut Context, + ) { + update(&mut self.defaults); + let host = self.host.clone(); + let defaults = self.defaults.clone(); + self.call( + async move { host.set_session_defaults(defaults).await }, + cx, + |this, result, cx| { + if let Err(message) = result { + this.prompt_notice = Some(message); + this.load_session_defaults(cx); + } cx.notify(); - }) - .ok(); - }) - .detach(); + }, + ); + cx.notify(); } /// Change one setting: apply it to the local copy, queue the write @@ -774,7 +835,7 @@ impl SettingsScreen { .iter() .map(|&mode| SettingOption { label: mode.label().to_string(), - current: self.settings.default_permission_mode == mode, + current: self.permission_default() == mode, }) .collect(), SettingMenu::Appearance => [ @@ -826,7 +887,7 @@ impl SettingsScreen { /// The saved value shown on the dropdown's trigger button. fn menu_value(&self, menu: SettingMenu) -> String { match menu { - SettingMenu::Permission => self.settings.default_permission_mode.label().to_string(), + SettingMenu::Permission => self.permission_default().label().to_string(), SettingMenu::Appearance => self.theme.label().to_string(), SettingMenu::ChatFont => { crate::ui::typography::ChatFontFamily::parse(&self.settings.chat_font_family) @@ -951,8 +1012,8 @@ impl SettingsScreen { else { return; }; - let mode = *mode; - self.edit_setting(move |settings| settings.default_permission_mode = mode, cx); + let mode = mode.as_str().to_string(); + self.edit_session_defaults(move |defaults| defaults.permission_mode = mode, cx); } SettingMenu::Appearance => { let Some(preference) = [ @@ -1012,8 +1073,8 @@ impl SettingsScreen { } fn toggle_web_default(&mut self, cx: &mut Context) { - let next = !self.settings.default_web_enabled; - self.edit_setting(move |settings| settings.default_web_enabled = next, cx); + let next = !self.defaults.web_enabled; + self.edit_session_defaults(move |defaults| defaults.web_enabled = next, cx); } fn choose_theme(&mut self, preference: theme::Preference, cx: &mut Context) { @@ -1169,20 +1230,20 @@ impl SettingsScreen { self.set_application_vim_enabled(next, cx); } - /// Persist the editor text as the harness instructions and hand it to - /// the running backend. Text equal to the default is saved as empty so - /// a future default change still applies. + /// Persist the editor text as the host's harness instructions. Text + /// equal to the default is saved as empty so a future default change + /// still applies. fn save_prompt(&mut self, cx: &mut Context) { let text = self.prompt_editor.read(cx).text().trim().to_string(); - self.settings.harness_instructions = if text == settings::DEFAULT_HARNESS_INSTRUCTIONS { + let instructions = if text == settings::DEFAULT_HARNESS_INSTRUCTIONS { String::new() } else { text }; - let instructions = self.settings.harness_instructions.clone(); - settings::update_settings_in_background(move |s| s.harness_instructions = instructions); - self.backend - .set_harness_instructions(self.settings.effective_harness_instructions()); + self.edit_session_defaults( + move |defaults| defaults.harness_instructions = instructions, + cx, + ); self.prompt_notice = Some("Saved. New tasks use this prompt.".to_string()); cx.notify(); } @@ -1563,7 +1624,7 @@ impl SettingsScreen { pane = pane .child(section_title("Defaults")) .child({ - let mode = self.settings.default_permission_mode; + let mode = self.permission_default(); self.application_target( || SettingsTarget::General(GeneralTarget::Permission), self.setting_menu_row( @@ -1580,7 +1641,7 @@ impl SettingsScreen { "New tasks can use the web", "Offers web_search and open_url to the model. Each task can \ switch web access on or off from its composer.", - self.settings.default_web_enabled, + self.defaults.web_enabled, cx.listener(|this, _event, _window, cx| { this.toggle_web_default(cx); }), @@ -3223,7 +3284,7 @@ fn stat(label: &str, value: String) -> Div { ) } -fn usage_table(title: &str, rows: &[crate::settings::UsageRow]) -> Div { +fn usage_table(title: &str, rows: &[maple_agent::host::UsageRow]) -> Div { let mut table = div().flex().flex_col().gap_2().child( div() .text_sm() @@ -3361,8 +3422,8 @@ mod tests { #[test] fn shortcut_result_preserves_unrelated_general_setting() { let mut settings = AppSettings::default(); - settings.default_web_enabled = !settings.default_web_enabled; - let expected_web_enabled = settings.default_web_enabled; + settings.tool_details = !settings.tool_details; + let expected_tool_details = settings.tool_details; settings .shortcut_overrides .insert("chat.focus_search".into(), None); @@ -3373,7 +3434,7 @@ mod tests { merge_shortcut_overrides(&mut settings, overrides.clone()); - assert_eq!(settings.default_web_enabled, expected_web_enabled); + assert_eq!(settings.tool_details, expected_tool_details); assert_eq!(settings.shortcut_overrides, overrides); } diff --git a/apps/maple-agent/app/src/ui/settings/navigation.rs b/apps/maple-agent/app/src/ui/settings/navigation.rs index 182bc912d..ba5bb81cf 100644 --- a/apps/maple-agent/app/src/ui/settings/navigation.rs +++ b/apps/maple-agent/app/src/ui/settings/navigation.rs @@ -685,12 +685,12 @@ mod tests { fn integration_targets_follow_the_visible_control_order(cx: &mut TestAppContext) { cx.executor().allow_parking(); let backend = std::sync::Arc::new( - crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string(), String::new()) - .expect("backend"), + crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string()).expect("backend"), ); let settings = cx.new(|cx| { SettingsScreen::new( - backend, + backend.clone(), + backend.local_host("user"), "user".to_string(), crate::settings::AppSettings::default(), crate::shortcuts::ShortcutSnapshot { @@ -771,12 +771,12 @@ mod tests { fn application_vim_off_keeps_settings_projection_empty(cx: &mut TestAppContext) { cx.executor().allow_parking(); let backend = std::sync::Arc::new( - crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string(), String::new()) - .expect("backend"), + crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string()).expect("backend"), ); let settings = cx.new(|cx| { SettingsScreen::new( - backend, + backend.clone(), + backend.local_host("user"), "user".to_string(), crate::settings::AppSettings { application_vim_enabled: false, @@ -845,12 +845,12 @@ mod tests { } let backend = std::sync::Arc::new( - crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string(), String::new()) - .expect("backend"), + crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string()).expect("backend"), ); let settings = cx.new(|cx| { SettingsScreen::new( - backend, + backend.clone(), + backend.local_host("user"), "user".to_string(), crate::settings::AppSettings { application_vim_enabled: true, @@ -910,12 +910,12 @@ mod tests { } let backend = std::sync::Arc::new( - crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string(), String::new()) - .expect("backend"), + crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string()).expect("backend"), ); let settings = cx.new(|cx| { SettingsScreen::new( - backend, + backend.clone(), + backend.local_host("user"), "user".to_string(), crate::settings::AppSettings { application_vim_enabled: true, @@ -987,12 +987,12 @@ mod tests { fn dropdown_screen(cx: &mut TestAppContext, application_vim: bool) -> Entity { cx.executor().allow_parking(); let backend = std::sync::Arc::new( - crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string(), String::new()) - .expect("backend"), + crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string()).expect("backend"), ); cx.new(|cx| { SettingsScreen::new( - backend, + backend.clone(), + backend.local_host("user"), "user".to_string(), crate::settings::AppSettings { application_vim_enabled: application_vim, diff --git a/apps/maple-agent/crates/maple-agent/Cargo.toml b/apps/maple-agent/crates/maple-agent/Cargo.toml index 812dc339b..680265177 100644 --- a/apps/maple-agent/crates/maple-agent/Cargo.toml +++ b/apps/maple-agent/crates/maple-agent/Cargo.toml @@ -38,6 +38,13 @@ sha2 = "0.10" agent-client-protocol = { version = "=2.0.0", optional = true, default-features = false, features = ["unstable_elicitation", "unstable_end_turn_token_usage"] } base64 = "0.22" bytes = "1" +# Host-side readers of the account stores: the Goose usage ledger (read-only) +# and the app-owned tool summary store. +rusqlite = { version = "0.32", features = ["bundled"] } +# Watches a project root's git dir so the branch shown to clients follows a +# checkout made by the agent or from a terminal. +notify = "8" +dirs = "6" [target.'cfg(windows)'.dependencies] windows = { version = "0.62.2", features = ["Win32_System_Threading"] } diff --git a/apps/maple-agent/crates/maple-agent/src/agent.rs b/apps/maple-agent/crates/maple-agent/src/agent.rs index 5f1e840d2..81bb8f620 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent.rs @@ -951,6 +951,11 @@ impl MapleAgentService { /// Desktop commands create a fresh handle at their boundary. Long-lived /// adapters such as ACP retain a handle, which makes account clearing an /// explicit revocation point instead of silently rebinding the adapter. + /// Where this service keeps its configuration and account data. + pub fn paths(&self) -> &AgentPathLayout { + &self.host.paths + } + pub async fn handle_for_user(&self, user_id: &str) -> Result { let account_scope = account_scope(user_id)?; let generation = account_generation(self, &account_scope).await; @@ -14255,6 +14260,7 @@ mod tests { trusted: true, }], removed_project_roots: Vec::new(), + ..AgentConfig::default() }; apply_project_root_removal(&mut config, &removed, Some(&fallback)).unwrap(); @@ -15395,6 +15401,7 @@ mod tests { mcp_servers: Vec::new(), project_trust: Vec::new(), removed_project_roots: Vec::new(), + ..AgentConfig::default() }; assert!(migrate_agent_config(&mut config)); @@ -15410,6 +15417,7 @@ mod tests { mcp_servers: Vec::new(), project_trust: Vec::new(), removed_project_roots: Vec::new(), + ..AgentConfig::default() }; assert!(migrate_agent_config(&mut config)); @@ -15425,6 +15433,7 @@ mod tests { mcp_servers: Vec::new(), project_trust: Vec::new(), removed_project_roots: Vec::new(), + ..AgentConfig::default() }; assert!(migrate_agent_config(&mut config)); @@ -15441,6 +15450,7 @@ mod tests { mcp_servers: Vec::new(), project_trust: Vec::new(), removed_project_roots: Vec::new(), + ..AgentConfig::default() }; assert!(!migrate_agent_config(&mut config)); @@ -15462,6 +15472,7 @@ mod tests { mcp_servers: Vec::new(), project_trust: Vec::new(), removed_project_roots: vec![removed.clone()], + ..AgentConfig::default() }; let resolved = resolve_project_root(None, &config).unwrap(); diff --git a/apps/maple-agent/crates/maple-agent/src/agent/attachments.rs b/apps/maple-agent/crates/maple-agent/src/agent/attachments.rs index 975f2a1ec..e6aad7e72 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/attachments.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/attachments.rs @@ -8,7 +8,7 @@ use std::path::{Path, PathBuf}; pub(super) const MAX_AGENT_IMAGE_BYTES: usize = 10 * 1024 * 1024; pub(super) const MAX_AGENT_IMAGES_PER_MESSAGE: usize = 10; -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct AgentImageUpload { pub name: String, diff --git a/apps/maple-agent/crates/maple-agent/src/agent/types.rs b/apps/maple-agent/crates/maple-agent/src/agent/types.rs index 934836498..8bee1a562 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/types.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/types.rs @@ -25,6 +25,17 @@ pub struct AgentConfig { pub project_trust: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub removed_project_roots: Vec, + /// Permission policy for new tasks: `smart_approve` or `auto`. `None` + /// means the host never saved one and the default applies. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_permission_mode: Option, + /// Whether new tasks can use the web tools; `None` means the default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_web_enabled: Option, + /// Opening system prompt text for tasks this host runs. `None` or blank + /// means the built-in default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub harness_instructions: Option, } pub(super) fn default_agent_model() -> String { @@ -54,6 +65,9 @@ impl Default for AgentConfig { mcp_servers: Vec::new(), project_trust: Vec::new(), removed_project_roots: Vec::new(), + default_permission_mode: None, + default_web_enabled: None, + harness_instructions: None, } } } @@ -65,13 +79,13 @@ pub struct AgentProjectTrust { pub trusted: bool, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum AgentProjectTrustFeature { Skills, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentProjectTrustStatus { pub path: String, @@ -122,7 +136,7 @@ pub struct AgentMcpServer { /// Integration discovery is intentionally separate from MCP configuration: /// an integration may be installed without being enabled, and device-local /// launch details must not leak into the account's roaming configuration. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentIntegration { pub id: String, @@ -163,7 +177,7 @@ pub enum AgentIntegrationBackend { /// can be read before use, while portal-based desktops grant capability per /// session at first use and therefore require none up front. Callers must not /// re-derive that per-platform knowledge; ask [`AgentIntegrationPermissions`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum AgentIntegrationPermissionKind { Accessibility, @@ -201,7 +215,7 @@ impl AgentIntegrationPermissionKind { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentIntegrationPermission { pub kind: AgentIntegrationPermissionKind, @@ -214,7 +228,7 @@ pub struct AgentIntegrationPermission { /// An empty requirement list means the platform needs no pre-flight grant, so /// [`AgentIntegrationPermissions::ready`] is true. That is the single place /// where "may this integration run" is decided. -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentIntegrationPermissions { pub required: Vec, @@ -248,7 +262,7 @@ impl AgentIntegrationPermissions { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum AgentIntegrationAvailability { NotDetected, @@ -256,14 +270,14 @@ pub enum AgentIntegrationAvailability { Available, } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct AgentSetIntegrationEnabledRequest { pub id: String, pub enabled: bool, } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct AgentSetupIntegrationRequest { pub id: String, @@ -296,7 +310,7 @@ pub(super) fn default_mcp_timeout_seconds() -> u64 { } /// A skill-derived slash command the composer can offer. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentSlashCommand { pub name: String, @@ -305,7 +319,7 @@ pub struct AgentSlashCommand { } /// One answer choice, mirroring codex's request_user_input option. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentQuestionOption { pub label: String, @@ -315,7 +329,7 @@ pub struct AgentQuestionOption { /// One question in a request_user_input call: one to three related /// questions ride a single call and are answered together. The client adds /// a free-form "Other" answer next to these options. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentQuestion { pub id: String, @@ -324,7 +338,7 @@ pub struct AgentQuestion { pub options: Vec, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentMcpConnectionError { pub name: String, @@ -335,7 +349,8 @@ pub(super) const TTS_MODEL: &str = "voxtral-tts"; pub(super) const TRANSCRIPTION_MODEL: &str = "whisper-large-v3"; /// Voice endpoints the signed-in account can use. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct AudioCapabilities { pub transcription: bool, pub speech: bool, @@ -456,7 +471,7 @@ pub enum AgentSessionIntegrationKind { ExternalAgent, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentSessionMcpServer { pub name: String, @@ -468,7 +483,7 @@ pub struct AgentSessionMcpServer { pub available: bool, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct AgentSetSessionMcpServerRequest { pub session_id: String, @@ -486,7 +501,7 @@ pub struct AgentStartRequest { pub mode: Option, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentRuntimeStatus { pub running: bool, @@ -504,7 +519,7 @@ pub struct RecentProjectRoot { pub last_used_ms: u128, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentProjectRootRegistration { pub project_root: String, @@ -512,7 +527,7 @@ pub struct AgentProjectRootRegistration { pub config: AgentConfig, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct AgentCreateSessionRequest { pub project_root: Option, @@ -528,7 +543,7 @@ pub struct AgentCreateSessionRequest { pub system_prompt: Option, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct AgentSendMessageRequest { pub session_id: String, @@ -547,14 +562,14 @@ pub struct AgentSendMessageRequest { pub attachments: Vec, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct AgentRenameSessionRequest { pub session_id: String, pub title: String, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct AgentPermissionResponse { pub session_id: String, @@ -562,7 +577,8 @@ pub struct AgentPermissionResponse { pub decision: String, } -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct AgentPermissionRequest { pub request_id: String, pub tool_name: String, @@ -570,7 +586,8 @@ pub struct AgentPermissionRequest { pub prompt: Option, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] pub enum AgentPermissionDecision { AllowOnce, DenyOnce, @@ -601,21 +618,21 @@ pub enum AgentPermissionRouting { CallingSurface, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct AgentPermissionModeRequest { pub session_id: String, pub mode: String, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct AgentSetSessionWebRequest { pub session_id: String, pub enabled: bool, } -#[derive(Debug, Clone, PartialEq, Serialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentQueuedMessage { pub queue_id: String, @@ -624,25 +641,26 @@ pub struct AgentQueuedMessage { pub text: String, pub attachments: Vec, pub created_ms: u128, - #[serde(skip)] + #[serde(skip, default = "Message::user")] pub(super) message: Message, } -#[derive(Debug, Clone, PartialEq, Serialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentDesktopQueueSnapshot { pub revision: u64, pub items: Vec, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct AgentQueueControlRequest { pub session_id: String, pub queue_id: String, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] pub enum AgentRunTerminal { Completed, Cancelled, @@ -661,7 +679,8 @@ pub struct AgentRunHandle { pub queue: AgentDesktopQueueSnapshot, } -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct AgentRunUsage { pub(crate) input_tokens: u64, pub(crate) output_tokens: u64, @@ -1033,7 +1052,8 @@ impl Drop for AgentToolContextLease { } } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub enum AgentRunEvent { SessionUpdated(AgentSessionSummary), Started, @@ -1074,7 +1094,8 @@ pub enum AgentRunEvent { }, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub enum AgentServiceEvent { RuntimeStatus(AgentRuntimeStatus), /// The agent asked the user one or more related questions (ask_user @@ -1111,7 +1132,7 @@ pub enum AgentServiceEvent { /// One subagent that is still working for a task. A caller that opens /// the task after the run ended reads these to rebuild its live view. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentSubagent { /// Request ID of the `delegate` call that started it. @@ -1130,7 +1151,7 @@ pub struct AgentSubagent { } /// Which external agent a subagent row stands for. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ExternalAgentRef { pub provider: String, @@ -1139,20 +1160,22 @@ pub struct ExternalAgentRef { /// One finished exchange of a `/btw` thread, replayed on a follow-up so /// the model sees the earlier side questions and answers. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct SideQuestionTurn { pub question: String, pub answer: String, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub enum SideQuestionEvent { Chunk(String), Finished, Error(String), } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentSessionSummary { pub id: String, @@ -1171,7 +1194,7 @@ pub struct AgentSessionSummary { pub acp: bool, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentSessionDetail { pub session: AgentSessionSummary, @@ -1180,7 +1203,7 @@ pub struct AgentSessionDetail { pub queue: AgentDesktopQueueSnapshot, } -#[derive(Debug, Clone, PartialEq, Serialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentTimelineItem { pub id: String, diff --git a/apps/maple-agent/crates/maple-agent/src/host/directories.rs b/apps/maple-agent/crates/maple-agent/src/host/directories.rs new file mode 100644 index 000000000..6a4cf30ab --- /dev/null +++ b/apps/maple-agent/crates/maple-agent/src/host/directories.rs @@ -0,0 +1,140 @@ +//! Directory completion for a typed project root. +//! +//! The host owns the filesystem, so it answers "what directories match +//! what I typed so far". Clients show the answer as-is and never parse or +//! filter paths themselves. + +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +/// One directory a typed root could mean. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DirectorySuggestion { + /// Absolute path. + pub path: String, + /// Last path component, for display. + pub name: String, +} + +/// Most suggestions one answer carries. +pub const SUGGESTION_LIMIT: usize = 50; + +/// Directories that complete `query`, absolute, sorted by name. +/// +/// An empty query lists the home directory. A query ending in a +/// separator lists that directory. Otherwise the last component is a +/// prefix filter on its parent. Hidden directories appear only when the +/// prefix starts with a dot. A leading `~` means the home directory. +/// Blocking: call from a blocking thread. +pub fn suggest(query: &str, home: Option<&Path>) -> Vec { + let query = query.trim(); + let expanded = match query.strip_prefix('~') { + Some(rest) => match home { + Some(home) => format!("{}{}", home.display(), rest), + None => return Vec::new(), + }, + None if query.is_empty() => match home { + Some(home) => format!("{}{}", home.display(), std::path::MAIN_SEPARATOR), + None => return Vec::new(), + }, + None => query.to_string(), + }; + let (parent, prefix) = split_query(&expanded); + if !parent.is_absolute() { + return Vec::new(); + } + let Ok(entries) = std::fs::read_dir(&parent) else { + return Vec::new(); + }; + let show_hidden = prefix.starts_with('.'); + let mut matches: Vec = entries + .flatten() + .filter(|entry| entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false)) + .filter_map(|entry| { + let name = entry.file_name().to_string_lossy().into_owned(); + if !show_hidden && name.starts_with('.') { + return None; + } + if !name.to_lowercase().starts_with(&prefix.to_lowercase()) { + return None; + } + Some(DirectorySuggestion { + path: entry.path().to_string_lossy().into_owned(), + name, + }) + }) + .collect(); + matches.sort_by_key(|suggestion| suggestion.name.to_lowercase()); + matches.truncate(SUGGESTION_LIMIT); + matches +} + +/// The directory to list and the name prefix to match in it. +fn split_query(query: &str) -> (PathBuf, String) { + if query.ends_with(std::path::MAIN_SEPARATOR) || query.ends_with('/') { + return (PathBuf::from(query), String::new()); + } + let path = Path::new(query); + let prefix = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default(); + let parent = path + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(query)); + (parent, prefix) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture() -> PathBuf { + let root = std::env::temp_dir().join(format!( + "maple-dirs-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + for name in ["projects", "Photos", ".hidden", "plain"] { + std::fs::create_dir_all(root.join(name)).unwrap(); + } + std::fs::write(root.join("pfile"), "x").unwrap(); + root + } + + #[test] + fn prefix_filters_case_insensitively_and_skips_files_and_hidden() { + let root = fixture(); + let query = format!("{}/p", root.display()); + let names: Vec = suggest(&query, None).into_iter().map(|s| s.name).collect(); + assert_eq!(names, vec!["Photos", "plain", "projects"]); + let hidden: Vec = suggest(&format!("{}/.h", root.display()), None) + .into_iter() + .map(|s| s.name) + .collect(); + assert_eq!(hidden, vec![".hidden"]); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn trailing_separator_lists_the_directory_and_tilde_means_home() { + let root = fixture(); + let all = suggest(&format!("{}/", root.display()), None); + assert_eq!(all.len(), 3, "hidden stays out without a dot prefix"); + assert!(all.iter().all(|s| Path::new(&s.path).is_absolute())); + let via_home = suggest("~/pr", Some(&root)); + assert_eq!(via_home.len(), 1); + assert_eq!(via_home[0].name, "projects"); + let empty = suggest("", Some(&root)); + assert_eq!(empty.len(), 3); + assert!(suggest("relative/path", None).is_empty()); + assert!(suggest("", None).is_empty()); + let _ = std::fs::remove_dir_all(root); + } +} diff --git a/apps/maple-agent/crates/maple-agent/src/host/git.rs b/apps/maple-agent/crates/maple-agent/src/host/git.rs new file mode 100644 index 000000000..25994aa89 --- /dev/null +++ b/apps/maple-agent/crates/maple-agent/src/host/git.rs @@ -0,0 +1,343 @@ +//! Git branch reporting for watched project roots. +//! +//! The host, not the client, owns the checkout, so it reads `HEAD` and +//! watches the git dir. Clients receive [`HostEvent::ProjectBranch`] when +//! a watch starts and whenever the branch may have changed. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use notify::Watcher as _; + +use super::{HostEvent, HostEventHub}; + +/// The directory that holds `HEAD` for a checkout, or `None` when `root` +/// is not one. Supports worktrees, whose `.git` is a file that points at +/// the real git dir. +pub fn git_dir(root: &Path) -> Option { + let dot_git = root.join(".git"); + if dot_git.is_dir() { + return Some(dot_git); + } + let pointer = std::fs::read_to_string(&dot_git).ok()?; + let target = pointer.trim().strip_prefix("gitdir:")?.trim(); + let target = Path::new(target); + Some(if target.is_absolute() { + target.to_path_buf() + } else { + root.join(target) + }) +} + +/// Current git branch from a git dir, or the short commit id when HEAD +/// is detached. `None` when there is no readable `HEAD`. +pub fn git_branch(git_dir: &Path) -> Option { + let head = std::fs::read_to_string(git_dir.join("HEAD")).ok()?; + let head = head.trim(); + match head.strip_prefix("ref: ") { + Some(reference) => Some( + reference + .strip_prefix("refs/heads/") + .unwrap_or(reference) + .to_string(), + ), + // Detached: a hex id. Anything else is a corrupt HEAD. + None => head + .get(..7) + .filter(|id| id.bytes().all(|byte| byte.is_ascii_hexdigit())) + .map(str::to_string), + } +} + +/// The branch of `root`, resolving its git dir first. +pub fn branch_of(root: &Path) -> Option { + git_dir(root).as_deref().and_then(git_branch) +} + +/// True when a watcher event means the branch may have changed: a semantic +/// change to a `HEAD` path (write, create, remove, or the rename pair of an +/// atomic replacement), or a rescan the backend requires. Access-only events +/// (open, read, close) are dropped: the branch read they would trigger emits +/// those same events again under Linux inotify, looping the watcher at full +/// CPU while idle (#945). Real writes still arrive as `Modify` on every +/// backend, so no true change is lost; `Any`/`Other` stay forwarded for +/// backends that cannot classify. +pub fn head_change_event(event: ¬ify::Event) -> bool { + if event.need_rescan() { + return true; + } + if matches!(event.kind, notify::EventKind::Access(_)) { + return false; + } + event + .paths + .iter() + .any(|path| path.file_name().is_some_and(|name| name == "HEAD")) +} + +struct BranchWatch { + /// Clients watching this root. The watcher lives while any remain. + watchers: usize, + /// `None` when the root is not a checkout or the watch could not start. + _watcher: Option, +} + +/// One watcher per root, shared by every client that asked for it. +#[derive(Default)] +pub struct BranchWatchers { + roots: Mutex>, +} + +impl BranchWatchers { + /// Report the branch of `root` now, and keep reporting on change. + /// Must run inside a Tokio runtime: the change reader is a task. + pub fn watch(&self, root: String, events: Arc) { + { + let mut roots = self + .roots + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(watch) = roots.get_mut(&root) { + watch.watchers += 1; + // A new client wants the current branch even though the + // watch is already running. + publish_branch(&events, root.clone()); + return; + } + } + let watcher = start_watcher(&root, Arc::clone(&events)); + self.roots + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert( + root.clone(), + BranchWatch { + watchers: 1, + _watcher: watcher, + }, + ); + publish_branch(&events, root); + } + + /// Drop one client's interest in `root`. The watcher stops with the + /// last one. + pub fn unwatch(&self, root: &str) { + let mut roots = self + .roots + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(watch) = roots.get_mut(root) { + watch.watchers = watch.watchers.saturating_sub(1); + if watch.watchers == 0 { + roots.remove(root); + } + } + } + + #[cfg(test)] + pub(crate) fn watched_roots(&self) -> Vec { + self.roots + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .keys() + .cloned() + .collect() + } +} + +/// Read the branch off the async workers and publish it. +fn publish_branch(events: &Arc, root: String) { + let events = Arc::clone(events); + tokio::task::spawn_blocking(move || { + let branch = branch_of(Path::new(&root)); + events.publish(HostEvent::ProjectBranch { + project_root: root, + branch, + }); + }); +} + +/// Watch the git dir of `root` and publish the branch when `HEAD` +/// changes. The watch is on the directory, not the file: git replaces +/// `HEAD` by rename, so a watch on the file itself is lost after the first +/// checkout. Non-recursive, so a busy `objects/` tree costs nothing. +/// Events arrive on the watcher's own thread and cross to a task through a +/// channel; a rebase or checkout touches `HEAD` several times in a row, so +/// one read per burst is enough. +fn start_watcher(root: &str, events: Arc) -> Option { + let git_dir = git_dir(Path::new(root))?; + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let mut watcher = + match notify::recommended_watcher(move |event: notify::Result| { + let Ok(event) = event else { return }; + if head_change_event(&event) { + tx.send(()).ok(); + } + }) { + Ok(watcher) => watcher, + Err(error) => { + log::debug!("branch watcher unavailable: {error}"); + return None; + } + }; + if let Err(error) = watcher.watch(&git_dir, notify::RecursiveMode::NonRecursive) { + log::debug!("cannot watch {}: {error}", git_dir.display()); + return None; + } + let root = root.to_string(); + tokio::spawn(async move { + while rx.recv().await.is_some() { + while rx.try_recv().is_ok() {} + let branch = { + let root = root.clone(); + tokio::task::spawn_blocking(move || branch_of(Path::new(&root))) + .await + .unwrap_or(None) + }; + events.publish(HostEvent::ProjectBranch { + project_root: root.clone(), + branch, + }); + } + }); + Some(watcher) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_root(tag: &str) -> PathBuf { + let root = std::env::temp_dir().join(format!("maple-git-{tag}-{}", uuid_like())); + std::fs::create_dir_all(&root).unwrap(); + root + } + + fn uuid_like() -> u128 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + ^ (std::process::id() as u128) + } + + #[test] + fn branch_reads_refs_detached_heads_and_worktree_pointers() { + let root = temp_root("branch"); + let git = root.join(".git"); + std::fs::create_dir_all(&git).unwrap(); + std::fs::write(git.join("HEAD"), "ref: refs/heads/feature/x\n").unwrap(); + assert_eq!(branch_of(&root).as_deref(), Some("feature/x")); + + std::fs::write(git.join("HEAD"), "0123abcdef0123abcdef\n").unwrap(); + assert_eq!(branch_of(&root).as_deref(), Some("0123abc")); + + std::fs::write(git.join("HEAD"), "garbage\n").unwrap(); + assert_eq!(branch_of(&root), None); + + let worktree = temp_root("worktree"); + std::fs::write( + worktree.join(".git"), + format!("gitdir: {}\n", git.display()), + ) + .unwrap(); + std::fs::write(git.join("HEAD"), "ref: refs/heads/main\n").unwrap(); + assert_eq!(branch_of(&worktree).as_deref(), Some("main")); + + let plain = temp_root("plain"); + assert_eq!(branch_of(&plain), None); + for dir in [root, worktree, plain] { + let _ = std::fs::remove_dir_all(dir); + } + } + + /// Issue #945: the branch watcher must ignore access-only HEAD events + /// (open, read, close). The branch read they trigger emits those same + /// events again under Linux inotify, looping at ~200% CPU while idle. + #[test] + fn head_change_ignores_access_events() { + use notify::EventKind; + use notify::event::{ + AccessKind, AccessMode, CreateKind, DataChange, Flag, MetadataKind, ModifyKind, + RemoveKind, RenameMode, + }; + + let head = PathBuf::from("/repo/.git/HEAD"); + let event = |kind: EventKind| notify::Event::new(kind).add_path(head.clone()); + + // The read side of the loop, as emitted by Linux inotify. + for kind in [ + EventKind::Access(AccessKind::Open(AccessMode::Read)), + EventKind::Access(AccessKind::Read), + EventKind::Access(AccessKind::Close(AccessMode::Read)), + EventKind::Access(AccessKind::Close(AccessMode::Write)), + EventKind::Access(AccessKind::Any), + EventKind::Access(AccessKind::Other), + ] { + assert!(!head_change_event(&event(kind)), "access {kind:?}"); + } + + // Real changes still refresh the label: in-place write, create, + // remove, and the rename pair of an atomic replacement, plus the + // unclassified kinds imprecise backends emit for real changes. + for kind in [ + EventKind::Modify(ModifyKind::Data(DataChange::Content)), + EventKind::Modify(ModifyKind::Any), + EventKind::Modify(ModifyKind::Metadata(MetadataKind::Any)), + EventKind::Modify(ModifyKind::Name(RenameMode::From)), + EventKind::Modify(ModifyKind::Name(RenameMode::To)), + EventKind::Modify(ModifyKind::Name(RenameMode::Both)), + EventKind::Modify(ModifyKind::Name(RenameMode::Any)), + EventKind::Create(CreateKind::File), + EventKind::Create(CreateKind::Any), + EventKind::Remove(RemoveKind::File), + EventKind::Remove(RemoveKind::Any), + EventKind::Any, + EventKind::Other, + ] { + assert!(head_change_event(&event(kind)), "change {kind:?}"); + } + + // Unrelated paths never refresh, even with a change kind. + let unrelated = + notify::Event::new(EventKind::Modify(ModifyKind::Data(DataChange::Content))) + .add_path(PathBuf::from("/repo/.git/index")); + assert!(!head_change_event(&unrelated)); + + // A required rescan refreshes even without a HEAD path. + let event = notify::Event::new(EventKind::Other).set_flag(Flag::Rescan); + assert!(head_change_event(&event)); + } + + #[tokio::test] + async fn watchers_are_shared_and_dropped_with_the_last_client() { + let root = temp_root("shared"); + let git = root.join(".git"); + std::fs::create_dir_all(&git).unwrap(); + std::fs::write(git.join("HEAD"), "ref: refs/heads/main\n").unwrap(); + let hub = Arc::new(HostEventHub::default()); + let mut rx = hub.subscribe(); + let watchers = BranchWatchers::default(); + let path = root.to_string_lossy().to_string(); + watchers.watch(path.clone(), Arc::clone(&hub)); + watchers.watch(path.clone(), Arc::clone(&hub)); + assert_eq!(watchers.watched_roots(), vec![path.clone()]); + // Both watch calls report the branch. + for _ in 0..2 { + let event = tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv()) + .await + .unwrap() + .unwrap(); + assert!(matches!( + event, + HostEvent::ProjectBranch { branch: Some(ref branch), .. } if branch == "main" + )); + } + watchers.unwatch(&path); + assert_eq!(watchers.watched_roots(), vec![path.clone()]); + watchers.unwatch(&path); + assert!(watchers.watched_roots().is_empty()); + let _ = std::fs::remove_dir_all(root); + } +} diff --git a/apps/maple-agent/crates/maple-agent/src/host/local.rs b/apps/maple-agent/crates/maple-agent/src/host/local.rs new file mode 100644 index 000000000..f2bab1a57 --- /dev/null +++ b/apps/maple-agent/crates/maple-agent/src/host/local.rs @@ -0,0 +1,813 @@ +//! The in-process host: [`HostBackend`] over [`AgentRuntimeHandle`]. +//! +//! The local window uses this directly. A server that publishes the same +//! runtime to remote clients is a sibling consumer of the runtime handle, +//! not a layer over this type. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; + +use async_trait::async_trait; +use tokio::sync::mpsc; + +use super::directories::{self, DirectorySuggestion}; +use super::git::BranchWatchers; +use super::store::{self, AccountStores, UsageSummary}; +use super::{ + ContextUsage, HostBackend, HostBootstrap, HostEvent, HostEventHub, HostId, HostSessionDefaults, + PERMISSION_MODE_AUTO, PERMISSION_MODE_SMART_APPROVE, effective_harness_instructions, +}; +use crate::agent::{ + AgentConfig, AgentCreateSessionRequest, AgentDesktopQueueSnapshot, AgentIntegration, + AgentMcpServer, AgentPermissionModeRequest, AgentPermissionResponse, + AgentProjectRootRegistration, AgentProjectTrustStatus, AgentQueueControlRequest, + AgentRenameSessionRequest, AgentRuntimeHandle, AgentRuntimeStatus, AgentSendMessageRequest, + AgentSessionDetail, AgentSessionIntegrationKind, AgentSessionMcpServer, AgentSessionSummary, + AgentSetIntegrationEnabledRequest, AgentSetSessionMcpServerRequest, AgentSetSessionWebRequest, + AgentSetupIntegrationRequest, AgentSlashCommand, AgentStartRequest, AgentSubagent, + MapleAgentService, RecentProjectRoot, SideQuestionTurn, account_sessions_db_path, + account_tool_summaries_db_path, +}; +use crate::maple_api::MapleApiSession; + +/// Where the local host gets the validated OpenSecret session it needs to +/// start the runtime and rename tasks. The app implements this over its +/// persisted sign-in; it waits for a background credential restore first. +#[async_trait] +pub trait LocalHostAuth: Send + Sync + 'static { + async fn api_session(&self, user_id: &str) -> Result, String>; +} + +/// Session defaults an older app kept in its own settings file. The local +/// host adopts them once into the account config; see +/// [`LocalHostBackend::migrate_session_defaults`]. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct LegacySessionDefaults { + pub permission_mode: Option, + pub web_enabled: Option, + pub harness_instructions: Option, +} + +impl LegacySessionDefaults { + pub fn is_empty(&self) -> bool { + self.permission_mode.is_none() + && self.web_enabled.is_none() + && self.harness_instructions.is_none() + } +} + +/// Fallback context limit when the catalog lacks the model. +const DEFAULT_CONTEXT_LIMIT: i64 = 200_000; + +/// How long a runtime start may take before a wedged enclave connection +/// surfaces as an error instead of an eternal spinner. +const RUNTIME_START_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); + +pub struct LocalHostBackend { + id: HostId, + service: MapleAgentService, + user_id: String, + auth: Arc, + events: Arc, + stores: Arc, + branches: BranchWatchers, +} + +impl LocalHostBackend { + /// Bind the runtime to `user_id`. `events` must be the sink the + /// service was built with, so runtime events and host events share one + /// stream. The account's saved harness instructions reach the runtime + /// on [`Self::apply_saved_harness`], which the bootstrap and runtime + /// start call first. + pub fn new( + service: MapleAgentService, + user_id: String, + auth: Arc, + events: Arc, + ) -> Arc { + Arc::new(Self { + id: HostId::local(), + service, + user_id, + auth, + events, + stores: Arc::new(AccountStores::default()), + branches: BranchWatchers::default(), + }) + } + + /// Hand the account's saved harness instructions to the runtime. + /// Applies to agents built afterwards. + pub async fn apply_saved_harness(&self) -> Result<(), String> { + let config = self.handle().await?.load_config().await?; + self.apply_harness(&config); + Ok(()) + } + + pub fn user_id(&self) -> &str { + &self.user_id + } + + /// The events hub, for callers that need to publish alongside the + /// runtime (tests, and the server later). + pub fn events(&self) -> &Arc { + &self.events + } + + async fn handle(&self) -> Result { + self.service.handle_for_user(&self.user_id).await + } + + async fn api_session(&self) -> Result, String> { + self.auth.api_session(&self.user_id).await + } + + fn apply_harness(&self, config: &AgentConfig) { + self.service + .set_harness_instructions(effective_harness_instructions( + config.harness_instructions.as_deref().unwrap_or(""), + )); + } + + /// Adopt defaults from an older app settings file into the account + /// config, for each value the config never saved. Values the config + /// already holds win. + pub async fn migrate_session_defaults( + &self, + legacy: LegacySessionDefaults, + ) -> Result<(), String> { + if legacy.is_empty() { + return Ok(()); + } + let handle = self.handle().await?; + let mut config = handle.load_config().await?; + let mut changed = false; + if config.default_permission_mode.is_none() + && let Some(mode) = legacy.permission_mode + { + config.default_permission_mode = Some(normalize_permission_mode(&mode)); + changed = true; + } + if config.default_web_enabled.is_none() + && let Some(enabled) = legacy.web_enabled + { + config.default_web_enabled = Some(enabled); + changed = true; + } + if config.harness_instructions.is_none() + && let Some(text) = legacy.harness_instructions + { + config.harness_instructions = Some(text); + changed = true; + } + if changed { + log::info!("adopted session defaults from the app settings into the account config"); + handle.save_config(config.clone()).await?; + self.apply_harness(&config); + } + Ok(()) + } + + fn sessions_db_path(&self) -> Result { + account_sessions_db_path(self.service.paths(), &self.user_id) + } + + fn summaries_db_path(&self) -> Result { + account_tool_summaries_db_path(self.service.paths(), &self.user_id) + } +} + +/// `smart_approve` or `auto`; anything else is the safer mode. +fn normalize_permission_mode(mode: &str) -> String { + if mode == PERMISSION_MODE_AUTO { + PERMISSION_MODE_AUTO.to_string() + } else { + PERMISSION_MODE_SMART_APPROVE.to_string() + } +} + +fn session_defaults_from(config: &AgentConfig) -> HostSessionDefaults { + HostSessionDefaults { + permission_mode: config + .default_permission_mode + .as_deref() + .map(normalize_permission_mode) + .unwrap_or_else(|| PERMISSION_MODE_SMART_APPROVE.to_string()), + web_enabled: config.default_web_enabled.unwrap_or(true), + harness_instructions: config.harness_instructions.clone().unwrap_or_default(), + default_model: Some(config.default_model.clone()).filter(|model| !model.is_empty()), + } +} + +/// Root for a GUI start: the saved default when it still is a folder, else +/// the home directory. Never the process working directory: that is the +/// job of the `acp` command, not a windowed app started from a launcher. +fn gui_project_root(config: &AgentConfig) -> Option { + config + .default_project_root + .as_deref() + .filter(|path| !path.trim().is_empty() && std::path::Path::new(path).is_dir()) + .map(str::to_owned) + .or_else(|| dirs::home_dir().map(|path| path.to_string_lossy().to_string())) +} + +/// Tasks that an ACP client created belong to that client's UI, not to a +/// desktop-class client's task list. +fn without_acp_sessions(mut sessions: Vec) -> Vec { + sessions.retain(|session| !session.acp); + sessions +} + +#[async_trait] +impl HostBackend for LocalHostBackend { + fn id(&self) -> &HostId { + &self.id + } + + fn subscribe(&self) -> mpsc::UnboundedReceiver { + self.events.subscribe() + } + + async fn bootstrap(&self) -> Result { + let handle = self.handle().await?; + let config = handle.load_config().await?; + self.apply_harness(&config); + let project_root = gui_project_root(&config); + let sessions = without_acp_sessions(handle.list_sessions(None).await?); + let recent_roots = handle + .list_recent_project_roots() + .await? + .into_iter() + .map(|root| root.path) + .collect(); + // Same choice a session refresh makes: the newest unarchived task + // under the root that the runtime will start in. + let latest_id = sessions + .iter() + .find(|session| { + !session.archived && Some(&session.project_root) == project_root.as_ref() + }) + .map(|session| session.id.clone()); + let latest = match latest_id { + Some(id) => handle.load_session(id).await.ok(), + None => None, + }; + Ok(HostBootstrap { + project_root, + sessions, + recent_roots, + latest, + session_defaults: session_defaults_from(&config), + }) + } + + async fn start_runtime( + &self, + request: Option, + ) -> Result { + let handle = self.handle().await?; + self.apply_harness(&handle.load_config().await?); + let session = self.api_session().await?; + // The agent falls back to the process working directory when no + // root is given. That is right for `acp`, not for a client. + let request = match request { + Some(AgentStartRequest { + project_root: None, + model, + mode, + }) => { + let config = handle.load_config().await?; + Some(AgentStartRequest { + project_root: gui_project_root(&config), + model, + mode, + }) + } + other => other, + }; + tokio::time::timeout(RUNTIME_START_TIMEOUT, handle.start(session, request)) + .await + .map_err(|_| "Runtime start timed out. Check your connection and retry.".to_string())? + } + + async fn stop_runtime(&self) -> Result { + self.handle().await?.stop().await + } + + async fn recent_project_roots(&self) -> Result, String> { + self.handle().await?.list_recent_project_roots().await + } + + async fn select_project_root( + &self, + path: String, + ) -> Result { + self.handle().await?.save_recent_project_root(path).await + } + + async fn remove_project_root( + &self, + path: String, + fallback: Option, + ) -> Result<(), String> { + self.handle() + .await? + .remove_project_root(path, fallback) + .await + .map(|_| ()) + } + + async fn suggest_directories(&self, query: String) -> Result, String> { + tokio::task::spawn_blocking(move || { + directories::suggest(&query, dirs::home_dir().as_deref()) + }) + .await + .map_err(|error| format!("Directory listing failed: {error}")) + } + + async fn watch_project_root(&self, path: String) -> Result<(), String> { + self.branches.watch(path, Arc::clone(&self.events)); + Ok(()) + } + + async fn unwatch_project_root(&self, path: String) -> Result<(), String> { + self.branches.unwatch(&path); + Ok(()) + } + + async fn project_trust(&self, path: String) -> Result { + self.handle().await?.get_project_trust(path).await + } + + async fn set_project_trust( + &self, + path: String, + trusted: bool, + ) -> Result { + self.handle().await?.set_project_trust(path, trusted).await + } + + async fn list_sessions( + &self, + project_root: Option, + ) -> Result, String> { + Ok(without_acp_sessions( + self.handle().await?.list_sessions(project_root).await?, + )) + } + + async fn create_session( + &self, + request: Option, + ) -> Result { + self.handle().await?.create_session(request).await + } + + async fn load_session(&self, session_id: String) -> Result { + self.handle().await?.load_session(session_id).await + } + + async fn rename_session( + &self, + session_id: String, + title: String, + ) -> Result { + let handle = self.handle().await?; + let session = self.api_session().await?; + handle + .rename_session(session, AgentRenameSessionRequest { session_id, title }) + .await + } + + async fn set_session_archived( + &self, + session_id: String, + archived: bool, + ) -> Result { + self.handle() + .await? + .set_session_archived(session_id, archived) + .await + } + + async fn compact_session(&self, session_id: String) -> Result<(), String> { + self.handle().await?.compact_session(session_id).await + } + + async fn session_subagents(&self, session_id: String) -> Result, String> { + Ok(self.handle().await?.session_subagents(&session_id).await) + } + + async fn cancel_external_agent( + &self, + session_id: String, + agent_id: String, + ) -> Result<(), String> { + self.handle() + .await? + .cancel_external_agent(&session_id, &agent_id) + .await + } + + async fn set_permission_mode(&self, session_id: String, mode: String) -> Result<(), String> { + self.handle() + .await? + .set_permission_mode(AgentPermissionModeRequest { session_id, mode }) + .await + } + + async fn set_session_web_enabled( + &self, + session_id: String, + enabled: bool, + ) -> Result { + self.handle() + .await? + .set_session_web_enabled(AgentSetSessionWebRequest { + session_id, + enabled, + }) + .await + } + + /// Latest context usage for a session from the goose usage ledger. + /// The limit comes from the model catalog for the selected model; + /// `MAPLE_CONTEXT_LIMIT` is a manual override; 200k is the fallback + /// when the catalog lacks the model. + async fn context_usage( + &self, + session_id: String, + model: Option, + ) -> Result, String> { + let limit: i64 = match std::env::var("MAPLE_CONTEXT_LIMIT") + .ok() + .and_then(|value| value.parse().ok()) + { + Some(limit) if limit > 0 => limit, + _ => match model { + Some(model) => self + .handle() + .await? + .context_limit_for_model(&model) + .await? + .unwrap_or(DEFAULT_CONTEXT_LIMIT), + None => DEFAULT_CONTEXT_LIMIT, + }, + }; + let db = self.sessions_db_path()?; + let stores = Arc::clone(&self.stores); + // SQLite is synchronous; keep it off the async workers. + let tokens = tokio::task::spawn_blocking(move || { + stores + .with_usage_db(&db, |conn| store::latest_context_tokens(conn, &session_id)) + .flatten() + }) + .await + .map_err(|error| format!("Context usage query failed: {error}"))?; + Ok(tokens.map(|tokens| ContextUsage { tokens, limit })) + } + + async fn read_image_attachment( + &self, + session_id: String, + attachment_id: String, + ) -> Result, String> { + self.handle() + .await? + .read_image_attachment(session_id, attachment_id) + .await + } + + async fn send_message(&self, request: AgentSendMessageRequest) -> Result { + Ok(self.handle().await?.send_message(request).await?.run_id) + } + + async fn cancel_run(&self, run_id: String) -> Result<(), String> { + self.handle().await?.cancel_desktop_run(run_id).await + } + + async fn cancel_queued_message( + &self, + session_id: String, + queue_id: String, + ) -> Result { + self.handle() + .await? + .cancel_queued_message(AgentQueueControlRequest { + session_id, + queue_id, + }) + .await + } + + async fn begin_queued_message_edit( + &self, + session_id: String, + queue_id: String, + ) -> Result<(), String> { + self.handle() + .await? + .begin_queued_message_edit(AgentQueueControlRequest { + session_id, + queue_id, + }) + .await + } + + async fn end_queued_message_edit( + &self, + session_id: String, + queue_id: String, + ) -> Result<(), String> { + self.handle() + .await? + .end_queued_message_edit(AgentQueueControlRequest { + session_id, + queue_id, + }) + .await + } + + async fn answer_question(&self, request_id: String, answer: String) -> Result { + self.handle() + .await? + .answer_question_via_handle(&request_id, answer) + .await + } + + async fn permission_respond( + &self, + session_id: String, + request_id: String, + allow: bool, + ) -> Result<(), String> { + self.handle() + .await? + .permission_respond(AgentPermissionResponse { + session_id, + request_id, + decision: if allow { + "allow_once".to_string() + } else { + "deny_once".to_string() + }, + }) + .await + } + + async fn ask_side_question( + &self, + session_id: String, + request_id: String, + prior: Vec, + question: String, + ) -> Result<(), String> { + self.handle() + .await? + .ask_side_question(&session_id, request_id, prior, question) + .await + } + + async fn summarize_tool_call( + &self, + session_id: String, + tool_name: String, + input: Option, + output_text: String, + ) -> Result, String> { + self.handle() + .await? + .summarize_tool_call(&session_id, &tool_name, input.as_ref(), &output_text) + .await + } + + async fn summarize_thinking( + &self, + session_id: String, + thinking_text: String, + ) -> Result, String> { + self.handle() + .await? + .summarize_thinking(&session_id, &thinking_text) + .await + } + + async fn tool_summaries(&self, session_id: String) -> Result, String> { + let db = self.summaries_db_path()?; + let stores = Arc::clone(&self.stores); + tokio::task::spawn_blocking(move || { + stores.with_summary_db(&db, |conn| store::load_tool_summaries(conn, &session_id)) + }) + .await + .map_err(|error| format!("Tool summary read failed: {error}"))? + } + + async fn store_tool_summary( + &self, + session_id: String, + item_id: String, + summary: String, + ) -> Result<(), String> { + let db = self.summaries_db_path()?; + let stores = Arc::clone(&self.stores); + tokio::task::spawn_blocking(move || { + stores.with_summary_db(&db, |conn| { + store::store_tool_summary(conn, &session_id, &item_id, &summary) + }) + }) + .await + .map_err(|error| format!("Tool summary write failed: {error}"))? + } + + async fn available_model_ids(&self) -> Result, String> { + self.handle().await?.available_model_ids().await + } + + async fn model_supports_vision(&self, model: String) -> Result, String> { + self.handle().await?.model_supports_vision(&model).await + } + + /// Filesystem scan, so it runs on a blocking thread. + async fn list_slash_commands( + &self, + working_dir: Option, + ) -> Result, String> { + let service = self.service.clone(); + let user_id = self.user_id.clone(); + tokio::task::spawn_blocking(move || { + service.list_slash_commands(Some(&user_id), working_dir.as_deref()) + }) + .await + .map_err(|error| format!("Slash command scan failed: {error}")) + } + + async fn resolve_slash_command( + &self, + working_dir: Option, + command: String, + args: String, + ) -> Result, String> { + let service = self.service.clone(); + tokio::task::spawn_blocking(move || { + service.resolve_slash_command(working_dir.as_deref(), &command, &args) + }) + .await + .map_err(|error| format!("Slash command resolve failed: {error}"))? + } + + async fn list_session_mcp_servers( + &self, + session_id: String, + ) -> Result, String> { + self.handle() + .await? + .list_session_mcp_servers(session_id) + .await + } + + async fn set_session_mcp_server_enabled( + &self, + session_id: String, + name: String, + kind: AgentSessionIntegrationKind, + enabled: bool, + ) -> Result, String> { + self.handle() + .await? + .set_session_mcp_server_enabled(AgentSetSessionMcpServerRequest { + session_id, + name, + kind, + enabled, + }) + .await + } + + async fn list_mcp_servers(&self) -> Result, String> { + self.handle().await?.list_mcp_servers().await + } + + async fn save_mcp_servers( + &self, + servers: Vec, + ) -> Result, String> { + self.handle().await?.save_mcp_servers(servers).await + } + + async fn list_integrations(&self) -> Result, String> { + self.handle().await?.list_integrations().await + } + + async fn set_integration_enabled( + &self, + id: String, + enabled: bool, + ) -> Result, String> { + self.handle() + .await? + .set_integration_enabled(AgentSetIntegrationEnabledRequest { id, enabled }) + .await + } + + async fn setup_integration(&self, id: String) -> Result, String> { + self.handle() + .await? + .setup_integration(AgentSetupIntegrationRequest { id }) + .await + } + + async fn session_defaults(&self) -> Result { + let config = self.handle().await?.load_config().await?; + Ok(session_defaults_from(&config)) + } + + async fn set_session_defaults(&self, defaults: HostSessionDefaults) -> Result<(), String> { + let handle = self.handle().await?; + let mut config = handle.load_config().await?; + config.default_permission_mode = Some(normalize_permission_mode(&defaults.permission_mode)); + config.default_web_enabled = Some(defaults.web_enabled); + config.harness_instructions = Some(defaults.harness_instructions); + if let Some(model) = defaults.default_model.filter(|model| !model.is_empty()) { + config.default_model = model; + } + handle.save_config(config.clone()).await?; + self.apply_harness(&config); + Ok(()) + } + + async fn save_default_model(&self, model: String) -> Result<(), String> { + let handle = self.handle().await?; + let mut config = handle.load_config().await?; + config.default_model = model; + handle.save_config(config).await + } + + async fn usage_summary(&self) -> Result { + let db = self.sessions_db_path()?; + let stores = Arc::clone(&self.stores); + tokio::task::spawn_blocking(move || { + stores + .with_usage_db(&db, store::usage_from_ledger) + .unwrap_or_default() + }) + .await + .map_err(|error| format!("Usage query failed: {error}")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn session_defaults_read_the_config_with_safe_fallbacks() { + let mut config = AgentConfig::default(); + let defaults = session_defaults_from(&config); + assert_eq!(defaults.permission_mode, PERMISSION_MODE_SMART_APPROVE); + assert!(defaults.web_enabled); + assert_eq!(defaults.harness_instructions, ""); + assert!(defaults.default_model.is_some()); + + config.default_permission_mode = Some("auto".to_string()); + config.default_web_enabled = Some(false); + config.harness_instructions = Some("custom".to_string()); + config.default_model = String::new(); + let defaults = session_defaults_from(&config); + assert_eq!(defaults.permission_mode, PERMISSION_MODE_AUTO); + assert!(!defaults.web_enabled); + assert_eq!(defaults.harness_instructions, "custom"); + assert_eq!(defaults.default_model, None); + + config.default_permission_mode = Some("garbage".to_string()); + assert_eq!( + session_defaults_from(&config).permission_mode, + PERMISSION_MODE_SMART_APPROVE + ); + } + + #[test] + fn acp_sessions_stay_out_of_client_lists() { + let mut acp = sample_session("acp"); + acp.acp = true; + let kept = without_acp_sessions(vec![sample_session("desktop"), acp]); + assert_eq!(kept.len(), 1); + assert_eq!(kept[0].id, "desktop"); + } + + fn sample_session(id: &str) -> AgentSessionSummary { + AgentSessionSummary { + id: id.to_string(), + title: id.to_string(), + project_root: "/p".to_string(), + created_ms: 0, + updated_ms: 0, + message_count: 0, + model: None, + mode: "smart_approve".to_string(), + web_enabled: true, + archived: false, + acp: false, + } + } +} diff --git a/apps/maple-agent/crates/maple-agent/src/host/mod.rs b/apps/maple-agent/crates/maple-agent/src/host/mod.rs new file mode 100644 index 000000000..2e6c23f6a --- /dev/null +++ b/apps/maple-agent/crates/maple-agent/src/host/mod.rs @@ -0,0 +1,451 @@ +//! The host boundary: everything a client drives on a host. +//! +//! A host runs the agent runtime. A client is the desktop app. Every app +//! instance is its own local host, and it can also be a client of remote +//! hosts. The [`HostBackend`] trait is the surface a client calls; the +//! [`HostEvent`] stream is what a host pushes back. [`LocalHostBackend`] +//! implements the trait in process over [`crate::agent::AgentRuntimeHandle`]. +//! A remote implementation speaks the same trait over the wire, so the UI +//! never branches on where a host runs. +//! +//! Account-level concerns (sign-in, billing, audio) are not part of this +//! surface: they use the client's own OpenSecret session and stay local. + +pub mod directories; +pub mod git; +pub mod local; +mod store; + +use std::collections::HashMap; +use std::sync::Mutex; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use tokio::sync::mpsc; + +use crate::agent::{ + AgentCreateSessionRequest, AgentDesktopQueueSnapshot, AgentEventSink, AgentIntegration, + AgentMcpServer, AgentProjectRootRegistration, AgentProjectTrustStatus, AgentRuntimeStatus, + AgentSendMessageRequest, AgentServiceEvent, AgentSessionDetail, AgentSessionIntegrationKind, + AgentSessionMcpServer, AgentSessionSummary, AgentSlashCommand, AgentStartRequest, + AgentSubagent, RecentProjectRoot, SideQuestionTurn, +}; + +pub use directories::DirectorySuggestion; +pub use local::{LegacySessionDefaults, LocalHostAuth, LocalHostBackend}; +pub use store::{UsageRow, UsageSummary}; + +/// Identifies a host on the client. The local host is [`HostId::local`]; +/// a remote host is identified by its static public key. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct HostId(String); + +impl HostId { + /// The id every client uses for its own in-process host. + pub const LOCAL: &'static str = "local"; + + pub fn local() -> Self { + Self(Self::LOCAL.to_string()) + } + + pub fn new(id: impl Into) -> Self { + Self(id.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn is_local(&self) -> bool { + self.0 == Self::LOCAL + } +} + +impl std::fmt::Display for HostId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +/// Everything a host pushes to its clients. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum HostEvent { + /// An event from the agent runtime. Boxed: it is far larger than the + /// other variants, and every event is cloned once per subscriber. + Service(Box), + /// The git branch of a watched project root, sent when a watch starts + /// and whenever `HEAD` changes. `None` when the root is not a checkout. + ProjectBranch { + project_root: String, + branch: Option, + }, +} + +/// Opening system prompt text used when a host has none saved. +pub const DEFAULT_HARNESS_INSTRUCTIONS: &str = + "You are a general-purpose AI agent called Maple, created by Maple AI. +You run in the Maple app's Agent Mode; users know you simply as Maple."; + +/// Permission policy name for "confirm each gated tool call". +pub const PERMISSION_MODE_SMART_APPROVE: &str = "smart_approve"; +/// Permission policy name for "approve every tool call". +pub const PERMISSION_MODE_AUTO: &str = "auto"; + +/// Defaults a host applies to the tasks its clients create. Stored in the +/// host's per-account config, so two hosts can differ. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HostSessionDefaults { + /// `smart_approve` or `auto`. + pub permission_mode: String, + /// Whether new tasks can use the web tools. + pub web_enabled: bool, + /// Opening system prompt text. Empty means + /// [`DEFAULT_HARNESS_INSTRUCTIONS`]. + pub harness_instructions: String, + /// The account's saved default model, if any. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_model: Option, +} + +impl Default for HostSessionDefaults { + fn default() -> Self { + Self { + permission_mode: PERMISSION_MODE_SMART_APPROVE.to_string(), + web_enabled: true, + harness_instructions: String::new(), + default_model: None, + } + } +} + +impl HostSessionDefaults { + /// The harness instructions to hand the runtime: the saved text, or + /// the default when nothing is saved. + pub fn effective_harness_instructions(&self) -> String { + effective_harness_instructions(&self.harness_instructions) + } +} + +/// The harness instructions for a saved value: the text, or the default +/// when it is blank. +pub fn effective_harness_instructions(saved: &str) -> String { + let saved = saved.trim(); + if saved.is_empty() { + DEFAULT_HARNESS_INSTRUCTIONS.to_string() + } else { + saved.to_string() + } +} + +/// Everything a client can show for a host before any network call: the +/// saved project root, the task list, the recent roots, the newest task's +/// transcript, and the session defaults. Read in one call so it all lands +/// before a runtime start takes the lifecycle lock. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HostBootstrap { + pub project_root: Option, + pub sessions: Vec, + pub recent_roots: Vec, + pub latest: Option, + pub session_defaults: HostSessionDefaults, +} + +/// Context window use for one task, from the host's usage ledger. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ContextUsage { + pub tokens: i64, + pub limit: i64, +} + +/// Fans one host's events out to every subscriber. The runtime's event +/// sink for the local host; a server projects the same stream to its +/// sockets. Subscribers that dropped their receiver are pruned on the +/// next publish. +#[derive(Default)] +pub struct HostEventHub { + subscribers: Mutex>>, +} + +impl HostEventHub { + pub fn subscribe(&self) -> mpsc::UnboundedReceiver { + let (tx, rx) = mpsc::unbounded_channel(); + self.subscribers + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(tx); + rx + } + + pub fn publish(&self, event: HostEvent) { + let mut subscribers = self + .subscribers + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + subscribers.retain(|subscriber| subscriber.send(event.clone()).is_ok()); + } + + #[cfg(test)] + pub(crate) fn subscriber_count(&self) -> usize { + self.subscribers + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .len() + } +} + +impl AgentEventSink for HostEventHub { + fn emit(&self, event: &AgentServiceEvent) { + self.publish(HostEvent::Service(Box::new(event.clone()))); + } +} + +/// What a client drives on one host. Every method is scoped to the one +/// account the host is signed in as. +#[async_trait] +pub trait HostBackend: Send + Sync + 'static { + fn id(&self) -> &HostId; + + /// A fresh stream of this host's events. Several subscribers may be + /// live at once; each receives every event. + fn subscribe(&self) -> mpsc::UnboundedReceiver; + + // ---- Runtime ----------------------------------------------------------- + + async fn bootstrap(&self) -> Result; + async fn start_runtime( + &self, + request: Option, + ) -> Result; + async fn stop_runtime(&self) -> Result; + + // ---- Projects ---------------------------------------------------------- + + async fn recent_project_roots(&self) -> Result, String>; + async fn select_project_root( + &self, + path: String, + ) -> Result; + async fn remove_project_root( + &self, + path: String, + fallback: Option, + ) -> Result<(), String>; + /// Directories on the host that complete `query`, for a typed root. + async fn suggest_directories(&self, query: String) -> Result, String>; + /// Start reporting the git branch of `path` through + /// [`HostEvent::ProjectBranch`]; the first report follows at once. + async fn watch_project_root(&self, path: String) -> Result<(), String>; + async fn unwatch_project_root(&self, path: String) -> Result<(), String>; + async fn project_trust(&self, path: String) -> Result; + async fn set_project_trust( + &self, + path: String, + trusted: bool, + ) -> Result; + + // ---- Sessions ---------------------------------------------------------- + + async fn list_sessions( + &self, + project_root: Option, + ) -> Result, String>; + async fn create_session( + &self, + request: Option, + ) -> Result; + async fn load_session(&self, session_id: String) -> Result; + async fn rename_session( + &self, + session_id: String, + title: String, + ) -> Result; + async fn set_session_archived( + &self, + session_id: String, + archived: bool, + ) -> Result; + async fn compact_session(&self, session_id: String) -> Result<(), String>; + async fn session_subagents(&self, session_id: String) -> Result, String>; + async fn cancel_external_agent( + &self, + session_id: String, + agent_id: String, + ) -> Result<(), String>; + async fn set_permission_mode(&self, session_id: String, mode: String) -> Result<(), String>; + async fn set_session_web_enabled( + &self, + session_id: String, + enabled: bool, + ) -> Result; + async fn context_usage( + &self, + session_id: String, + model: Option, + ) -> Result, String>; + async fn read_image_attachment( + &self, + session_id: String, + attachment_id: String, + ) -> Result, String>; + + // ---- Messages and runs ------------------------------------------------- + + /// Returns the run id. + async fn send_message(&self, request: AgentSendMessageRequest) -> Result; + async fn cancel_run(&self, run_id: String) -> Result<(), String>; + async fn cancel_queued_message( + &self, + session_id: String, + queue_id: String, + ) -> Result; + async fn begin_queued_message_edit( + &self, + session_id: String, + queue_id: String, + ) -> Result<(), String>; + async fn end_queued_message_edit( + &self, + session_id: String, + queue_id: String, + ) -> Result<(), String>; + /// Returns false when no question was pending. + async fn answer_question(&self, request_id: String, answer: String) -> Result; + async fn permission_respond( + &self, + session_id: String, + request_id: String, + allow: bool, + ) -> Result<(), String>; + async fn ask_side_question( + &self, + session_id: String, + request_id: String, + prior: Vec, + question: String, + ) -> Result<(), String>; + + // ---- Summaries --------------------------------------------------------- + + async fn summarize_tool_call( + &self, + session_id: String, + tool_name: String, + input: Option, + output_text: String, + ) -> Result, String>; + async fn summarize_thinking( + &self, + session_id: String, + thinking_text: String, + ) -> Result, String>; + /// Stored summaries for one session, keyed by timeline item id. + async fn tool_summaries(&self, session_id: String) -> Result, String>; + async fn store_tool_summary( + &self, + session_id: String, + item_id: String, + summary: String, + ) -> Result<(), String>; + + // ---- Models and skills ------------------------------------------------- + + async fn available_model_ids(&self) -> Result, String>; + async fn model_supports_vision(&self, model: String) -> Result, String>; + async fn list_slash_commands( + &self, + working_dir: Option, + ) -> Result, String>; + async fn resolve_slash_command( + &self, + working_dir: Option, + command: String, + args: String, + ) -> Result, String>; + + // ---- Integrations and MCP ---------------------------------------------- + + async fn list_session_mcp_servers( + &self, + session_id: String, + ) -> Result, String>; + async fn set_session_mcp_server_enabled( + &self, + session_id: String, + name: String, + kind: AgentSessionIntegrationKind, + enabled: bool, + ) -> Result, String>; + async fn list_mcp_servers(&self) -> Result, String>; + async fn save_mcp_servers( + &self, + servers: Vec, + ) -> Result, String>; + async fn list_integrations(&self) -> Result, String>; + async fn set_integration_enabled( + &self, + id: String, + enabled: bool, + ) -> Result, String>; + /// Persist a curated integration after its permission flow ran on the + /// host. The flow itself is a local capability the client starts from + /// its own UI thread; a remote client cannot run it. + async fn setup_integration(&self, id: String) -> Result, String>; + + // ---- Host configuration ------------------------------------------------ + + async fn session_defaults(&self) -> Result; + async fn set_session_defaults(&self, defaults: HostSessionDefaults) -> Result<(), String>; + async fn save_default_model(&self, model: String) -> Result<(), String>; + async fn usage_summary(&self) -> Result; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hub_delivers_to_every_live_subscriber_and_prunes_dropped_ones() { + let hub = HostEventHub::default(); + let mut first = hub.subscribe(); + let second = hub.subscribe(); + drop(second); + hub.publish(HostEvent::ProjectBranch { + project_root: "/p".to_string(), + branch: Some("main".to_string()), + }); + assert!(matches!( + first.try_recv(), + Ok(HostEvent::ProjectBranch { branch: Some(branch), .. }) if branch == "main" + )); + assert_eq!(hub.subscriber_count(), 1); + } + + #[test] + fn host_ids_round_trip_and_know_local() { + assert!(HostId::local().is_local()); + assert!(!HostId::new("abc").is_local()); + let json = serde_json::to_string(&HostId::new("abc")).unwrap(); + assert_eq!(json, "\"abc\""); + assert_eq!( + serde_json::from_str::(&json).unwrap().as_str(), + "abc" + ); + } + + #[test] + fn blank_harness_instructions_mean_the_default() { + assert_eq!( + effective_harness_instructions(" \n"), + DEFAULT_HARNESS_INSTRUCTIONS + ); + assert_eq!(effective_harness_instructions(" custom "), "custom"); + let defaults = HostSessionDefaults::default(); + assert_eq!(defaults.permission_mode, PERMISSION_MODE_SMART_APPROVE); + assert!(defaults.web_enabled); + } +} diff --git a/apps/maple-agent/crates/maple-agent/src/host/store.rs b/apps/maple-agent/crates/maple-agent/src/host/store.rs new file mode 100644 index 000000000..fdb0221e2 --- /dev/null +++ b/apps/maple-agent/crates/maple-agent/src/host/store.rs @@ -0,0 +1,332 @@ +//! Host-side readers of the account stores. +//! +//! The Goose usage ledger is owned and written by the runtime; this module +//! opens it read-only. The tool summary store is owned by the host and +//! written here. Both are SQLite, so every call is blocking and runs on a +//! blocking thread. + +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use serde::{Deserialize, Serialize}; + +/// One aggregated usage row: per session or per model. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UsageRow { + pub label: String, + pub sessions: u64, + pub turns: u64, + pub total_tokens: i64, + pub cost: f64, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UsageSummary { + pub totals: UsageRow, + pub by_model: Vec, + pub by_session: Vec, +} + +/// Open the goose sessions database for reading. Returns `None` when the +/// file does not exist yet (read-only open never creates it). The busy +/// timeout covers the short locks goose takes for WAL checkpoints. +fn open_session_db_read_only(path: &Path) -> Option { + use rusqlite::OpenFlags; + let flags = OpenFlags::SQLITE_OPEN_READ_ONLY + | OpenFlags::SQLITE_OPEN_NO_MUTEX + | OpenFlags::SQLITE_OPEN_URI; + let conn = match rusqlite::Connection::open_with_flags(path, flags) { + Ok(conn) => conn, + Err(error) => { + if path.exists() { + log::warn!("Cannot open session db {}: {error}", path.display()); + } + return None; + } + }; + if let Err(error) = conn.busy_timeout(std::time::Duration::from_secs(5)) { + log::warn!("Cannot set busy timeout on {}: {error}", path.display()); + } + Some(conn) +} + +/// Open (and create) the tool summary store. +fn open_summary_db(path: &Path) -> Result { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|error| format!("Cannot create {}: {error}", parent.display()))?; + } + let conn = rusqlite::Connection::open(path) + .map_err(|error| format!("Cannot open {}: {error}", path.display()))?; + conn.execute_batch( + "PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; \ + CREATE TABLE IF NOT EXISTS tool_summaries ( \ + session_id TEXT NOT NULL, \ + item_id TEXT NOT NULL, \ + summary TEXT NOT NULL, \ + PRIMARY KEY (session_id, item_id) \ + );", + ) + .map_err(|error| format!("Cannot init {}: {error}", path.display()))?; + Ok(conn) +} + +/// Open handles to one account's stores. The usage ledger is polled every +/// second during a run, so its connection is kept open rather than +/// reopened per query. +#[derive(Default)] +pub(super) struct AccountStores { + usage_db: Mutex>, + summary_db: Mutex>, +} + +impl AccountStores { + /// Run `f` against the usage ledger at `path`, or `None` when the + /// ledger does not exist yet. Blocking. + pub(super) fn with_usage_db( + &self, + path: &Path, + f: impl FnOnce(&rusqlite::Connection) -> T, + ) -> Option { + let mut guard = self + .usage_db + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if guard.as_ref().map(|(open, _)| open != path).unwrap_or(true) { + let conn = open_session_db_read_only(path)?; + *guard = Some((path.to_path_buf(), conn)); + } + Some(f(&guard.as_ref().expect("usage db opened above").1)) + } + + /// Run `f` against the summary store at `path`. Blocking. + pub(super) fn with_summary_db( + &self, + path: &Path, + f: impl FnOnce(&rusqlite::Connection) -> Result, + ) -> Result { + let mut guard = self + .summary_db + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if guard.as_ref().map(|(open, _)| open != path).unwrap_or(true) { + *guard = Some((path.to_path_buf(), open_summary_db(path)?)); + } + f(&guard.as_ref().expect("summary db opened above").1) + } +} + +/// Latest context tokens for a session from the usage ledger: input plus +/// cache reads and writes of the newest non-compaction row. +pub(super) fn latest_context_tokens(conn: &rusqlite::Connection, session_id: &str) -> Option { + conn.query_row( + "SELECT COALESCE(input_tokens,0) + COALESCE(cache_read_tokens,0) \ + + COALESCE(cache_write_tokens,0) FROM usage_ledger \ + WHERE session_id = ?1 AND is_compaction = 0 \ + ORDER BY id DESC LIMIT 1", + [session_id], + |row| row.get::<_, i64>(0), + ) + .ok() +} + +pub(super) fn load_tool_summaries( + conn: &rusqlite::Connection, + session_id: &str, +) -> Result, String> { + let mut stmt = conn + .prepare("SELECT item_id, summary FROM tool_summaries WHERE session_id = ?1") + .map_err(|error| error.to_string())?; + let rows = stmt + .query_map([session_id], |row| Ok((row.get(0)?, row.get(1)?))) + .map_err(|error| error.to_string())?; + rows.collect::, _>>() + .map_err(|error| error.to_string()) +} + +pub(super) fn store_tool_summary( + conn: &rusqlite::Connection, + session_id: &str, + item_id: &str, + summary: &str, +) -> Result<(), String> { + conn.execute( + "INSERT OR REPLACE INTO tool_summaries (session_id, item_id, summary) \ + VALUES (?1, ?2, ?3)", + [session_id, item_id, summary], + ) + .map(|_| ()) + .map_err(|error| error.to_string()) +} + +/// Aggregate one account's ledger. +/// +/// A subagent has a session of its own, and its provider calls land in +/// the ledger under it. Every row counts against the task that delegated +/// the work, so the reader sees what a task cost in total. Goose refuses +/// a subagent of a subagent, so resolving one parent is enough. +pub(super) fn usage_from_ledger(conn: &rusqlite::Connection) -> UsageSummary { + let mut summary = UsageSummary::default(); + + if let Ok(mut stmt) = conn.prepare( + "SELECT COUNT(*), COALESCE(SUM(total_tokens),0), COALESCE(SUM(cost),0) \ + FROM usage_ledger", + ) && let Ok(row) = stmt.query_row([], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, f64>(2)?, + )) + }) { + summary.totals = UsageRow { + label: "All activity".to_string(), + sessions: 0, + turns: row.0.max(0) as u64, + total_tokens: row.1, + cost: row.2, + }; + } + + if let Ok(mut stmt) = conn.prepare( + "SELECT u.model, COUNT(DISTINCT COALESCE(s.parent_session_id, u.session_id)), COUNT(*), \ + COALESCE(SUM(u.total_tokens),0), COALESCE(SUM(u.cost),0) \ + FROM usage_ledger u LEFT JOIN sessions s ON s.id = u.session_id \ + GROUP BY u.model ORDER BY SUM(u.total_tokens) DESC", + ) && let Ok(rows) = stmt.query_map([], |row| { + Ok(UsageRow { + label: row + .get::<_, Option>(0)? + .unwrap_or_else(|| "unknown".into()), + sessions: row.get::<_, i64>(1)?.max(0) as u64, + turns: row.get::<_, i64>(2)?.max(0) as u64, + total_tokens: row.get::<_, i64>(3)?, + cost: row.get::<_, f64>(4)?, + }) + }) { + for row in rows.flatten() { + summary.totals.sessions += row.sessions; + summary.by_model.push(row); + } + } + + if let Ok(mut stmt) = conn.prepare( + "SELECT COALESCE(parent.name, s.name), COALESCE(s.parent_session_id, u.session_id) AS task, \ + COUNT(*), COALESCE(SUM(u.total_tokens),0), COALESCE(SUM(u.cost),0) \ + FROM usage_ledger u JOIN sessions s ON s.id = u.session_id \ + LEFT JOIN sessions parent ON parent.id = s.parent_session_id \ + GROUP BY task ORDER BY MAX(u.created_timestamp) DESC LIMIT 20", + ) && let Ok(rows) = stmt.query_map([], |row| { + Ok(UsageRow { + label: { + let name: String = row.get::<_, Option>(0)?.unwrap_or_default(); + let id: String = row.get(1)?; + if name.trim().is_empty() { id } else { name } + }, + sessions: 1, + turns: row.get::<_, i64>(2)?.max(0) as u64, + total_tokens: row.get::<_, i64>(3)?, + cost: row.get::<_, f64>(4)?, + }) + }) { + for row in rows.flatten() { + summary.by_session.push(row); + } + } + + summary +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A subagent bills to the task that delegated the work, so the + /// usage screen shows one row per task and not one per subagent. + #[test] + fn subagent_usage_counts_against_its_parent_task() { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + conn.execute_batch( + "CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL DEFAULT '', + parent_session_id TEXT + ); + CREATE TABLE usage_ledger ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + created_timestamp INTEGER NOT NULL, + model TEXT, + total_tokens INTEGER, + cost REAL + ); + INSERT INTO sessions VALUES ('task-1', 'Review the parser', NULL); + INSERT INTO sessions VALUES ('sub-1', 'Delegated task', 'task-1'); + INSERT INTO sessions VALUES ('task-2', 'Other work', NULL); + INSERT INTO usage_ledger (session_id, created_timestamp, model, total_tokens, cost) + VALUES ('task-1', 10, 'maple-1', 100, 1.0), + ('sub-1', 20, 'maple-1', 400, 4.0), + ('task-2', 30, 'maple-1', 700, 7.0);", + ) + .unwrap(); + + let usage = usage_from_ledger(&conn); + let rows = usage + .by_session + .iter() + .map(|row| (row.label.as_str(), row.turns, row.total_tokens)) + .collect::>(); + assert_eq!( + rows, + vec![("Other work", 1, 700), ("Review the parser", 2, 500)], + "the subagent's tokens belong to the task that delegated them" + ); + // Two tasks ran, not three sessions. + assert_eq!(usage.by_model.len(), 1); + assert_eq!(usage.by_model[0].sessions, 2); + assert_eq!(usage.totals.total_tokens, 1200); + } + + #[test] + fn tool_summaries_round_trip_and_replace() { + let dir = std::env::temp_dir().join(format!( + "maple-summaries-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let path = dir.join("tool_summaries.db"); + let stores = AccountStores::default(); + stores + .with_summary_db(&path, |conn| store_tool_summary(conn, "s1", "i1", "first")) + .unwrap(); + stores + .with_summary_db(&path, |conn| store_tool_summary(conn, "s1", "i1", "second")) + .unwrap(); + stores + .with_summary_db(&path, |conn| store_tool_summary(conn, "s2", "i9", "other")) + .unwrap(); + let loaded = stores + .with_summary_db(&path, |conn| load_tool_summaries(conn, "s1")) + .unwrap(); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded.get("i1").map(String::as_str), Some("second")); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn a_missing_ledger_reads_as_none() { + let stores = AccountStores::default(); + let missing = std::env::temp_dir() + .join("maple-no-such-dir") + .join("sessions.db"); + assert!( + stores + .with_usage_db(&missing, |conn| latest_context_tokens(conn, "s")) + .is_none() + ); + } +} diff --git a/apps/maple-agent/crates/maple-agent/src/lib.rs b/apps/maple-agent/crates/maple-agent/src/lib.rs index fe32d602a..a19e4e552 100644 --- a/apps/maple-agent/crates/maple-agent/src/lib.rs +++ b/apps/maple-agent/crates/maple-agent/src/lib.rs @@ -11,6 +11,7 @@ pub mod acp; pub mod agent; mod desktop_environment; +pub mod host; pub use desktop_environment::prepare_process_environment; pub mod maple_api; pub mod open_secret_config; diff --git a/apps/maple-agent/docs/remote-development.md b/apps/maple-agent/docs/remote-development.md new file mode 100644 index 000000000..8f6aa32be --- /dev/null +++ b/apps/maple-agent/docs/remote-development.md @@ -0,0 +1,549 @@ +# Remote development + +Status: design and implementation plan, agreed 2026-09-19. The seam +(step 1 of the implementation order) is built; the protocol crate, `serve`, +pairing, and the client are not. When code and this document disagree, the +code wins; update this document in the same change. + +A Maple host runs the agent runtime and serves it over the network. A Maple +client is the desktop app, which drives one or more hosts. The first release +covers direct connections over a LAN or a Tailscale network. A later release +adds a blind relay through the OpenSecret enclave without changing the +protocol above the transport. + +## Glossary + +Use these words and no synonyms. Do not use "machine", "server", "daemon", +"remote", or "peer" for any of them in code, UI, or docs. + +| Term | Meaning | +| --- | --- | +| Host | A process that runs the agent runtime and accepts client connections. Every app instance is its own local host. Identified by its static Noise public key. | +| Client | The desktop app acting as a consumer of a host. | +| Device | A client identity, one static Noise key pair. One person may have several devices. | +| Connection | One way to reach a host: an address for a direct connection, or the relay later. A host has one or more connections. | +| Pairing | The one-time exchange that gives a device and a host each other's static key. | +| Session | A Maple task with its timeline, owned by exactly one host. | +| Generation | A UUID a host mints at boot. Every cursor is scoped to it. | + +## Decisions + +These are settled. Each maps to a question answered during planning. + +- Full parity. A client connected to a remote host gets everything the local + window has: queue, steering, permissions, questions, integrations, Codex and + Claude Code delegation, context usage, tool summaries, and host settings. + ACP is not the remote protocol. It stays a narrower surface for editors. +- Host forms. A new `serve` subcommand behind a `serve` cargo feature, default + on, headless compatible. The desktop app can also serve, behind an "Allow + remote connections" setting that is off by default. Linux and macOS hosts + both matter. +- Host credentials. The host signs in on its own with `maple-gpui login` and + holds its own `auth.json`. There is no remote sign-in. When the host's + refresh token is rejected, clients see "host needs sign-in" and nothing + more. +- Pairing. A high-entropy single-use code shown on the host and typed on the + client. The code is the only proof. No account proof. The host records the + client's claimed user id for display only. +- Encryption. End-to-end between device keys with Noise, so a relay sees only + ciphertext. The relay is not built in this release. +- Client model. One sidebar merges sessions from the local host and every + connected remote host, with a host filter. New tasks go to the host chosen + in the sidebar. +- Fan-out. Every connected client sees every event. Permission prompts and + questions go to all clients. The first answer wins. +- Discovery. Manual address entry. No mDNS. The desktop app shows its listen + addresses and pairing code in Settings. +- Project roots. Text entry plus the host's recent roots plus host-side + directory suggestions. No native folder picker for remote hosts. +- Session defaults are per host. Default permission mode, default web, + harness instructions, and default model move from app settings into the + host's per-account config. +- Local window. The local window calls the runtime in process and never loops + through the protocol. +- Delivery. Host-assigned sequences, bounded snapshots, paged catch-up. + A client must never miss a message. +- Slow clients. The host closes a socket whose outbound queue would cross the + limit. It never blocks the run and never touches other clients. +- Reserved. A generic binary stream channel type in the framing so a PTY can + be added later without a protocol change. CUA stays host-local. + +## Out of scope for this release + +Relay implementation, mDNS, a remote directory browser beyond suggestions, +remote re-authentication, cached session lists for offline hosts, terminals, +CUA over the wire, running the client with the local runtime disabled, and +connection probing with automatic switching between connections. + +## Architecture + +### The backend seam + +`app/src/backend.rs` is today the only module that imports `maple_agent`. It +is a concrete struct held as `Arc`. It splits in two. + +Account-level concerns stay in the local backend and never go over the wire: +sign-in and OAuth, billing, audio transcription and speech, update checks, +opening URLs, and desktop notifications. These use the client's own +OpenSecret session. + +Session-level concerns move behind a per-host trait, `HostBackend` in +`crates/maple-agent/src/host/`, with two implementations: + +- `LocalHostBackend` wraps `AgentRuntimeHandle` in process. The local window + uses this directly. `AgentBackend::local_host` creates one per account. +- `RemoteHostBackend` speaks the wire protocol to a remote host. + +A `HostRouter` owns one `HostBackend` per connected host, keyed by host id, +and presents the merged view the UI reads. The UI never holds a host backend +directly. Every UI call that names a session carries `(host_id, session_id)`. + +The host side is `HostServer`. It consumes the same `AgentRuntimeHandle` the +local window uses. Fan-out happens at the `AgentEventSink` level: the runtime +emits once, and the server projects to every subscribed socket. The +`LocalHostBackend` and `HostServer` are siblings, not layers. + +`HostServer` is split into per-domain controllers from the first commit. Each +controller owns a set of request types and a non-async dispatch that returns +immediately on a miss. Do not grow one session controller. + +### Reach-arounds that close + +Four places in the UI touch the host filesystem directly today. Each becomes +a host method or a pushed event. + +| Today | After | +| --- | --- | +| Native folder picker returns a local path used as `project_root` (`ui/chat/mod.rs`), and `backend.rs` checks `is_dir()` in the UI process. | `HostBackend::suggest_directories(query, cwd)` returns entries from the host. `HostBackend::validate_project_root(path)` runs on the host. Recent roots come from the host. | +| Git branch read and `notify` watcher run in the UI process. | Host owns the watcher and pushes `SessionGitState` events. Rate limited per root. | +| UI opens `sessions.db` read-only every second for the context ring. | `HostBackend::context_usage(session)` and a pushed `ContextUsage` event during runs. | +| UI opens `tool_summaries.db` read-write. | `HostBackend::tool_summaries(session)` and `set_tool_summary`. | + +Image attachments already travel as bytes in `AgentImageUpload`. They move to +a binary stream channel, chunked, so the control channel frame limit does not +cap them. + +### Local-only capabilities + +Anything that must run where the window is stays gated on "this host is the +local host": desktop notifications, opening a URL, and later "reveal in file +manager". Nothing else may branch on locality. + +### Integrations on a remote host + +Claude Code, Codex, custom MCP servers, the shell tool, and project trust run +where the runtime runs, which is the host. A remote client sees their output +and answers their permission cards. Installation status, setup, and the +enabled toggles are host-side settings, edited from the client through the +host selector in Settings. + +The external-agent gate requires a desktop session of type User. "Desktop" +means created by a desktop-class client, local or remote. Sessions created by +a remote client are desktop sessions. Sessions created by ACP callers stay +excluded as today. Do not read "desktop" as "the local window". + +Embedded CUA attaches to a remotely driven task the same way it attaches to a +locally driven one, with two conditions that are properties of the host: + +- The host needs a live graphical session: a logged-in user session on + macOS, or a running compositor with the desktop portal on Linux. A `serve` + process started over SSH on a machine with no logged-in desktop reports + CUA as unavailable. +- Setup happens at the host's screen. macOS attributes Accessibility and + Screen Recording grants to the app identity that asks, after a direct user + action on the UI thread, and the prompt appears on the host's display. The + Linux portal consent prompt also appears on the host's display. The client + therefore shows the host's CUA status and, when grants are missing, says + to grant them on the host. It never triggers the grant flow remotely. + +On macOS the grants belong to the bundle identity, so a CUA-capable host is +the desktop app with "Allow remote connections" on, or `serve` launched from +inside the same staged bundle. That is the recommended CUA host +configuration. The client cannot see the host's screen; screenshots reach it +only as timeline items, as today. + +## Protocol + +The protocol lives in a new crate, `crates/maple-remote`. It holds wire types, +framing, sequences, the Noise layer, `HostServer`, and the client. It depends +on `crates/maple-agent` for domain types and never on `app`. + +### Carrier + +WebSocket. Direct connections use plain `ws://` because Noise encrypts inside +the WebSocket. The relay later uses `wss://` to the enclave with the same +Noise inside. The enclave ingress is Cloudflare, nginx, socat, then axum, with +a 300 second idle timeout, so keepalives are mandatory. `perMessageDeflate` is +off. + +A connector trait abstracts dialing: + +```rust +trait Connector { + async fn connect(&self, target: &Connection) -> Result>; +} +``` + +This release ships `DirectConnector`. `RelayConnector` arrives with the relay +and rendezvous by device public key. + +### Framing + +Every WebSocket message after the handshake is one Noise transport message. +Its plaintext is: + +``` +[channel: u16][kind: u8][payload] +``` + +Channel 0 is control and carries JSON. Other channels carry binary streams +opened by a control message. Kinds are `open`, `data`, `close`, `credit`. +Binary channels have explicit per-channel credit-based flow control so a slow +file upload cannot starve control. The control channel frame limit is 4 MiB; +anything larger belongs on a stream or must be paged. + +Reserved now, unused in this release: a stream kind for a PTY, which carries +`{rows, cols, intent: claim | update}` resizes. Passive rendering never sends +a claim. + +### Messages + +Control messages are JSON-RPC 2.0. Methods are namespaced by domain and +mirror `AgentRuntimeHandle`: + +``` +session.list, session.create, session.load, session.subscribe, +session.send, session.cancel, session.queue.*, session.permission.respond, +session.question.answer, session.compact, session.rename, session.archive, +project.recent_roots, project.suggest_directories, project.trust.*, +integration.list, integration.set_enabled, integration.setup, +host.status, host.settings.get, host.settings.set, host.context_usage, +host.tool_summaries.get, host.tool_summaries.set, +device.list, device.revoke +``` + +Each domain has its own request and response enums and its own controller on +the host. There is no single message union. + +Rules for every type on the wire: + +- Every optional field has `#[serde(default)]`. Enums that may grow are + `#[non_exhaustive]` on the wire with an `Unknown` fallback on decode. +- Schemas are append-only. Never remove a field, never make an optional field + required, never narrow a type. A field you stop sending stays accepted. +- Every compatibility shim carries a dated tag: + `// COMPAT(name): added in vX.Y, remove after YYYY-MM-DD once host floor >= vX.Y.` + `rg 'COMPAT\('` is the cleanup backlog. +- No fallback paths for new features. If the host lacks a capability, the + client says "update the host" in one place and does nothing else. + +`AgentServiceEvent`, `AgentRunEvent`, and every request and response type in +`crates/maple-agent/src/agent/types.rs` and `timeline.rs` gain the missing +`Serialize` or `Deserialize` derives. This freezes the wire contract, so +review those types with that in mind. + +### Handshake + +After the Noise handshake, each side sends one `hello`: + +```json +{ + "protocol": 1, + "app_version": "0.1.0", + "pcr_environment": "Production", + "generation": "uuid", + "features": { "session_defaults": true, "directory_suggestions": true }, + "device": { "public_key": "...", "name": "bens-laptop", "user_id": "..." } +} +``` + +`protocol` is a tripwire, not a negotiation channel. It is refused on +mismatch and is only bumped for a change that cannot be expressed as a +feature. All real evolution goes through `features`. A mismatched +`pcr_environment` is always refused, because the two binaries cannot share a +backend. `generation` on the host side scopes every cursor. + +### Liveness + +Four separate budgets. None of them may be inferred from another. + +| Budget | Value | +| --- | --- | +| Connect | 15 s | +| Application ping | Client pings every 10 s, 15 s timeout, reconnect after 2 consecutive misses | +| Host lease | 45 s, claimed by the first ping, renewed by any inbound activity, checked every 10 s, force close on expiry | +| RPC | 60 s default. A timeout is an operation failure, never proof the socket is dead | + +Reconnect uses full-jitter exponential backoff from 1 s to a 30 s cap, +reset on a successful `hello`. Returning to the foreground probes the +connection with a 3 s deadline and bypasses backoff on failure. + +## Delivery guarantees + +Two lanes. Live events are for immediacy. Fetches are authoritative. Catch-up +is paged but complete. + +### Host-level state + +The session list, runtime status, and host settings are entities with +independent monotonic sequences under one host generation. The host keeps +only the latest projection per entity plus bounded tombstones. There is no +event log. + +The client sends `{generation, after_seq}`. The host answers either +`changes` with entities above the cursor plus `removals: [{id, seq}]`, or +`snapshot` with `reason: generation_changed` when the cursor is missing, +expired, or from another generation. Repeated identical updates do not bump a +sequence. + +### Session timelines + +Each session has a host-assigned `(epoch, seq)`. A new run starts a new +epoch. Allocation is append-only. `session.subscribe` returns a bounded +snapshot: the newest page of timeline items with `seq_start`, `seq_end`, +`has_older`, `has_newer`, plus queue, pending permissions, and pending +questions. + +Live events carry `(epoch, seq)`. The client reducer is a four-way state +machine: `seq <= end_seq` drops as stale, `seq == end_seq + 1` accepts, a +different epoch or a jump is a gap. On a gap the client fetches +`session.timeline(after: end_seq)` and keeps fetching while `has_newer` is +true. Recovery is complete only at `has_newer: false`. + +A resume after a long absence is bounded. If the first page reports much +newer history, the client fetches one latest tail and marks `has_older`, so +skipped history stays reachable by scrolling, rather than replaying every +missed page. Page limits count projected timeline items, not raw events. + +Failed catch-up retries back off from 1 s to a 30 s cap and reset on +success, reconnect, or visibility change. + +Permission prompts and questions unanswered while no client was connected +block the run and appear in the next snapshot. + +### Fan-out + +A logical client session is keyed by device. A second socket from the same +device attaches to the same logical session. Each physical socket owns its +own features and subscriptions and never borrows a sibling's. When the last +socket drops, the logical session survives for 90 s before teardown. + +Permission responses go through an in-flight guard. The first response is +submitted. A second response to the same request fails with a real error so +the losing client can show "answered on another device". The resolution is +broadcast to every subscribed client. A resolution event that races the +response is buffered and dispatched after. + +Queue edits and steering are last write wins with the host authoritative. + +Presence is not delivery. Focus, visibility, and heartbeat may route desktop +notifications. They never gate which events a subscribed socket receives. + +### Slow clients + +Each physical socket has a bounded outbound queue, 64 MiB. The host +serializes a broadcast once, filters out sockets already over the limit, +then checks exact byte length per remaining socket. A frame that would cross +the limit closes that socket. The run never waits, and sibling sockets are +untouched. The closed client comes back through its ordinary reconnect and +catch-up path. + +## Security + +### Keys + +Each host and each device has one static X25519 key pair, generated on first +use and stored at mode 0600 under `/remote/`. Host identity and +device identity are these public keys. Keys are never logged. + +### Pairing + +1. On the host, `maple-gpui serve pair` writes a pending pairing record into + `/remote/pending_pairing.json` at mode 0600 and prints the + code. The desktop app's "Generate pairing code" button does the same. The + running server watches this file. A record is valid for 5 minutes and is + consumed once. +2. The code is at least 80 bits of entropy, rendered as words or base32. +3. The client connects and runs a Noise handshake with a pre-shared-key + pattern, using the code as the PSK. Both sides learn and pin each other's + static public key in that handshake. +4. The host stores the device in `/remote/devices.json`: public + key, name, claimed user id, paired at, last seen. The client stores the + host in its per-account `hosts.json`: public key, display name, + connections, paired at. +5. Failed pairing attempts are rate limited per source address with lockout + after repeated failures. The pending record is deleted after one failure + window. + +After pairing, every connection uses Noise IK with the pinned statics. A peer +whose static key does not match the pinned key is refused, and the client +shows a host identity error rather than re-pairing silently. Noise gives +counters and rekeying, so there is no replay window within a session. + +Implementation uses the `snow` crate with its default resolver, which builds +on the `x25519-dalek`, `chacha20poly1305`, and `sha2` crates already in the +lockfile through the Rust SDK. Do not hand-build the handshake. + +### Authority + +A paired device has the same reach as the desktop window on that host. +Project trust is enforced host-side as today. Sessions created by ACP callers +stay hidden from clients as they are hidden from the desktop today. The host +records the claimed user id for display only; the pairing code is the whole +proof. + +Revocation on the host drops that device's live connections immediately. + +### Listening + +The host binds `0.0.0.0` on a configurable port by default, because pairing +is the gate, with a flag to bind one interface such as a Tailscale address. A +lock file under the data root prevents two servers on one data root. The +desktop app does not listen until "Allow remote connections" is on. + +The host never logs access or refresh tokens, plaintext prompts, or +credential-bearing environments, per the repository security rules. Pairing +codes are never logged. + +## Client + +### Hosts and connections + +A saved host is: + +```json +{ + "id": "", + "name": "workstation", + "connections": [ + { "kind": "direct", "address": "100.64.0.7:7130" }, + { "kind": "direct", "address": "192.168.1.20:7130" } + ], + "paired_at_ms": 0 +} +``` + +Adding a connection whose host presents an already-known public key merges +into that host rather than creating a second one. Loading `hosts.json` uses +per-entry salvage: a malformed connection is dropped, not the host. Saved +hosts live per account scope, so switching accounts hides other accounts' +hosts. + +This release tries connections in order and uses the first that completes a +handshake. Concurrent probing with latency-based switching and hysteresis is +the follow-up that lands with the relay. + +### Lifecycle + +Saved hosts auto-connect on launch and reconnect with jittered backoff. An +offline host shows as a collapsed status row in the sidebar with no sessions. +The status states are `idle`, `connecting`, `online`, `offline`, `error`, +with `last_online_at`, and the client distinguishes "error before first +success" from "error after ready" so it can show a reconnecting banner over +data it already has. + +### Sidebar and new tasks + +The sidebar merges sessions across hosts, sorted as today, with a host filter +that defaults to all. Rows show a host badge only when more than one host is +connected. New tasks target the host selected in the sidebar filter. When the +filter is "all", the composer shows the target host with a picker. + +### Settings scoping + +Client-only, stays in `/maple-gpui/settings.json`: theme, fonts, vim +modes, shortcut overrides, notifications, reduce motion, window state, TTS +voice and speed. + +Per host, moves into the host's per-account `AgentConfig`: default +permission mode, default web enabled, harness instructions, default model, +tool details and tool summaries display defaults if they affect the run. +Existing values migrate silently into the local host's config on first run. + +State keyed by absolute path today re-keys by `(host_id, path)`: pinned +roots, project names, pinned tasks, settled and unsettled tasks. + +Host-scoped sections in Settings get a host selector. Integrations, project +trust, and custom MCP servers are host-side as today. Settings also gains a +Hosts section for the client role, with add, rename, and remove, and a Remote +access section for the host role, with the listen toggle, addresses, pairing +code, and paired devices with revoke. + +## Host CLI + +``` +maple-gpui serve [--listen ADDR:PORT] [--name NAME] +maple-gpui serve pair +maple-gpui serve devices list +maple-gpui serve devices revoke +``` + +`serve` requires a saved sign-in and exits with a clear message otherwise, +like `acp`. It logs to the usual log file. The default host name is the +machine hostname. The default port is 7130 unless taken; it is not 8080, +which the proxy mode uses. + +## Persistence + +| Path | Owner | Contents | +| --- | --- | --- | +| `/remote/host_key` | host | static private key, 0600 | +| `/remote/device_key` | client | static private key, 0600 | +| `/remote/devices.json` | host | paired devices | +| `/remote/pending_pairing.json` | host | one pending code, 0600, short-lived | +| `/remote/serve.lock` | host | single-server lock | +| `/agent/accounts//hosts.json` | client | saved hosts per account | +| `/agent/accounts//config.json` | host | existing `AgentConfig`, gains session defaults | + +## Testing + +- Loopback integration tests in `crates/maple-remote`: a `HostServer` over + an in-process carrier with XDG-isolated data roots, a `RemoteHostBackend` + client, and a scripted fake runtime. Cover subscribe, gap recovery, epoch + change, generation change, slow-socket close, permission race, and device + revoke mid-connection. +- Noise tests: pairing success, wrong code, expired code, reused code, pinned + key mismatch, and rate limit lockout. +- Compatibility tests: every wire type round-trips with unknown fields + present and with optional fields absent. +- The existing UI tests keep passing against `LocalHostBackend` with no + behavioral change after the seam extraction. + +## Implementation order + +Logical commits in this order. The first two change nothing a user sees. + +1. Seam. Extract `HostBackend` and `HostRouter`, wrap the runtime in + `LocalHostBackend`, add the missing serde derives, close the four + reach-arounds with host methods and events, re-key path-based settings by + host, and migrate session defaults into `AgentConfig`. Add the glossary + to this document's neighbors where terms appear. +2. Protocol crate. Add `crates/maple-remote` with wire types per domain, + framing, sequences, generation, `HostServer` with per-domain controllers, + and `RemoteHostBackend`, over an in-process carrier. Loopback tests. +3. Transport and security. WebSocket carrier, Noise with `snow`, pairing, + device and host key stores, devices file, rate limiting, lease and ping, + slow-socket close. The `serve` subcommand with `pair` and `devices`. +4. Client. `DirectConnector`, saved hosts with merge-on-identity and + salvage, auto-connect and jittered reconnect, merged sidebar with filter + and host badge, new-task targeting, Hosts settings section, host selector + on host-scoped settings. +5. Desktop serving. "Allow remote connections" toggle, listen addresses, + pairing code UI, paired devices with revoke, lock file shared with + `serve`. + +## Relay, later + +The relay lives inside the OpenSecret enclave and sees only ciphertext. +Design constraints to honor when it arrives, so nothing above changes: + +- One persistent host-to-relay connection carrying a mux with explicit + per-stream flow control. The relay buffers nothing and never drops a frame + on the host's behalf. No dial-back-per-client topology. +- Rendezvous by device public key. The host publishes reachability through + the user's encrypted KV store. +- `RelayConnector` is a second `Connector`. Hosts gain a `relay` connection + kind. Connection probing with first-available activation and hysteresis + lands here. +- Keepalive under 300 s is already mandatory, so the enclave ingress needs + only an nginx upgrade block. From 39547fe12de7c5d463f507760630e419cc22dded Mon Sep 17 00:00:00 2001 From: benthecarman Date: Sat, 19 Sep 2026 15:26:40 -0500 Subject: [PATCH 05/37] Add the host wire protocol and remote client A client that drives a host on another machine needs a wire that carries the whole HostBackend surface, never silently loses an event, and never lets one slow client hold up the runtime. The new maple-remote crate is that wire, written against an abstract carrier so the WebSocket and Noise layers can land underneath without touching it. Frames are [channel][kind][payload]. Channel 0 carries JSON-RPC 2.0 with methods grouped by domain; each domain has its own request enum and its own controller on the host, so no single dispatcher grows without bound. Other channels are binary streams with credit-based flow control, used for attachments now and reserved for a terminal later. The handshake carries protocol version, app version, the compile-time enclave environment, and a feature table. Environment and protocol mismatches are refused; everything else evolves through features. Schemas are append-only and unknown fields are ignored. Every event on a connection carries a sequence. The client publishes a resync event on a gap, and the UI re-reads what it shows instead of trusting the stream. Snapshots are paged by count and by bytes from a copy the connection keeps, so a long transcript never has to fit one frame. Requests are answered concurrently so a slow call cannot delay the keepalive; a lease on the host and an application ping on the client keep liveness separate from request timeouts; and a connection whose outbound queue overflows is closed rather than blocking the host. Loopback tests run a server and a client over an in-process carrier with a scripted host: refused handshakes, paged snapshots, ordered events, a forged sequence gap, attachment streams, a client that stops draining, and an expired lease. Co-Authored-By: Claude Fable 5.1 --- apps/maple-agent/Cargo.lock | 15 + apps/maple-agent/Cargo.toml | 2 +- apps/maple-agent/README.md | 3 + apps/maple-agent/app/src/ui/chat/mod.rs | 9 + .../crates/maple-agent/src/host/mod.rs | 4 + .../crates/maple-remote/Cargo.toml | 21 + .../crates/maple-remote/src/carrier.rs | 95 ++ .../crates/maple-remote/src/client.rs | 858 ++++++++++++++++++ .../crates/maple-remote/src/frame.rs | 132 +++ .../crates/maple-remote/src/lib.rs | 38 + .../crates/maple-remote/src/outbound.rs | 119 +++ .../crates/maple-remote/src/rpc.rs | 182 ++++ .../crates/maple-remote/src/server.rs | 681 ++++++++++++++ .../crates/maple-remote/src/streams.rs | 399 ++++++++ .../crates/maple-remote/src/wire.rs | 523 +++++++++++ .../crates/maple-remote/tests/loopback.rs | 797 ++++++++++++++++ apps/maple-agent/docs/remote-development.md | 78 +- 17 files changed, 3923 insertions(+), 33 deletions(-) create mode 100644 apps/maple-agent/crates/maple-remote/Cargo.toml create mode 100644 apps/maple-agent/crates/maple-remote/src/carrier.rs create mode 100644 apps/maple-agent/crates/maple-remote/src/client.rs create mode 100644 apps/maple-agent/crates/maple-remote/src/frame.rs create mode 100644 apps/maple-agent/crates/maple-remote/src/lib.rs create mode 100644 apps/maple-agent/crates/maple-remote/src/outbound.rs create mode 100644 apps/maple-agent/crates/maple-remote/src/rpc.rs create mode 100644 apps/maple-agent/crates/maple-remote/src/server.rs create mode 100644 apps/maple-agent/crates/maple-remote/src/streams.rs create mode 100644 apps/maple-agent/crates/maple-remote/src/wire.rs create mode 100644 apps/maple-agent/crates/maple-remote/tests/loopback.rs diff --git a/apps/maple-agent/Cargo.lock b/apps/maple-agent/Cargo.lock index 1dec4bb04..e8e0a9ce1 100644 --- a/apps/maple-agent/Cargo.lock +++ b/apps/maple-agent/Cargo.lock @@ -5899,6 +5899,21 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "maple-remote" +version = "0.1.0" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "log", + "maple-agent", + "serde", + "serde_json", + "tokio", + "uuid", +] + [[package]] name = "maple-sdk" version = "4.0.1" diff --git a/apps/maple-agent/Cargo.toml b/apps/maple-agent/Cargo.toml index 465b15ac5..39ea2704e 100644 --- a/apps/maple-agent/Cargo.toml +++ b/apps/maple-agent/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["crates/maple-agent", "crates/maple-billing", "app"] +members = ["crates/maple-agent", "crates/maple-billing", "crates/maple-remote", "app"] [workspace.package] edition = "2024" diff --git a/apps/maple-agent/README.md b/apps/maple-agent/README.md index e2959af2e..8aecdc5ca 100644 --- a/apps/maple-agent/README.md +++ b/apps/maple-agent/README.md @@ -18,6 +18,9 @@ crates/maple-agent/ Maple's transport-neutral agent runtime, extracted from the Maple provider over the Maple Rust SDK, developer tools, permission policy, account-scoped session storage, and the ACP server. +crates/maple-remote/ The wire between a client and a host: framing, + JSON-RPC on the control channel, binary streams, + the host server, and the remote HostBackend. crates/maple-billing/ HTTP client for the Maple billing API. docs/ Theme spec measured from the Tauri app. scripts/ One maintainer helper: screenshot.py takes a desktop diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index e646fa390..2ec35d61c 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -3934,6 +3934,15 @@ impl ChatScreen { project_root, branch, } => self.apply_project_branch(&project_root, branch, cx), + HostEvent::Resync => { + // Events may have been lost: re-read the list and the + // task on screen instead of trusting what arrived. + self.refresh_sessions(cx); + if let Some(session_id) = self.selected_session.clone() { + self.reload_timeline(&session_id, cx); + } + false + } }; } if changed { diff --git a/apps/maple-agent/crates/maple-agent/src/host/mod.rs b/apps/maple-agent/crates/maple-agent/src/host/mod.rs index 2e6c23f6a..b64525e38 100644 --- a/apps/maple-agent/crates/maple-agent/src/host/mod.rs +++ b/apps/maple-agent/crates/maple-agent/src/host/mod.rs @@ -81,6 +81,10 @@ pub enum HostEvent { project_root: String, branch: Option, }, + /// Events may have been missed: a remote connection saw a gap in the + /// host's sequence, or reconnected. The client re-reads the task list + /// and reloads any task it shows. The local host never sends this. + Resync, } /// Opening system prompt text used when a host has none saved. diff --git a/apps/maple-agent/crates/maple-remote/Cargo.toml b/apps/maple-agent/crates/maple-remote/Cargo.toml new file mode 100644 index 000000000..48d3a45aa --- /dev/null +++ b/apps/maple-agent/crates/maple-remote/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "maple-remote" +description = "Wire protocol between a Maple client and a host: framing, JSON-RPC over channel 0, the host server, and the remote HostBackend" +edition.workspace = true +version.workspace = true +license.workspace = true +publish = false + +[dependencies] +maple-agent = { path = "../maple-agent", default-features = false } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +async-trait = { workspace = true } +futures-util = { workspace = true } +log = { workspace = true } +bytes = "1" +uuid = { version = "1", features = ["v4"] } + +[dev-dependencies] +tokio = { workspace = true, features = ["test-util"] } diff --git a/apps/maple-agent/crates/maple-remote/src/carrier.rs b/apps/maple-agent/crates/maple-remote/src/carrier.rs new file mode 100644 index 000000000..c31dd5aff --- /dev/null +++ b/apps/maple-agent/crates/maple-remote/src/carrier.rs @@ -0,0 +1,95 @@ +//! A bidirectional frame transport. +//! +//! The server and the client are written against this trait. Tests and +//! the local loopback use [`in_process_pair`]; the WebSocket carrier with +//! Noise inside is a separate implementation that delivers the same +//! frames. + +use async_trait::async_trait; +use tokio::sync::mpsc; + +use crate::frame::Frame; + +/// The sending half of a carrier. +#[async_trait] +pub trait FrameSink: Send + 'static { + /// Deliver one frame. `Err` means the carrier is gone. + async fn send(&mut self, frame: Frame) -> Result<(), String>; + /// Close the carrier for good. + async fn close(&mut self); +} + +/// The receiving half of a carrier. +#[async_trait] +pub trait FrameStream: Send + 'static { + /// The next frame, or `None` once the peer closed. + async fn recv(&mut self) -> Option; +} + +/// Both halves, before they are split. +pub struct Carrier { + pub sink: Box, + pub stream: Box, +} + +struct ChannelSink(Option>); + +#[async_trait] +impl FrameSink for ChannelSink { + async fn send(&mut self, frame: Frame) -> Result<(), String> { + match &self.0 { + Some(tx) => tx + .send(frame) + .await + .map_err(|_| "peer closed the carrier".to_string()), + None => Err("carrier closed".to_string()), + } + } + + async fn close(&mut self) { + self.0 = None; + } +} + +struct ChannelStream(mpsc::Receiver); + +#[async_trait] +impl FrameStream for ChannelStream { + async fn recv(&mut self) -> Option { + self.0.recv().await + } +} + +/// Two connected carriers in one process. Frames sent on one arrive on +/// the other. `buffer` frames may be in flight each way. +pub fn in_process_pair(buffer: usize) -> (Carrier, Carrier) { + let (a_to_b_tx, a_to_b_rx) = mpsc::channel(buffer); + let (b_to_a_tx, b_to_a_rx) = mpsc::channel(buffer); + ( + Carrier { + sink: Box::new(ChannelSink(Some(a_to_b_tx))), + stream: Box::new(ChannelStream(b_to_a_rx)), + }, + Carrier { + sink: Box::new(ChannelSink(Some(b_to_a_tx))), + stream: Box::new(ChannelStream(a_to_b_rx)), + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn frames_cross_the_pair_both_ways_and_close_ends_the_stream() { + let (mut a, mut b) = in_process_pair(4); + a.sink.send(Frame::control("to b")).await.unwrap(); + b.sink.send(Frame::control("to a")).await.unwrap(); + assert_eq!(b.stream.recv().await.unwrap().payload, "to b"); + assert_eq!(a.stream.recv().await.unwrap().payload, "to a"); + a.sink.close().await; + assert!(b.stream.recv().await.is_none()); + assert!(a.sink.send(Frame::control("late")).await.is_err()); + } +} diff --git a/apps/maple-agent/crates/maple-remote/src/client.rs b/apps/maple-agent/crates/maple-remote/src/client.rs new file mode 100644 index 000000000..791dd0d89 --- /dev/null +++ b/apps/maple-agent/crates/maple-remote/src/client.rs @@ -0,0 +1,858 @@ +//! The client side: a [`HostBackend`] over one connection to a host. +//! +//! [`RemoteHostBackend`] speaks the wire to a [`crate::server::HostServer`] +//! and presents the same trait the local host does, so the UI drives it +//! without knowing where it runs. One instance is one connection; when the +//! connection ends the instance is dead, and whoever owns it reconnects +//! with a fresh one and treats the change as a resync. +//! +//! Delivery: every event carries the connection's sequence. A gap means +//! the host dropped this client's queue or something in between lost +//! frames; the backend then publishes [`HostEvent::Resync`] so the UI +//! re-reads what it shows, rather than trusting the stream. Liveness is +//! an application ping on its own budget; a request timeout is an +//! operation failure, never proof the connection is dead. + +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; + +use async_trait::async_trait; +use maple_agent::agent::{ + AgentCreateSessionRequest, AgentDesktopQueueSnapshot, AgentIntegration, AgentMcpServer, + AgentProjectRootRegistration, AgentProjectTrustStatus, AgentRuntimeStatus, + AgentSendMessageRequest, AgentSessionDetail, AgentSessionIntegrationKind, + AgentSessionMcpServer, AgentSessionSummary, AgentSlashCommand, AgentStartRequest, + AgentSubagent, RecentProjectRoot, SideQuestionTurn, +}; +use maple_agent::host::{ + ContextUsage, DirectorySuggestion, HostBackend, HostBootstrap, HostEvent, HostEventHub, HostId, + HostSessionDefaults, UsageSummary, +}; +use serde::Serialize; +use serde::de::DeserializeOwned; +use serde_json::Value; +use tokio::sync::{Mutex, mpsc, oneshot, watch}; + +use crate::carrier::Carrier; +use crate::frame::{CONTROL_CHANNEL, Frame, FrameKind}; +use crate::outbound::{self, DEFAULT_MAX_OUTBOUND_BYTES, Outbound}; +use crate::rpc::{self, Message, Request as RpcRequest, RpcError}; +use crate::streams::StreamReceivers; +use crate::wire::{ + AnswerQuestion, AskSideQuestion, AttachmentHandle, BootstrapSnapshot, CancelExternalAgent, + ClientHello, ContextUsageParams, CreateSession, EVENT_METHOD, Empty, EventEnvelope, HostHello, + HostRequest, IntegrationId, IntegrationRequest, ListSessions, LoadSession, ModelName, + ModelRequest, PROTOCOL_VERSION, PermissionRespond, ProjectRequest, QueueControl, + ReadAttachment, RemoveRoot, RenameSession, ResolveSlashCommand, RootPath, RunId, RunRequest, + SaveDefaultModel, SaveMcpServers, SendMessage, SessionId, SessionRequest, SessionSnapshot, + SetArchived, SetIntegrationEnabled, SetPermissionMode, SetSessionDefaults, SetSessionMcp, + SetTrust, SetWebEnabled, StartRuntime, StoreToolSummary, SuggestDirectories, SummarizeThinking, + SummarizeToolCall, TimelinePage, TimelinePageParams, WorkingDir, +}; + +#[derive(Debug, Clone)] +pub struct ClientConfig { + /// The handshake must complete within this. + pub connect_timeout: Duration, + /// Default budget for one request. + pub request_timeout: Duration, + /// Budget for a runtime start, which has its own long timeout on the + /// host. + pub long_request_timeout: Duration, + /// Application ping period. + pub ping_interval: Duration, + /// A ping unanswered for this long counts as a miss. + pub ping_timeout: Duration, + /// Consecutive misses before the connection is declared dead. + pub ping_misses: u32, + /// Timeline items requested per page. + pub timeline_page_items: usize, + pub max_outbound_bytes: usize, +} + +impl Default for ClientConfig { + fn default() -> Self { + Self { + connect_timeout: Duration::from_secs(15), + request_timeout: Duration::from_secs(60), + long_request_timeout: Duration::from_secs(90), + ping_interval: Duration::from_secs(10), + ping_timeout: Duration::from_secs(15), + ping_misses: 2, + timeline_page_items: 200, + max_outbound_bytes: DEFAULT_MAX_OUTBOUND_BYTES, + } + } +} + +type Pending = oneshot::Sender>; + +pub struct RemoteHostBackend { + id: HostId, + hello: HostHello, + config: ClientConfig, + out: Outbound, + pending: Mutex>, + next_id: AtomicU64, + events: Arc, + streams: StreamReceivers, + closed: watch::Sender>, + tasks: std::sync::Mutex>>, +} + +impl RemoteHostBackend { + /// Open a connection: run the handshake and start the reader and the + /// keepalive. Fails when the host refuses the hello. + pub async fn connect( + carrier: Carrier, + hello: ClientHello, + config: ClientConfig, + ) -> Result, String> { + let Carrier { + mut sink, + mut stream, + } = carrier; + let (out, mut queue) = outbound::channel(config.max_outbound_bytes); + let (closed_tx, _) = watch::channel(None); + let (inbound_tx, mut inbound_rx) = mpsc::unbounded_channel::>(); + // The reader task only moves frames; demultiplexing needs the + // backend, which does not exist until the handshake answered. + let reader = tokio::spawn(async move { + loop { + let frame = stream.recv().await; + let ended = frame.is_none(); + if inbound_tx.send(frame).is_err() || ended { + break; + } + } + }); + let writer = tokio::spawn(async move { + while let Some(frame) = queue.recv().await { + if sink.send(frame).await.is_err() { + break; + } + } + sink.close().await; + }); + + // Handshake, by hand: the demultiplexer is not running yet. + let hello_request = RpcRequest::new( + 1, + "host.hello", + serde_json::to_value(&hello).map_err(|error| error.to_string())?, + ); + out.try_send(Frame::control(rpc::encode(&Message::Request( + hello_request, + ))?))?; + let answer = tokio::time::timeout(config.connect_timeout, async { + loop { + match inbound_rx.recv().await.flatten() { + Some(frame) if frame.channel == CONTROL_CHANNEL => { + if let Message::Response(response) = rpc::decode(&frame.payload)? { + return Ok::<_, String>(response); + } + } + Some(_) => continue, + None => return Err("the host closed the connection".to_string()), + } + } + }) + .await + .map_err(|_| "the host did not answer the handshake in time".to_string())??; + let host_hello: HostHello = match (answer.result, answer.error) { + (Some(value), _) => serde_json::from_value(value).map_err(|error| error.to_string())?, + (None, Some(error)) => return Err(error.message), + (None, None) => return Err("empty handshake answer".to_string()), + }; + if host_hello.protocol != PROTOCOL_VERSION { + return Err(format!( + "the host speaks protocol {}; this client speaks {PROTOCOL_VERSION}", + host_hello.protocol + )); + } + + let this = Arc::new(Self { + id: HostId::new(host_hello.host.id.clone()), + hello: host_hello, + config, + out, + pending: Mutex::new(HashMap::new()), + next_id: AtomicU64::new(2), + events: Arc::new(HostEventHub::default()), + streams: StreamReceivers::default(), + closed: closed_tx, + tasks: std::sync::Mutex::new(vec![reader, writer]), + }); + let demux = { + let this = Arc::clone(&this); + tokio::spawn(async move { + let mut expected_seq: u64 = this.hello.seq + 1; + while let Some(Some(frame)) = inbound_rx.recv().await { + this.on_frame(frame, &mut expected_seq).await; + } + this.mark_closed("the host closed the connection").await; + }) + }; + let pinger = { + let this = Arc::clone(&this); + tokio::spawn(async move { this.keepalive().await }) + }; + this.tasks + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .extend([demux, pinger]); + Ok(this) + } + + /// What the host said about itself. + pub fn host_hello(&self) -> &HostHello { + &self.hello + } + + /// Resolves with the reason once the connection is gone. + pub fn closed(&self) -> watch::Receiver> { + self.closed.subscribe() + } + + pub fn is_closed(&self) -> bool { + self.closed.borrow().is_some() + } + + /// End the connection now. + pub async fn close(&self) { + self.mark_closed("closed by the client").await; + } + + async fn mark_closed(&self, reason: &str) { + if self.closed.borrow().is_some() { + return; + } + self.closed.send_replace(Some(reason.to_string())); + for (_, pending) in self.pending.lock().await.drain() { + let _ = pending.send(Err(RpcError::host(reason))); + } + self.streams.fail_all(reason); + let tasks = std::mem::take( + &mut *self + .tasks + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner), + ); + for task in tasks { + task.abort(); + } + } + + async fn on_frame(&self, frame: Frame, expected_seq: &mut u64) { + if frame.channel != CONTROL_CHANNEL { + match frame.kind { + FrameKind::Open => self.streams.on_open(frame.channel, &frame.payload), + FrameKind::Data => { + if let Some(credit) = self.streams.on_data(frame.channel, &frame.payload) + && let Err(error) = self.out.try_send(credit) + { + self.mark_closed(&error).await; + } + } + FrameKind::Close => self.streams.on_close(frame.channel, &frame.payload), + FrameKind::Credit => {} + } + return; + } + let message = match rpc::decode(&frame.payload) { + Ok(message) => message, + Err(error) => { + log::debug!("dropping undecodable control frame: {error}"); + return; + } + }; + match message { + Message::Response(response) => { + if let Some(pending) = self.pending.lock().await.remove(&response.id) { + let _ = pending.send(match (response.result, response.error) { + (Some(value), _) => Ok(value), + (None, Some(error)) => Err(error), + (None, None) => Ok(Value::Null), + }); + } + } + Message::Notification(notification) if notification.method == EVENT_METHOD => { + match serde_json::from_value::(notification.params) { + Ok(envelope) => { + if envelope.seq != *expected_seq { + log::warn!( + "host event sequence jumped from {} to {}; resyncing", + *expected_seq, + envelope.seq + ); + self.events.publish(HostEvent::Resync); + } + *expected_seq = envelope.seq + 1; + self.events.publish(envelope.event); + } + Err(error) => log::debug!("dropping undecodable event: {error}"), + } + } + Message::Notification(notification) => { + log::debug!("ignoring notification {}", notification.method); + } + Message::Request(request) => { + log::debug!("ignoring request {} from the host", request.method); + } + } + } + + async fn keepalive(&self) { + let mut misses = 0; + loop { + tokio::time::sleep(self.config.ping_interval).await; + if self.is_closed() { + return; + } + let ping = self.call_with_timeout::( + &HostRequest::Ping(Empty {}), + self.config.ping_timeout, + ); + match ping.await { + Ok(_) => misses = 0, + Err(error) => { + misses += 1; + log::debug!("ping missed ({misses}): {error}"); + if misses >= self.config.ping_misses { + self.mark_closed("the host stopped answering").await; + return; + } + } + } + } + } + + async fn call(&self, request: &T) -> Result { + self.call_with_timeout(request, self.config.request_timeout) + .await + } + + async fn call_with_timeout( + &self, + request: &T, + timeout: Duration, + ) -> Result { + let (value, _) = self.call_raw(request, timeout).await?; + serde_json::from_value(value).map_err(|error| format!("bad answer from the host: {error}")) + } + + /// Send a request and wait for its answer. Returns the request id with + /// the value so a caller can pair a stream with it. + async fn call_raw( + &self, + request: &T, + timeout: Duration, + ) -> Result<(Value, u64), String> { + if let Some(reason) = self.closed.borrow().clone() { + return Err(reason); + } + let (method, params) = crate::wire::encode_request(request)?; + let id = self.next_id.fetch_add(1, Ordering::Relaxed); + let (tx, rx) = oneshot::channel(); + self.pending.lock().await.insert(id, tx); + let message = Message::Request(RpcRequest::new(id, method.clone(), params)); + if let Err(error) = + rpc::encode(&message).and_then(|bytes| self.out.try_send(Frame::control(bytes))) + { + self.pending.lock().await.remove(&id); + return Err(error); + } + match tokio::time::timeout(timeout, rx).await { + Ok(Ok(Ok(value))) => Ok((value, id)), + Ok(Ok(Err(error))) => Err(error.message), + Ok(Err(_)) => Err("the connection ended".to_string()), + Err(_) => { + self.pending.lock().await.remove(&id); + Err(format!("{method} timed out")) + } + } + } + + /// Fetch every page of a snapshot's timeline. + async fn page_timeline( + &self, + session_id: &str, + expected_len: usize, + ) -> Result, String> { + let mut items = Vec::with_capacity(expected_len); + loop { + let page: TimelinePage = self + .call(&SessionRequest::Timeline(TimelinePageParams { + session_id: session_id.to_string(), + offset: items.len(), + limit: self.config.timeline_page_items, + })) + .await?; + let received = page.items.len(); + items.extend(page.items); + if !page.has_more || received == 0 { + break; + } + } + Ok(items) + } +} + +#[async_trait] +impl HostBackend for RemoteHostBackend { + fn id(&self) -> &HostId { + &self.id + } + + fn subscribe(&self) -> mpsc::UnboundedReceiver { + self.events.subscribe() + } + + async fn bootstrap(&self) -> Result { + let snapshot: BootstrapSnapshot = self.call(&HostRequest::Bootstrap(Empty {})).await?; + let mut bootstrap = snapshot.bootstrap; + if let Some(latest) = bootstrap.latest.as_mut() { + latest.timeline = self + .page_timeline(&latest.session.id, snapshot.latest_timeline_len) + .await?; + } + Ok(bootstrap) + } + + async fn start_runtime( + &self, + request: Option, + ) -> Result { + self.call_with_timeout( + &HostRequest::StartRuntime(StartRuntime { request }), + self.config.long_request_timeout, + ) + .await + } + + async fn stop_runtime(&self) -> Result { + self.call(&HostRequest::StopRuntime(Empty {})).await + } + + async fn recent_project_roots(&self) -> Result, String> { + self.call(&ProjectRequest::RecentRoots(Empty {})).await + } + + async fn select_project_root( + &self, + path: String, + ) -> Result { + self.call(&ProjectRequest::SelectRoot(RootPath { path })) + .await + } + + async fn remove_project_root( + &self, + path: String, + fallback: Option, + ) -> Result<(), String> { + self.call(&ProjectRequest::RemoveRoot(RemoveRoot { path, fallback })) + .await + } + + async fn suggest_directories(&self, query: String) -> Result, String> { + self.call(&ProjectRequest::SuggestDirectories(SuggestDirectories { + query, + })) + .await + } + + async fn watch_project_root(&self, path: String) -> Result<(), String> { + self.call(&ProjectRequest::Watch(RootPath { path })).await + } + + async fn unwatch_project_root(&self, path: String) -> Result<(), String> { + self.call(&ProjectRequest::Unwatch(RootPath { path })).await + } + + async fn project_trust(&self, path: String) -> Result { + self.call(&ProjectRequest::Trust(RootPath { path })).await + } + + async fn set_project_trust( + &self, + path: String, + trusted: bool, + ) -> Result { + self.call(&ProjectRequest::SetTrust(SetTrust { path, trusted })) + .await + } + + async fn list_sessions( + &self, + project_root: Option, + ) -> Result, String> { + self.call(&SessionRequest::List(ListSessions { project_root })) + .await + } + + async fn create_session( + &self, + request: Option, + ) -> Result { + self.call(&SessionRequest::Create(CreateSession { request })) + .await + } + + async fn load_session(&self, session_id: String) -> Result { + let snapshot: SessionSnapshot = self + .call(&SessionRequest::Load(LoadSession { + session_id: session_id.clone(), + })) + .await?; + let mut detail = snapshot.detail; + detail.timeline = self + .page_timeline(&session_id, snapshot.timeline_len) + .await?; + Ok(detail) + } + + async fn rename_session( + &self, + session_id: String, + title: String, + ) -> Result { + self.call(&SessionRequest::Rename(RenameSession { session_id, title })) + .await + } + + async fn set_session_archived( + &self, + session_id: String, + archived: bool, + ) -> Result { + self.call(&SessionRequest::SetArchived(SetArchived { + session_id, + archived, + })) + .await + } + + async fn compact_session(&self, session_id: String) -> Result<(), String> { + self.call(&SessionRequest::Compact(SessionId { session_id })) + .await + } + + async fn session_subagents(&self, session_id: String) -> Result, String> { + self.call(&SessionRequest::Subagents(SessionId { session_id })) + .await + } + + async fn cancel_external_agent( + &self, + session_id: String, + agent_id: String, + ) -> Result<(), String> { + self.call(&SessionRequest::CancelExternalAgent(CancelExternalAgent { + session_id, + agent_id, + })) + .await + } + + async fn set_permission_mode(&self, session_id: String, mode: String) -> Result<(), String> { + self.call(&SessionRequest::SetPermissionMode(SetPermissionMode { + session_id, + mode, + })) + .await + } + + async fn set_session_web_enabled( + &self, + session_id: String, + enabled: bool, + ) -> Result { + self.call(&SessionRequest::SetWebEnabled(SetWebEnabled { + session_id, + enabled, + })) + .await + } + + async fn context_usage( + &self, + session_id: String, + model: Option, + ) -> Result, String> { + self.call(&HostRequest::ContextUsage(ContextUsageParams { + session_id, + model, + })) + .await + } + + async fn read_image_attachment( + &self, + session_id: String, + attachment_id: String, + ) -> Result, String> { + let (value, request_id) = self + .call_raw( + &SessionRequest::ReadAttachment(ReadAttachment { + session_id, + attachment_id, + }), + self.config.request_timeout, + ) + .await?; + let handle: AttachmentHandle = + serde_json::from_value(value).map_err(|error| error.to_string())?; + // The open frame preceded the answer on the same ordered carrier, + // so the collector for this request exists. + let receiver = self + .streams + .take_by_request(request_id) + .ok_or_else(|| format!("no stream {} for the attachment", handle.stream))?; + tokio::time::timeout(self.config.request_timeout, receiver) + .await + .map_err(|_| "attachment transfer timed out".to_string())? + .map_err(|_| "attachment transfer was cut off".to_string())? + } + + async fn send_message(&self, request: AgentSendMessageRequest) -> Result { + self.call(&RunRequest::Send(SendMessage { request })).await + } + + async fn cancel_run(&self, run_id: String) -> Result<(), String> { + self.call(&RunRequest::Cancel(RunId { run_id })).await + } + + async fn cancel_queued_message( + &self, + session_id: String, + queue_id: String, + ) -> Result { + self.call(&RunRequest::CancelQueued(QueueControl { + session_id, + queue_id, + })) + .await + } + + async fn begin_queued_message_edit( + &self, + session_id: String, + queue_id: String, + ) -> Result<(), String> { + self.call(&RunRequest::BeginQueuedEdit(QueueControl { + session_id, + queue_id, + })) + .await + } + + async fn end_queued_message_edit( + &self, + session_id: String, + queue_id: String, + ) -> Result<(), String> { + self.call(&RunRequest::EndQueuedEdit(QueueControl { + session_id, + queue_id, + })) + .await + } + + async fn answer_question(&self, request_id: String, answer: String) -> Result { + self.call(&RunRequest::AnswerQuestion(AnswerQuestion { + request_id, + answer, + })) + .await + } + + async fn permission_respond( + &self, + session_id: String, + request_id: String, + allow: bool, + ) -> Result<(), String> { + self.call(&RunRequest::PermissionRespond(PermissionRespond { + session_id, + request_id, + allow, + })) + .await + } + + async fn ask_side_question( + &self, + session_id: String, + request_id: String, + prior: Vec, + question: String, + ) -> Result<(), String> { + self.call(&RunRequest::AskSideQuestion(AskSideQuestion { + session_id, + request_id, + prior, + question, + })) + .await + } + + async fn summarize_tool_call( + &self, + session_id: String, + tool_name: String, + input: Option, + output_text: String, + ) -> Result, String> { + self.call(&RunRequest::SummarizeToolCall(SummarizeToolCall { + session_id, + tool_name, + input, + output_text, + })) + .await + } + + async fn summarize_thinking( + &self, + session_id: String, + thinking_text: String, + ) -> Result, String> { + self.call(&RunRequest::SummarizeThinking(SummarizeThinking { + session_id, + thinking_text, + })) + .await + } + + async fn tool_summaries(&self, session_id: String) -> Result, String> { + self.call(&HostRequest::ToolSummaries(SessionId { session_id })) + .await + } + + async fn store_tool_summary( + &self, + session_id: String, + item_id: String, + summary: String, + ) -> Result<(), String> { + self.call(&HostRequest::StoreToolSummary(StoreToolSummary { + session_id, + item_id, + summary, + })) + .await + } + + async fn available_model_ids(&self) -> Result, String> { + self.call(&ModelRequest::List(Empty {})).await + } + + async fn model_supports_vision(&self, model: String) -> Result, String> { + self.call(&ModelRequest::SupportsVision(ModelName { model })) + .await + } + + async fn list_slash_commands( + &self, + working_dir: Option, + ) -> Result, String> { + self.call(&ModelRequest::SlashCommands(WorkingDir { working_dir })) + .await + } + + async fn resolve_slash_command( + &self, + working_dir: Option, + command: String, + args: String, + ) -> Result, String> { + self.call(&ModelRequest::ResolveSlashCommand(ResolveSlashCommand { + working_dir, + command, + args, + })) + .await + } + + async fn list_session_mcp_servers( + &self, + session_id: String, + ) -> Result, String> { + self.call(&IntegrationRequest::ListSessionMcp(SessionId { + session_id, + })) + .await + } + + async fn set_session_mcp_server_enabled( + &self, + session_id: String, + name: String, + kind: AgentSessionIntegrationKind, + enabled: bool, + ) -> Result, String> { + self.call(&IntegrationRequest::SetSessionMcp(SetSessionMcp { + session_id, + name, + kind, + enabled, + })) + .await + } + + async fn list_mcp_servers(&self) -> Result, String> { + self.call(&IntegrationRequest::ListMcp(Empty {})).await + } + + async fn save_mcp_servers( + &self, + servers: Vec, + ) -> Result, String> { + self.call(&IntegrationRequest::SaveMcp(SaveMcpServers { servers })) + .await + } + + async fn list_integrations(&self) -> Result, String> { + self.call(&IntegrationRequest::List(Empty {})).await + } + + async fn set_integration_enabled( + &self, + id: String, + enabled: bool, + ) -> Result, String> { + self.call(&IntegrationRequest::SetEnabled(SetIntegrationEnabled { + id, + enabled, + })) + .await + } + + async fn setup_integration(&self, id: String) -> Result, String> { + self.call(&IntegrationRequest::Setup(IntegrationId { id })) + .await + } + + async fn session_defaults(&self) -> Result { + self.call(&HostRequest::SessionDefaults(Empty {})).await + } + + async fn set_session_defaults(&self, defaults: HostSessionDefaults) -> Result<(), String> { + self.call(&HostRequest::SetSessionDefaults(SetSessionDefaults { + defaults, + })) + .await + } + + async fn save_default_model(&self, model: String) -> Result<(), String> { + self.call(&HostRequest::SaveDefaultModel(SaveDefaultModel { model })) + .await + } + + async fn usage_summary(&self) -> Result { + self.call(&HostRequest::UsageSummary(Empty {})).await + } +} diff --git a/apps/maple-agent/crates/maple-remote/src/frame.rs b/apps/maple-agent/crates/maple-remote/src/frame.rs new file mode 100644 index 000000000..d059c0632 --- /dev/null +++ b/apps/maple-agent/crates/maple-remote/src/frame.rs @@ -0,0 +1,132 @@ +//! Framing inside one encrypted message. +//! +//! Every message after the handshake is one frame: +//! `[channel: u16 BE][kind: u8][payload]`. Channel 0 is the control +//! channel and carries one JSON-RPC message per `Data` frame. Channels 1 +//! and up are binary streams opened by the side that sends the data; see +//! [`crate::streams`]. + +use bytes::{BufMut, Bytes, BytesMut}; + +/// The control channel: JSON-RPC 2.0. +pub const CONTROL_CHANNEL: u16 = 0; + +/// Largest control frame either side accepts. Anything larger belongs on +/// a stream or must be paged. +pub const MAX_CONTROL_FRAME_BYTES: usize = 4 * 1024 * 1024; + +/// Largest data frame on a binary stream. +pub const MAX_STREAM_FRAME_BYTES: usize = 256 * 1024; + +const HEADER_BYTES: usize = 3; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum FrameKind { + /// Opens a stream. Payload: a JSON [`crate::streams::StreamOpen`]. + Open = 0, + /// Bytes on a stream, or one JSON-RPC message on channel 0. + Data = 1, + /// Ends a stream. Payload: empty, or a JSON + /// [`crate::streams::StreamClose`] naming an error. + Close = 2, + /// The receiver grants the sender more `Data` frames on a stream. + /// Payload: a u32 BE count. + Credit = 3, +} + +impl FrameKind { + fn from_byte(byte: u8) -> Option { + match byte { + 0 => Some(Self::Open), + 1 => Some(Self::Data), + 2 => Some(Self::Close), + 3 => Some(Self::Credit), + _ => None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Frame { + pub channel: u16, + pub kind: FrameKind, + pub payload: Bytes, +} + +impl Frame { + pub fn control(payload: impl Into) -> Self { + Self { + channel: CONTROL_CHANNEL, + kind: FrameKind::Data, + payload: payload.into(), + } + } + + pub fn encode(&self) -> Bytes { + let mut out = BytesMut::with_capacity(HEADER_BYTES + self.payload.len()); + out.put_u16(self.channel); + out.put_u8(self.kind as u8); + out.extend_from_slice(&self.payload); + out.freeze() + } + + /// Decode one frame. Rejects a short header, an unknown kind, and a + /// payload over the limit for its channel. + pub fn decode(bytes: Bytes) -> Result { + if bytes.len() < HEADER_BYTES { + return Err("frame shorter than its header".to_string()); + } + let channel = u16::from_be_bytes([bytes[0], bytes[1]]); + let kind = FrameKind::from_byte(bytes[2]) + .ok_or_else(|| format!("unknown frame kind {}", bytes[2]))?; + let payload = bytes.slice(HEADER_BYTES..); + let limit = if channel == CONTROL_CHANNEL { + MAX_CONTROL_FRAME_BYTES + } else { + MAX_STREAM_FRAME_BYTES + }; + if payload.len() > limit { + return Err(format!( + "frame of {} bytes on channel {channel} exceeds the {limit} byte limit", + payload.len() + )); + } + Ok(Self { + channel, + kind, + payload, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn frames_round_trip() { + let frame = Frame { + channel: 7, + kind: FrameKind::Credit, + payload: Bytes::from_static(&[0, 0, 0, 16]), + }; + let decoded = Frame::decode(frame.encode()).unwrap(); + assert_eq!(decoded, frame); + let control = Frame::control(r#"{"jsonrpc":"2.0"}"#); + assert_eq!(control.channel, CONTROL_CHANNEL); + assert_eq!(Frame::decode(control.encode()).unwrap(), control); + } + + #[test] + fn malformed_frames_are_refused() { + assert!(Frame::decode(Bytes::from_static(&[0, 0])).is_err()); + assert!(Frame::decode(Bytes::from_static(&[0, 0, 9])).is_err()); + let oversized = Frame { + channel: 3, + kind: FrameKind::Data, + payload: Bytes::from(vec![0u8; MAX_STREAM_FRAME_BYTES + 1]), + }; + assert!(Frame::decode(oversized.encode()).is_err()); + } +} diff --git a/apps/maple-agent/crates/maple-remote/src/lib.rs b/apps/maple-agent/crates/maple-remote/src/lib.rs new file mode 100644 index 000000000..a5897caac --- /dev/null +++ b/apps/maple-agent/crates/maple-remote/src/lib.rs @@ -0,0 +1,38 @@ +//! The wire between a Maple client and a host. +//! +//! Layers, bottom up: +//! +//! - [`carrier`]: a bidirectional stream of [`frame::Frame`]s. The +//! in-process pair here is for tests; a WebSocket carrier and the Noise +//! layer sit below this crate's view and deliver the same frames. +//! - [`frame`]: `[channel][kind][payload]`. Channel 0 is control and carries +//! JSON-RPC 2.0 ([`rpc`]). Other channels are binary streams with +//! credit-based flow control ([`streams`]). Every frame a side sends goes +//! through one byte-bounded queue ([`outbound`]); overflow closes the +//! connection rather than blocking the host. +//! - [`wire`]: the methods, grouped by domain, and the handshake. +//! - [`server`]: [`server::HostServer`] publishes any +//! [`maple_agent::host::HostBackend`] to connections. +//! - [`client`]: [`client::RemoteHostBackend`] implements `HostBackend` over +//! a connection, so the UI drives a remote host exactly like the local one. +//! +//! Compatibility rules for everything in [`wire`]: schemas are append-only; +//! new fields are optional with a serde default; unknown fields are +//! ignored; a field that stops being sent stays accepted. Every shim is +//! tagged `COMPAT(name): added in vX.Y, remove after YYYY-MM-DD`. Real +//! evolution goes through the feature bags in the handshake; the protocol +//! version is a tripwire that is bumped only for a change no feature flag +//! can express. + +pub mod carrier; +pub mod client; +pub mod frame; +pub mod outbound; +pub mod rpc; +pub mod server; +pub mod streams; +pub mod wire; + +pub use client::RemoteHostBackend; +pub use server::{HostServer, HostServerConfig}; +pub use wire::{ClientHello, HostInfo, PROTOCOL_VERSION}; diff --git a/apps/maple-agent/crates/maple-remote/src/outbound.rs b/apps/maple-agent/crates/maple-remote/src/outbound.rs new file mode 100644 index 000000000..1dd414eba --- /dev/null +++ b/apps/maple-agent/crates/maple-remote/src/outbound.rs @@ -0,0 +1,119 @@ +//! The bounded outbound queue in front of a carrier. +//! +//! Every frame a side sends goes through one queue that counts queued +//! bytes. A frame that would push the count past the limit closes the +//! connection: the peer has stopped draining, and the host must never +//! wait on a client. The peer comes back through its ordinary reconnect +//! and resync path. + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + +use tokio::sync::mpsc; + +use crate::frame::Frame; + +/// Default byte limit for one connection's outbound queue. +pub const DEFAULT_MAX_OUTBOUND_BYTES: usize = 64 * 1024 * 1024; + +struct Shared { + queued_bytes: AtomicUsize, + limit: usize, + overflowed: AtomicBool, +} + +/// The sending handle. Cheap to clone; every clone shares the budget. +#[derive(Clone)] +pub struct Outbound { + tx: mpsc::UnboundedSender, + shared: Arc, +} + +/// The draining end, owned by the writer task. +pub struct OutboundQueue { + rx: mpsc::UnboundedReceiver, + shared: Arc, +} + +/// Create a queue with a byte limit. +pub fn channel(max_bytes: usize) -> (Outbound, OutboundQueue) { + let (tx, rx) = mpsc::unbounded_channel(); + let shared = Arc::new(Shared { + queued_bytes: AtomicUsize::new(0), + limit: max_bytes, + overflowed: AtomicBool::new(false), + }); + ( + Outbound { + tx, + shared: Arc::clone(&shared), + }, + OutboundQueue { rx, shared }, + ) +} + +impl Outbound { + /// Queue a frame. Fails when the queue is closed or would overflow; + /// an overflow also marks the connection for closing. + pub fn try_send(&self, frame: Frame) -> Result<(), String> { + let bytes = frame.payload.len() + 3; + let queued = self.shared.queued_bytes.fetch_add(bytes, Ordering::AcqRel) + bytes; + if queued > self.shared.limit { + self.shared.queued_bytes.fetch_sub(bytes, Ordering::AcqRel); + self.shared.overflowed.store(true, Ordering::Release); + return Err(format!( + "outbound queue over its {} byte limit; closing the connection", + self.shared.limit + )); + } + self.tx.send(frame).map_err(|_| { + self.shared.queued_bytes.fetch_sub(bytes, Ordering::AcqRel); + "connection closed".to_string() + }) + } + + /// Same as [`Self::try_send`]; the queue never blocks, so this exists + /// for callers in async context that want the same shape as a sink. + pub async fn send(&self, frame: Frame) -> Result<(), String> { + self.try_send(frame) + } + + /// True once a frame overflowed the budget. + pub fn overflowed(&self) -> bool { + self.shared.overflowed.load(Ordering::Acquire) + } + + pub fn is_closed(&self) -> bool { + self.tx.is_closed() + } +} + +impl OutboundQueue { + /// The next frame to write, with its bytes released from the budget. + pub async fn recv(&mut self) -> Option { + let frame = self.rx.recv().await?; + self.shared + .queued_bytes + .fetch_sub(frame.payload.len() + 3, Ordering::AcqRel); + Some(frame) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn overflow_refuses_the_frame_and_marks_the_connection() { + let (out, mut queue) = channel(100); + out.try_send(Frame::control(vec![0u8; 50])).unwrap(); + assert!(!out.overflowed()); + assert!(out.try_send(Frame::control(vec![0u8; 60])).is_err()); + assert!(out.overflowed()); + // Draining releases the budget. + assert_eq!(queue.recv().await.unwrap().payload.len(), 50); + out.try_send(Frame::control(vec![0u8; 60])).unwrap(); + drop(queue); + assert!(out.try_send(Frame::control("late")).is_err()); + } +} diff --git a/apps/maple-agent/crates/maple-remote/src/rpc.rs b/apps/maple-agent/crates/maple-remote/src/rpc.rs new file mode 100644 index 000000000..3a306857d --- /dev/null +++ b/apps/maple-agent/crates/maple-remote/src/rpc.rs @@ -0,0 +1,182 @@ +//! JSON-RPC 2.0 on the control channel. +//! +//! Requests carry a numeric id and get exactly one response. Notifications +//! (no id) carry host events in one direction and the keepalive in both. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// Error codes. The JSON-RPC reserved range is honored; ours start at +/// -32000 as the spec allows. +pub mod code { + pub const PARSE_ERROR: i64 = -32700; + pub const INVALID_REQUEST: i64 = -32600; + pub const METHOD_NOT_FOUND: i64 = -32601; + pub const INVALID_PARAMS: i64 = -32602; + /// The host refused the handshake: mismatched environment or + /// protocol. The connection closes after this answer. + pub const HANDSHAKE_REFUSED: i64 = -32000; + /// The method requires a handshake first. + pub const NOT_READY: i64 = -32001; + /// The host's `HostBackend` returned an error; the message is the + /// user-facing text. + pub const HOST_ERROR: i64 = -32002; +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct RpcError { + pub code: i64, + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +impl RpcError { + pub fn new(code: i64, message: impl Into) -> Self { + Self { + code, + message: message.into(), + data: None, + } + } + + pub fn host(message: impl Into) -> Self { + Self::new(code::HOST_ERROR, message) + } +} + +/// Any message on the control channel. Serde tries the shapes in order, +/// most demanding first: `Request` needs `id` and `method`, +/// `Notification` needs `method` without `id`, and `Response` is whatever +/// remains with an `id`. Unknown fields are ignored everywhere, so the +/// order is what keeps a request from reading as a response. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(untagged)] +pub enum Message { + Request(Request), + Notification(Notification), + Response(Response), +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Request { + pub jsonrpc: Version, + pub id: u64, + pub method: String, + #[serde(default)] + pub params: Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Notification { + pub jsonrpc: Version, + pub method: String, + #[serde(default)] + pub params: Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Response { + pub jsonrpc: Version, + pub id: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// The literal `"2.0"`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Version; + +impl Serialize for Version { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str("2.0") + } +} + +impl<'de> Deserialize<'de> for Version { + fn deserialize>(deserializer: D) -> Result { + let value = ::deserialize(deserializer)?; + if value == "2.0" { + Ok(Version) + } else { + Err(serde::de::Error::custom(format!( + "unsupported JSON-RPC version {value:?}" + ))) + } + } +} + +impl Request { + pub fn new(id: u64, method: impl Into, params: Value) -> Self { + Self { + jsonrpc: Version, + id, + method: method.into(), + params, + } + } +} + +impl Notification { + pub fn new(method: impl Into, params: Value) -> Self { + Self { + jsonrpc: Version, + method: method.into(), + params, + } + } +} + +impl Response { + pub fn ok(id: u64, result: Value) -> Self { + Self { + jsonrpc: Version, + id, + result: Some(result), + error: None, + } + } + + pub fn err(id: u64, error: RpcError) -> Self { + Self { + jsonrpc: Version, + id, + result: None, + error: Some(error), + } + } +} + +pub fn encode(message: &Message) -> Result, String> { + serde_json::to_vec(message).map_err(|error| format!("cannot encode message: {error}")) +} + +pub fn decode(bytes: &[u8]) -> Result { + serde_json::from_slice(bytes).map_err(|error| format!("cannot decode message: {error}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn messages_round_trip_and_classify() { + let request = Message::Request(Request::new(1, "session.list", serde_json::json!({}))); + let bytes = encode(&request).unwrap(); + assert_eq!(decode(&bytes).unwrap(), request); + + let response = Message::Response(Response::ok(1, serde_json::json!([]))); + assert_eq!(decode(&encode(&response).unwrap()).unwrap(), response); + + let failure = Message::Response(Response::err(2, RpcError::host("no"))); + assert_eq!(decode(&encode(&failure).unwrap()).unwrap(), failure); + + let note = Message::Notification(Notification::new("event", serde_json::json!({"seq": 1}))); + assert_eq!(decode(&encode(¬e).unwrap()).unwrap(), note); + + assert!(decode(br#"{"jsonrpc":"1.0","id":1,"method":"x"}"#).is_err()); + assert!(decode(b"not json").is_err()); + } +} diff --git a/apps/maple-agent/crates/maple-remote/src/server.rs b/apps/maple-agent/crates/maple-remote/src/server.rs new file mode 100644 index 000000000..6c2fcc783 --- /dev/null +++ b/apps/maple-agent/crates/maple-remote/src/server.rs @@ -0,0 +1,681 @@ +//! The host side: publishes a [`HostBackend`] to connections. +//! +//! One [`HostServer`] serves any number of connections. Each connection +//! subscribes to the host's events before it answers the handshake, so no +//! event is lost between the two; forwards every event with a per-connection +//! sequence; answers requests concurrently so a slow call never delays the +//! keepalive; and closes itself when its outbound queue overflows or the +//! peer goes quiet past the lease. +//! +//! Requests are dispatched by domain to one controller each. A controller +//! is a plain `match` over its domain's request enum; it never grows past +//! that domain. + +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; + +use maple_agent::agent::AgentSessionDetail; +use maple_agent::host::{HostBackend, HostBootstrap}; +use serde::Serialize; +use serde_json::Value; +use tokio::sync::{Mutex, Notify, watch}; + +use crate::carrier::Carrier; +use crate::frame::{CONTROL_CHANNEL, Frame, FrameKind}; +use crate::outbound::{self, DEFAULT_MAX_OUTBOUND_BYTES, Outbound}; +use crate::rpc::{self, Message, Request as RpcRequest, Response, RpcError, code}; +use crate::streams::{StreamOpen, StreamSenders, decode_credit}; +use crate::wire::{ + AttachmentHandle, BootstrapSnapshot, ClientHello, EventEnvelope, HostHello, HostInfo, + HostRequest, IntegrationRequest, ModelRequest, PROTOCOL_VERSION, ProjectRequest, Request, + RunRequest, SessionRequest, SessionSnapshot, TimelinePage, decode_request, features, +}; + +/// What the host tells clients about itself in the handshake. +#[derive(Debug, Clone)] +pub struct HostIdentity { + pub app_version: String, + pub pcr_environment: String, +} + +#[derive(Debug, Clone)] +pub struct HostServerConfig { + /// Bytes one connection may have queued before it is closed. + pub max_outbound_bytes: usize, + /// A connection with no inbound frame for this long is closed. + pub lease: Duration, + /// How often the lease is checked. + pub lease_check: Duration, + /// Most timeline items in one page. + pub timeline_page_items: usize, + /// Approximate serialized bytes in one page; a page stops before + /// the item that would cross it (at least one item always fits). + pub timeline_page_bytes: usize, +} + +impl Default for HostServerConfig { + fn default() -> Self { + Self { + max_outbound_bytes: DEFAULT_MAX_OUTBOUND_BYTES, + lease: Duration::from_secs(45), + lease_check: Duration::from_secs(10), + timeline_page_items: 200, + timeline_page_bytes: 1024 * 1024, + } + } +} + +pub struct HostServer { + host: Arc, + info: HostInfo, + identity: HostIdentity, + config: HostServerConfig, + /// Minted once per process; every connection's sequences are scoped + /// to it. + generation: String, +} + +impl HostServer { + pub fn new( + host: Arc, + info: HostInfo, + identity: HostIdentity, + config: HostServerConfig, + ) -> Arc { + Arc::new(Self { + host, + info, + identity, + config, + generation: uuid::Uuid::new_v4().to_string(), + }) + } + + pub fn generation(&self) -> &str { + &self.generation + } + + pub fn host(&self) -> &Arc { + &self.host + } + + /// Serve one connection until it ends. Returns why it ended. + pub async fn serve(self: Arc, carrier: Carrier) -> Result<(), String> { + let Carrier { + mut sink, + mut stream, + } = carrier; + let (out, mut queue) = outbound::channel(self.config.max_outbound_bytes); + let (closed_tx, mut closed_rx) = watch::channel(false); + let connection = Arc::new(Connection { + server: Arc::clone(&self), + out: out.clone(), + streams: StreamSenders::default(), + snapshots: Mutex::new(HashMap::new()), + ready: AtomicBool::new(false), + ready_notify: Notify::new(), + last_activity: std::sync::Mutex::new(Instant::now()), + closed: closed_tx, + }); + + // Subscribe before the handshake so nothing is missed between the + // two; the forwarder holds events until the client is ready. + let mut events = self.host.subscribe(); + let forwarder = { + let connection = Arc::clone(&connection); + tokio::spawn(async move { + connection.ready_notify.notified().await; + let mut seq: u64 = 0; + while let Some(event) = events.recv().await { + seq += 1; + let envelope = EventEnvelope { seq, event }; + if connection + .notify(crate::wire::EVENT_METHOD, &envelope) + .is_err() + { + break; + } + } + }) + }; + + let writer = { + let connection = Arc::clone(&connection); + tokio::spawn(async move { + while let Some(frame) = queue.recv().await { + if sink.send(frame).await.is_err() { + break; + } + } + sink.close().await; + connection.close("writer ended"); + }) + }; + + let lease = { + let connection = Arc::clone(&connection); + let lease = self.config.lease; + let check = self.config.lease_check; + tokio::spawn(async move { + loop { + tokio::time::sleep(check).await; + let idle = connection.idle_for(); + if idle > lease { + connection.close("lease expired"); + return; + } + if connection.out.overflowed() { + connection.close("outbound queue overflowed"); + return; + } + } + }) + }; + + let reason = loop { + let frame = tokio::select! { + frame = stream.recv() => frame, + _ = closed_rx.changed() => None, + }; + let Some(frame) = frame else { + break connection + .close_reason() + .unwrap_or_else(|| "peer closed".to_string()); + }; + connection.touch(); + if let Err(error) = connection.on_frame(frame) { + connection.close(&error); + break error; + } + }; + forwarder.abort(); + lease.abort(); + drop(out); + // Let queued frames (a refusal, an error answer) reach the peer. + let _ = tokio::time::timeout(Duration::from_secs(2), writer).await; + log::debug!("host connection ended: {reason}"); + Ok(()) + } +} + +/// One client connection. +struct Connection { + server: Arc, + out: Outbound, + streams: StreamSenders, + /// Snapshots the client pages through, keyed by session id. Replaced + /// by the next load of the same task. + snapshots: Mutex>>, + ready: AtomicBool, + ready_notify: Notify, + last_activity: std::sync::Mutex, + closed: watch::Sender, +} + +impl Connection { + fn touch(&self) { + *self + .last_activity + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Instant::now(); + } + + fn idle_for(&self) -> Duration { + self.last_activity + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .elapsed() + } + + fn close(&self, reason: &str) { + if !*self.closed.borrow() { + log::debug!("closing host connection: {reason}"); + self.closed.send_replace(true); + } + } + + fn close_reason(&self) -> Option { + (*self.closed.borrow()).then(|| "closed".to_string()) + } + + fn notify(&self, method: &str, params: &T) -> Result<(), String> { + let params = serde_json::to_value(params).map_err(|error| error.to_string())?; + let message = Message::Notification(rpc::Notification::new(method, params)); + self.send_message(&message) + } + + fn send_message(&self, message: &Message) -> Result<(), String> { + let bytes = rpc::encode(message)?; + self.out.try_send(Frame::control(bytes)) + } + + fn respond(&self, response: Response) { + if let Err(error) = self.send_message(&Message::Response(response)) { + self.close(&error); + } + } + + /// Route one inbound frame. Requests are answered on their own task. + fn on_frame(self: &Arc, frame: Frame) -> Result<(), String> { + if frame.channel != CONTROL_CHANNEL { + if frame.kind == FrameKind::Credit + && let Some(credit) = decode_credit(&frame.payload) + { + self.streams.credit(frame.channel, credit); + } + // Clients do not send streams yet; other kinds are ignored. + return Ok(()); + } + let message = rpc::decode(&frame.payload)?; + match message { + Message::Request(request) => { + let connection = Arc::clone(self); + tokio::spawn(async move { connection.handle(request).await }); + } + Message::Notification(notification) => { + log::debug!("ignoring notification {}", notification.method); + } + Message::Response(_) => { + log::debug!("ignoring a response from the client"); + } + } + Ok(()) + } + + async fn handle(self: Arc, request: RpcRequest) { + let id = request.id; + let decoded = match decode_request(&request.method, request.params) { + Ok(decoded) => decoded, + Err(crate::wire::DecodeError::UnknownMethod(method)) => { + self.respond(Response::err( + id, + RpcError::new(code::METHOD_NOT_FOUND, format!("unknown method {method}")), + )); + return; + } + Err(crate::wire::DecodeError::InvalidParams(message)) => { + self.respond(Response::err( + id, + RpcError::new(code::INVALID_PARAMS, message), + )); + return; + } + }; + if let Request::Host(HostRequest::Hello(hello)) = decoded { + self.handle_hello(id, hello); + return; + } + if !self.ready.load(Ordering::Acquire) { + self.respond(Response::err( + id, + RpcError::new(code::NOT_READY, "send host.hello first"), + )); + return; + } + let result = match decoded { + Request::Host(request) => self.host_controller(request).await, + Request::Project(request) => self.project_controller(request).await, + Request::Session(request) => self.session_controller(id, request).await, + Request::Run(request) => self.run_controller(request).await, + Request::Model(request) => self.model_controller(request).await, + Request::Integration(request) => self.integration_controller(request).await, + }; + self.respond(match result { + Ok(value) => Response::ok(id, value), + Err(error) => Response::err(id, error), + }); + } + + fn handle_hello(&self, id: u64, hello: ClientHello) { + let identity = &self.server.identity; + let refusal = if hello.protocol != PROTOCOL_VERSION { + Some(format!( + "protocol {} is not supported; this host speaks {PROTOCOL_VERSION}. Update the client or the host.", + hello.protocol + )) + } else if hello.pcr_environment != identity.pcr_environment { + Some(format!( + "the client is built for the {} environment and this host for {}", + hello.pcr_environment, identity.pcr_environment + )) + } else { + None + }; + if let Some(message) = refusal { + self.respond(Response::err( + id, + RpcError::new(code::HANDSHAKE_REFUSED, message), + )); + self.close("handshake refused"); + return; + } + log::info!( + "client {} ({}) connected", + hello.device.name, + hello.device.public_key + ); + let answer = HostHello { + protocol: PROTOCOL_VERSION, + app_version: identity.app_version.clone(), + pcr_environment: identity.pcr_environment.clone(), + generation: self.server.generation.clone(), + seq: 0, + features: features(), + host: self.server.info.clone(), + }; + match serde_json::to_value(&answer) { + Ok(value) => { + self.respond(Response::ok(id, value)); + self.ready.store(true, Ordering::Release); + self.ready_notify.notify_one(); + } + Err(error) => self.respond(Response::err(id, RpcError::host(error.to_string()))), + } + } + + fn host(&self) -> &Arc { + &self.server.host + } + + /// Serialize a controller result, mapping host errors to RPC errors. + fn ok(value: Result) -> Result { + let value = value.map_err(RpcError::host)?; + serde_json::to_value(value).map_err(|error| RpcError::host(error.to_string())) + } + + async fn host_controller(&self, request: HostRequest) -> Result { + let host = self.host(); + match request { + HostRequest::Hello(_) => Err(RpcError::new( + code::INVALID_REQUEST, + "hello was already sent", + )), + HostRequest::Ping(_) => Ok(Value::Object(Default::default())), + HostRequest::Bootstrap(_) => { + let bootstrap = host.bootstrap().await.map_err(RpcError::host)?; + Self::ok(Ok(self.snapshot_bootstrap(bootstrap).await)) + } + HostRequest::StartRuntime(params) => Self::ok(host.start_runtime(params.request).await), + HostRequest::StopRuntime(_) => Self::ok(host.stop_runtime().await), + HostRequest::SessionDefaults(_) => Self::ok(host.session_defaults().await), + HostRequest::SetSessionDefaults(params) => { + Self::ok(host.set_session_defaults(params.defaults).await) + } + HostRequest::SaveDefaultModel(params) => { + Self::ok(host.save_default_model(params.model).await) + } + HostRequest::UsageSummary(_) => Self::ok(host.usage_summary().await), + HostRequest::ContextUsage(params) => { + Self::ok(host.context_usage(params.session_id, params.model).await) + } + HostRequest::ToolSummaries(params) => { + Self::ok(host.tool_summaries(params.session_id).await) + } + HostRequest::StoreToolSummary(params) => Self::ok( + host.store_tool_summary(params.session_id, params.item_id, params.summary) + .await, + ), + } + } + + async fn project_controller(&self, request: ProjectRequest) -> Result { + let host = self.host(); + match request { + ProjectRequest::RecentRoots(_) => Self::ok(host.recent_project_roots().await), + ProjectRequest::SelectRoot(params) => { + Self::ok(host.select_project_root(params.path).await) + } + ProjectRequest::RemoveRoot(params) => { + Self::ok(host.remove_project_root(params.path, params.fallback).await) + } + ProjectRequest::SuggestDirectories(params) => { + Self::ok(host.suggest_directories(params.query).await) + } + ProjectRequest::Watch(params) => Self::ok(host.watch_project_root(params.path).await), + ProjectRequest::Unwatch(params) => { + Self::ok(host.unwatch_project_root(params.path).await) + } + ProjectRequest::Trust(params) => Self::ok(host.project_trust(params.path).await), + ProjectRequest::SetTrust(params) => { + Self::ok(host.set_project_trust(params.path, params.trusted).await) + } + } + } + + async fn session_controller( + &self, + request_id: u64, + request: SessionRequest, + ) -> Result { + let host = self.host(); + match request { + SessionRequest::List(params) => Self::ok(host.list_sessions(params.project_root).await), + SessionRequest::Create(params) => Self::ok(host.create_session(params.request).await), + SessionRequest::Load(params) => { + let detail = host + .load_session(params.session_id) + .await + .map_err(RpcError::host)?; + Self::ok(Ok(self.snapshot_session(detail).await)) + } + SessionRequest::Timeline(params) => Self::ok(self.timeline_page(params).await), + SessionRequest::Rename(params) => { + Self::ok(host.rename_session(params.session_id, params.title).await) + } + SessionRequest::SetArchived(params) => Self::ok( + host.set_session_archived(params.session_id, params.archived) + .await, + ), + SessionRequest::Compact(params) => { + Self::ok(host.compact_session(params.session_id).await) + } + SessionRequest::Subagents(params) => { + Self::ok(host.session_subagents(params.session_id).await) + } + SessionRequest::CancelExternalAgent(params) => Self::ok( + host.cancel_external_agent(params.session_id, params.agent_id) + .await, + ), + SessionRequest::SetPermissionMode(params) => Self::ok( + host.set_permission_mode(params.session_id, params.mode) + .await, + ), + SessionRequest::SetWebEnabled(params) => Self::ok( + host.set_session_web_enabled(params.session_id, params.enabled) + .await, + ), + SessionRequest::ReadAttachment(params) => { + let bytes = host + .read_image_attachment(params.session_id, params.attachment_id) + .await + .map_err(RpcError::host)?; + let sender = self + .streams + .open( + &self.out, + StreamOpen { + purpose: "attachment".to_string(), + request_id: Some(request_id), + len: Some(bytes.len() as u64), + }, + ) + .await + .map_err(RpcError::host)?; + let handle = AttachmentHandle { + stream: sender.channel(), + len: bytes.len() as u64, + }; + // The bytes follow the answer; the receiver pairs them + // through the request id in the open frame. + tokio::spawn(async move { + if let Err(error) = sender.send_all(&bytes).await { + log::debug!("attachment stream ended early: {error}"); + } + }); + Self::ok(Ok(handle)) + } + } + } + + async fn run_controller(&self, request: RunRequest) -> Result { + let host = self.host(); + match request { + RunRequest::Send(params) => Self::ok(host.send_message(params.request).await), + RunRequest::Cancel(params) => Self::ok(host.cancel_run(params.run_id).await), + RunRequest::CancelQueued(params) => Self::ok( + host.cancel_queued_message(params.session_id, params.queue_id) + .await, + ), + RunRequest::BeginQueuedEdit(params) => Self::ok( + host.begin_queued_message_edit(params.session_id, params.queue_id) + .await, + ), + RunRequest::EndQueuedEdit(params) => Self::ok( + host.end_queued_message_edit(params.session_id, params.queue_id) + .await, + ), + RunRequest::AnswerQuestion(params) => { + Self::ok(host.answer_question(params.request_id, params.answer).await) + } + RunRequest::PermissionRespond(params) => Self::ok( + host.permission_respond(params.session_id, params.request_id, params.allow) + .await, + ), + RunRequest::AskSideQuestion(params) => Self::ok( + host.ask_side_question( + params.session_id, + params.request_id, + params.prior, + params.question, + ) + .await, + ), + RunRequest::SummarizeToolCall(params) => Self::ok( + host.summarize_tool_call( + params.session_id, + params.tool_name, + params.input, + params.output_text, + ) + .await, + ), + RunRequest::SummarizeThinking(params) => Self::ok( + host.summarize_thinking(params.session_id, params.thinking_text) + .await, + ), + } + } + + async fn model_controller(&self, request: ModelRequest) -> Result { + let host = self.host(); + match request { + ModelRequest::List(_) => Self::ok(host.available_model_ids().await), + ModelRequest::SupportsVision(params) => { + Self::ok(host.model_supports_vision(params.model).await) + } + ModelRequest::SlashCommands(params) => { + Self::ok(host.list_slash_commands(params.working_dir).await) + } + ModelRequest::ResolveSlashCommand(params) => Self::ok( + host.resolve_slash_command(params.working_dir, params.command, params.args) + .await, + ), + } + } + + async fn integration_controller(&self, request: IntegrationRequest) -> Result { + let host = self.host(); + match request { + IntegrationRequest::ListSessionMcp(params) => { + Self::ok(host.list_session_mcp_servers(params.session_id).await) + } + IntegrationRequest::SetSessionMcp(params) => Self::ok( + host.set_session_mcp_server_enabled( + params.session_id, + params.name, + params.kind, + params.enabled, + ) + .await, + ), + IntegrationRequest::ListMcp(_) => Self::ok(host.list_mcp_servers().await), + IntegrationRequest::SaveMcp(params) => { + Self::ok(host.save_mcp_servers(params.servers).await) + } + IntegrationRequest::List(_) => Self::ok(host.list_integrations().await), + IntegrationRequest::SetEnabled(params) => Self::ok( + host.set_integration_enabled(params.id, params.enabled) + .await, + ), + IntegrationRequest::Setup(params) => Self::ok(host.setup_integration(params.id).await), + } + } + + /// Keep a loaded task for paging and answer with its timeline stripped. + async fn snapshot_session(&self, detail: AgentSessionDetail) -> SessionSnapshot { + let timeline_len = detail.timeline.len(); + let detail = Arc::new(detail); + self.snapshots + .lock() + .await + .insert(detail.session.id.clone(), Arc::clone(&detail)); + let mut stripped = (*detail).clone(); + stripped.timeline = Vec::new(); + SessionSnapshot { + detail: stripped, + timeline_len, + } + } + + async fn snapshot_bootstrap(&self, mut bootstrap: HostBootstrap) -> BootstrapSnapshot { + let mut latest_timeline_len = 0; + if let Some(latest) = bootstrap.latest.take() { + let snapshot = self.snapshot_session(latest).await; + latest_timeline_len = snapshot.timeline_len; + bootstrap.latest = Some(snapshot.detail); + } + BootstrapSnapshot { + bootstrap, + latest_timeline_len, + } + } + + /// One page of a kept snapshot, bounded by item count and bytes. A + /// task that was never loaded on this connection is loaded first. + async fn timeline_page( + &self, + params: crate::wire::TimelinePageParams, + ) -> Result { + let cached = self.snapshots.lock().await.get(¶ms.session_id).cloned(); + let detail = match cached { + Some(detail) => detail, + None => { + let detail = self.host().load_session(params.session_id.clone()).await?; + let detail = Arc::new(detail); + self.snapshots + .lock() + .await + .insert(params.session_id.clone(), Arc::clone(&detail)); + detail + } + }; + let config = &self.server.config; + let limit = params.limit.clamp(1, config.timeline_page_items); + let mut items = Vec::new(); + let mut bytes = 0usize; + for item in detail.timeline.iter().skip(params.offset) { + let size = serde_json::to_vec(item).map(|json| json.len()).unwrap_or(0); + if !items.is_empty() + && (items.len() >= limit || bytes + size > config.timeline_page_bytes) + { + break; + } + bytes += size; + items.push(item.clone()); + } + let has_more = params.offset + items.len() < detail.timeline.len(); + Ok(TimelinePage { items, has_more }) + } +} diff --git a/apps/maple-agent/crates/maple-remote/src/streams.rs b/apps/maple-agent/crates/maple-remote/src/streams.rs new file mode 100644 index 000000000..a82a04f6e --- /dev/null +++ b/apps/maple-agent/crates/maple-remote/src/streams.rs @@ -0,0 +1,399 @@ +//! Binary streams on channels 1 and up, with credit-based flow control. +//! +//! The side that sends the bytes opens the stream with an `Open` frame, +//! sends `Data` frames while it holds credit, and ends with `Close`. The +//! receiver starts the sender with [`INITIAL_CREDIT`] frames and grants +//! more as it consumes, so one slow transfer can never fill the +//! connection's outbound queue. Attachments use this today; a PTY would +//! use the same frames with its own `purpose`. + +use std::collections::HashMap; +use std::sync::Mutex; +use std::sync::atomic::{AtomicU16, Ordering}; + +use bytes::Bytes; +use serde::{Deserialize, Serialize}; +use tokio::sync::{mpsc, oneshot}; + +use crate::frame::{Frame, FrameKind, MAX_STREAM_FRAME_BYTES}; +use crate::outbound::Outbound; + +/// Frames a sender may have in flight when a stream opens. +pub const INITIAL_CREDIT: u32 = 16; +/// The receiver grants this many more frames each time it has consumed +/// this many. +pub const CREDIT_REFILL: u32 = 8; + +/// Payload of an `Open` frame. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StreamOpen { + /// What the bytes are: `attachment` today. + pub purpose: String, + /// The JSON-RPC request this stream answers, so the receiver can pair + /// the bytes with the response that names the channel. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub request_id: Option, + /// Total bytes, when known up front. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub len: Option, +} + +/// Payload of a `Close` frame that ends a stream early. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StreamClose { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +fn credit_frame(channel: u16, credit: u32) -> Frame { + Frame { + channel, + kind: FrameKind::Credit, + payload: Bytes::copy_from_slice(&credit.to_be_bytes()), + } +} + +pub fn decode_credit(payload: &[u8]) -> Option { + let bytes: [u8; 4] = payload.try_into().ok()?; + Some(u32::from_be_bytes(bytes)) +} + +// ---- Sending side ------------------------------------------------------------- + +/// One open stream the local side is sending on. +pub struct StreamSender { + channel: u16, + out: Outbound, + credits: mpsc::UnboundedReceiver, + available: u32, +} + +impl StreamSender { + pub fn channel(&self) -> u16 { + self.channel + } + + /// Send every byte in frames of at most [`MAX_STREAM_FRAME_BYTES`], + /// waiting for credit between frames, then close the stream. + pub async fn send_all(mut self, bytes: &[u8]) -> Result<(), String> { + for chunk in bytes.chunks(MAX_STREAM_FRAME_BYTES) { + while self.available == 0 { + self.available += self + .credits + .recv() + .await + .ok_or_else(|| "stream receiver went away".to_string())?; + } + self.out + .send(Frame { + channel: self.channel, + kind: FrameKind::Data, + payload: Bytes::copy_from_slice(chunk), + }) + .await?; + self.available -= 1; + } + self.out + .send(Frame { + channel: self.channel, + kind: FrameKind::Close, + payload: Bytes::new(), + }) + .await + } + + /// End the stream with an error instead of bytes. + pub async fn fail(self, error: &str) -> Result<(), String> { + let payload = serde_json::to_vec(&StreamClose { + error: Some(error.to_string()), + }) + .unwrap_or_default(); + self.out + .send(Frame { + channel: self.channel, + kind: FrameKind::Close, + payload: Bytes::from(payload), + }) + .await + } +} + +/// The streams one connection is sending, keyed by channel, so incoming +/// `Credit` frames reach the right sender. +pub struct StreamSenders { + next_channel: AtomicU16, + credits: Mutex>>, +} + +impl Default for StreamSenders { + fn default() -> Self { + Self { + next_channel: AtomicU16::new(1), + credits: Mutex::new(HashMap::new()), + } + } +} + +impl StreamSenders { + /// Open a stream: sends the `Open` frame and returns the sender, which + /// starts with [`INITIAL_CREDIT`]. + pub async fn open(&self, out: &Outbound, open: StreamOpen) -> Result { + let channel = self.allocate_channel(); + let (credit_tx, credit_rx) = mpsc::unbounded_channel(); + self.credits + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(channel, credit_tx); + let payload = serde_json::to_vec(&open).map_err(|error| error.to_string())?; + out.send(Frame { + channel, + kind: FrameKind::Open, + payload: Bytes::from(payload), + }) + .await?; + Ok(StreamSender { + channel, + out: out.clone(), + credits: credit_rx, + available: INITIAL_CREDIT, + }) + } + + fn allocate_channel(&self) -> u16 { + loop { + let channel = self.next_channel.fetch_add(1, Ordering::Relaxed); + // Channel 0 is control; wrap past it. + if channel != 0 { + return channel; + } + } + } + + /// The peer granted `credit` more frames on `channel`. + pub fn credit(&self, channel: u16, credit: u32) { + let mut credits = self + .credits + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(sender) = credits.get(&channel) + && sender.send(credit).is_err() + { + credits.remove(&channel); + } + } +} + +// ---- Receiving side ----------------------------------------------------------- + +struct Collector { + bytes: Vec, + consumed_since_credit: u32, + done: Option>, +} + +/// The collected bytes of one stream, or why it ended early. +pub type StreamResult = Result, String>; + +/// The streams one connection is receiving. Bytes are collected whole; +/// the request that asked for them awaits the result by request id. +#[derive(Default)] +pub struct StreamReceivers { + open: Mutex>, + by_request: Mutex>>, +} + +impl StreamReceivers { + /// An `Open` frame arrived. + pub fn on_open(&self, channel: u16, payload: &[u8]) { + let open: StreamOpen = match serde_json::from_slice(payload) { + Ok(open) => open, + Err(error) => { + log::debug!("ignoring stream open on channel {channel}: {error}"); + return; + } + }; + let (done_tx, done_rx) = oneshot::channel(); + self.open + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert( + channel, + Collector { + bytes: Vec::with_capacity(open.len.unwrap_or(0).min(64 * 1024 * 1024) as usize), + consumed_since_credit: 0, + done: Some(done_tx), + }, + ); + if let Some(request_id) = open.request_id { + self.by_request + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(request_id, done_rx); + } + } + + /// A `Data` frame arrived. Returns a credit frame to send back when the + /// sender has earned more. + pub fn on_data(&self, channel: u16, payload: &[u8]) -> Option { + let mut open = self + .open + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let collector = open.get_mut(&channel)?; + collector.bytes.extend_from_slice(payload); + collector.consumed_since_credit += 1; + if collector.consumed_since_credit >= CREDIT_REFILL { + collector.consumed_since_credit = 0; + return Some(credit_frame(channel, CREDIT_REFILL)); + } + None + } + + /// A `Close` frame arrived: the bytes are complete, or the sender + /// reported an error. + pub fn on_close(&self, channel: u16, payload: &[u8]) { + let Some(mut collector) = self + .open + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&channel) + else { + return; + }; + let error = if payload.is_empty() { + None + } else { + serde_json::from_slice::(payload) + .ok() + .and_then(|close| close.error) + }; + if let Some(done) = collector.done.take() { + let _ = done.send(match error { + Some(error) => Err(error), + None => Ok(std::mem::take(&mut collector.bytes)), + }); + } + } + + /// The receiver for the stream that answers `request_id`, once its + /// `Open` frame has arrived. + pub fn take_by_request(&self, request_id: u64) -> Option> { + self.by_request + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&request_id) + } + + /// The connection ended: every open stream fails. + pub fn fail_all(&self, reason: &str) { + let mut open = self + .open + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + for (_, mut collector) in open.drain() { + if let Some(done) = collector.done.take() { + let _ = done.send(Err(reason.to_string())); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::outbound; + + #[tokio::test] + async fn a_stream_crosses_with_credit_and_collects_whole() { + let (out, mut queue) = outbound::channel(usize::MAX); + let senders = StreamSenders::default(); + let receivers = StreamReceivers::default(); + let payload = vec![7u8; MAX_STREAM_FRAME_BYTES * 20 + 5]; + let sender = senders + .open( + &out, + StreamOpen { + purpose: "attachment".into(), + request_id: Some(9), + len: Some(payload.len() as u64), + }, + ) + .await + .unwrap(); + let channel = sender.channel(); + let sending = tokio::spawn(async move { sender.send_all(&payload).await }); + + // Pump frames from the sender's queue into the receiver, returning + // credit the way the peer would. + let mut delivered = None; + loop { + let frame = queue.recv().await.expect("frame"); + match frame.kind { + FrameKind::Open => { + receivers.on_open(frame.channel, &frame.payload); + delivered = receivers.take_by_request(9); + } + FrameKind::Data => { + if let Some(credit) = receivers.on_data(frame.channel, &frame.payload) { + senders.credit(credit.channel, decode_credit(&credit.payload).unwrap()); + } + } + FrameKind::Close => { + receivers.on_close(frame.channel, &frame.payload); + break; + } + FrameKind::Credit => unreachable!(), + } + } + sending.await.unwrap().unwrap(); + assert_eq!(channel, 1); + let bytes = delivered.unwrap().await.unwrap().unwrap(); + assert_eq!(bytes.len(), MAX_STREAM_FRAME_BYTES * 20 + 5); + assert!(bytes.iter().all(|byte| *byte == 7)); + } + + #[tokio::test] + async fn a_failed_stream_reports_its_error_and_connection_loss_fails_the_rest() { + let (out, mut queue) = outbound::channel(usize::MAX); + let senders = StreamSenders::default(); + let receivers = StreamReceivers::default(); + let sender = senders + .open( + &out, + StreamOpen { + purpose: "attachment".into(), + request_id: Some(1), + len: None, + }, + ) + .await + .unwrap(); + sender.fail("missing").await.unwrap(); + let open = queue.recv().await.unwrap(); + receivers.on_open(open.channel, &open.payload); + let rx = receivers.take_by_request(1).unwrap(); + let close = queue.recv().await.unwrap(); + receivers.on_close(close.channel, &close.payload); + assert_eq!(rx.await.unwrap(), Err("missing".to_string())); + + let other = senders + .open( + &out, + StreamOpen { + purpose: "attachment".into(), + request_id: Some(2), + len: None, + }, + ) + .await + .unwrap(); + drop(other); + let open = queue.recv().await.unwrap(); + receivers.on_open(open.channel, &open.payload); + let rx = receivers.take_by_request(2).unwrap(); + receivers.fail_all("connection lost"); + assert_eq!(rx.await.unwrap(), Err("connection lost".to_string())); + } +} diff --git a/apps/maple-agent/crates/maple-remote/src/wire.rs b/apps/maple-agent/crates/maple-remote/src/wire.rs new file mode 100644 index 000000000..c8f334ca2 --- /dev/null +++ b/apps/maple-agent/crates/maple-remote/src/wire.rs @@ -0,0 +1,523 @@ +//! The methods a client calls on a host, grouped by domain, and the +//! handshake both sides exchange first. +//! +//! Every request enum is internally tagged by the JSON-RPC method name, so +//! a request `{ "method": "session.list", "params": {...} }` decodes into +//! `SessionRequest::List(ListSessions {...})`. The server dispatches by the +//! prefix before the dot; each domain has its own controller. +//! +//! Compatibility: append-only. New params are `Option` with a default; +//! unknown fields are ignored on both sides. See the crate docs. + +use std::collections::BTreeMap; + +use maple_agent::agent::{ + AgentCreateSessionRequest, AgentMcpServer, AgentSendMessageRequest, AgentSessionDetail, + AgentSessionIntegrationKind, AgentStartRequest, AgentTimelineItem, SideQuestionTurn, +}; +use maple_agent::host::{HostBootstrap, HostEvent, HostSessionDefaults}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// Bumped only for a change no feature flag can express. Mismatch refuses +/// the connection. +pub const PROTOCOL_VERSION: u32 = 1; + +/// Feature flags a side advertises. Absent means off. +pub type Features = BTreeMap; + +/// Features this build implements. Both sides send the same table; a +/// client gates a new call on the host's answer. +pub fn features() -> Features { + let mut features = Features::new(); + for name in ["timelinePaging", "attachmentStreams", "ping"] { + features.insert(name.to_string(), true); + } + features +} + +/// The notification method that carries host events. +pub const EVENT_METHOD: &str = "event"; + +/// First request on a connection. Everything else is refused before it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClientHello { + pub protocol: u32, + pub app_version: String, + /// The compile-time OpenSecret environment (`Production` or + /// `Development`). Two binaries built for different enclaves cannot + /// share a backend, so a mismatch is refused. + pub pcr_environment: String, + #[serde(default)] + pub features: Features, + pub device: DeviceInfo, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeviceInfo { + /// The device's static public key, or a placeholder before pairing + /// exists. + pub public_key: String, + /// Display name, for the host's device list. + pub name: String, + /// The account the client is signed in as, for display only. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_id: Option, +} + +/// The host's answer to [`ClientHello`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HostHello { + pub protocol: u32, + pub app_version: String, + pub pcr_environment: String, + /// Minted when the host process started; every sequence is scoped to + /// it. A different value after a reconnect means every cursor is + /// stale. + pub generation: String, + /// The connection's event sequence starts after this value. + pub seq: u64, + #[serde(default)] + pub features: Features, + pub host: HostInfo, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HostInfo { + /// The host's identity: its static public key once pairing exists. + pub id: String, + pub name: String, + /// The account the host is signed in as, for display only. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_id: Option, +} + +/// One host event as delivered over a connection. `seq` increases by one +/// per event on that connection; a gap means events were lost and the +/// client must resync. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EventEnvelope { + pub seq: u64, + pub event: HostEvent, +} + +/// A task snapshot as sent over the wire. The timeline is paged separately +/// (see [`SessionRequest::Timeline`]) so one message never carries a whole +/// long transcript; `detail.timeline` is empty here. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionSnapshot { + pub detail: AgentSessionDetail, + /// Timeline items in the snapshot the pages read from. + pub timeline_len: usize, +} + +/// One page of a snapshot's timeline. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TimelinePage { + pub items: Vec, + /// More items follow this page; fetch again from `offset + items.len()`. + pub has_more: bool, +} + +/// A bootstrap as sent over the wire: like [`HostBootstrap`] but with the +/// newest task's timeline paged separately. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BootstrapSnapshot { + pub bootstrap: HostBootstrap, + /// Timeline length of `bootstrap.latest`, whose timeline is empty here. + pub latest_timeline_len: usize, +} + +/// Where the bytes of an attachment arrive: on a binary stream the host +/// opened just before answering. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AttachmentHandle { + pub stream: u16, + pub len: u64, +} + +// ---- Domain requests -------------------------------------------------------- + +macro_rules! params { + ($name:ident { $($field:ident : $ty:ty),* $(,)? }) => { + #[derive(Debug, Clone, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct $name { $(pub $field: $ty,)* } + }; +} + +params!(Empty {}); +params!(StartRuntime { request: Option }); +params!(SetSessionDefaults { + defaults: HostSessionDefaults +}); +params!(SaveDefaultModel { model: String }); +params!(ContextUsageParams { session_id: String, model: Option }); +params!(SessionId { session_id: String }); +params!(StoreToolSummary { + session_id: String, + item_id: String, + summary: String +}); + +/// `host.*`: the connection, the runtime, and host configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "method", content = "params")] +pub enum HostRequest { + #[serde(rename = "host.hello")] + Hello(ClientHello), + #[serde(rename = "host.ping")] + Ping(Empty), + #[serde(rename = "host.bootstrap")] + Bootstrap(Empty), + #[serde(rename = "host.start_runtime")] + StartRuntime(StartRuntime), + #[serde(rename = "host.stop_runtime")] + StopRuntime(Empty), + #[serde(rename = "host.session_defaults")] + SessionDefaults(Empty), + #[serde(rename = "host.set_session_defaults")] + SetSessionDefaults(SetSessionDefaults), + #[serde(rename = "host.save_default_model")] + SaveDefaultModel(SaveDefaultModel), + #[serde(rename = "host.usage_summary")] + UsageSummary(Empty), + #[serde(rename = "host.context_usage")] + ContextUsage(ContextUsageParams), + #[serde(rename = "host.tool_summaries")] + ToolSummaries(SessionId), + #[serde(rename = "host.store_tool_summary")] + StoreToolSummary(StoreToolSummary), +} + +params!(RootPath { path: String }); +params!(RemoveRoot { path: String, fallback: Option }); +params!(SuggestDirectories { query: String }); +params!(SetTrust { + path: String, + trusted: bool +}); + +/// `project.*`: roots on the host's filesystem. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "method", content = "params")] +pub enum ProjectRequest { + #[serde(rename = "project.recent_roots")] + RecentRoots(Empty), + #[serde(rename = "project.select_root")] + SelectRoot(RootPath), + #[serde(rename = "project.remove_root")] + RemoveRoot(RemoveRoot), + #[serde(rename = "project.suggest_directories")] + SuggestDirectories(SuggestDirectories), + #[serde(rename = "project.watch")] + Watch(RootPath), + #[serde(rename = "project.unwatch")] + Unwatch(RootPath), + #[serde(rename = "project.trust")] + Trust(RootPath), + #[serde(rename = "project.set_trust")] + SetTrust(SetTrust), +} + +params!(ListSessions { project_root: Option }); +params!(CreateSession { request: Option }); +params!(LoadSession { session_id: String }); +params!(TimelinePageParams { + session_id: String, + offset: usize, + limit: usize +}); +params!(RenameSession { + session_id: String, + title: String +}); +params!(SetArchived { + session_id: String, + archived: bool +}); +params!(CancelExternalAgent { + session_id: String, + agent_id: String +}); +params!(SetPermissionMode { + session_id: String, + mode: String +}); +params!(SetWebEnabled { + session_id: String, + enabled: bool +}); +params!(ReadAttachment { + session_id: String, + attachment_id: String +}); + +/// `session.*`: tasks and their snapshots. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "method", content = "params")] +pub enum SessionRequest { + #[serde(rename = "session.list")] + List(ListSessions), + #[serde(rename = "session.create")] + Create(CreateSession), + /// Returns a [`SessionSnapshot`]; page its timeline with `Timeline`. + #[serde(rename = "session.load")] + Load(LoadSession), + /// One page of the snapshot the last `Load` (or the bootstrap) built for + /// this task on this connection. Returns a [`TimelinePage`]. + #[serde(rename = "session.timeline")] + Timeline(TimelinePageParams), + #[serde(rename = "session.rename")] + Rename(RenameSession), + #[serde(rename = "session.set_archived")] + SetArchived(SetArchived), + #[serde(rename = "session.compact")] + Compact(SessionId), + #[serde(rename = "session.subagents")] + Subagents(SessionId), + #[serde(rename = "session.cancel_external_agent")] + CancelExternalAgent(CancelExternalAgent), + #[serde(rename = "session.set_permission_mode")] + SetPermissionMode(SetPermissionMode), + #[serde(rename = "session.set_web_enabled")] + SetWebEnabled(SetWebEnabled), + /// Returns an [`AttachmentHandle`]; the bytes arrive on its stream. + #[serde(rename = "session.read_attachment")] + ReadAttachment(ReadAttachment), +} + +params!(SendMessage { + request: AgentSendMessageRequest +}); +params!(RunId { run_id: String }); +params!(QueueControl { + session_id: String, + queue_id: String +}); +params!(AnswerQuestion { + request_id: String, + answer: String +}); +params!(PermissionRespond { + session_id: String, + request_id: String, + allow: bool +}); +params!(AskSideQuestion { + session_id: String, + request_id: String, + prior: Vec, + question: String, +}); +params!(SummarizeToolCall { + session_id: String, + tool_name: String, + input: Option, + output_text: String, +}); +params!(SummarizeThinking { + session_id: String, + thinking_text: String +}); + +/// `run.*`: messages, runs, and the prompts they raise. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "method", content = "params")] +pub enum RunRequest { + #[serde(rename = "run.send")] + Send(SendMessage), + #[serde(rename = "run.cancel")] + Cancel(RunId), + #[serde(rename = "run.cancel_queued")] + CancelQueued(QueueControl), + #[serde(rename = "run.begin_queued_edit")] + BeginQueuedEdit(QueueControl), + #[serde(rename = "run.end_queued_edit")] + EndQueuedEdit(QueueControl), + #[serde(rename = "run.answer_question")] + AnswerQuestion(AnswerQuestion), + #[serde(rename = "run.permission_respond")] + PermissionRespond(PermissionRespond), + #[serde(rename = "run.ask_side_question")] + AskSideQuestion(AskSideQuestion), + #[serde(rename = "run.summarize_tool_call")] + SummarizeToolCall(SummarizeToolCall), + #[serde(rename = "run.summarize_thinking")] + SummarizeThinking(SummarizeThinking), +} + +params!(ModelName { model: String }); +params!(WorkingDir { working_dir: Option }); +params!(ResolveSlashCommand { working_dir: Option, command: String, args: String }); + +/// `model.*`: the catalog and skills. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "method", content = "params")] +pub enum ModelRequest { + #[serde(rename = "model.list")] + List(Empty), + #[serde(rename = "model.supports_vision")] + SupportsVision(ModelName), + #[serde(rename = "model.slash_commands")] + SlashCommands(WorkingDir), + #[serde(rename = "model.resolve_slash_command")] + ResolveSlashCommand(ResolveSlashCommand), +} + +params!(SetSessionMcp { + session_id: String, + name: String, + kind: AgentSessionIntegrationKind, + enabled: bool, +}); +params!(SaveMcpServers { servers: Vec }); +params!(SetIntegrationEnabled { + id: String, + enabled: bool +}); +params!(IntegrationId { id: String }); + +/// `integration.*`: MCP servers and curated integrations. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "method", content = "params")] +pub enum IntegrationRequest { + #[serde(rename = "integration.list_session_mcp")] + ListSessionMcp(SessionId), + #[serde(rename = "integration.set_session_mcp")] + SetSessionMcp(SetSessionMcp), + #[serde(rename = "integration.list_mcp")] + ListMcp(Empty), + #[serde(rename = "integration.save_mcp")] + SaveMcp(SaveMcpServers), + #[serde(rename = "integration.list")] + List(Empty), + #[serde(rename = "integration.set_enabled")] + SetEnabled(SetIntegrationEnabled), + #[serde(rename = "integration.setup")] + Setup(IntegrationId), +} + +/// A request decoded into its domain. +#[derive(Debug, Clone)] +pub enum Request { + Host(HostRequest), + Project(ProjectRequest), + Session(SessionRequest), + Run(RunRequest), + Model(ModelRequest), + Integration(IntegrationRequest), +} + +/// Outcome of decoding a method name and its params. +#[derive(Debug)] +pub enum DecodeError { + /// No domain owns this method. + UnknownMethod(String), + /// The domain knows the method but the params do not fit. + InvalidParams(String), +} + +/// Decode a JSON-RPC request by its method's domain prefix. +pub fn decode_request(method: &str, params: Value) -> Result { + let (domain, _) = method + .split_once('.') + .ok_or_else(|| DecodeError::UnknownMethod(method.to_string()))?; + let tagged = serde_json::json!({ "method": method, "params": params }); + fn decode( + method: &str, + tagged: Value, + ) -> Result { + serde_json::from_value(tagged).map_err(|error| { + let text = error.to_string(); + if text.contains("unknown variant") { + DecodeError::UnknownMethod(method.to_string()) + } else { + DecodeError::InvalidParams(text) + } + }) + } + Ok(match domain { + "host" => Request::Host(decode(method, tagged)?), + "project" => Request::Project(decode(method, tagged)?), + "session" => Request::Session(decode(method, tagged)?), + "run" => Request::Run(decode(method, tagged)?), + "model" => Request::Model(decode(method, tagged)?), + "integration" => Request::Integration(decode(method, tagged)?), + _ => return Err(DecodeError::UnknownMethod(method.to_string())), + }) +} + +/// The method name and params of a typed request, for the client side. +pub fn encode_request(request: &T) -> Result<(String, Value), String> { + let value = serde_json::to_value(request).map_err(|error| error.to_string())?; + let method = value + .get("method") + .and_then(Value::as_str) + .ok_or_else(|| "request has no method".to_string())? + .to_string(); + let params = value.get("params").cloned().unwrap_or(Value::Null); + Ok((method, params)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn requests_decode_by_domain_and_refuse_unknown_methods() { + let (method, params) = encode_request(&SessionRequest::List(ListSessions { + project_root: Some("/p".to_string()), + })) + .unwrap(); + assert_eq!(method, "session.list"); + match decode_request(&method, params).unwrap() { + Request::Session(SessionRequest::List(list)) => { + assert_eq!(list.project_root.as_deref(), Some("/p")); + } + other => panic!("wrong decode: {other:?}"), + } + assert!(matches!( + decode_request("session.nope", Value::Null), + Err(DecodeError::UnknownMethod(_)) + )); + assert!(matches!( + decode_request("nodot", Value::Null), + Err(DecodeError::UnknownMethod(_)) + )); + assert!(matches!( + decode_request("run.cancel", serde_json::json!({"wrong": 1})), + Err(DecodeError::InvalidParams(_)) + )); + } + + #[test] + fn unknown_fields_are_ignored_and_optional_fields_default() { + let params = serde_json::json!({ + "protocol": 1, + "appVersion": "0.1.0", + "pcrEnvironment": "Production", + "device": {"publicKey": "k", "name": "laptop", "futureField": true}, + "somethingNew": {"nested": 1} + }); + let hello: ClientHello = serde_json::from_value(params).unwrap(); + assert!(hello.features.is_empty()); + assert_eq!(hello.device.user_id, None); + let json = serde_json::to_value(&hello).unwrap(); + assert!(json.get("somethingNew").is_none()); + } + + #[test] + fn features_table_has_this_builds_flags() { + assert_eq!(features().get("timelinePaging"), Some(&true)); + } +} diff --git a/apps/maple-agent/crates/maple-remote/tests/loopback.rs b/apps/maple-agent/crates/maple-remote/tests/loopback.rs new file mode 100644 index 000000000..e6b6dc7d6 --- /dev/null +++ b/apps/maple-agent/crates/maple-remote/tests/loopback.rs @@ -0,0 +1,797 @@ +//! A host server and a remote client over an in-process carrier, with a +//! scripted host behind the server. + +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; + +use async_trait::async_trait; +use maple_agent::agent::{ + AgentCreateSessionRequest, AgentDesktopQueueSnapshot, AgentIntegration, AgentMcpServer, + AgentProjectRootRegistration, AgentProjectTrustStatus, AgentRuntimeStatus, + AgentSendMessageRequest, AgentServiceEvent, AgentSessionDetail, AgentSessionIntegrationKind, + AgentSessionMcpServer, AgentSessionSummary, AgentSlashCommand, AgentStartRequest, + AgentSubagent, AgentTimelineItem, RecentProjectRoot, SideQuestionTurn, +}; +use maple_agent::host::{ + ContextUsage, DirectorySuggestion, HostBackend, HostBootstrap, HostEvent, HostEventHub, HostId, + HostSessionDefaults, UsageSummary, +}; +use maple_remote::carrier::{Carrier, in_process_pair}; +use maple_remote::client::{ClientConfig, RemoteHostBackend}; +use maple_remote::frame::Frame; +use maple_remote::rpc::{self, Message, Response}; +use maple_remote::server::{HostIdentity, HostServer, HostServerConfig}; +use maple_remote::wire::{ + ClientHello, DeviceInfo, EventEnvelope, HostHello, HostInfo, PROTOCOL_VERSION, +}; +use tokio::sync::mpsc; + +/// A host whose answers are fixed and whose calls are counted. +struct FakeHost { + id: HostId, + events: Arc, + timeline_len: usize, + attachment: Vec, + loads: AtomicUsize, +} + +fn summary(id: &str) -> AgentSessionSummary { + AgentSessionSummary { + id: id.to_string(), + title: format!("Task {id}"), + project_root: "/p".to_string(), + created_ms: 1, + updated_ms: 2, + message_count: 0, + model: None, + mode: "smart_approve".to_string(), + web_enabled: true, + archived: false, + acp: false, + } +} + +fn item(index: usize) -> AgentTimelineItem { + AgentTimelineItem { + id: format!("item-{index}"), + item_type: "message".to_string(), + role: Some("assistant".to_string()), + title: None, + text: Some("x".repeat(3000)), + status: None, + input: None, + output: None, + created_ms: index as u128, + merge: "none".to_string(), + } +} + +impl FakeHost { + fn new(timeline_len: usize) -> Arc { + Arc::new(Self { + id: HostId::new("fake"), + events: Arc::new(HostEventHub::default()), + timeline_len, + attachment: (0..(600 * 1024)).map(|i| (i % 251) as u8).collect(), + loads: AtomicUsize::new(0), + }) + } + + fn detail(&self, id: &str) -> AgentSessionDetail { + AgentSessionDetail { + session: summary(id), + timeline: (0..self.timeline_len).map(item).collect(), + mcp_errors: Vec::new(), + queue: AgentDesktopQueueSnapshot { + revision: 0, + items: Vec::new(), + }, + } + } +} + +fn unsupported() -> Result { + Err("unsupported in the fake host".to_string()) +} + +#[async_trait] +impl HostBackend for FakeHost { + fn id(&self) -> &HostId { + &self.id + } + fn subscribe(&self) -> mpsc::UnboundedReceiver { + self.events.subscribe() + } + async fn bootstrap(&self) -> Result { + Ok(HostBootstrap { + project_root: Some("/p".to_string()), + sessions: vec![summary("s1"), summary("s2")], + recent_roots: vec!["/p".to_string()], + latest: Some(self.detail("s1")), + session_defaults: HostSessionDefaults { + permission_mode: "auto".to_string(), + ..Default::default() + }, + }) + } + async fn start_runtime( + &self, + _request: Option, + ) -> Result { + Ok(AgentRuntimeStatus { + running: true, + project_root: Some("/p".to_string()), + model: None, + mode: None, + active_runs: HashMap::new(), + }) + } + async fn stop_runtime(&self) -> Result { + unsupported() + } + async fn recent_project_roots(&self) -> Result, String> { + unsupported() + } + async fn select_project_root( + &self, + path: String, + ) -> Result { + Err(format!("cannot select {path}")) + } + async fn remove_project_root(&self, _: String, _: Option) -> Result<(), String> { + unsupported() + } + async fn suggest_directories(&self, query: String) -> Result, String> { + Ok(vec![DirectorySuggestion { + path: format!("{query}dir"), + name: "dir".to_string(), + }]) + } + async fn watch_project_root(&self, path: String) -> Result<(), String> { + self.events.publish(HostEvent::ProjectBranch { + project_root: path, + branch: Some("main".to_string()), + }); + Ok(()) + } + async fn unwatch_project_root(&self, _: String) -> Result<(), String> { + Ok(()) + } + async fn project_trust(&self, _: String) -> Result { + unsupported() + } + async fn set_project_trust( + &self, + _: String, + _: bool, + ) -> Result { + unsupported() + } + async fn list_sessions(&self, _: Option) -> Result, String> { + Ok(vec![summary("s1"), summary("s2")]) + } + async fn create_session( + &self, + _: Option, + ) -> Result { + unsupported() + } + async fn load_session(&self, session_id: String) -> Result { + self.loads.fetch_add(1, Ordering::Relaxed); + Ok(self.detail(&session_id)) + } + async fn rename_session( + &self, + id: String, + title: String, + ) -> Result { + let mut renamed = summary(&id); + renamed.title = title; + Ok(renamed) + } + async fn set_session_archived( + &self, + _: String, + _: bool, + ) -> Result { + unsupported() + } + async fn compact_session(&self, _: String) -> Result<(), String> { + unsupported() + } + async fn session_subagents(&self, _: String) -> Result, String> { + Ok(Vec::new()) + } + async fn cancel_external_agent(&self, _: String, _: String) -> Result<(), String> { + unsupported() + } + async fn set_permission_mode(&self, _: String, _: String) -> Result<(), String> { + Ok(()) + } + async fn set_session_web_enabled( + &self, + _: String, + _: bool, + ) -> Result { + unsupported() + } + async fn context_usage( + &self, + _: String, + _: Option, + ) -> Result, String> { + Ok(Some(ContextUsage { + tokens: 10, + limit: 100, + })) + } + async fn read_image_attachment(&self, _: String, id: String) -> Result, String> { + if id == "missing" { + return Err("no such attachment".to_string()); + } + Ok(self.attachment.clone()) + } + async fn send_message(&self, request: AgentSendMessageRequest) -> Result { + Ok(format!("run-for-{}", request.session_id)) + } + async fn cancel_run(&self, _: String) -> Result<(), String> { + Ok(()) + } + async fn cancel_queued_message( + &self, + _: String, + _: String, + ) -> Result { + unsupported() + } + async fn begin_queued_message_edit(&self, _: String, _: String) -> Result<(), String> { + unsupported() + } + async fn end_queued_message_edit(&self, _: String, _: String) -> Result<(), String> { + unsupported() + } + async fn answer_question(&self, _: String, _: String) -> Result { + Ok(true) + } + async fn permission_respond(&self, _: String, _: String, _: bool) -> Result<(), String> { + Ok(()) + } + async fn ask_side_question( + &self, + _: String, + _: String, + _: Vec, + _: String, + ) -> Result<(), String> { + unsupported() + } + async fn summarize_tool_call( + &self, + _: String, + _: String, + _: Option, + _: String, + ) -> Result, String> { + Ok(None) + } + async fn summarize_thinking(&self, _: String, _: String) -> Result, String> { + Ok(None) + } + async fn tool_summaries(&self, _: String) -> Result, String> { + Ok(HashMap::from([( + "item-1".to_string(), + "did a thing".to_string(), + )])) + } + async fn store_tool_summary(&self, _: String, _: String, _: String) -> Result<(), String> { + Ok(()) + } + async fn available_model_ids(&self) -> Result, String> { + Ok(vec!["m1".to_string()]) + } + async fn model_supports_vision(&self, _: String) -> Result, String> { + Ok(Some(true)) + } + async fn list_slash_commands( + &self, + _: Option, + ) -> Result, String> { + Ok(Vec::new()) + } + async fn resolve_slash_command( + &self, + _: Option, + _: String, + _: String, + ) -> Result, String> { + Ok(None) + } + async fn list_session_mcp_servers( + &self, + _: String, + ) -> Result, String> { + Ok(Vec::new()) + } + async fn set_session_mcp_server_enabled( + &self, + _: String, + _: String, + _: AgentSessionIntegrationKind, + _: bool, + ) -> Result, String> { + unsupported() + } + async fn list_mcp_servers(&self) -> Result, String> { + Ok(Vec::new()) + } + async fn save_mcp_servers( + &self, + _: Vec, + ) -> Result, String> { + unsupported() + } + async fn list_integrations(&self) -> Result, String> { + Ok(Vec::new()) + } + async fn set_integration_enabled( + &self, + _: String, + _: bool, + ) -> Result, String> { + unsupported() + } + async fn setup_integration(&self, _: String) -> Result, String> { + unsupported() + } + async fn session_defaults(&self) -> Result { + Ok(HostSessionDefaults::default()) + } + async fn set_session_defaults(&self, _: HostSessionDefaults) -> Result<(), String> { + Ok(()) + } + async fn save_default_model(&self, _: String) -> Result<(), String> { + Ok(()) + } + async fn usage_summary(&self) -> Result { + Ok(UsageSummary::default()) + } +} + +fn identity() -> HostIdentity { + HostIdentity { + app_version: "0.1.0".to_string(), + pcr_environment: "Development".to_string(), + } +} + +fn info() -> HostInfo { + HostInfo { + id: "host-key".to_string(), + name: "workstation".to_string(), + user_id: None, + } +} + +fn hello() -> ClientHello { + ClientHello { + protocol: PROTOCOL_VERSION, + app_version: "0.1.0".to_string(), + pcr_environment: "Development".to_string(), + features: maple_remote::wire::features(), + device: DeviceInfo { + public_key: "device-key".to_string(), + name: "laptop".to_string(), + user_id: None, + }, + } +} + +fn client_config() -> ClientConfig { + ClientConfig { + connect_timeout: Duration::from_secs(5), + request_timeout: Duration::from_secs(5), + long_request_timeout: Duration::from_secs(5), + ping_interval: Duration::from_millis(50), + ping_timeout: Duration::from_millis(200), + ping_misses: 2, + timeline_page_items: 50, + ..Default::default() + } +} + +/// Start a server on a fresh pair and connect a client through it. +async fn connect( + host: Arc, + config: HostServerConfig, +) -> ( + Arc, + tokio::task::JoinHandle>, +) { + let (client_side, host_side) = in_process_pair(64); + let server = HostServer::new(host, info(), identity(), config); + let serving = tokio::spawn(server.serve(host_side)); + let client = RemoteHostBackend::connect(client_side, hello(), client_config()) + .await + .expect("connect"); + (client, serving) +} + +#[tokio::test] +async fn handshake_refuses_a_different_environment_and_protocol() { + for (protocol, environment) in [(PROTOCOL_VERSION, "Production"), (99, "Development")] { + let (client_side, host_side) = in_process_pair(8); + let server = HostServer::new( + FakeHost::new(0), + info(), + identity(), + HostServerConfig::default(), + ); + let serving = tokio::spawn(server.serve(host_side)); + let mut hello = hello(); + hello.protocol = protocol; + hello.pcr_environment = environment.to_string(); + let error = RemoteHostBackend::connect(client_side, hello, client_config()) + .await + .err() + .expect("refused"); + assert!( + error.contains("environment") || error.contains("protocol"), + "{error}" + ); + tokio::time::timeout(Duration::from_secs(5), serving) + .await + .expect("server ends after a refusal") + .unwrap() + .unwrap(); + } +} + +#[tokio::test] +async fn snapshots_page_completely_and_calls_round_trip() { + let host = FakeHost::new(230); + let (client, serving) = connect(Arc::clone(&host), HostServerConfig::default()).await; + assert_eq!(client.id().as_str(), "host-key"); + assert_eq!(client.host_hello().host.name, "workstation"); + + let boot = client.bootstrap().await.unwrap(); + assert_eq!(boot.sessions.len(), 2); + assert_eq!(boot.session_defaults.permission_mode, "auto"); + let latest = boot.latest.unwrap(); + assert_eq!( + latest.timeline.len(), + 230, + "every page of the bootstrap snapshot arrives" + ); + assert_eq!(latest.timeline[229].id, "item-229"); + + let detail = client.load_session("s2".to_string()).await.unwrap(); + assert_eq!(detail.timeline.len(), 230); + assert_eq!(detail.session.id, "s2"); + // The bootstrap loaded s1 once and the load loaded s2 once; paging read + // the kept snapshots instead of loading again. + assert_eq!(host.loads.load(Ordering::Relaxed), 1); + + assert_eq!( + client + .send_message(AgentSendMessageRequest { + session_id: "s1".to_string(), + text: "hi".to_string(), + model: None, + context_limit: None, + mode: None, + vision_capable: false, + steer: false, + queue_id: None, + attachments: Vec::new(), + }) + .await + .unwrap(), + "run-for-s1" + ); + assert_eq!( + client + .rename_session("s1".to_string(), "Renamed".to_string()) + .await + .unwrap() + .title, + "Renamed" + ); + assert_eq!( + client.context_usage("s1".to_string(), None).await.unwrap(), + Some(ContextUsage { + tokens: 10, + limit: 100 + }) + ); + assert_eq!( + client + .tool_summaries("s1".to_string()) + .await + .unwrap() + .get("item-1") + .map(String::as_str), + Some("did a thing") + ); + assert_eq!( + client + .select_project_root("/x".to_string()) + .await + .unwrap_err(), + "cannot select /x", + "host errors keep their message" + ); + assert_eq!( + client.suggest_directories("/p/".to_string()).await.unwrap()[0].path, + "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/p/dir" + ); + + client.close().await; + tokio::time::timeout(Duration::from_secs(5), serving) + .await + .unwrap() + .unwrap() + .unwrap(); +} + +#[tokio::test] +async fn events_arrive_in_order_through_the_hub() { + let host = FakeHost::new(0); + let (client, _serving) = connect(Arc::clone(&host), HostServerConfig::default()).await; + let mut events = client.subscribe(); + host.events.publish(HostEvent::Service(Box::new( + AgentServiceEvent::SessionCreated(summary("s9")), + ))); + client.watch_project_root("/p".to_string()).await.unwrap(); + let first = tokio::time::timeout(Duration::from_secs(5), events.recv()) + .await + .unwrap() + .unwrap(); + assert!(matches!( + first, + HostEvent::Service(ref event) if matches!(**event, AgentServiceEvent::SessionCreated(ref s) if s.id == "s9") + )); + let second = tokio::time::timeout(Duration::from_secs(5), events.recv()) + .await + .unwrap() + .unwrap(); + assert!(matches!( + second, + HostEvent::ProjectBranch { ref branch, .. } if branch.as_deref() == Some("main") + )); +} + +#[tokio::test] +async fn attachments_stream_whole_and_a_missing_one_is_an_error() { + let host = FakeHost::new(0); + let (client, _serving) = connect(Arc::clone(&host), HostServerConfig::default()).await; + let bytes = client + .read_image_attachment("s1".to_string(), "a1".to_string()) + .await + .unwrap(); + assert_eq!(bytes, host.attachment); + let error = client + .read_image_attachment("s1".to_string(), "missing".to_string()) + .await + .unwrap_err(); + assert_eq!(error, "no such attachment"); +} + +/// A raw peer that answers the handshake by hand and then sends whatever +/// frames the test wants, so sequences can be forged. +async fn raw_host(mut carrier: Carrier, frames: Vec) { + let request = carrier.stream.recv().await.expect("hello"); + let Message::Request(request) = rpc::decode(&request.payload).unwrap() else { + panic!("first message must be the hello request"); + }; + assert_eq!(request.method, "host.hello"); + let answer = HostHello { + protocol: PROTOCOL_VERSION, + app_version: "0.1.0".to_string(), + pcr_environment: "Development".to_string(), + generation: "gen".to_string(), + seq: 0, + features: Default::default(), + host: info(), + }; + let response = Response::ok(request.id, serde_json::to_value(answer).unwrap()); + carrier + .sink + .send(Frame::control( + rpc::encode(&Message::Response(response)).unwrap(), + )) + .await + .unwrap(); + for frame in frames { + carrier.sink.send(frame).await.unwrap(); + } + // Keep the connection open until the test drops the client. + while carrier.stream.recv().await.is_some() {} +} + +fn event_frame(seq: u64) -> Frame { + let envelope = EventEnvelope { + seq, + event: HostEvent::ProjectBranch { + project_root: "/p".to_string(), + branch: Some(format!("b{seq}")), + }, + }; + Frame::control( + rpc::encode(&Message::Notification(rpc::Notification::new( + "event", + serde_json::to_value(envelope).unwrap(), + ))) + .unwrap(), + ) +} + +#[tokio::test] +async fn a_sequence_gap_publishes_a_resync_before_the_event() { + let (client_side, host_side) = in_process_pair(16); + let peer = tokio::spawn(raw_host( + host_side, + vec![event_frame(1), event_frame(2), event_frame(4)], + )); + let mut config = client_config(); + config.ping_interval = Duration::from_secs(3600); + let client = RemoteHostBackend::connect(client_side, hello(), config) + .await + .unwrap(); + let mut events = client.subscribe(); + let mut seen = Vec::new(); + for _ in 0..4 { + let event = tokio::time::timeout(Duration::from_secs(5), events.recv()) + .await + .unwrap() + .unwrap(); + seen.push(match event { + HostEvent::ProjectBranch { branch, .. } => branch.unwrap(), + HostEvent::Resync => "resync".to_string(), + other => panic!("unexpected {other:?}"), + }); + } + assert_eq!(seen, vec!["b1", "b2", "resync", "b4"]); + client.close().await; + let _ = tokio::time::timeout(Duration::from_secs(5), peer).await; +} + +#[tokio::test] +async fn a_client_that_stops_draining_is_closed_without_blocking_the_host() { + let host = FakeHost::new(0); + let (client_side, host_side) = in_process_pair(2); + let server = HostServer::new( + Arc::clone(&host) as Arc, + info(), + identity(), + HostServerConfig { + max_outbound_bytes: 8 * 1024, + lease: Duration::from_secs(3600), + lease_check: Duration::from_millis(20), + ..Default::default() + }, + ); + let serving = tokio::spawn(server.serve(host_side)); + // Hand-rolled client: completes the handshake, then never reads. + let Carrier { + mut sink, + mut stream, + } = client_side; + let hello_request = rpc::Request::new(1, "host.hello", serde_json::to_value(hello()).unwrap()); + sink.send(Frame::control( + rpc::encode(&Message::Request(hello_request)).unwrap(), + )) + .await + .unwrap(); + let answer = stream.recv().await.unwrap(); + assert!(matches!( + rpc::decode(&answer.payload).unwrap(), + Message::Response(_) + )); + // The host keeps emitting; the queue fills past the limit and the + // server closes this connection while the publisher never waits. + let publish = tokio::spawn(async move { + for seq in 0..2000u64 { + host.events.publish(HostEvent::ProjectBranch { + project_root: "/p".to_string(), + branch: Some(format!("{seq}{}", "x".repeat(64))), + }); + } + }); + tokio::time::timeout(Duration::from_secs(1), publish) + .await + .expect("publishing never blocks") + .unwrap(); + tokio::time::timeout(Duration::from_secs(5), serving) + .await + .expect("the server closes the stalled connection") + .unwrap() + .unwrap(); + drop(sink); +} + +#[tokio::test] +async fn a_quiet_peer_loses_its_lease_and_a_pinging_client_keeps_it() { + let host = FakeHost::new(0); + let config = HostServerConfig { + lease: Duration::from_millis(150), + lease_check: Duration::from_millis(20), + ..Default::default() + }; + // Pinging client: stays connected past several leases. + let (client, serving) = connect(Arc::clone(&host), config.clone()).await; + tokio::time::sleep(Duration::from_millis(500)).await; + assert!(!client.is_closed()); + assert!(!serving.is_finished()); + client.close().await; + let _ = tokio::time::timeout(Duration::from_secs(5), serving).await; + + // Quiet peer: handshake only, then silence. + let (client_side, host_side) = in_process_pair(8); + let server = HostServer::new( + Arc::clone(&host) as Arc, + info(), + identity(), + config, + ); + let serving = tokio::spawn(server.serve(host_side)); + let Carrier { + mut sink, + mut stream, + } = client_side; + let hello_request = rpc::Request::new(1, "host.hello", serde_json::to_value(hello()).unwrap()); + sink.send(Frame::control( + rpc::encode(&Message::Request(hello_request)).unwrap(), + )) + .await + .unwrap(); + stream.recv().await.unwrap(); + tokio::time::timeout(Duration::from_secs(5), serving) + .await + .expect("the lease expires") + .unwrap() + .unwrap(); +} + +#[tokio::test] +async fn requests_before_the_handshake_and_unknown_methods_are_refused() { + let host = FakeHost::new(0); + let (client_side, host_side) = in_process_pair(8); + let server = HostServer::new(host, info(), identity(), HostServerConfig::default()); + let _serving = tokio::spawn(server.serve(host_side)); + let Carrier { + mut sink, + mut stream, + } = client_side; + let early = rpc::Request::new(7, "session.list", serde_json::json!({})); + sink.send(Frame::control( + rpc::encode(&Message::Request(early)).unwrap(), + )) + .await + .unwrap(); + let Message::Response(response) = rpc::decode(&stream.recv().await.unwrap().payload).unwrap() + else { + panic!("expected a response"); + }; + assert_eq!(response.error.unwrap().code, rpc::code::NOT_READY); + + let hello_request = rpc::Request::new(1, "host.hello", serde_json::to_value(hello()).unwrap()); + sink.send(Frame::control( + rpc::encode(&Message::Request(hello_request)).unwrap(), + )) + .await + .unwrap(); + stream.recv().await.unwrap(); + let unknown = rpc::Request::new(8, "session.explode", serde_json::json!({})); + sink.send(Frame::control( + rpc::encode(&Message::Request(unknown)).unwrap(), + )) + .await + .unwrap(); + let Message::Response(response) = rpc::decode(&stream.recv().await.unwrap().payload).unwrap() + else { + panic!("expected a response"); + }; + assert_eq!(response.error.unwrap().code, rpc::code::METHOD_NOT_FOUND); +} diff --git a/apps/maple-agent/docs/remote-development.md b/apps/maple-agent/docs/remote-development.md index 8f6aa32be..11aee5a8b 100644 --- a/apps/maple-agent/docs/remote-development.md +++ b/apps/maple-agent/docs/remote-development.md @@ -1,9 +1,11 @@ # Remote development -Status: design and implementation plan, agreed 2026-09-19. The seam -(step 1 of the implementation order) is built; the protocol crate, `serve`, -pairing, and the client are not. When code and this document disagree, the -code wins; update this document in the same change. +Status: design and implementation plan, agreed 2026-09-19. Steps 1 and 2 +of the implementation order are built: the seam, and the protocol crate +with its server and remote client over an in-process carrier. The +WebSocket carrier, Noise, pairing, `serve`, and the client's connection +management are not. When code and this document disagree, the code wins; +update this document in the same change. A Maple host runs the agent runtime and serves it over the network. A Maple client is the desktop app, which drives one or more hosts. The first release @@ -209,19 +211,34 @@ a claim. ### Messages Control messages are JSON-RPC 2.0. Methods are namespaced by domain and -mirror `AgentRuntimeHandle`: +mirror `HostBackend`; the request enums in `crates/maple-remote/src/wire.rs` +are the source of truth: ``` -session.list, session.create, session.load, session.subscribe, -session.send, session.cancel, session.queue.*, session.permission.respond, -session.question.answer, session.compact, session.rename, session.archive, -project.recent_roots, project.suggest_directories, project.trust.*, -integration.list, integration.set_enabled, integration.setup, -host.status, host.settings.get, host.settings.set, host.context_usage, -host.tool_summaries.get, host.tool_summaries.set, -device.list, device.revoke +host.hello, host.ping, host.bootstrap, host.start_runtime, +host.stop_runtime, host.session_defaults, host.set_session_defaults, +host.save_default_model, host.usage_summary, host.context_usage, +host.tool_summaries, host.store_tool_summary +project.recent_roots, project.select_root, project.remove_root, +project.suggest_directories, project.watch, project.unwatch, +project.trust, project.set_trust +session.list, session.create, session.load, session.timeline, +session.rename, session.set_archived, session.compact, session.subagents, +session.cancel_external_agent, session.set_permission_mode, +session.set_web_enabled, session.read_attachment +run.send, run.cancel, run.cancel_queued, run.begin_queued_edit, +run.end_queued_edit, run.answer_question, run.permission_respond, +run.ask_side_question, run.summarize_tool_call, run.summarize_thinking +model.list, model.supports_vision, model.slash_commands, +model.resolve_slash_command +integration.list_session_mcp, integration.set_session_mcp, +integration.list_mcp, integration.save_mcp, integration.list, +integration.set_enabled, integration.setup ``` +Host events arrive as the `event` notification with a per-connection +sequence. Device methods arrive with pairing. + Each domain has its own request and response enums and its own controller on the host. There is no single message union. @@ -298,25 +315,22 @@ sequence. ### Session timelines -Each session has a host-assigned `(epoch, seq)`. A new run starts a new -epoch. Allocation is append-only. `session.subscribe` returns a bounded -snapshot: the newest page of timeline items with `seq_start`, `seq_end`, -`has_older`, `has_newer`, plus queue, pending permissions, and pending -questions. - -Live events carry `(epoch, seq)`. The client reducer is a four-way state -machine: `seq <= end_seq` drops as stale, `seq == end_seq + 1` accepts, a -different epoch or a jump is a gap. On a gap the client fetches -`session.timeline(after: end_seq)` and keeps fetching while `has_newer` is -true. Recovery is complete only at `has_newer: false`. - -A resume after a long absence is bounded. If the first page reports much -newer history, the client fetches one latest tail and marks `has_older`, so -skipped history stays reachable by scrolling, rather than replaying every -missed page. Page limits count projected timeline items, not raw events. - -Failed catch-up retries back off from 1 s to a 30 s cap and reset on -success, reconnect, or visibility change. +As built: every event on a connection carries one monotonic `seq`. The +client accepts `seq == expected`, and treats anything else as a gap. On a +gap it publishes `HostEvent::Resync` and the UI re-reads the task list and +reloads the task on screen. A reconnect is a new connection and always +resyncs. The host keeps no event log: `session.load` builds a snapshot, +keeps it for the connection, and `session.timeline` pages it by item count +and by bytes until `has_more` is false. The bootstrap's newest task is +paged the same way. Live events emitted while a snapshot loads are also in +the snapshot; applying them again is idempotent because timeline items are +keyed by id. + +Still to do here: per-session epochs so a client can tell a compaction or +history replacement from a gap, and a bounded first resume that fetches a +latest tail with `has_older` instead of the whole snapshot. Both are +additive: new fields on `SessionSnapshot` and `TimelinePage`, gated by a +feature flag. Permission prompts and questions unanswered while no client was connected block the run and appear in the next snapshot. From 6e2da81f3875783d06695f6f21959cc4fd5a34c2 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Sat, 19 Sep 2026 15:45:13 -0500 Subject: [PATCH 06/37] Add Noise transport, pairing, and maple-gpui serve Clients and hosts now meet over a plain WebSocket with Noise inside, so a relay in between later sees only ciphertext and a LAN needs no certificates. Pairing runs the XXpsk3 pattern with a one-time code as the pre-shared key: both sides learn and pin each other's static key, and the code is the only proof. Every later connection runs IK against the pinned host key, and the host accepts a device only if it is in its device list. The hello a client sends must name the key the handshake proved, so the protocol identity and the transport identity cannot diverge. The pairing pattern ends with a client message, so a client could not tell a wrong code from success until the host dropped it; the host now sends one empty transport message to confirm, and the client trusts nothing before decrypting it. Wrong codes count against the source address and lock it out after a few tries. `maple-gpui serve` runs this machine as a host: it reuses the saved sign-in, holds one server per data root behind a lock file, and stores its key, its paired devices, and the pending code under the local data root at mode 0600. `serve pair` publishes a code the running host reads without a restart; `serve devices` lists and revokes, and a revoked device's live connection ends at the listener's next check. The end-to-end test binds a real port: a device pairs with a published code, reconnects with the pinned key, a stranger and a wrong pin are refused, a mismatched hello is refused, revocation refuses the next connection, and attachments larger than one Noise message cross intact. Co-Authored-By: Claude Fable 5.1 --- apps/maple-agent/Cargo.lock | 34 ++ apps/maple-agent/README.md | 29 ++ apps/maple-agent/app/Cargo.toml | 8 +- apps/maple-agent/app/src/main.rs | 19 + apps/maple-agent/app/src/serve.rs | 269 +++++++++++ .../crates/maple-remote/Cargo.toml | 11 + .../crates/maple-remote/src/devices.rs | 195 ++++++++ .../crates/maple-remote/src/keys.rs | 125 +++++ .../crates/maple-remote/src/lib.rs | 11 +- .../crates/maple-remote/src/net.rs | 201 ++++++++ .../crates/maple-remote/src/noise.rs | 441 ++++++++++++++++++ .../crates/maple-remote/src/pairing.rs | 286 ++++++++++++ .../crates/maple-remote/src/server.rs | 53 ++- .../crates/maple-remote/tests/common/mod.rs | 398 ++++++++++++++++ .../crates/maple-remote/tests/loopback.rs | 402 +--------------- .../crates/maple-remote/tests/transport.rs | 269 +++++++++++ apps/maple-agent/docs/remote-development.md | 20 +- apps/maple-agent/justfile | 2 +- 18 files changed, 2359 insertions(+), 414 deletions(-) create mode 100644 apps/maple-agent/app/src/serve.rs create mode 100644 apps/maple-agent/crates/maple-remote/src/devices.rs create mode 100644 apps/maple-agent/crates/maple-remote/src/keys.rs create mode 100644 apps/maple-agent/crates/maple-remote/src/net.rs create mode 100644 apps/maple-agent/crates/maple-remote/src/noise.rs create mode 100644 apps/maple-agent/crates/maple-remote/src/pairing.rs create mode 100644 apps/maple-agent/crates/maple-remote/tests/common/mod.rs create mode 100644 apps/maple-agent/crates/maple-remote/tests/transport.rs diff --git a/apps/maple-agent/Cargo.lock b/apps/maple-agent/Cargo.lock index e8e0a9ce1..06231c865 100644 --- a/apps/maple-agent/Cargo.lock +++ b/apps/maple-agent/Cargo.lock @@ -1197,6 +1197,15 @@ dependencies = [ "wyz", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "blake3" version = "1.8.7" @@ -5859,6 +5868,7 @@ dependencies = [ "maple-agent", "maple-billing", "maple-proxy", + "maple-remote", "maple-sdk", "parking_lot", "percent-encoding", @@ -5871,6 +5881,7 @@ dependencies = [ "serde_json", "spellbook", "tokio", + "tokio-util", "tower-http 0.6.11", "unicode-segmentation", "uuid", @@ -5904,13 +5915,19 @@ name = "maple-remote" version = "0.1.0" dependencies = [ "async-trait", + "base64 0.22.1", "bytes", "futures-util", "log", "maple-agent", + "rand 0.8.7", "serde", "serde_json", + "sha2 0.10.9", + "snow", "tokio", + "tokio-tungstenite 0.29.0", + "tokio-util", "uuid", ] @@ -9436,6 +9453,23 @@ dependencies = [ "serde_core", ] +[[package]] +name = "snow" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "599b506ccc4aff8cf7844bc42cf783009a434c1e26c964432560fb6d6ad02d82" +dependencies = [ + "aes-gcm", + "blake2", + "chacha20poly1305 0.10.1", + "curve25519-dalek", + "getrandom 0.3.4", + "ring", + "rustc_version", + "sha2 0.10.9", + "subtle", +] + [[package]] name = "socket2" version = "0.6.5" diff --git a/apps/maple-agent/README.md b/apps/maple-agent/README.md index 8aecdc5ca..b1bc9fa2f 100644 --- a/apps/maple-agent/README.md +++ b/apps/maple-agent/README.md @@ -368,6 +368,7 @@ maple-gpui Open the desktop app. maple-gpui acp Serve the Agent Client Protocol on stdio. maple-gpui proxy [FLAGS] Serve an OpenAI-compatible HTTP endpoint. maple-gpui login Sign in with email and password from a terminal. +maple-gpui serve [FLAGS] Publish this machine's runtime to paired clients. maple-gpui --version Print the version. ``` @@ -402,6 +403,29 @@ Without `--cors`, the proxy rejects requests that carry browser-only headers (`Origin`, `Sec-Fetch-Site`) so a web page cannot spend a saved key through loopback. With `--cors`, a default key is refused for the same reason. +### `maple-gpui serve` + +``` +maple-gpui serve Listen for paired clients. +maple-gpui serve pair Publish a one-time pairing code. +maple-gpui serve devices list Paired devices. +maple-gpui serve devices revoke DEV Forget a device by key or name. + +--listen ADDR:PORT bind address (default 0.0.0.0:7130, env MAPLE_SERVE_LISTEN) +--name NAME host name clients show (default: hostname, env MAPLE_SERVE_NAME) +``` + +Runs this machine as a host for the desktop app on another machine, over +a LAN or a Tailscale network. It reuses the sign-in saved by `login` or +the desktop app and hosts its own runtime. A device is admitted by a +one-time code: run `serve pair` on the host, enter the code in the app +within five minutes, and both sides pin each other's key; later connections +need no code. Traffic is Noise-encrypted inside a plain WebSocket, so +pairing is the only gate and the listener binds every interface by default. +Repeated wrong codes lock the source address out. Revoking a device ends +its live connections within seconds. One `serve` per data root; a lock +file refuses a second. See [`docs/remote-development.md`](docs/remote-development.md). + ## Build features The default build has every mode. Cargo features turn modes off, so a @@ -413,6 +437,7 @@ window and its display libraries: | `desktop` | The gpui window. Without it the binary is headless. | | `acp` | `maple-gpui acp` and `maple_agent::acp`. | | `proxy` | `maple-gpui proxy`. | +| `serve` | `maple-gpui serve` and the `maple-remote` host side. | ```sh cargo build --release -p maple-gpui --no-default-features --features acp @@ -463,6 +488,10 @@ The roots follow the platform, the same way the Tauri app's | `/agent/accounts//tool_summaries.db` | Model-written one-line summaries of tool calls (SQLite, WAL). | | `/agent/accounts//attachments/` | Image attachments. | | `/agent/acp/accounts//config.json` | ACP configuration. | +| `/remote/host_key.json` | This machine's static Noise key as a host (mode 0600). | +| `/remote/devices.json` | Devices paired with this host. | +| `/remote/pending_pairing.json` | The pairing code `serve pair` published, until used or expired (mode 0600). | +| `/remote/serve.lock`, `serve.json` | The running host's lock and its listen address. | | `/logs/maple-gpui.log` | Log file. Panics are logged here too. | `` is the SHA-256 of the account's user id. Small JSON files are diff --git a/apps/maple-agent/app/Cargo.toml b/apps/maple-agent/app/Cargo.toml index 9243ca3b9..88f9e7109 100644 --- a/apps/maple-agent/app/Cargo.toml +++ b/apps/maple-agent/app/Cargo.toml @@ -11,7 +11,7 @@ name = "maple-gpui" path = "src/main.rs" [features] -default = ["desktop", "acp", "proxy"] +default = ["desktop", "acp", "proxy", "serve"] # The gpui window. Without it the binary is headless and needs no display # libraries; only `acp` and `proxy` modes remain. desktop = [ @@ -29,11 +29,15 @@ desktop = [ acp = ["maple-agent/acp"] # `maple-gpui proxy`: the OpenAI-compatible HTTP endpoint. proxy = ["dep:maple-proxy", "dep:axum", "dep:tower-http"] +# `maple-gpui serve`: publish this machine's agent runtime to paired clients. +serve = ["dep:maple-remote", "dep:tokio-util"] [dependencies] dirs = "6" maple-agent = { path = "../crates/maple-agent", default-features = false } maple-billing = { path = "../crates/maple-billing" } +maple-remote = { path = "../crates/maple-remote", optional = true } +tokio-util = { workspace = true, optional = true } # gpui is pinned to the Zed commit that gpui-libghostty (the embedded # terminal Ben is preparing) builds against, so the two agree on one gpui # once the terminal lands. crates.io 0.2.2 is older than this commit. @@ -44,7 +48,7 @@ gpui = { version = "0.2.2", git = "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/zed-industries/zed", rev = gpui-platform = { package = "gpui_platform", version = "0.1.0", git = "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/zed-industries/zed", rev = "cc053a4a6fa2fd0e8793201ed9099466af1be0b1", features = ["font-kit", "wayland", "x11"], optional = true } image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"], optional = true } base64 = "0.22" -tokio = { workspace = true } +tokio = { workspace = true, features = ["signal"] } async-trait = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/apps/maple-agent/app/src/main.rs b/apps/maple-agent/app/src/main.rs index b38bb0f05..5ed237db8 100644 --- a/apps/maple-agent/app/src/main.rs +++ b/apps/maple-agent/app/src/main.rs @@ -17,6 +17,7 @@ mod keymap; mod notify; #[cfg(feature = "desktop")] mod platform; +mod serve; mod settings; #[cfg(feature = "desktop")] mod shortcuts; @@ -71,6 +72,8 @@ enum Mode { Proxy(ProxyArgs), /// Sign in with email and password and save the session for `acp`. Login(LoginArgs), + /// Publish this machine's agent runtime to paired Maple clients. + Serve(serve::ServeArgs), } /// Settings for `maple-gpui login`. The password is always prompted for @@ -190,6 +193,22 @@ fn main() { std::process::exit(1); } } + Some(Mode::Serve(args)) => { + #[cfg(not(feature = "serve"))] + { + let _ = args; + disabled_mode("serve", "serve"); + } + #[cfg(feature = "serve")] + { + init_logging(LogOutput::FileAndStderr); + if let Err(error) = serve::run(args) { + log::error!("{error}"); + eprintln!("{error}"); + std::process::exit(1); + } + } + } None => { #[cfg(feature = "desktop")] desktop::run(); diff --git a/apps/maple-agent/app/src/serve.rs b/apps/maple-agent/app/src/serve.rs new file mode 100644 index 000000000..794343039 --- /dev/null +++ b/apps/maple-agent/app/src/serve.rs @@ -0,0 +1,269 @@ +//! `maple-gpui serve`: publish this machine's agent runtime to paired +//! clients over the LAN or a Tailscale network. +//! +//! The host signs in on its own (`maple-gpui login`) and holds its own +//! credentials; clients bring nothing but their device key. A one-time code +//! from `serve pair` admits a device; `serve devices` lists and revokes +//! them. One server per data root, enforced with a lock file. + +use clap::{Args, Subcommand}; + +/// Default port. Not 8080, which the proxy mode uses. +pub const DEFAULT_LISTEN: &str = "0.0.0.0:7130"; + +#[derive(Debug, Clone, PartialEq, Eq, Args)] +pub struct ServeArgs { + #[command(subcommand)] + pub command: Option, + /// Address to listen on. Pairing is the gate, so every interface is + /// the default; give one address (a Tailscale IP) to narrow it. + #[arg(long, env = "MAPLE_SERVE_LISTEN", default_value = DEFAULT_LISTEN)] + pub listen: String, + /// Name clients show for this host. Defaults to the machine's hostname. + #[arg(long, env = "MAPLE_SERVE_NAME")] + pub name: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +pub enum ServeCommand { + /// Publish a one-time pairing code for a new device. The running host + /// accepts it for five minutes. + Pair, + /// Devices paired with this host. + Devices { + #[command(subcommand)] + command: DevicesCommand, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +pub enum DevicesCommand { + List, + /// Forget a device by public key or by name. Its live connections end + /// within seconds. + Revoke { + device: String, + }, +} + +#[cfg(feature = "serve")] +pub use enabled::run; + +#[cfg(feature = "serve")] +mod enabled { + use std::path::PathBuf; + use std::sync::Arc; + + use maple_remote::devices::DeviceStore; + use maple_remote::keys::StaticKey; + use maple_remote::net::{HostStores, serve_listener}; + use maple_remote::pairing::{CODE_TTL, PairingCode, PairingLimiter, PendingPairingStore}; + use maple_remote::server::{HostIdentity, HostServer, HostServerConfig}; + use maple_remote::wire::HostInfo; + use tokio_util::sync::CancellationToken; + + use super::{DevicesCommand, ServeArgs, ServeCommand}; + use crate::backend::AgentBackend; + + /// Where the host keeps its key, its devices, and the pending code. + pub(crate) fn remote_dir() -> PathBuf { + crate::backend::local_data_root().join("remote") + } + + fn ensure_remote_dir() -> Result { + let dir = remote_dir(); + std::fs::create_dir_all(&dir) + .map_err(|error| format!("cannot create {}: {error}", dir.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)); + } + Ok(dir) + } + + /// What a running server records for `pair` to describe. + #[derive(serde::Serialize, serde::Deserialize)] + #[serde(rename_all = "camelCase")] + struct ServeState { + listen: String, + name: String, + host_id: String, + pid: u32, + } + + fn state_path(dir: &std::path::Path) -> PathBuf { + dir.join("serve.json") + } + + fn read_state(dir: &std::path::Path) -> Option { + let bytes = std::fs::read(state_path(dir)).ok()?; + serde_json::from_slice(&bytes).ok() + } + + pub fn run(args: ServeArgs) -> Result<(), String> { + match args.command.clone() { + None => run_server(args), + Some(ServeCommand::Pair) => publish_code(), + Some(ServeCommand::Devices { + command: DevicesCommand::List, + }) => list_devices(), + Some(ServeCommand::Devices { + command: DevicesCommand::Revoke { device }, + }) => revoke_device(&device), + } + } + + fn hostname() -> String { + if let Ok(name) = std::env::var("HOSTNAME") + && !name.trim().is_empty() + { + return name.trim().to_string(); + } + if let Ok(name) = std::fs::read_to_string("/etc/hostname") + && !name.trim().is_empty() + { + return name.trim().to_string(); + } + "maple-host".to_string() + } + + fn run_server(args: ServeArgs) -> Result<(), String> { + let dir = ensure_remote_dir()?; + // One server per data root: two would race the runtime's own + // account state and the pairing file. + let lock_path = dir.join("serve.lock"); + let lock = std::fs::File::create(&lock_path) + .map_err(|error| format!("cannot open {}: {error}", lock_path.display()))?; + lock.try_lock() + .map_err(|_| "another `maple-gpui serve` already holds this data root".to_string())?; + + let key = StaticKey::load_or_create(&dir.join("host_key.json"))?; + let backend = Arc::new(AgentBackend::new(crate::configured_api_url())?); + let user_id = backend.restore_now().ok_or_else(|| { + "No saved Maple sign-in on this machine. Run `maple-gpui login` first.".to_string() + })?; + crate::adopt_legacy_session_defaults(&backend, &user_id); + let host = backend.local_host(&user_id); + let name = args.name.clone().unwrap_or_else(hostname); + + let devices = Arc::new(DeviceStore::new(dir.join("devices.json"))); + let pending = Arc::new(PendingPairingStore::new(dir.join("pending_pairing.json"))); + let hook_devices = Arc::clone(&devices); + let config = HostServerConfig { + on_client_hello: Some(Arc::new(move |hello| { + if let Err(error) = hook_devices.touch( + &hello.device.public_key, + &hello.device.name, + hello.device.user_id.as_deref(), + ) { + log::warn!("cannot record the device: {error}"); + } + })), + ..Default::default() + }; + let identity = HostIdentity { + app_version: env!("CARGO_PKG_VERSION").to_string(), + pcr_environment: format!( + "{:?}", + maple_agent::open_secret_config::configured_pcr0_environment()? + ), + }; + let info = HostInfo { + id: key.public_id(), + name: name.clone(), + user_id: Some(user_id.clone()), + }; + let server = HostServer::new(host.clone(), info, identity, config); + let host_id = key.public_id(); + let listen = args.listen.clone(); + let state_dir = dir.clone(); + let result = backend.runtime_handle().block_on(async move { + host.apply_saved_harness().await?; + let listener = tokio::net::TcpListener::bind(&listen) + .await + .map_err(|error| format!("cannot listen on {listen}: {error}"))?; + let local = listener.local_addr().map_err(|error| error.to_string())?; + maple_agent::private_file::write_private_json( + &state_path(&state_dir), + &ServeState { + listen: local.to_string(), + name: name.clone(), + host_id: host_id.clone(), + pid: std::process::id(), + }, + ) + .map_err(|error| format!("cannot write the serve state: {error}"))?; + eprintln!("Serving as host \"{name}\" ({host_id}) on {local}."); + eprintln!("Pair a device with `maple-gpui serve pair`. Stop with Ctrl-C."); + let shutdown = CancellationToken::new(); + let on_signal = shutdown.clone(); + tokio::spawn(async move { + if tokio::signal::ctrl_c().await.is_ok() { + on_signal.cancel(); + } + }); + let stores = Arc::new(HostStores { + key, + devices, + pending_pairing: pending, + limiter: PairingLimiter::default(), + }); + serve_listener(listener, server, stores, shutdown).await + }); + let _ = std::fs::remove_file(state_path(&dir)); + drop(lock); + result + } + + fn publish_code() -> Result<(), String> { + let dir = ensure_remote_dir()?; + let pending = PendingPairingStore::new(dir.join("pending_pairing.json")); + let code = PairingCode::generate(); + pending.publish(&code)?; + // The code goes to stdout so it can be piped; guidance to stderr. + println!("{}", code.display()); + eprintln!( + "Pairing code published. It admits one device and expires in {} minutes.", + CODE_TTL.as_secs() / 60 + ); + match read_state(&dir) { + Some(state) => eprintln!( + "In the Maple app, add host \"{}\" at {} and enter the code.", + state.name, state.listen + ), + None => eprintln!( + "The host is not running here. Start `maple-gpui serve` before the code expires." + ), + } + Ok(()) + } + + fn list_devices() -> Result<(), String> { + let dir = ensure_remote_dir()?; + let devices = DeviceStore::new(dir.join("devices.json")).list()?; + if devices.is_empty() { + eprintln!("No paired devices. Publish a code with `maple-gpui serve pair`."); + return Ok(()); + } + for device in devices { + println!( + "{}\t{}\t{}", + device.public_key, + device.name, + device.user_id.as_deref().unwrap_or("-") + ); + } + Ok(()) + } + + fn revoke_device(device: &str) -> Result<(), String> { + let dir = ensure_remote_dir()?; + let removed = DeviceStore::new(dir.join("devices.json")).revoke(device)?; + eprintln!( + "Revoked {} ({}). A live connection from it ends within seconds.", + removed.name, removed.public_key + ); + Ok(()) + } +} diff --git a/apps/maple-agent/crates/maple-remote/Cargo.toml b/apps/maple-agent/crates/maple-remote/Cargo.toml index 48d3a45aa..5c2b64b20 100644 --- a/apps/maple-agent/crates/maple-remote/Cargo.toml +++ b/apps/maple-agent/crates/maple-remote/Cargo.toml @@ -16,6 +16,17 @@ futures-util = { workspace = true } log = { workspace = true } bytes = "1" uuid = { version = "1", features = ["v4"] } +tokio-util = { workspace = true } +# Noise: pairing runs XXpsk3 with the one-time code as the pre-shared key, +# later connections run IK with pinned statics. The default resolver builds +# on the dalek and chacha crates the Rust SDK already brings in. +snow = "0.10" +# Plain `ws://` on direct connections; Noise inside is the encryption. TLS +# features stay off; the relay adds `wss://` to the enclave later. +tokio-tungstenite = { version = "0.29", default-features = false, features = ["handshake"] } +sha2 = "0.10" +rand = "0.8" +base64 = "0.22" [dev-dependencies] tokio = { workspace = true, features = ["test-util"] } diff --git a/apps/maple-agent/crates/maple-remote/src/devices.rs b/apps/maple-agent/crates/maple-remote/src/devices.rs new file mode 100644 index 000000000..61c0f5560 --- /dev/null +++ b/apps/maple-agent/crates/maple-remote/src/devices.rs @@ -0,0 +1,195 @@ +//! The devices a host has paired with. +//! +//! One JSON file at mode 0600. A device is its static public key; the name +//! and account are what the device claimed in its last handshake, kept for +//! display. Revoking a device removes it here; the host's listener notices +//! and drops the device's live connections. + +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PairedDevice { + pub public_key: String, + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_id: Option, + pub paired_at_ms: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_seen_ms: Option, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +struct DeviceFile { + #[serde(default)] + devices: Vec, +} + +pub struct DeviceStore { + path: PathBuf, + lock: Mutex<()>, +} + +fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_millis() as u64) + .unwrap_or(0) +} + +impl DeviceStore { + pub fn new(path: impl Into) -> Self { + Self { + path: path.into(), + lock: Mutex::new(()), + } + } + + pub fn path(&self) -> &Path { + &self.path + } + + fn read(&self) -> Result { + match std::fs::read(&self.path) { + Ok(bytes) => serde_json::from_slice(&bytes) + .map_err(|error| format!("{} is not a device file: {error}", self.path.display())), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(DeviceFile::default()), + Err(error) => Err(format!("cannot read {}: {error}", self.path.display())), + } + } + + fn write(&self, file: &DeviceFile) -> Result<(), String> { + maple_agent::private_file::write_private_json(&self.path, file) + .map_err(|error| format!("cannot write {}: {error}", self.path.display())) + } + + pub fn list(&self) -> Result, String> { + let _guard = self + .lock + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + Ok(self.read()?.devices) + } + + pub fn is_paired(&self, public_key: &str) -> bool { + self.list() + .map(|devices| devices.iter().any(|device| device.public_key == public_key)) + .unwrap_or(false) + } + + /// Record a device that just paired. Pairing again with the same key + /// keeps the record and refreshes its name. + pub fn insert(&self, public_key: &str, name: &str) -> Result { + let _guard = self + .lock + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut file = self.read()?; + let now = now_ms(); + let device = match file + .devices + .iter_mut() + .find(|device| device.public_key == public_key) + { + Some(existing) => { + existing.name = name.to_string(); + existing.last_seen_ms = Some(now); + existing.clone() + } + None => { + let device = PairedDevice { + public_key: public_key.to_string(), + name: name.to_string(), + user_id: None, + paired_at_ms: now, + last_seen_ms: Some(now), + }; + file.devices.push(device.clone()); + device + } + }; + self.write(&file)?; + Ok(device) + } + + /// A paired device connected: keep what it claims about itself. + pub fn touch(&self, public_key: &str, name: &str, user_id: Option<&str>) -> Result<(), String> { + let _guard = self + .lock + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut file = self.read()?; + let Some(device) = file + .devices + .iter_mut() + .find(|device| device.public_key == public_key) + else { + return Ok(()); + }; + device.name = name.to_string(); + device.user_id = user_id.map(str::to_string); + device.last_seen_ms = Some(now_ms()); + self.write(&file) + } + + /// Remove a device by public key or by name. A name that matches + /// several devices is refused; use the key. + pub fn revoke(&self, key_or_name: &str) -> Result { + let _guard = self + .lock + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut file = self.read()?; + let matches: Vec = file + .devices + .iter() + .enumerate() + .filter(|(_, device)| device.public_key == key_or_name || device.name == key_or_name) + .map(|(index, _)| index) + .collect(); + match matches.as_slice() { + [] => Err(format!("no paired device matches {key_or_name:?}")), + [index] => { + let removed = file.devices.remove(*index); + self.write(&file)?; + Ok(removed) + } + _ => Err(format!( + "{key_or_name:?} names several devices; revoke by public key" + )), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn devices_insert_touch_and_revoke() { + let dir = std::env::temp_dir().join(format!("maple-devices-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + let store = DeviceStore::new(dir.join("devices.json")); + assert!(store.list().unwrap().is_empty()); + assert!(!store.is_paired("k1")); + store.insert("k1", "laptop").unwrap(); + store.insert("k2", "laptop").unwrap(); + assert!(store.is_paired("k1")); + store.touch("k1", "bens-laptop", Some("user-1")).unwrap(); + let listed = store.list().unwrap(); + assert_eq!(listed[0].name, "bens-laptop"); + assert_eq!(listed[0].user_id.as_deref(), Some("user-1")); + assert!( + store.revoke("laptop").is_ok(), + "only k2 is still named laptop" + ); + assert!(store.revoke("nobody").is_err()); + store.insert("k1", "laptop").unwrap(); + assert_eq!(store.revoke("k1").unwrap().public_key, "k1"); + assert!(store.list().unwrap().is_empty()); + let _ = std::fs::remove_dir_all(dir); + } +} diff --git a/apps/maple-agent/crates/maple-remote/src/keys.rs b/apps/maple-agent/crates/maple-remote/src/keys.rs new file mode 100644 index 000000000..f7d69c87a --- /dev/null +++ b/apps/maple-agent/crates/maple-remote/src/keys.rs @@ -0,0 +1,125 @@ +//! The static Noise key of a host or a device. +//! +//! One X25519 key pair per host and per device, generated on first use and +//! kept at mode 0600. The public key is the identity: a host is known to +//! its clients by it, and a device to its hosts. + +use std::path::Path; + +use base64::Engine as _; +use serde::{Deserialize, Serialize}; + +const ENGINE: base64::engine::GeneralPurpose = base64::engine::general_purpose::URL_SAFE_NO_PAD; + +#[derive(Clone)] +pub struct StaticKey { + private: [u8; 32], + public: [u8; 32], +} + +impl std::fmt::Debug for StaticKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("StaticKey") + .field("public", &self.public_id()) + .finish_non_exhaustive() + } +} + +#[derive(Serialize, Deserialize)] +struct StoredKey { + private: String, + public: String, +} + +impl StaticKey { + pub fn generate() -> Result { + let keypair = snow::Builder::new( + crate::noise::SESSION_PATTERN + .parse() + .map_err(|error| format!("noise pattern: {error}"))?, + ) + .generate_keypair() + .map_err(|error| format!("cannot generate a key: {error}"))?; + Ok(Self { + private: to_array(&keypair.private)?, + public: to_array(&keypair.public)?, + }) + } + + /// The key at `path`, generated and saved there when there is none. + pub fn load_or_create(path: &Path) -> Result { + match std::fs::read(path) { + Ok(bytes) => { + let stored: StoredKey = serde_json::from_slice(&bytes) + .map_err(|error| format!("{} is not a key file: {error}", path.display()))?; + Ok(Self { + private: decode_key(&stored.private)?, + public: decode_key(&stored.public)?, + }) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let key = Self::generate()?; + maple_agent::private_file::write_private_json( + path, + &StoredKey { + private: encode_key(&key.private), + public: encode_key(&key.public), + }, + ) + .map_err(|error| format!("cannot save {}: {error}", path.display()))?; + Ok(key) + } + Err(error) => Err(format!("cannot read {}: {error}", path.display())), + } + } + + pub fn private(&self) -> &[u8; 32] { + &self.private + } + + pub fn public(&self) -> &[u8; 32] { + &self.public + } + + /// The public key as the string identity used everywhere else. + pub fn public_id(&self) -> String { + encode_key(&self.public) + } +} + +pub fn encode_key(bytes: &[u8]) -> String { + ENGINE.encode(bytes) +} + +pub fn decode_key(text: &str) -> Result<[u8; 32], String> { + let bytes = ENGINE + .decode(text.trim()) + .map_err(|error| format!("not a key: {error}"))?; + to_array(&bytes) +} + +fn to_array(bytes: &[u8]) -> Result<[u8; 32], String> { + bytes + .try_into() + .map_err(|_| format!("a key is 32 bytes, not {}", bytes.len())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keys_persist_and_encode_round_trip() { + let dir = std::env::temp_dir().join(format!("maple-keys-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("key.json"); + let first = StaticKey::load_or_create(&path).unwrap(); + let again = StaticKey::load_or_create(&path).unwrap(); + assert_eq!(first.public(), again.public()); + assert_eq!(first.private(), again.private()); + assert_eq!(decode_key(&first.public_id()).unwrap(), *first.public()); + assert!(decode_key("short").is_err()); + assert_ne!(StaticKey::generate().unwrap().public(), first.public()); + let _ = std::fs::remove_dir_all(dir); + } +} diff --git a/apps/maple-agent/crates/maple-remote/src/lib.rs b/apps/maple-agent/crates/maple-remote/src/lib.rs index a5897caac..917cbaf84 100644 --- a/apps/maple-agent/crates/maple-remote/src/lib.rs +++ b/apps/maple-agent/crates/maple-remote/src/lib.rs @@ -3,8 +3,10 @@ //! Layers, bottom up: //! //! - [`carrier`]: a bidirectional stream of [`frame::Frame`]s. The -//! in-process pair here is for tests; a WebSocket carrier and the Noise -//! layer sit below this crate's view and deliver the same frames. +//! in-process pair is for tests; [`noise`] delivers the same frames over +//! a WebSocket with Noise inside, and [`net`] dials and listens. +//! - [`keys`], [`pairing`], [`devices`]: the static key of a host or a +//! device, the one-time pairing code, and the host's paired device list. //! - [`frame`]: `[channel][kind][payload]`. Channel 0 is control and carries //! JSON-RPC 2.0 ([`rpc`]). Other channels are binary streams with //! credit-based flow control ([`streams`]). Every frame a side sends goes @@ -26,8 +28,13 @@ pub mod carrier; pub mod client; +pub mod devices; pub mod frame; +pub mod keys; +pub mod net; +pub mod noise; pub mod outbound; +pub mod pairing; pub mod rpc; pub mod server; pub mod streams; diff --git a/apps/maple-agent/crates/maple-remote/src/net.rs b/apps/maple-agent/crates/maple-remote/src/net.rs new file mode 100644 index 000000000..51eb011b1 --- /dev/null +++ b/apps/maple-agent/crates/maple-remote/src/net.rs @@ -0,0 +1,201 @@ +//! Listening and dialing over plain WebSocket with Noise inside. +//! +//! [`serve_listener`] accepts TCP connections on behalf of one +//! [`HostServer`], runs the handshake, registers a newly paired device, and +//! hands each established carrier to the server. [`connect_direct`] dials +//! a host by address for a client. The relay is another connector later; +//! everything above the carrier is shared. + +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; + +use tokio::net::{TcpListener, TcpStream}; +use tokio_util::sync::CancellationToken; + +use crate::carrier::Carrier; +use crate::devices::DeviceStore; +use crate::keys::{StaticKey, decode_key, encode_key}; +use crate::noise::{self, HandshakeMode, Initiate, Respond}; +use crate::pairing::{PairingCode, PairingLimiter, PendingPairingStore}; +use crate::server::HostServer; + +/// The name a device carries until its first hello names it. +const UNNAMED_DEVICE: &str = "new device"; + +/// Time a peer gets to finish the WebSocket and Noise handshakes. +const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(15); + +/// What a listening host needs besides the server: its key and its +/// device and pairing records. +pub struct HostStores { + pub key: StaticKey, + pub devices: Arc, + pub pending_pairing: Arc, + pub limiter: PairingLimiter, +} + +/// Accept connections until `shutdown` fires. +pub async fn serve_listener( + listener: TcpListener, + server: Arc, + stores: Arc, + shutdown: CancellationToken, +) -> Result<(), String> { + log::info!( + "listening on {} as host {}", + listener + .local_addr() + .map(|addr| addr.to_string()) + .unwrap_or_default(), + stores.key.public_id() + ); + loop { + let (stream, peer) = tokio::select! { + accepted = listener.accept() => match accepted { + Ok(accepted) => accepted, + Err(error) => { + log::warn!("accept failed: {error}"); + tokio::time::sleep(Duration::from_millis(100)).await; + continue; + } + }, + _ = shutdown.cancelled() => return Ok(()), + }; + let server = Arc::clone(&server); + let stores = Arc::clone(&stores); + let shutdown = shutdown.clone(); + tokio::spawn(async move { + if let Err(error) = handle_connection(stream, peer, server, stores, shutdown).await { + log::info!("connection from {peer} ended: {error}"); + } + }); + } +} + +async fn handle_connection( + stream: TcpStream, + peer: SocketAddr, + server: Arc, + stores: Arc, + shutdown: CancellationToken, +) -> Result<(), String> { + let _ = stream.set_nodelay(true); + let established = tokio::time::timeout(HANDSHAKE_TIMEOUT, async { + let socket = tokio_tungstenite::accept_async(stream) + .await + .map_err(|error| format!("websocket accept: {error}"))?; + let pending = stores.pending_pairing.current(); + let pairing_psk = match &pending { + Some(pending) if stores.limiter.allows(peer.ip()) => Some(pending.code()?.psk()), + Some(_) => { + log::warn!("pairing attempts from {peer} are rate limited"); + None + } + None => None, + }; + let devices = Arc::clone(&stores.devices); + let is_paired = move |key: &[u8; 32]| devices.is_paired(&encode_key(key)); + noise::respond( + socket, + stores.key.private(), + Respond { + pairing_psk, + is_paired: &is_paired, + }, + ) + .await + }) + .await + .map_err(|_| "handshake timed out".to_string())?; + let established = match established { + Ok(established) => established, + Err(error) => { + // A failed pairing counts against the address whatever the + // reason; a wrong code and a probe look the same. + stores.limiter.record_failure(peer.ip()); + return Err(error); + } + }; + let device_key = encode_key(&established.remote_static); + if established.mode == HandshakeMode::Pair { + stores.pending_pairing.consume(); + stores.devices.insert(&device_key, UNNAMED_DEVICE)?; + log::info!("paired device {device_key} from {peer}"); + } + let connection = shutdown.child_token(); + // Revocation is an edit to the device file; a revoked device's live + // connection ends at the next check. + let revocation_watch = { + let devices = Arc::clone(&stores.devices); + let key = device_key.clone(); + let connection = connection.clone(); + tokio::spawn(async move { + loop { + tokio::time::sleep(Duration::from_secs(10)).await; + if !devices.is_paired(&key) { + log::info!("device {key} was revoked; disconnecting"); + connection.cancel(); + return; + } + } + }) + }; + let result = server + .serve_with_peer(established.carrier, Some(device_key), connection) + .await; + revocation_watch.abort(); + result +} + +/// What to dial for. +pub enum ConnectTarget { + /// First contact: pair with the code the host published. + Pair(PairingCode), + /// A host already paired, whose static key is pinned. + Host { host_key: String }, +} + +/// A carrier to the host at `address` plus the host's static key, which +/// the client pins after pairing and verifies afterwards. +pub struct Dialed { + pub carrier: Carrier, + pub host_key: String, +} + +/// Dial `address` (`host:port`) over plain WebSocket and run the Noise +/// handshake as `device`. +pub async fn connect_direct( + address: &str, + device: &StaticKey, + target: ConnectTarget, +) -> Result { + let url = format!("ws://{address}/"); + let (socket, _) = + tokio::time::timeout(HANDSHAKE_TIMEOUT, tokio_tungstenite::connect_async(&url)) + .await + .map_err(|_| format!("connecting to {address} timed out"))? + .map_err(|error| format!("cannot connect to {address}: {error}"))?; + let initiate = match &target { + ConnectTarget::Pair(code) => Initiate::Pair { psk: code.psk() }, + ConnectTarget::Host { host_key } => Initiate::Session { + host_static: decode_key(host_key)?, + }, + }; + let established = tokio::time::timeout( + HANDSHAKE_TIMEOUT, + noise::initiate(socket, device.private(), initiate), + ) + .await + .map_err(|_| "the host did not finish the handshake in time".to_string())??; + let host_key = encode_key(&established.remote_static); + if let ConnectTarget::Host { host_key: pinned } = &target + && pinned != &host_key + { + return Err("the host's key does not match the pinned key".to_string()); + } + Ok(Dialed { + carrier: established.carrier, + host_key, + }) +} diff --git a/apps/maple-agent/crates/maple-remote/src/noise.rs b/apps/maple-agent/crates/maple-remote/src/noise.rs new file mode 100644 index 000000000..c95880553 --- /dev/null +++ b/apps/maple-agent/crates/maple-remote/src/noise.rs @@ -0,0 +1,441 @@ +//! Noise inside a WebSocket: the encrypted carrier. +//! +//! Two handshakes. Pairing runs `XXpsk3` with the one-time code as the +//! pre-shared key: both sides send their static keys, and the code is what +//! authenticates the exchange. Every later connection runs `IK`: the client +//! knows the host's static key, sends its own encrypted in the first +//! message, and the host accepts it only if that key is paired. After +//! either handshake both sides hold the other's static key to pin. +//! +//! A relay in between sees only the handshake's ciphertext and the +//! transport messages. The first byte of the first message names the +//! handshake and is also the Noise prologue, so a relay cannot swap one +//! for the other. +//! +//! In the pairing pattern the client sends the last handshake message, so +//! it could not tell a wrong code from success until the host dropped it. +//! The host therefore sends one empty transport message once its side +//! completes; the client must decrypt it before it trusts the session. +//! +//! Noise transport messages hold at most 65535 bytes, so a frame is cut +//! into pieces; each piece carries one continuation byte before the +//! frame bytes. Every WebSocket binary message is exactly one Noise message. + +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use bytes::Bytes; +use futures_util::{SinkExt, StreamExt}; +use tokio::io::{AsyncRead, AsyncWrite}; +use tokio_tungstenite::WebSocketStream; +use tokio_tungstenite::tungstenite::Message; + +use crate::carrier::{Carrier, FrameSink, FrameStream}; +use crate::frame::Frame; + +/// Pairing: statics exchanged, authenticated by the pre-shared code. +pub const PAIRING_PATTERN: &str = "Noise_XXpsk3_25519_ChaChaPoly_BLAKE2s"; +/// Every later connection: the host's static is known and pinned. +pub const SESSION_PATTERN: &str = "Noise_IK_25519_ChaChaPoly_BLAKE2s"; + +const MODE_PAIR: u8 = 1; +const MODE_SESSION: u8 = 2; +const PROLOGUE_PAIR: &[u8] = b"maple-remote-v1/pair"; +const PROLOGUE_SESSION: &[u8] = b"maple-remote-v1/session"; + +/// Largest plaintext one Noise message carries: 65535 minus the 16-byte +/// tag, minus the continuation byte. +const PIECE_BYTES: usize = 65535 - 16 - 1; +const MORE: u8 = 1; +const LAST: u8 = 0; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HandshakeMode { + Pair, + Session, +} + +impl HandshakeMode { + fn byte(self) -> u8 { + match self { + Self::Pair => MODE_PAIR, + Self::Session => MODE_SESSION, + } + } + + fn from_byte(byte: u8) -> Option { + match byte { + MODE_PAIR => Some(Self::Pair), + MODE_SESSION => Some(Self::Session), + _ => None, + } + } + + fn pattern(self) -> &'static str { + match self { + Self::Pair => PAIRING_PATTERN, + Self::Session => SESSION_PATTERN, + } + } + + fn prologue(self) -> &'static [u8] { + match self { + Self::Pair => PROLOGUE_PAIR, + Self::Session => PROLOGUE_SESSION, + } + } +} + +/// What the initiator brings to a handshake. +pub enum Initiate { + /// Pair with the code the host published. + Pair { psk: [u8; 32] }, + /// Connect to a host whose static key is pinned. + Session { host_static: [u8; 32] }, +} + +/// What the responder needs to answer a handshake. +pub struct Respond<'a> { + /// The pre-shared key for a pairing attempt, when one is pending. + pub pairing_psk: Option<[u8; 32]>, + /// Whether a device's static key is paired, for a session handshake. + pub is_paired: &'a (dyn Fn(&[u8; 32]) -> bool + Send + Sync), +} + +/// The outcome of a handshake: the encrypted carrier and the peer's static +/// key. +pub struct Established { + pub carrier: Carrier, + pub remote_static: [u8; 32], + pub mode: HandshakeMode, +} + +fn builder<'a>( + mode: HandshakeMode, + local_private: &'a [u8; 32], +) -> Result, String> { + let params = mode + .pattern() + .parse() + .map_err(|error| format!("noise pattern: {error}"))?; + snow::Builder::new(params) + .prologue(mode.prologue()) + .map_err(|error| format!("noise prologue: {error}"))? + .local_private_key(local_private) + .map_err(|error| format!("noise local key: {error}")) +} + +fn remote_static(state: &snow::HandshakeState) -> Result<[u8; 32], String> { + state + .get_remote_static() + .and_then(|key| key.try_into().ok()) + .ok_or_else(|| "the peer sent no static key".to_string()) +} + +/// Run the client side of a handshake over `socket`. +pub async fn initiate( + mut socket: WebSocketStream, + local_private: &[u8; 32], + initiate: Initiate, +) -> Result +where + S: AsyncRead + AsyncWrite + Unpin + Send + 'static, +{ + let (mode, mut state) = match initiate { + Initiate::Pair { psk } => ( + HandshakeMode::Pair, + builder(HandshakeMode::Pair, local_private)? + .psk(3, &psk) + .map_err(|error| format!("noise psk: {error}"))? + .build_initiator() + .map_err(|error| format!("noise: {error}"))?, + ), + Initiate::Session { host_static } => ( + HandshakeMode::Session, + builder(HandshakeMode::Session, local_private)? + .remote_public_key(&host_static) + .map_err(|error| format!("noise remote key: {error}"))? + .build_initiator() + .map_err(|error| format!("noise: {error}"))?, + ), + }; + let mut buffer = vec![0u8; 1024]; + let mut first = true; + while !state.is_handshake_finished() { + if state.is_my_turn() { + let written = state + .write_message(&[], &mut buffer) + .map_err(|error| format!("noise handshake: {error}"))?; + let mut message = Vec::with_capacity(written + 1); + if first { + message.push(mode.byte()); + first = false; + } + message.extend_from_slice(&buffer[..written]); + socket + .send(Message::Binary(message.into())) + .await + .map_err(|error| format!("cannot send the handshake: {error}"))?; + } else { + let message = next_binary(&mut socket) + .await? + .ok_or_else(|| "the host closed during the handshake".to_string())?; + state.read_message(&message, &mut buffer).map_err(|_| { + "the host refused the handshake: wrong pairing code, or this device is not paired" + .to_string() + })?; + } + } + let remote = remote_static(&state)?; + let mut transport = state + .into_transport_mode() + .map_err(|error| format!("noise transport: {error}"))?; + if mode == HandshakeMode::Pair { + let confirmation = next_binary(&mut socket) + .await? + .ok_or_else(|| "the host refused the pairing code".to_string())?; + let mut out = vec![0u8; confirmation.len()]; + let read = transport + .read_message(&confirmation, &mut out) + .map_err(|_| "the host refused the pairing code".to_string())?; + if read != 0 { + return Err("unexpected data before the pairing completed".to_string()); + } + } + Ok(Established { + carrier: carrier(socket, transport), + remote_static: remote, + mode, + }) +} + +/// Run the host side of a handshake over `socket`. +pub async fn respond( + mut socket: WebSocketStream, + local_private: &[u8; 32], + respond: Respond<'_>, +) -> Result +where + S: AsyncRead + AsyncWrite + Unpin + Send + 'static, +{ + let first = next_binary(&mut socket) + .await? + .ok_or_else(|| "the client closed before the handshake".to_string())?; + let (&mode_byte, first_message) = first + .split_first() + .ok_or_else(|| "empty handshake message".to_string())?; + let mode = HandshakeMode::from_byte(mode_byte) + .ok_or_else(|| format!("unknown handshake mode {mode_byte}"))?; + let mut state = match mode { + HandshakeMode::Pair => { + let psk = respond + .pairing_psk + .ok_or_else(|| "no pairing code is pending".to_string())?; + builder(mode, local_private)? + .psk(3, &psk) + .map_err(|error| format!("noise psk: {error}"))? + .build_responder() + .map_err(|error| format!("noise: {error}"))? + } + HandshakeMode::Session => builder(mode, local_private)? + .build_responder() + .map_err(|error| format!("noise: {error}"))?, + }; + let mut buffer = vec![0u8; 1024]; + state + .read_message(first_message, &mut buffer) + .map_err(|error| format!("handshake refused: {error}"))?; + if mode == HandshakeMode::Session { + // IK carries the client's static in its first message; refuse an + // unpaired device before answering anything. + let key = remote_static(&state)?; + if !(respond.is_paired)(&key) { + return Err("this device is not paired with the host".to_string()); + } + } + while !state.is_handshake_finished() { + if state.is_my_turn() { + let written = state + .write_message(&[], &mut buffer) + .map_err(|error| format!("noise handshake: {error}"))?; + socket + .send(Message::Binary(buffer[..written].to_vec().into())) + .await + .map_err(|error| format!("cannot send the handshake: {error}"))?; + } else { + let message = next_binary(&mut socket) + .await? + .ok_or_else(|| "the client closed during the handshake".to_string())?; + state + .read_message(&message, &mut buffer) + .map_err(|error| format!("handshake refused: {error}"))?; + } + } + let remote = remote_static(&state)?; + let mut transport = state + .into_transport_mode() + .map_err(|error| format!("noise transport: {error}"))?; + if mode == HandshakeMode::Pair { + // Tell the client its code was right; see the module docs. + let mut out = vec![0u8; 16]; + let written = transport + .write_message(&[], &mut out) + .map_err(|error| format!("noise confirm: {error}"))?; + socket + .send(Message::Binary(out[..written].to_vec().into())) + .await + .map_err(|error| format!("cannot confirm the pairing: {error}"))?; + } + Ok(Established { + carrier: carrier(socket, transport), + remote_static: remote, + mode, + }) +} + +async fn next_binary(socket: &mut WebSocketStream) -> Result>, String> +where + S: AsyncRead + AsyncWrite + Unpin, +{ + loop { + match socket.next().await { + Some(Ok(Message::Binary(bytes))) => return Ok(Some(bytes.to_vec())), + Some(Ok(Message::Close(_))) | None => return Ok(None), + Some(Ok(_)) => continue, + Some(Err(error)) => return Err(format!("websocket: {error}")), + } + } +} + +type Transport = Arc>; + +fn carrier(socket: WebSocketStream, transport: snow::TransportState) -> Carrier +where + S: AsyncRead + AsyncWrite + Unpin + Send + 'static, +{ + let transport = Arc::new(Mutex::new(transport)); + let (sink, stream) = socket.split(); + Carrier { + sink: Box::new(NoiseSink { + sink: Some(sink), + transport: Arc::clone(&transport), + }), + stream: Box::new(NoiseStream { + stream, + transport, + partial: Vec::new(), + }), + } +} + +struct NoiseSink { + sink: Option, Message>>, + transport: Transport, +} + +#[async_trait] +impl FrameSink for NoiseSink +where + S: AsyncRead + AsyncWrite + Unpin + Send + 'static, +{ + async fn send(&mut self, frame: Frame) -> Result<(), String> { + let sink = self + .sink + .as_mut() + .ok_or_else(|| "carrier closed".to_string())?; + let encoded = frame.encode(); + let pieces: Vec<&[u8]> = if encoded.is_empty() { + vec![&[][..]] + } else { + encoded.chunks(PIECE_BYTES).collect() + }; + let count = pieces.len(); + for (index, piece) in pieces.into_iter().enumerate() { + let mut plaintext = Vec::with_capacity(piece.len() + 1); + plaintext.push(if index + 1 == count { LAST } else { MORE }); + plaintext.extend_from_slice(piece); + let ciphertext = { + let mut transport = self + .transport + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut out = vec![0u8; plaintext.len() + 16]; + let written = transport + .write_message(&plaintext, &mut out) + .map_err(|error| format!("noise encrypt: {error}"))?; + out.truncate(written); + out + }; + sink.send(Message::Binary(ciphertext.into())) + .await + .map_err(|error| format!("websocket send: {error}"))?; + } + Ok(()) + } + + async fn close(&mut self) { + if let Some(mut sink) = self.sink.take() { + let _ = sink.send(Message::Close(None)).await; + let _ = sink.close().await; + } + } +} + +struct NoiseStream { + stream: futures_util::stream::SplitStream>, + transport: Transport, + /// Pieces of the frame being reassembled. + partial: Vec, +} + +#[async_trait] +impl FrameStream for NoiseStream +where + S: AsyncRead + AsyncWrite + Unpin + Send + 'static, +{ + async fn recv(&mut self) -> Option { + loop { + let ciphertext = match self.stream.next().await { + Some(Ok(Message::Binary(bytes))) => bytes, + Some(Ok(Message::Close(_))) | None => return None, + Some(Ok(_)) => continue, + Some(Err(error)) => { + log::debug!("websocket receive: {error}"); + return None; + } + }; + let plaintext = { + let mut transport = self + .transport + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut out = vec![0u8; ciphertext.len()]; + match transport.read_message(&ciphertext, &mut out) { + Ok(read) => { + out.truncate(read); + out + } + Err(error) => { + log::warn!("noise decrypt failed; closing: {error}"); + return None; + } + } + }; + let Some((&flag, piece)) = plaintext.split_first() else { + continue; + }; + self.partial.extend_from_slice(piece); + if flag == MORE { + continue; + } + let bytes = Bytes::from(std::mem::take(&mut self.partial)); + match Frame::decode(bytes) { + Ok(frame) => return Some(frame), + Err(error) => { + log::warn!("bad frame; closing: {error}"); + return None; + } + } + } + } +} diff --git a/apps/maple-agent/crates/maple-remote/src/pairing.rs b/apps/maple-agent/crates/maple-remote/src/pairing.rs new file mode 100644 index 000000000..7abbd43fc --- /dev/null +++ b/apps/maple-agent/crates/maple-remote/src/pairing.rs @@ -0,0 +1,286 @@ +//! One-time pairing codes and the host's pending pairing record. +//! +//! A code is 80 bits of randomness shown as sixteen Crockford base32 +//! characters. It is the pre-shared key of one pairing handshake, valid for +//! five minutes and consumed by the first success. The host reads it from a +//! private file its `pair` command writes, so a running host needs no +//! restart to accept a new device. + +use std::collections::HashMap; +use std::net::IpAddr; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use rand::RngCore as _; +use serde::{Deserialize, Serialize}; +use sha2::{Digest as _, Sha256}; + +const ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ"; +/// Characters in a code: 16 × 5 bits = 80 bits. +pub const CODE_CHARS: usize = 16; +/// How long a published code stays valid. +pub const CODE_TTL: Duration = Duration::from_secs(5 * 60); +const PSK_DOMAIN: &[u8] = b"maple-pairing-v1"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PairingCode(String); + +impl PairingCode { + pub fn generate() -> Self { + let mut bytes = [0u8; 10]; + rand::rngs::OsRng.fill_bytes(&mut bytes); + let mut code = String::with_capacity(CODE_CHARS); + let mut acc: u32 = 0; + let mut bits = 0; + for byte in bytes { + acc = (acc << 8) | byte as u32; + bits += 8; + while bits >= 5 { + bits -= 5; + code.push(ALPHABET[((acc >> bits) & 31) as usize] as char); + } + } + Self(code) + } + + /// Accept what a person typed: any case, with or without dashes or + /// spaces, and the usual Crockford confusables. + pub fn parse(input: &str) -> Result { + let mut code = String::with_capacity(CODE_CHARS); + for ch in input.chars() { + let ch = match ch.to_ascii_uppercase() { + '-' | ' ' => continue, + 'O' => '0', + 'I' | 'L' => '1', + other => other, + }; + if !ALPHABET.contains(&(ch as u8)) { + return Err(format!("'{ch}' is not part of a pairing code")); + } + code.push(ch); + } + if code.len() != CODE_CHARS { + return Err(format!("a pairing code has {CODE_CHARS} characters")); + } + Ok(Self(code)) + } + + /// The code grouped for reading aloud. + pub fn display(&self) -> String { + self.0 + .as_bytes() + .chunks(4) + .map(|chunk| std::str::from_utf8(chunk).unwrap_or_default()) + .collect::>() + .join("-") + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + /// The pre-shared key for the pairing handshake. + pub fn psk(&self) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(PSK_DOMAIN); + hasher.update(self.0.as_bytes()); + hasher.finalize().into() + } +} + +/// The code a host currently accepts, as stored on disk. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PendingPairing { + pub code: String, + pub created_ms: u64, + pub expires_ms: u64, +} + +impl PendingPairing { + pub fn code(&self) -> Result { + PairingCode::parse(&self.code) + } + + pub fn is_valid_at(&self, now_ms: u64) -> bool { + now_ms < self.expires_ms + } +} + +fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_millis() as u64) + .unwrap_or(0) +} + +/// The private file that holds the pending code. +pub struct PendingPairingStore { + path: PathBuf, + lock: Mutex<()>, +} + +impl PendingPairingStore { + pub fn new(path: impl Into) -> Self { + Self { + path: path.into(), + lock: Mutex::new(()), + } + } + + pub fn path(&self) -> &Path { + &self.path + } + + /// Publish a fresh code, replacing any pending one. + pub fn publish(&self, code: &PairingCode) -> Result { + let _guard = self + .lock + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let now = now_ms(); + let pending = PendingPairing { + code: code.as_str().to_string(), + created_ms: now, + expires_ms: now + CODE_TTL.as_millis() as u64, + }; + maple_agent::private_file::write_private_json(&self.path, &pending) + .map_err(|error| format!("cannot write {}: {error}", self.path.display()))?; + Ok(pending) + } + + /// The pending code when one is valid. An expired record is removed. + pub fn current(&self) -> Option { + let _guard = self + .lock + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let bytes = std::fs::read(&self.path).ok()?; + let pending: PendingPairing = serde_json::from_slice(&bytes).ok()?; + if pending.is_valid_at(now_ms()) { + Some(pending) + } else { + let _ = std::fs::remove_file(&self.path); + None + } + } + + /// A pairing succeeded: the code is spent. + pub fn consume(&self) { + let _guard = self + .lock + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _ = std::fs::remove_file(&self.path); + } +} + +/// Pairing attempts per source address. A wrong code is one attempt; too +/// many in the window lock that address out until the window passes. +pub struct PairingLimiter { + attempts: Mutex>>, + max_attempts: usize, + window: Duration, +} + +impl Default for PairingLimiter { + fn default() -> Self { + Self::new(5, Duration::from_secs(10 * 60)) + } +} + +impl PairingLimiter { + pub fn new(max_attempts: usize, window: Duration) -> Self { + Self { + attempts: Mutex::new(HashMap::new()), + max_attempts, + window, + } + } + + /// Whether `ip` may try now. + pub fn allows(&self, ip: IpAddr) -> bool { + let mut attempts = self + .attempts + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let now = Instant::now(); + let recent = attempts.entry(ip).or_default(); + recent.retain(|at| now.duration_since(*at) < self.window); + recent.len() < self.max_attempts + } + + pub fn record_failure(&self, ip: IpAddr) { + self.attempts + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .entry(ip) + .or_default() + .push(Instant::now()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn codes_have_sixteen_characters_and_parse_leniently() { + let code = PairingCode::generate(); + assert_eq!(code.as_str().len(), CODE_CHARS); + assert!(code.as_str().bytes().all(|b| ALPHABET.contains(&b))); + let shown = code.display(); + assert_eq!(shown.len(), CODE_CHARS + 3); + assert_eq!(PairingCode::parse(&shown).unwrap(), code); + assert_eq!( + PairingCode::parse(&shown.to_lowercase().replace('-', " ")).unwrap(), + code + ); + assert_eq!( + PairingCode::parse("oOiIlL1100AAAAAA").unwrap().as_str(), + "0011111100AAAAAA" + ); + assert!(PairingCode::parse("TOO-SHORT").is_err()); + assert!( + PairingCode::parse("UUUUUUUUUUUUUUUU").is_err(), + "U is not in the alphabet" + ); + assert_ne!(code.psk(), PairingCode::generate().psk()); + assert_eq!(code.psk(), PairingCode::parse(&shown).unwrap().psk()); + } + + #[test] + fn pending_codes_expire_and_are_consumed() { + let dir = std::env::temp_dir().join(format!("maple-pairing-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + let store = PendingPairingStore::new(dir.join("pending.json")); + assert!(store.current().is_none()); + let code = PairingCode::generate(); + let pending = store.publish(&code).unwrap(); + assert_eq!(store.current().unwrap().code, code.as_str()); + assert!(pending.is_valid_at(pending.created_ms)); + assert!(!pending.is_valid_at(pending.expires_ms)); + store.consume(); + assert!(store.current().is_none()); + let mut expired = store.publish(&code).unwrap(); + expired.expires_ms = 0; + maple_agent::private_file::write_private_json(store.path(), &expired).unwrap(); + assert!(store.current().is_none()); + assert!(!store.path().exists(), "an expired record is removed"); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn the_limiter_locks_an_address_out_after_repeated_failures() { + let limiter = PairingLimiter::new(2, Duration::from_secs(60)); + let ip: IpAddr = "10.0.0.1".parse().unwrap(); + let other: IpAddr = "10.0.0.2".parse().unwrap(); + assert!(limiter.allows(ip)); + limiter.record_failure(ip); + assert!(limiter.allows(ip)); + limiter.record_failure(ip); + assert!(!limiter.allows(ip)); + assert!(limiter.allows(other)); + } +} diff --git a/apps/maple-agent/crates/maple-remote/src/server.rs b/apps/maple-agent/crates/maple-remote/src/server.rs index 6c2fcc783..b745db34d 100644 --- a/apps/maple-agent/crates/maple-remote/src/server.rs +++ b/apps/maple-agent/crates/maple-remote/src/server.rs @@ -20,7 +20,8 @@ use maple_agent::agent::AgentSessionDetail; use maple_agent::host::{HostBackend, HostBootstrap}; use serde::Serialize; use serde_json::Value; -use tokio::sync::{Mutex, Notify, watch}; +use tokio::sync::{Mutex, Notify}; +use tokio_util::sync::CancellationToken; use crate::carrier::Carrier; use crate::frame::{CONTROL_CHANNEL, Frame, FrameKind}; @@ -40,7 +41,11 @@ pub struct HostIdentity { pub pcr_environment: String, } -#[derive(Debug, Clone)] +/// Called once a client's hello is accepted, with what the client claims +/// about itself. The listener uses it to name the paired device. +pub type ClientHelloHook = Arc; + +#[derive(Clone)] pub struct HostServerConfig { /// Bytes one connection may have queued before it is closed. pub max_outbound_bytes: usize, @@ -53,6 +58,7 @@ pub struct HostServerConfig { /// Approximate serialized bytes in one page; a page stops before /// the item that would cross it (at least one item always fits). pub timeline_page_bytes: usize, + pub on_client_hello: Option, } impl Default for HostServerConfig { @@ -63,6 +69,7 @@ impl Default for HostServerConfig { lease_check: Duration::from_secs(10), timeline_page_items: 200, timeline_page_bytes: 1024 * 1024, + on_client_hello: None, } } } @@ -101,14 +108,28 @@ impl HostServer { &self.host } - /// Serve one connection until it ends. Returns why it ended. + /// Serve one connection until it ends, with no identity check on the + /// hello. For carriers that authenticated nothing: tests and loopback. pub async fn serve(self: Arc, carrier: Carrier) -> Result<(), String> { + self.serve_with_peer(carrier, None, CancellationToken::new()) + .await + } + + /// Serve one connection whose transport authenticated the peer as + /// `peer` (the device's static public key). The hello must claim the + /// same key, so the identity in the protocol is the identity the + /// handshake proved. Cancelling `cancel` ends the connection. + pub async fn serve_with_peer( + self: Arc, + carrier: Carrier, + peer: Option, + cancel: CancellationToken, + ) -> Result<(), String> { let Carrier { mut sink, mut stream, } = carrier; let (out, mut queue) = outbound::channel(self.config.max_outbound_bytes); - let (closed_tx, mut closed_rx) = watch::channel(false); let connection = Arc::new(Connection { server: Arc::clone(&self), out: out.clone(), @@ -117,7 +138,8 @@ impl HostServer { ready: AtomicBool::new(false), ready_notify: Notify::new(), last_activity: std::sync::Mutex::new(Instant::now()), - closed: closed_tx, + peer, + closed: cancel, }); // Subscribe before the handshake so nothing is missed between the @@ -177,7 +199,7 @@ impl HostServer { let reason = loop { let frame = tokio::select! { frame = stream.recv() => frame, - _ = closed_rx.changed() => None, + _ = connection.closed.cancelled() => None, }; let Some(frame) = frame else { break connection @@ -211,7 +233,9 @@ struct Connection { ready: AtomicBool, ready_notify: Notify, last_activity: std::sync::Mutex, - closed: watch::Sender, + /// The device key the transport proved, when it proved one. + peer: Option, + closed: CancellationToken, } impl Connection { @@ -230,14 +254,14 @@ impl Connection { } fn close(&self, reason: &str) { - if !*self.closed.borrow() { + if !self.closed.is_cancelled() { log::debug!("closing host connection: {reason}"); - self.closed.send_replace(true); + self.closed.cancel(); } } fn close_reason(&self) -> Option { - (*self.closed.borrow()).then(|| "closed".to_string()) + self.closed.is_cancelled().then(|| "closed".to_string()) } fn notify(&self, method: &str, params: &T) -> Result<(), String> { @@ -340,6 +364,12 @@ impl Connection { "the client is built for the {} environment and this host for {}", hello.pcr_environment, identity.pcr_environment )) + } else if self + .peer + .as_ref() + .is_some_and(|peer| peer != &hello.device.public_key) + { + Some("the hello names a different device than the one that connected".to_string()) } else { None }; @@ -370,6 +400,9 @@ impl Connection { self.respond(Response::ok(id, value)); self.ready.store(true, Ordering::Release); self.ready_notify.notify_one(); + if let Some(hook) = &self.server.config.on_client_hello { + hook(&hello); + } } Err(error) => self.respond(Response::err(id, RpcError::host(error.to_string()))), } diff --git a/apps/maple-agent/crates/maple-remote/tests/common/mod.rs b/apps/maple-agent/crates/maple-remote/tests/common/mod.rs new file mode 100644 index 000000000..9998e6c27 --- /dev/null +++ b/apps/maple-agent/crates/maple-remote/tests/common/mod.rs @@ -0,0 +1,398 @@ +//! Shared fixtures: a scripted host and handshake values. + +#![allow(dead_code)] + +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; + +use async_trait::async_trait; +use maple_agent::agent::{ + AgentCreateSessionRequest, AgentDesktopQueueSnapshot, AgentIntegration, AgentMcpServer, + AgentProjectRootRegistration, AgentProjectTrustStatus, AgentRuntimeStatus, + AgentSendMessageRequest, AgentSessionDetail, AgentSessionIntegrationKind, + AgentSessionMcpServer, AgentSessionSummary, AgentSlashCommand, AgentStartRequest, + AgentSubagent, AgentTimelineItem, RecentProjectRoot, SideQuestionTurn, +}; +use maple_agent::host::{ + ContextUsage, DirectorySuggestion, HostBackend, HostBootstrap, HostEvent, HostEventHub, HostId, + HostSessionDefaults, UsageSummary, +}; +use maple_remote::client::ClientConfig; +use maple_remote::server::HostIdentity; +use maple_remote::wire::{ClientHello, DeviceInfo, HostInfo, PROTOCOL_VERSION}; +use tokio::sync::mpsc; + +/// A host whose answers are fixed and whose calls are counted. +pub struct FakeHost { + pub id: HostId, + pub events: Arc, + pub timeline_len: usize, + pub attachment: Vec, + pub loads: AtomicUsize, +} + +pub fn summary(id: &str) -> AgentSessionSummary { + AgentSessionSummary { + id: id.to_string(), + title: format!("Task {id}"), + project_root: "/p".to_string(), + created_ms: 1, + updated_ms: 2, + message_count: 0, + model: None, + mode: "smart_approve".to_string(), + web_enabled: true, + archived: false, + acp: false, + } +} + +pub fn item(index: usize) -> AgentTimelineItem { + AgentTimelineItem { + id: format!("item-{index}"), + item_type: "message".to_string(), + role: Some("assistant".to_string()), + title: None, + text: Some("x".repeat(3000)), + status: None, + input: None, + output: None, + created_ms: index as u128, + merge: "none".to_string(), + } +} + +impl FakeHost { + pub fn new(timeline_len: usize) -> Arc { + Arc::new(Self { + id: HostId::new("fake"), + events: Arc::new(HostEventHub::default()), + timeline_len, + attachment: (0..(600 * 1024)).map(|i| (i % 251) as u8).collect(), + loads: AtomicUsize::new(0), + }) + } + + pub fn detail(&self, id: &str) -> AgentSessionDetail { + AgentSessionDetail { + session: summary(id), + timeline: (0..self.timeline_len).map(item).collect(), + mcp_errors: Vec::new(), + queue: AgentDesktopQueueSnapshot { + revision: 0, + items: Vec::new(), + }, + } + } +} + +fn unsupported() -> Result { + Err("unsupported in the fake host".to_string()) +} + +#[async_trait] +impl HostBackend for FakeHost { + fn id(&self) -> &HostId { + &self.id + } + fn subscribe(&self) -> mpsc::UnboundedReceiver { + self.events.subscribe() + } + async fn bootstrap(&self) -> Result { + Ok(HostBootstrap { + project_root: Some("/p".to_string()), + sessions: vec![summary("s1"), summary("s2")], + recent_roots: vec!["/p".to_string()], + latest: Some(self.detail("s1")), + session_defaults: HostSessionDefaults { + permission_mode: "auto".to_string(), + ..Default::default() + }, + }) + } + async fn start_runtime( + &self, + _request: Option, + ) -> Result { + Ok(AgentRuntimeStatus { + running: true, + project_root: Some("/p".to_string()), + model: None, + mode: None, + active_runs: HashMap::new(), + }) + } + async fn stop_runtime(&self) -> Result { + unsupported() + } + async fn recent_project_roots(&self) -> Result, String> { + unsupported() + } + async fn select_project_root( + &self, + path: String, + ) -> Result { + Err(format!("cannot select {path}")) + } + async fn remove_project_root(&self, _: String, _: Option) -> Result<(), String> { + unsupported() + } + async fn suggest_directories(&self, query: String) -> Result, String> { + Ok(vec![DirectorySuggestion { + path: format!("{query}dir"), + name: "dir".to_string(), + }]) + } + async fn watch_project_root(&self, path: String) -> Result<(), String> { + self.events.publish(HostEvent::ProjectBranch { + project_root: path, + branch: Some("main".to_string()), + }); + Ok(()) + } + async fn unwatch_project_root(&self, _: String) -> Result<(), String> { + Ok(()) + } + async fn project_trust(&self, _: String) -> Result { + unsupported() + } + async fn set_project_trust( + &self, + _: String, + _: bool, + ) -> Result { + unsupported() + } + async fn list_sessions(&self, _: Option) -> Result, String> { + Ok(vec![summary("s1"), summary("s2")]) + } + async fn create_session( + &self, + _: Option, + ) -> Result { + unsupported() + } + async fn load_session(&self, session_id: String) -> Result { + self.loads.fetch_add(1, Ordering::Relaxed); + Ok(self.detail(&session_id)) + } + async fn rename_session( + &self, + id: String, + title: String, + ) -> Result { + let mut renamed = summary(&id); + renamed.title = title; + Ok(renamed) + } + async fn set_session_archived( + &self, + _: String, + _: bool, + ) -> Result { + unsupported() + } + async fn compact_session(&self, _: String) -> Result<(), String> { + unsupported() + } + async fn session_subagents(&self, _: String) -> Result, String> { + Ok(Vec::new()) + } + async fn cancel_external_agent(&self, _: String, _: String) -> Result<(), String> { + unsupported() + } + async fn set_permission_mode(&self, _: String, _: String) -> Result<(), String> { + Ok(()) + } + async fn set_session_web_enabled( + &self, + _: String, + _: bool, + ) -> Result { + unsupported() + } + async fn context_usage( + &self, + _: String, + _: Option, + ) -> Result, String> { + Ok(Some(ContextUsage { + tokens: 10, + limit: 100, + })) + } + async fn read_image_attachment(&self, _: String, id: String) -> Result, String> { + if id == "missing" { + return Err("no such attachment".to_string()); + } + Ok(self.attachment.clone()) + } + async fn send_message(&self, request: AgentSendMessageRequest) -> Result { + Ok(format!("run-for-{}", request.session_id)) + } + async fn cancel_run(&self, _: String) -> Result<(), String> { + Ok(()) + } + async fn cancel_queued_message( + &self, + _: String, + _: String, + ) -> Result { + unsupported() + } + async fn begin_queued_message_edit(&self, _: String, _: String) -> Result<(), String> { + unsupported() + } + async fn end_queued_message_edit(&self, _: String, _: String) -> Result<(), String> { + unsupported() + } + async fn answer_question(&self, _: String, _: String) -> Result { + Ok(true) + } + async fn permission_respond(&self, _: String, _: String, _: bool) -> Result<(), String> { + Ok(()) + } + async fn ask_side_question( + &self, + _: String, + _: String, + _: Vec, + _: String, + ) -> Result<(), String> { + unsupported() + } + async fn summarize_tool_call( + &self, + _: String, + _: String, + _: Option, + _: String, + ) -> Result, String> { + Ok(None) + } + async fn summarize_thinking(&self, _: String, _: String) -> Result, String> { + Ok(None) + } + async fn tool_summaries(&self, _: String) -> Result, String> { + Ok(HashMap::from([( + "item-1".to_string(), + "did a thing".to_string(), + )])) + } + async fn store_tool_summary(&self, _: String, _: String, _: String) -> Result<(), String> { + Ok(()) + } + async fn available_model_ids(&self) -> Result, String> { + Ok(vec!["m1".to_string()]) + } + async fn model_supports_vision(&self, _: String) -> Result, String> { + Ok(Some(true)) + } + async fn list_slash_commands( + &self, + _: Option, + ) -> Result, String> { + Ok(Vec::new()) + } + async fn resolve_slash_command( + &self, + _: Option, + _: String, + _: String, + ) -> Result, String> { + Ok(None) + } + async fn list_session_mcp_servers( + &self, + _: String, + ) -> Result, String> { + Ok(Vec::new()) + } + async fn set_session_mcp_server_enabled( + &self, + _: String, + _: String, + _: AgentSessionIntegrationKind, + _: bool, + ) -> Result, String> { + unsupported() + } + async fn list_mcp_servers(&self) -> Result, String> { + Ok(Vec::new()) + } + async fn save_mcp_servers( + &self, + _: Vec, + ) -> Result, String> { + unsupported() + } + async fn list_integrations(&self) -> Result, String> { + Ok(Vec::new()) + } + async fn set_integration_enabled( + &self, + _: String, + _: bool, + ) -> Result, String> { + unsupported() + } + async fn setup_integration(&self, _: String) -> Result, String> { + unsupported() + } + async fn session_defaults(&self) -> Result { + Ok(HostSessionDefaults::default()) + } + async fn set_session_defaults(&self, _: HostSessionDefaults) -> Result<(), String> { + Ok(()) + } + async fn save_default_model(&self, _: String) -> Result<(), String> { + Ok(()) + } + async fn usage_summary(&self) -> Result { + Ok(UsageSummary::default()) + } +} + +pub fn identity() -> HostIdentity { + HostIdentity { + app_version: "0.1.0".to_string(), + pcr_environment: "Development".to_string(), + } +} + +pub fn info() -> HostInfo { + HostInfo { + id: "host-key".to_string(), + name: "workstation".to_string(), + user_id: None, + } +} + +pub fn hello() -> ClientHello { + ClientHello { + protocol: PROTOCOL_VERSION, + app_version: "0.1.0".to_string(), + pcr_environment: "Development".to_string(), + features: maple_remote::wire::features(), + device: DeviceInfo { + public_key: "device-key".to_string(), + name: "laptop".to_string(), + user_id: None, + }, + } +} + +pub fn client_config() -> ClientConfig { + ClientConfig { + connect_timeout: Duration::from_secs(5), + request_timeout: Duration::from_secs(5), + long_request_timeout: Duration::from_secs(5), + ping_interval: Duration::from_millis(50), + ping_timeout: Duration::from_millis(200), + ping_misses: 2, + timeline_page_items: 50, + ..Default::default() + } +} diff --git a/apps/maple-agent/crates/maple-remote/tests/loopback.rs b/apps/maple-agent/crates/maple-remote/tests/loopback.rs index e6b6dc7d6..b908141c9 100644 --- a/apps/maple-agent/crates/maple-remote/tests/loopback.rs +++ b/apps/maple-agent/crates/maple-remote/tests/loopback.rs @@ -1,405 +1,21 @@ //! A host server and a remote client over an in-process carrier, with a //! scripted host behind the server. -use std::collections::HashMap; +mod common; + use std::sync::Arc; -use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::atomic::Ordering; use std::time::Duration; -use async_trait::async_trait; -use maple_agent::agent::{ - AgentCreateSessionRequest, AgentDesktopQueueSnapshot, AgentIntegration, AgentMcpServer, - AgentProjectRootRegistration, AgentProjectTrustStatus, AgentRuntimeStatus, - AgentSendMessageRequest, AgentServiceEvent, AgentSessionDetail, AgentSessionIntegrationKind, - AgentSessionMcpServer, AgentSessionSummary, AgentSlashCommand, AgentStartRequest, - AgentSubagent, AgentTimelineItem, RecentProjectRoot, SideQuestionTurn, -}; -use maple_agent::host::{ - ContextUsage, DirectorySuggestion, HostBackend, HostBootstrap, HostEvent, HostEventHub, HostId, - HostSessionDefaults, UsageSummary, -}; +use common::{FakeHost, client_config, hello, identity, info, summary}; +use maple_agent::agent::{AgentSendMessageRequest, AgentServiceEvent}; +use maple_agent::host::{ContextUsage, HostBackend, HostEvent}; use maple_remote::carrier::{Carrier, in_process_pair}; -use maple_remote::client::{ClientConfig, RemoteHostBackend}; +use maple_remote::client::RemoteHostBackend; use maple_remote::frame::Frame; use maple_remote::rpc::{self, Message, Response}; -use maple_remote::server::{HostIdentity, HostServer, HostServerConfig}; -use maple_remote::wire::{ - ClientHello, DeviceInfo, EventEnvelope, HostHello, HostInfo, PROTOCOL_VERSION, -}; -use tokio::sync::mpsc; - -/// A host whose answers are fixed and whose calls are counted. -struct FakeHost { - id: HostId, - events: Arc, - timeline_len: usize, - attachment: Vec, - loads: AtomicUsize, -} - -fn summary(id: &str) -> AgentSessionSummary { - AgentSessionSummary { - id: id.to_string(), - title: format!("Task {id}"), - project_root: "/p".to_string(), - created_ms: 1, - updated_ms: 2, - message_count: 0, - model: None, - mode: "smart_approve".to_string(), - web_enabled: true, - archived: false, - acp: false, - } -} - -fn item(index: usize) -> AgentTimelineItem { - AgentTimelineItem { - id: format!("item-{index}"), - item_type: "message".to_string(), - role: Some("assistant".to_string()), - title: None, - text: Some("x".repeat(3000)), - status: None, - input: None, - output: None, - created_ms: index as u128, - merge: "none".to_string(), - } -} - -impl FakeHost { - fn new(timeline_len: usize) -> Arc { - Arc::new(Self { - id: HostId::new("fake"), - events: Arc::new(HostEventHub::default()), - timeline_len, - attachment: (0..(600 * 1024)).map(|i| (i % 251) as u8).collect(), - loads: AtomicUsize::new(0), - }) - } - - fn detail(&self, id: &str) -> AgentSessionDetail { - AgentSessionDetail { - session: summary(id), - timeline: (0..self.timeline_len).map(item).collect(), - mcp_errors: Vec::new(), - queue: AgentDesktopQueueSnapshot { - revision: 0, - items: Vec::new(), - }, - } - } -} - -fn unsupported() -> Result { - Err("unsupported in the fake host".to_string()) -} - -#[async_trait] -impl HostBackend for FakeHost { - fn id(&self) -> &HostId { - &self.id - } - fn subscribe(&self) -> mpsc::UnboundedReceiver { - self.events.subscribe() - } - async fn bootstrap(&self) -> Result { - Ok(HostBootstrap { - project_root: Some("/p".to_string()), - sessions: vec![summary("s1"), summary("s2")], - recent_roots: vec!["/p".to_string()], - latest: Some(self.detail("s1")), - session_defaults: HostSessionDefaults { - permission_mode: "auto".to_string(), - ..Default::default() - }, - }) - } - async fn start_runtime( - &self, - _request: Option, - ) -> Result { - Ok(AgentRuntimeStatus { - running: true, - project_root: Some("/p".to_string()), - model: None, - mode: None, - active_runs: HashMap::new(), - }) - } - async fn stop_runtime(&self) -> Result { - unsupported() - } - async fn recent_project_roots(&self) -> Result, String> { - unsupported() - } - async fn select_project_root( - &self, - path: String, - ) -> Result { - Err(format!("cannot select {path}")) - } - async fn remove_project_root(&self, _: String, _: Option) -> Result<(), String> { - unsupported() - } - async fn suggest_directories(&self, query: String) -> Result, String> { - Ok(vec![DirectorySuggestion { - path: format!("{query}dir"), - name: "dir".to_string(), - }]) - } - async fn watch_project_root(&self, path: String) -> Result<(), String> { - self.events.publish(HostEvent::ProjectBranch { - project_root: path, - branch: Some("main".to_string()), - }); - Ok(()) - } - async fn unwatch_project_root(&self, _: String) -> Result<(), String> { - Ok(()) - } - async fn project_trust(&self, _: String) -> Result { - unsupported() - } - async fn set_project_trust( - &self, - _: String, - _: bool, - ) -> Result { - unsupported() - } - async fn list_sessions(&self, _: Option) -> Result, String> { - Ok(vec![summary("s1"), summary("s2")]) - } - async fn create_session( - &self, - _: Option, - ) -> Result { - unsupported() - } - async fn load_session(&self, session_id: String) -> Result { - self.loads.fetch_add(1, Ordering::Relaxed); - Ok(self.detail(&session_id)) - } - async fn rename_session( - &self, - id: String, - title: String, - ) -> Result { - let mut renamed = summary(&id); - renamed.title = title; - Ok(renamed) - } - async fn set_session_archived( - &self, - _: String, - _: bool, - ) -> Result { - unsupported() - } - async fn compact_session(&self, _: String) -> Result<(), String> { - unsupported() - } - async fn session_subagents(&self, _: String) -> Result, String> { - Ok(Vec::new()) - } - async fn cancel_external_agent(&self, _: String, _: String) -> Result<(), String> { - unsupported() - } - async fn set_permission_mode(&self, _: String, _: String) -> Result<(), String> { - Ok(()) - } - async fn set_session_web_enabled( - &self, - _: String, - _: bool, - ) -> Result { - unsupported() - } - async fn context_usage( - &self, - _: String, - _: Option, - ) -> Result, String> { - Ok(Some(ContextUsage { - tokens: 10, - limit: 100, - })) - } - async fn read_image_attachment(&self, _: String, id: String) -> Result, String> { - if id == "missing" { - return Err("no such attachment".to_string()); - } - Ok(self.attachment.clone()) - } - async fn send_message(&self, request: AgentSendMessageRequest) -> Result { - Ok(format!("run-for-{}", request.session_id)) - } - async fn cancel_run(&self, _: String) -> Result<(), String> { - Ok(()) - } - async fn cancel_queued_message( - &self, - _: String, - _: String, - ) -> Result { - unsupported() - } - async fn begin_queued_message_edit(&self, _: String, _: String) -> Result<(), String> { - unsupported() - } - async fn end_queued_message_edit(&self, _: String, _: String) -> Result<(), String> { - unsupported() - } - async fn answer_question(&self, _: String, _: String) -> Result { - Ok(true) - } - async fn permission_respond(&self, _: String, _: String, _: bool) -> Result<(), String> { - Ok(()) - } - async fn ask_side_question( - &self, - _: String, - _: String, - _: Vec, - _: String, - ) -> Result<(), String> { - unsupported() - } - async fn summarize_tool_call( - &self, - _: String, - _: String, - _: Option, - _: String, - ) -> Result, String> { - Ok(None) - } - async fn summarize_thinking(&self, _: String, _: String) -> Result, String> { - Ok(None) - } - async fn tool_summaries(&self, _: String) -> Result, String> { - Ok(HashMap::from([( - "item-1".to_string(), - "did a thing".to_string(), - )])) - } - async fn store_tool_summary(&self, _: String, _: String, _: String) -> Result<(), String> { - Ok(()) - } - async fn available_model_ids(&self) -> Result, String> { - Ok(vec!["m1".to_string()]) - } - async fn model_supports_vision(&self, _: String) -> Result, String> { - Ok(Some(true)) - } - async fn list_slash_commands( - &self, - _: Option, - ) -> Result, String> { - Ok(Vec::new()) - } - async fn resolve_slash_command( - &self, - _: Option, - _: String, - _: String, - ) -> Result, String> { - Ok(None) - } - async fn list_session_mcp_servers( - &self, - _: String, - ) -> Result, String> { - Ok(Vec::new()) - } - async fn set_session_mcp_server_enabled( - &self, - _: String, - _: String, - _: AgentSessionIntegrationKind, - _: bool, - ) -> Result, String> { - unsupported() - } - async fn list_mcp_servers(&self) -> Result, String> { - Ok(Vec::new()) - } - async fn save_mcp_servers( - &self, - _: Vec, - ) -> Result, String> { - unsupported() - } - async fn list_integrations(&self) -> Result, String> { - Ok(Vec::new()) - } - async fn set_integration_enabled( - &self, - _: String, - _: bool, - ) -> Result, String> { - unsupported() - } - async fn setup_integration(&self, _: String) -> Result, String> { - unsupported() - } - async fn session_defaults(&self) -> Result { - Ok(HostSessionDefaults::default()) - } - async fn set_session_defaults(&self, _: HostSessionDefaults) -> Result<(), String> { - Ok(()) - } - async fn save_default_model(&self, _: String) -> Result<(), String> { - Ok(()) - } - async fn usage_summary(&self) -> Result { - Ok(UsageSummary::default()) - } -} - -fn identity() -> HostIdentity { - HostIdentity { - app_version: "0.1.0".to_string(), - pcr_environment: "Development".to_string(), - } -} - -fn info() -> HostInfo { - HostInfo { - id: "host-key".to_string(), - name: "workstation".to_string(), - user_id: None, - } -} - -fn hello() -> ClientHello { - ClientHello { - protocol: PROTOCOL_VERSION, - app_version: "0.1.0".to_string(), - pcr_environment: "Development".to_string(), - features: maple_remote::wire::features(), - device: DeviceInfo { - public_key: "device-key".to_string(), - name: "laptop".to_string(), - user_id: None, - }, - } -} - -fn client_config() -> ClientConfig { - ClientConfig { - connect_timeout: Duration::from_secs(5), - request_timeout: Duration::from_secs(5), - long_request_timeout: Duration::from_secs(5), - ping_interval: Duration::from_millis(50), - ping_timeout: Duration::from_millis(200), - ping_misses: 2, - timeline_page_items: 50, - ..Default::default() - } -} +use maple_remote::server::{HostServer, HostServerConfig}; +use maple_remote::wire::{EventEnvelope, HostHello, PROTOCOL_VERSION}; /// Start a server on a fresh pair and connect a client through it. async fn connect( diff --git a/apps/maple-agent/crates/maple-remote/tests/transport.rs b/apps/maple-agent/crates/maple-remote/tests/transport.rs new file mode 100644 index 000000000..100e499f4 --- /dev/null +++ b/apps/maple-agent/crates/maple-remote/tests/transport.rs @@ -0,0 +1,269 @@ +//! A host listening on a real TCP port with Noise inside WebSocket: a +//! device pairs with a published code, reconnects with the pinned key, +//! and is refused once revoked. + +mod common; + +use std::sync::Arc; +use std::time::Duration; + +use common::{FakeHost, client_config, hello, identity, info}; +use maple_agent::host::HostBackend; +use maple_remote::client::RemoteHostBackend; +use maple_remote::devices::DeviceStore; +use maple_remote::keys::StaticKey; +use maple_remote::net::{ConnectTarget, HostStores, connect_direct, serve_listener}; +use maple_remote::pairing::{PairingCode, PairingLimiter, PendingPairingStore}; +use maple_remote::server::{HostServer, HostServerConfig}; +use maple_remote::wire::{ClientHello, HostInfo}; +use tokio_util::sync::CancellationToken; + +struct Host { + address: String, + key: StaticKey, + devices: Arc, + pending: Arc, + shutdown: CancellationToken, + _dir: TempDir, +} + +struct TempDir(std::path::PathBuf); + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +async fn start_host(fake: Arc) -> Host { + let dir = std::env::temp_dir().join(format!("maple-transport-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + let key = StaticKey::load_or_create(&dir.join("host_key.json")).unwrap(); + let devices = Arc::new(DeviceStore::new(dir.join("devices.json"))); + let pending = Arc::new(PendingPairingStore::new(dir.join("pending_pairing.json"))); + let hook_devices = Arc::clone(&devices); + let config = HostServerConfig { + on_client_hello: Some(Arc::new(move |hello: &ClientHello| { + hook_devices + .touch( + &hello.device.public_key, + &hello.device.name, + hello.device.user_id.as_deref(), + ) + .unwrap(); + })), + ..Default::default() + }; + let server = HostServer::new( + fake, + HostInfo { + id: key.public_id(), + ..info() + }, + identity(), + config, + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap().to_string(); + let shutdown = CancellationToken::new(); + let stores = Arc::new(HostStores { + key: key.clone(), + devices: Arc::clone(&devices), + pending_pairing: Arc::clone(&pending), + limiter: PairingLimiter::new(3, Duration::from_secs(60)), + }); + tokio::spawn(serve_listener(listener, server, stores, shutdown.clone())); + Host { + address, + key, + devices, + pending, + shutdown, + _dir: TempDir(dir), + } +} + +fn device_hello(device: &StaticKey) -> ClientHello { + let mut hello = hello(); + hello.device.public_key = device.public_id(); + hello.device.name = "bens-laptop".to_string(); + hello.device.user_id = Some("user-1".to_string()); + hello +} + +#[tokio::test] +async fn a_device_pairs_reconnects_and_is_refused_once_revoked() { + let fake = FakeHost::new(3); + let host = start_host(Arc::clone(&fake)).await; + let device = StaticKey::generate().unwrap(); + + // No code published: pairing is refused and counts as a failure. + let refused = connect_direct( + &host.address, + &device, + ConnectTarget::Pair(PairingCode::generate()), + ) + .await; + assert!(refused.is_err()); + + // A wrong code against a published one is refused too. + let code = PairingCode::generate(); + host.pending.publish(&code).unwrap(); + let wrong = connect_direct( + &host.address, + &device, + ConnectTarget::Pair(PairingCode::generate()), + ) + .await; + assert!(wrong.is_err()); + assert!( + host.pending.current().is_some(), + "a failed attempt leaves the code pending" + ); + + // The right code pairs, pins the host key, and is consumed. + let dialed = connect_direct(&host.address, &device, ConnectTarget::Pair(code)) + .await + .expect("pairing"); + assert_eq!(dialed.host_key, host.key.public_id()); + assert!(host.pending.current().is_none(), "the code is spent"); + assert!(host.devices.is_paired(&device.public_id())); + let client = RemoteHostBackend::connect(dialed.carrier, device_hello(&device), client_config()) + .await + .expect("hello after pairing"); + assert_eq!(client.host_hello().host.id, host.key.public_id()); + let sessions = client.list_sessions(None).await.unwrap(); + assert_eq!(sessions.len(), 2); + // The hello named the device. + tokio::time::sleep(Duration::from_millis(50)).await; + let listed = host.devices.list().unwrap(); + assert_eq!(listed[0].name, "bens-laptop"); + assert_eq!(listed[0].user_id.as_deref(), Some("user-1")); + client.close().await; + + // Reconnect with the pinned key; a wrong pin is refused. + let mut wrong_pin = StaticKey::generate().unwrap().public_id(); + wrong_pin.truncate(43); + assert!( + connect_direct( + &host.address, + &device, + ConnectTarget::Host { + host_key: wrong_pin + } + ) + .await + .is_err() + ); + let dialed = connect_direct( + &host.address, + &device, + ConnectTarget::Host { + host_key: host.key.public_id(), + }, + ) + .await + .expect("reconnect with the pinned key"); + let client = RemoteHostBackend::connect(dialed.carrier, device_hello(&device), client_config()) + .await + .unwrap(); + let detail = client.load_session("s1".to_string()).await.unwrap(); + assert_eq!(detail.timeline.len(), 3); + + // A hello that claims another device's key is refused. + let stranger = StaticKey::generate().unwrap(); + let dialed = connect_direct( + &host.address, + &device, + ConnectTarget::Host { + host_key: host.key.public_id(), + }, + ) + .await + .unwrap(); + let error = + RemoteHostBackend::connect(dialed.carrier, device_hello(&stranger), client_config()) + .await + .err() + .expect("mismatched device identity is refused"); + assert!(error.contains("different device"), "{error}"); + + // An unpaired device cannot open a session handshake at all. + assert!( + connect_direct( + &host.address, + &stranger, + ConnectTarget::Host { + host_key: host.key.public_id(), + }, + ) + .await + .is_err() + ); + + // Revocation refuses the next connection. + host.devices.revoke(&device.public_id()).unwrap(); + assert!( + connect_direct( + &host.address, + &device, + ConnectTarget::Host { + host_key: host.key.public_id(), + }, + ) + .await + .is_err() + ); + client.close().await; + host.shutdown.cancel(); +} + +#[tokio::test] +async fn repeated_wrong_codes_lock_the_address_out() { + let fake = FakeHost::new(0); + let host = start_host(fake).await; + let device = StaticKey::generate().unwrap(); + let code = PairingCode::generate(); + host.pending.publish(&code).unwrap(); + for _ in 0..3 { + let _ = connect_direct( + &host.address, + &device, + ConnectTarget::Pair(PairingCode::generate()), + ) + .await; + } + // The right code no longer works from this address within the window. + assert!( + connect_direct(&host.address, &device, ConnectTarget::Pair(code)) + .await + .is_err() + ); + host.shutdown.cancel(); +} + +#[tokio::test] +async fn large_frames_cross_the_noise_carrier_in_pieces() { + // Attachments are larger than one Noise message; the carrier must cut + // and reassemble them. + let fake = FakeHost::new(0); + let host = start_host(Arc::clone(&fake)).await; + let device = StaticKey::generate().unwrap(); + let code = PairingCode::generate(); + host.pending.publish(&code).unwrap(); + let dialed = connect_direct(&host.address, &device, ConnectTarget::Pair(code)) + .await + .unwrap(); + let client = RemoteHostBackend::connect(dialed.carrier, device_hello(&device), client_config()) + .await + .unwrap(); + let bytes = client + .read_image_attachment("s1".to_string(), "a1".to_string()) + .await + .unwrap(); + assert_eq!(bytes, fake.attachment); + let boot = client.bootstrap().await.unwrap(); + assert_eq!(boot.sessions.len(), 2); + client.close().await; + host.shutdown.cancel(); +} diff --git a/apps/maple-agent/docs/remote-development.md b/apps/maple-agent/docs/remote-development.md index 11aee5a8b..dcd6ad13f 100644 --- a/apps/maple-agent/docs/remote-development.md +++ b/apps/maple-agent/docs/remote-development.md @@ -1,11 +1,11 @@ # Remote development -Status: design and implementation plan, agreed 2026-09-19. Steps 1 and 2 -of the implementation order are built: the seam, and the protocol crate -with its server and remote client over an in-process carrier. The -WebSocket carrier, Noise, pairing, `serve`, and the client's connection -management are not. When code and this document disagree, the code wins; -update this document in the same change. +Status: design and implementation plan, agreed 2026-09-19. Steps 1 to 3 +of the implementation order are built: the seam, the protocol crate with +its server and remote client, and the transport with Noise, pairing, the +device store, and the `serve` command. The client's saved hosts, reconnect, +merged sidebar, and the desktop app serving are not. When code and this +document disagree, the code wins; update this document in the same change. A Maple host runs the agent runtime and serves it over the network. A Maple client is the desktop app, which drives one or more hosts. The first release @@ -394,6 +394,14 @@ whose static key does not match the pinned key is refused, and the client shows a host identity error rather than re-pairing silently. Noise gives counters and rekeying, so there is no replay window within a session. +As built: the first byte of the first handshake message names the pattern +and doubles as the Noise prologue. In the pairing pattern the client sends +the last handshake message, so the host sends one empty transport message +to confirm the code; the client trusts nothing until it decrypts it. The +hello's device key must equal the key the handshake proved. Revocation is +an edit to the device file; the listener checks it every ten seconds and +drops a revoked device's connection. + Implementation uses the `snow` crate with its default resolver, which builds on the `x25519-dalek`, `chacha20poly1305`, and `sha2` crates already in the lockfile through the Rust SDK. Do not hand-build the handshake. diff --git a/apps/maple-agent/justfile b/apps/maple-agent/justfile index 0ab60ac2f..1c86f53f3 100644 --- a/apps/maple-agent/justfile +++ b/apps/maple-agent/justfile @@ -10,7 +10,7 @@ export CARGO_TERM_COLOR := "always" # incompatible toolchains in separate caches. export CARGO_BUILD_BUILD_DIR := `if [ -n "${CARGO_BUILD_BUILD_DIR:-}" ]; then printf "%s" "$CARGO_BUILD_BUILD_DIR"; elif [ -n "${CI:-}" ] || [ "${MAPLE_GPUI_DISABLE_SHARED_CARGO_BUILD_DIR:-0}" = "1" ]; then printf target; elif command -v rustc >/dev/null 2>&1; then printf "%s/.cache/cargo-build/maple-gpui/%s/rust-%s" "$HOME" "$(rustc -vV | awk '/^host:/{print $2}')" "$(rustc --version | awk '{print $2}')"; else printf target; fi` -headless := "--no-default-features --features acp,proxy" +headless := "--no-default-features --features acp,proxy,serve" # List the recipes. default: From 66e51b7deac7ec0b294d850fd88aabd9eb437a31 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Sat, 19 Sep 2026 16:03:02 -0500 Subject: [PATCH 07/37] Connect the desktop app to saved hosts The desktop app is now a client of remote hosts. Each account keeps a hosts file: a host is its static public key, a name, and the addresses it can be reached at, so pairing again over another address merges into the same host rather than adding a second one, and a malformed entry is dropped without losing the file. A connection manager runs one connector per saved host on the backend runtime. It dials the host's addresses in order, hands the chat screen a connected backend, forwards the host's events, and reconnects with jittered exponential backoff when the connection ends. Pairing dials with the code, saves the host, and starts its connector on the connection the pairing opened. The chat screen keeps one entry per host and maps every task to its host. Its single host handle follows the selected task, so every existing call site drives the right host without knowing which one. Task lists are read per host and merged; a host's runtime status only speaks for that host's runs; an offline host's tasks leave the list until it returns while the task on screen stays readable. New tasks go to the sidebar's host filter when one is set, else to the selected task's host. The sidebar lists hosts in the project switcher, grays offline ones, badges each row with its host once more than one host is known, and persists pins and settles to the host that owns the task. Settings gains a Hosts section to pair, list, and remove hosts, and host-scoped sections get a host selector when more than one host is connected. Co-Authored-By: Claude Fable 5.1 --- apps/maple-agent/README.md | 14 + apps/maple-agent/app/Cargo.toml | 4 +- apps/maple-agent/app/src/desktop.rs | 42 +- apps/maple-agent/app/src/env.rs | 14 + apps/maple-agent/app/src/hosts.rs | 82 +++ apps/maple-agent/app/src/main.rs | 2 + apps/maple-agent/app/src/serve.rs | 16 +- apps/maple-agent/app/src/settings.rs | 1 + apps/maple-agent/app/src/ui/chat/mod.rs | 501 +++++++++++++++++- apps/maple-agent/app/src/ui/chat/sidebar.rs | 312 +++++++++-- apps/maple-agent/app/src/ui/settings.rs | 344 +++++++++++- .../app/src/ui/settings/navigation.rs | 12 +- .../crates/maple-remote/src/hosts.rs | 260 +++++++++ .../crates/maple-remote/src/lib.rs | 4 + .../crates/maple-remote/src/manager.rs | 397 ++++++++++++++ apps/maple-agent/docs/remote-development.md | 28 +- 16 files changed, 1936 insertions(+), 97 deletions(-) create mode 100644 apps/maple-agent/app/src/hosts.rs create mode 100644 apps/maple-agent/crates/maple-remote/src/hosts.rs create mode 100644 apps/maple-agent/crates/maple-remote/src/manager.rs diff --git a/apps/maple-agent/README.md b/apps/maple-agent/README.md index b1bc9fa2f..7f57a1832 100644 --- a/apps/maple-agent/README.md +++ b/apps/maple-agent/README.md @@ -132,6 +132,18 @@ Cargo manifests and lockfile; Research has an independent dependency graph. Nothing is downloaded or installed by the app. - Window size and maximized state persist between launches. +### Hosts + +Tasks can run on another machine. Settings > Hosts pairs this device with a +host running `maple-gpui serve` (address plus the one-time code the host +printed) and lists the paired hosts with their connection state. Saved +hosts connect at launch and reconnect with backoff. Their tasks join the +sidebar, badged with the host name once more than one host is known; the +project switcher filters by host, and new tasks go to the filtered host or +to the selected task's host. Host-scoped settings (defaults, system prompt, +integrations, usage) get a host selector when more than one host is +connected. Offline hosts stay listed without their tasks until they return. + ### Integrations preview On macOS and Linux, Settings > Integrations can set up computer use inside @@ -489,6 +501,8 @@ The roots follow the platform, the same way the Tauri app's | `/agent/accounts//attachments/` | Image attachments. | | `/agent/acp/accounts//config.json` | ACP configuration. | | `/remote/host_key.json` | This machine's static Noise key as a host (mode 0600). | +| `/remote/device_key.json` | This machine's static Noise key as a client device (mode 0600). | +| `/agent/accounts//hosts.json` | Hosts this account paired with: key, name, addresses. | | `/remote/devices.json` | Devices paired with this host. | | `/remote/pending_pairing.json` | The pairing code `serve pair` published, until used or expired (mode 0600). | | `/remote/serve.lock`, `serve.json` | The running host's lock and its listen address. | diff --git a/apps/maple-agent/app/Cargo.toml b/apps/maple-agent/app/Cargo.toml index 88f9e7109..c08844237 100644 --- a/apps/maple-agent/app/Cargo.toml +++ b/apps/maple-agent/app/Cargo.toml @@ -30,13 +30,13 @@ acp = ["maple-agent/acp"] # `maple-gpui proxy`: the OpenAI-compatible HTTP endpoint. proxy = ["dep:maple-proxy", "dep:axum", "dep:tower-http"] # `maple-gpui serve`: publish this machine's agent runtime to paired clients. -serve = ["dep:maple-remote", "dep:tokio-util"] +serve = ["dep:tokio-util"] [dependencies] dirs = "6" maple-agent = { path = "../crates/maple-agent", default-features = false } maple-billing = { path = "../crates/maple-billing" } -maple-remote = { path = "../crates/maple-remote", optional = true } +maple-remote = { path = "../crates/maple-remote" } tokio-util = { workspace = true, optional = true } # gpui is pinned to the Zed commit that gpui-libghostty (the embedded # terminal Ben is preparing) builds against, so the two agree on one gpui diff --git a/apps/maple-agent/app/src/desktop.rs b/apps/maple-agent/app/src/desktop.rs index 68db15271..d3847badd 100644 --- a/apps/maple-agent/app/src/desktop.rs +++ b/apps/maple-agent/app/src/desktop.rs @@ -34,6 +34,8 @@ struct MapleApp { backend: Arc, screen: Screen, user_id: Option, + /// Connections to the account's saved hosts; lives with the chat. + hosts: Option>, /// The chat screen is parked while settings is open so Back returns to /// it with its state intact. parked_chat: Option>, @@ -87,6 +89,9 @@ impl MapleApp { fn show_login(&mut self, cx: &mut Context) { self.user_id = None; self.parked_chat = None; + if let Some(hosts) = self.hosts.take() { + hosts.shutdown(); + } if matches!(self.screen, Screen::Login(_)) { return; } @@ -116,7 +121,37 @@ impl MapleApp { ); let backend = self.backend.clone(); let host = backend.local_host(&user_id); - let chat = cx.new(|cx| ChatScreen::new(backend, host, user_id.clone(), cx)); + let chat = cx.new(|cx| ChatScreen::new(backend.clone(), host, user_id.clone(), cx)); + // Remote hosts: connect to every saved one and pump what they + // report into the chat screen, batched like the local events. + if let Some(previous) = self.hosts.take() { + previous.shutdown(); + } + match crate::hosts::start_manager(&backend, &user_id) { + Ok((manager, mut events)) => { + self.hosts = Some(manager); + let chat = chat.downgrade(); + cx.spawn(async move |_app, cx| { + while let Some(event) = events.recv().await { + let mut batch = vec![event]; + while let Ok(next) = events.try_recv() { + batch.push(next); + if batch.len() >= 256 { + break; + } + } + if chat + .update(cx, |chat, cx| chat.handle_manager_events(batch, cx)) + .is_err() + { + break; + } + } + }) + .detach(); + } + Err(error) => log::warn!("remote hosts are unavailable: {error}"), + } // The release check may have finished while the login screen was // up; the banner must not be lost with it. if let Some(info) = crate::update::available() { @@ -137,12 +172,16 @@ impl MapleApp { let backend = self.backend.clone(); let user_id = self.user_id.clone().unwrap_or_default(); let host = backend.local_host(&user_id); + let hosts = chat.read(cx).connected_hosts(); + let manager = self.hosts.clone(); let settings = self.settings.clone(); let shortcut_snapshot = self.shortcuts.snapshot(); let screen = cx.new(|cx| { SettingsScreen::new( backend, host, + hosts, + manager, user_id, settings, shortcut_snapshot, @@ -476,6 +515,7 @@ pub fn run() { backend: root_backend, screen: Screen::Restoring, user_id: None, + hosts: None, parked_chat: None, settings: root_settings, shortcuts: shortcut_runtime, diff --git a/apps/maple-agent/app/src/env.rs b/apps/maple-agent/app/src/env.rs index aab16da24..4db2be629 100644 --- a/apps/maple-agent/app/src/env.rs +++ b/apps/maple-agent/app/src/env.rs @@ -14,6 +14,20 @@ pub fn env_string(name: &str) -> Option { .filter(|value| !value.is_empty()) } +/// This machine's name, for hosts and devices to show each other. +#[allow(dead_code)] +pub fn hostname() -> String { + if let Some(name) = env_string("HOSTNAME") { + return name; + } + if let Ok(name) = std::fs::read_to_string("/etc/hostname") + && !name.trim().is_empty() + { + return name.trim().to_string(); + } + "maple".to_string() +} + /// Whether `name` is set to `1`, `true`, or `yes` (case-insensitive). pub fn env_flag(name: &str) -> bool { env_string(name).is_some_and(|value| { diff --git a/apps/maple-agent/app/src/hosts.rs b/apps/maple-agent/app/src/hosts.rs new file mode 100644 index 000000000..ae7a65eb1 --- /dev/null +++ b/apps/maple-agent/app/src/hosts.rs @@ -0,0 +1,82 @@ +//! The desktop app as a client of remote hosts. +//! +//! Builds the connection manager for the signed-in account: this device's +//! static key, the saved hosts file under the account, and the hello every +//! connection sends. The manager runs on the backend's Tokio runtime and +//! reports through one channel that the desktop shell pumps into the chat +//! screen. + +use std::path::PathBuf; +use std::sync::Arc; + +use maple_remote::client::ClientConfig; +use maple_remote::hosts::HostsStore; +use maple_remote::keys::StaticKey; +use maple_remote::manager::{HostManager, HostManagerEvent}; +use maple_remote::wire::{ClientHello, DeviceInfo, PROTOCOL_VERSION, features}; +use tokio::sync::mpsc; + +use crate::backend::AgentBackend; + +/// Where this machine keeps its keys and host records for remote work. +fn remote_dir() -> Result { + let dir = crate::backend::local_data_root().join("remote"); + std::fs::create_dir_all(&dir) + .map_err(|error| format!("cannot create {}: {error}", dir.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)); + } + Ok(dir) +} + +/// This device's static key, generated on first use. +pub fn device_key() -> Result { + StaticKey::load_or_create(&remote_dir()?.join("device_key.json")) +} + +/// The saved hosts of one account. +fn hosts_store(user_id: &str) -> Result { + let scope = maple_agent::maple_api::account_scope(user_id)?; + Ok(HostsStore::new( + crate::backend::local_data_root() + .join("agent") + .join("accounts") + .join(scope) + .join("hosts.json"), + )) +} + +/// The hello this device sends to every host. +fn client_hello(device: &StaticKey, user_id: &str) -> Result { + Ok(ClientHello { + protocol: PROTOCOL_VERSION, + app_version: env!("CARGO_PKG_VERSION").to_string(), + pcr_environment: format!( + "{:?}", + maple_agent::open_secret_config::configured_pcr0_environment()? + ), + features: features(), + device: DeviceInfo { + public_key: device.public_id(), + name: crate::env::hostname(), + user_id: Some(user_id.to_string()), + }, + }) +} + +/// Start connecting to every saved host of `user_id`. Connectors run on +/// the backend runtime; events arrive on the returned channel. +pub fn start_manager( + backend: &Arc, + user_id: &str, +) -> Result<(Arc, mpsc::UnboundedReceiver), String> { + let device = device_key()?; + let hello = client_hello(&device, user_id)?; + let store = Arc::new(hosts_store(user_id)?); + let (manager, events) = HostManager::new(device, hello, store, ClientConfig::default()); + let _runtime = backend.runtime_handle().enter(); + manager.start(); + Ok((manager, events)) +} diff --git a/apps/maple-agent/app/src/main.rs b/apps/maple-agent/app/src/main.rs index 5ed237db8..bb2bf2243 100644 --- a/apps/maple-agent/app/src/main.rs +++ b/apps/maple-agent/app/src/main.rs @@ -12,6 +12,8 @@ mod billing; mod desktop; mod env; #[cfg(feature = "desktop")] +mod hosts; +#[cfg(feature = "desktop")] mod keymap; #[cfg(feature = "desktop")] mod notify; diff --git a/apps/maple-agent/app/src/serve.rs b/apps/maple-agent/app/src/serve.rs index 794343039..f9fc145da 100644 --- a/apps/maple-agent/app/src/serve.rs +++ b/apps/maple-agent/app/src/serve.rs @@ -114,20 +114,6 @@ mod enabled { } } - fn hostname() -> String { - if let Ok(name) = std::env::var("HOSTNAME") - && !name.trim().is_empty() - { - return name.trim().to_string(); - } - if let Ok(name) = std::fs::read_to_string("/etc/hostname") - && !name.trim().is_empty() - { - return name.trim().to_string(); - } - "maple-host".to_string() - } - fn run_server(args: ServeArgs) -> Result<(), String> { let dir = ensure_remote_dir()?; // One server per data root: two would race the runtime's own @@ -145,7 +131,7 @@ mod enabled { })?; crate::adopt_legacy_session_defaults(&backend, &user_id); let host = backend.local_host(&user_id); - let name = args.name.clone().unwrap_or_else(hostname); + let name = args.name.clone().unwrap_or_else(crate::env::hostname); let devices = Arc::new(DeviceStore::new(dir.join("devices.json"))); let pending = Arc::new(PendingPairingStore::new(dir.join("pending_pairing.json"))); diff --git a/apps/maple-agent/app/src/settings.rs b/apps/maple-agent/app/src/settings.rs index 972b777ea..1a217d487 100644 --- a/apps/maple-agent/app/src/settings.rs +++ b/apps/maple-agent/app/src/settings.rs @@ -280,6 +280,7 @@ pub use maple_agent::host::DEFAULT_HARNESS_INSTRUCTIONS; impl AppSettings { /// Client-side state for one host, empty when none is saved. + #[cfg(test)] pub fn host_state(&self, host: &maple_agent::host::HostId) -> HostUiState { self.hosts.get(host.as_str()).cloned().unwrap_or_default() } diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index 2ec35d61c..2dbb5becd 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -15,7 +15,9 @@ use maple_agent::agent::{ AgentSendMessageRequest, AgentServiceEvent, AgentSessionMcpServer, AgentSessionSummary, AgentSlashCommand, AgentSubagent, AgentTimelineItem, SideQuestionEvent, }; -use maple_agent::host::{HostBackend, HostEvent, HostSessionDefaults}; +use maple_agent::host::{HostBackend, HostEvent, HostId, HostSessionDefaults}; +use maple_remote::hosts::SavedHost; +use maple_remote::manager::{HostManagerEvent, HostStatus}; use crate::backend::{AgentBackend, PendingPermission, PendingQuestion}; use crate::ui::icons::{icon, spinner, wordmark}; @@ -84,6 +86,44 @@ pub struct PickQuestionOption { pub struct LoggedOut; +/// One host the chat screen shows tasks from: the local host always, and +/// each remote host as it connects or as a saved entry waiting to. +pub(crate) struct ChatHost { + /// Absent for a saved host that is not connected. + pub(crate) backend: Option>, + pub(crate) name: String, + pub(crate) online: bool, + /// What the host's bootstrap reported, adopted when it becomes the + /// target of new tasks. + project_root: Option, + recent_roots: Vec, + session_defaults: Option, +} + +impl ChatHost { + fn local(backend: Arc) -> Self { + Self { + backend: Some(backend), + name: "This computer".to_string(), + online: true, + project_root: None, + recent_roots: Vec::new(), + session_defaults: None, + } + } + + fn saved(name: String) -> Self { + Self { + backend: None, + name, + online: false, + project_root: None, + recent_roots: Vec::new(), + session_defaults: None, + } + } +} + /// Emitted when the user opens app settings from the chat header. pub struct OpenSettings; @@ -172,8 +212,19 @@ pub(crate) struct SpeechState { pub struct ChatScreen { /// Account-level calls: sign-out, billing, audio. backend: Arc, - /// The host whose tasks this screen shows. + /// The host of the selected task, and of new tasks: the target. host: Arc, + /// Every host this screen knows, keyed by host id. + hosts: HashMap, + /// Which host owns each task in `sessions`. + session_hosts: HashMap, + /// Host new tasks go to: the sidebar's host filter when set, else the + /// selected task's host, else the local host. + target_host: HostId, + /// The sidebar's host filter, so a task selection does not override it. + host_filter: Option, + /// Host whose events are being applied, for the length of one batch. + event_host: Option, user_id: String, /// The task list; its own entity so it renders only when it changes. sidebar: Entity, @@ -542,6 +593,7 @@ impl ChatScreen { self.notice = Some(message); cx.notify(); } + SidebarEvent::HostFilter(host) => self.set_host_filter(host, cx), } } @@ -554,11 +606,14 @@ impl ChatScreen { let running: HashSet = self.active_runs.keys().cloned().collect(); let unread = self.completed_unread_sessions.clone(); let roots = self.recent_roots.clone(); + let hosts = self.sidebar_hosts(); + let session_hosts = self.session_hosts.clone(); self.sidebar.update(cx, |sidebar, cx| { sidebar.set_sessions(sessions, cx); sidebar.set_selected(selected, cx); sidebar.set_activity(running, unread, cx); sidebar.set_recent_roots(roots, cx); + sidebar.set_hosts(hosts, session_hosts, cx); }); self.refresh_selected_title(); } @@ -857,9 +912,15 @@ impl ChatScreen { let weak = cx.entity().downgrade(); let sidebar = cx.new(|cx| Sidebar::new(backend.clone(), host.clone(), weak, &settings, cx)); cx.subscribe(&sidebar, Self::on_sidebar_event).detach(); + let hosts = HashMap::from([(HostId::local(), ChatHost::local(host.clone()))]); Self { backend, host, + hosts, + session_hosts: HashMap::new(), + target_host: HostId::local(), + host_filter: None, + event_host: None, user_id, sidebar, sessions: Vec::new(), @@ -1058,9 +1119,13 @@ impl ChatScreen { this.project_root = boot.project_root; this.project_root_changed(cx); this.check_project_trust(cx); - this.recent_roots = boot.recent_roots; - this.sessions = boot.sessions; - this.sync_sidebar(cx); + this.recent_roots = boot.recent_roots.clone(); + if let Some(local) = this.hosts.get_mut(&HostId::local()) { + local.project_root = this.project_root.clone(); + local.recent_roots = boot.recent_roots; + local.session_defaults = Some(boot.session_defaults.clone()); + } + this.apply_host_session_list(&HostId::local(), boot.sessions, cx); // A click that landed before this callback wins. if let Some(detail) = boot.latest.filter(|_| this.selected_session.is_none()) @@ -1461,24 +1526,377 @@ impl ChatScreen { ); } + /// Re-read every connected host's task list. The sidebar groups tasks + /// by project, so each host lists every root; a task's stored root + /// remains authoritative when it is opened or run. fn refresh_sessions(&self, cx: &mut Context) { - let host = self.host.clone(); - // The sidebar groups tasks by project, so list every root. Each task's - // stored root remains authoritative when it is opened or run. let generation = self.selection_generation; + for (id, entry) in &self.hosts { + let Some(backend) = entry.backend.clone().filter(|_| entry.online) else { + continue; + }; + let id = id.clone(); + self.call( + async move { backend.list_sessions(None).await }, + cx, + move |this, result, cx| { + match result { + Ok(sessions) if id.is_local() => { + this.apply_session_list(sessions, generation, cx) + } + Ok(sessions) => this.apply_host_session_list(&id, sessions, cx), + Err(message) => this.notice = Some(message.into()), + } + cx.notify(); + }, + ); + } + } + + /// Replace one host's tasks in the merged list. + fn apply_host_session_list( + &mut self, + host: &HostId, + sessions: Vec, + cx: &mut Context, + ) { + let session_hosts = &self.session_hosts; + self.sessions + .retain(|session| session_hosts.get(&session.id).unwrap_or(&HostId::local()) != host); + for session in &sessions { + self.session_hosts.insert(session.id.clone(), host.clone()); + } + self.sessions.extend(sessions); + self.sessions + .sort_by_key(|session| std::cmp::Reverse(session.updated_ms)); + self.sync_sidebar(cx); + } + + /// The hosts as the sidebar lists them: local first, then by name. + fn sidebar_hosts(&self) -> Vec { + let mut hosts: Vec = self + .hosts + .iter() + .map(|(id, entry)| sidebar::SidebarHost { + id: id.clone(), + name: SharedString::from(entry.name.clone()), + online: entry.online, + }) + .collect(); + hosts.sort_by(|a, b| { + b.id.is_local() + .cmp(&a.id.is_local()) + .then_with(|| a.name.as_ref().cmp(b.name.as_ref())) + }); + hosts + } + + fn host_name(&self, id: &HostId) -> String { + self.hosts + .get(id) + .map(|entry| entry.name.clone()) + .unwrap_or_else(|| id.to_string()) + } + + /// The host that owns `session_id`; the local host when unknown. + fn host_of(&self, session_id: &str) -> HostId { + self.session_hosts + .get(session_id) + .cloned() + .unwrap_or_else(HostId::local) + } + + /// Point `host` at the backend that owns `session_id`, so the load and + /// every call for the selected task go to the right host. A task whose + /// host is offline keeps the previous backend; its calls report the + /// host as gone. + fn use_host_of(&mut self, session_id: &str) { + let owner = self.host_of(session_id); + if let Some(backend) = self + .hosts + .get(&owner) + .and_then(|entry| entry.backend.clone()) + { + self.host = backend; + } + } + + /// Make `host` the target of new tasks. The selected task's host is + /// the target unless the sidebar filters on one. + fn set_target_host(&mut self, host: HostId, cx: &mut Context) { + if self.target_host == host { + return; + } + let Some(entry) = self.hosts.get(&host) else { + return; + }; + let Some(backend) = entry.backend.clone() else { + return; + }; + self.target_host = host; + self.host = backend; + self.recent_roots = entry.recent_roots.clone(); + self.refresh_roots(cx); + self.refresh_slash_commands(cx); + self.sync_sidebar(cx); + } + + /// Show the target host's saved project and defaults, for when the + /// target changed without a task selection. + fn adopt_target_host_context(&mut self, cx: &mut Context) { + let Some(entry) = self.hosts.get(&self.target_host) else { + return; + }; + let root = entry.project_root.clone(); + let defaults = entry.session_defaults.clone(); + self.set_project_context(root, cx); + if let Some(defaults) = defaults { + self.apply_session_defaults(&defaults, cx); + } + } + + /// The sidebar filters on `host` (or on none): new tasks go there. + fn set_host_filter(&mut self, host: Option, cx: &mut Context) { + self.host_filter = host.clone(); + let target = host.unwrap_or_else(|| { + self.selected_session + .as_deref() + .map(|id| self.host_of(id)) + .unwrap_or_else(HostId::local) + }); + let changed = target != self.target_host; + self.set_target_host(target, cx); + if changed { + self.adopt_target_host_context(cx); + } + cx.notify(); + } + + /// Hosts a settings screen can point at: every connected one. + pub(crate) fn connected_hosts(&self) -> Vec { + let mut hosts: Vec = self + .hosts + .iter() + .filter(|(_, entry)| entry.online) + .filter_map(|(id, entry)| { + Some(crate::ui::settings::SettingsHost { + id: id.clone(), + name: entry.name.clone(), + backend: entry.backend.clone()?, + }) + }) + .collect(); + hosts.sort_by(|a, b| { + b.id.is_local() + .cmp(&a.id.is_local()) + .then_with(|| a.name.cmp(&b.name)) + }); + hosts + } + + /// The saved host list changed: list saved hosts that are not + /// connected as offline, and drop hosts that were removed. + pub fn set_saved_hosts(&mut self, saved: Vec, cx: &mut Context) { + let keep: HashSet = saved + .iter() + .map(|host| HostId::new(host.id.clone())) + .chain(std::iter::once(HostId::local())) + .collect(); + let removed: Vec = self + .hosts + .keys() + .filter(|id| !keep.contains(id)) + .cloned() + .collect(); + for id in removed { + self.drop_host_sessions(&id); + self.hosts.remove(&id); + if self.target_host == id { + self.host_filter = None; + self.set_target_host(HostId::local(), cx); + } + } + for host in saved { + let id = HostId::new(host.id); + match self.hosts.get_mut(&id) { + Some(entry) => entry.name = host.name, + None => { + self.hosts.insert(id, ChatHost::saved(host.name)); + } + } + } + self.sync_sidebar(cx); + cx.notify(); + } + + /// A remote host's connection changed. Online: adopt it and read its + /// tasks. Otherwise its tasks leave the list until it is back; the + /// task on screen stays readable. + pub fn set_remote_host_status( + &mut self, + host: HostId, + name: String, + status: HostStatus, + backend: Option>, + cx: &mut Context, + ) { + let online = status == HostStatus::Online; + let entry = self + .hosts + .entry(host.clone()) + .or_insert_with(|| ChatHost::saved(name.clone())); + entry.name = name; + entry.online = online; + if let Some(backend) = backend { + entry.backend = Some(backend); + } + if online { + self.bootstrap_remote_host(host, cx); + } else { + self.drop_host_sessions(&host); + if self.target_host == host { + self.host_filter = None; + self.set_target_host(HostId::local(), cx); + } + if let HostStatus::Offline { reason } = status + && reason != "removed" + && self + .hosts + .get(&host) + .is_some_and(|entry| entry.backend.is_some()) + { + self.notice = Some(format!("{}: {reason}", self.host_name(&host)).into()); + } + self.sync_sidebar(cx); + } + cx.notify(); + } + + /// Forget a host's tasks in the list and their runs. The selected task + /// keeps its host mapping so its screen stays coherent. + fn drop_host_sessions(&mut self, host: &HostId) { + let selected = self.selected_session.clone(); + let gone: Vec = self + .session_hosts + .iter() + .filter(|(_, owner)| *owner == host) + .map(|(id, _)| id.clone()) + .collect(); + self.sessions.retain(|session| !gone.contains(&session.id)); + for id in &gone { + self.active_runs.remove(id); + self.completed_unread_sessions.remove(id); + if selected.as_deref() != Some(id.as_str()) { + self.session_hosts.remove(id); + } + } + } + + /// Read a freshly connected host: its tasks, roots, and defaults, and + /// start its runtime so it can run them. + fn bootstrap_remote_host(&mut self, host: HostId, cx: &mut Context) { + let Some(backend) = self + .hosts + .get(&host) + .and_then(|entry| entry.backend.clone()) + else { + return; + }; + let target = host.clone(); self.call( - async move { host.list_sessions(None).await }, + async move { + let boot = backend.bootstrap().await?; + let start_error = backend.start_runtime(None).await.err(); + Ok::<_, String>((boot, start_error)) + }, cx, move |this, result, cx| { match result { - Ok(sessions) => this.apply_session_list(sessions, generation, cx), - Err(message) => this.notice = Some(message.into()), + Ok((boot, start_error)) => { + if let Some(entry) = this.hosts.get_mut(&target) { + entry.project_root = boot.project_root.clone(); + entry.recent_roots = boot.recent_roots.clone(); + entry.session_defaults = Some(boot.session_defaults.clone()); + } + this.apply_host_session_list(&target, boot.sessions, cx); + if let Some(error) = start_error { + this.notice = Some( + format!( + "{}: runtime failed to start: {error}", + this.host_name(&target) + ) + .into(), + ); + } + if this.target_host == target { + this.recent_roots = boot.recent_roots; + this.adopt_target_host_context(cx); + } + } + Err(message) => { + this.notice = + Some(format!("{}: {message}", this.host_name(&target)).into()); + } } cx.notify(); }, ); } + /// Everything the connection manager reports, in one batch. + pub fn handle_manager_events(&mut self, events: Vec, cx: &mut Context) { + let mut pending: Option<(HostId, Vec)> = None; + for event in events { + if let HostManagerEvent::Event { host, event } = event { + match &mut pending { + Some((current, batch)) if *current == host => batch.push(event), + _ => { + if let Some((host, batch)) = pending.take() { + self.handle_remote_host_events(host, batch, cx); + } + pending = Some((host, vec![event])); + } + } + continue; + } + if let Some((host, batch)) = pending.take() { + self.handle_remote_host_events(host, batch, cx); + } + match event { + HostManagerEvent::Status { + host, + name, + status, + backend, + } => self.set_remote_host_status( + host, + name, + status, + backend.map(|backend| backend as Arc), + cx, + ), + HostManagerEvent::HostsChanged(hosts) => self.set_saved_hosts(hosts, cx), + HostManagerEvent::Event { .. } => unreachable!("handled above"), + } + } + if let Some((host, batch)) = pending.take() { + self.handle_remote_host_events(host, batch, cx); + } + } + + /// Events from a remote host; dropped once it is offline. + pub fn handle_remote_host_events( + &mut self, + host: HostId, + events: Vec, + cx: &mut Context, + ) { + if !self.hosts.get(&host).is_some_and(|entry| entry.online) { + return; + } + self.apply_events_from(host, events, cx); + } + /// Take a fresh session list. With nothing on screen, open the latest /// task of the visible project or create one, but only when no task or /// project was selected since the list was requested: a click whose @@ -1490,8 +1908,7 @@ impl ChatScreen { generation: u64, cx: &mut Context, ) { - self.sessions = sessions; - self.sync_sidebar(cx); + self.apply_host_session_list(&HostId::local(), sessions, cx); if self.selected_session.is_some() || self.selection_generation != generation { return; } @@ -1555,6 +1972,9 @@ impl ChatScreen { cx: &mut Context, ) { self.session_setup_pending = false; + // The task was created on the target host. + self.session_hosts + .insert(session.id.clone(), self.target_host.clone()); // The SessionCreated event may arrive before this callback; upsert so // the sidebar never shows the task twice, even when its navigation is // no longer current. @@ -1605,6 +2025,7 @@ impl ChatScreen { if self.btw.is_some() && self.selected_session.as_deref() != Some(session_id) { self.close_side_thread(cx); } + self.use_host_of(session_id); self.load_session(session_id, LoadMode::Select, LOAD_RETRIES, cx); } @@ -2141,7 +2562,14 @@ impl ChatScreen { // marker goes. Only an explicit settle ever moves a task out of // the active inbox. self.completed_unread_sessions.remove(&session.id); - self.selected_session = Some(session.id); + self.selected_session = Some(session.id.clone()); + // The selected task's host becomes the target for new tasks unless + // the sidebar filters on a host. + let owner = self.host_of(&session.id); + self.use_host_of(&session.id); + if self.host_filter.is_none() && owner != self.target_host { + self.set_target_host(owner, cx); + } self.sync_sidebar(cx); let previous_root = self.project_root.clone(); if self.set_project_context(Some(project_root.clone()), cx) && previous_root.is_some() { @@ -3833,6 +4261,13 @@ impl ChatScreen { if session.archived { self.completed_unread_sessions.remove(&session.id); } + let owner = self + .event_host + .clone() + .unwrap_or_else(|| self.target_host.clone()); + self.session_hosts + .entry(session.id.clone()) + .or_insert(owner); if let Some(existing) = self .sessions .iter_mut() @@ -3924,8 +4359,18 @@ impl ChatScreen { .find(|permission| permission.session_id == selected) } - /// Apply a batch of host events in one update. + /// Apply a batch of the local host's events in one update. pub fn handle_host_events(&mut self, events: Vec, cx: &mut Context) { + self.apply_events_from(HostId::local(), events, cx); + } + + fn apply_events_from(&mut self, host: HostId, events: Vec, cx: &mut Context) { + self.event_host = Some(host); + self.apply_host_events(events, cx); + self.event_host = None; + } + + fn apply_host_events(&mut self, events: Vec, cx: &mut Context) { let mut changed = false; for event in events { changed |= match event { @@ -3978,22 +4423,36 @@ impl ChatScreen { fn apply_service_event(&mut self, event: AgentServiceEvent, cx: &mut Context) -> bool { match event { AgentServiceEvent::RuntimeStatus(mut status) => { + let scope = self.event_host.clone().unwrap_or_else(HostId::local); // A runtime that just started points Goose at the account's // skills, which the first scan ran before; ask again. - if status.running && !self.runtime_running_seen { + if scope.is_local() && status.running && !self.runtime_running_seen { self.runtime_running_seen = true; self.refresh_slash_commands(cx); } - // The status snapshot is authoritative for active runs; one - // that repeats the known state changes nothing. A snapshot - // raced with a terminal event must not resurrect that run. + // The status snapshot is authoritative for its host's active + // runs and says nothing about other hosts'. One that repeats + // the known state changes nothing. A snapshot raced with a + // terminal event must not resurrect that run. status .active_runs .retain(|_, run_id| !self.finished_runs.contains(run_id)); - if self.active_runs == status.active_runs { + let mut merged: HashMap = self + .active_runs + .iter() + .filter(|(session_id, _)| self.host_of(session_id) != scope) + .map(|(session_id, run_id)| (session_id.clone(), run_id.clone())) + .collect(); + for (session_id, run_id) in status.active_runs { + self.session_hosts + .entry(session_id.clone()) + .or_insert_with(|| scope.clone()); + merged.insert(session_id, run_id); + } + if self.active_runs == merged { return false; } - self.active_runs = status.active_runs; + self.active_runs = merged; // Run membership decides the inbox sections: a task woken // from elsewhere moves the moment the snapshot lands. self.sync_sidebar(cx); diff --git a/apps/maple-agent/app/src/ui/chat/sidebar.rs b/apps/maple-agent/app/src/ui/chat/sidebar.rs index 5dbe63a62..7b5f7d92f 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar.rs @@ -31,6 +31,7 @@ use crate::ui::theme; use crate::ui::titlebar; use crate::ui::widgets; use maple_agent::host::{HostBackend, HostId}; +use std::collections::BTreeMap; /// What the sidebar asks the screen to do. #[derive(Clone, Debug)] @@ -50,6 +51,17 @@ pub(super) enum SidebarEvent { ChooseProject, /// Something to tell the user. Notice(SharedString), + /// Show only one host's tasks (`None` for every host); new tasks go to + /// that host. + HostFilter(Option), +} + +/// One host the sidebar can filter by and badge rows with. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct SidebarHost { + pub(super) id: HostId, + pub(super) name: SharedString, + pub(super) online: bool, } /// Activity a task row indicates beside its title. @@ -86,14 +98,16 @@ pub(super) struct SidebarRow { pub(super) menu_settle_id: SharedString, pub(super) menu_archive_id: SharedString, pub(super) title: SharedString, - /// Display name of the task's project, shown on every row. + /// Display name of the task's project, shown on every row, with the + /// host's name after it when more than one host is known. pub(super) project_name: SharedString, /// Lower-cased title, matched against the sidebar filter. pub(super) search: String, } impl SidebarRow { - fn build(session: &AgentSessionSummary, project_name: &str) -> Self { + fn build(session: &AgentSessionSummary, project_label: &str) -> Self { + let project_name = project_label; let id = &session.id; Self { id: Arc::from(id.as_str()), @@ -255,7 +269,15 @@ pub(super) struct Sidebar { rename: Option, rename_input: Option>, rename_focus_pending: bool, - // Persisted in the app settings. + /// Every known host, local first. More than one turns on the row badge + /// and the host block of the switcher menu. + hosts: Vec, + /// Which host owns each task; unknown means the local host. + session_hosts: HashMap, + /// Show only this host's tasks. + host_filter: Option, + // Persisted in the app settings, per host; merged here because task ids + // are unique across hosts. pinned_tasks: Vec, settled_tasks: HashSet, unsettled_tasks: HashSet, @@ -278,8 +300,23 @@ impl Sidebar { settings: &crate::settings::AppSettings, cx: &mut Context, ) -> Self { - let host_state = settings.host_state(host.id()); + // Every host's persisted task state, merged: ids are unique. + let mut pinned_tasks = Vec::new(); + let mut settled_tasks = HashSet::new(); + let mut unsettled_tasks = HashSet::new(); + let mut project_names: HashMap = HashMap::new(); + for state in settings.hosts.values() { + pinned_tasks.extend(state.pinned_tasks.iter().cloned()); + settled_tasks.extend(state.settled_tasks.iter().cloned()); + unsettled_tasks.extend(state.unsettled_tasks.iter().cloned()); + for (root, name) in &state.project_names { + project_names + .entry(root.clone()) + .or_insert_with(|| name.clone()); + } + } let application_vim_enabled = settings.application_vim_enabled; + let local_host_id = host.id().clone(); let search_chat = chat.clone(); let search_input = cx.new(move |cx| { TextInput::new("Search tasks", cx) @@ -330,10 +367,17 @@ impl Sidebar { rename: None, rename_input: None, rename_focus_pending: false, - pinned_tasks: host_state.pinned_tasks, - settled_tasks: host_state.settled_tasks.into_iter().collect(), - unsettled_tasks: host_state.unsettled_tasks.into_iter().collect(), - project_names: host_state.project_names.into_iter().collect(), + hosts: vec![SidebarHost { + id: local_host_id, + name: "This computer".into(), + online: true, + }], + session_hosts: HashMap::new(), + host_filter: None, + pinned_tasks, + settled_tasks, + unsettled_tasks, + project_names, vim_selected: None, vim_by_row: Vec::new(), vim_order: Vec::new(), @@ -396,6 +440,61 @@ impl Sidebar { cx.notify(); } + /// Replace the host list and the task-to-host map. Rows re-label when + /// the host count crosses one. + pub(super) fn set_hosts( + &mut self, + hosts: Vec, + session_hosts: HashMap, + cx: &mut Context, + ) { + if self.hosts == hosts && self.session_hosts == session_hosts { + return; + } + self.hosts = hosts; + self.session_hosts = session_hosts; + if self + .host_filter + .as_ref() + .is_some_and(|filter| !self.hosts.iter().any(|host| &host.id == filter)) + { + self.host_filter = None; + cx.emit(SidebarEvent::HostFilter(None)); + } + self.rebuild_sections(); + cx.notify(); + } + + /// The host a task belongs to; the local host when unknown. + fn host_of(&self, session_id: &str) -> HostId { + self.session_hosts + .get(session_id) + .cloned() + .unwrap_or_else(|| self.host.id().clone()) + } + + fn host_name(&self, id: &HostId) -> Option<&SharedString> { + self.hosts + .iter() + .find(|host| &host.id == id) + .map(|host| &host.name) + } + + /// Show only `host`'s tasks, or every host's. The screen learns of it + /// so new tasks target that host. + pub(super) fn set_host_filter(&mut self, host: Option, cx: &mut Context) { + self.switcher_menu_open = false; + self.menu_selected = None; + if self.host_filter == host { + cx.notify(); + return; + } + self.host_filter = host.clone(); + self.rebuild_sections(); + cx.emit(SidebarEvent::HostFilter(host)); + cx.notify(); + } + pub(super) fn set_recent_roots(&mut self, roots: Vec, cx: &mut Context) { if self.recent_roots == roots { return; @@ -700,6 +799,7 @@ impl Sidebar { let filter = self.filter.as_str(); let filtering = !filter.is_empty(); let scoped = self.project_filter.as_deref(); + let host_scoped = self.host_filter.clone(); let pinned_ids: HashSet<&str> = self.pinned_tasks.iter().map(String::as_str).collect(); let mut pinned = Vec::new(); let mut active = Vec::new(); @@ -719,6 +819,12 @@ impl Sidebar { if scoped.is_some_and(|scoped| scoped != root) { continue; } + if host_scoped + .as_ref() + .is_some_and(|host| self.host_of(&session.id) != *host) + { + continue; + } let matches = !filtering || self.rows[index].search.contains(filter) || root_search @@ -759,12 +865,17 @@ impl Sidebar { self.active_rows = active; self.settled_rows = settled; self.archived_rows = archived; - self.scope_label = self - .project_filter - .as_deref() - .map(|root| self.root_name(root)) - .map(SharedString::from) - .unwrap_or_else(|| "All projects".into()); + self.scope_label = match ( + self.project_filter.as_deref(), + self.host_filter + .as_ref() + .and_then(|host| self.host_name(host).cloned()), + ) { + (Some(root), _) => SharedString::from(self.root_name(root)), + (None, Some(host)) => host, + (None, None) if self.hosts.len() > 1 => "All hosts".into(), + (None, None) => "All projects".into(), + }; let recent_roots: HashSet<&str> = self.recent_roots.iter().map(String::as_str).collect(); let mut fresh_roots: Vec<&str> = session_roots .iter() @@ -921,7 +1032,7 @@ impl Sidebar { && self.rows.iter().zip(&self.sessions).all(|(row, session)| { *row.id == *session.id && row.title.as_ref() == session.title - && self.root_name_matches(&session.project_root, &row.project_name) + && row.project_name.as_ref() == self.row_project_label(session) }); if fresh { return; @@ -929,10 +1040,23 @@ impl Sidebar { self.rows = self .sessions .iter() - .map(|session| SidebarRow::build(session, &self.root_name(&session.project_root))) + .map(|session| SidebarRow::build(session, &self.row_project_label(session))) .collect(); } + /// The project name a row shows, with the host after it once more than + /// one host is known: the badge that tells two hosts' tasks apart. + fn row_project_label(&self, session: &AgentSessionSummary) -> String { + let root = self.root_name(&session.project_root); + if self.hosts.len() <= 1 { + return root; + } + match self.host_name(&self.host_of(&session.id)) { + Some(host) => format!("{root} \u{b7} {host}"), + None => root, + } + } + fn search_changed(&mut self, input: &Entity, cx: &mut Context) { let filter = input.read(cx).text_ref().trim().to_lowercase(); if filter != self.filter { @@ -998,8 +1122,13 @@ impl Sidebar { } self.rebuild_sections(); cx.notify(); - let pinned = self.pinned_tasks.clone(); - let host_id = self.host.id().clone(); + let host_id = self.host_of(session_id); + let pinned: Vec = self + .pinned_tasks + .iter() + .filter(|id| self.host_of(id) == host_id) + .cloned() + .collect(); persist_settings(move |settings| { settings.host_state_mut(&host_id).pinned_tasks = pinned; }); @@ -1012,14 +1141,28 @@ impl Sidebar { if self.settled_tasks.insert(session_id.to_string()) { self.rebuild_sections(); cx.notify(); - persist_task_sets( - self.host.id().clone(), - self.settled_tasks.clone(), - self.unsettled_tasks.clone(), - ); + self.persist_task_sets_for(session_id); } } + /// Write the settle sets of the host that owns `session_id`. + fn persist_task_sets_for(&self, session_id: &str) { + let host_id = self.host_of(session_id); + let settled: HashSet = self + .settled_tasks + .iter() + .filter(|id| self.host_of(id) == host_id) + .cloned() + .collect(); + let unsettled: HashSet = self + .unsettled_tasks + .iter() + .filter(|id| self.host_of(id) == host_id) + .cloned() + .collect(); + persist_task_sets(host_id, settled, unsettled); + } + /// Whether a collapsible section shows its rows. fn section_expanded(&self, section: SidebarSection) -> bool { match section { @@ -1046,11 +1189,7 @@ impl Sidebar { if self.unsettled_tasks.insert(session_id.to_string()) { self.rebuild_sections(); cx.notify(); - persist_task_sets( - self.host.id().clone(), - self.settled_tasks.clone(), - self.unsettled_tasks.clone(), - ); + self.persist_task_sets_for(session_id); } } @@ -1083,22 +1222,6 @@ impl Sidebar { cx.notify(); } - /// Whether `name` is the display name of `root`, without building the - /// name the way `root_name` does. - fn root_name_matches(&self, root: &str, name: &str) -> bool { - match self - .project_names - .get(root) - .filter(|name| !name.trim().is_empty()) - { - Some(stored) => stored == name, - None => match std::path::Path::new(root).file_name() { - Some(file) => file.to_string_lossy().as_ref() == name, - None => root == name, - }, - } - } - // ---- Rename ------------------------------------------------------------ /// Start an inline rename of a task or project in the sidebar. @@ -1190,9 +1313,14 @@ impl Sidebar { self.project_names.insert(root.clone(), name); } self.rebuild_sections(); - let names: std::collections::BTreeMap = + // Project names are keyed by path; the filtered host owns + // the rename, else the local host. + let names: BTreeMap = self.project_names.clone().into_iter().collect(); - let host_id = self.host.id().clone(); + let host_id = self + .host_filter + .clone() + .unwrap_or_else(|| self.host.id().clone()); persist_settings(move |settings| { settings.host_state_mut(&host_id).project_names = names; }); @@ -1937,9 +2065,87 @@ impl Sidebar { /// The menu the project switcher opens: every project, each with an /// overflow menu of its own, plus a way back to all projects. + /// The host block of the switcher menu: every host, then all hosts. + /// Offline hosts stay listed, grayed, so a host that dropped is still + /// visible; its tasks return with it. + fn render_host_rows(&self, cx: &mut Context) -> Vec> { + let mut rows = Vec::with_capacity(self.hosts.len() + 2); + let host_row = |id: SharedString, + label: SharedString, + online: bool, + current: bool, + filter: Option, + cx: &mut Context| { + div() + .id(id) + .flex() + .items_center() + .gap_2() + .px_3() + .py_1p5() + .text_sm() + .text_color(gpui::rgb(if online { + theme::text_primary() + } else { + theme::text_muted() + })) + .hover(|style| { + style + .bg(gpui::rgb(theme::bg_sidebar_row_hover())) + .cursor_pointer() + }) + .on_click(cx.listener(move |this, _event, _window, cx| { + this.set_host_filter(filter.clone(), cx); + })) + .child( + div() + .flex_1() + .min_w_0() + .line_clamp(1) + .text_ellipsis() + .child(label), + ) + .when(!online, |row| { + row.child( + div() + .text_xs() + .text_color(gpui::rgb(theme::text_muted())) + .child("offline"), + ) + }) + .when(current, |row| { + row.child(icon("check", px(14.), theme::accent())) + }) + }; + rows.push(host_row( + "switcher-all-hosts".into(), + "All hosts".into(), + true, + self.host_filter.is_none(), + None, + cx, + )); + for host in &self.hosts { + rows.push(host_row( + SharedString::from(format!("switcher-host-{}", host.id)), + host.name.clone(), + host.online, + self.host_filter.as_ref() == Some(&host.id), + Some(host.id.clone()), + cx, + )); + } + rows.push(section_divider("switcher-hosts-divider")); + rows + } + fn render_switcher_menu(&self, cx: &mut Context) -> gpui::Deferred { let selected = self.menu_selection(); - let mut items = vec![ + let mut items = Vec::new(); + if self.hosts.len() > 1 { + items.extend(self.render_host_rows(cx)); + } + items.push( div() .id("switcher-all-projects") .flex() @@ -1971,7 +2177,7 @@ impl Sidebar { .when(self.project_filter.is_none(), |row| { row.child(icon("check", px(14.), theme::accent())) }), - ]; + ); for (index, root) in self.switcher_roots.iter().enumerate() { let is_current = self.project_filter.as_deref() == Some(root.root.as_str()); let has_menu = self.project_menu.as_deref() == Some(root.root.as_str()); @@ -2509,6 +2715,16 @@ impl Render for Sidebar { } } +/// A thin rule between menu blocks. +fn section_divider(id: &'static str) -> gpui::Stateful
{ + div() + .id(id) + .h(px(1.)) + .mx_3() + .my_1() + .bg(gpui::rgb(theme::bg_input())) +} + /// Apply `update` to the settings file off the UI thread. fn persist_settings(update: impl FnOnce(&mut crate::settings::AppSettings) + Send + 'static) { crate::settings::update_settings_in_background(update); diff --git a/apps/maple-agent/app/src/ui/settings.rs b/apps/maple-agent/app/src/ui/settings.rs index 766bf2a6c..cbaf221a5 100644 --- a/apps/maple-agent/app/src/ui/settings.rs +++ b/apps/maple-agent/app/src/ui/settings.rs @@ -26,7 +26,10 @@ use crate::shortcuts::{ }; use crate::ui::theme; use crate::ui::widgets; -use maple_agent::host::{HostBackend, HostSessionDefaults, UsageSummary}; +use maple_agent::host::{HostBackend, HostId, HostSessionDefaults, UsageSummary}; +use maple_remote::hosts::SavedHost; +use maple_remote::manager::HostManager; +use maple_remote::pairing::PairingCode; mod account; mod api_keys; @@ -56,10 +59,20 @@ pub enum Section { Shortcuts, Prompt, Integrations, + Hosts, Usage, About, } +/// One connected host the settings screen can point its host-scoped +/// sections at. +#[derive(Clone)] +pub struct SettingsHost { + pub id: HostId, + pub name: String, + pub backend: Arc, +} + impl Section { fn label(self) -> &'static str { match self { @@ -70,12 +83,13 @@ impl Section { Self::Shortcuts => "Keyboard Shortcuts", Self::Prompt => "System prompt", Self::Integrations => "Integrations", + Self::Hosts => "Hosts", Self::Usage => "Usage", Self::About => "About", } } - const ALL: [Self; 9] = [ + const ALL: [Self; 10] = [ Self::General, Self::Account, Self::Billing, @@ -83,15 +97,27 @@ impl Section { Self::Shortcuts, Self::Prompt, Self::Integrations, + Self::Hosts, Self::Usage, Self::About, ]; + + /// Sections whose content belongs to one host and follow the host + /// selector. + fn is_host_scoped(self) -> bool { + matches!( + self, + Self::General | Self::Prompt | Self::Integrations | Self::Usage + ) + } } /// One multi-value General row that selects from a dropdown instead of /// toggling. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(super) enum SettingMenu { + /// Which connected host the host-scoped sections show. + Host, Permission, Appearance, ChatFont, @@ -104,6 +130,7 @@ impl SettingMenu { /// Stable id fragment for the popup panel and its rows. fn id(self) -> &'static str { match self { + Self::Host => "host", Self::Permission => "permission", Self::Appearance => "appearance", Self::ChatFont => "chat-font", @@ -122,6 +149,18 @@ pub struct SettingsScreen { /// The host whose integrations, MCP servers, usage, and session /// defaults the host-scoped sections show. host: Arc, + /// Every connected host, for the selector; local first. + hosts: Vec, + /// Pairs, renames, and removes hosts. Absent in tests. + manager: Option>, + /// The saved host list the Hosts section shows. + saved_hosts: Vec, + /// The add-host form. + host_address: Entity, + host_code: Entity, + host_name: Entity, + pairing: bool, + hosts_notice: Option, user_id: String, settings: AppSettings, /// The host's defaults for new tasks; the built-in defaults until the @@ -240,9 +279,12 @@ impl EventEmitter for SettingsScreen {} impl EventEmitter for SettingsScreen {} impl SettingsScreen { + #[allow(clippy::too_many_arguments)] pub fn new( backend: Arc, host: Arc, + hosts: Vec, + manager: Option>, user_id: String, settings: AppSettings, shortcut_snapshot: ShortcutSnapshot, @@ -250,6 +292,22 @@ impl SettingsScreen { cx: &mut Context, ) -> Self { let defaults = HostSessionDefaults::default(); + let host_field = |placeholder: &str, index: isize, cx: &mut Context| { + let application_vim_enabled = settings.application_vim_enabled; + let placeholder = placeholder.to_string(); + cx.new(move |cx| { + TextInput::new(&placeholder, cx) + .with_tab_index(index) + .application_vim(application_vim_enabled) + }) + }; + let host_address = host_field("100.64.0.7:7130", 6, cx); + let host_code = host_field("XXXX-XXXX-XXXX-XXXX", 7, cx); + let host_name = host_field("Workstation (optional)", 8, cx); + let saved_hosts = manager + .as_ref() + .and_then(|manager| manager.store().list().ok()) + .unwrap_or_default(); let prompt_text = defaults.effective_harness_instructions(); let application_vim_enabled = settings.application_vim_enabled; let application_focus = cx.focus_handle(); @@ -293,6 +351,14 @@ impl SettingsScreen { bridged_tasks: std::cell::RefCell::new(Vec::new()), backend, host, + hosts, + manager, + saved_hosts, + host_address, + host_code, + host_name, + pairing: false, + hosts_notice: None, user_id, theme: theme::Preference::parse(&settings.theme), settings, @@ -715,6 +781,243 @@ impl SettingsScreen { self.save_mcp_servers(servers, cx); } + fn current_host_name(&self) -> String { + self.hosts + .iter() + .find(|host| host.id == *self.host.id()) + .map(|host| host.name.clone()) + .unwrap_or_else(|| "This computer".to_string()) + } + + /// Point the host-scoped sections at another connected host and + /// re-read everything they show. + fn select_host(&mut self, host: SettingsHost, cx: &mut Context) { + if host.id == *self.host.id() { + return; + } + self.host = host.backend; + self.mcp_servers = None; + self.mcp_editor = None; + self.integrations = None; + self.usage = None; + self.load_session_defaults(cx); + self.load_usage(cx); + self.load_mcp_servers(cx); + self.load_integrations(cx); + cx.notify(); + } + + /// Whether a host is connected right now. + fn host_is_online(&self, id: &str) -> bool { + self.hosts.iter().any(|host| host.id.as_str() == id) + } + + fn reload_saved_hosts(&mut self) { + if let Some(manager) = &self.manager + && let Ok(hosts) = manager.store().list() + { + self.saved_hosts = hosts; + } + } + + /// Pair with the host in the form. The manager saves it and connects; + /// it appears in the sidebar once online. + fn pair_host(&mut self, cx: &mut Context) { + if self.pairing { + return; + } + let Some(manager) = self.manager.clone() else { + self.hosts_notice = Some("Hosts are unavailable in this session.".to_string()); + cx.notify(); + return; + }; + let address = self.host_address.read(cx).text().trim().to_string(); + let code = match PairingCode::parse(&self.host_code.read(cx).text()) { + Ok(code) => code, + Err(message) => { + self.hosts_notice = Some(message); + cx.notify(); + return; + } + }; + if address.is_empty() { + self.hosts_notice = Some("Enter the host's address, like 100.64.0.7:7130.".to_string()); + cx.notify(); + return; + } + let name = + Some(self.host_name.read(cx).text().trim().to_string()).filter(|name| !name.is_empty()); + self.pairing = true; + self.hosts_notice = Some("Pairing…".to_string()); + cx.notify(); + self.call( + async move { manager.pair(&address, code, name).await }, + cx, + |this, result, cx| { + this.pairing = false; + match result { + Ok(host) => { + this.hosts_notice = Some(format!( + "Paired with {}. Its tasks appear in the sidebar once it is connected.", + host.name + )); + for input in [&this.host_address, &this.host_code, &this.host_name] { + input.update(cx, |input, cx| input.set_text("", cx)); + } + this.reload_saved_hosts(); + } + Err(message) => this.hosts_notice = Some(message), + } + cx.notify(); + }, + ); + } + + fn remove_host(&mut self, id: &str, cx: &mut Context) { + let Some(manager) = &self.manager else { + return; + }; + self.hosts_notice = manager.remove(id).err(); + self.reload_saved_hosts(); + cx.notify(); + } + + fn render_hosts_pane(&self, cx: &mut Context) -> Div { + let mut pane = div() + .flex() + .flex_col() + .gap_4() + .child(section_title("Hosts")) + .child( + div() + .text_sm() + .text_color(gpui::rgb(theme::text_muted())) + .child( + "A host is another machine running `maple-gpui serve`. Its tasks join \ + the sidebar and run there. Pair once with the code the host prints; \ + later connections need no code.", + ), + ); + if self.saved_hosts.is_empty() { + pane = pane.child( + div() + .text_sm() + .text_color(gpui::rgb(theme::text_muted())) + .child("No hosts yet."), + ); + } + for host in &self.saved_hosts { + let online = self.host_is_online(&host.id); + let id = host.id.clone(); + let short_id: String = host.id.chars().take(10).collect(); + let connections = host + .connections + .iter() + .map(|connection| connection.label().to_string()) + .collect::>() + .join(", "); + pane = pane.child( + widgets::card_row() + .id(gpui::SharedString::from(format!("host-{}", host.id))) + .flex() + .items_center() + .justify_between() + .gap_4() + .child( + div() + .flex() + .flex_col() + .min_w_0() + .child( + div() + .flex() + .items_center() + .gap_2() + .child( + div() + .font_weight(gpui::FontWeight::MEDIUM) + .child(host.name.clone()), + ) + .child( + div() + .text_xs() + .text_color(gpui::rgb(if online { + theme::accent() + } else { + theme::text_muted() + })) + .child(if online { "online" } else { "offline" }), + ), + ) + .child( + div() + .text_xs() + .text_color(gpui::rgb(theme::text_muted())) + .line_clamp(1) + .text_ellipsis() + .child(format!("{connections} \u{b7} key {short_id}\u{2026}")), + ), + ) + .child( + widgets::ghost_button(gpui::SharedString::from(format!( + "remove-host-{}", + host.id + ))) + .on_click(cx.listener(move |this, _event, _window, cx| { + this.remove_host(&id, cx); + })) + .child("Remove"), + ), + ); + } + pane = pane + .child(section_title("Add a host")) + .child( + div() + .text_sm() + .text_color(gpui::rgb(theme::text_muted())) + .child( + "On the host, run `maple-gpui serve pair` and enter its address and \ + the code here within five minutes.", + ), + ) + .child(labeled_input("Address", self.host_address.clone())) + .child(labeled_input("Pairing code", self.host_code.clone())) + .child(labeled_input("Name", self.host_name.clone())) + .child( + div().flex().items_center().gap_3().child( + widgets::primary_button("pair-host") + .on_click(cx.listener(|this, _event, _window, cx| { + this.pair_host(cx); + })) + .child(if self.pairing { "Pairing…" } else { "Pair" }), + ), + ); + if let Some(notice) = &self.hosts_notice { + pane = pane.child( + div() + .text_sm() + .text_color(gpui::rgb(theme::text_muted())) + .child(notice.clone()), + ); + } + pane + } + + /// The host selector row, shown on host-scoped sections when more than + /// one host is connected. + fn render_host_selector(&self, cx: &mut Context) -> Option> { + if self.hosts.len() <= 1 { + return None; + } + Some(self.setting_menu_row( + "Host", + "Which host these settings belong to.", + SettingMenu::Host, + cx, + )) + } + fn load_usage(&self, cx: &mut Context) { let host = self.host.clone(); self.call( @@ -831,6 +1134,14 @@ impl SettingsScreen { /// share this order, so an index means the same option in both. fn menu_options(&self, menu: SettingMenu) -> Vec { match menu { + SettingMenu::Host => self + .hosts + .iter() + .map(|host| SettingOption { + label: host.name.clone(), + current: host.id == *self.host.id(), + }) + .collect(), SettingMenu::Permission => [PermissionMode::SmartApprove, PermissionMode::Auto] .iter() .map(|&mode| SettingOption { @@ -887,6 +1198,7 @@ impl SettingsScreen { /// The saved value shown on the dropdown's trigger button. fn menu_value(&self, menu: SettingMenu) -> String { match menu { + SettingMenu::Host => self.current_host_name(), SettingMenu::Permission => self.permission_default().label().to_string(), SettingMenu::Appearance => self.theme.label().to_string(), SettingMenu::ChatFont => { @@ -1007,6 +1319,11 @@ impl SettingsScreen { cx: &mut Context, ) { match menu { + SettingMenu::Host => { + if let Some(host) = self.hosts.get(index).cloned() { + self.select_host(host, cx); + } + } SettingMenu::Permission => { let Some(mode) = [PermissionMode::SmartApprove, PermissionMode::Auto].get(index) else { @@ -1619,7 +1936,15 @@ impl SettingsScreen { .p_6() .track_scroll(&self.pane_scroll) .overflow_y_scroll(); + if self.section.is_host_scoped() + && let Some(selector) = self.render_host_selector(cx) + { + pane = pane.child(selector); + } match self.section { + Section::Hosts => { + pane = pane.child(self.render_hosts_pane(cx)); + } Section::General => { pane = pane .child(section_title("Defaults")) @@ -3244,6 +3569,21 @@ fn toggle_row( )) } +/// A label above a text input, for short forms. +fn labeled_input(label: &str, input: Entity) -> Div { + div() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_xs() + .text_color(gpui::rgb(theme::text_muted())) + .child(label.to_string()), + ) + .child(input) +} + fn info_row(label: &str, value: String) -> Div { widgets::card_row() .flex() diff --git a/apps/maple-agent/app/src/ui/settings/navigation.rs b/apps/maple-agent/app/src/ui/settings/navigation.rs index ba5bb81cf..7dd1ed6b8 100644 --- a/apps/maple-agent/app/src/ui/settings/navigation.rs +++ b/apps/maple-agent/app/src/ui/settings/navigation.rs @@ -182,7 +182,7 @@ impl SettingsScreen { .map(|server| SettingsTarget::McpServer(server.name.clone())), ) .collect(), - Section::Usage | Section::About => Vec::new(), + Section::Hosts | Section::Usage | Section::About => Vec::new(), } } @@ -691,6 +691,8 @@ mod tests { SettingsScreen::new( backend.clone(), backend.local_host("user"), + Vec::new(), + None, "user".to_string(), crate::settings::AppSettings::default(), crate::shortcuts::ShortcutSnapshot { @@ -777,6 +779,8 @@ mod tests { SettingsScreen::new( backend.clone(), backend.local_host("user"), + Vec::new(), + None, "user".to_string(), crate::settings::AppSettings { application_vim_enabled: false, @@ -851,6 +855,8 @@ mod tests { SettingsScreen::new( backend.clone(), backend.local_host("user"), + Vec::new(), + None, "user".to_string(), crate::settings::AppSettings { application_vim_enabled: true, @@ -916,6 +922,8 @@ mod tests { SettingsScreen::new( backend.clone(), backend.local_host("user"), + Vec::new(), + None, "user".to_string(), crate::settings::AppSettings { application_vim_enabled: true, @@ -993,6 +1001,8 @@ mod tests { SettingsScreen::new( backend.clone(), backend.local_host("user"), + Vec::new(), + None, "user".to_string(), crate::settings::AppSettings { application_vim_enabled: application_vim, diff --git a/apps/maple-agent/crates/maple-remote/src/hosts.rs b/apps/maple-agent/crates/maple-remote/src/hosts.rs new file mode 100644 index 000000000..a7fa56b26 --- /dev/null +++ b/apps/maple-agent/crates/maple-remote/src/hosts.rs @@ -0,0 +1,260 @@ +//! The hosts a client has paired with. +//! +//! One JSON file per account. A host is identified by its static public +//! key and may be reachable through several connections; adding a second +//! address to a host the client already knows merges into that host rather +//! than creating another. Loading salvages per entry: a malformed +//! connection is dropped, not the host, and a malformed host is dropped, +//! not the file. + +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use serde::{Deserialize, Serialize}; + +/// One way to reach a host. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum HostConnection { + /// Plain WebSocket to `host:port` on a LAN or a Tailscale network. + Direct { address: String }, +} + +impl HostConnection { + pub fn label(&self) -> &str { + match self { + Self::Direct { address } => address, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SavedHost { + /// The host's static public key. + pub id: String, + pub name: String, + pub connections: Vec, + pub paired_at_ms: u64, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +struct HostsFile { + #[serde(default)] + hosts: Vec, +} + +pub struct HostsStore { + path: PathBuf, + lock: Mutex<()>, +} + +fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_millis() as u64) + .unwrap_or(0) +} + +/// Decode one saved host, dropping connections that do not parse. +fn salvage_host(value: serde_json::Value) -> Option { + let object = value.as_object()?; + let id = object.get("id")?.as_str()?.to_string(); + let name = object + .get("name") + .and_then(serde_json::Value::as_str) + .unwrap_or("host") + .to_string(); + let paired_at_ms = object + .get("pairedAtMs") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + let connections = object + .get("connections") + .and_then(serde_json::Value::as_array) + .map(|list| { + list.iter() + .filter_map(|entry| serde_json::from_value(entry.clone()).ok()) + .collect() + }) + .unwrap_or_default(); + Some(SavedHost { + id, + name, + connections, + paired_at_ms, + }) +} + +impl HostsStore { + pub fn new(path: impl Into) -> Self { + Self { + path: path.into(), + lock: Mutex::new(()), + } + } + + pub fn path(&self) -> &Path { + &self.path + } + + fn read(&self) -> Result, String> { + match std::fs::read(&self.path) { + Ok(bytes) => { + let file: HostsFile = serde_json::from_slice(&bytes).map_err(|error| { + format!("{} is not a hosts file: {error}", self.path.display()) + })?; + Ok(file.hosts.into_iter().filter_map(salvage_host).collect()) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()), + Err(error) => Err(format!("cannot read {}: {error}", self.path.display())), + } + } + + fn write(&self, hosts: &[SavedHost]) -> Result<(), String> { + let file = HostsFile { + hosts: hosts + .iter() + .map(|host| serde_json::to_value(host).unwrap_or(serde_json::Value::Null)) + .collect(), + }; + maple_agent::private_file::write_private_json(&self.path, &file) + .map_err(|error| format!("cannot write {}: {error}", self.path.display())) + } + + pub fn list(&self) -> Result, String> { + let _guard = self + .lock + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + self.read() + } + + pub fn get(&self, id: &str) -> Result, String> { + Ok(self.list()?.into_iter().find(|host| host.id == id)) + } + + /// Save a host. A host with the same key already saved keeps its + /// record and gains the new connections; the name changes only when + /// the saved one is empty. + pub fn upsert(&self, host: SavedHost) -> Result { + let _guard = self + .lock + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut hosts = self.read()?; + let merged = match hosts.iter_mut().find(|saved| saved.id == host.id) { + Some(saved) => { + for connection in host.connections { + if !saved.connections.contains(&connection) { + saved.connections.push(connection); + } + } + if saved.name.trim().is_empty() { + saved.name = host.name; + } + saved.clone() + } + None => { + let mut host = host; + if host.paired_at_ms == 0 { + host.paired_at_ms = now_ms(); + } + hosts.push(host.clone()); + host + } + }; + self.write(&hosts)?; + Ok(merged) + } + + pub fn rename(&self, id: &str, name: &str) -> Result<(), String> { + let _guard = self + .lock + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut hosts = self.read()?; + let host = hosts + .iter_mut() + .find(|host| host.id == id) + .ok_or_else(|| "no such host".to_string())?; + host.name = name.trim().to_string(); + self.write(&hosts) + } + + pub fn remove(&self, id: &str) -> Result<(), String> { + let _guard = self + .lock + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut hosts = self.read()?; + hosts.retain(|host| host.id != id); + self.write(&hosts) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hosts_merge_on_identity_and_salvage_bad_entries() { + let dir = std::env::temp_dir().join(format!("maple-hosts-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + let store = HostsStore::new(dir.join("hosts.json")); + store + .upsert(SavedHost { + id: "k1".into(), + name: "workstation".into(), + connections: vec![HostConnection::Direct { + address: "100.64.0.7:7130".into(), + }], + paired_at_ms: 0, + }) + .unwrap(); + // Pairing again over another address merges into the same host. + let merged = store + .upsert(SavedHost { + id: "k1".into(), + name: "other name".into(), + connections: vec![ + HostConnection::Direct { + address: "192.168.1.20:7130".into(), + }, + HostConnection::Direct { + address: "100.64.0.7:7130".into(), + }, + ], + paired_at_ms: 0, + }) + .unwrap(); + assert_eq!(merged.name, "workstation"); + assert_eq!(merged.connections.len(), 2); + assert!(merged.paired_at_ms > 0); + assert_eq!(store.list().unwrap().len(), 1); + + store.rename("k1", " box ").unwrap(); + assert_eq!(store.get("k1").unwrap().unwrap().name, "box"); + + // A hand-edited file with one bad connection and one bad host. + std::fs::write( + store.path(), + r#"{"hosts":[ + {"id":"k1","name":"box","pairedAtMs":5,"connections":[ + {"kind":"direct","address":"a:1"}, + {"kind":"teleport","where":"nowhere"} + ]}, + {"name":"no id"}, + 7 + ]}"#, + ) + .unwrap(); + let hosts = store.list().unwrap(); + assert_eq!(hosts.len(), 1); + assert_eq!(hosts[0].connections.len(), 1); + + store.remove("k1").unwrap(); + assert!(store.list().unwrap().is_empty()); + let _ = std::fs::remove_dir_all(dir); + } +} diff --git a/apps/maple-agent/crates/maple-remote/src/lib.rs b/apps/maple-agent/crates/maple-remote/src/lib.rs index 917cbaf84..288298e65 100644 --- a/apps/maple-agent/crates/maple-remote/src/lib.rs +++ b/apps/maple-agent/crates/maple-remote/src/lib.rs @@ -7,6 +7,8 @@ //! a WebSocket with Noise inside, and [`net`] dials and listens. //! - [`keys`], [`pairing`], [`devices`]: the static key of a host or a //! device, the one-time pairing code, and the host's paired device list. +//! - [`hosts`], [`manager`]: the client's saved hosts, and the connectors +//! that keep them connected and forward their events. //! - [`frame`]: `[channel][kind][payload]`. Channel 0 is control and carries //! JSON-RPC 2.0 ([`rpc`]). Other channels are binary streams with //! credit-based flow control ([`streams`]). Every frame a side sends goes @@ -30,7 +32,9 @@ pub mod carrier; pub mod client; pub mod devices; pub mod frame; +pub mod hosts; pub mod keys; +pub mod manager; pub mod net; pub mod noise; pub mod outbound; diff --git a/apps/maple-agent/crates/maple-remote/src/manager.rs b/apps/maple-agent/crates/maple-remote/src/manager.rs new file mode 100644 index 000000000..296c41791 --- /dev/null +++ b/apps/maple-agent/crates/maple-remote/src/manager.rs @@ -0,0 +1,397 @@ +//! The client's connections to its saved hosts. +//! +//! One connector task per saved host dials the host's connections in +//! order, hands the UI a connected [`RemoteHostBackend`], forwards the +//! host's events, and reconnects with jittered exponential backoff when +//! the connection ends. Pairing dials with a code, saves the host, and +//! starts its connector with the connection already open. Everything the +//! UI needs arrives as [`HostManagerEvent`]s on one channel. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use maple_agent::host::{HostBackend, HostEvent, HostId}; +use rand::Rng as _; +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; + +use crate::client::{ClientConfig, RemoteHostBackend}; +use crate::hosts::{HostConnection, HostsStore, SavedHost}; +use crate::keys::StaticKey; +use crate::net::{ConnectTarget, connect_direct}; +use crate::pairing::PairingCode; +use crate::wire::ClientHello; + +/// Reconnect backoff: full jitter between half and all of an exponential +/// delay from `BACKOFF_FLOOR` to `BACKOFF_CAP`. +const BACKOFF_FLOOR: Duration = Duration::from_secs(1); +const BACKOFF_CAP: Duration = Duration::from_secs(30); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HostStatus { + Connecting, + Online, + Offline { reason: String }, +} + +/// What the manager tells the UI. +#[derive(Clone)] +pub enum HostManagerEvent { + /// A host's connection state changed. `backend` is present exactly + /// when the status is `Online`. + Status { + host: HostId, + name: String, + status: HostStatus, + backend: Option>, + }, + /// The host pushed an event. + Event { host: HostId, event: HostEvent }, + /// The saved host list changed (paired, renamed, removed). + HostsChanged(Vec), +} + +impl std::fmt::Debug for HostManagerEvent { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Status { host, status, .. } => { + write!(f, "Status({host}, {status:?})") + } + Self::Event { host, .. } => write!(f, "Event({host})"), + Self::HostsChanged(hosts) => write!(f, "HostsChanged({})", hosts.len()), + } + } +} + +pub struct HostManager { + device: StaticKey, + /// The hello every connection sends; the device is this client. + hello: ClientHello, + store: Arc, + config: ClientConfig, + events: mpsc::UnboundedSender, + connectors: Mutex>, + shutdown: CancellationToken, +} + +impl HostManager { + pub fn new( + device: StaticKey, + hello: ClientHello, + store: Arc, + config: ClientConfig, + ) -> (Arc, mpsc::UnboundedReceiver) { + let (events, receiver) = mpsc::unbounded_channel(); + ( + Arc::new(Self { + device, + hello, + store, + config, + events, + connectors: Mutex::new(HashMap::new()), + shutdown: CancellationToken::new(), + }), + receiver, + ) + } + + pub fn store(&self) -> &Arc { + &self.store + } + + pub fn device_id(&self) -> String { + self.device.public_id() + } + + /// Start a connector for every saved host. Must run inside a Tokio + /// runtime. + pub fn start(self: &Arc) { + let hosts = self.store.list().unwrap_or_else(|error| { + log::warn!("cannot read saved hosts: {error}"); + Vec::new() + }); + for host in hosts { + self.spawn_connector(host, None); + } + } + + /// Stop every connector. + pub fn shutdown(&self) { + self.shutdown.cancel(); + } + + fn emit(&self, event: HostManagerEvent) { + let _ = self.events.send(event); + } + + /// Pair with the host at `address` using `code`, save it, and start its + /// connector on the connection the pairing opened. + pub async fn pair( + self: &Arc, + address: &str, + code: PairingCode, + name: Option, + ) -> Result { + let address = address.trim().to_string(); + if address.is_empty() { + return Err("Enter the host's address".to_string()); + } + let dialed = connect_direct(&address, &self.device, ConnectTarget::Pair(code)).await?; + let backend = + RemoteHostBackend::connect(dialed.carrier, self.hello.clone(), self.config.clone()) + .await?; + let announced = backend.host_hello().host.clone(); + let host = self.store.upsert(SavedHost { + id: dialed.host_key.clone(), + name: name + .filter(|name| !name.trim().is_empty()) + .unwrap_or(announced.name), + connections: vec![HostConnection::Direct { address }], + paired_at_ms: 0, + })?; + self.emit(HostManagerEvent::HostsChanged(self.store.list()?)); + self.spawn_connector(host.clone(), Some(backend)); + Ok(host) + } + + /// Add another address to a saved host and reconnect through it when + /// the current connection drops. + pub fn add_connection(&self, id: &str, address: &str) -> Result<(), String> { + let address = address.trim(); + if address.is_empty() { + return Err("Enter an address".to_string()); + } + let Some(saved) = self.store.get(id)? else { + return Err("no such host".to_string()); + }; + self.store.upsert(SavedHost { + connections: vec![HostConnection::Direct { + address: address.to_string(), + }], + ..saved + })?; + self.emit(HostManagerEvent::HostsChanged(self.store.list()?)); + Ok(()) + } + + pub fn rename(&self, id: &str, name: &str) -> Result<(), String> { + self.store.rename(id, name)?; + self.emit(HostManagerEvent::HostsChanged(self.store.list()?)); + Ok(()) + } + + /// Forget a host: its connector stops and its record goes. + pub fn remove(&self, id: &str) -> Result<(), String> { + if let Some(token) = self + .connectors + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(id) + { + token.cancel(); + } + let name = self + .store + .get(id)? + .map(|host| host.name) + .unwrap_or_default(); + self.store.remove(id)?; + self.emit(HostManagerEvent::Status { + host: HostId::new(id), + name, + status: HostStatus::Offline { + reason: "removed".to_string(), + }, + backend: None, + }); + self.emit(HostManagerEvent::HostsChanged(self.store.list()?)); + Ok(()) + } + + fn spawn_connector(self: &Arc, host: SavedHost, initial: Option>) { + let token = self.shutdown.child_token(); + if let Some(previous) = self + .connectors + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(host.id.clone(), token.clone()) + { + previous.cancel(); + } + let manager = Arc::clone(self); + tokio::spawn(async move { + manager.run_connector(host, initial, token).await; + }); + } + + async fn run_connector( + &self, + host: SavedHost, + mut initial: Option>, + cancel: CancellationToken, + ) { + let id = HostId::new(host.id.clone()); + let mut attempt: u32 = 0; + while !cancel.is_cancelled() { + // Connections may have been added since; read them fresh. + let saved = self + .store + .get(&host.id) + .ok() + .flatten() + .unwrap_or_else(|| host.clone()); + let name = saved.name.clone(); + let backend = match initial.take() { + Some(backend) => Ok(backend), + None => { + self.emit(HostManagerEvent::Status { + host: id.clone(), + name: name.clone(), + status: HostStatus::Connecting, + backend: None, + }); + self.dial(&saved, &cancel).await + } + }; + match backend { + Ok(backend) => { + attempt = 0; + self.emit(HostManagerEvent::Status { + host: id.clone(), + name: name.clone(), + status: HostStatus::Online, + backend: Some(Arc::clone(&backend)), + }); + let reason = self.forward_events(&id, &backend, &cancel).await; + backend.close().await; + self.emit(HostManagerEvent::Status { + host: id.clone(), + name: name.clone(), + status: HostStatus::Offline { + reason: reason.clone(), + }, + backend: None, + }); + if cancel.is_cancelled() { + return; + } + } + Err(reason) => { + self.emit(HostManagerEvent::Status { + host: id.clone(), + name, + status: HostStatus::Offline { reason }, + backend: None, + }); + } + } + let delay = backoff(attempt); + attempt = attempt.saturating_add(1); + tokio::select! { + _ = tokio::time::sleep(delay) => {} + _ = cancel.cancelled() => return, + } + } + } + + /// Try each connection in order; the first that completes a handshake + /// wins. + async fn dial( + &self, + host: &SavedHost, + cancel: &CancellationToken, + ) -> Result, String> { + if host.connections.is_empty() { + return Err("no address saved for this host".to_string()); + } + let mut last_error = String::new(); + for connection in &host.connections { + if cancel.is_cancelled() { + return Err("cancelled".to_string()); + } + let HostConnection::Direct { address } = connection; + let dialed = connect_direct( + address, + &self.device, + ConnectTarget::Host { + host_key: host.id.clone(), + }, + ) + .await; + match dialed { + Ok(dialed) => { + match RemoteHostBackend::connect( + dialed.carrier, + self.hello.clone(), + self.config.clone(), + ) + .await + { + Ok(backend) => return Ok(backend), + Err(error) => last_error = error, + } + } + Err(error) => last_error = error, + } + } + Err(last_error) + } + + /// Forward the host's events until the connection ends or the + /// connector is cancelled. Returns why it stopped. + async fn forward_events( + &self, + id: &HostId, + backend: &Arc, + cancel: &CancellationToken, + ) -> String { + let mut events = backend.subscribe(); + let mut closed = backend.closed(); + loop { + tokio::select! { + event = events.recv() => match event { + Some(event) => self.emit(HostManagerEvent::Event { host: id.clone(), event }), + None => return "connection ended".to_string(), + }, + changed = closed.changed() => { + if changed.is_err() { + return "connection ended".to_string(); + } + if let Some(reason) = closed.borrow().clone() { + return reason; + } + } + _ = cancel.cancelled() => return "stopped".to_string(), + } + } + } +} + +/// Full-jitter exponential backoff. +fn backoff(attempt: u32) -> Duration { + let exponential = BACKOFF_FLOOR + .checked_mul(2u32.saturating_pow(attempt.min(10))) + .unwrap_or(BACKOFF_CAP) + .min(BACKOFF_CAP); + let millis = exponential.as_millis() as u64; + let jittered = rand::thread_rng().gen_range((millis / 2).max(1)..=millis); + Duration::from_millis(jittered) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn backoff_grows_to_the_cap_and_stays_jittered() { + for attempt in 0..12 { + let delay = backoff(attempt); + assert!(delay >= BACKOFF_FLOOR / 2, "attempt {attempt}: {delay:?}"); + assert!(delay <= BACKOFF_CAP, "attempt {attempt}: {delay:?}"); + } + assert!(backoff(0) <= BACKOFF_FLOOR); + assert!(backoff(10) >= BACKOFF_CAP / 2); + } +} diff --git a/apps/maple-agent/docs/remote-development.md b/apps/maple-agent/docs/remote-development.md index dcd6ad13f..4682d351f 100644 --- a/apps/maple-agent/docs/remote-development.md +++ b/apps/maple-agent/docs/remote-development.md @@ -1,11 +1,12 @@ # Remote development -Status: design and implementation plan, agreed 2026-09-19. Steps 1 to 3 +Status: design and implementation plan, agreed 2026-09-19. Steps 1 to 4 of the implementation order are built: the seam, the protocol crate with -its server and remote client, and the transport with Noise, pairing, the -device store, and the `serve` command. The client's saved hosts, reconnect, -merged sidebar, and the desktop app serving are not. When code and this -document disagree, the code wins; update this document in the same change. +its server and remote client, the transport with Noise, pairing, the +device store, and the `serve` command, and the client with saved hosts, +reconnect, the merged sidebar, and the Hosts settings. The desktop app +serving (step 5) is not. When code and this document disagree, the code +wins; update this document in the same change. A Maple host runs the agent runtime and serves it over the network. A Maple client is the desktop app, which drives one or more hosts. The first release @@ -455,6 +456,14 @@ This release tries connections in order and uses the first that completes a handshake. Concurrent probing with latency-based switching and hysteresis is the follow-up that lands with the relay. +As built: `maple_remote::manager::HostManager` runs one connector per +saved host on the backend runtime and reports status, events, and list +changes on one channel; the desktop shell pumps that channel into the chat +screen in batches, like the local host's events. The chat screen keeps one +entry per host, maps every task to its host, and points its single "host" +handle at the selected task's host, so every existing call site drives the +right host without knowing it. The local host is named "This computer". + ### Lifecycle Saved hosts auto-connect on launch and reconnect with jittered backoff. An @@ -468,8 +477,13 @@ data it already has. The sidebar merges sessions across hosts, sorted as today, with a host filter that defaults to all. Rows show a host badge only when more than one host is -connected. New tasks target the host selected in the sidebar filter. When the -filter is "all", the composer shows the target host with a picker. +known. New tasks target the host selected in the sidebar filter, else the +selected task's host. + +As built: the host filter lives in the project switcher menu, above the +project rows, with every host listed and offline hosts grayed. The badge is +the host name after the project name on each row. A composer picker for the +target host is not built; the filter and the selection decide it. ### Settings scoping From 6db9fd53aa1cb88a336d8693f4d613ba9ea3ce39 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Sat, 19 Sep 2026 16:08:49 -0500 Subject: [PATCH 08/37] Let the desktop app serve paired devices The host role now lives in one place the command and the window share. Hosting takes the data-root lock, binds, and serves the account's local host on the backend runtime; the serve command runs it in the foreground and the window runs it behind a new "Allow remote connections" setting, off by default so a fresh install never listens. Turning it on in Settings takes effect at once and reports where the host listens and under which key. The Hosts section publishes pairing codes through the same pending file the command uses, lists the devices that paired with this machine with revocation, and shows a host paired from Settings as online. Both roles share the host key, the device list, and the lock, so only one of them serves at a time and the other says who holds the root. As a service the command ends cleanly on SIGTERM as well as Ctrl-C, and when NOTIFY_SOCKET is set it reports READY=1 once the port is bound and STOPPING=1 on the way out, so a unit can be Type=notify. Co-Authored-By: Claude Fable 5.1 --- apps/maple-agent/README.md | 33 +- apps/maple-agent/app/Cargo.toml | 1 + apps/maple-agent/app/src/desktop.rs | 21 ++ apps/maple-agent/app/src/hosting.rs | 351 ++++++++++++++++++ apps/maple-agent/app/src/main.rs | 2 + apps/maple-agent/app/src/serve.rs | 177 +++------ apps/maple-agent/app/src/settings.rs | 5 + apps/maple-agent/app/src/ui/settings.rs | 224 ++++++++++- .../app/src/ui/settings/navigation.rs | 5 + .../crates/maple-remote/src/manager.rs | 29 +- apps/maple-agent/docs/remote-development.md | 23 +- 11 files changed, 726 insertions(+), 145 deletions(-) create mode 100644 apps/maple-agent/app/src/hosting.rs diff --git a/apps/maple-agent/README.md b/apps/maple-agent/README.md index 7f57a1832..6a5e426e6 100644 --- a/apps/maple-agent/README.md +++ b/apps/maple-agent/README.md @@ -429,7 +429,11 @@ maple-gpui serve devices revoke DEV Forget a device by key or name. Runs this machine as a host for the desktop app on another machine, over a LAN or a Tailscale network. It reuses the sign-in saved by `login` or -the desktop app and hosts its own runtime. A device is admitted by a +the desktop app and hosts its own runtime. The desktop app can serve the +same way: Settings > Hosts > "Allow remote connections" (off by default) +listens on the same port, publishes pairing codes, and lists paired +devices; the command and the window share the host key, the device list, +and the lock, so only one of them serves at a time. A device is admitted by a one-time code: run `serve pair` on the host, enter the code in the app within five minutes, and both sides pin each other's key; later connections need no code. Traffic is Noise-encrypted inside a plain WebSocket, so @@ -438,6 +442,33 @@ Repeated wrong codes lock the source address out. Revoking a device ends its live connections within seconds. One `serve` per data root; a lock file refuses a second. See [`docs/remote-development.md`](docs/remote-development.md). +`serve` is written to run under systemd: it stops cleanly on SIGTERM as +well as Ctrl-C, reports `READY=1` once the port is bound and `STOPPING=1` +on the way out when `NOTIFY_SOCKET` is set, and logs to stderr for the +journal. A user unit: + +```ini +[Unit] +Description=Maple host +After=network-online.target +Wants=network-online.target + +[Service] +Type=notify +NotifyAccess=main +ExecStart=%h/.local/bin/maple-gpui serve --listen 100.64.0.7:7130 +Restart=on-failure +RestartSec=5 +TimeoutStopSec=15 + +[Install] +WantedBy=default.target +``` + +Run `maple-gpui login` once as that user first, then +`systemctl --user enable --now maple-serve`; `loginctl enable-linger` keeps +it up after logout. + ## Build features The default build has every mode. Cargo features turn modes off, so a diff --git a/apps/maple-agent/app/Cargo.toml b/apps/maple-agent/app/Cargo.toml index c08844237..99e236031 100644 --- a/apps/maple-agent/app/Cargo.toml +++ b/apps/maple-agent/app/Cargo.toml @@ -24,6 +24,7 @@ desktop = [ "dep:rodio", "dep:spellbook", "dep:wayland-client", + "serve", ] # `maple-gpui acp`: the Agent Client Protocol server on stdio. acp = ["maple-agent/acp"] diff --git a/apps/maple-agent/app/src/desktop.rs b/apps/maple-agent/app/src/desktop.rs index d3847badd..dcb39985d 100644 --- a/apps/maple-agent/app/src/desktop.rs +++ b/apps/maple-agent/app/src/desktop.rs @@ -36,6 +36,8 @@ struct MapleApp { user_id: Option, /// Connections to the account's saved hosts; lives with the chat. hosts: Option>, + /// This window's host role for the signed-in account. + hosting: Option>, /// The chat screen is parked while settings is open so Back returns to /// it with its state intact. parked_chat: Option>, @@ -92,6 +94,9 @@ impl MapleApp { if let Some(hosts) = self.hosts.take() { hosts.shutdown(); } + if let Some(hosting) = self.hosting.take() { + hosting.stop(); + } if matches!(self.screen, Screen::Login(_)) { return; } @@ -152,6 +157,19 @@ impl MapleApp { } Err(error) => log::warn!("remote hosts are unavailable: {error}"), } + // The host role: listen only when the setting says so. + if let Some(previous) = self.hosting.take() { + previous.stop(); + } + let hosting = Arc::new(crate::hosting::HostingController::new( + backend.clone(), + backend.local_host(&user_id), + user_id.clone(), + )); + if self.settings.allow_remote_connections { + hosting.start(crate::hosting::DEFAULT_LISTEN); + } + self.hosting = Some(hosting); // The release check may have finished while the login screen was // up; the banner must not be lost with it. if let Some(info) = crate::update::available() { @@ -174,6 +192,7 @@ impl MapleApp { let host = backend.local_host(&user_id); let hosts = chat.read(cx).connected_hosts(); let manager = self.hosts.clone(); + let hosting = self.hosting.clone(); let settings = self.settings.clone(); let shortcut_snapshot = self.shortcuts.snapshot(); let screen = cx.new(|cx| { @@ -182,6 +201,7 @@ impl MapleApp { host, hosts, manager, + hosting, user_id, settings, shortcut_snapshot, @@ -516,6 +536,7 @@ pub fn run() { screen: Screen::Restoring, user_id: None, hosts: None, + hosting: None, parked_chat: None, settings: root_settings, shortcuts: shortcut_runtime, diff --git a/apps/maple-agent/app/src/hosting.rs b/apps/maple-agent/app/src/hosting.rs new file mode 100644 index 000000000..d84343cca --- /dev/null +++ b/apps/maple-agent/app/src/hosting.rs @@ -0,0 +1,351 @@ +//! This machine as a host, for the desktop app and `maple-gpui serve`. +//! +//! Both roles share one data root: the host key, the paired devices, the +//! pending pairing code, and the lock that keeps two servers off one root. +//! The desktop app starts hosting when "Allow remote connections" is on and +//! shows the state here in Settings; the command runs it in the foreground. + +use std::net::SocketAddr; +#[cfg(unix)] +use std::os::unix::ffi::OsStringExt as _; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +#[cfg(feature = "desktop")] +use std::sync::Mutex; + +use maple_agent::host::LocalHostBackend; +use maple_remote::devices::{DeviceStore, PairedDevice}; +use maple_remote::keys::StaticKey; +use maple_remote::net::{HostStores, serve_listener}; +use maple_remote::pairing::{PairingCode, PairingLimiter, PendingPairing, PendingPairingStore}; +use maple_remote::server::{HostIdentity, HostServer, HostServerConfig}; +use maple_remote::wire::HostInfo; +use tokio_util::sync::CancellationToken; + +use crate::backend::AgentBackend; + +/// Default listen address. Not 8080, which the proxy mode uses. +#[cfg(feature = "desktop")] +pub const DEFAULT_LISTEN: &str = "0.0.0.0:7130"; + +/// Where the host keeps its key, its devices, the pending code, and its +/// lock. Created owner-only on first use. +pub fn remote_dir() -> Result { + let dir = crate::backend::local_data_root().join("remote"); + std::fs::create_dir_all(&dir) + .map_err(|error| format!("cannot create {}: {error}", dir.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)); + } + Ok(dir) +} + +pub fn device_store(dir: &Path) -> DeviceStore { + DeviceStore::new(dir.join("devices.json")) +} + +pub fn pending_pairing_store(dir: &Path) -> PendingPairingStore { + PendingPairingStore::new(dir.join("pending_pairing.json")) +} + +/// What a running host records for `serve pair` to describe. +#[derive(serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ServeState { + pub listen: String, + pub name: String, + pub host_id: String, + pub pid: u32, +} + +fn state_path(dir: &Path) -> PathBuf { + dir.join("serve.json") +} + +/// The running host's state, or `None` when there is none or the recorded +/// process is gone (a crash leaves the file behind). +pub fn read_state(dir: &Path) -> Option { + let bytes = std::fs::read(state_path(dir)).ok()?; + let state: ServeState = serde_json::from_slice(&bytes).ok()?; + process_is_alive(state.pid).then_some(state) +} + +#[cfg(target_os = "linux")] +fn process_is_alive(pid: u32) -> bool { + Path::new(&format!("/proc/{pid}")).exists() +} + +#[cfg(not(target_os = "linux"))] +fn process_is_alive(_pid: u32) -> bool { + true +} + +/// Tell systemd how the service is doing, when it asked (`NOTIFY_SOCKET` +/// set). Silent everywhere else. Only the main process may report, so the +/// caller is the `serve` command itself. +pub fn sd_notify(state: &str) { + #[cfg(unix)] + { + let Some(socket) = std::env::var_os("NOTIFY_SOCKET") else { + return; + }; + let mut path = socket.into_encoded_bytes(); + // An abstract socket is written with a leading `@`; the address + // needs a NUL there. + if path.first() == Some(&b'@') { + path[0] = 0; + } + let Ok(socket) = std::os::unix::net::UnixDatagram::unbound() else { + return; + }; + let sent = if path.first() == Some(&0) { + #[cfg(target_os = "linux")] + { + use std::os::linux::net::SocketAddrExt as _; + std::os::unix::net::SocketAddr::from_abstract_name(&path[1..]) + .and_then(|address| socket.send_to_addr(state.as_bytes(), &address)) + } + #[cfg(not(target_os = "linux"))] + { + Err(std::io::Error::other("abstract sockets are Linux only")) + } + } else { + socket.send_to( + state.as_bytes(), + std::path::PathBuf::from(std::ffi::OsString::from_vec(path)), + ) + }; + if let Err(error) = sent { + log::debug!("sd_notify failed: {error}"); + } + } + #[cfg(not(unix))] + let _ = state; +} + +/// Publish a fresh pairing code for the running host to accept. +pub fn publish_pairing_code() -> Result<(PairingCode, PendingPairing), String> { + let dir = remote_dir()?; + let code = PairingCode::generate(); + let pending = pending_pairing_store(&dir).publish(&code)?; + Ok((code, pending)) +} + +pub fn list_devices() -> Result, String> { + device_store(&remote_dir()?).list() +} + +pub fn revoke_device(device: &str) -> Result { + device_store(&remote_dir()?).revoke(device) +} + +/// A running host: its listener and the lock on the data root. Dropping +/// it stops nothing; call [`Hosting::stop`]. +pub struct Hosting { + pub listen: SocketAddr, + pub host_id: String, + pub name: String, + shutdown: CancellationToken, + dir: PathBuf, + _lock: std::fs::File, +} + +impl Hosting { + /// Bind `listen` and serve the local host of `user_id` on the backend + /// runtime until [`Self::stop`]. Fails when another host holds the + /// data root or the address cannot be bound. + pub fn start( + backend: &Arc, + host: Arc, + user_id: &str, + listen: &str, + name: String, + ) -> Result { + let dir = remote_dir()?; + // One server per data root: two would race the runtime's own + // account state and the pairing file. + let lock_path = dir.join("serve.lock"); + let lock = std::fs::File::create(&lock_path) + .map_err(|error| format!("cannot open {}: {error}", lock_path.display()))?; + lock.try_lock().map_err(|_| { + "another Maple host already runs on this machine (`maple-gpui serve` or another window)" + .to_string() + })?; + + let key = StaticKey::load_or_create(&dir.join("host_key.json"))?; + let devices = Arc::new(device_store(&dir)); + let pending = Arc::new(pending_pairing_store(&dir)); + let hook_devices = Arc::clone(&devices); + let config = HostServerConfig { + on_client_hello: Some(Arc::new(move |hello| { + if let Err(error) = hook_devices.touch( + &hello.device.public_key, + &hello.device.name, + hello.device.user_id.as_deref(), + ) { + log::warn!("cannot record the device: {error}"); + } + })), + ..Default::default() + }; + let identity = HostIdentity { + app_version: env!("CARGO_PKG_VERSION").to_string(), + pcr_environment: format!( + "{:?}", + maple_agent::open_secret_config::configured_pcr0_environment()? + ), + }; + let host_id = key.public_id(); + let info = HostInfo { + id: host_id.clone(), + name: name.clone(), + user_id: Some(user_id.to_string()), + }; + let server = HostServer::new(host.clone(), info, identity, config); + let listen_text = listen.to_string(); + let runtime = backend.runtime_handle(); + let listener = runtime.block_on(async { + host.apply_saved_harness().await?; + tokio::net::TcpListener::bind(&listen_text) + .await + .map_err(|error| format!("cannot listen on {listen_text}: {error}")) + })?; + let local = listener.local_addr().map_err(|error| error.to_string())?; + maple_agent::private_file::write_private_json( + &state_path(&dir), + &ServeState { + listen: local.to_string(), + name: name.clone(), + host_id: host_id.clone(), + pid: std::process::id(), + }, + ) + .map_err(|error| format!("cannot write the serve state: {error}"))?; + let shutdown = CancellationToken::new(); + let stores = Arc::new(HostStores { + key, + devices, + pending_pairing: pending, + limiter: PairingLimiter::default(), + }); + runtime.spawn(serve_listener(listener, server, stores, shutdown.clone())); + Ok(Self { + listen: local, + host_id, + name, + shutdown, + dir, + _lock: lock, + }) + } + + pub fn stop(&self) { + self.shutdown.cancel(); + let _ = std::fs::remove_file(state_path(&self.dir)); + } + + /// Resolves when the listener stopped. + pub async fn stopped(&self) { + self.shutdown.cancelled().await; + } +} + +/// Where the desktop app's hosting stands. +#[cfg(feature = "desktop")] +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HostingStatus { + Off, + Listening { listen: String, host_id: String }, + Failed(String), +} + +/// The desktop app's host role: starts and stops hosting for the signed-in +/// account and answers Settings. +#[cfg(feature = "desktop")] +pub struct HostingController { + backend: Arc, + host: Arc, + user_id: String, + state: Mutex<(Option, Option)>, +} + +#[cfg(feature = "desktop")] +impl HostingController { + pub fn new(backend: Arc, host: Arc, user_id: String) -> Self { + Self { + backend, + host, + user_id, + state: Mutex::new((None, None)), + } + } + + pub fn status(&self) -> HostingStatus { + let state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + match (&state.0, &state.1) { + (Some(hosting), _) => HostingStatus::Listening { + listen: hosting.listen.to_string(), + host_id: hosting.host_id.clone(), + }, + (None, Some(error)) => HostingStatus::Failed(error.clone()), + (None, None) => HostingStatus::Off, + } + } + + /// Start hosting on `listen`. Already running is fine. + pub fn start(&self, listen: &str) -> HostingStatus { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if state.0.is_none() { + match Hosting::start( + &self.backend, + Arc::clone(&self.host), + &self.user_id, + listen, + crate::env::hostname(), + ) { + Ok(hosting) => { + log::info!( + "hosting as {} ({}) on {}", + hosting.name, + hosting.host_id, + hosting.listen + ); + state.0 = Some(hosting); + state.1 = None; + } + Err(error) => { + log::warn!("hosting did not start: {error}"); + state.1 = Some(error); + } + } + } + drop(state); + self.status() + } + + pub fn stop(&self) { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(hosting) = state.0.take() { + hosting.stop(); + } + state.1 = None; + } +} + +#[cfg(feature = "desktop")] +impl Drop for HostingController { + fn drop(&mut self) { + self.stop(); + } +} diff --git a/apps/maple-agent/app/src/main.rs b/apps/maple-agent/app/src/main.rs index bb2bf2243..35d190ea6 100644 --- a/apps/maple-agent/app/src/main.rs +++ b/apps/maple-agent/app/src/main.rs @@ -11,6 +11,8 @@ mod billing; #[cfg(feature = "desktop")] mod desktop; mod env; +#[cfg(feature = "serve")] +mod hosting; #[cfg(feature = "desktop")] mod hosts; #[cfg(feature = "desktop")] diff --git a/apps/maple-agent/app/src/serve.rs b/apps/maple-agent/app/src/serve.rs index f9fc145da..21ade6f44 100644 --- a/apps/maple-agent/app/src/serve.rs +++ b/apps/maple-agent/app/src/serve.rs @@ -51,55 +51,11 @@ pub use enabled::run; #[cfg(feature = "serve")] mod enabled { - use std::path::PathBuf; use std::sync::Arc; - use maple_remote::devices::DeviceStore; - use maple_remote::keys::StaticKey; - use maple_remote::net::{HostStores, serve_listener}; - use maple_remote::pairing::{CODE_TTL, PairingCode, PairingLimiter, PendingPairingStore}; - use maple_remote::server::{HostIdentity, HostServer, HostServerConfig}; - use maple_remote::wire::HostInfo; - use tokio_util::sync::CancellationToken; - use super::{DevicesCommand, ServeArgs, ServeCommand}; use crate::backend::AgentBackend; - - /// Where the host keeps its key, its devices, and the pending code. - pub(crate) fn remote_dir() -> PathBuf { - crate::backend::local_data_root().join("remote") - } - - fn ensure_remote_dir() -> Result { - let dir = remote_dir(); - std::fs::create_dir_all(&dir) - .map_err(|error| format!("cannot create {}: {error}", dir.display()))?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt as _; - let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)); - } - Ok(dir) - } - - /// What a running server records for `pair` to describe. - #[derive(serde::Serialize, serde::Deserialize)] - #[serde(rename_all = "camelCase")] - struct ServeState { - listen: String, - name: String, - host_id: String, - pid: u32, - } - - fn state_path(dir: &std::path::Path) -> PathBuf { - dir.join("serve.json") - } - - fn read_state(dir: &std::path::Path) -> Option { - let bytes = std::fs::read(state_path(dir)).ok()?; - serde_json::from_slice(&bytes).ok() - } + use crate::hosting::{self, Hosting}; pub fn run(args: ServeArgs) -> Result<(), String> { match args.command.clone() { @@ -115,16 +71,6 @@ mod enabled { } fn run_server(args: ServeArgs) -> Result<(), String> { - let dir = ensure_remote_dir()?; - // One server per data root: two would race the runtime's own - // account state and the pairing file. - let lock_path = dir.join("serve.lock"); - let lock = std::fs::File::create(&lock_path) - .map_err(|error| format!("cannot open {}: {error}", lock_path.display()))?; - lock.try_lock() - .map_err(|_| "another `maple-gpui serve` already holds this data root".to_string())?; - - let key = StaticKey::load_or_create(&dir.join("host_key.json"))?; let backend = Arc::new(AgentBackend::new(crate::configured_api_url())?); let user_id = backend.restore_now().ok_or_else(|| { "No saved Maple sign-in on this machine. Run `maple-gpui login` first.".to_string() @@ -132,102 +78,72 @@ mod enabled { crate::adopt_legacy_session_defaults(&backend, &user_id); let host = backend.local_host(&user_id); let name = args.name.clone().unwrap_or_else(crate::env::hostname); + let hosting = Hosting::start(&backend, host, &user_id, &args.listen, name)?; + eprintln!( + "Serving as host \"{}\" ({}) on {}.", + hosting.name, hosting.host_id, hosting.listen + ); + eprintln!("Pair a device with `maple-gpui serve pair`. Stop with Ctrl-C."); + // Under systemd (`Type=notify`) the unit is up once the port is + // bound, not when the process forked. + hosting::sd_notify(&format!( + "READY=1\nSTATUS=Serving as {} on {}", + hosting.name, hosting.listen + )); + backend.runtime_handle().block_on(async { + tokio::select! { + _ = tokio::signal::ctrl_c() => {} + _ = terminate() => {} + _ = hosting.stopped() => {} + } + }); + hosting::sd_notify("STOPPING=1"); + hosting.stop(); + Ok(()) + } - let devices = Arc::new(DeviceStore::new(dir.join("devices.json"))); - let pending = Arc::new(PendingPairingStore::new(dir.join("pending_pairing.json"))); - let hook_devices = Arc::clone(&devices); - let config = HostServerConfig { - on_client_hello: Some(Arc::new(move |hello| { - if let Err(error) = hook_devices.touch( - &hello.device.public_key, - &hello.device.name, - hello.device.user_id.as_deref(), - ) { - log::warn!("cannot record the device: {error}"); + /// Resolves on SIGTERM, which `systemctl stop` sends. Never resolves + /// where there is no such signal. + async fn terminate() { + #[cfg(unix)] + { + match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { + Ok(mut signal) => { + signal.recv().await; } - })), - ..Default::default() - }; - let identity = HostIdentity { - app_version: env!("CARGO_PKG_VERSION").to_string(), - pcr_environment: format!( - "{:?}", - maple_agent::open_secret_config::configured_pcr0_environment()? - ), - }; - let info = HostInfo { - id: key.public_id(), - name: name.clone(), - user_id: Some(user_id.clone()), - }; - let server = HostServer::new(host.clone(), info, identity, config); - let host_id = key.public_id(); - let listen = args.listen.clone(); - let state_dir = dir.clone(); - let result = backend.runtime_handle().block_on(async move { - host.apply_saved_harness().await?; - let listener = tokio::net::TcpListener::bind(&listen) - .await - .map_err(|error| format!("cannot listen on {listen}: {error}"))?; - let local = listener.local_addr().map_err(|error| error.to_string())?; - maple_agent::private_file::write_private_json( - &state_path(&state_dir), - &ServeState { - listen: local.to_string(), - name: name.clone(), - host_id: host_id.clone(), - pid: std::process::id(), - }, - ) - .map_err(|error| format!("cannot write the serve state: {error}"))?; - eprintln!("Serving as host \"{name}\" ({host_id}) on {local}."); - eprintln!("Pair a device with `maple-gpui serve pair`. Stop with Ctrl-C."); - let shutdown = CancellationToken::new(); - let on_signal = shutdown.clone(); - tokio::spawn(async move { - if tokio::signal::ctrl_c().await.is_ok() { - on_signal.cancel(); + Err(error) => { + log::warn!("cannot listen for SIGTERM: {error}"); + std::future::pending::<()>().await; } - }); - let stores = Arc::new(HostStores { - key, - devices, - pending_pairing: pending, - limiter: PairingLimiter::default(), - }); - serve_listener(listener, server, stores, shutdown).await - }); - let _ = std::fs::remove_file(state_path(&dir)); - drop(lock); - result + } + } + #[cfg(not(unix))] + std::future::pending::<()>().await; } fn publish_code() -> Result<(), String> { - let dir = ensure_remote_dir()?; - let pending = PendingPairingStore::new(dir.join("pending_pairing.json")); - let code = PairingCode::generate(); - pending.publish(&code)?; + let (code, _pending) = hosting::publish_pairing_code()?; // The code goes to stdout so it can be piped; guidance to stderr. println!("{}", code.display()); eprintln!( "Pairing code published. It admits one device and expires in {} minutes.", - CODE_TTL.as_secs() / 60 + maple_remote::pairing::CODE_TTL.as_secs() / 60 ); - match read_state(&dir) { + match hosting::read_state(&hosting::remote_dir()?) { Some(state) => eprintln!( "In the Maple app, add host \"{}\" at {} and enter the code.", state.name, state.listen ), None => eprintln!( - "The host is not running here. Start `maple-gpui serve` before the code expires." + "No host is running here. Start `maple-gpui serve` or turn on remote \ + connections in the app before the code expires." ), } Ok(()) } fn list_devices() -> Result<(), String> { - let dir = ensure_remote_dir()?; - let devices = DeviceStore::new(dir.join("devices.json")).list()?; + let devices = hosting::list_devices()?; if devices.is_empty() { eprintln!("No paired devices. Publish a code with `maple-gpui serve pair`."); return Ok(()); @@ -244,8 +160,7 @@ mod enabled { } fn revoke_device(device: &str) -> Result<(), String> { - let dir = ensure_remote_dir()?; - let removed = DeviceStore::new(dir.join("devices.json")).revoke(device)?; + let removed = hosting::revoke_device(device)?; eprintln!( "Revoked {} ({}). A live connection from it ends within seconds.", removed.name, removed.public_key diff --git a/apps/maple-agent/app/src/settings.rs b/apps/maple-agent/app/src/settings.rs index 1a217d487..79d245a96 100644 --- a/apps/maple-agent/app/src/settings.rs +++ b/apps/maple-agent/app/src/settings.rs @@ -64,6 +64,10 @@ pub struct AppSettings { /// is [`maple_agent::host::HostId::LOCAL`]. #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] pub hosts: std::collections::BTreeMap, + /// Whether this window also serves the account's runtime to paired + /// devices. Off until asked: a fresh install never listens. + #[serde(default)] + pub allow_remote_connections: bool, /// Whether run completion, permissions, and questions raise desktop /// notifications while the window is not focused. #[serde(default = "default_desktop_notifications")] @@ -353,6 +357,7 @@ impl Default for AppSettings { application_vim_enabled: false, shortcut_overrides: std::collections::BTreeMap::new(), hosts: std::collections::BTreeMap::new(), + allow_remote_connections: false, desktop_notifications: default_desktop_notifications(), reduce_motion: false, window: None, diff --git a/apps/maple-agent/app/src/ui/settings.rs b/apps/maple-agent/app/src/ui/settings.rs index cbaf221a5..d4e6b1d74 100644 --- a/apps/maple-agent/app/src/ui/settings.rs +++ b/apps/maple-agent/app/src/ui/settings.rs @@ -19,6 +19,7 @@ use crate::ui::icons::icon; use crate::ui::text_input::TextInput; use crate::backend::AgentBackend; +use crate::hosting::{HostingController, HostingStatus}; use crate::settings::{self, AppSettings, PermissionMode}; use crate::shortcuts::{ ShortcutConflict, ShortcutConflictKind, ShortcutContextOverlap, ShortcutOverrides, @@ -27,6 +28,7 @@ use crate::shortcuts::{ use crate::ui::theme; use crate::ui::widgets; use maple_agent::host::{HostBackend, HostId, HostSessionDefaults, UsageSummary}; +use maple_remote::devices::PairedDevice; use maple_remote::hosts::SavedHost; use maple_remote::manager::HostManager; use maple_remote::pairing::PairingCode; @@ -153,6 +155,13 @@ pub struct SettingsScreen { hosts: Vec, /// Pairs, renames, and removes hosts. Absent in tests. manager: Option>, + /// This window's host role. Absent in tests. + hosting: Option>, + hosting_status: HostingStatus, + /// The pairing code this window published, shown until it expires. + pairing_code: Option<(String, u64)>, + /// Devices paired with this machine as a host. + paired_devices: Vec, /// The saved host list the Hosts section shows. saved_hosts: Vec, /// The add-host form. @@ -285,6 +294,7 @@ impl SettingsScreen { host: Arc, hosts: Vec, manager: Option>, + hosting: Option>, user_id: String, settings: AppSettings, shortcut_snapshot: ShortcutSnapshot, @@ -292,6 +302,14 @@ impl SettingsScreen { cx: &mut Context, ) -> Self { let defaults = HostSessionDefaults::default(); + let hosting_status = hosting + .as_ref() + .map(|hosting| hosting.status()) + .unwrap_or(HostingStatus::Off); + let paired_devices = hosting + .as_ref() + .and_then(|_| crate::hosting::list_devices().ok()) + .unwrap_or_default(); let host_field = |placeholder: &str, index: isize, cx: &mut Context| { let application_vim_enabled = settings.application_vim_enabled; let placeholder = placeholder.to_string(); @@ -353,6 +371,10 @@ impl SettingsScreen { host, hosts, manager, + hosting, + hosting_status, + pairing_code: None, + paired_devices, saved_hosts, host_address, host_code, @@ -807,9 +829,36 @@ impl SettingsScreen { cx.notify(); } - /// Whether a host is connected right now. + /// Whether a host is connected right now: known at open, or connected + /// since (a host paired from this screen). fn host_is_online(&self, id: &str) -> bool { self.hosts.iter().any(|host| host.id.as_str() == id) + || self + .manager + .as_ref() + .is_some_and(|manager| manager.is_online(id)) + } + + /// Re-render shortly, so a host that connects after pairing shows as + /// online without the user leaving the screen. + fn refresh_hosts_soon(&self, cx: &mut Context) { + let task = cx.spawn(async move |this, cx| { + for _ in 0..4 { + cx.background_executor() + .timer(std::time::Duration::from_millis(750)) + .await; + if this + .update(cx, |this, cx| { + this.reload_saved_hosts(); + cx.notify(); + }) + .is_err() + { + return; + } + } + }); + crate::ui::task::retain(&self.bridged_tasks, task); } fn reload_saved_hosts(&mut self) { @@ -865,6 +914,7 @@ impl SettingsScreen { input.update(cx, |input, cx| input.set_text("", cx)); } this.reload_saved_hosts(); + this.refresh_hosts_soon(cx); } Err(message) => this.hosts_notice = Some(message), } @@ -882,6 +932,167 @@ impl SettingsScreen { cx.notify(); } + /// Turn this window's host role on or off. The setting persists; the + /// controller starts or stops now and reports. + fn toggle_remote_connections(&mut self, cx: &mut Context) { + let next = !self.settings.allow_remote_connections; + self.edit_setting(move |settings| settings.allow_remote_connections = next, cx); + let Some(hosting) = self.hosting.clone() else { + return; + }; + self.hosting_status = if next { + hosting.start(crate::hosting::DEFAULT_LISTEN) + } else { + hosting.stop(); + HostingStatus::Off + }; + cx.notify(); + } + + fn generate_pairing_code(&mut self, cx: &mut Context) { + match crate::hosting::publish_pairing_code() { + Ok((code, pending)) => { + self.pairing_code = Some((code.display(), pending.expires_ms)); + self.hosts_notice = None; + } + Err(message) => self.hosts_notice = Some(message), + } + cx.notify(); + } + + fn revoke_device(&mut self, key: &str, cx: &mut Context) { + if let Err(message) = crate::hosting::revoke_device(key) { + self.hosts_notice = Some(message); + } + self.paired_devices = crate::hosting::list_devices().unwrap_or_default(); + cx.notify(); + } + + /// This machine's host role: the toggle, where it listens, the pairing + /// code, and the paired devices. + fn render_remote_access(&self, cx: &mut Context) -> Div { + let mut pane = div() + .flex() + .flex_col() + .gap_4() + .child(section_title("Remote access")) + .child(toggle_row( + "Allow remote connections", + "Serve this machine's tasks to paired devices on the LAN or a Tailscale \ + network. Pairing is the only gate; traffic is end-to-end encrypted.", + self.settings.allow_remote_connections, + cx.listener(|this, _event, _window, cx| { + this.toggle_remote_connections(cx); + }), + )); + let status = match &self.hosting_status { + HostingStatus::Off => "Not listening.".to_string(), + HostingStatus::Listening { listen, host_id } => { + let short: String = host_id.chars().take(10).collect(); + format!( + "Listening on {listen} as \"{}\" (key {short}\u{2026}). Devices reach it at \ + this machine's LAN or Tailscale address and that port.", + crate::env::hostname() + ) + } + HostingStatus::Failed(error) => format!("Not listening: {error}"), + }; + pane = pane.child( + div() + .text_sm() + .text_color(gpui::rgb(theme::text_muted())) + .child(status), + ); + if self.settings.allow_remote_connections { + pane = pane.child( + div() + .flex() + .items_center() + .gap_3() + .child( + widgets::secondary_button("generate-pairing-code") + .on_click(cx.listener(|this, _event, _window, cx| { + this.generate_pairing_code(cx); + })) + .child("Generate pairing code"), + ) + .when_some(self.pairing_code.as_ref(), |row, (code, _)| { + row.child( + div() + .font_weight(gpui::FontWeight::SEMIBOLD) + .child(code.clone()), + ) + .child( + div() + .text_xs() + .text_color(gpui::rgb(theme::text_muted())) + .child("valid for 5 minutes, one device"), + ) + }), + ); + } + pane = pane.child(section_title("Paired devices")); + if self.paired_devices.is_empty() { + pane = pane.child( + div() + .text_sm() + .text_color(gpui::rgb(theme::text_muted())) + .child("No devices have paired with this machine."), + ); + } + for device in &self.paired_devices { + let key = device.public_key.clone(); + let short: String = device.public_key.chars().take(10).collect(); + pane = pane.child( + widgets::card_row() + .id(gpui::SharedString::from(format!( + "device-{}", + device.public_key + ))) + .flex() + .items_center() + .justify_between() + .gap_4() + .child( + div() + .flex() + .flex_col() + .min_w_0() + .child( + div() + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(gpui::rgb(theme::text_primary())) + .child(device.name.clone()), + ) + .child( + div() + .text_xs() + .text_color(gpui::rgb(theme::text_muted())) + .child(format!( + "key {short}\u{2026}{}", + device + .user_id + .as_deref() + .map(|user| format!(" \u{b7} account {user}")) + .unwrap_or_default() + )), + ), + ) + .child( + widgets::ghost_button(gpui::SharedString::from(format!( + "revoke-device-{}", + device.public_key + ))) + .on_click(cx.listener(move |this, _event, _window, cx| { + this.revoke_device(&key, cx); + })) + .child("Revoke"), + ), + ); + } + pane + } + fn render_hosts_pane(&self, cx: &mut Context) -> Div { let mut pane = div() .flex() @@ -936,6 +1147,7 @@ impl SettingsScreen { .child( div() .font_weight(gpui::FontWeight::MEDIUM) + .text_color(gpui::rgb(theme::text_primary())) .child(host.name.clone()), ) .child( @@ -1001,7 +1213,7 @@ impl SettingsScreen { .child(notice.clone()), ); } - pane + pane.child(self.render_remote_access(cx)) } /// The host selector row, shown on host-scoped sections when more than @@ -3569,7 +3781,8 @@ fn toggle_row( )) } -/// A label above a text input, for short forms. +/// A label above a text input, for short forms. The input frame carries +/// the themed text and background colors; a bare input inherits none. fn labeled_input(label: &str, input: Entity) -> Div { div() .flex() @@ -3578,10 +3791,11 @@ fn labeled_input(label: &str, input: Entity) -> Div { .child( div() .text_xs() - .text_color(gpui::rgb(theme::text_muted())) + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(gpui::rgb(theme::text_secondary())) .child(label.to_string()), ) - .child(input) + .child(widgets::input_frame().text_sm().child(input)) } fn info_row(label: &str, value: String) -> Div { diff --git a/apps/maple-agent/app/src/ui/settings/navigation.rs b/apps/maple-agent/app/src/ui/settings/navigation.rs index 7dd1ed6b8..61bb53c27 100644 --- a/apps/maple-agent/app/src/ui/settings/navigation.rs +++ b/apps/maple-agent/app/src/ui/settings/navigation.rs @@ -693,6 +693,7 @@ mod tests { backend.local_host("user"), Vec::new(), None, + None, "user".to_string(), crate::settings::AppSettings::default(), crate::shortcuts::ShortcutSnapshot { @@ -781,6 +782,7 @@ mod tests { backend.local_host("user"), Vec::new(), None, + None, "user".to_string(), crate::settings::AppSettings { application_vim_enabled: false, @@ -857,6 +859,7 @@ mod tests { backend.local_host("user"), Vec::new(), None, + None, "user".to_string(), crate::settings::AppSettings { application_vim_enabled: true, @@ -924,6 +927,7 @@ mod tests { backend.local_host("user"), Vec::new(), None, + None, "user".to_string(), crate::settings::AppSettings { application_vim_enabled: true, @@ -1003,6 +1007,7 @@ mod tests { backend.local_host("user"), Vec::new(), None, + None, "user".to_string(), crate::settings::AppSettings { application_vim_enabled: application_vim, diff --git a/apps/maple-agent/crates/maple-remote/src/manager.rs b/apps/maple-agent/crates/maple-remote/src/manager.rs index 296c41791..6fd6f6a9c 100644 --- a/apps/maple-agent/crates/maple-remote/src/manager.rs +++ b/apps/maple-agent/crates/maple-remote/src/manager.rs @@ -7,7 +7,7 @@ //! starts its connector with the connection already open. Everything the //! UI needs arrives as [`HostManagerEvent`]s on one channel. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -72,6 +72,9 @@ pub struct HostManager { config: ClientConfig, events: mpsc::UnboundedSender, connectors: Mutex>, + /// Hosts with a live connection right now, for callers that did not + /// watch the event stream (the settings screen). + online: Mutex>, shutdown: CancellationToken, } @@ -91,6 +94,7 @@ impl HostManager { config, events, connectors: Mutex::new(HashMap::new()), + online: Mutex::new(HashSet::new()), shutdown: CancellationToken::new(), }), receiver, @@ -105,6 +109,26 @@ impl HostManager { self.device.public_id() } + /// Whether `id` has a live connection right now. + pub fn is_online(&self, id: &str) -> bool { + self.online + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .contains(id) + } + + fn set_online(&self, id: &str, online: bool) { + let mut set = self + .online + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if online { + set.insert(id.to_string()); + } else { + set.remove(id); + } + } + /// Start a connector for every saved host. Must run inside a Tokio /// runtime. pub fn start(self: &Arc) { @@ -192,6 +216,7 @@ impl HostManager { { token.cancel(); } + self.set_online(id, false); let name = self .store .get(id)? @@ -258,6 +283,7 @@ impl HostManager { match backend { Ok(backend) => { attempt = 0; + self.set_online(&host.id, true); self.emit(HostManagerEvent::Status { host: id.clone(), name: name.clone(), @@ -265,6 +291,7 @@ impl HostManager { backend: Some(Arc::clone(&backend)), }); let reason = self.forward_events(&id, &backend, &cancel).await; + self.set_online(&host.id, false); backend.close().await; self.emit(HostManagerEvent::Status { host: id.clone(), diff --git a/apps/maple-agent/docs/remote-development.md b/apps/maple-agent/docs/remote-development.md index 4682d351f..fc69face7 100644 --- a/apps/maple-agent/docs/remote-development.md +++ b/apps/maple-agent/docs/remote-development.md @@ -1,12 +1,9 @@ # Remote development -Status: design and implementation plan, agreed 2026-09-19. Steps 1 to 4 -of the implementation order are built: the seam, the protocol crate with -its server and remote client, the transport with Noise, pairing, the -device store, and the `serve` command, and the client with saved hosts, -reconnect, the merged sidebar, and the Hosts settings. The desktop app -serving (step 5) is not. When code and this document disagree, the code -wins; update this document in the same change. +Status: design agreed 2026-09-19; all five implementation steps are built. +What remains is listed under "Out of scope for this release" and in the +"As built" and "Still to do" notes below. When code and this document +disagree, the code wins; update this document in the same change. A Maple host runs the agent runtime and serves it over the network. A Maple client is the desktop app, which drives one or more hosts. The first release @@ -505,6 +502,18 @@ Hosts section for the client role, with add, rename, and remove, and a Remote access section for the host role, with the listen toggle, addresses, pairing code, and paired devices with revoke. +### Desktop hosting + +As built: `app/src/hosting.rs` holds the shared host role. `Hosting::start` +takes the data-root lock, binds, and serves the account's local host on the +backend runtime; the `serve` command runs it in the foreground, and the +window runs it behind the "Allow remote connections" setting, off by +default, which takes effect at once from Settings > Hosts. That section +also publishes pairing codes through the same pending file the CLI uses, +and lists and revokes paired devices. The command and the window share the +host key, the device list, and the lock, so only one of them serves at a +time; the other reports who holds the root. + ## Host CLI ``` From b5650ff812205c2d8e6f3d881a265efdcb7ee133 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Sat, 19 Sep 2026 18:31:39 -0500 Subject: [PATCH 09/37] Pick hosts and projects through one flow Choosing a project on a remote host opened the laptop's native folder picker, which can only produce local paths the host rejects. Which host a new task would run on was only implied by the sidebar filter and the selected task, and the header could name a task from the sidebar while the pane showed the new-task hero. Every host now shares one project picker: a dialog with a search box over the host's recent projects and its folder suggestions, an "Open this path" row when the text looks like a path, a "Browse..." row for the system picker on the local host, and focus in the search box on open. The header gains a host chip once more than one host is known, placed before the project chip: a status dot and the target host's name, with a dropdown to switch hosts or reach the Hosts settings. The header title follows what the pane shows, and the boot auto-select considers only the target host's tasks. The flow follows Paseo's add-project overlay and host picker. Co-Authored-By: Claude Fable 5.1 --- apps/maple-agent/README.md | 14 +- apps/maple-agent/app/src/ui/chat/composer.rs | 382 ++++++++++++++++--- apps/maple-agent/app/src/ui/chat/mod.rs | 339 +++++++++++++++- apps/maple-agent/app/src/ui/chat/sidebar.rs | 24 +- apps/maple-agent/app/src/ui/chat/tests.rs | 113 ++++++ apps/maple-agent/docs/remote-development.md | 10 +- 6 files changed, 801 insertions(+), 81 deletions(-) diff --git a/apps/maple-agent/README.md b/apps/maple-agent/README.md index 6a5e426e6..2b107340c 100644 --- a/apps/maple-agent/README.md +++ b/apps/maple-agent/README.md @@ -138,11 +138,15 @@ Tasks can run on another machine. Settings > Hosts pairs this device with a host running `maple-gpui serve` (address plus the one-time code the host printed) and lists the paired hosts with their connection state. Saved hosts connect at launch and reconnect with backoff. Their tasks join the -sidebar, badged with the host name once more than one host is known; the -project switcher filters by host, and new tasks go to the filtered host or -to the selected task's host. Host-scoped settings (defaults, system prompt, -integrations, usage) get a host selector when more than one host is -connected. Offline hosts stay listed without their tasks until they return. +sidebar, badged with the host name once more than one host is known, and +the project switcher filters by host. A host chip in the header, shown once +more than one host is known, names the host new tasks run on and switches +it. Choosing a project opens one picker for every host: a search box over +the host's recent projects and folders, a row that opens a typed path, and +on the local host a row for the system folder picker. Host-scoped settings +(defaults, system prompt, integrations, usage) get a host selector when +more than one host is connected. Offline hosts stay listed without their +tasks until they return. ### Integrations preview diff --git a/apps/maple-agent/app/src/ui/chat/composer.rs b/apps/maple-agent/app/src/ui/chat/composer.rs index 64f007155..3cec5b872 100644 --- a/apps/maple-agent/app/src/ui/chat/composer.rs +++ b/apps/maple-agent/app/src/ui/chat/composer.rs @@ -11,8 +11,8 @@ use super::cache::MarkdownKind; use super::commands::ChatCommand; use super::transcript::{render_plan_row, render_subagent_row}; use super::{ - COMPOSER_PLACEHOLDER, ChatScreen, DraftImage, OpenSettingsSection, ROOT_MENU_RECENTS, - SIDE_THREAD_PLACEHOLDER, SIDEBAR_COLLAPSED_INSET, Section, + COMPOSER_PLACEHOLDER, ChatScreen, DraftImage, OpenSettingsSection, PickerRow, PickerRowKind, + ROOT_MENU_RECENTS, SIDE_THREAD_PLACEHOLDER, SIDEBAR_COLLAPSED_INSET, Section, }; use crate::ui::icons::{icon, spinner}; use crate::ui::markdown; @@ -55,6 +55,24 @@ impl ChatScreen { .text_color(gpui::rgb(theme::text_primary())) .child(title), ) + .when(self.hosts.len() > 1, |row| { + // Which host new tasks go to. One host needs no chip. + let online = self.target_host_online(); + let (frame, color) = chip_frame("host-picker", self.host_menu_open, false); + row.child( + frame + .flex_none() + .child(status_dot(online)) + .child(div().whitespace_nowrap().child(self.target_host_name())) + .child(icon("chevron-down", px(14.), color)) + .on_mouse_down(gpui::MouseButton::Left, |_event, _window, cx| { + cx.stop_propagation(); + }) + .on_click(cx.listener(|this, _event, _window, cx| { + this.toggle_host_menu(cx); + })), + ) + }) .child( chip( "root-picker", @@ -188,51 +206,6 @@ impl ChatScreen { })) .child("New project…"), ); - if let Some(input) = self.root_input.clone() { - menu = menu - .child( - div() - .px_3() - .pb_1() - .text_xs() - .text_color(gpui::rgb(theme::text_muted())) - .child("Or type an absolute path:"), - ) - .child( - div() - .flex() - .items_center() - .gap_2() - .px_3() - .pb_2() - .child(div().flex_1().child(input)) - .child( - div() - .id("root-apply") - .px_3() - .py_1() - .rounded(theme::RADIUS_SM) - .bg(gpui::rgb(theme::accent())) - .text_sm() - .font_weight(gpui::FontWeight::MEDIUM) - .text_color(gpui::rgb(theme::on_accent())) - .hover(|style| { - style.bg(gpui::rgb(theme::accent_hover())).cursor_pointer() - }) - .active(|style| style.bg(gpui::rgb(theme::send_bottom()))) - .on_click(cx.listener(|this, _event, _window, cx| { - if let Some(path) = this - .root_input - .as_ref() - .map(|input| input.read(cx).text()) - { - this.select_project_root(path, cx); - } - })) - .child("Go"), - ), - ); - } } Some( div() @@ -1291,6 +1264,304 @@ pub(super) fn slash_entries_for(token: &str, skills: &[AgentSlashCommand]) -> Ve /// One control in the composer chip row. `active` means its menu is /// open; `highlight` means the feature it toggles is on, shown in the /// accent so the two states never look alike. +/// A small live-state dot: green online, muted otherwise. +fn status_dot(online: bool) -> Div { + div() + .flex_none() + .size(px(8.)) + .rounded_full() + .bg(gpui::rgb(if online { + theme::status_success() + } else { + theme::text_muted() + })) +} + +impl ChatScreen { + /// The host chip's dropdown: every known host with its state, then a + /// way to the Hosts settings. + pub(super) fn render_host_menu(&self, cx: &mut Context) -> Option
{ + if !self.host_menu_open { + return None; + } + let mut menu = div() + .id("host-menu") + .occlude() + .flex() + .flex_col() + .w(px(300.)) + .py_1() + .rounded(theme::RADIUS_MD) + .bg(gpui::rgb(theme::bg_elevated())) + .border_1() + .border_color(gpui::rgb(theme::border())) + .shadow_md() + .on_mouse_down_out(cx.listener(|this, _event, _window, cx| { + this.host_menu_open = false; + cx.notify(); + })) + .child( + div() + .px_3() + .pt_1() + .pb_1() + .text_xs() + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(gpui::rgb(theme::text_muted())) + .child("NEW TASKS RUN ON"), + ); + for (id, name, online) in self.host_choices() { + let current = id == self.target_host; + let pick = id.clone(); + menu = menu.child( + div() + .id(SharedString::from(format!("host-menu-{id}"))) + .flex() + .items_center() + .gap_2() + .px_3() + .py_1p5() + .text_sm() + .text_color(gpui::rgb(if online { + theme::text_primary() + } else { + theme::text_muted() + })) + .hover(|style| style.bg(gpui::rgb(theme::bg_input())).cursor_pointer()) + .on_click(cx.listener(move |this, _event, _window, cx| { + this.pick_host(pick.clone(), cx); + })) + .child(status_dot(online)) + .child( + div() + .flex_1() + .min_w_0() + .line_clamp(1) + .text_ellipsis() + .child(name), + ) + .when(!online, |row| { + row.child( + div() + .text_xs() + .text_color(gpui::rgb(theme::text_muted())) + .child("offline"), + ) + }) + .when(current, |row| { + row.child(icon("check", px(14.), theme::accent())) + }), + ); + } + menu = menu + .child(div().h(px(1.)).mx_2().my_1().bg(gpui::rgb(theme::border()))) + .child( + div() + .id("host-menu-manage") + .px_3() + .py_1p5() + .text_sm() + .text_color(gpui::rgb(theme::text_secondary())) + .hover(|style| style.bg(gpui::rgb(theme::bg_input())).cursor_pointer()) + .on_click(cx.listener(|this, _event, _window, cx| { + this.host_menu_open = false; + cx.emit(OpenSettingsSection(Section::Hosts)); + })) + .child("Manage hosts\u{2026}"), + ); + Some( + div() + .absolute() + .top(px(40.)) + .left_4() + .when(self.sidebar_collapsed, |menu| { + menu.left(SIDEBAR_COLLAPSED_INSET) + }) + .child(menu), + ) + } + + /// The project picker: a centered dialog over the pane with a search + /// box, the rows it matched, and the keys that drive it. + pub(super) fn render_project_picker(&self, cx: &mut Context) -> gpui::Stateful
{ + let picker = self.project_picker.as_ref(); + let rows: Vec = picker.map(|picker| picker.rows.clone()).unwrap_or_default(); + let selected = picker.map(|picker| picker.selected).unwrap_or(0); + let searching = picker.is_some_and(|picker| !picker.query.trim().is_empty()); + let mut list = div() + .id("project-picker-rows") + .flex() + .flex_col() + .max_h(px(380.)) + .overflow_y_scroll(); + if rows.is_empty() { + list = list.child( + div() + .px_3() + .py_3() + .text_sm() + .text_color(gpui::rgb(theme::text_muted())) + .child(if searching { + "No folders match" + } else { + "No recent projects on this host yet; type a path" + }), + ); + } + for (index, row) in rows.iter().enumerate() { + let is_selected = index == selected; + let glyph = match row.kind { + PickerRowKind::Recent => "folder-open", + PickerRowKind::Suggestion => "folder", + PickerRowKind::OpenPath => "search", + PickerRowKind::Browse => "folder-open", + }; + list = list.child( + div() + .id(SharedString::from(format!("project-picker-row-{index}"))) + .flex() + .items_center() + .gap_3() + .px_3() + .py_2() + .rounded(theme::RADIUS_SM) + .when(is_selected, |row| row.bg(gpui::rgb(theme::bg_input()))) + .hover(|style| style.bg(gpui::rgb(theme::bg_input())).cursor_pointer()) + .on_click(cx.listener(move |this, _event, _window, cx| { + this.activate_picker_row(index, cx); + })) + .child(icon(glyph, px(16.), theme::text_muted())) + .child( + div() + .flex() + .flex_col() + .min_w_0() + .flex_1() + .child( + div() + .text_sm() + .text_color(gpui::rgb(theme::text_primary())) + .line_clamp(1) + .text_ellipsis() + .child(row.title.clone()), + ) + .when_some(row.subtitle.clone(), |col, subtitle| { + col.child( + div() + .text_xs() + .text_color(gpui::rgb(theme::text_muted())) + .line_clamp(1) + .text_ellipsis() + .child(subtitle), + ) + }), + ) + .when(is_selected, |row| { + row.child( + div() + .text_xs() + .text_color(gpui::rgb(theme::text_muted())) + .child("Enter"), + ) + }), + ); + } + let hint = |keys: &'static str, label: &'static str| { + div() + .flex() + .items_center() + .gap_1() + .text_xs() + .text_color(gpui::rgb(theme::text_muted())) + .child( + div() + .px_1() + .rounded(theme::RADIUS_SM) + .bg(gpui::rgb(theme::bg_input())) + .font_family(crate::assets::FONT_MONO) + .child(keys), + ) + .child(label) + }; + div() + .id("project-picker-backdrop") + .absolute() + .size_full() + .top_0() + .left_0() + .occlude() + .bg(theme::scrim()) + .flex() + .items_start() + .justify_center() + .pt(px(96.)) + .on_click(cx.listener(|this, _event, _window, cx| { + this.close_project_picker(cx); + })) + .child( + div() + .id("project-picker") + .role(gpui::Role::Dialog) + .aria_label("Choose a project") + .w(px(640.)) + .max_w_full() + .rounded(theme::RADIUS_XL) + .shadow_lg() + .bg(gpui::rgb(theme::bg_elevated())) + .border_1() + .border_color(gpui::rgb(theme::border())) + .flex() + .flex_col() + .on_click(|_event, _window, cx| cx.stop_propagation()) + .child( + div() + .flex() + .flex_col() + .gap_2() + .px_4() + .pt_4() + .pb_2() + .child( + div() + .flex() + .items_baseline() + .gap_2() + .child( + div() + .text_lg() + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(gpui::rgb(theme::text_primary())) + .child("Choose a project"), + ) + .child( + div() + .text_sm() + .text_color(gpui::rgb(theme::text_muted())) + .child(format!("on {}", self.target_host_name())), + ), + ) + .when_some(self.root_input.clone(), |col, input| { + col.child(widgets::input_frame().text_sm().child(input)) + }), + ) + .child(div().px_2().pb_2().child(list)) + .child( + div() + .flex() + .items_center() + .gap_4() + .px_4() + .py_2() + .border_t_1() + .border_color(gpui::rgb(theme::border())) + .child(hint("\u{2191}\u{2193}", "Navigate")) + .child(hint("Enter", "Open")) + .child(hint("Esc", "Close")), + ), + ) + } +} + fn chip( id: &'static str, leading: Option<&'static str>, @@ -1299,6 +1570,15 @@ fn chip( active: bool, highlight: bool, ) -> gpui::Stateful
{ + let (frame, color) = chip_frame(id, active, highlight); + frame + .children(leading.map(|name| icon(name, px(16.), color))) + .child(div().whitespace_nowrap().child(label.into())) + .when(chevron, |el| el.child(icon("chevron-down", px(14.), color))) +} + +/// A header chip with no content yet, and the color its content takes. +fn chip_frame(id: &'static str, active: bool, highlight: bool) -> (gpui::Stateful
, u32) { let color = if highlight { theme::accent() } else if active { @@ -1306,7 +1586,7 @@ fn chip( } else { theme::text_secondary() }; - div() + let frame = div() .id(id) .h_8() .flex() @@ -1323,8 +1603,6 @@ fn chip( .bg(gpui::rgb(theme::bg_sidebar_pill())) .cursor_pointer() }) - .active(|style| style.bg(gpui::rgb(theme::bg_sidebar_row_selected()))) - .children(leading.map(|name| icon(name, px(16.), color))) - .child(div().whitespace_nowrap().child(label.into())) - .when(chevron, |el| el.child(icon("chevron-down", px(14.), color))) + .active(|style| style.bg(gpui::rgb(theme::bg_sidebar_row_selected()))); + (frame, color) } diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index 2dbb5becd..9850ba451 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -86,6 +86,38 @@ pub struct PickQuestionOption { pub struct LoggedOut; +/// What one row of the project picker stands for. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum PickerRowKind { + /// A project this host used before. + Recent, + /// A folder the host found for the typed text. + Suggestion, + /// The typed text itself, when it looks like a path. + OpenPath, + /// The system folder picker; the local host only. + Browse, +} + +#[derive(Clone)] +pub(super) struct PickerRow { + pub(super) kind: PickerRowKind, + pub(super) path: String, + pub(super) title: SharedString, + pub(super) subtitle: Option, +} + +/// The project picker while it is open: one search box over the target +/// host's recent projects and folders, keyboard-navigable. +pub(super) struct ProjectPicker { + pub(super) rows: Vec, + pub(super) selected: usize, + /// Bumped per folder request; a late answer is dropped. + generation: u64, + suggestions: Vec, + query: String, +} + /// One host the chat screen shows tasks from: the local host always, and /// each remote host as it connects or as a saved entry waiting to. pub(crate) struct ChatHost { @@ -337,8 +369,15 @@ pub struct ChatScreen { trust_prompts: bool, /// The menu was just opened and still needs the focus. root_menu_focus_pending: bool, - /// Manual path entry for the project selector. + /// The project picker's search box, created once. root_input: Option>, + /// The picker just opened; the next render moves keyboard focus into + /// its search box so typing does not land in the composer. + root_input_focus_pending: bool, + /// The project picker, while open. + project_picker: Option, + /// The host chip's dropdown is open. + host_menu_open: bool, root_selecting: bool, /// Header label for the project root; set when the root changes so /// render does not format it. @@ -997,6 +1036,9 @@ impl ChatScreen { timeline_index: HashMap::new(), selected_title: DEFAULT_TASK_TITLE.into(), root_input: None, + root_input_focus_pending: false, + project_picker: None, + host_menu_open: false, root_selecting: false, project_label: SharedString::from("Choose folder"), project_branch: None, @@ -1333,12 +1375,19 @@ impl ChatScreen { true } + /// Open the project picker for the target host. Every host, local or + /// remote, goes through the same dialog; the local host adds a row for + /// the system folder picker. fn choose_root_dialog(&mut self, cx: &mut Context) { - // The platform folder picker through gpui: NSOpenPanel on macOS, - // the common file dialog on Windows, the XDG portal on Linux. - // The panel closes with the app, so quit is never blocked on it. - // Manual entry only when the picker cannot open (e.g. a Linux - // desktop with no portal); a cancel just closes. + self.open_project_picker(cx); + } + + /// The platform folder picker through gpui: NSOpenPanel on macOS, the + /// common file dialog on Windows, the XDG portal on Linux. The panel + /// closes with the app, so quit is never blocked on it. When it cannot + /// open (a Linux desktop with no portal), the project picker's typed + /// entry is the fallback. + fn browse_local_folder(&mut self, cx: &mut Context) { if !self.begin_root_picker(cx) { return; } @@ -1360,7 +1409,7 @@ impl ChatScreen { } // Cancelled, or the picker dropped its channel. Ok(Ok(None)) | Err(_) => {} - Ok(Err(_)) => this.show_root_input(cx), + Ok(Err(_)) => this.open_project_picker(cx), } cx.notify(); }) @@ -1453,25 +1502,241 @@ impl ChatScreen { true } - /// Manual path entry when the native picker is unavailable. - fn show_root_input(&mut self, cx: &mut Context) { - // The native picker could not open: offer manual entry. + /// Open the project picker: a search box over the target host's recent + /// projects and folders. The search box is created once and reused. + fn open_project_picker(&mut self, cx: &mut Context) { + self.root_menu_open = false; + self.host_menu_open = false; if self.root_input.is_none() { let chat = cx.entity().downgrade(); + let key_chat = chat.clone(); let application_vim_enabled = self.application_vim_enabled; let input = cx.new(move |cx| { - TextInput::new("/absolute/path/to/project", cx) + TextInput::new("Search folders or enter a path\u{2026}", cx) .with_tab_index(0) .application_vim(application_vim_enabled) - .on_application_escape(move |window, cx| { + .on_key(move |event, _text, _window, cx| { + let Some(chat) = key_chat.upgrade() else { + return false; + }; + match event.keystroke.key.as_str() { + "up" => chat.update(cx, |chat, cx| chat.project_picker_move(-1, cx)), + "down" => chat.update(cx, |chat, cx| chat.project_picker_move(1, cx)), + "enter" => chat.update(cx, |chat, cx| chat.project_picker_confirm(cx)), + "escape" => chat.update(cx, |chat, cx| chat.close_project_picker(cx)), + _ => return false, + } + true + }) + .on_application_escape(move |_window, cx| { if let Some(chat) = chat.upgrade() { - chat.update(cx, |chat, cx| chat.focus_application_vim(window, cx)); + chat.update(cx, |chat, cx| chat.close_project_picker(cx)); } }) }); + cx.observe(&input, |this, input, cx| { + let query = input.read(cx).text(); + this.refresh_project_picker(query, cx); + }) + .detach(); self.root_input = Some(input); + } else if let Some(input) = self.root_input.clone() { + input.update(cx, |input, cx| input.set_text("", cx)); + } + self.project_picker = Some(ProjectPicker { + rows: Vec::new(), + selected: 0, + generation: 0, + suggestions: Vec::new(), + query: String::new(), + }); + self.root_input_focus_pending = true; + self.refresh_project_picker(String::new(), cx); + cx.notify(); + } + + pub(super) fn close_project_picker(&mut self, cx: &mut Context) { + if self.project_picker.take().is_some() { + // The composer takes the keyboard back. + self.screen_focus_pending = true; + cx.notify(); + } + } + + /// The search text changed: rebuild the rows now from what is known + /// and ask the host for folders that match. + fn refresh_project_picker(&mut self, query: String, cx: &mut Context) { + let Some(picker) = self.project_picker.as_mut() else { + return; + }; + picker.query = query.clone(); + picker.generation += 1; + let generation = picker.generation; + self.rebuild_picker_rows(); + let host = self.host.clone(); + self.call( + async move { host.suggest_directories(query).await }, + cx, + move |this, result, cx| { + let Some(picker) = this.project_picker.as_mut() else { + return; + }; + if picker.generation != generation { + return; + } + if let Ok(suggestions) = result { + picker.suggestions = suggestions; + this.rebuild_picker_rows(); + cx.notify(); + } + }, + ); + } + + /// Rows in display order: the typed path itself when it looks like + /// one, recent projects that match, the host's folders, and the system + /// picker for the local host. + fn rebuild_picker_rows(&mut self) { + let Some(mut picker) = self.project_picker.take() else { + return; + }; + let query = picker.query.trim().to_string(); + let needle = query.to_lowercase(); + let mut rows: Vec = Vec::new(); + for root in &self.recent_roots { + if !needle.is_empty() && !root.to_lowercase().contains(&needle) { + continue; + } + rows.push(PickerRow { + kind: PickerRowKind::Recent, + path: root.clone(), + title: SharedString::from(root_display_name(root)), + subtitle: Some(SharedString::from(root.clone())), + }); + } + for suggestion in &picker.suggestions { + if rows.iter().any(|row| row.path == suggestion.path) { + continue; + } + rows.push(PickerRow { + kind: PickerRowKind::Suggestion, + path: suggestion.path.clone(), + title: SharedString::from(suggestion.name.clone()), + subtitle: Some(SharedString::from(suggestion.path.clone())), + }); + } + let looks_like_path = query.starts_with('/') || query.starts_with('~'); + let typed = query.trim_end_matches('/').to_string(); + if looks_like_path && !typed.is_empty() && !rows.iter().any(|row| row.path == typed) { + rows.insert( + 0, + PickerRow { + kind: PickerRowKind::OpenPath, + path: typed.clone(), + title: "Open this path".into(), + subtitle: Some(SharedString::from(typed)), + }, + ); + } + if self.target_host.is_local() { + rows.push(PickerRow { + kind: PickerRowKind::Browse, + path: String::new(), + title: "Browse\u{2026}".into(), + subtitle: Some("Choose a folder with the system picker".into()), + }); + } + picker.selected = picker.selected.min(rows.len().saturating_sub(1)); + picker.rows = rows; + self.project_picker = Some(picker); + } + + pub(super) fn project_picker_move(&mut self, delta: isize, cx: &mut Context) { + let Some(picker) = self.project_picker.as_mut() else { + return; + }; + let len = picker.rows.len(); + if len == 0 { + return; + } + picker.selected = (picker.selected as isize + delta).rem_euclid(len as isize) as usize; + cx.notify(); + } + + pub(super) fn project_picker_confirm(&mut self, cx: &mut Context) { + let Some(index) = self.project_picker.as_ref().map(|picker| picker.selected) else { + return; + }; + self.activate_picker_row(index, cx); + } + + /// Open the project a row names, or the system picker. + pub(super) fn activate_picker_row(&mut self, index: usize, cx: &mut Context) { + let Some(row) = self + .project_picker + .as_ref() + .and_then(|picker| picker.rows.get(index).cloned()) + else { + return; + }; + self.close_project_picker(cx); + match row.kind { + PickerRowKind::Browse => self.browse_local_folder(cx), + PickerRowKind::Recent | PickerRowKind::Suggestion | PickerRowKind::OpenPath => { + self.select_project_root(row.path, cx); + } + } + } + + /// The name of the host the picker and the host chip speak for. + pub(super) fn target_host_name(&self) -> String { + self.host_name(&self.target_host) + } + + pub(super) fn target_host_online(&self) -> bool { + self.hosts + .get(&self.target_host) + .is_some_and(|entry| entry.online) + } + + /// Hosts for the header chip's dropdown: local first, then by name. + pub(super) fn host_choices(&self) -> Vec<(HostId, String, bool)> { + let mut hosts: Vec<(HostId, String, bool)> = self + .hosts + .iter() + .map(|(id, entry)| (id.clone(), entry.name.clone(), entry.online)) + .collect(); + hosts.sort_by(|a, b| { + b.0.is_local() + .cmp(&a.0.is_local()) + .then_with(|| a.1.cmp(&b.1)) + }); + hosts + } + + pub(super) fn toggle_host_menu(&mut self, cx: &mut Context) { + self.root_menu_open = false; + self.models_menu_open = false; + self.mode_menu_open = false; + self.mcp_menu_open = false; + self.host_menu_open = !self.host_menu_open; + cx.notify(); + } + + /// The user chose a host in the header chip: new tasks go there, and + /// the project context follows that host. + pub(super) fn pick_host(&mut self, host: HostId, cx: &mut Context) { + self.host_menu_open = false; + if !self.hosts.get(&host).is_some_and(|entry| entry.online) { + self.notice = Some(format!("{} is offline", self.host_name(&host)).into()); + cx.notify(); + return; + } + let changed = host != self.target_host; + self.set_target_host(host, cx); + if changed { + self.adopt_target_host_context(cx); } - self.root_menu_open = true; cx.notify(); } @@ -1635,6 +1900,7 @@ impl ChatScreen { }; self.target_host = host; self.host = backend; + self.host_menu_open = false; self.recent_roots = entry.recent_roots.clone(); self.refresh_roots(cx); self.refresh_slash_commands(cx); @@ -1913,10 +2179,16 @@ impl ChatScreen { return; } let root = self.project_root.clone(); + // The visible project belongs to the target host; another host's + // task under the same path is not it. let latest = self .sessions .iter() - .find(|session| !session.archived && Some(&session.project_root) == root.as_ref()) + .find(|session| { + !session.archived + && Some(&session.project_root) == root.as_ref() + && self.host_of(&session.id) == self.target_host + }) .map(|session| session.id.clone()); match latest { Some(id) => self.select_session(&id, cx), @@ -2140,6 +2412,7 @@ impl ChatScreen { fn finish_loading(&mut self, session_id: &str) { if self.loading_session.as_deref() == Some(session_id) { self.loading_session = None; + self.refresh_selected_title(); } } @@ -2202,6 +2475,7 @@ impl ChatScreen { .find_map(plan_entries) .unwrap_or_default(); self.set_plan(plan); + self.refresh_selected_title(); } /// Apply the host's session defaults: web access for new tasks, and @@ -3177,6 +3451,15 @@ impl ChatScreen { } fn escape(&mut self, cx: &mut Context) { + if self.project_picker.is_some() { + self.close_project_picker(cx); + return; + } + if self.host_menu_open { + self.host_menu_open = false; + cx.notify(); + return; + } if self .sidebar .update(cx, |sidebar, cx| sidebar.cancel_rename(cx)) @@ -4245,6 +4528,10 @@ impl ChatScreen { self.timeline_index.insert(item.id.clone(), (index, 0)); self.timeline.push(item); self.list_state.splice(index..index, 1); + if index == 0 { + // The pane leaves the empty state; the header follows. + self.refresh_selected_title(); + } index } }; @@ -4820,7 +5107,13 @@ impl Render for ChatScreen { window.focus(&handle, cx); } } - if self.root_menu_focus_pending { + if self.root_input_focus_pending { + self.root_input_focus_pending = false; + if let Some(input) = self.root_input.clone() { + let handle = input.read(cx).focus_handle(cx); + window.focus(&handle, cx); + } + } else if self.root_menu_focus_pending { self.root_menu_focus_pending = false; if let Some(handle) = self.root_menu_focus.clone() { window.focus(&handle, cx); @@ -5047,9 +5340,13 @@ impl Render for ChatScreen { ) }) .child(main) - .children(self.render_root_menu(cx)), + .children(self.render_root_menu(cx)) + .children(self.render_host_menu(cx)), ), ) + .when(self.project_picker.is_some(), |root| { + root.child(self.render_project_picker(cx)) + }) .when_some(self.lightbox.clone(), |root, image| { root.child(motion::fade_in( div() @@ -5222,8 +5519,14 @@ impl ChatScreen { self.session_mcp = servers; } - /// Cache the header title for the selected task. + /// Cache the header title for the selected task. The header names what + /// the pane shows: with no transcript on screen it is a new task, + /// whatever the list has selected. fn refresh_selected_title(&mut self) { + if self.timeline.is_empty() && self.loading_session.is_none() { + self.selected_title = DEFAULT_TASK_TITLE.into(); + return; + } self.selected_title = self .selected_session .as_deref() diff --git a/apps/maple-agent/app/src/ui/chat/sidebar.rs b/apps/maple-agent/app/src/ui/chat/sidebar.rs index 7b5f7d92f..5caadc16c 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar.rs @@ -2117,6 +2117,7 @@ impl Sidebar { row.child(icon("check", px(14.), theme::accent())) }) }; + rows.push(menu_section_label("switcher-hosts-label", "HOSTS")); rows.push(host_row( "switcher-all-hosts".into(), "All hosts".into(), @@ -2136,6 +2137,7 @@ impl Sidebar { )); } rows.push(section_divider("switcher-hosts-divider")); + rows.push(menu_section_label("switcher-projects-label", "PROJECTS")); rows } @@ -2715,14 +2717,28 @@ impl Render for Sidebar { } } -/// A thin rule between menu blocks. +/// A rule between menu blocks. fn section_divider(id: &'static str) -> gpui::Stateful
{ div() .id(id) .h(px(1.)) - .mx_3() - .my_1() - .bg(gpui::rgb(theme::bg_input())) + .mx_2() + .my_1p5() + .bg(gpui::rgb(theme::border())) +} + +/// A small upper-case heading over a menu block, like the sidebar's +/// section labels. +fn menu_section_label(id: &'static str, text: &'static str) -> gpui::Stateful
{ + div() + .id(id) + .px_3() + .pt_2() + .pb_1() + .text_xs() + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(gpui::rgb(theme::text_muted())) + .child(text) } /// Apply `update` to the settings file off the UI thread. diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index 1d89a5bf1..912ffc8cf 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -1398,6 +1398,55 @@ mod state_tests { }); } + /// The boot auto-select opens the visible project's latest task on + /// the target host; a remote task under the same path is not it. + #[gpui::test] + fn test_session_list_auto_select_stays_on_the_target_host(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + let screen = screen(cx); + screen.update(cx, |this, cx| { + this.project_root = Some("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/work/alpha".to_string()); + this.selected_session = None; + let remote = HostId::new("remote-key".to_string()); + this.hosts + .insert(remote.clone(), ChatHost::saved("Box".to_string())); + this.apply_host_session_list( + &remote, + vec![summary_at("r1", "Remote", "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/work/alpha")], + cx, + ); + let requested = this.selection_generation; + + this.apply_session_list(vec![summary_at("s1", "Local", "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/work/beta")], requested, cx); + + // Nothing local under /work/alpha: a new task starts rather + // than the remote one opening and dragging the target along. + assert!(this.session_setup_pending); + assert_eq!(this.selected_session, None); + assert!(this.target_host.is_local()); + }); + } + + /// The header names what the pane shows: an empty pane is a new task + /// even while the list still marks a row. + #[gpui::test] + fn test_header_says_new_task_while_the_pane_is_empty(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + let screen = screen(cx); + screen.update(cx, |this, _cx| { + this.sessions = vec![summary_at("s1", "Hello", "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/work/alpha")]; + this.selected_session = Some("s1".to_string()); + this.replace_timeline(Vec::new()); + assert_eq!(this.selected_title.as_ref(), "New Task"); + + this.replace_timeline(vec![user_item("u1", "hi")]); + assert_eq!(this.selected_title.as_ref(), "Hello"); + + this.replace_timeline(Vec::new()); + assert_eq!(this.selected_title.as_ref(), "New Task"); + }); + } + #[gpui::test] fn test_new_task_waits_for_a_project_selection(cx: &mut TestAppContext) { cx.executor().allow_parking(); @@ -1445,6 +1494,70 @@ mod state_tests { }); } + #[gpui::test] + fn test_project_picker_rows_follow_the_query(cx: &mut TestAppContext) { + use crate::ui::chat::{PickerRowKind, ProjectPicker}; + cx.executor().allow_parking(); + let screen = screen(cx); + let kinds = |picker: &ProjectPicker| { + picker + .rows + .iter() + .map(|row| (row.kind, row.path.clone())) + .collect::>() + }; + screen.update(cx, |this, cx| { + this.recent_roots = vec!["/home/me/alpha".to_string(), "/home/me/beta".to_string()]; + this.open_project_picker(cx); + assert!( + this.root_input_focus_pending, + "typing must land in the picker" + ); + let picker = this.project_picker.as_ref().expect("picker open"); + // Recent projects first; the local host ends with the system picker. + assert_eq!( + kinds(picker), + vec![ + (PickerRowKind::Recent, "/home/me/alpha".to_string()), + (PickerRowKind::Recent, "/home/me/beta".to_string()), + (PickerRowKind::Browse, String::new()), + ] + ); + + // A typed path is offered as is, above what matched it. + this.refresh_project_picker("/srv/work/".to_string(), cx); + let picker = this.project_picker.as_ref().expect("picker open"); + assert_eq!( + kinds(picker), + vec![ + (PickerRowKind::OpenPath, "/srv/work".to_string()), + (PickerRowKind::Browse, String::new()), + ] + ); + + // Plain text filters the recents. + this.refresh_project_picker("BETA".to_string(), cx); + let picker = this.project_picker.as_ref().expect("picker open"); + assert_eq!( + kinds(picker), + vec![ + (PickerRowKind::Recent, "/home/me/beta".to_string()), + (PickerRowKind::Browse, String::new()), + ] + ); + + // Arrows wrap; Escape closes and hands the keyboard back. + this.project_picker_move(-1, cx); + assert_eq!(this.project_picker.as_ref().map(|p| p.selected), Some(1)); + this.project_picker_move(1, cx); + assert_eq!(this.project_picker.as_ref().map(|p| p.selected), Some(0)); + this.screen_focus_pending = false; + this.escape(cx); + assert!(this.project_picker.is_none()); + assert!(this.screen_focus_pending); + }); + } + #[gpui::test] fn test_escape_closes_root_menu(cx: &mut TestAppContext) { cx.executor().allow_parking(); diff --git a/apps/maple-agent/docs/remote-development.md b/apps/maple-agent/docs/remote-development.md index fc69face7..9b41f36ab 100644 --- a/apps/maple-agent/docs/remote-development.md +++ b/apps/maple-agent/docs/remote-development.md @@ -479,8 +479,14 @@ selected task's host. As built: the host filter lives in the project switcher menu, above the project rows, with every host listed and offline hosts grayed. The badge is -the host name after the project name on each row. A composer picker for the -target host is not built; the filter and the selection decide it. +the host name after the project name on each row. A host chip in the +header, shown once more than one host is known, names the target host with +a status dot and switches it from a dropdown; the filter and the selection +still move the target too. Project selection is one dialog for every host +(after Paseo's add-project flow): a search box over the host's recent +projects and its directory suggestions, an "Open this path" row when the +text looks like a path, and a "Browse…" row for the system folder +picker on the local host only. Arrows, Enter, and Escape drive it. ### Settings scoping From 0c88ffb2ecd584fd64fc4fa38ed451526e71013b Mon Sep 17 00:00:00 2001 From: benthecarman Date: Sat, 19 Sep 2026 21:49:47 -0500 Subject: [PATCH 10/37] Start on the host the last new task ran on Most people work on one host at a time, so a launch that always targeted the local host sent every remote user through the host chip before their first task. The client settings remember the host of the last new task. At launch the chat screen holds its local auto-select while that host connects, then makes it the target, shows its saved project, and opens its latest task there. A remembered host that reports offline or is no longer saved releases startup to the local auto-select. The connection manager now announces the saved host list before dialing, so the screen can tell a host still connecting from one it does not know. Co-Authored-By: Claude Fable 5.1 --- apps/maple-agent/README.md | 3 +- apps/maple-agent/app/src/settings.rs | 6 + apps/maple-agent/app/src/ui/chat/mod.rs | 143 ++++++++++++++---- apps/maple-agent/app/src/ui/chat/tests.rs | 81 ++++++++++ .../crates/maple-remote/src/manager.rs | 3 + apps/maple-agent/docs/remote-development.md | 7 +- 6 files changed, 213 insertions(+), 30 deletions(-) diff --git a/apps/maple-agent/README.md b/apps/maple-agent/README.md index 2b107340c..60db7aec4 100644 --- a/apps/maple-agent/README.md +++ b/apps/maple-agent/README.md @@ -141,7 +141,8 @@ hosts connect at launch and reconnect with backoff. Their tasks join the sidebar, badged with the host name once more than one host is known, and the project switcher filters by host. A host chip in the header, shown once more than one host is known, names the host new tasks run on and switches -it. Choosing a project opens one picker for every host: a search box over +it. The host the last new task ran on is the target again at the next +launch once it connects. Choosing a project opens one picker for every host: a search box over the host's recent projects and folders, a row that opens a typed path, and on the local host a row for the system folder picker. Host-scoped settings (defaults, system prompt, integrations, usage) get a host selector when diff --git a/apps/maple-agent/app/src/settings.rs b/apps/maple-agent/app/src/settings.rs index 79d245a96..c6862a5e8 100644 --- a/apps/maple-agent/app/src/settings.rs +++ b/apps/maple-agent/app/src/settings.rs @@ -64,6 +64,11 @@ pub struct AppSettings { /// is [`maple_agent::host::HostId::LOCAL`]. #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] pub hosts: std::collections::BTreeMap, + /// The host the last new task was created on, by host id; absent for + /// the local host. The next launch targets it again once it connects, + /// since most people work on one host at a time. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_task_host: Option, /// Whether this window also serves the account's runtime to paired /// devices. Off until asked: a fresh install never listens. #[serde(default)] @@ -357,6 +362,7 @@ impl Default for AppSettings { application_vim_enabled: false, shortcut_overrides: std::collections::BTreeMap::new(), hosts: std::collections::BTreeMap::new(), + last_task_host: None, allow_remote_connections: false, desktop_notifications: default_desktop_notifications(), reduce_motion: false, diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index 9850ba451..3d34ce3a5 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -15,7 +15,7 @@ use maple_agent::agent::{ AgentSendMessageRequest, AgentServiceEvent, AgentSessionMcpServer, AgentSessionSummary, AgentSlashCommand, AgentSubagent, AgentTimelineItem, SideQuestionEvent, }; -use maple_agent::host::{HostBackend, HostEvent, HostId, HostSessionDefaults}; +use maple_agent::host::{HostBackend, HostBootstrap, HostEvent, HostId, HostSessionDefaults}; use maple_remote::hosts::SavedHost; use maple_remote::manager::{HostManagerEvent, HostStatus}; @@ -376,6 +376,9 @@ pub struct ChatScreen { root_input_focus_pending: bool, /// The project picker, while open. project_picker: Option, + /// The remote host the last new task ran on, until it connects and + /// becomes the target again. Startup holds its auto-select for it. + restore_host: Option, /// The host chip's dropdown is open. host_menu_open: bool, root_selecting: bool, @@ -952,6 +955,11 @@ impl ChatScreen { let sidebar = cx.new(|cx| Sidebar::new(backend.clone(), host.clone(), weak, &settings, cx)); cx.subscribe(&sidebar, Self::on_sidebar_event).detach(); let hosts = HashMap::from([(HostId::local(), ChatHost::local(host.clone()))]); + let restore_host = settings + .last_task_host + .as_deref() + .map(HostId::new) + .filter(|host| !host.is_local()); Self { backend, host, @@ -1038,6 +1046,7 @@ impl ChatScreen { root_input: None, root_input_focus_pending: false, project_picker: None, + restore_host, host_menu_open: false, root_selecting: false, project_label: SharedString::from("Choose folder"), @@ -1168,10 +1177,11 @@ impl ChatScreen { local.session_defaults = Some(boot.session_defaults.clone()); } this.apply_host_session_list(&HostId::local(), boot.sessions, cx); - // A click that landed before this callback wins. - if let Some(detail) = - boot.latest.filter(|_| this.selected_session.is_none()) - { + // A click that landed before this callback wins, and + // a remote host being restored opens its own task. + if let Some(detail) = boot.latest.filter(|_| { + this.selected_session.is_none() && this.restore_host.is_none() + }) { let summaries = summaries .into_iter() .map(|(id, summary)| (id, SharedString::from(summary))) @@ -1982,6 +1992,14 @@ impl ChatScreen { self.set_target_host(HostId::local(), cx); } } + // A remembered host that is not saved any more is not coming back. + if let Some(host) = self + .restore_host + .clone() + .filter(|host| !keep.contains(host)) + { + self.give_up_restore(&host, cx); + } for host in saved { let id = HostId::new(host.id); match self.hosts.get_mut(&id) { @@ -2024,6 +2042,9 @@ impl ChatScreen { self.host_filter = None; self.set_target_host(HostId::local(), cx); } + if matches!(status, HostStatus::Offline { .. }) { + self.give_up_restore(&host, cx); + } if let HostStatus::Offline { reason } = status && reason != "removed" && self @@ -2069,35 +2090,30 @@ impl ChatScreen { return; }; let target = host.clone(); + let restoring = self.restore_host.as_ref() == Some(&host); self.call( async move { let boot = backend.bootstrap().await?; let start_error = backend.start_runtime(None).await.err(); - Ok::<_, String>((boot, start_error)) + // Only the restored host opens its latest task, so only it + // needs that task's stored summaries. + let summaries = match boot.latest.as_ref().filter(|_| restoring) { + Some(detail) => backend + .tool_summaries(detail.session.id.clone()) + .await + .unwrap_or_else(|error| { + log::warn!("Cannot load tool summaries: {error}"); + HashMap::new() + }), + None => HashMap::new(), + }; + Ok::<_, String>((boot, start_error, summaries)) }, cx, move |this, result, cx| { match result { - Ok((boot, start_error)) => { - if let Some(entry) = this.hosts.get_mut(&target) { - entry.project_root = boot.project_root.clone(); - entry.recent_roots = boot.recent_roots.clone(); - entry.session_defaults = Some(boot.session_defaults.clone()); - } - this.apply_host_session_list(&target, boot.sessions, cx); - if let Some(error) = start_error { - this.notice = Some( - format!( - "{}: runtime failed to start: {error}", - this.host_name(&target) - ) - .into(), - ); - } - if this.target_host == target { - this.recent_roots = boot.recent_roots; - this.adopt_target_host_context(cx); - } + Ok((boot, start_error, summaries)) => { + this.apply_remote_bootstrap(target, boot, start_error, summaries, cx); } Err(message) => { this.notice = @@ -2109,6 +2125,69 @@ impl ChatScreen { ); } + /// A remote host answered its bootstrap. When it is the host the last + /// new task ran on, it becomes the target again and its latest task + /// opens, unless a task was chosen meanwhile. + fn apply_remote_bootstrap( + &mut self, + target: HostId, + boot: HostBootstrap, + start_error: Option, + summaries: HashMap, + cx: &mut Context, + ) { + if let Some(entry) = self.hosts.get_mut(&target) { + entry.project_root = boot.project_root.clone(); + entry.recent_roots = boot.recent_roots.clone(); + entry.session_defaults = Some(boot.session_defaults.clone()); + } + self.apply_host_session_list(&target, boot.sessions, cx); + if let Some(error) = start_error { + self.notice = Some( + format!( + "{}: runtime failed to start: {error}", + self.host_name(&target) + ) + .into(), + ); + } + let restoring = self.restore_host.as_ref() == Some(&target); + if restoring { + self.restore_host = None; + if self.selected_session.is_none() && self.host_filter.is_none() { + self.set_target_host(target.clone(), cx); + } + } + if self.target_host == target { + self.recent_roots = boot.recent_roots; + self.adopt_target_host_context(cx); + if let Some(detail) = boot + .latest + .filter(|_| restoring && self.selected_session.is_none()) + { + let summaries = summaries + .into_iter() + .map(|(id, summary)| (id, SharedString::from(summary))) + .collect(); + self.upsert_session(detail.session.clone(), cx); + self.set_active_session(detail.session, detail.timeline, summaries, cx); + self.queue = detail.queue.items; + } + } + } + + /// The remembered host will not come: stop holding startup for it and + /// let the local auto-select run. + fn give_up_restore(&mut self, host: &HostId, cx: &mut Context) { + if self.restore_host.as_ref() != Some(host) { + return; + } + self.restore_host = None; + if self.selected_session.is_none() { + self.refresh_sessions(cx); + } + } + /// Everything the connection manager reports, in one batch. pub fn handle_manager_events(&mut self, events: Vec, cx: &mut Context) { let mut pending: Option<(HostId, Vec)> = None; @@ -2175,7 +2254,10 @@ impl ChatScreen { cx: &mut Context, ) { self.apply_host_session_list(&HostId::local(), sessions, cx); - if self.selected_session.is_some() || self.selection_generation != generation { + if self.selected_session.is_some() + || self.selection_generation != generation + || self.restore_host.is_some() + { return; } let root = self.project_root.clone(); @@ -2244,9 +2326,14 @@ impl ChatScreen { cx: &mut Context, ) { self.session_setup_pending = false; - // The task was created on the target host. + // The task was created on the target host; the next launch starts + // there too. self.session_hosts .insert(session.id.clone(), self.target_host.clone()); + let last_task_host = (!self.target_host.is_local()).then(|| self.target_host.to_string()); + crate::settings::update_settings_in_background(move |settings| { + settings.last_task_host = last_task_host; + }); // The SessionCreated event may arrive before this callback; upsert so // the sidebar never shows the task twice, even when its navigation is // no longer current. diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index 912ffc8cf..1ecdebda8 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -1427,6 +1427,87 @@ mod state_tests { }); } + /// The host the last new task ran on is the target again at launch: + /// startup holds its auto-select until that host connects, then opens + /// the host's latest task under its saved project. + #[gpui::test] + fn test_launch_restores_the_last_task_host(cx: &mut TestAppContext) { + use maple_agent::agent::AgentDesktopQueueSnapshot; + use maple_agent::host::HostSessionDefaults; + cx.executor().allow_parking(); + let screen = screen(cx); + screen.update(cx, |this, cx| { + let remote = HostId::new("remote-key".to_string()); + let backend = this.backend.local_host("user") as Arc; + let mut entry = ChatHost::local(backend); + entry.name = "Box".to_string(); + this.hosts.insert(remote.clone(), entry); + this.restore_host = Some(remote.clone()); + this.selected_session = None; + this.project_root = Some("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/work/local".to_string()); + + // The local list arrives first: no auto-select, no new task. + let requested = this.selection_generation; + this.apply_session_list( + vec![summary_at("s1", "Local", "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/work/local")], + requested, + cx, + ); + assert_eq!(this.selected_session, None); + assert!(!this.session_setup_pending); + assert!(this.target_host.is_local()); + + // The remembered host connects: it is the target, its project + // shows, and its latest task opens. + let latest = maple_agent::agent::AgentSessionDetail { + session: summary_at("r1", "Remote", "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/work/remote"), + timeline: vec![user_item("u1", "hi")], + mcp_errors: Vec::new(), + queue: AgentDesktopQueueSnapshot { + revision: 0, + items: Vec::new(), + }, + }; + let boot = HostBootstrap { + project_root: Some("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/work/remote".to_string()), + sessions: vec![summary_at("r1", "Remote", "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/work/remote")], + recent_roots: vec!["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/work/remote".to_string()], + latest: Some(latest), + session_defaults: HostSessionDefaults::default(), + }; + this.apply_remote_bootstrap(remote.clone(), boot, None, HashMap::new(), cx); + assert_eq!(this.restore_host, None); + assert_eq!(this.target_host, remote); + assert_eq!(this.project_root.as_deref(), Some("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/work/remote")); + assert_eq!(this.selected_session.as_deref(), Some("r1")); + assert_eq!(this.selected_title.as_ref(), "Remote"); + }); + } + + /// A remembered host that stays offline releases startup to the local + /// auto-select instead of holding the window empty. + #[gpui::test] + fn test_offline_restore_host_releases_startup(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + let screen = screen(cx); + screen.update(cx, |this, cx| { + let remote = HostId::new("remote-key".to_string()); + this.restore_host = Some(remote.clone()); + this.selected_session = None; + this.set_remote_host_status( + remote, + "Box".to_string(), + HostStatus::Offline { + reason: "unreachable".to_string(), + }, + None, + cx, + ); + assert_eq!(this.restore_host, None); + assert!(this.target_host.is_local()); + }); + } + /// The header names what the pane shows: an empty pane is a new task /// even while the list still marks a row. #[gpui::test] diff --git a/apps/maple-agent/crates/maple-remote/src/manager.rs b/apps/maple-agent/crates/maple-remote/src/manager.rs index 6fd6f6a9c..40968eb6b 100644 --- a/apps/maple-agent/crates/maple-remote/src/manager.rs +++ b/apps/maple-agent/crates/maple-remote/src/manager.rs @@ -136,6 +136,9 @@ impl HostManager { log::warn!("cannot read saved hosts: {error}"); Vec::new() }); + // The UI learns the full list first, so it can tell a saved host + // that is still connecting from one it does not know at all. + self.emit(HostManagerEvent::HostsChanged(hosts.clone())); for host in hosts { self.spawn_connector(host, None); } diff --git a/apps/maple-agent/docs/remote-development.md b/apps/maple-agent/docs/remote-development.md index 9b41f36ab..b7e1c13e6 100644 --- a/apps/maple-agent/docs/remote-development.md +++ b/apps/maple-agent/docs/remote-development.md @@ -482,7 +482,12 @@ project rows, with every host listed and offline hosts grayed. The badge is the host name after the project name on each row. A host chip in the header, shown once more than one host is known, names the target host with a status dot and switches it from a dropdown; the filter and the selection -still move the target too. Project selection is one dialog for every host +still move the target too. The host the last new task ran on is saved in +the client settings (`last_task_host`) and is the target again at the next +launch: startup holds the local auto-select until that host connects, then +shows its saved project and opens its latest task there. A remembered host +that reports offline or is no longer saved releases startup to the local +auto-select. Project selection is one dialog for every host (after Paseo's add-project flow): a search box over the host's recent projects and its directory suggestions, an "Open this path" row when the text looks like a path, and a "Browse…" row for the system folder From 8c6c0648725de21dd9d8d2b6538dced411a0748e Mon Sep 17 00:00:00 2001 From: benthecarman Date: Sat, 19 Sep 2026 22:05:20 -0500 Subject: [PATCH 11/37] Fix the first bugs found using remote hosts Enabling Claude or Codex on a task that lives on a remote host failed with "Session not found": calls about the task on screen went through the backend that new tasks target. Every call scoped to a task now resolves the backend from the task's owner, and the sidebar hands a rename to the screen instead of calling its local backend. The project picker's search box lost typing to the composer, because the chat root forwards plain typing there unless a known input holds focus; the box joins that list and nothing types past an open picker. Its arrows never arrived, because up and down are actions of the text input; the input gains a vertical hook that a list above it takes, and an arrow fills the box with the highlighted path as a shell completes. A typed path may start with a tilde, which the host expands to its own home directory. Tasks on a remote host appeared twice in the sidebar when a load that landed after a connection blip filed the task under the wrong host; a host's list now replaces every row it names, and a load files the task under the host that answered it. The unfiltered sidebar scope reads "All projects" whatever the host count. Co-Authored-By: Claude Fable 5.1 --- apps/maple-agent/app/src/ui/chat/composer.rs | 2 +- apps/maple-agent/app/src/ui/chat/dialogs.rs | 2 +- apps/maple-agent/app/src/ui/chat/mod.rs | 148 +++++++++++++--- apps/maple-agent/app/src/ui/chat/queue.rs | 6 +- apps/maple-agent/app/src/ui/chat/sidebar.rs | 16 +- apps/maple-agent/app/src/ui/chat/summaries.rs | 4 +- apps/maple-agent/app/src/ui/chat/tests.rs | 164 ++++++++++++++++++ apps/maple-agent/app/src/ui/text_input.rs | 38 +++- .../crates/maple-agent/src/agent.rs | 39 ++++- 9 files changed, 374 insertions(+), 45 deletions(-) diff --git a/apps/maple-agent/app/src/ui/chat/composer.rs b/apps/maple-agent/app/src/ui/chat/composer.rs index 3cec5b872..a71377418 100644 --- a/apps/maple-agent/app/src/ui/chat/composer.rs +++ b/apps/maple-agent/app/src/ui/chat/composer.rs @@ -527,7 +527,7 @@ impl ChatScreen { input.set_placeholder(SIDE_THREAD_PLACEHOLDER, cx) }); } - let host = self.host.clone(); + let host = self.backend_for(session_id); let session_id = session_id.to_string(); let question = question.to_string(); let callback_id = request_id.clone(); diff --git a/apps/maple-agent/app/src/ui/chat/dialogs.rs b/apps/maple-agent/app/src/ui/chat/dialogs.rs index 8569bd351..aa0163558 100644 --- a/apps/maple-agent/app/src/ui/chat/dialogs.rs +++ b/apps/maple-agent/app/src/ui/chat/dialogs.rs @@ -347,7 +347,7 @@ impl ChatScreen { archived: bool, cx: &mut Context, ) { - let host = self.host.clone(); + let host = self.backend_for(session_id); let session_id = session_id.to_string(); let changed_id = session_id.clone(); self.call( diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index 3d34ce3a5..49fbd762b 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -115,7 +115,15 @@ pub(super) struct ProjectPicker { /// Bumped per folder request; a late answer is dropped. generation: u64, suggestions: Vec, + /// What the rows were built from: the typed text, or the path an + /// arrow filled in. query: String, + /// The text the user typed, kept while arrows fill the box so a row + /// with no path (Browse) restores it. + typed: String, + /// The text an arrow just filled in; the box reporting it back is not + /// a new query, so the rows stay put while the highlight moves. + filled: Option, } /// One host the chat screen shows tasks from: the local host always, and @@ -620,9 +628,8 @@ impl ChatScreen { ) { match event.clone() { SidebarEvent::Select(id) => self.select_session(&id, cx), - SidebarEvent::SessionChanged(session) => { - self.upsert_session(session, cx); - cx.notify(); + SidebarEvent::RenameTask { session_id, name } => { + self.rename_session(session_id, name, cx); } SidebarEvent::SetArchived { session_id, @@ -1520,6 +1527,7 @@ impl ChatScreen { if self.root_input.is_none() { let chat = cx.entity().downgrade(); let key_chat = chat.clone(); + let arrow_chat = chat.clone(); let application_vim_enabled = self.application_vim_enabled; let input = cx.new(move |cx| { TextInput::new("Search folders or enter a path\u{2026}", cx) @@ -1530,14 +1538,23 @@ impl ChatScreen { return false; }; match event.keystroke.key.as_str() { - "up" => chat.update(cx, |chat, cx| chat.project_picker_move(-1, cx)), - "down" => chat.update(cx, |chat, cx| chat.project_picker_move(1, cx)), "enter" => chat.update(cx, |chat, cx| chat.project_picker_confirm(cx)), "escape" => chat.update(cx, |chat, cx| chat.close_project_picker(cx)), _ => return false, } true }) + .on_vertical(move |delta, _window, cx| { + // The move writes the highlighted path back into + // this input, which is mid-update here: defer it. + let chat = arrow_chat.clone(); + cx.defer(move |cx| { + if let Some(chat) = chat.upgrade() { + chat.update(cx, |chat, cx| chat.project_picker_move(delta, cx)); + } + }); + true + }) .on_application_escape(move |_window, cx| { if let Some(chat) = chat.upgrade() { chat.update(cx, |chat, cx| chat.close_project_picker(cx)); @@ -1546,6 +1563,14 @@ impl ChatScreen { }); cx.observe(&input, |this, input, cx| { let query = input.read(cx).text(); + // An arrow filled this text in; the rows already show it. + if this + .project_picker + .as_ref() + .is_some_and(|picker| picker.filled.as_deref() == Some(query.as_str())) + { + return; + } this.refresh_project_picker(query, cx); }) .detach(); @@ -1559,6 +1584,8 @@ impl ChatScreen { generation: 0, suggestions: Vec::new(), query: String::new(), + typed: String::new(), + filled: None, }); self.root_input_focus_pending = true; self.refresh_project_picker(String::new(), cx); @@ -1580,6 +1607,8 @@ impl ChatScreen { return; }; picker.query = query.clone(); + picker.typed = query.clone(); + picker.filled = None; picker.generation += 1; let generation = picker.generation; self.rebuild_picker_rows(); @@ -1661,6 +1690,8 @@ impl ChatScreen { self.project_picker = Some(picker); } + /// Move the highlight and put the highlighted path in the search box, + /// as a shell completes; a row with no path restores the typed text. pub(super) fn project_picker_move(&mut self, delta: isize, cx: &mut Context) { let Some(picker) = self.project_picker.as_mut() else { return; @@ -1670,6 +1701,16 @@ impl ChatScreen { return; } picker.selected = (picker.selected as isize + delta).rem_euclid(len as isize) as usize; + let row = &picker.rows[picker.selected]; + let text = if row.path.is_empty() { + picker.typed.clone() + } else { + row.path.clone() + }; + picker.filled = Some(text.clone()); + if let Some(input) = self.root_input.clone() { + input.update(cx, |input, cx| input.set_text(&text, cx)); + } cx.notify(); } @@ -1828,16 +1869,21 @@ impl ChatScreen { } } - /// Replace one host's tasks in the merged list. + /// Replace one host's tasks in the merged list. A task the list names + /// leaves whatever host it was filed under: the list is the truth + /// about where it lives, and one row per task is the invariant. fn apply_host_session_list( &mut self, host: &HostId, sessions: Vec, cx: &mut Context, ) { + let listed: HashSet<&str> = sessions.iter().map(|session| session.id.as_str()).collect(); let session_hosts = &self.session_hosts; - self.sessions - .retain(|session| session_hosts.get(&session.id).unwrap_or(&HostId::local()) != host); + self.sessions.retain(|session| { + !listed.contains(session.id.as_str()) + && session_hosts.get(&session.id).unwrap_or(&HostId::local()) != host + }); for session in &sessions { self.session_hosts.insert(session.id.clone(), host.clone()); } @@ -1881,6 +1927,43 @@ impl ChatScreen { .unwrap_or_else(HostId::local) } + /// The backend that owns `session_id`: every call about that task goes + /// there, whatever host new tasks target. The target's backend stands + /// in for a task whose host is unknown. + pub(super) fn backend_for(&self, session_id: &str) -> Arc { + let owner = self.host_of(session_id); + self.hosts + .get(&owner) + .and_then(|entry| entry.backend.clone()) + .unwrap_or_else(|| self.host.clone()) + } + + /// The backend of the task on screen; the target's with none. + pub(super) fn session_backend(&self) -> Arc { + match self.selected_session.as_deref() { + Some(session_id) => self.backend_for(session_id), + None => self.host.clone(), + } + } + + /// Rename a task on the host that owns it. + fn rename_session(&mut self, session_id: String, name: String, cx: &mut Context) { + let host = self.backend_for(&session_id); + self.call( + async move { host.rename_session(session_id, name).await }, + cx, + |this, result, cx| { + match result { + Ok(session) => { + this.upsert_session(session, cx); + } + Err(message) => this.notice = Some(message.into()), + } + cx.notify(); + }, + ); + } + /// Point `host` at the backend that owns `session_id`, so the load and /// every call for the selected task go to the right host. A task whose /// host is offline keeps the previous backend; its calls report the @@ -2420,7 +2503,11 @@ impl ChatScreen { self.loading_session = Some(session_id.to_string()); cx.notify(); } - let host = self.host.clone(); + let host = self.backend_for(session_id); + // The host answering the load owns the task; a mapping dropped + // while the load is in flight (a connection blip) comes back as + // this, not as whatever host new tasks target by then. + let owner = self.host_of(session_id); let session_id = session_id.to_string(); let target = session_id.clone(); self.call( @@ -2477,6 +2564,9 @@ impl ChatScreen { .collect(); match mode { LoadMode::Select => { + this.session_hosts + .entry(detail.session.id.clone()) + .or_insert(owner); this.upsert_session(detail.session.clone(), cx); this.set_active_session(detail.session, detail.timeline, summaries, cx); this.queue = detail.queue.items; @@ -2638,7 +2728,7 @@ impl ChatScreen { let Some(session_id) = self.selected_session.clone() else { return; }; - let host = self.host.clone(); + let host = self.session_backend(); let model = self.selected_model.clone(); self.call( async move { host.context_usage(session_id, model).await }, @@ -2658,7 +2748,7 @@ impl ChatScreen { let Some(session_id) = self.selected_session.clone() else { return; }; - let host = self.host.clone(); + let host = self.session_backend(); let requested = session_id.clone(); let epoch = self.subagent_epoch; self.call( @@ -2795,7 +2885,7 @@ impl ChatScreen { let Some(session_id) = self.selected_session.clone() else { return; }; - let host = self.host.clone(); + let host = self.session_backend(); let compacted = session_id.clone(); self.notice = Some("Compacting…".into()); cx.notify(); @@ -2825,7 +2915,7 @@ impl ChatScreen { let Some(session_id) = self.selected_session.clone() else { return; }; - let host = self.host.clone(); + let host = self.session_backend(); let mode = self.permission_mode.as_str().to_string(); self.call( async move { @@ -3291,7 +3381,7 @@ impl ChatScreen { let Some(session_id) = self.selected_session.clone() else { return; }; - let host = self.host.clone(); + let host = self.session_backend(); let agent_id = agent_id.to_string(); self.call( async move { @@ -3669,17 +3759,27 @@ impl ChatScreen { cx.stop_propagation(); return; } - // A modal dialog owns the keyboard; nothing types past it. + // A modal dialog owns the keyboard; nothing types past it. The + // project picker's search box takes what its own input misses. if self.trust_prompt.is_some() || self.confirm_remove_root.is_some() { return; } + if self.project_picker.is_some() { + self.root_input_focus_pending = true; + cx.notify(); + return; + } // A focused text input already receives typing. let focused = window.focused(cx); - let typing_here = [self.composer.clone(), self.pending_question_input.clone()] - .into_iter() - .flatten() - .chain(self.sidebar.read(cx).inputs()) - .any(|input| Some(input.read(cx).focus_handle(cx)) == focused); + let typing_here = [ + self.composer.clone(), + self.pending_question_input.clone(), + self.root_input.clone(), + ] + .into_iter() + .flatten() + .chain(self.sidebar.read(cx).inputs()) + .any(|input| Some(input.read(cx).focus_handle(cx)) == focused); if typing_here { return; } @@ -4115,7 +4215,7 @@ impl ChatScreen { cx: &mut Context, ) { let session_id = session_id.to_string(); - let host = self.host.clone(); + let host = self.backend_for(&session_id); let model = self.selected_model.clone(); let vision_capable = self.selected_model_supports_vision(); let run_active = self.active_runs.contains_key(&session_id); @@ -4356,7 +4456,7 @@ impl ChatScreen { } let request_id = question.request_id.clone(); let callback_request_id = request_id.clone(); - let host = self.host.clone(); + let host = self.session_backend(); // Drop the answered question and its input so the next card starts // fresh; a queued question's event already fired, so the input is // recreated right away when one is showing. @@ -4398,7 +4498,7 @@ impl ChatScreen { .retain(|queued| queued.request_id != question.request_id); self.reset_question_card(cx); cx.notify(); - let host = self.host.clone(); + let host = self.session_backend(); { let request_id = question.request_id.clone(); let answer_host = host.clone(); @@ -4480,7 +4580,7 @@ impl ChatScreen { } self.permission_responding = true; cx.notify(); - let host = self.host.clone(); + let host = self.session_backend(); let request_id = permission.request_id.clone(); self.call( async move { diff --git a/apps/maple-agent/app/src/ui/chat/queue.rs b/apps/maple-agent/app/src/ui/chat/queue.rs index e11a33fa5..e6ab8704e 100644 --- a/apps/maple-agent/app/src/ui/chat/queue.rs +++ b/apps/maple-agent/app/src/ui/chat/queue.rs @@ -43,7 +43,7 @@ impl ChatScreen { return; } self.queue_busy = true; - let host = self.host.clone(); + let host = self.backend_for(&session_id); let queue_id = queue_id.to_string(); let target = session_id.clone(); self.call( @@ -81,7 +81,7 @@ impl ChatScreen { }; let text = item.text.clone(); self.queue_busy = true; - let host = self.host.clone(); + let host = self.backend_for(&session_id); let queue_id = queue_id.to_string(); let target = session_id.clone(); let held_id = queue_id.clone(); @@ -165,7 +165,7 @@ impl ChatScreen { } fn release_queue_hold(&self, session_id: &str, queue_id: &str, cx: &mut Context) { - let host = self.host.clone(); + let host = self.backend_for(session_id); let session_id = session_id.to_string(); let queue_id = queue_id.to_string(); self.call( diff --git a/apps/maple-agent/app/src/ui/chat/sidebar.rs b/apps/maple-agent/app/src/ui/chat/sidebar.rs index 5caadc16c..78d63a764 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar.rs @@ -40,7 +40,8 @@ pub(super) enum SidebarEvent { Select(String), /// A session changed on the backend (a rename); the screen owns the /// canonical list and pushes it back. - SessionChanged(AgentSessionSummary), + /// The user renamed a task; the screen sends it to the task's host. + RenameTask { session_id: String, name: String }, /// Archive or restore a task. SetArchived { session_id: String, archived: bool }, /// Remove a project; the screen confirms first. @@ -873,7 +874,7 @@ impl Sidebar { ) { (Some(root), _) => SharedString::from(self.root_name(root)), (None, Some(host)) => host, - (None, None) if self.hosts.len() > 1 => "All hosts".into(), + // Every host's projects: the scope is still projects. (None, None) => "All projects".into(), }; let recent_roots: HashSet<&str> = self.recent_roots.iter().map(String::as_str).collect(); @@ -1296,15 +1297,8 @@ impl Sidebar { } match target { RenameTarget::Task(session_id) => { - let host = self.host.clone(); - self.call( - async move { host.rename_session(session_id.clone(), name).await }, - cx, - |_this, result, cx| match result { - Ok(session) => cx.emit(SidebarEvent::SessionChanged(session)), - Err(message) => cx.emit(SidebarEvent::Notice(message.into())), - }, - ); + // The screen knows which host owns the task. + cx.emit(SidebarEvent::RenameTask { session_id, name }); } RenameTarget::Project(root) => { if name == root_display_name(&root) { diff --git a/apps/maple-agent/app/src/ui/chat/summaries.rs b/apps/maple-agent/app/src/ui/chat/summaries.rs index 2ee87a059..3c7d8dcc9 100644 --- a/apps/maple-agent/app/src/ui/chat/summaries.rs +++ b/apps/maple-agent/app/src/ui/chat/summaries.rs @@ -138,7 +138,7 @@ impl ChatScreen { } let tool_name = item.title.clone().unwrap_or_else(|| item.item_type.clone()); log::debug!("Requesting summary for {item_id} ({tool_name})"); - let host = self.host.clone(); + let host = self.backend_for(&session_id); let generation = self.summary_generation; self.pending_summaries += 1; let store_id = item_id.clone(); @@ -239,7 +239,7 @@ impl ChatScreen { }; for id in wanted { self.attachment_requests.insert(id.clone()); - let host = self.host.clone(); + let host = self.session_backend(); let session = session_id.clone(); let attachment_id = id.clone(); self.call( diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index 1ecdebda8..11c307b38 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -1484,6 +1484,73 @@ mod state_tests { }); } + /// A host's list replaces every row it names, even one filed under + /// another host by an earlier event, so a task never shows twice. + #[gpui::test] + fn test_host_session_list_never_duplicates_a_task(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + let screen = screen(cx); + screen.update(cx, |this, cx| { + let remote = HostId::new("remote-key".to_string()); + this.hosts + .insert(remote.clone(), ChatHost::saved("Box".to_string())); + // The task arrived once with no host mapping (filed local), + // once under the remote host: both stale by the time its + // host lists it. + this.sessions = vec![ + summary_at("r1", "Remote", "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/work/remote"), + summary_at("s1", "Local", "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/work/local"), + ]; + this.session_hosts.remove("r1"); + this.apply_host_session_list( + &remote, + vec![ + summary_at("r1", "Remote", "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/work/remote"), + summary_at("r2", "Other", "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/work/remote"), + ], + cx, + ); + let mut ids: Vec<&str> = this.sessions.iter().map(|s| s.id.as_str()).collect(); + ids.sort_unstable(); + assert_eq!(ids, vec!["r1", "r2", "s1"]); + assert_eq!(this.host_of("r1"), remote); + assert!(this.host_of("s1").is_local()); + + // Listing again, and listing the local host, keeps one row each. + this.apply_host_session_list(&remote, vec![summary_at("r1", "Remote", "/w")], cx); + this.apply_host_session_list(&HostId::local(), vec![summary_at("s1", "L", "/w")], cx); + let mut ids: Vec<&str> = this.sessions.iter().map(|s| s.id.as_str()).collect(); + ids.sort_unstable(); + assert_eq!(ids, vec!["r1", "s1"]); + }); + } + + /// Calls about a task go to the host that owns it, whatever host new + /// tasks target: enabling an integration on a remote task must not + /// ask the local runtime, which does not know the task. + #[gpui::test] + fn test_task_calls_go_to_the_owning_host(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + let screen = screen(cx); + screen.update(cx, |this, _cx| { + let thin = |backend: &Arc| Arc::as_ptr(backend) as *const (); + let remote = HostId::new("remote-key".to_string()); + let remote_backend = this.backend.local_host("other") as Arc; + let local_backend = this.host.clone(); + assert_ne!(thin(&remote_backend), thin(&local_backend)); + this.hosts + .insert(remote.clone(), ChatHost::local(remote_backend.clone())); + this.session_hosts.insert("r1".to_string(), remote); + this.selected_session = Some("r1".to_string()); + + // New tasks still target the local host. + assert!(this.target_host.is_local()); + assert_eq!(thin(&this.session_backend()), thin(&remote_backend)); + assert_eq!(thin(&this.backend_for("r1")), thin(&remote_backend)); + assert_eq!(thin(&this.backend_for("s1")), thin(&local_backend)); + }); + } + /// A remembered host that stays offline releases startup to the local /// auto-select instead of holding the window empty. #[gpui::test] @@ -3072,6 +3139,103 @@ mod state_tests { ); } + /// With the project picker open, typing goes to its search box and + /// never to the composer behind it, whatever held focus before. + #[gpui::test] + fn test_typing_with_the_project_picker_open_lands_in_its_search(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + struct ChatHost { + chat: Entity, + } + impl Render for ChatHost { + fn render( + &mut self, + _window: &mut Window, + _cx: &mut Context, + ) -> impl IntoElement { + div().w(px(1200.)).h(px(800.)).child(self.chat.clone()) + } + } + + let chat = cx.new(|cx| { + let _guard = SETTINGS_LOCK.lock(); + let backend = std::sync::Arc::new( + crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string()) + .expect("backend"), + ); + let host = backend.local_host("user"); + // The arrows reach the input as bound actions, as in the app. + crate::desktop::register_key_bindings(cx); + // No bootstrap: the recent roots below must stay as set. + ChatScreen::new_without_start(backend, host, "user".to_string(), cx) + }); + chat.update(cx, |this, _cx| { + this.booting = false; + this.trust_prompts = false; + this.selected_session = Some("s1".to_string()); + this.replace_timeline(vec![user_item("u1", "hello")]); + this.recent_roots = vec!["/home/me/alpha".to_string(), "/home/me/beta".to_string()]; + }); + + let (_host, cx) = cx.add_window_view(|_window, _cx| ChatHost { chat: chat.clone() }); + cx.simulate_resize(gpui::size(px(1200.), px(800.))); + + // Start from the transcript, as after a text-selection press. + let transcript_focus = + cx.update(|_window, app| chat.read(app).transcript_focus.clone().unwrap()); + cx.update(|window, app| window.focus(&transcript_focus, app)); + cx.update(|_window, app| chat.update(app, |this, cx| this.open_project_picker(cx))); + cx.run_until_parked(); + + // Arrows move the highlight and fill the box with its path; the + // rows stay put while they do. Browse restores the typed text. + let picker_state = |cx: &mut gpui::VisualTestContext| { + cx.update(|_window, app| { + let this = chat.read(app); + let picker = this.project_picker.as_ref().expect("picker open"); + ( + picker.selected, + picker.rows.len(), + this.root_input + .as_ref() + .unwrap() + .read(app) + .text() + .to_string(), + ) + }) + }; + // The local host also lists this machine's home folders after + // the recents, so only the count's stability is asserted. + let rows = picker_state(cx).1; + cx.simulate_keystrokes("down"); + cx.run_until_parked(); + assert_eq!(picker_state(cx), (1, rows, "/home/me/beta".to_string())); + // Up from the top wraps to the last row, Browse, which has no path. + cx.simulate_keystrokes("up up"); + cx.run_until_parked(); + assert_eq!(picker_state(cx), (rows - 1, rows, String::new())); + cx.simulate_keystrokes("down"); + cx.run_until_parked(); + assert_eq!(picker_state(cx), (0, rows, "/home/me/alpha".to_string())); + + cx.simulate_input("src"); + cx.run_until_parked(); + cx.update(|_window, app| { + chat.update(app, |this, cx| { + assert_eq!( + this.root_input.as_ref().unwrap().read(cx).text(), + "/home/me/alphasrc" + ); + assert_eq!(this.composer.as_ref().unwrap().read(cx).text(), ""); + assert!( + this.project_picker.is_some(), + "typing keeps the picker open" + ); + }) + }); + } + /// Plain typing while the transcript holds focus must land in the /// composer, including the first character, without a click first. /// Chords and enter/tab keep their meaning instead of stealing focus. diff --git a/apps/maple-agent/app/src/ui/text_input.rs b/apps/maple-agent/app/src/ui/text_input.rs index 310296c83..f7580a374 100644 --- a/apps/maple-agent/app/src/ui/text_input.rs +++ b/apps/maple-agent/app/src/ui/text_input.rs @@ -64,6 +64,9 @@ type KeyHandler = Box< dyn Fn(&gpui::KeyDownEvent, &SharedString, &mut Window, &mut Context) -> bool + 'static, >; +/// Called for the up (-1) and down (1) arrows; returning true consumes +/// the arrow instead of moving the caret. +type VerticalHandler = Box) -> bool + 'static>; #[derive(Clone)] struct ImeBaseline { @@ -118,6 +121,9 @@ pub struct TextInput { on_paste_image: Option, /// First look at every key press; returning true consumes the key. on_key: Option, + /// The up and down arrows are actions of this input, so the key hook + /// never sees them; a list above the input takes them here. + on_vertical: Option, /// Underline words the dictionary rejects (composer only). spell_check: bool, /// Byte ranges of misspelled words, refreshed on every content change @@ -207,6 +213,17 @@ impl TextInput { self } + /// Take the up and down arrows before they move the caret, for an + /// input that drives a list. The handler runs inside this entity's + /// update: defer any write back to the input. + pub fn on_vertical( + mut self, + handler: impl Fn(isize, &mut Window, &mut Context) -> bool + 'static, + ) -> Self { + self.on_vertical = Some(Box::new(handler)); + self + } + pub fn new(placeholder: &str, cx: &mut Context) -> Self { Self { focus_handle: cx.focus_handle(), @@ -232,6 +249,7 @@ impl TextInput { on_enter: None, on_paste_image: None, on_key: None, + on_vertical: None, spell_check: false, misspelled: Vec::new(), spell_generation: 0, @@ -944,11 +962,24 @@ impl TextInput { } } - fn up(&mut self, _: &Up, _: &mut Window, cx: &mut Context) { + /// Offer an arrow to the vertical hook; true when it took it. + fn take_vertical(&mut self, delta: isize, window: &mut Window, cx: &mut Context) -> bool { + let Some(on_vertical) = self.on_vertical.take() else { + return false; + }; + let consumed = on_vertical(delta, window, cx); + self.on_vertical = Some(on_vertical); + consumed + } + + fn up(&mut self, _: &Up, window: &mut Window, cx: &mut Context) { if matches!(self.vim_mode(), Some(VimMode::Normal | VimMode::Visual)) { self.execute_vim_command(VimCommand::Motion(Motion::Up), cx); return; } + if self.take_vertical(-1, window, cx) { + return; + } match self.vertical_neighbor(-1) { Some(offset) => { self.move_to(offset, cx); @@ -961,11 +992,14 @@ impl TextInput { } } - fn down(&mut self, _: &Down, _: &mut Window, cx: &mut Context) { + fn down(&mut self, _: &Down, window: &mut Window, cx: &mut Context) { if matches!(self.vim_mode(), Some(VimMode::Normal | VimMode::Visual)) { self.execute_vim_command(VimCommand::Motion(Motion::Down), cx); return; } + if self.take_vertical(1, window, cx) { + return; + } match self.vertical_neighbor(1) { Some(offset) => { self.move_to(offset, cx); diff --git a/apps/maple-agent/crates/maple-agent/src/agent.rs b/apps/maple-agent/crates/maple-agent/src/agent.rs index 81bb8f620..e3a4e76b1 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent.rs @@ -9183,7 +9183,8 @@ fn is_removed_project_root(path: &str, removed_project_roots: &[String]) -> bool } fn normalize_project_root(path: &Path) -> Result { - let canonical = path + let expanded = expand_home(path); + let canonical = expanded .canonicalize() .map_err(|e| format!("{}: {e}", path.display()))?; if !canonical.is_dir() { @@ -9192,6 +9193,42 @@ fn normalize_project_root(path: &Path) -> Result { Ok(canonical) } +/// `~` and `~/...` mean this host's home directory: a typed path arrives +/// as written on the client, which cannot know the host's home. +fn expand_home(path: &Path) -> PathBuf { + let Some(rest) = path.to_str().and_then(|text| text.strip_prefix('~')) else { + return path.to_path_buf(); + }; + if !(rest.is_empty() || rest.starts_with('/') || rest.starts_with(std::path::MAIN_SEPARATOR)) { + return path.to_path_buf(); + } + match dirs::home_dir() { + Some(home) => PathBuf::from(format!("{}{rest}", home.display())), + None => path.to_path_buf(), + } +} + +#[cfg(test)] +mod home_expansion_tests { + use super::*; + + #[test] + fn tilde_means_the_home_directory() { + let Some(home) = dirs::home_dir() else { + return; + }; + assert_eq!(expand_home(Path::new("~")), home); + assert_eq!(expand_home(Path::new("~/work")), home.join("work")); + // A name that merely starts with a tilde is left alone. + assert_eq!( + expand_home(Path::new("~ben/work")), + PathBuf::from("~ben/work") + ); + assert_eq!(expand_home(Path::new("/tmp/~")), PathBuf::from("/tmp/~")); + assert!(normalize_project_root(Path::new("~")).is_ok()); + } +} + fn agent_root_dir(paths: &AgentPathLayout) -> Result { let path = paths.config_root.clone(); fs::create_dir_all(&path)?; From 60e432bbd879ea104a17d1b7adee897f9c75e92b Mon Sep 17 00:00:00 2001 From: benthecarman Date: Sun, 20 Sep 2026 00:02:54 -0500 Subject: [PATCH 12/37] Harden the remote protocol A review of the crate found denial-of-service holes and lifecycle leaks, none of them an authentication bypass. The carrier accepted WebSocket messages of any size before any authentication and reassembled frame pieces without bound; both are now capped, and an oversized control frame fails at the sender rather than making the peer close. A pairing code could be consumed by two racing connections; it is now compared and consumed under the store lock inside the handshake. Failures of any handshake fed the pairing limiter, so a revoked device's reconnects locked its address out of re-pairing; only pairing-mode failures count, and the limiter prunes and caps its map. A device could take another's public key as its name to make it unrevocable; revoke matches keys first and names are capped. A hello after ready re-ran the device hook; it is refused. Every host connection leaked its writer task, its snapshot cache, and a two-second close wait, and never sent the WebSocket Close; the writer now holds only a cancellation token, teardown closes on every path, snapshots are an eight-entry LRU, and watched roots are released. A replaced connector could report a live host offline; it checks it is still the registered one before emitting. Removing a host no longer waits out a dial. Param decode errors are no longer reported as an unknown method, pairing codes parse non-ASCII correctly and never print in Debug, a host-provided length no longer sizes a client allocation, and integration setup is refused over the wire since the trait documents it as local. Unused surface goes, host answers are encoded once, and the crate has one clock helper. Co-Authored-By: Claude Fable 5.1 --- .../crates/maple-remote/Cargo.toml | 2 +- .../crates/maple-remote/src/client.rs | 84 +++-- .../crates/maple-remote/src/devices.rs | 85 ++++- .../crates/maple-remote/src/frame.rs | 18 +- .../crates/maple-remote/src/hosts.rs | 9 +- .../crates/maple-remote/src/lib.rs | 8 + .../crates/maple-remote/src/manager.rs | 228 ++++++++----- .../crates/maple-remote/src/net.rs | 105 ++++-- .../crates/maple-remote/src/noise.rs | 153 ++++++++- .../crates/maple-remote/src/outbound.rs | 68 +++- .../crates/maple-remote/src/pairing.rs | 187 +++++++++-- .../crates/maple-remote/src/rpc.rs | 40 ++- .../crates/maple-remote/src/server.rs | 316 +++++++++++++----- .../crates/maple-remote/src/streams.rs | 104 ++++-- .../crates/maple-remote/src/wire.rs | 161 ++++++++- .../crates/maple-remote/tests/common/mod.rs | 6 +- .../crates/maple-remote/tests/loopback.rs | 162 ++++++++- .../crates/maple-remote/tests/transport.rs | 107 ++++++ 18 files changed, 1482 insertions(+), 361 deletions(-) diff --git a/apps/maple-agent/crates/maple-remote/Cargo.toml b/apps/maple-agent/crates/maple-remote/Cargo.toml index 5c2b64b20..583b29191 100644 --- a/apps/maple-agent/crates/maple-remote/Cargo.toml +++ b/apps/maple-agent/crates/maple-remote/Cargo.toml @@ -9,7 +9,7 @@ publish = false [dependencies] maple-agent = { path = "../maple-agent", default-features = false } serde = { workspace = true } -serde_json = { workspace = true } +serde_json = { workspace = true, features = ["raw_value"] } tokio = { workspace = true } async-trait = { workspace = true } futures-util = { workspace = true } diff --git a/apps/maple-agent/crates/maple-remote/src/client.rs b/apps/maple-agent/crates/maple-remote/src/client.rs index 791dd0d89..e685907f6 100644 --- a/apps/maple-agent/crates/maple-remote/src/client.rs +++ b/apps/maple-agent/crates/maple-remote/src/client.rs @@ -43,12 +43,12 @@ use crate::streams::StreamReceivers; use crate::wire::{ AnswerQuestion, AskSideQuestion, AttachmentHandle, BootstrapSnapshot, CancelExternalAgent, ClientHello, ContextUsageParams, CreateSession, EVENT_METHOD, Empty, EventEnvelope, HostHello, - HostRequest, IntegrationId, IntegrationRequest, ListSessions, LoadSession, ModelName, - ModelRequest, PROTOCOL_VERSION, PermissionRespond, ProjectRequest, QueueControl, - ReadAttachment, RemoveRoot, RenameSession, ResolveSlashCommand, RootPath, RunId, RunRequest, - SaveDefaultModel, SaveMcpServers, SendMessage, SessionId, SessionRequest, SessionSnapshot, - SetArchived, SetIntegrationEnabled, SetPermissionMode, SetSessionDefaults, SetSessionMcp, - SetTrust, SetWebEnabled, StartRuntime, StoreToolSummary, SuggestDirectories, SummarizeThinking, + HostRequest, IntegrationRequest, ListSessions, LoadSession, ModelName, ModelRequest, + PROTOCOL_VERSION, PermissionRespond, ProjectRequest, QueueControl, ReadAttachment, RemoveRoot, + RenameSession, ResolveSlashCommand, RootPath, RunId, RunRequest, SaveDefaultModel, + SaveMcpServers, SendMessage, SessionId, SessionRequest, SessionSnapshot, SetArchived, + SetIntegrationEnabled, SetPermissionMode, SetSessionDefaults, SetSessionMcp, SetTrust, + SetWebEnabled, StartRuntime, StoreToolSummary, SuggestDirectories, SummarizeThinking, SummarizeToolCall, TimelinePage, TimelinePageParams, WorkingDir, }; @@ -89,6 +89,12 @@ impl Default for ClientConfig { type Pending = oneshot::Sender>; +/// Why a remote client cannot set up a curated integration. +pub const SETUP_IS_LOCAL: &str = "set up integrations on the host itself"; + +/// Most timeline items reserved up front on the host's announced length. +const MAX_PREALLOCATED_ITEMS: usize = 4096; + pub struct RemoteHostBackend { id: HostId, hello: HostHello, @@ -339,22 +345,23 @@ impl RemoteHostBackend { request: &T, timeout: Duration, ) -> Result { - let (value, _) = self.call_raw(request, timeout).await?; + let id = self.next_id.fetch_add(1, Ordering::Relaxed); + let value = self.call_with_id(id, request, timeout).await?; serde_json::from_value(value).map_err(|error| format!("bad answer from the host: {error}")) } - /// Send a request and wait for its answer. Returns the request id with - /// the value so a caller can pair a stream with it. - async fn call_raw( + /// Send a request under a caller-chosen id and wait for its answer. + /// The caller picks the id when it must pair a stream with it. + async fn call_with_id( &self, + id: u64, request: &T, timeout: Duration, - ) -> Result<(Value, u64), String> { + ) -> Result { if let Some(reason) = self.closed.borrow().clone() { return Err(reason); } let (method, params) = crate::wire::encode_request(request)?; - let id = self.next_id.fetch_add(1, Ordering::Relaxed); let (tx, rx) = oneshot::channel(); self.pending.lock().await.insert(id, tx); let message = Message::Request(RpcRequest::new(id, method.clone(), params)); @@ -365,7 +372,7 @@ impl RemoteHostBackend { return Err(error); } match tokio::time::timeout(timeout, rx).await { - Ok(Ok(Ok(value))) => Ok((value, id)), + Ok(Ok(Ok(value))) => Ok(value), Ok(Ok(Err(error))) => Err(error.message), Ok(Err(_)) => Err("the connection ended".to_string()), Err(_) => { @@ -375,13 +382,14 @@ impl RemoteHostBackend { } } - /// Fetch every page of a snapshot's timeline. + /// Fetch every page of a snapshot's timeline. `expected_len` is the + /// host's word and only sizes the first allocation, within reason. async fn page_timeline( &self, session_id: &str, expected_len: usize, ) -> Result, String> { - let mut items = Vec::with_capacity(expected_len); + let mut items = Vec::with_capacity(expected_len.min(MAX_PREALLOCATED_ITEMS)); loop { let page: TimelinePage = self .call(&SessionRequest::Timeline(TimelinePageParams { @@ -594,15 +602,23 @@ impl HostBackend for RemoteHostBackend { session_id: String, attachment_id: String, ) -> Result, String> { - let (value, request_id) = self - .call_raw( - &SessionRequest::ReadAttachment(ReadAttachment { - session_id, - attachment_id, - }), - self.config.request_timeout, - ) - .await?; + let request_id = self.next_id.fetch_add(1, Ordering::Relaxed); + let request = SessionRequest::ReadAttachment(ReadAttachment { + session_id, + attachment_id, + }); + // The host may have opened the stream before its answer failed or + // the wait ran out; whatever it opened for this request goes too. + let value = match self + .call_with_id(request_id, &request, self.config.request_timeout) + .await + { + Ok(value) => value, + Err(error) => { + self.streams.abandon_request(request_id); + return Err(error); + } + }; let handle: AttachmentHandle = serde_json::from_value(value).map_err(|error| error.to_string())?; // The open frame preceded the answer on the same ordered carrier, @@ -611,10 +627,14 @@ impl HostBackend for RemoteHostBackend { .streams .take_by_request(request_id) .ok_or_else(|| format!("no stream {} for the attachment", handle.stream))?; - tokio::time::timeout(self.config.request_timeout, receiver) - .await - .map_err(|_| "attachment transfer timed out".to_string())? - .map_err(|_| "attachment transfer was cut off".to_string())? + match tokio::time::timeout(self.config.request_timeout, receiver).await { + Ok(Ok(result)) => result, + Ok(Err(_)) => Err("attachment transfer was cut off".to_string()), + Err(_) => { + self.streams.abandon_request(request_id); + Err("attachment transfer timed out".to_string()) + } + } } async fn send_message(&self, request: AgentSendMessageRequest) -> Result { @@ -831,9 +851,11 @@ impl HostBackend for RemoteHostBackend { .await } - async fn setup_integration(&self, id: String) -> Result, String> { - self.call(&IntegrationRequest::Setup(IntegrationId { id })) - .await + /// The permission flow behind a setup runs on the host's own screen; + /// the host refuses this over the wire, so answer without a round + /// trip. The method stays on the wire for compatibility. + async fn setup_integration(&self, _id: String) -> Result, String> { + Err(SETUP_IS_LOCAL.to_string()) } async fn session_defaults(&self) -> Result { diff --git a/apps/maple-agent/crates/maple-remote/src/devices.rs b/apps/maple-agent/crates/maple-remote/src/devices.rs index 61c0f5560..fd69a9fcb 100644 --- a/apps/maple-agent/crates/maple-remote/src/devices.rs +++ b/apps/maple-agent/crates/maple-remote/src/devices.rs @@ -10,6 +10,8 @@ use std::sync::Mutex; use serde::{Deserialize, Serialize}; +use crate::now_ms; + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PairedDevice { @@ -33,11 +35,19 @@ pub struct DeviceStore { lock: Mutex<()>, } -fn now_ms() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|elapsed| elapsed.as_millis() as u64) - .unwrap_or(0) +/// Longest device name kept. A client claims its own name; it is display +/// text, not identity. +pub const MAX_DEVICE_NAME_CHARS: usize = 64; + +/// A device name as the host keeps and shows it: control characters +/// removed, cut to [`MAX_DEVICE_NAME_CHARS`], and trimmed. +pub fn clean_device_name(name: &str) -> String { + name.chars() + .filter(|ch| !ch.is_control()) + .take(MAX_DEVICE_NAME_CHARS) + .collect::() + .trim() + .to_string() } impl DeviceStore { @@ -89,20 +99,21 @@ impl DeviceStore { .unwrap_or_else(std::sync::PoisonError::into_inner); let mut file = self.read()?; let now = now_ms(); + let name = clean_device_name(name); let device = match file .devices .iter_mut() .find(|device| device.public_key == public_key) { Some(existing) => { - existing.name = name.to_string(); + existing.name = name; existing.last_seen_ms = Some(now); existing.clone() } None => { let device = PairedDevice { public_key: public_key.to_string(), - name: name.to_string(), + name, user_id: None, paired_at_ms: now, last_seen_ms: Some(now), @@ -129,25 +140,37 @@ impl DeviceStore { else { return Ok(()); }; - device.name = name.to_string(); + let name = clean_device_name(name); + if !name.is_empty() { + device.name = name; + } device.user_id = user_id.map(str::to_string); device.last_seen_ms = Some(now_ms()); self.write(&file) } - /// Remove a device by public key or by name. A name that matches - /// several devices is refused; use the key. + /// Remove a device by public key, or by name when no key matches. A + /// name that matches several devices is refused; use the key. pub fn revoke(&self, key_or_name: &str) -> Result { let _guard = self .lock .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let mut file = self.read()?; + if let Some(index) = file + .devices + .iter() + .position(|device| device.public_key == key_or_name) + { + let removed = file.devices.remove(index); + self.write(&file)?; + return Ok(removed); + } let matches: Vec = file .devices .iter() .enumerate() - .filter(|(_, device)| device.public_key == key_or_name || device.name == key_or_name) + .filter(|(_, device)| device.name == key_or_name) .map(|(index, _)| index) .collect(); match matches.as_slice() { @@ -192,4 +215,44 @@ mod tests { assert!(store.list().unwrap().is_empty()); let _ = std::fs::remove_dir_all(dir); } + + #[test] + fn revoke_prefers_the_key_and_names_are_kept_short_and_printable() { + let dir = std::env::temp_dir().join(format!("maple-devices-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + let store = DeviceStore::new(dir.join("devices.json")); + store.insert("k1", "laptop").unwrap(); + // A device that names itself after another device's key. + store.insert("k2", "k1").unwrap(); + assert_eq!( + store.revoke("k1").unwrap().public_key, + "k1", + "the key match wins over the name match" + ); + assert_eq!( + store.revoke("k1").unwrap().public_key, + "k2", + "with no key left to match, the name is used" + ); + + let long = format!("a\u{0}b\tc\u{7f}{}", "x".repeat(100)); + store.insert("k3", &long).unwrap(); + let name = store.list().unwrap()[0].name.clone(); + assert_eq!(name.chars().count(), MAX_DEVICE_NAME_CHARS); + assert!(name.starts_with("abcxxx"), "{name}"); + assert!(name.chars().all(|ch| !ch.is_control())); + store.touch("k3", "\u{1b}[31m", None).unwrap(); + assert_eq!( + store.list().unwrap()[0].name, + "[31m", + "escape codes are stripped on touch" + ); + store.touch("k3", "\u{0}\u{1}", None).unwrap(); + assert_eq!( + store.list().unwrap()[0].name, + "[31m", + "a name that cleans to nothing keeps the old one" + ); + let _ = std::fs::remove_dir_all(dir); + } } diff --git a/apps/maple-agent/crates/maple-remote/src/frame.rs b/apps/maple-agent/crates/maple-remote/src/frame.rs index d059c0632..bd2b42d6f 100644 --- a/apps/maple-agent/crates/maple-remote/src/frame.rs +++ b/apps/maple-agent/crates/maple-remote/src/frame.rs @@ -18,7 +18,17 @@ pub const MAX_CONTROL_FRAME_BYTES: usize = 4 * 1024 * 1024; /// Largest data frame on a binary stream. pub const MAX_STREAM_FRAME_BYTES: usize = 256 * 1024; -const HEADER_BYTES: usize = 3; +/// Bytes in the `[channel][kind]` header before the payload. +pub const HEADER_BYTES: usize = 3; + +/// Largest payload a frame on `channel` may carry. +pub fn max_payload_bytes(channel: u16) -> usize { + if channel == CONTROL_CHANNEL { + MAX_CONTROL_FRAME_BYTES + } else { + MAX_STREAM_FRAME_BYTES + } +} #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[repr(u8)] @@ -81,11 +91,7 @@ impl Frame { let kind = FrameKind::from_byte(bytes[2]) .ok_or_else(|| format!("unknown frame kind {}", bytes[2]))?; let payload = bytes.slice(HEADER_BYTES..); - let limit = if channel == CONTROL_CHANNEL { - MAX_CONTROL_FRAME_BYTES - } else { - MAX_STREAM_FRAME_BYTES - }; + let limit = max_payload_bytes(channel); if payload.len() > limit { return Err(format!( "frame of {} bytes on channel {channel} exceeds the {limit} byte limit", diff --git a/apps/maple-agent/crates/maple-remote/src/hosts.rs b/apps/maple-agent/crates/maple-remote/src/hosts.rs index a7fa56b26..9ce485ff4 100644 --- a/apps/maple-agent/crates/maple-remote/src/hosts.rs +++ b/apps/maple-agent/crates/maple-remote/src/hosts.rs @@ -12,6 +12,8 @@ use std::sync::Mutex; use serde::{Deserialize, Serialize}; +use crate::now_ms; + /// One way to reach a host. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] @@ -49,13 +51,6 @@ pub struct HostsStore { lock: Mutex<()>, } -fn now_ms() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|elapsed| elapsed.as_millis() as u64) - .unwrap_or(0) -} - /// Decode one saved host, dropping connections that do not parse. fn salvage_host(value: serde_json::Value) -> Option { let object = value.as_object()?; diff --git a/apps/maple-agent/crates/maple-remote/src/lib.rs b/apps/maple-agent/crates/maple-remote/src/lib.rs index 288298e65..af58cb4a8 100644 --- a/apps/maple-agent/crates/maple-remote/src/lib.rs +++ b/apps/maple-agent/crates/maple-remote/src/lib.rs @@ -47,3 +47,11 @@ pub mod wire; pub use client::RemoteHostBackend; pub use server::{HostServer, HostServerConfig}; pub use wire::{ClientHello, HostInfo, PROTOCOL_VERSION}; + +/// Milliseconds since the Unix epoch, for the timestamps in the stores. +pub(crate) fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_millis() as u64) + .unwrap_or(0) +} diff --git a/apps/maple-agent/crates/maple-remote/src/manager.rs b/apps/maple-agent/crates/maple-remote/src/manager.rs index 40968eb6b..d4aad5fec 100644 --- a/apps/maple-agent/crates/maple-remote/src/manager.rs +++ b/apps/maple-agent/crates/maple-remote/src/manager.rs @@ -105,10 +105,6 @@ impl HostManager { &self.store } - pub fn device_id(&self) -> String { - self.device.public_id() - } - /// Whether `id` has a live connection right now. pub fn is_online(&self, id: &str) -> bool { self.online @@ -129,6 +125,18 @@ impl HostManager { } } + /// Whether `token` still belongs to the connector registered for `id`. + /// A connector that was replaced or removed keeps running until it + /// notices its cancellation; nothing it says after that may reach the + /// UI or the online set, or it would overwrite its successor's state. + fn is_current(&self, id: &str, token: &CancellationToken) -> bool { + self.connectors + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(id) + .is_some_and(|current| current == token) + } + /// Start a connector for every saved host. Must run inside a Tokio /// runtime. pub fn start(self: &Arc) { @@ -183,26 +191,6 @@ impl HostManager { Ok(host) } - /// Add another address to a saved host and reconnect through it when - /// the current connection drops. - pub fn add_connection(&self, id: &str, address: &str) -> Result<(), String> { - let address = address.trim(); - if address.is_empty() { - return Err("Enter an address".to_string()); - } - let Some(saved) = self.store.get(id)? else { - return Err("no such host".to_string()); - }; - self.store.upsert(SavedHost { - connections: vec![HostConnection::Direct { - address: address.to_string(), - }], - ..saved - })?; - self.emit(HostManagerEvent::HostsChanged(self.store.list()?)); - Ok(()) - } - pub fn rename(&self, id: &str, name: &str) -> Result<(), String> { self.store.rename(id, name)?; self.emit(HostManagerEvent::HostsChanged(self.store.list()?)); @@ -261,6 +249,22 @@ impl HostManager { cancel: CancellationToken, ) { let id = HostId::new(host.id.clone()); + // Status and the online set belong to the current connector only. + let status = |name: &str, status: HostStatus, backend: Option>| { + if self.is_current(&host.id, &cancel) { + self.emit(HostManagerEvent::Status { + host: id.clone(), + name: name.to_string(), + status, + backend, + }); + } + }; + let online = |online: bool| { + if self.is_current(&host.id, &cancel) { + self.set_online(&host.id, online); + } + }; let mut attempt: u32 = 0; while !cancel.is_cancelled() { // Connections may have been added since; read them fresh. @@ -274,48 +278,24 @@ impl HostManager { let backend = match initial.take() { Some(backend) => Ok(backend), None => { - self.emit(HostManagerEvent::Status { - host: id.clone(), - name: name.clone(), - status: HostStatus::Connecting, - backend: None, - }); + status(&name, HostStatus::Connecting, None); self.dial(&saved, &cancel).await } }; match backend { Ok(backend) => { attempt = 0; - self.set_online(&host.id, true); - self.emit(HostManagerEvent::Status { - host: id.clone(), - name: name.clone(), - status: HostStatus::Online, - backend: Some(Arc::clone(&backend)), - }); + online(true); + status(&name, HostStatus::Online, Some(Arc::clone(&backend))); let reason = self.forward_events(&id, &backend, &cancel).await; - self.set_online(&host.id, false); + online(false); backend.close().await; - self.emit(HostManagerEvent::Status { - host: id.clone(), - name: name.clone(), - status: HostStatus::Offline { - reason: reason.clone(), - }, - backend: None, - }); + status(&name, HostStatus::Offline { reason }, None); if cancel.is_cancelled() { return; } } - Err(reason) => { - self.emit(HostManagerEvent::Status { - host: id.clone(), - name, - status: HostStatus::Offline { reason }, - backend: None, - }); - } + Err(reason) => status(&name, HostStatus::Offline { reason }, None), } let delay = backoff(attempt); attempt = attempt.saturating_add(1); @@ -327,7 +307,8 @@ impl HostManager { } /// Try each connection in order; the first that completes a handshake - /// wins. + /// wins. Cancelling ends the attempt at once rather than after the + /// dial's own timeout. async fn dial( &self, host: &SavedHost, @@ -338,31 +319,25 @@ impl HostManager { } let mut last_error = String::new(); for connection in &host.connections { - if cancel.is_cancelled() { - return Err("cancelled".to_string()); - } let HostConnection::Direct { address } = connection; - let dialed = connect_direct( - address, - &self.device, - ConnectTarget::Host { - host_key: host.id.clone(), - }, - ) - .await; - match dialed { - Ok(dialed) => { - match RemoteHostBackend::connect( - dialed.carrier, - self.hello.clone(), - self.config.clone(), - ) + let attempt = async { + let dialed = connect_direct( + address, + &self.device, + ConnectTarget::Host { + host_key: host.id.clone(), + }, + ) + .await?; + RemoteHostBackend::connect(dialed.carrier, self.hello.clone(), self.config.clone()) .await - { - Ok(backend) => return Ok(backend), - Err(error) => last_error = error, - } - } + }; + let outcome = tokio::select! { + outcome = attempt => outcome, + _ = cancel.cancelled() => return Err("cancelled".to_string()), + }; + match outcome { + Ok(backend) => return Ok(backend), Err(error) => last_error = error, } } @@ -414,6 +389,103 @@ fn backoff(attempt: u32) -> Duration { mod tests { use super::*; + fn manager( + dir: &std::path::Path, + ) -> (Arc, mpsc::UnboundedReceiver) { + let device = StaticKey::generate().unwrap(); + let hello = ClientHello { + protocol: crate::wire::PROTOCOL_VERSION, + app_version: "0.1.0".to_string(), + pcr_environment: "Development".to_string(), + features: crate::wire::features(), + device: crate::wire::DeviceInfo { + public_key: device.public_id(), + name: "test".to_string(), + user_id: None, + }, + }; + let store = Arc::new(HostsStore::new(dir.join("hosts.json"))); + HostManager::new(device, hello, store, ClientConfig::default()) + } + + #[tokio::test] + async fn removing_a_host_drops_its_dial_and_silences_its_connector() { + let dir = std::env::temp_dir().join(format!("maple-manager-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + // A listener that accepts and then never answers the WebSocket + // handshake, so a dial hangs until it is cancelled. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap().to_string(); + let (accepted_tx, accepted_rx) = tokio::sync::oneshot::channel(); + tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut buffer = vec![0u8; 4096]; + // The WebSocket request arrives; then nothing more until EOF. + let mut total = 0; + loop { + match tokio::io::AsyncReadExt::read(&mut stream, &mut buffer).await { + Ok(0) | Err(_) => break, + Ok(read) => total += read, + } + } + let _ = accepted_tx.send(total); + }); + + let (manager, mut events) = manager(&dir); + let host = manager + .store() + .upsert(SavedHost { + id: StaticKey::generate().unwrap().public_id(), + name: "slow".to_string(), + connections: vec![HostConnection::Direct { address }], + paired_at_ms: 0, + }) + .unwrap(); + manager.start(); + // The connector announces it is connecting, then hangs in the dial. + let started = std::time::Instant::now(); + loop { + let event = tokio::time::timeout(Duration::from_secs(5), events.recv()) + .await + .unwrap() + .unwrap(); + if let HostManagerEvent::Status { + status: HostStatus::Connecting, + .. + } = event + { + break; + } + } + manager.remove(&host.id).unwrap(); + // The peer sees EOF as soon as the dial is dropped, long before the + // handshake timeout. How much of the WebSocket request was written + // first depends on scheduling and does not matter. + let _read = tokio::time::timeout(Duration::from_secs(5), accepted_rx) + .await + .expect("the dial was dropped at once") + .unwrap(); + assert!(started.elapsed() < Duration::from_secs(5)); + // After the removal notice the old connector says nothing more. + let mut statuses = Vec::new(); + while let Ok(Some(event)) = + tokio::time::timeout(Duration::from_millis(300), events.recv()).await + { + if let HostManagerEvent::Status { status, .. } = event { + statuses.push(status); + } + } + assert_eq!( + statuses, + vec![HostStatus::Offline { + reason: "removed".to_string() + }] + ); + assert!(!manager.is_online(&host.id)); + manager.shutdown(); + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn backoff_grows_to_the_cap_and_stays_jittered() { for attempt in 0..12 { diff --git a/apps/maple-agent/crates/maple-remote/src/net.rs b/apps/maple-agent/crates/maple-remote/src/net.rs index 51eb011b1..f846b1006 100644 --- a/apps/maple-agent/crates/maple-remote/src/net.rs +++ b/apps/maple-agent/crates/maple-remote/src/net.rs @@ -11,6 +11,7 @@ use std::sync::Arc; use std::time::Duration; use tokio::net::{TcpListener, TcpStream}; +use tokio_tungstenite::tungstenite::protocol::WebSocketConfig; use tokio_util::sync::CancellationToken; use crate::carrier::Carrier; @@ -26,6 +27,18 @@ const UNNAMED_DEVICE: &str = "new device"; /// Time a peer gets to finish the WebSocket and Noise handshakes. const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(15); +/// Largest WebSocket message either side reads. A Noise message is at +/// most 65535 bytes, so anything larger is not this protocol and is +/// refused before it is buffered. +pub const MAX_WEBSOCKET_MESSAGE_BYTES: usize = 65535; + +/// The WebSocket configuration both roles use. +pub fn websocket_config() -> WebSocketConfig { + WebSocketConfig::default() + .max_message_size(Some(MAX_WEBSOCKET_MESSAGE_BYTES)) + .max_frame_size(Some(MAX_WEBSOCKET_MESSAGE_BYTES)) +} + /// What a listening host needs besides the server: its key and its /// device and pairing records. pub struct HostStores { @@ -81,46 +94,62 @@ async fn handle_connection( shutdown: CancellationToken, ) -> Result<(), String> { let _ = stream.set_nodelay(true); - let established = tokio::time::timeout(HANDSHAKE_TIMEOUT, async { - let socket = tokio_tungstenite::accept_async(stream) - .await - .map_err(|error| format!("websocket accept: {error}"))?; - let pending = stores.pending_pairing.current(); - let pairing_psk = match &pending { - Some(pending) if stores.limiter.allows(peer.ip()) => Some(pending.code()?.psk()), - Some(_) => { - log::warn!("pairing attempts from {peer} are rate limited"); - None - } - None => None, - }; - let devices = Arc::clone(&stores.devices); - let is_paired = move |key: &[u8; 32]| devices.is_paired(&encode_key(key)); + // One deadline covers both handshakes. + let deadline = tokio::time::Instant::now() + HANDSHAKE_TIMEOUT; + let socket = tokio::time::timeout_at( + deadline, + tokio_tungstenite::accept_async_with_config(stream, Some(websocket_config())), + ) + .await + .map_err(|_| "handshake timed out".to_string())? + .map_err(|error| format!("websocket accept: {error}"))?; + let pending_code = match stores.pending_pairing.current() { + Some(pending) if stores.limiter.allows(peer.ip()) => Some(pending.code()?), + Some(_) => { + log::warn!("pairing attempts from {peer} are rate limited"); + None + } + None => None, + }; + let devices = Arc::clone(&stores.devices); + let is_paired = move |key: &[u8; 32]| devices.is_paired(&encode_key(key)); + let confirm_pairing = |key: &[u8; 32]| -> Result<(), String> { + let code = pending_code + .as_ref() + .ok_or_else(|| "no pairing code is pending".to_string())?; + stores.pending_pairing.consume_if(code)?; + stores.devices.insert(&encode_key(key), UNNAMED_DEVICE)?; + Ok(()) + }; + let established = tokio::time::timeout_at( + deadline, noise::respond( socket, stores.key.private(), Respond { - pairing_psk, + pairing_psk: pending_code.as_ref().map(PairingCode::psk), is_paired: &is_paired, + confirm_pairing: &confirm_pairing, }, - ) - .await - }) + ), + ) .await .map_err(|_| "handshake timed out".to_string())?; let established = match established { Ok(established) => established, - Err(error) => { - // A failed pairing counts against the address whatever the - // reason; a wrong code and a probe look the same. - stores.limiter.record_failure(peer.ip()); - return Err(error); + Err(refused) => { + // Only a failed pairing counts against the address: a wrong + // code and a probe of the pairing pattern look the same. A + // session handshake a revoked device keeps retrying must not + // lock its address out of pairing again. + if refused.mode == Some(HandshakeMode::Pair) { + stores.limiter.record_failure(peer.ip()); + } + return Err(refused.message); } }; let device_key = encode_key(&established.remote_static); if established.mode == HandshakeMode::Pair { - stores.pending_pairing.consume(); - stores.devices.insert(&device_key, UNNAMED_DEVICE)?; log::info!("paired device {device_key} from {peer}"); } let connection = shutdown.child_token(); @@ -171,11 +200,13 @@ pub async fn connect_direct( target: ConnectTarget, ) -> Result { let url = format!("ws://{address}/"); - let (socket, _) = - tokio::time::timeout(HANDSHAKE_TIMEOUT, tokio_tungstenite::connect_async(&url)) - .await - .map_err(|_| format!("connecting to {address} timed out"))? - .map_err(|error| format!("cannot connect to {address}: {error}"))?; + let (socket, _) = tokio::time::timeout( + HANDSHAKE_TIMEOUT, + tokio_tungstenite::connect_async_with_config(&url, Some(websocket_config()), true), + ) + .await + .map_err(|_| format!("connecting to {address} timed out"))? + .map_err(|error| format!("cannot connect to {address}: {error}"))?; let initiate = match &target { ConnectTarget::Pair(code) => Initiate::Pair { psk: code.psk() }, ConnectTarget::Host { host_key } => Initiate::Session { @@ -199,3 +230,15 @@ pub async fn connect_direct( host_key, }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn websocket_messages_are_capped_at_one_noise_message() { + let config = websocket_config(); + assert_eq!(config.max_message_size, Some(65535)); + assert_eq!(config.max_frame_size, Some(65535)); + } +} diff --git a/apps/maple-agent/crates/maple-remote/src/noise.rs b/apps/maple-agent/crates/maple-remote/src/noise.rs index c95880553..1a075f411 100644 --- a/apps/maple-agent/crates/maple-remote/src/noise.rs +++ b/apps/maple-agent/crates/maple-remote/src/noise.rs @@ -15,7 +15,8 @@ //! In the pairing pattern the client sends the last handshake message, so //! it could not tell a wrong code from success until the host dropped it. //! The host therefore sends one empty transport message once its side -//! completes; the client must decrypt it before it trusts the session. +//! completes and it has spent the code and recorded the device; the +//! client must decrypt it before it trusts the session. //! //! Noise transport messages hold at most 65535 bytes, so a frame is cut //! into pieces; each piece carries one continuation byte before the @@ -31,7 +32,7 @@ use tokio_tungstenite::WebSocketStream; use tokio_tungstenite::tungstenite::Message; use crate::carrier::{Carrier, FrameSink, FrameStream}; -use crate::frame::Frame; +use crate::frame::{Frame, HEADER_BYTES, MAX_CONTROL_FRAME_BYTES}; /// Pairing: statics exchanged, authenticated by the pre-shared code. pub const PAIRING_PATTERN: &str = "Noise_XXpsk3_25519_ChaChaPoly_BLAKE2s"; @@ -49,6 +50,10 @@ const PIECE_BYTES: usize = 65535 - 16 - 1; const MORE: u8 = 1; const LAST: u8 = 0; +/// Largest frame a peer may reassemble from pieces: the biggest control +/// frame plus its header. Stream frames are smaller still. +const MAX_REASSEMBLED_BYTES: usize = HEADER_BYTES + MAX_CONTROL_FRAME_BYTES; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum HandshakeMode { Pair, @@ -100,6 +105,26 @@ pub struct Respond<'a> { pub pairing_psk: Option<[u8; 32]>, /// Whether a device's static key is paired, for a session handshake. pub is_paired: &'a (dyn Fn(&[u8; 32]) -> bool + Send + Sync), + /// Called with the device's static key once a pairing handshake + /// completes and before the client is told its code was right. It + /// spends the code and records the device; an error refuses the + /// pairing, so two clients racing on one code cannot both succeed. + pub confirm_pairing: &'a (dyn Fn(&[u8; 32]) -> Result<(), String> + Send + Sync), +} + +/// Why the host side of a handshake failed. +#[derive(Debug)] +pub struct HandshakeRefused { + /// The handshake the client asked for, once its first byte was read. + /// `None` when the failure came before that. + pub mode: Option, + pub message: String, +} + +impl std::fmt::Display for HandshakeRefused { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } } /// The outcome of a handshake: the encrypted carrier and the peer's static @@ -214,18 +239,42 @@ pub async fn respond( mut socket: WebSocketStream, local_private: &[u8; 32], respond: Respond<'_>, -) -> Result +) -> Result where S: AsyncRead + AsyncWrite + Unpin + Send + 'static, { + let before_mode = |message: String| HandshakeRefused { + mode: None, + message, + }; let first = next_binary(&mut socket) - .await? - .ok_or_else(|| "the client closed before the handshake".to_string())?; + .await + .map_err(before_mode)? + .ok_or_else(|| before_mode("the client closed before the handshake".to_string()))?; let (&mode_byte, first_message) = first .split_first() - .ok_or_else(|| "empty handshake message".to_string())?; + .ok_or_else(|| before_mode("empty handshake message".to_string()))?; let mode = HandshakeMode::from_byte(mode_byte) - .ok_or_else(|| format!("unknown handshake mode {mode_byte}"))?; + .ok_or_else(|| before_mode(format!("unknown handshake mode {mode_byte}")))?; + respond_as(socket, local_private, respond, mode, first_message) + .await + .map_err(|message| HandshakeRefused { + mode: Some(mode), + message, + }) +} + +/// The rest of the host side once the handshake's mode is known. +async fn respond_as( + mut socket: WebSocketStream, + local_private: &[u8; 32], + respond: Respond<'_>, + mode: HandshakeMode, + first_message: &[u8], +) -> Result +where + S: AsyncRead + AsyncWrite + Unpin + Send + 'static, +{ let mut state = match mode { HandshakeMode::Pair => { let psk = respond @@ -276,7 +325,9 @@ where .into_transport_mode() .map_err(|error| format!("noise transport: {error}"))?; if mode == HandshakeMode::Pair { - // Tell the client its code was right; see the module docs. + // Spend the code and record the device, then tell the client its + // code was right; see the module docs. + (respond.confirm_pairing)(&remote)?; let mut out = vec![0u8; 16]; let written = transport .write_message(&[], &mut out) @@ -323,7 +374,7 @@ where stream: Box::new(NoiseStream { stream, transport, - partial: Vec::new(), + reassembly: Reassembly::default(), }), } } @@ -381,11 +432,38 @@ where } } +/// The frame being put back together from its pieces. +#[derive(Default)] +struct Reassembly { + partial: Vec, +} + +impl Reassembly { + /// Take one decrypted piece. `Ok(Some(_))` is a whole frame's bytes, + /// `Ok(None)` means more pieces follow (an empty piece is ignored), and + /// `Err` means the peer is sending a frame larger than any it may send. + fn push(&mut self, plaintext: &[u8]) -> Result, String> { + let Some((&flag, piece)) = plaintext.split_first() else { + return Ok(None); + }; + if self.partial.len() + piece.len() > MAX_REASSEMBLED_BYTES { + self.partial = Vec::new(); + return Err(format!( + "frame grew past the {MAX_REASSEMBLED_BYTES} byte limit while reassembling" + )); + } + self.partial.extend_from_slice(piece); + if flag == MORE { + return Ok(None); + } + Ok(Some(Bytes::from(std::mem::take(&mut self.partial)))) + } +} + struct NoiseStream { stream: futures_util::stream::SplitStream>, transport: Transport, - /// Pieces of the frame being reassembled. - partial: Vec, + reassembly: Reassembly, } #[async_trait] @@ -421,14 +499,14 @@ where } } }; - let Some((&flag, piece)) = plaintext.split_first() else { - continue; + let bytes = match self.reassembly.push(&plaintext) { + Ok(Some(bytes)) => bytes, + Ok(None) => continue, + Err(error) => { + log::warn!("bad frame; closing: {error}"); + return None; + } }; - self.partial.extend_from_slice(piece); - if flag == MORE { - continue; - } - let bytes = Bytes::from(std::mem::take(&mut self.partial)); match Frame::decode(bytes) { Ok(frame) => return Some(frame), Err(error) => { @@ -439,3 +517,42 @@ where } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reassembly_rejects_a_frame_past_the_control_limit() { + let mut reassembly = Reassembly::default(); + assert_eq!(reassembly.push(&[]).unwrap(), None); + let mut more = vec![MORE]; + more.extend_from_slice(&[1, 2]); + assert_eq!(reassembly.push(&more).unwrap(), None); + let mut last = vec![LAST]; + last.extend_from_slice(&[3]); + assert_eq!( + reassembly.push(&last).unwrap(), + Some(Bytes::from_static(&[1, 2, 3])) + ); + + let mut piece = vec![MORE]; + piece.extend(std::iter::repeat_n(0u8, PIECE_BYTES)); + let mut total = 0; + let error = loop { + match reassembly.push(&piece) { + Ok(None) => total += PIECE_BYTES, + Ok(Some(_)) => panic!("MORE pieces never complete a frame"), + Err(error) => break error, + } + }; + assert!(total <= MAX_REASSEMBLED_BYTES); + assert!(total + PIECE_BYTES > MAX_REASSEMBLED_BYTES); + assert!(error.contains("limit"), "{error}"); + // The partial frame is dropped with the error. + assert_eq!( + reassembly.push(&last).unwrap(), + Some(Bytes::from_static(&[3])) + ); + } +} diff --git a/apps/maple-agent/crates/maple-remote/src/outbound.rs b/apps/maple-agent/crates/maple-remote/src/outbound.rs index 1dd414eba..b217e8880 100644 --- a/apps/maple-agent/crates/maple-remote/src/outbound.rs +++ b/apps/maple-agent/crates/maple-remote/src/outbound.rs @@ -11,7 +11,7 @@ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use tokio::sync::mpsc; -use crate::frame::Frame; +use crate::frame::{Frame, HEADER_BYTES, max_payload_bytes}; /// Default byte limit for one connection's outbound queue. pub const DEFAULT_MAX_OUTBOUND_BYTES: usize = 64 * 1024 * 1024; @@ -53,10 +53,19 @@ pub fn channel(max_bytes: usize) -> (Outbound, OutboundQueue) { } impl Outbound { - /// Queue a frame. Fails when the queue is closed or would overflow; - /// an overflow also marks the connection for closing. + /// Queue a frame. Fails when the frame is larger than the peer would + /// accept, when the queue is closed, or when the frame would overflow + /// the budget; an overflow also marks the connection for closing. pub fn try_send(&self, frame: Frame) -> Result<(), String> { - let bytes = frame.payload.len() + 3; + let limit = max_payload_bytes(frame.channel); + if frame.payload.len() > limit { + return Err(format!( + "frame of {} bytes on channel {} exceeds the {limit} byte limit", + frame.payload.len(), + frame.channel + )); + } + let bytes = frame.payload.len() + HEADER_BYTES; let queued = self.shared.queued_bytes.fetch_add(bytes, Ordering::AcqRel) + bytes; if queued > self.shared.limit { self.shared.queued_bytes.fetch_sub(bytes, Ordering::AcqRel); @@ -72,30 +81,31 @@ impl Outbound { }) } - /// Same as [`Self::try_send`]; the queue never blocks, so this exists - /// for callers in async context that want the same shape as a sink. - pub async fn send(&self, frame: Frame) -> Result<(), String> { - self.try_send(frame) - } - /// True once a frame overflowed the budget. pub fn overflowed(&self) -> bool { self.shared.overflowed.load(Ordering::Acquire) } - - pub fn is_closed(&self) -> bool { - self.tx.is_closed() - } } impl OutboundQueue { /// The next frame to write, with its bytes released from the budget. pub async fn recv(&mut self) -> Option { let frame = self.rx.recv().await?; + self.release(&frame); + Some(frame) + } + + /// A frame already queued, without waiting for one. + pub fn try_recv(&mut self) -> Option { + let frame = self.rx.try_recv().ok()?; + self.release(&frame); + Some(frame) + } + + fn release(&self, frame: &Frame) { self.shared .queued_bytes - .fetch_sub(frame.payload.len() + 3, Ordering::AcqRel); - Some(frame) + .fetch_sub(frame.payload.len() + HEADER_BYTES, Ordering::AcqRel); } } @@ -116,4 +126,30 @@ mod tests { drop(queue); assert!(out.try_send(Frame::control("late")).is_err()); } + + #[tokio::test] + async fn an_oversized_frame_fails_at_the_sender_without_overflowing() { + use crate::frame::{FrameKind, MAX_CONTROL_FRAME_BYTES, MAX_STREAM_FRAME_BYTES}; + let (out, mut queue) = channel(usize::MAX); + let error = out + .try_send(Frame::control(vec![0u8; MAX_CONTROL_FRAME_BYTES + 1])) + .unwrap_err(); + assert!(error.contains("exceeds"), "{error}"); + assert!(!out.overflowed(), "a refused frame is not an overflow"); + assert!( + out.try_send(Frame { + channel: 4, + kind: FrameKind::Data, + payload: vec![0u8; MAX_STREAM_FRAME_BYTES + 1].into(), + }) + .is_err() + ); + out.try_send(Frame::control(vec![0u8; MAX_CONTROL_FRAME_BYTES])) + .unwrap(); + assert_eq!( + queue.recv().await.unwrap().payload.len(), + MAX_CONTROL_FRAME_BYTES, + "only the frame within the limit was queued" + ); + } } diff --git a/apps/maple-agent/crates/maple-remote/src/pairing.rs b/apps/maple-agent/crates/maple-remote/src/pairing.rs index 7abbd43fc..f28790a39 100644 --- a/apps/maple-agent/crates/maple-remote/src/pairing.rs +++ b/apps/maple-agent/crates/maple-remote/src/pairing.rs @@ -16,6 +16,8 @@ use rand::RngCore as _; use serde::{Deserialize, Serialize}; use sha2::{Digest as _, Sha256}; +use crate::now_ms; + const ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ"; /// Characters in a code: 16 × 5 bits = 80 bits. pub const CODE_CHARS: usize = 16; @@ -23,9 +25,16 @@ pub const CODE_CHARS: usize = 16; pub const CODE_TTL: Duration = Duration::from_secs(5 * 60); const PSK_DOMAIN: &[u8] = b"maple-pairing-v1"; -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Clone, PartialEq, Eq)] pub struct PairingCode(String); +/// The code is a secret; `Debug` never shows it. +impl std::fmt::Debug for PairingCode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("PairingCode(..)") + } +} + impl PairingCode { pub fn generate() -> Self { let mut bytes = [0u8; 10]; @@ -55,12 +64,13 @@ impl PairingCode { 'I' | 'L' => '1', other => other, }; - if !ALPHABET.contains(&(ch as u8)) { + let byte = u8::try_from(ch).ok().filter(|byte| ALPHABET.contains(byte)); + let Some(byte) = byte else { return Err(format!("'{ch}' is not part of a pairing code")); - } - code.push(ch); + }; + code.push(byte as char); } - if code.len() != CODE_CHARS { + if code.chars().count() != CODE_CHARS { return Err(format!("a pairing code has {CODE_CHARS} characters")); } Ok(Self(code)) @@ -90,7 +100,7 @@ impl PairingCode { } /// The code a host currently accepts, as stored on disk. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PendingPairing { pub code: String, @@ -98,6 +108,16 @@ pub struct PendingPairing { pub expires_ms: u64, } +/// The code is a secret; `Debug` shows only the validity window. +impl std::fmt::Debug for PendingPairing { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PendingPairing") + .field("created_ms", &self.created_ms) + .field("expires_ms", &self.expires_ms) + .finish_non_exhaustive() + } +} + impl PendingPairing { pub fn code(&self) -> Result { PairingCode::parse(&self.code) @@ -108,13 +128,6 @@ impl PendingPairing { } } -fn now_ms() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|elapsed| elapsed.as_millis() as u64) - .unwrap_or(0) -} - /// The private file that holds the pending code. pub struct PendingPairingStore { path: PathBuf, @@ -156,6 +169,12 @@ impl PendingPairingStore { .lock .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); + self.read_valid() + } + + /// The record on disk when it is still valid; an expired one is + /// removed. Callers hold the lock. + fn read_valid(&self) -> Option { let bytes = std::fs::read(&self.path).ok()?; let pending: PendingPairing = serde_json::from_slice(&bytes).ok()?; if pending.is_valid_at(now_ms()) { @@ -166,7 +185,8 @@ impl PendingPairingStore { } } - /// A pairing succeeded: the code is spent. + /// Spend the pending code without checking which one it is: the + /// operator withdrew it. pub fn consume(&self) { let _guard = self .lock @@ -174,16 +194,42 @@ impl PendingPairingStore { .unwrap_or_else(std::sync::PoisonError::into_inner); let _ = std::fs::remove_file(&self.path); } + + /// A pairing with `code` completed: spend the code if it is still the + /// pending one. Fails when it was already spent or replaced, so of two + /// pairings racing on one code exactly one succeeds. + pub fn consume_if(&self, code: &PairingCode) -> Result<(), String> { + let _guard = self + .lock + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let stored = self + .read_valid() + .and_then(|pending| pending.code().ok()) + .filter(|pending| pending == code); + if stored.is_none() { + return Err("the pairing code was already used".to_string()); + } + std::fs::remove_file(&self.path) + .map_err(|error| format!("cannot spend the pairing code: {error}")) + } } -/// Pairing attempts per source address. A wrong code is one attempt; too -/// many in the window lock that address out until the window passes. +/// Pairing attempts per source address. A failed pairing handshake is one +/// attempt; too many in the window lock that address out until the window +/// passes. Session handshakes never count, so a revoked device that keeps +/// reconnecting does not lock its address out of pairing again. pub struct PairingLimiter { attempts: Mutex>>, max_attempts: usize, window: Duration, } +/// Addresses remembered at once. Past this the address with the oldest +/// latest failure is forgotten, so a flood of sources cannot grow the map +/// without bound. +pub const MAX_TRACKED_ADDRESSES: usize = 1024; + impl Default for PairingLimiter { fn default() -> Self { Self::new(5, Duration::from_secs(10 * 60)) @@ -205,19 +251,49 @@ impl PairingLimiter { .attempts .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); + let Some(recent) = attempts.get_mut(&ip) else { + return true; + }; let now = Instant::now(); - let recent = attempts.entry(ip).or_default(); recent.retain(|at| now.duration_since(*at) < self.window); + if recent.is_empty() { + attempts.remove(&ip); + return true; + } recent.len() < self.max_attempts } + /// A pairing handshake from `ip` failed. pub fn record_failure(&self, ip: IpAddr) { + let mut attempts = self + .attempts + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let now = Instant::now(); + if !attempts.contains_key(&ip) { + attempts.retain(|_, recent| { + recent.retain(|at| now.duration_since(*at) < self.window); + !recent.is_empty() + }); + if attempts.len() >= MAX_TRACKED_ADDRESSES { + let oldest = attempts + .iter() + .min_by_key(|(_, recent)| recent.iter().max().copied()) + .map(|(ip, _)| *ip); + if let Some(oldest) = oldest { + attempts.remove(&oldest); + } + } + } + attempts.entry(ip).or_default().push(now); + } + + /// Addresses with a failure still inside the window. + pub fn tracked_addresses(&self) -> usize { self.attempts .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) - .entry(ip) - .or_default() - .push(Instant::now()); + .len() } } @@ -250,6 +326,49 @@ mod tests { assert_eq!(code.psk(), PairingCode::parse(&shown).unwrap().psk()); } + #[test] + fn non_ascii_input_is_refused_and_debug_hides_the_code() { + // U+0150 truncates to 0x50, 'P', which is in the alphabet. + let error = PairingCode::parse("\u{150}000000000000000").unwrap_err(); + assert!(error.contains("not part of"), "{error}"); + // Sixteen characters, one of them multi-byte: not a length error. + let error = PairingCode::parse("000000000000000\u{e9}").unwrap_err(); + assert!(error.contains("not part of"), "{error}"); + assert!(PairingCode::parse("0000000000000000").is_ok()); + + let code = PairingCode::generate(); + let shown = format!("{code:?}"); + assert!(!shown.contains(code.as_str()), "{shown}"); + let pending = PendingPairing { + code: code.as_str().to_string(), + created_ms: 1, + expires_ms: 2, + }; + let shown = format!("{pending:?}"); + assert!(!shown.contains(code.as_str()), "{shown}"); + assert!(shown.contains("expires_ms"), "{shown}"); + } + + #[test] + fn a_code_is_spent_only_by_the_pairing_that_used_it() { + let dir = std::env::temp_dir().join(format!("maple-pairing-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + let store = PendingPairingStore::new(dir.join("pending.json")); + let code = PairingCode::generate(); + let other = PairingCode::generate(); + assert!(store.consume_if(&code).is_err(), "nothing pending"); + store.publish(&code).unwrap(); + assert!(store.consume_if(&other).is_err(), "a different code"); + assert!(store.current().is_some(), "the pending code survives"); + store.consume_if(&code).unwrap(); + assert!(store.current().is_none()); + assert!( + store.consume_if(&code).is_err(), + "the second pairing on one code loses" + ); + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn pending_codes_expire_and_are_consumed() { let dir = std::env::temp_dir().join(format!("maple-pairing-{}", uuid::Uuid::new_v4())); @@ -282,5 +401,33 @@ mod tests { limiter.record_failure(ip); assert!(!limiter.allows(ip)); assert!(limiter.allows(other)); + assert_eq!(limiter.tracked_addresses(), 1, "asking never adds an entry"); + } + + #[test] + fn the_limiter_forgets_quiet_addresses_and_caps_how_many_it_tracks() { + let limiter = PairingLimiter::new(2, Duration::from_millis(1)); + let ip: IpAddr = "10.0.0.1".parse().unwrap(); + limiter.record_failure(ip); + limiter.record_failure(ip); + assert!(!limiter.allows(ip)); + std::thread::sleep(Duration::from_millis(5)); + assert!(limiter.allows(ip), "the window passed"); + assert_eq!(limiter.tracked_addresses(), 0, "an empty entry is pruned"); + + let limiter = PairingLimiter::new(2, Duration::from_secs(60)); + let first: IpAddr = "10.1.0.0".parse().unwrap(); + limiter.record_failure(first); + limiter.record_failure(first); + assert!(!limiter.allows(first)); + for index in 1..=MAX_TRACKED_ADDRESSES as u32 { + let ip = IpAddr::from(std::net::Ipv4Addr::from(0x0a01_0000 + index)); + limiter.record_failure(ip); + } + assert_eq!(limiter.tracked_addresses(), MAX_TRACKED_ADDRESSES); + assert!( + limiter.allows(first), + "the address with the oldest failure was forgotten" + ); } } diff --git a/apps/maple-agent/crates/maple-remote/src/rpc.rs b/apps/maple-agent/crates/maple-remote/src/rpc.rs index 3a306857d..104edb9b9 100644 --- a/apps/maple-agent/crates/maple-remote/src/rpc.rs +++ b/apps/maple-agent/crates/maple-remote/src/rpc.rs @@ -7,9 +7,10 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; /// Error codes. The JSON-RPC reserved range is honored; ours start at -/// -32000 as the spec allows. +/// -32000 as the spec allows. There is no parse error: a control frame +/// that is not a JSON-RPC message closes the connection instead of being +/// answered, because nothing in it can be trusted to carry an id. pub mod code { - pub const PARSE_ERROR: i64 = -32700; pub const INVALID_REQUEST: i64 = -32600; pub const METHOD_NOT_FOUND: i64 = -32601; pub const INVALID_PARAMS: i64 = -32602; @@ -153,6 +154,25 @@ pub fn encode(message: &Message) -> Result, String> { serde_json::to_vec(message).map_err(|error| format!("cannot encode message: {error}")) } +/// A successful response whose result is already JSON text, so a large +/// answer is not built as a tree and serialized again. +#[derive(Serialize)] +struct EncodedResponse<'a> { + jsonrpc: Version, + id: u64, + result: &'a serde_json::value::RawValue, +} + +/// Encode `Response::ok(id, result)` from the result's JSON text. +pub fn encode_result(id: u64, result: &serde_json::value::RawValue) -> Result, String> { + serde_json::to_vec(&EncodedResponse { + jsonrpc: Version, + id, + result, + }) + .map_err(|error| format!("cannot encode response: {error}")) +} + pub fn decode(bytes: &[u8]) -> Result { serde_json::from_slice(bytes).map_err(|error| format!("cannot decode message: {error}")) } @@ -179,4 +199,20 @@ mod tests { assert!(decode(br#"{"jsonrpc":"1.0","id":1,"method":"x"}"#).is_err()); assert!(decode(b"not json").is_err()); } + + #[test] + fn an_encoded_result_reads_back_as_the_same_response() { + let raw = serde_json::value::RawValue::from_string( + r#"{"items":[1,2],"hasMore":false}"#.to_string(), + ) + .unwrap(); + let bytes = encode_result(7, &raw).unwrap(); + assert_eq!( + decode(&bytes).unwrap(), + Message::Response(Response::ok( + 7, + serde_json::json!({"items": [1, 2], "hasMore": false}) + )) + ); + } } diff --git a/apps/maple-agent/crates/maple-remote/src/server.rs b/apps/maple-agent/crates/maple-remote/src/server.rs index b745db34d..e9f4289c8 100644 --- a/apps/maple-agent/crates/maple-remote/src/server.rs +++ b/apps/maple-agent/crates/maple-remote/src/server.rs @@ -20,6 +20,7 @@ use maple_agent::agent::AgentSessionDetail; use maple_agent::host::{HostBackend, HostBootstrap}; use serde::Serialize; use serde_json::Value; +use serde_json::value::RawValue; use tokio::sync::{Mutex, Notify}; use tokio_util::sync::CancellationToken; @@ -31,7 +32,7 @@ use crate::streams::{StreamOpen, StreamSenders, decode_credit}; use crate::wire::{ AttachmentHandle, BootstrapSnapshot, ClientHello, EventEnvelope, HostHello, HostInfo, HostRequest, IntegrationRequest, ModelRequest, PROTOCOL_VERSION, ProjectRequest, Request, - RunRequest, SessionRequest, SessionSnapshot, TimelinePage, decode_request, features, + RunRequest, SessionRequest, SessionSnapshot, decode_request, features, }; /// What the host tells clients about itself in the handshake. @@ -74,6 +75,17 @@ impl Default for HostServerConfig { } } +/// Loaded tasks one connection keeps for paging. Loading a ninth drops +/// the one paged least recently. +pub const MAX_KEPT_SNAPSHOTS: usize = 8; + +/// Project roots one connection may watch at once. +pub const MAX_WATCHED_ROOTS: usize = 64; + +/// How long a closing connection waits for its queued frames (a refusal, +/// an error answer) to reach the peer before the writer is abandoned. +const CLOSE_FLUSH_TIMEOUT: Duration = Duration::from_secs(2); + pub struct HostServer { host: Arc, info: HostInfo, @@ -100,14 +112,6 @@ impl HostServer { }) } - pub fn generation(&self) -> &str { - &self.generation - } - - pub fn host(&self) -> &Arc { - &self.host - } - /// Serve one connection until it ends, with no identity check on the /// hello. For carriers that authenticated nothing: tests and loopback. pub async fn serve(self: Arc, carrier: Carrier) -> Result<(), String> { @@ -132,18 +136,21 @@ impl HostServer { let (out, mut queue) = outbound::channel(self.config.max_outbound_bytes); let connection = Arc::new(Connection { server: Arc::clone(&self), - out: out.clone(), + out, streams: StreamSenders::default(), - snapshots: Mutex::new(HashMap::new()), + snapshots: Mutex::new(Vec::new()), + watched_roots: Mutex::new(HashMap::new()), ready: AtomicBool::new(false), ready_notify: Notify::new(), last_activity: std::sync::Mutex::new(Instant::now()), peer, closed: cancel, + close_reason: std::sync::Mutex::new(None), }); // Subscribe before the handshake so nothing is missed between the - // two; the forwarder holds events until the client is ready. + // two; the forwarder holds events until the client is ready. An + // overflow closes the connection right here. let mut events = self.host.subscribe(); let forwarder = { let connection = Arc::clone(&connection); @@ -153,26 +160,38 @@ impl HostServer { while let Some(event) = events.recv().await { seq += 1; let envelope = EventEnvelope { seq, event }; - if connection - .notify(crate::wire::EVENT_METHOD, &envelope) - .is_err() - { + if let Err(error) = connection.notify(crate::wire::EVENT_METHOD, &envelope) { + connection.close(&error); break; } } }) }; - let writer = { - let connection = Arc::clone(&connection); + // The writer owns the carrier's sink and the queue's draining end + // and nothing else, so it cannot keep the connection alive. It + // ends when the connection closes, after writing what was queued. + let mut writer = { + let closed = connection.closed.clone(); tokio::spawn(async move { - while let Some(frame) = queue.recv().await { + loop { + let frame = tokio::select! { + frame = queue.recv() => frame, + _ = closed.cancelled() => { + while let Some(frame) = queue.try_recv() { + if sink.send(frame).await.is_err() { + break; + } + } + None + } + }; + let Some(frame) = frame else { break }; if sink.send(frame).await.is_err() { break; } } sink.close().await; - connection.close("writer ended"); }) }; @@ -183,15 +202,10 @@ impl HostServer { tokio::spawn(async move { loop { tokio::time::sleep(check).await; - let idle = connection.idle_for(); - if idle > lease { + if connection.idle_for() > lease { connection.close("lease expired"); return; } - if connection.out.overflowed() { - connection.close("outbound queue overflowed"); - return; - } } }) }; @@ -202,6 +216,7 @@ impl HostServer { _ = connection.closed.cancelled() => None, }; let Some(frame) = frame else { + connection.close("peer closed"); break connection .close_reason() .unwrap_or_else(|| "peer closed".to_string()); @@ -214,9 +229,15 @@ impl HostServer { }; forwarder.abort(); lease.abort(); - drop(out); - // Let queued frames (a refusal, an error answer) reach the peer. - let _ = tokio::time::timeout(Duration::from_secs(2), writer).await; + // Let queued frames (a refusal, an error answer) reach the peer; a + // peer that stopped reading does not get to hold the writer. + if tokio::time::timeout(CLOSE_FLUSH_TIMEOUT, &mut writer) + .await + .is_err() + { + writer.abort(); + } + connection.release_watches().await; log::debug!("host connection ended: {reason}"); Ok(()) } @@ -227,15 +248,20 @@ struct Connection { server: Arc, out: Outbound, streams: StreamSenders, - /// Snapshots the client pages through, keyed by session id. Replaced - /// by the next load of the same task. - snapshots: Mutex>>, + /// Snapshots the client pages through, least recently paged first. + /// Replaced by the next load of the same task; at most + /// [`MAX_KEPT_SNAPSHOTS`]. + snapshots: Mutex)>>, + /// Project roots this connection asked the host to watch, with how + /// many times each, so teardown can balance every watch. + watched_roots: Mutex>, ready: AtomicBool, ready_notify: Notify, last_activity: std::sync::Mutex, /// The device key the transport proved, when it proved one. peer: Option, closed: CancellationToken, + close_reason: std::sync::Mutex>, } impl Connection { @@ -253,15 +279,36 @@ impl Connection { .elapsed() } + /// End the connection. The first reason given is the one kept. fn close(&self, reason: &str) { - if !self.closed.is_cancelled() { + let mut close_reason = self + .close_reason + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if close_reason.is_none() { log::debug!("closing host connection: {reason}"); + *close_reason = Some(reason.to_string()); self.closed.cancel(); } } fn close_reason(&self) -> Option { - self.closed.is_cancelled().then(|| "closed".to_string()) + self.close_reason + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } + + /// The connection is gone: take back every watch it placed. + async fn release_watches(&self) { + let watched = std::mem::take(&mut *self.watched_roots.lock().await); + for (root, count) in watched { + for _ in 0..count { + if let Err(error) = self.host().unwatch_project_root(root.clone()).await { + log::debug!("cannot unwatch {root} at teardown: {error}"); + } + } + } } fn notify(&self, method: &str, params: &T) -> Result<(), String> { @@ -281,6 +328,15 @@ impl Connection { } } + /// Answer a request with JSON that is already encoded. + fn respond_ok(&self, id: u64, answer: &RawValue) { + if let Err(error) = rpc::encode_result(id, answer) + .and_then(|bytes| self.out.try_send(Frame::control(bytes))) + { + self.close(&error); + } + } + /// Route one inbound frame. Requests are answered on their own task. fn on_frame(self: &Arc, frame: Frame) -> Result<(), String> { if frame.channel != CONTROL_CHANNEL { @@ -327,17 +383,18 @@ impl Connection { return; } }; - if let Request::Host(HostRequest::Hello(hello)) = decoded { - self.handle_hello(id, hello); - return; - } if !self.ready.load(Ordering::Acquire) { - self.respond(Response::err( - id, - RpcError::new(code::NOT_READY, "send host.hello first"), - )); + match decoded { + Request::Host(HostRequest::Hello(hello)) => self.handle_hello(id, hello), + _ => self.respond(Response::err( + id, + RpcError::new(code::NOT_READY, "send host.hello first"), + )), + } return; } + // A hello after the handshake is an ordinary request, and the host + // controller refuses it: the hook and the ready state ran once. let result = match decoded { Request::Host(request) => self.host_controller(request).await, Request::Project(request) => self.project_controller(request).await, @@ -346,13 +403,16 @@ impl Connection { Request::Model(request) => self.model_controller(request).await, Request::Integration(request) => self.integration_controller(request).await, }; - self.respond(match result { - Ok(value) => Response::ok(id, value), - Err(error) => Response::err(id, error), - }); + match result { + Ok(answer) => self.respond_ok(id, &answer), + Err(error) => self.respond(Response::err(id, error)), + } } - fn handle_hello(&self, id: u64, hello: ClientHello) { + fn handle_hello(&self, id: u64, mut hello: ClientHello) { + // The name is what the client claims; keep it short and printable + // before it reaches a log or the device list. + hello.device.name = crate::devices::clean_device_name(&hello.device.name); let identity = &self.server.identity; let refusal = if hello.protocol != PROTOCOL_VERSION { Some(format!( @@ -395,16 +455,29 @@ impl Connection { features: features(), host: self.server.info.clone(), }; - match serde_json::to_value(&answer) { - Ok(value) => { - self.respond(Response::ok(id, value)); - self.ready.store(true, Ordering::Release); - self.ready_notify.notify_one(); - if let Some(hook) = &self.server.config.on_client_hello { - hook(&hello); - } + let answer = match encode_answer(&answer) { + Ok(answer) => answer, + Err(error) => { + self.respond(Response::err(id, error)); + return; } - Err(error) => self.respond(Response::err(id, RpcError::host(error.to_string()))), + }; + // Two hellos in flight at once: the first to get here wins. + if self + .ready + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + self.respond(Response::err( + id, + RpcError::new(code::INVALID_REQUEST, "hello was already sent"), + )); + return; + } + self.respond_ok(id, &answer); + self.ready_notify.notify_one(); + if let Some(hook) = &self.server.config.on_client_hello { + hook(&hello); } } @@ -412,20 +485,20 @@ impl Connection { &self.server.host } - /// Serialize a controller result, mapping host errors to RPC errors. - fn ok(value: Result) -> Result { + /// Encode a controller result once, mapping host errors to RPC errors. + fn ok(value: Result) -> Result { let value = value.map_err(RpcError::host)?; - serde_json::to_value(value).map_err(|error| RpcError::host(error.to_string())) + encode_answer(&value) } - async fn host_controller(&self, request: HostRequest) -> Result { + async fn host_controller(&self, request: HostRequest) -> Result { let host = self.host(); match request { HostRequest::Hello(_) => Err(RpcError::new( code::INVALID_REQUEST, "hello was already sent", )), - HostRequest::Ping(_) => Ok(Value::Object(Default::default())), + HostRequest::Ping(_) => Self::ok(Ok(serde_json::Map::::new())), HostRequest::Bootstrap(_) => { let bootstrap = host.bootstrap().await.map_err(RpcError::host)?; Self::ok(Ok(self.snapshot_bootstrap(bootstrap).await)) @@ -453,7 +526,7 @@ impl Connection { } } - async fn project_controller(&self, request: ProjectRequest) -> Result { + async fn project_controller(&self, request: ProjectRequest) -> Result { let host = self.host(); match request { ProjectRequest::RecentRoots(_) => Self::ok(host.recent_project_roots().await), @@ -466,8 +539,30 @@ impl Connection { ProjectRequest::SuggestDirectories(params) => { Self::ok(host.suggest_directories(params.query).await) } - ProjectRequest::Watch(params) => Self::ok(host.watch_project_root(params.path).await), + ProjectRequest::Watch(params) => { + let mut watched = self.watched_roots.lock().await; + if !watched.contains_key(¶ms.path) && watched.len() >= MAX_WATCHED_ROOTS { + return Err(RpcError::new( + code::INVALID_REQUEST, + format!("a connection may watch at most {MAX_WATCHED_ROOTS} project roots"), + )); + } + host.watch_project_root(params.path.clone()) + .await + .map_err(RpcError::host)?; + *watched.entry(params.path).or_default() += 1; + Self::ok(Ok(())) + } ProjectRequest::Unwatch(params) => { + let mut watched = self.watched_roots.lock().await; + match watched.get_mut(¶ms.path) { + Some(count) if *count > 1 => *count -= 1, + Some(_) => { + watched.remove(¶ms.path); + } + // Never watched here: nothing to balance. + None => return Self::ok(Ok(())), + } Self::ok(host.unwatch_project_root(params.path).await) } ProjectRequest::Trust(params) => Self::ok(host.project_trust(params.path).await), @@ -478,10 +573,10 @@ impl Connection { } async fn session_controller( - &self, + self: &Arc, request_id: u64, request: SessionRequest, - ) -> Result { + ) -> Result { let host = self.host(); match request { SessionRequest::List(params) => Self::ok(host.list_sessions(params.project_root).await), @@ -534,7 +629,6 @@ impl Connection { len: Some(bytes.len() as u64), }, ) - .await .map_err(RpcError::host)?; let handle = AttachmentHandle { stream: sender.channel(), @@ -542,9 +636,13 @@ impl Connection { }; // The bytes follow the answer; the receiver pairs them // through the request id in the open frame. + let connection = Arc::clone(self); tokio::spawn(async move { if let Err(error) = sender.send_all(&bytes).await { log::debug!("attachment stream ended early: {error}"); + if connection.out.overflowed() { + connection.close(&error); + } } }); Self::ok(Ok(handle)) @@ -552,7 +650,7 @@ impl Connection { } } - async fn run_controller(&self, request: RunRequest) -> Result { + async fn run_controller(&self, request: RunRequest) -> Result { let host = self.host(); match request { RunRequest::Send(params) => Self::ok(host.send_message(params.request).await), @@ -601,7 +699,7 @@ impl Connection { } } - async fn model_controller(&self, request: ModelRequest) -> Result { + async fn model_controller(&self, request: ModelRequest) -> Result { let host = self.host(); match request { ModelRequest::List(_) => Self::ok(host.available_model_ids().await), @@ -618,7 +716,10 @@ impl Connection { } } - async fn integration_controller(&self, request: IntegrationRequest) -> Result { + async fn integration_controller( + &self, + request: IntegrationRequest, + ) -> Result { let host = self.host(); match request { IntegrationRequest::ListSessionMcp(params) => { @@ -642,7 +743,12 @@ impl Connection { host.set_integration_enabled(params.id, params.enabled) .await, ), - IntegrationRequest::Setup(params) => Self::ok(host.setup_integration(params.id).await), + // The permission flow behind a setup runs on the host's own + // screen; `HostBackend` documents it as local-only. + IntegrationRequest::Setup(_) => Err(RpcError::new( + code::INVALID_REQUEST, + crate::client::SETUP_IS_LOCAL, + )), } } @@ -650,10 +756,8 @@ impl Connection { async fn snapshot_session(&self, detail: AgentSessionDetail) -> SessionSnapshot { let timeline_len = detail.timeline.len(); let detail = Arc::new(detail); - self.snapshots - .lock() - .await - .insert(detail.session.id.clone(), Arc::clone(&detail)); + self.keep_snapshot(&detail.session.id, Arc::clone(&detail)) + .await; let mut stripped = (*detail).clone(); stripped.timeline = Vec::new(); SessionSnapshot { @@ -662,6 +766,27 @@ impl Connection { } } + /// Remember `detail` as the most recently used snapshot, dropping the + /// least recently used one past [`MAX_KEPT_SNAPSHOTS`]. + async fn keep_snapshot(&self, session_id: &str, detail: Arc) { + let mut snapshots = self.snapshots.lock().await; + snapshots.retain(|(id, _)| id != session_id); + snapshots.push((session_id.to_string(), detail)); + if snapshots.len() > MAX_KEPT_SNAPSHOTS { + snapshots.remove(0); + } + } + + /// The kept snapshot of `session_id`, marked most recently used. + async fn kept_snapshot(&self, session_id: &str) -> Option> { + let mut snapshots = self.snapshots.lock().await; + let index = snapshots.iter().position(|(id, _)| id == session_id)?; + let entry = snapshots.remove(index); + let detail = Arc::clone(&entry.1); + snapshots.push(entry); + Some(detail) + } + async fn snapshot_bootstrap(&self, mut bootstrap: HostBootstrap) -> BootstrapSnapshot { let mut latest_timeline_len = 0; if let Some(latest) = bootstrap.latest.take() { @@ -677,38 +802,55 @@ impl Connection { /// One page of a kept snapshot, bounded by item count and bytes. A /// task that was never loaded on this connection is loaded first. + /// Each item is serialized once, to measure it, and that text is what + /// the answer carries. async fn timeline_page( &self, params: crate::wire::TimelinePageParams, - ) -> Result { - let cached = self.snapshots.lock().await.get(¶ms.session_id).cloned(); - let detail = match cached { + ) -> Result { + let detail = match self.kept_snapshot(¶ms.session_id).await { Some(detail) => detail, None => { let detail = self.host().load_session(params.session_id.clone()).await?; let detail = Arc::new(detail); - self.snapshots - .lock() - .await - .insert(params.session_id.clone(), Arc::clone(&detail)); + self.keep_snapshot(¶ms.session_id, Arc::clone(&detail)) + .await; detail } }; let config = &self.server.config; let limit = params.limit.clamp(1, config.timeline_page_items); - let mut items = Vec::new(); + let mut items: Vec> = Vec::new(); let mut bytes = 0usize; for item in detail.timeline.iter().skip(params.offset) { - let size = serde_json::to_vec(item).map(|json| json.len()).unwrap_or(0); + let json = serde_json::to_string(item).map_err(|error| error.to_string())?; if !items.is_empty() - && (items.len() >= limit || bytes + size > config.timeline_page_bytes) + && (items.len() >= limit || bytes + json.len() > config.timeline_page_bytes) { break; } - bytes += size; - items.push(item.clone()); + bytes += json.len(); + items.push(RawValue::from_string(json).map_err(|error| error.to_string())?); } let has_more = params.offset + items.len() < detail.timeline.len(); - Ok(TimelinePage { items, has_more }) + Ok(EncodedTimelinePage { items, has_more }) } } + +/// A controller's answer: the result's JSON, encoded once. +type Answer = Box; + +fn encode_answer(value: &T) -> Result { + serde_json::to_string(value) + .and_then(RawValue::from_string) + .map_err(|error| RpcError::host(error.to_string())) +} + +/// [`crate::wire::TimelinePage`] with its items already encoded; the same +/// JSON on the wire. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct EncodedTimelinePage { + items: Vec>, + has_more: bool, +} diff --git a/apps/maple-agent/crates/maple-remote/src/streams.rs b/apps/maple-agent/crates/maple-remote/src/streams.rs index a82a04f6e..93e7f64c8 100644 --- a/apps/maple-agent/crates/maple-remote/src/streams.rs +++ b/apps/maple-agent/crates/maple-remote/src/streams.rs @@ -86,37 +86,34 @@ impl StreamSender { .await .ok_or_else(|| "stream receiver went away".to_string())?; } - self.out - .send(Frame { - channel: self.channel, - kind: FrameKind::Data, - payload: Bytes::copy_from_slice(chunk), - }) - .await?; + self.out.try_send(Frame { + channel: self.channel, + kind: FrameKind::Data, + payload: Bytes::copy_from_slice(chunk), + })?; self.available -= 1; } - self.out - .send(Frame { - channel: self.channel, - kind: FrameKind::Close, - payload: Bytes::new(), - }) - .await + self.out.try_send(Frame { + channel: self.channel, + kind: FrameKind::Close, + payload: Bytes::new(), + }) } - /// End the stream with an error instead of bytes. - pub async fn fail(self, error: &str) -> Result<(), String> { + /// End the stream with an error instead of bytes. No sender needs + /// this yet: the host answers a failed read with an RPC error before + /// it opens a stream. + #[cfg(test)] + pub fn fail(self, error: &str) -> Result<(), String> { let payload = serde_json::to_vec(&StreamClose { error: Some(error.to_string()), }) .unwrap_or_default(); - self.out - .send(Frame { - channel: self.channel, - kind: FrameKind::Close, - payload: Bytes::from(payload), - }) - .await + self.out.try_send(Frame { + channel: self.channel, + kind: FrameKind::Close, + payload: Bytes::from(payload), + }) } } @@ -137,9 +134,9 @@ impl Default for StreamSenders { } impl StreamSenders { - /// Open a stream: sends the `Open` frame and returns the sender, which + /// Open a stream: queues the `Open` frame and returns the sender, which /// starts with [`INITIAL_CREDIT`]. - pub async fn open(&self, out: &Outbound, open: StreamOpen) -> Result { + pub fn open(&self, out: &Outbound, open: StreamOpen) -> Result { let channel = self.allocate_channel(); let (credit_tx, credit_rx) = mpsc::unbounded_channel(); self.credits @@ -147,12 +144,11 @@ impl StreamSenders { .unwrap_or_else(std::sync::PoisonError::into_inner) .insert(channel, credit_tx); let payload = serde_json::to_vec(&open).map_err(|error| error.to_string())?; - out.send(Frame { + out.try_send(Frame { channel, kind: FrameKind::Open, payload: Bytes::from(payload), - }) - .await?; + })?; Ok(StreamSender { channel, out: out.clone(), @@ -191,6 +187,9 @@ struct Collector { bytes: Vec, consumed_since_credit: u32, done: Option>, + /// The request this stream answers, so an abandoned request can drop + /// its collector. + request_id: Option, } /// The collected bytes of one stream, or why it ended early. @@ -224,6 +223,7 @@ impl StreamReceivers { bytes: Vec::with_capacity(open.len.unwrap_or(0).min(64 * 1024 * 1024) as usize), consumed_since_credit: 0, done: Some(done_tx), + request_id: open.request_id, }, ); if let Some(request_id) = open.request_id { @@ -286,6 +286,28 @@ impl StreamReceivers { .remove(&request_id) } + /// The request that asked for a stream failed or gave up waiting: + /// forget its receiver and any collector already opened for it, so + /// late frames on that channel are dropped instead of kept. + pub fn abandon_request(&self, request_id: u64) { + self.by_request + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&request_id); + self.open + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .retain(|_, collector| collector.request_id != Some(request_id)); + } + + /// Streams still collecting, for tests and diagnostics. + pub fn open_count(&self) -> usize { + self.open + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .len() + } + /// The connection ended: every open stream fails. pub fn fail_all(&self, reason: &str) { let mut open = self @@ -320,7 +342,6 @@ mod tests { len: Some(payload.len() as u64), }, ) - .await .unwrap(); let channel = sender.channel(); let sending = tokio::spawn(async move { sender.send_all(&payload).await }); @@ -368,9 +389,8 @@ mod tests { len: None, }, ) - .await .unwrap(); - sender.fail("missing").await.unwrap(); + sender.fail("missing").unwrap(); let open = queue.recv().await.unwrap(); receivers.on_open(open.channel, &open.payload); let rx = receivers.take_by_request(1).unwrap(); @@ -387,7 +407,6 @@ mod tests { len: None, }, ) - .await .unwrap(); drop(other); let open = queue.recv().await.unwrap(); @@ -396,4 +415,25 @@ mod tests { receivers.fail_all("connection lost"); assert_eq!(rx.await.unwrap(), Err("connection lost".to_string())); } + + #[test] + fn an_abandoned_request_drops_its_receiver_and_collector() { + let receivers = StreamReceivers::default(); + let open = serde_json::to_vec(&StreamOpen { + purpose: "attachment".into(), + request_id: Some(5), + len: None, + }) + .unwrap(); + receivers.on_open(3, &open); + assert_eq!(receivers.open_count(), 1); + receivers.abandon_request(5); + assert_eq!(receivers.open_count(), 0); + assert!(receivers.take_by_request(5).is_none()); + assert!( + receivers.on_data(3, b"late").is_none(), + "late frames are dropped" + ); + receivers.abandon_request(6); + } } diff --git a/apps/maple-agent/crates/maple-remote/src/wire.rs b/apps/maple-agent/crates/maple-remote/src/wire.rs index c8f334ca2..0a040612d 100644 --- a/apps/maple-agent/crates/maple-remote/src/wire.rs +++ b/apps/maple-agent/crates/maple-remote/src/wire.rs @@ -427,32 +427,111 @@ pub enum DecodeError { InvalidParams(String), } +/// Every method name, by domain, in the order of the enum variants. The +/// test below checks each list against the variants serde knows, so a new +/// variant fails a test until it is listed here. +pub const HOST_METHODS: &[&str] = &[ + "host.hello", + "host.ping", + "host.bootstrap", + "host.start_runtime", + "host.stop_runtime", + "host.session_defaults", + "host.set_session_defaults", + "host.save_default_model", + "host.usage_summary", + "host.context_usage", + "host.tool_summaries", + "host.store_tool_summary", +]; +pub const PROJECT_METHODS: &[&str] = &[ + "project.recent_roots", + "project.select_root", + "project.remove_root", + "project.suggest_directories", + "project.watch", + "project.unwatch", + "project.trust", + "project.set_trust", +]; +pub const SESSION_METHODS: &[&str] = &[ + "session.list", + "session.create", + "session.load", + "session.timeline", + "session.rename", + "session.set_archived", + "session.compact", + "session.subagents", + "session.cancel_external_agent", + "session.set_permission_mode", + "session.set_web_enabled", + "session.read_attachment", +]; +pub const RUN_METHODS: &[&str] = &[ + "run.send", + "run.cancel", + "run.cancel_queued", + "run.begin_queued_edit", + "run.end_queued_edit", + "run.answer_question", + "run.permission_respond", + "run.ask_side_question", + "run.summarize_tool_call", + "run.summarize_thinking", +]; +pub const MODEL_METHODS: &[&str] = &[ + "model.list", + "model.supports_vision", + "model.slash_commands", + "model.resolve_slash_command", +]; +pub const INTEGRATION_METHODS: &[&str] = &[ + "integration.list_session_mcp", + "integration.set_session_mcp", + "integration.list_mcp", + "integration.save_mcp", + "integration.list", + "integration.set_enabled", + "integration.setup", +]; + +/// Whether a host answers `method` at all. Deciding this by name, rather +/// than by reading serde's error text, keeps a bad enum value inside the +/// params from reading as an unknown method. +pub fn is_known_method(method: &str) -> bool { + [ + HOST_METHODS, + PROJECT_METHODS, + SESSION_METHODS, + RUN_METHODS, + MODEL_METHODS, + INTEGRATION_METHODS, + ] + .iter() + .any(|methods| methods.contains(&method)) +} + /// Decode a JSON-RPC request by its method's domain prefix. pub fn decode_request(method: &str, params: Value) -> Result { + if !is_known_method(method) { + return Err(DecodeError::UnknownMethod(method.to_string())); + } let (domain, _) = method .split_once('.') .ok_or_else(|| DecodeError::UnknownMethod(method.to_string()))?; let tagged = serde_json::json!({ "method": method, "params": params }); - fn decode( - method: &str, - tagged: Value, - ) -> Result { - serde_json::from_value(tagged).map_err(|error| { - let text = error.to_string(); - if text.contains("unknown variant") { - DecodeError::UnknownMethod(method.to_string()) - } else { - DecodeError::InvalidParams(text) - } - }) + fn decode(tagged: Value) -> Result { + serde_json::from_value(tagged) + .map_err(|error| DecodeError::InvalidParams(error.to_string())) } Ok(match domain { - "host" => Request::Host(decode(method, tagged)?), - "project" => Request::Project(decode(method, tagged)?), - "session" => Request::Session(decode(method, tagged)?), - "run" => Request::Run(decode(method, tagged)?), - "model" => Request::Model(decode(method, tagged)?), - "integration" => Request::Integration(decode(method, tagged)?), + "host" => Request::Host(decode(tagged)?), + "project" => Request::Project(decode(tagged)?), + "session" => Request::Session(decode(tagged)?), + "run" => Request::Run(decode(tagged)?), + "model" => Request::Model(decode(tagged)?), + "integration" => Request::Integration(decode(tagged)?), _ => return Err(DecodeError::UnknownMethod(method.to_string())), }) } @@ -498,6 +577,52 @@ mod tests { decode_request("run.cancel", serde_json::json!({"wrong": 1})), Err(DecodeError::InvalidParams(_)) )); + // A bad enum value inside the params is a params error, not an + // unknown method, even though serde reports it as a variant. + let bad_kind = serde_json::json!({ + "sessionId": "s1", "name": "n", "kind": "teleporter", "enabled": true + }); + match decode_request("integration.set_session_mcp", bad_kind) { + Err(DecodeError::InvalidParams(text)) => { + assert!(text.contains("teleporter"), "{text}"); + } + other => panic!("wrong decode: {other:?}"), + } + } + + /// The names serde accepts as tags for `T`, read from its error for a + /// tag it does not know. + fn serde_variants( + domain: &str, + ) -> Vec { + let probe = serde_json::json!({ "method": format!("{domain}.__probe__"), "params": {} }); + let text = serde_json::from_value::(probe).unwrap_err().to_string(); + let (_, expected) = text + .split_once("expected one of ") + .unwrap_or_else(|| panic!("no variant list in {text:?}")); + expected + .split(", ") + .map(|name| name.trim().trim_matches('`').to_string()) + .filter(|name| !name.is_empty()) + .collect() + } + + #[test] + fn the_method_lists_match_the_enums() { + assert_eq!(serde_variants::("host"), HOST_METHODS); + assert_eq!(serde_variants::("project"), PROJECT_METHODS); + assert_eq!(serde_variants::("session"), SESSION_METHODS); + assert_eq!(serde_variants::("run"), RUN_METHODS); + assert_eq!(serde_variants::("model"), MODEL_METHODS); + assert_eq!( + serde_variants::("integration"), + INTEGRATION_METHODS + ); + for method in HOST_METHODS.iter().chain(SESSION_METHODS) { + assert!(is_known_method(method)); + assert!(method.starts_with("host.") || method.starts_with("session.")); + } + assert!(!is_known_method("host.__probe__")); } #[test] diff --git a/apps/maple-agent/crates/maple-remote/tests/common/mod.rs b/apps/maple-agent/crates/maple-remote/tests/common/mod.rs index 9998e6c27..27ea6bbfa 100644 --- a/apps/maple-agent/crates/maple-remote/tests/common/mod.rs +++ b/apps/maple-agent/crates/maple-remote/tests/common/mod.rs @@ -31,6 +31,8 @@ pub struct FakeHost { pub timeline_len: usize, pub attachment: Vec, pub loads: AtomicUsize, + /// Every root passed to `unwatch_project_root`, in order. + pub unwatched: std::sync::Mutex>, } pub fn summary(id: &str) -> AgentSessionSummary { @@ -72,6 +74,7 @@ impl FakeHost { timeline_len, attachment: (0..(600 * 1024)).map(|i| (i % 251) as u8).collect(), loads: AtomicUsize::new(0), + unwatched: std::sync::Mutex::new(Vec::new()), }) } @@ -152,7 +155,8 @@ impl HostBackend for FakeHost { }); Ok(()) } - async fn unwatch_project_root(&self, _: String) -> Result<(), String> { + async fn unwatch_project_root(&self, path: String) -> Result<(), String> { + self.unwatched.lock().unwrap().push(path); Ok(()) } async fn project_trust(&self, _: String) -> Result { diff --git a/apps/maple-agent/crates/maple-remote/tests/loopback.rs b/apps/maple-agent/crates/maple-remote/tests/loopback.rs index b908141c9..409cb9fd2 100644 --- a/apps/maple-agent/crates/maple-remote/tests/loopback.rs +++ b/apps/maple-agent/crates/maple-remote/tests/loopback.rs @@ -10,11 +10,11 @@ use std::time::Duration; use common::{FakeHost, client_config, hello, identity, info, summary}; use maple_agent::agent::{AgentSendMessageRequest, AgentServiceEvent}; use maple_agent::host::{ContextUsage, HostBackend, HostEvent}; -use maple_remote::carrier::{Carrier, in_process_pair}; +use maple_remote::carrier::{Carrier, FrameSink, FrameStream, in_process_pair}; use maple_remote::client::RemoteHostBackend; use maple_remote::frame::Frame; use maple_remote::rpc::{self, Message, Response}; -use maple_remote::server::{HostServer, HostServerConfig}; +use maple_remote::server::{HostServer, HostServerConfig, MAX_WATCHED_ROOTS}; use maple_remote::wire::{EventEnvelope, HostHello, PROTOCOL_VERSION}; /// Start a server on a fresh pair and connect a client through it. @@ -370,6 +370,164 @@ async fn a_quiet_peer_loses_its_lease_and_a_pinging_client_keeps_it() { .unwrap(); } +/// Complete the handshake by hand on a raw carrier and return its halves. +async fn raw_handshake(client_side: Carrier) -> (Box, Box) { + let Carrier { + mut sink, + mut stream, + } = client_side; + let hello_request = rpc::Request::new(1, "host.hello", serde_json::to_value(hello()).unwrap()); + sink.send(Frame::control( + rpc::encode(&Message::Request(hello_request)).unwrap(), + )) + .await + .unwrap(); + let Message::Response(response) = rpc::decode(&stream.recv().await.unwrap().payload).unwrap() + else { + panic!("expected the hello answer"); + }; + assert!(response.error.is_none(), "{:?}", response.error); + (sink, stream) +} + +/// Send one request on a raw carrier and return its response. +async fn raw_call( + sink: &mut Box, + stream: &mut Box, + id: u64, + method: &str, + params: serde_json::Value, +) -> Response { + sink.send(Frame::control( + rpc::encode(&Message::Request(rpc::Request::new(id, method, params))).unwrap(), + )) + .await + .unwrap(); + loop { + let frame = stream.recv().await.expect("a response"); + if let Message::Response(response) = rpc::decode(&frame.payload).unwrap() + && response.id == id + { + return response; + } + } +} + +#[tokio::test] +async fn a_second_hello_is_refused_and_the_hook_runs_once() { + let hellos = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let hook_hellos = Arc::clone(&hellos); + let config = HostServerConfig { + on_client_hello: Some(Arc::new(move |_| { + hook_hellos.fetch_add(1, Ordering::Relaxed); + })), + ..Default::default() + }; + let (client_side, host_side) = in_process_pair(8); + let server = HostServer::new(FakeHost::new(0), info(), identity(), config); + let _serving = tokio::spawn(server.serve(host_side)); + let (mut sink, mut stream) = raw_handshake(client_side).await; + let again = raw_call( + &mut sink, + &mut stream, + 2, + "host.hello", + serde_json::to_value(hello()).unwrap(), + ) + .await; + let error = again.error.expect("a second hello is refused"); + assert_eq!(error.code, rpc::code::INVALID_REQUEST); + assert_eq!(error.message, "hello was already sent"); + assert_eq!(hellos.load(Ordering::Relaxed), 1, "the hook ran once"); + // The connection is still usable. + let ping = raw_call( + &mut sink, + &mut stream, + 3, + "host.ping", + serde_json::json!({}), + ) + .await; + assert!(ping.error.is_none()); +} + +#[tokio::test] +async fn integration_setup_is_refused_over_the_wire() { + let host = FakeHost::new(0); + let (client, _serving) = connect(Arc::clone(&host), HostServerConfig::default()).await; + let error = client.setup_integration("x".to_string()).await.unwrap_err(); + assert!(error.contains("on the host itself"), "{error}"); + + let (client_side, host_side) = in_process_pair(8); + let server = HostServer::new(host, info(), identity(), HostServerConfig::default()); + let _serving = tokio::spawn(server.serve(host_side)); + let (mut sink, mut stream) = raw_handshake(client_side).await; + let response = raw_call( + &mut sink, + &mut stream, + 2, + "integration.setup", + serde_json::json!({"id": "x"}), + ) + .await; + let error = response.error.expect("refused"); + assert_eq!(error.code, rpc::code::INVALID_REQUEST); + assert!( + error.message.contains("on the host itself"), + "{}", + error.message + ); +} + +#[tokio::test] +async fn watches_are_capped_and_released_when_the_connection_ends() { + let host = FakeHost::new(0); + let (client, serving) = connect(Arc::clone(&host), HostServerConfig::default()).await; + client.watch_project_root("/a".to_string()).await.unwrap(); + client.watch_project_root("/a".to_string()).await.unwrap(); + client.watch_project_root("/b".to_string()).await.unwrap(); + client.watch_project_root("/c".to_string()).await.unwrap(); + client.unwatch_project_root("/c".to_string()).await.unwrap(); + client + .unwatch_project_root("/never".to_string()) + .await + .unwrap(); + for index in 0..(MAX_WATCHED_ROOTS - 2) { + client + .watch_project_root(format!("/many/{index}")) + .await + .unwrap(); + } + let error = client + .watch_project_root("/one-too-many".to_string()) + .await + .unwrap_err(); + assert!(error.contains(&MAX_WATCHED_ROOTS.to_string()), "{error}"); + assert_eq!( + host.unwatched.lock().unwrap().as_slice(), + ["/c"], + "an unwatch of a root never watched here is not forwarded" + ); + + client.close().await; + tokio::time::timeout(Duration::from_secs(5), serving) + .await + .unwrap() + .unwrap() + .unwrap(); + let mut unwatched = host.unwatched.lock().unwrap().clone(); + unwatched.sort(); + let mut expected = vec![ + "/a".to_string(), + "/a".to_string(), + "/b".to_string(), + "/c".to_string(), + ]; + expected.extend((0..(MAX_WATCHED_ROOTS - 2)).map(|index| format!("/many/{index}"))); + expected.sort(); + assert_eq!(unwatched, expected, "every watch was balanced at teardown"); +} + #[tokio::test] async fn requests_before_the_handshake_and_unknown_methods_are_refused() { let host = FakeHost::new(0); diff --git a/apps/maple-agent/crates/maple-remote/tests/transport.rs b/apps/maple-agent/crates/maple-remote/tests/transport.rs index 100e499f4..f52e30a76 100644 --- a/apps/maple-agent/crates/maple-remote/tests/transport.rs +++ b/apps/maple-agent/crates/maple-remote/tests/transport.rs @@ -8,6 +8,7 @@ use std::sync::Arc; use std::time::Duration; use common::{FakeHost, client_config, hello, identity, info}; +use futures_util::{SinkExt, StreamExt}; use maple_agent::host::HostBackend; use maple_remote::client::RemoteHostBackend; use maple_remote::devices::DeviceStore; @@ -16,6 +17,7 @@ use maple_remote::net::{ConnectTarget, HostStores, connect_direct, serve_listene use maple_remote::pairing::{PairingCode, PairingLimiter, PendingPairingStore}; use maple_remote::server::{HostServer, HostServerConfig}; use maple_remote::wire::{ClientHello, HostInfo}; +use tokio_tungstenite::tungstenite::Message; use tokio_util::sync::CancellationToken; struct Host { @@ -242,6 +244,65 @@ async fn repeated_wrong_codes_lock_the_address_out() { host.shutdown.cancel(); } +#[tokio::test] +async fn one_code_pairs_exactly_one_of_two_racing_devices() { + let host = start_host(FakeHost::new(0)).await; + let code = PairingCode::generate(); + host.pending.publish(&code).unwrap(); + let first = StaticKey::generate().unwrap(); + let second = StaticKey::generate().unwrap(); + let (a, b) = tokio::join!( + connect_direct(&host.address, &first, ConnectTarget::Pair(code.clone())), + connect_direct(&host.address, &second, ConnectTarget::Pair(code.clone())), + ); + assert_eq!( + a.is_ok() as u8 + b.is_ok() as u8, + 1, + "exactly one pairing succeeds: {:?} / {:?}", + a.as_ref().err(), + b.as_ref().err() + ); + assert!(host.pending.current().is_none(), "the code is spent"); + let paired = host.devices.list().unwrap(); + assert_eq!(paired.len(), 1); + let winner = if a.is_ok() { &first } else { &second }; + assert_eq!(paired[0].public_key, winner.public_id()); + host.shutdown.cancel(); +} + +#[tokio::test] +async fn a_revoked_device_reconnecting_does_not_lock_out_pairing_again() { + let host = start_host(FakeHost::new(0)).await; + let device = StaticKey::generate().unwrap(); + let code = PairingCode::generate(); + host.pending.publish(&code).unwrap(); + connect_direct(&host.address, &device, ConnectTarget::Pair(code)) + .await + .expect("pairing"); + host.devices.revoke(&device.public_id()).unwrap(); + // More session refusals than the limiter allows pairing failures. + for _ in 0..5 { + assert!( + connect_direct( + &host.address, + &device, + ConnectTarget::Host { + host_key: host.key.public_id(), + }, + ) + .await + .is_err() + ); + } + let code = PairingCode::generate(); + host.pending.publish(&code).unwrap(); + connect_direct(&host.address, &device, ConnectTarget::Pair(code)) + .await + .expect("session refusals did not count against pairing"); + assert!(host.devices.is_paired(&device.public_id())); + host.shutdown.cancel(); +} + #[tokio::test] async fn large_frames_cross_the_noise_carrier_in_pieces() { // Attachments are larger than one Noise message; the carrier must cut @@ -267,3 +328,49 @@ async fn large_frames_cross_the_noise_carrier_in_pieces() { client.close().await; host.shutdown.cancel(); } + +#[tokio::test] +async fn an_oversized_websocket_message_is_refused_by_both_roles() { + let oversized = || Message::Binary(vec![1u8; 70_000].into()); + + // Host role: a raw peer without the cap sends more than one Noise + // message can hold. The host ends the connection without answering. + let host = start_host(FakeHost::new(0)).await; + let (mut socket, _) = tokio_tungstenite::connect_async(format!("ws://{}/", host.address)) + .await + .unwrap(); + socket.send(oversized()).await.unwrap(); + let next = tokio::time::timeout(Duration::from_secs(5), socket.next()) + .await + .expect("the host drops the connection"); + assert!( + !matches!(next, Some(Ok(Message::Binary(_)))), + "the host never answers an oversized message: {next:?}" + ); + host.shutdown.cancel(); + + // Client role: a raw host answers the client's first handshake message + // with an oversized one. The dial fails instead of buffering it. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap().to_string(); + tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut socket = tokio_tungstenite::accept_async(stream).await.unwrap(); + let _ = socket.next().await; + let _ = socket.send(oversized()).await; + while let Some(Ok(_)) = socket.next().await {} + }); + let device = StaticKey::generate().unwrap(); + let error = connect_direct( + &address, + &device, + ConnectTarget::Pair(PairingCode::generate()), + ) + .await + .err() + .expect("an oversized message fails the dial"); + assert!( + error.to_lowercase().contains("too long") || error.contains("limit"), + "{error}" + ); +} From cbbbdc631676ed32d79d49197c22f93f3cbb3699 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Sun, 20 Sep 2026 00:27:58 -0500 Subject: [PATCH 13/37] Carry request params as struct variants Every method's params lived in a one-off struct made by a macro, and the enums wrapped those structs in newtype variants, so the method name and its fields sat in two places and no-params methods sent an empty object. Each request enum now names its fields directly on the variant, with camelCase field names, and a method without params is a unit variant that sends no params key. The JSON for methods with params is unchanged. Decoding tries an empty or missing params both ways, since serde takes a unit variant without params and an all-optional struct variant with an empty object. The construction sites in the client and the server destructure the variants in place. Co-Authored-By: Claude Fable 5.1 --- .../crates/maple-remote/src/client.rs | 188 ++++----- .../crates/maple-remote/src/rpc.rs | 3 +- .../crates/maple-remote/src/server.rs | 303 +++++++------- .../crates/maple-remote/src/wire.rs | 380 +++++++++--------- 4 files changed, 430 insertions(+), 444 deletions(-) diff --git a/apps/maple-agent/crates/maple-remote/src/client.rs b/apps/maple-agent/crates/maple-remote/src/client.rs index e685907f6..2dafba878 100644 --- a/apps/maple-agent/crates/maple-remote/src/client.rs +++ b/apps/maple-agent/crates/maple-remote/src/client.rs @@ -41,15 +41,9 @@ use crate::outbound::{self, DEFAULT_MAX_OUTBOUND_BYTES, Outbound}; use crate::rpc::{self, Message, Request as RpcRequest, RpcError}; use crate::streams::StreamReceivers; use crate::wire::{ - AnswerQuestion, AskSideQuestion, AttachmentHandle, BootstrapSnapshot, CancelExternalAgent, - ClientHello, ContextUsageParams, CreateSession, EVENT_METHOD, Empty, EventEnvelope, HostHello, - HostRequest, IntegrationRequest, ListSessions, LoadSession, ModelName, ModelRequest, - PROTOCOL_VERSION, PermissionRespond, ProjectRequest, QueueControl, ReadAttachment, RemoveRoot, - RenameSession, ResolveSlashCommand, RootPath, RunId, RunRequest, SaveDefaultModel, - SaveMcpServers, SendMessage, SessionId, SessionRequest, SessionSnapshot, SetArchived, - SetIntegrationEnabled, SetPermissionMode, SetSessionDefaults, SetSessionMcp, SetTrust, - SetWebEnabled, StartRuntime, StoreToolSummary, SuggestDirectories, SummarizeThinking, - SummarizeToolCall, TimelinePage, TimelinePageParams, WorkingDir, + AttachmentHandle, BootstrapSnapshot, ClientHello, EVENT_METHOD, EventEnvelope, HostHello, + HostRequest, IntegrationRequest, ModelRequest, PROTOCOL_VERSION, ProjectRequest, RunRequest, + SessionRequest, SessionSnapshot, TimelinePage, }; #[derive(Debug, Clone)] @@ -318,7 +312,7 @@ impl RemoteHostBackend { return; } let ping = self.call_with_timeout::( - &HostRequest::Ping(Empty {}), + &HostRequest::Ping, self.config.ping_timeout, ); match ping.await { @@ -392,11 +386,11 @@ impl RemoteHostBackend { let mut items = Vec::with_capacity(expected_len.min(MAX_PREALLOCATED_ITEMS)); loop { let page: TimelinePage = self - .call(&SessionRequest::Timeline(TimelinePageParams { + .call(&SessionRequest::Timeline { session_id: session_id.to_string(), offset: items.len(), limit: self.config.timeline_page_items, - })) + }) .await?; let received = page.items.len(); items.extend(page.items); @@ -419,7 +413,7 @@ impl HostBackend for RemoteHostBackend { } async fn bootstrap(&self) -> Result { - let snapshot: BootstrapSnapshot = self.call(&HostRequest::Bootstrap(Empty {})).await?; + let snapshot: BootstrapSnapshot = self.call(&HostRequest::Bootstrap).await?; let mut bootstrap = snapshot.bootstrap; if let Some(latest) = bootstrap.latest.as_mut() { latest.timeline = self @@ -434,26 +428,25 @@ impl HostBackend for RemoteHostBackend { request: Option, ) -> Result { self.call_with_timeout( - &HostRequest::StartRuntime(StartRuntime { request }), + &HostRequest::StartRuntime { request }, self.config.long_request_timeout, ) .await } async fn stop_runtime(&self) -> Result { - self.call(&HostRequest::StopRuntime(Empty {})).await + self.call(&HostRequest::StopRuntime).await } async fn recent_project_roots(&self) -> Result, String> { - self.call(&ProjectRequest::RecentRoots(Empty {})).await + self.call(&ProjectRequest::RecentRoots).await } async fn select_project_root( &self, path: String, ) -> Result { - self.call(&ProjectRequest::SelectRoot(RootPath { path })) - .await + self.call(&ProjectRequest::SelectRoot { path }).await } async fn remove_project_root( @@ -461,27 +454,25 @@ impl HostBackend for RemoteHostBackend { path: String, fallback: Option, ) -> Result<(), String> { - self.call(&ProjectRequest::RemoveRoot(RemoveRoot { path, fallback })) + self.call(&ProjectRequest::RemoveRoot { path, fallback }) .await } async fn suggest_directories(&self, query: String) -> Result, String> { - self.call(&ProjectRequest::SuggestDirectories(SuggestDirectories { - query, - })) - .await + self.call(&ProjectRequest::SuggestDirectories { query }) + .await } async fn watch_project_root(&self, path: String) -> Result<(), String> { - self.call(&ProjectRequest::Watch(RootPath { path })).await + self.call(&ProjectRequest::Watch { path }).await } async fn unwatch_project_root(&self, path: String) -> Result<(), String> { - self.call(&ProjectRequest::Unwatch(RootPath { path })).await + self.call(&ProjectRequest::Unwatch { path }).await } async fn project_trust(&self, path: String) -> Result { - self.call(&ProjectRequest::Trust(RootPath { path })).await + self.call(&ProjectRequest::Trust { path }).await } async fn set_project_trust( @@ -489,31 +480,28 @@ impl HostBackend for RemoteHostBackend { path: String, trusted: bool, ) -> Result { - self.call(&ProjectRequest::SetTrust(SetTrust { path, trusted })) - .await + self.call(&ProjectRequest::SetTrust { path, trusted }).await } async fn list_sessions( &self, project_root: Option, ) -> Result, String> { - self.call(&SessionRequest::List(ListSessions { project_root })) - .await + self.call(&SessionRequest::List { project_root }).await } async fn create_session( &self, request: Option, ) -> Result { - self.call(&SessionRequest::Create(CreateSession { request })) - .await + self.call(&SessionRequest::Create { request }).await } async fn load_session(&self, session_id: String) -> Result { let snapshot: SessionSnapshot = self - .call(&SessionRequest::Load(LoadSession { + .call(&SessionRequest::Load { session_id: session_id.clone(), - })) + }) .await?; let mut detail = snapshot.detail; detail.timeline = self @@ -527,7 +515,7 @@ impl HostBackend for RemoteHostBackend { session_id: String, title: String, ) -> Result { - self.call(&SessionRequest::Rename(RenameSession { session_id, title })) + self.call(&SessionRequest::Rename { session_id, title }) .await } @@ -536,21 +524,19 @@ impl HostBackend for RemoteHostBackend { session_id: String, archived: bool, ) -> Result { - self.call(&SessionRequest::SetArchived(SetArchived { + self.call(&SessionRequest::SetArchived { session_id, archived, - })) + }) .await } async fn compact_session(&self, session_id: String) -> Result<(), String> { - self.call(&SessionRequest::Compact(SessionId { session_id })) - .await + self.call(&SessionRequest::Compact { session_id }).await } async fn session_subagents(&self, session_id: String) -> Result, String> { - self.call(&SessionRequest::Subagents(SessionId { session_id })) - .await + self.call(&SessionRequest::Subagents { session_id }).await } async fn cancel_external_agent( @@ -558,19 +544,16 @@ impl HostBackend for RemoteHostBackend { session_id: String, agent_id: String, ) -> Result<(), String> { - self.call(&SessionRequest::CancelExternalAgent(CancelExternalAgent { + self.call(&SessionRequest::CancelExternalAgent { session_id, agent_id, - })) + }) .await } async fn set_permission_mode(&self, session_id: String, mode: String) -> Result<(), String> { - self.call(&SessionRequest::SetPermissionMode(SetPermissionMode { - session_id, - mode, - })) - .await + self.call(&SessionRequest::SetPermissionMode { session_id, mode }) + .await } async fn set_session_web_enabled( @@ -578,10 +561,10 @@ impl HostBackend for RemoteHostBackend { session_id: String, enabled: bool, ) -> Result { - self.call(&SessionRequest::SetWebEnabled(SetWebEnabled { + self.call(&SessionRequest::SetWebEnabled { session_id, enabled, - })) + }) .await } @@ -590,11 +573,8 @@ impl HostBackend for RemoteHostBackend { session_id: String, model: Option, ) -> Result, String> { - self.call(&HostRequest::ContextUsage(ContextUsageParams { - session_id, - model, - })) - .await + self.call(&HostRequest::ContextUsage { session_id, model }) + .await } async fn read_image_attachment( @@ -603,10 +583,10 @@ impl HostBackend for RemoteHostBackend { attachment_id: String, ) -> Result, String> { let request_id = self.next_id.fetch_add(1, Ordering::Relaxed); - let request = SessionRequest::ReadAttachment(ReadAttachment { + let request = SessionRequest::ReadAttachment { session_id, attachment_id, - }); + }; // The host may have opened the stream before its answer failed or // the wait ran out; whatever it opened for this request goes too. let value = match self @@ -638,11 +618,11 @@ impl HostBackend for RemoteHostBackend { } async fn send_message(&self, request: AgentSendMessageRequest) -> Result { - self.call(&RunRequest::Send(SendMessage { request })).await + self.call(&RunRequest::Send { request }).await } async fn cancel_run(&self, run_id: String) -> Result<(), String> { - self.call(&RunRequest::Cancel(RunId { run_id })).await + self.call(&RunRequest::Cancel { run_id }).await } async fn cancel_queued_message( @@ -650,10 +630,10 @@ impl HostBackend for RemoteHostBackend { session_id: String, queue_id: String, ) -> Result { - self.call(&RunRequest::CancelQueued(QueueControl { + self.call(&RunRequest::CancelQueued { session_id, queue_id, - })) + }) .await } @@ -662,10 +642,10 @@ impl HostBackend for RemoteHostBackend { session_id: String, queue_id: String, ) -> Result<(), String> { - self.call(&RunRequest::BeginQueuedEdit(QueueControl { + self.call(&RunRequest::BeginQueuedEdit { session_id, queue_id, - })) + }) .await } @@ -674,19 +654,16 @@ impl HostBackend for RemoteHostBackend { session_id: String, queue_id: String, ) -> Result<(), String> { - self.call(&RunRequest::EndQueuedEdit(QueueControl { + self.call(&RunRequest::EndQueuedEdit { session_id, queue_id, - })) + }) .await } async fn answer_question(&self, request_id: String, answer: String) -> Result { - self.call(&RunRequest::AnswerQuestion(AnswerQuestion { - request_id, - answer, - })) - .await + self.call(&RunRequest::AnswerQuestion { request_id, answer }) + .await } async fn permission_respond( @@ -695,11 +672,11 @@ impl HostBackend for RemoteHostBackend { request_id: String, allow: bool, ) -> Result<(), String> { - self.call(&RunRequest::PermissionRespond(PermissionRespond { + self.call(&RunRequest::PermissionRespond { session_id, request_id, allow, - })) + }) .await } @@ -710,12 +687,12 @@ impl HostBackend for RemoteHostBackend { prior: Vec, question: String, ) -> Result<(), String> { - self.call(&RunRequest::AskSideQuestion(AskSideQuestion { + self.call(&RunRequest::AskSideQuestion { session_id, request_id, prior, question, - })) + }) .await } @@ -726,12 +703,12 @@ impl HostBackend for RemoteHostBackend { input: Option, output_text: String, ) -> Result, String> { - self.call(&RunRequest::SummarizeToolCall(SummarizeToolCall { + self.call(&RunRequest::SummarizeToolCall { session_id, tool_name, input, output_text, - })) + }) .await } @@ -740,16 +717,15 @@ impl HostBackend for RemoteHostBackend { session_id: String, thinking_text: String, ) -> Result, String> { - self.call(&RunRequest::SummarizeThinking(SummarizeThinking { + self.call(&RunRequest::SummarizeThinking { session_id, thinking_text, - })) + }) .await } async fn tool_summaries(&self, session_id: String) -> Result, String> { - self.call(&HostRequest::ToolSummaries(SessionId { session_id })) - .await + self.call(&HostRequest::ToolSummaries { session_id }).await } async fn store_tool_summary( @@ -758,28 +734,27 @@ impl HostBackend for RemoteHostBackend { item_id: String, summary: String, ) -> Result<(), String> { - self.call(&HostRequest::StoreToolSummary(StoreToolSummary { + self.call(&HostRequest::StoreToolSummary { session_id, item_id, summary, - })) + }) .await } async fn available_model_ids(&self) -> Result, String> { - self.call(&ModelRequest::List(Empty {})).await + self.call(&ModelRequest::List).await } async fn model_supports_vision(&self, model: String) -> Result, String> { - self.call(&ModelRequest::SupportsVision(ModelName { model })) - .await + self.call(&ModelRequest::SupportsVision { model }).await } async fn list_slash_commands( &self, working_dir: Option, ) -> Result, String> { - self.call(&ModelRequest::SlashCommands(WorkingDir { working_dir })) + self.call(&ModelRequest::SlashCommands { working_dir }) .await } @@ -789,11 +764,11 @@ impl HostBackend for RemoteHostBackend { command: String, args: String, ) -> Result, String> { - self.call(&ModelRequest::ResolveSlashCommand(ResolveSlashCommand { + self.call(&ModelRequest::ResolveSlashCommand { working_dir, command, args, - })) + }) .await } @@ -801,10 +776,8 @@ impl HostBackend for RemoteHostBackend { &self, session_id: String, ) -> Result, String> { - self.call(&IntegrationRequest::ListSessionMcp(SessionId { - session_id, - })) - .await + self.call(&IntegrationRequest::ListSessionMcp { session_id }) + .await } async fn set_session_mcp_server_enabled( @@ -814,29 +787,28 @@ impl HostBackend for RemoteHostBackend { kind: AgentSessionIntegrationKind, enabled: bool, ) -> Result, String> { - self.call(&IntegrationRequest::SetSessionMcp(SetSessionMcp { + self.call(&IntegrationRequest::SetSessionMcp { session_id, name, kind, enabled, - })) + }) .await } async fn list_mcp_servers(&self) -> Result, String> { - self.call(&IntegrationRequest::ListMcp(Empty {})).await + self.call(&IntegrationRequest::ListMcp).await } async fn save_mcp_servers( &self, servers: Vec, ) -> Result, String> { - self.call(&IntegrationRequest::SaveMcp(SaveMcpServers { servers })) - .await + self.call(&IntegrationRequest::SaveMcp { servers }).await } async fn list_integrations(&self) -> Result, String> { - self.call(&IntegrationRequest::List(Empty {})).await + self.call(&IntegrationRequest::List).await } async fn set_integration_enabled( @@ -844,11 +816,8 @@ impl HostBackend for RemoteHostBackend { id: String, enabled: bool, ) -> Result, String> { - self.call(&IntegrationRequest::SetEnabled(SetIntegrationEnabled { - id, - enabled, - })) - .await + self.call(&IntegrationRequest::SetEnabled { id, enabled }) + .await } /// The permission flow behind a setup runs on the host's own screen; @@ -859,22 +828,19 @@ impl HostBackend for RemoteHostBackend { } async fn session_defaults(&self) -> Result { - self.call(&HostRequest::SessionDefaults(Empty {})).await + self.call(&HostRequest::SessionDefaults).await } async fn set_session_defaults(&self, defaults: HostSessionDefaults) -> Result<(), String> { - self.call(&HostRequest::SetSessionDefaults(SetSessionDefaults { - defaults, - })) - .await + self.call(&HostRequest::SetSessionDefaults { defaults }) + .await } async fn save_default_model(&self, model: String) -> Result<(), String> { - self.call(&HostRequest::SaveDefaultModel(SaveDefaultModel { model })) - .await + self.call(&HostRequest::SaveDefaultModel { model }).await } async fn usage_summary(&self) -> Result { - self.call(&HostRequest::UsageSummary(Empty {})).await + self.call(&HostRequest::UsageSummary).await } } diff --git a/apps/maple-agent/crates/maple-remote/src/rpc.rs b/apps/maple-agent/crates/maple-remote/src/rpc.rs index 104edb9b9..86036a342 100644 --- a/apps/maple-agent/crates/maple-remote/src/rpc.rs +++ b/apps/maple-agent/crates/maple-remote/src/rpc.rs @@ -64,7 +64,8 @@ pub struct Request { pub jsonrpc: Version, pub id: u64, pub method: String, - #[serde(default)] + /// Omitted when there are none. + #[serde(default, skip_serializing_if = "Value::is_null")] pub params: Value, } diff --git a/apps/maple-agent/crates/maple-remote/src/server.rs b/apps/maple-agent/crates/maple-remote/src/server.rs index e9f4289c8..c0393566c 100644 --- a/apps/maple-agent/crates/maple-remote/src/server.rs +++ b/apps/maple-agent/crates/maple-remote/src/server.rs @@ -498,76 +498,75 @@ impl Connection { code::INVALID_REQUEST, "hello was already sent", )), - HostRequest::Ping(_) => Self::ok(Ok(serde_json::Map::::new())), - HostRequest::Bootstrap(_) => { + HostRequest::Ping => Self::ok(Ok(serde_json::Map::::new())), + HostRequest::Bootstrap => { let bootstrap = host.bootstrap().await.map_err(RpcError::host)?; Self::ok(Ok(self.snapshot_bootstrap(bootstrap).await)) } - HostRequest::StartRuntime(params) => Self::ok(host.start_runtime(params.request).await), - HostRequest::StopRuntime(_) => Self::ok(host.stop_runtime().await), - HostRequest::SessionDefaults(_) => Self::ok(host.session_defaults().await), - HostRequest::SetSessionDefaults(params) => { - Self::ok(host.set_session_defaults(params.defaults).await) + HostRequest::StartRuntime { request } => Self::ok(host.start_runtime(request).await), + HostRequest::StopRuntime => Self::ok(host.stop_runtime().await), + HostRequest::SessionDefaults => Self::ok(host.session_defaults().await), + HostRequest::SetSessionDefaults { defaults } => { + Self::ok(host.set_session_defaults(defaults).await) } - HostRequest::SaveDefaultModel(params) => { - Self::ok(host.save_default_model(params.model).await) + HostRequest::SaveDefaultModel { model } => { + Self::ok(host.save_default_model(model).await) } - HostRequest::UsageSummary(_) => Self::ok(host.usage_summary().await), - HostRequest::ContextUsage(params) => { - Self::ok(host.context_usage(params.session_id, params.model).await) + HostRequest::UsageSummary => Self::ok(host.usage_summary().await), + HostRequest::ContextUsage { session_id, model } => { + Self::ok(host.context_usage(session_id, model).await) } - HostRequest::ToolSummaries(params) => { - Self::ok(host.tool_summaries(params.session_id).await) + HostRequest::ToolSummaries { session_id } => { + Self::ok(host.tool_summaries(session_id).await) } - HostRequest::StoreToolSummary(params) => Self::ok( - host.store_tool_summary(params.session_id, params.item_id, params.summary) - .await, - ), + HostRequest::StoreToolSummary { + session_id, + item_id, + summary, + } => Self::ok(host.store_tool_summary(session_id, item_id, summary).await), } } async fn project_controller(&self, request: ProjectRequest) -> Result { let host = self.host(); match request { - ProjectRequest::RecentRoots(_) => Self::ok(host.recent_project_roots().await), - ProjectRequest::SelectRoot(params) => { - Self::ok(host.select_project_root(params.path).await) + ProjectRequest::RecentRoots => Self::ok(host.recent_project_roots().await), + ProjectRequest::SelectRoot { path } => Self::ok(host.select_project_root(path).await), + ProjectRequest::RemoveRoot { path, fallback } => { + Self::ok(host.remove_project_root(path, fallback).await) } - ProjectRequest::RemoveRoot(params) => { - Self::ok(host.remove_project_root(params.path, params.fallback).await) + ProjectRequest::SuggestDirectories { query } => { + Self::ok(host.suggest_directories(query).await) } - ProjectRequest::SuggestDirectories(params) => { - Self::ok(host.suggest_directories(params.query).await) - } - ProjectRequest::Watch(params) => { + ProjectRequest::Watch { path } => { let mut watched = self.watched_roots.lock().await; - if !watched.contains_key(¶ms.path) && watched.len() >= MAX_WATCHED_ROOTS { + if !watched.contains_key(&path) && watched.len() >= MAX_WATCHED_ROOTS { return Err(RpcError::new( code::INVALID_REQUEST, format!("a connection may watch at most {MAX_WATCHED_ROOTS} project roots"), )); } - host.watch_project_root(params.path.clone()) + host.watch_project_root(path.clone()) .await .map_err(RpcError::host)?; - *watched.entry(params.path).or_default() += 1; + *watched.entry(path).or_default() += 1; Self::ok(Ok(())) } - ProjectRequest::Unwatch(params) => { + ProjectRequest::Unwatch { path } => { let mut watched = self.watched_roots.lock().await; - match watched.get_mut(¶ms.path) { + match watched.get_mut(&path) { Some(count) if *count > 1 => *count -= 1, Some(_) => { - watched.remove(¶ms.path); + watched.remove(&path); } // Never watched here: nothing to balance. None => return Self::ok(Ok(())), } - Self::ok(host.unwatch_project_root(params.path).await) + Self::ok(host.unwatch_project_root(path).await) } - ProjectRequest::Trust(params) => Self::ok(host.project_trust(params.path).await), - ProjectRequest::SetTrust(params) => { - Self::ok(host.set_project_trust(params.path, params.trusted).await) + ProjectRequest::Trust { path } => Self::ok(host.project_trust(path).await), + ProjectRequest::SetTrust { path, trusted } => { + Self::ok(host.set_project_trust(path, trusted).await) } } } @@ -579,44 +578,52 @@ impl Connection { ) -> Result { let host = self.host(); match request { - SessionRequest::List(params) => Self::ok(host.list_sessions(params.project_root).await), - SessionRequest::Create(params) => Self::ok(host.create_session(params.request).await), - SessionRequest::Load(params) => { + SessionRequest::List { project_root } => { + Self::ok(host.list_sessions(project_root).await) + } + SessionRequest::Create { request } => Self::ok(host.create_session(request).await), + SessionRequest::Load { session_id } => { let detail = host - .load_session(params.session_id) + .load_session(session_id) .await .map_err(RpcError::host)?; Self::ok(Ok(self.snapshot_session(detail).await)) } - SessionRequest::Timeline(params) => Self::ok(self.timeline_page(params).await), - SessionRequest::Rename(params) => { - Self::ok(host.rename_session(params.session_id, params.title).await) - } - SessionRequest::SetArchived(params) => Self::ok( - host.set_session_archived(params.session_id, params.archived) - .await, - ), - SessionRequest::Compact(params) => { - Self::ok(host.compact_session(params.session_id).await) - } - SessionRequest::Subagents(params) => { - Self::ok(host.session_subagents(params.session_id).await) - } - SessionRequest::CancelExternalAgent(params) => Self::ok( - host.cancel_external_agent(params.session_id, params.agent_id) - .await, - ), - SessionRequest::SetPermissionMode(params) => Self::ok( - host.set_permission_mode(params.session_id, params.mode) - .await, - ), - SessionRequest::SetWebEnabled(params) => Self::ok( - host.set_session_web_enabled(params.session_id, params.enabled) - .await, - ), - SessionRequest::ReadAttachment(params) => { + SessionRequest::Timeline { + session_id, + offset, + limit, + } => Self::ok(self.timeline_page(session_id, offset, limit).await), + SessionRequest::Rename { session_id, title } => { + Self::ok(host.rename_session(session_id, title).await) + } + SessionRequest::SetArchived { + session_id, + archived, + } => Self::ok(host.set_session_archived(session_id, archived).await), + SessionRequest::Compact { session_id } => { + Self::ok(host.compact_session(session_id).await) + } + SessionRequest::Subagents { session_id } => { + Self::ok(host.session_subagents(session_id).await) + } + SessionRequest::CancelExternalAgent { + session_id, + agent_id, + } => Self::ok(host.cancel_external_agent(session_id, agent_id).await), + SessionRequest::SetPermissionMode { session_id, mode } => { + Self::ok(host.set_permission_mode(session_id, mode).await) + } + SessionRequest::SetWebEnabled { + session_id, + enabled, + } => Self::ok(host.set_session_web_enabled(session_id, enabled).await), + SessionRequest::ReadAttachment { + session_id, + attachment_id, + } => { let bytes = host - .read_image_attachment(params.session_id, params.attachment_id) + .read_image_attachment(session_id, attachment_id) .await .map_err(RpcError::host)?; let sender = self @@ -653,66 +660,68 @@ impl Connection { async fn run_controller(&self, request: RunRequest) -> Result { let host = self.host(); match request { - RunRequest::Send(params) => Self::ok(host.send_message(params.request).await), - RunRequest::Cancel(params) => Self::ok(host.cancel_run(params.run_id).await), - RunRequest::CancelQueued(params) => Self::ok( - host.cancel_queued_message(params.session_id, params.queue_id) - .await, - ), - RunRequest::BeginQueuedEdit(params) => Self::ok( - host.begin_queued_message_edit(params.session_id, params.queue_id) - .await, - ), - RunRequest::EndQueuedEdit(params) => Self::ok( - host.end_queued_message_edit(params.session_id, params.queue_id) - .await, - ), - RunRequest::AnswerQuestion(params) => { - Self::ok(host.answer_question(params.request_id, params.answer).await) - } - RunRequest::PermissionRespond(params) => Self::ok( - host.permission_respond(params.session_id, params.request_id, params.allow) + RunRequest::Send { request } => Self::ok(host.send_message(request).await), + RunRequest::Cancel { run_id } => Self::ok(host.cancel_run(run_id).await), + RunRequest::CancelQueued { + session_id, + queue_id, + } => Self::ok(host.cancel_queued_message(session_id, queue_id).await), + RunRequest::BeginQueuedEdit { + session_id, + queue_id, + } => Self::ok(host.begin_queued_message_edit(session_id, queue_id).await), + RunRequest::EndQueuedEdit { + session_id, + queue_id, + } => Self::ok(host.end_queued_message_edit(session_id, queue_id).await), + RunRequest::AnswerQuestion { request_id, answer } => { + Self::ok(host.answer_question(request_id, answer).await) + } + RunRequest::PermissionRespond { + session_id, + request_id, + allow, + } => Self::ok(host.permission_respond(session_id, request_id, allow).await), + RunRequest::AskSideQuestion { + session_id, + request_id, + prior, + question, + } => Self::ok( + host.ask_side_question(session_id, request_id, prior, question) .await, ), - RunRequest::AskSideQuestion(params) => Self::ok( - host.ask_side_question( - params.session_id, - params.request_id, - params.prior, - params.question, - ) - .await, - ), - RunRequest::SummarizeToolCall(params) => Self::ok( - host.summarize_tool_call( - params.session_id, - params.tool_name, - params.input, - params.output_text, - ) - .await, - ), - RunRequest::SummarizeThinking(params) => Self::ok( - host.summarize_thinking(params.session_id, params.thinking_text) + RunRequest::SummarizeToolCall { + session_id, + tool_name, + input, + output_text, + } => Self::ok( + host.summarize_tool_call(session_id, tool_name, input, output_text) .await, ), + RunRequest::SummarizeThinking { + session_id, + thinking_text, + } => Self::ok(host.summarize_thinking(session_id, thinking_text).await), } } async fn model_controller(&self, request: ModelRequest) -> Result { let host = self.host(); match request { - ModelRequest::List(_) => Self::ok(host.available_model_ids().await), - ModelRequest::SupportsVision(params) => { - Self::ok(host.model_supports_vision(params.model).await) - } - ModelRequest::SlashCommands(params) => { - Self::ok(host.list_slash_commands(params.working_dir).await) - } - ModelRequest::ResolveSlashCommand(params) => Self::ok( - host.resolve_slash_command(params.working_dir, params.command, params.args) - .await, - ), + ModelRequest::List => Self::ok(host.available_model_ids().await), + ModelRequest::SupportsVision { model } => { + Self::ok(host.model_supports_vision(model).await) + } + ModelRequest::SlashCommands { working_dir } => { + Self::ok(host.list_slash_commands(working_dir).await) + } + ModelRequest::ResolveSlashCommand { + working_dir, + command, + args, + } => Self::ok(host.resolve_slash_command(working_dir, command, args).await), } } @@ -722,30 +731,29 @@ impl Connection { ) -> Result { let host = self.host(); match request { - IntegrationRequest::ListSessionMcp(params) => { - Self::ok(host.list_session_mcp_servers(params.session_id).await) - } - IntegrationRequest::SetSessionMcp(params) => Self::ok( - host.set_session_mcp_server_enabled( - params.session_id, - params.name, - params.kind, - params.enabled, - ) - .await, - ), - IntegrationRequest::ListMcp(_) => Self::ok(host.list_mcp_servers().await), - IntegrationRequest::SaveMcp(params) => { - Self::ok(host.save_mcp_servers(params.servers).await) - } - IntegrationRequest::List(_) => Self::ok(host.list_integrations().await), - IntegrationRequest::SetEnabled(params) => Self::ok( - host.set_integration_enabled(params.id, params.enabled) + IntegrationRequest::ListSessionMcp { session_id } => { + Self::ok(host.list_session_mcp_servers(session_id).await) + } + IntegrationRequest::SetSessionMcp { + session_id, + name, + kind, + enabled, + } => Self::ok( + host.set_session_mcp_server_enabled(session_id, name, kind, enabled) .await, ), + IntegrationRequest::ListMcp => Self::ok(host.list_mcp_servers().await), + IntegrationRequest::SaveMcp { servers } => { + Self::ok(host.save_mcp_servers(servers).await) + } + IntegrationRequest::List => Self::ok(host.list_integrations().await), + IntegrationRequest::SetEnabled { id, enabled } => { + Self::ok(host.set_integration_enabled(id, enabled).await) + } // The permission flow behind a setup runs on the host's own // screen; `HostBackend` documents it as local-only. - IntegrationRequest::Setup(_) => Err(RpcError::new( + IntegrationRequest::Setup { .. } => Err(RpcError::new( code::INVALID_REQUEST, crate::client::SETUP_IS_LOCAL, )), @@ -806,23 +814,24 @@ impl Connection { /// the answer carries. async fn timeline_page( &self, - params: crate::wire::TimelinePageParams, + session_id: String, + offset: usize, + limit: usize, ) -> Result { - let detail = match self.kept_snapshot(¶ms.session_id).await { + let detail = match self.kept_snapshot(&session_id).await { Some(detail) => detail, None => { - let detail = self.host().load_session(params.session_id.clone()).await?; + let detail = self.host().load_session(session_id.clone()).await?; let detail = Arc::new(detail); - self.keep_snapshot(¶ms.session_id, Arc::clone(&detail)) - .await; + self.keep_snapshot(&session_id, Arc::clone(&detail)).await; detail } }; let config = &self.server.config; - let limit = params.limit.clamp(1, config.timeline_page_items); + let limit = limit.clamp(1, config.timeline_page_items); let mut items: Vec> = Vec::new(); let mut bytes = 0usize; - for item in detail.timeline.iter().skip(params.offset) { + for item in detail.timeline.iter().skip(offset) { let json = serde_json::to_string(item).map_err(|error| error.to_string())?; if !items.is_empty() && (items.len() >= limit || bytes + json.len() > config.timeline_page_bytes) @@ -832,7 +841,7 @@ impl Connection { bytes += json.len(); items.push(RawValue::from_string(json).map_err(|error| error.to_string())?); } - let has_more = params.offset + items.len() < detail.timeline.len(); + let has_more = offset + items.len() < detail.timeline.len(); Ok(EncodedTimelinePage { items, has_more }) } } diff --git a/apps/maple-agent/crates/maple-remote/src/wire.rs b/apps/maple-agent/crates/maple-remote/src/wire.rs index 0a040612d..e4f78df4d 100644 --- a/apps/maple-agent/crates/maple-remote/src/wire.rs +++ b/apps/maple-agent/crates/maple-remote/src/wire.rs @@ -1,10 +1,11 @@ //! The methods a client calls on a host, grouped by domain, and the //! handshake both sides exchange first. //! -//! Every request enum is internally tagged by the JSON-RPC method name, so -//! a request `{ "method": "session.list", "params": {...} }` decodes into -//! `SessionRequest::List(ListSessions {...})`. The server dispatches by the -//! prefix before the dot; each domain has its own controller. +//! Every request enum is tagged by the JSON-RPC method name with the +//! variant's fields as `params`, so `{ "method": "session.list", "params": +//! {...} }` decodes into `SessionRequest::List {...}`. A method without +//! params is a unit variant and carries no `params`. The server dispatches +//! by the prefix before the dot; each domain has its own controller. //! //! Compatibility: append-only. New params are `Option` with a default; //! unknown fields are ignored on both sides. See the crate docs. @@ -146,265 +147,213 @@ pub struct AttachmentHandle { } // ---- Domain requests -------------------------------------------------------- - -macro_rules! params { - ($name:ident { $($field:ident : $ty:ty),* $(,)? }) => { - #[derive(Debug, Clone, Serialize, Deserialize)] - #[serde(rename_all = "camelCase")] - pub struct $name { $(pub $field: $ty,)* } - }; -} - -params!(Empty {}); -params!(StartRuntime { request: Option }); -params!(SetSessionDefaults { - defaults: HostSessionDefaults -}); -params!(SaveDefaultModel { model: String }); -params!(ContextUsageParams { session_id: String, model: Option }); -params!(SessionId { session_id: String }); -params!(StoreToolSummary { - session_id: String, - item_id: String, - summary: String -}); +// +// Each variant is one method. A variant with fields carries them as the +// `params` object, in camelCase; a unit variant has no `params`. /// `host.*`: the connection, the runtime, and host configuration. #[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "method", content = "params")] +#[serde(tag = "method", content = "params", rename_all_fields = "camelCase")] pub enum HostRequest { #[serde(rename = "host.hello")] Hello(ClientHello), #[serde(rename = "host.ping")] - Ping(Empty), + Ping, #[serde(rename = "host.bootstrap")] - Bootstrap(Empty), + Bootstrap, #[serde(rename = "host.start_runtime")] - StartRuntime(StartRuntime), + StartRuntime { request: Option }, #[serde(rename = "host.stop_runtime")] - StopRuntime(Empty), + StopRuntime, #[serde(rename = "host.session_defaults")] - SessionDefaults(Empty), + SessionDefaults, #[serde(rename = "host.set_session_defaults")] - SetSessionDefaults(SetSessionDefaults), + SetSessionDefaults { defaults: HostSessionDefaults }, #[serde(rename = "host.save_default_model")] - SaveDefaultModel(SaveDefaultModel), + SaveDefaultModel { model: String }, #[serde(rename = "host.usage_summary")] - UsageSummary(Empty), + UsageSummary, #[serde(rename = "host.context_usage")] - ContextUsage(ContextUsageParams), + ContextUsage { + session_id: String, + model: Option, + }, #[serde(rename = "host.tool_summaries")] - ToolSummaries(SessionId), + ToolSummaries { session_id: String }, #[serde(rename = "host.store_tool_summary")] - StoreToolSummary(StoreToolSummary), + StoreToolSummary { + session_id: String, + item_id: String, + summary: String, + }, } -params!(RootPath { path: String }); -params!(RemoveRoot { path: String, fallback: Option }); -params!(SuggestDirectories { query: String }); -params!(SetTrust { - path: String, - trusted: bool -}); - /// `project.*`: roots on the host's filesystem. #[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "method", content = "params")] +#[serde(tag = "method", content = "params", rename_all_fields = "camelCase")] pub enum ProjectRequest { #[serde(rename = "project.recent_roots")] - RecentRoots(Empty), + RecentRoots, #[serde(rename = "project.select_root")] - SelectRoot(RootPath), + SelectRoot { path: String }, #[serde(rename = "project.remove_root")] - RemoveRoot(RemoveRoot), + RemoveRoot { + path: String, + fallback: Option, + }, #[serde(rename = "project.suggest_directories")] - SuggestDirectories(SuggestDirectories), + SuggestDirectories { query: String }, #[serde(rename = "project.watch")] - Watch(RootPath), + Watch { path: String }, #[serde(rename = "project.unwatch")] - Unwatch(RootPath), + Unwatch { path: String }, #[serde(rename = "project.trust")] - Trust(RootPath), + Trust { path: String }, #[serde(rename = "project.set_trust")] - SetTrust(SetTrust), + SetTrust { path: String, trusted: bool }, } -params!(ListSessions { project_root: Option }); -params!(CreateSession { request: Option }); -params!(LoadSession { session_id: String }); -params!(TimelinePageParams { - session_id: String, - offset: usize, - limit: usize -}); -params!(RenameSession { - session_id: String, - title: String -}); -params!(SetArchived { - session_id: String, - archived: bool -}); -params!(CancelExternalAgent { - session_id: String, - agent_id: String -}); -params!(SetPermissionMode { - session_id: String, - mode: String -}); -params!(SetWebEnabled { - session_id: String, - enabled: bool -}); -params!(ReadAttachment { - session_id: String, - attachment_id: String -}); - /// `session.*`: tasks and their snapshots. #[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "method", content = "params")] +#[serde(tag = "method", content = "params", rename_all_fields = "camelCase")] pub enum SessionRequest { #[serde(rename = "session.list")] - List(ListSessions), + List { project_root: Option }, #[serde(rename = "session.create")] - Create(CreateSession), + Create { + request: Option, + }, /// Returns a [`SessionSnapshot`]; page its timeline with `Timeline`. #[serde(rename = "session.load")] - Load(LoadSession), + Load { session_id: String }, /// One page of the snapshot the last `Load` (or the bootstrap) built for /// this task on this connection. Returns a [`TimelinePage`]. #[serde(rename = "session.timeline")] - Timeline(TimelinePageParams), + Timeline { + session_id: String, + offset: usize, + limit: usize, + }, #[serde(rename = "session.rename")] - Rename(RenameSession), + Rename { session_id: String, title: String }, #[serde(rename = "session.set_archived")] - SetArchived(SetArchived), + SetArchived { session_id: String, archived: bool }, #[serde(rename = "session.compact")] - Compact(SessionId), + Compact { session_id: String }, #[serde(rename = "session.subagents")] - Subagents(SessionId), + Subagents { session_id: String }, #[serde(rename = "session.cancel_external_agent")] - CancelExternalAgent(CancelExternalAgent), + CancelExternalAgent { + session_id: String, + agent_id: String, + }, #[serde(rename = "session.set_permission_mode")] - SetPermissionMode(SetPermissionMode), + SetPermissionMode { session_id: String, mode: String }, #[serde(rename = "session.set_web_enabled")] - SetWebEnabled(SetWebEnabled), + SetWebEnabled { session_id: String, enabled: bool }, /// Returns an [`AttachmentHandle`]; the bytes arrive on its stream. #[serde(rename = "session.read_attachment")] - ReadAttachment(ReadAttachment), + ReadAttachment { + session_id: String, + attachment_id: String, + }, } -params!(SendMessage { - request: AgentSendMessageRequest -}); -params!(RunId { run_id: String }); -params!(QueueControl { - session_id: String, - queue_id: String -}); -params!(AnswerQuestion { - request_id: String, - answer: String -}); -params!(PermissionRespond { - session_id: String, - request_id: String, - allow: bool -}); -params!(AskSideQuestion { - session_id: String, - request_id: String, - prior: Vec, - question: String, -}); -params!(SummarizeToolCall { - session_id: String, - tool_name: String, - input: Option, - output_text: String, -}); -params!(SummarizeThinking { - session_id: String, - thinking_text: String -}); - /// `run.*`: messages, runs, and the prompts they raise. #[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "method", content = "params")] +#[serde(tag = "method", content = "params", rename_all_fields = "camelCase")] pub enum RunRequest { #[serde(rename = "run.send")] - Send(SendMessage), + Send { request: AgentSendMessageRequest }, #[serde(rename = "run.cancel")] - Cancel(RunId), + Cancel { run_id: String }, #[serde(rename = "run.cancel_queued")] - CancelQueued(QueueControl), + CancelQueued { + session_id: String, + queue_id: String, + }, #[serde(rename = "run.begin_queued_edit")] - BeginQueuedEdit(QueueControl), + BeginQueuedEdit { + session_id: String, + queue_id: String, + }, #[serde(rename = "run.end_queued_edit")] - EndQueuedEdit(QueueControl), + EndQueuedEdit { + session_id: String, + queue_id: String, + }, #[serde(rename = "run.answer_question")] - AnswerQuestion(AnswerQuestion), + AnswerQuestion { request_id: String, answer: String }, #[serde(rename = "run.permission_respond")] - PermissionRespond(PermissionRespond), + PermissionRespond { + session_id: String, + request_id: String, + allow: bool, + }, #[serde(rename = "run.ask_side_question")] - AskSideQuestion(AskSideQuestion), + AskSideQuestion { + session_id: String, + request_id: String, + prior: Vec, + question: String, + }, #[serde(rename = "run.summarize_tool_call")] - SummarizeToolCall(SummarizeToolCall), + SummarizeToolCall { + session_id: String, + tool_name: String, + input: Option, + output_text: String, + }, #[serde(rename = "run.summarize_thinking")] - SummarizeThinking(SummarizeThinking), + SummarizeThinking { + session_id: String, + thinking_text: String, + }, } -params!(ModelName { model: String }); -params!(WorkingDir { working_dir: Option }); -params!(ResolveSlashCommand { working_dir: Option, command: String, args: String }); - /// `model.*`: the catalog and skills. #[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "method", content = "params")] +#[serde(tag = "method", content = "params", rename_all_fields = "camelCase")] pub enum ModelRequest { #[serde(rename = "model.list")] - List(Empty), + List, #[serde(rename = "model.supports_vision")] - SupportsVision(ModelName), + SupportsVision { model: String }, #[serde(rename = "model.slash_commands")] - SlashCommands(WorkingDir), + SlashCommands { working_dir: Option }, #[serde(rename = "model.resolve_slash_command")] - ResolveSlashCommand(ResolveSlashCommand), + ResolveSlashCommand { + working_dir: Option, + command: String, + args: String, + }, } -params!(SetSessionMcp { - session_id: String, - name: String, - kind: AgentSessionIntegrationKind, - enabled: bool, -}); -params!(SaveMcpServers { servers: Vec }); -params!(SetIntegrationEnabled { - id: String, - enabled: bool -}); -params!(IntegrationId { id: String }); - /// `integration.*`: MCP servers and curated integrations. #[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "method", content = "params")] +#[serde(tag = "method", content = "params", rename_all_fields = "camelCase")] pub enum IntegrationRequest { #[serde(rename = "integration.list_session_mcp")] - ListSessionMcp(SessionId), + ListSessionMcp { session_id: String }, #[serde(rename = "integration.set_session_mcp")] - SetSessionMcp(SetSessionMcp), + SetSessionMcp { + session_id: String, + name: String, + kind: AgentSessionIntegrationKind, + enabled: bool, + }, #[serde(rename = "integration.list_mcp")] - ListMcp(Empty), + ListMcp, #[serde(rename = "integration.save_mcp")] - SaveMcp(SaveMcpServers), + SaveMcp { servers: Vec }, #[serde(rename = "integration.list")] - List(Empty), + List, #[serde(rename = "integration.set_enabled")] - SetEnabled(SetIntegrationEnabled), + SetEnabled { id: String, enabled: bool }, + /// Refused over the wire; the flow behind it runs on the host's own + /// screen. Kept so the method name stays reserved. #[serde(rename = "integration.setup")] - Setup(IntegrationId), + Setup { id: String }, } /// A request decoded into its domain. @@ -513,18 +462,39 @@ pub fn is_known_method(method: &str) -> bool { } /// Decode a JSON-RPC request by its method's domain prefix. +/// +/// A method without params is a unit variant, which serde accepts with +/// `params` missing or `null` but not `{}`; a method whose params are all +/// optional is a struct variant, which needs `{}`. A missing, `null`, or +/// empty `params` is tried both ways so either client shape decodes. pub fn decode_request(method: &str, params: Value) -> Result { if !is_known_method(method) { return Err(DecodeError::UnknownMethod(method.to_string())); } - let (domain, _) = method - .split_once('.') - .ok_or_else(|| DecodeError::UnknownMethod(method.to_string()))?; - let tagged = serde_json::json!({ "method": method, "params": params }); + let empty = params.is_null() || params.as_object().is_some_and(|object| object.is_empty()); + if !empty { + return decode_tagged( + method, + serde_json::json!({ "method": method, "params": params }), + ); + } + decode_tagged( + method, + serde_json::json!({ "method": method, "params": {} }), + ) + .or_else(|first| { + decode_tagged(method, serde_json::json!({ "method": method })).map_err(|_| first) + }) +} + +fn decode_tagged(method: &str, tagged: Value) -> Result { fn decode(tagged: Value) -> Result { serde_json::from_value(tagged) .map_err(|error| DecodeError::InvalidParams(error.to_string())) } + let (domain, _) = method + .split_once('.') + .ok_or_else(|| DecodeError::UnknownMethod(method.to_string()))?; Ok(match domain { "host" => Request::Host(decode(tagged)?), "project" => Request::Project(decode(tagged)?), @@ -536,7 +506,9 @@ pub fn decode_request(method: &str, params: Value) -> Result(request: &T) -> Result<(String, Value), String> { let value = serde_json::to_value(request).map_err(|error| error.to_string())?; let method = value @@ -554,14 +526,14 @@ mod tests { #[test] fn requests_decode_by_domain_and_refuse_unknown_methods() { - let (method, params) = encode_request(&SessionRequest::List(ListSessions { + let (method, params) = encode_request(&SessionRequest::List { project_root: Some("/p".to_string()), - })) + }) .unwrap(); assert_eq!(method, "session.list"); match decode_request(&method, params).unwrap() { - Request::Session(SessionRequest::List(list)) => { - assert_eq!(list.project_root.as_deref(), Some("/p")); + Request::Session(SessionRequest::List { project_root }) => { + assert_eq!(project_root.as_deref(), Some("/p")); } other => panic!("wrong decode: {other:?}"), } @@ -590,6 +562,44 @@ mod tests { } } + #[test] + fn params_keep_their_wire_shape_and_empty_params_decode_both_ways() { + // A method with params: the same JSON as before the variants held + // their own structs. + let (method, params) = encode_request(&SessionRequest::Rename { + session_id: "s1".to_string(), + title: "T".to_string(), + }) + .unwrap(); + assert_eq!(method, "session.rename"); + assert_eq!(params, serde_json::json!({"sessionId": "s1", "title": "T"})); + // A method without params has none. + let (method, params) = encode_request(&HostRequest::Ping).unwrap(); + assert_eq!(method, "host.ping"); + assert_eq!(params, Value::Null); + for params in [Value::Null, serde_json::json!({})] { + assert!(matches!( + decode_request("host.ping", params.clone()), + Ok(Request::Host(HostRequest::Ping)) + )); + // All-optional params decode from nothing as well. + assert!(matches!( + decode_request("session.list", params), + Ok(Request::Session(SessionRequest::List { + project_root: None + })) + )); + } + assert!(matches!( + decode_request("host.ping", serde_json::json!({"extra": 1})), + Err(DecodeError::InvalidParams(_)) + )); + assert!(matches!( + decode_request("session.rename", Value::Null), + Err(DecodeError::InvalidParams(_)) + )); + } + /// The names serde accepts as tags for `T`, read from its error for a /// tag it does not know. fn serde_variants( From 7603268804a8711d1d12dff63d7194a9e982f52d Mon Sep 17 00:00:00 2001 From: benthecarman Date: Sun, 20 Sep 2026 00:29:26 -0500 Subject: [PATCH 14/37] Split the network module by role net.rs held both the host's listener and the client's dialer, so a reader of either role had to read the other and the pairing-failure accounting sat beside code that never pairs. Move the host role (serve_listener, handle_connection, HostStores, the pairing-failure accounting) to listen.rs and the client role (connect_direct, ConnectTarget, Dialed) to dial.rs. net.rs keeps the WebSocket configuration and the handshake budget both roles share and re-exports both roles, so the desktop app and the tests compile unchanged. No behavior changes. Co-Authored-By: Claude Fable 5.1 --- .../crates/maple-remote/src/dial.rs | 66 ++++++ .../crates/maple-remote/src/lib.rs | 5 +- .../crates/maple-remote/src/listen.rs | 162 +++++++++++++ .../crates/maple-remote/src/net.rs | 219 +----------------- 4 files changed, 239 insertions(+), 213 deletions(-) create mode 100644 apps/maple-agent/crates/maple-remote/src/dial.rs create mode 100644 apps/maple-agent/crates/maple-remote/src/listen.rs diff --git a/apps/maple-agent/crates/maple-remote/src/dial.rs b/apps/maple-agent/crates/maple-remote/src/dial.rs new file mode 100644 index 000000000..c3d6b4654 --- /dev/null +++ b/apps/maple-agent/crates/maple-remote/src/dial.rs @@ -0,0 +1,66 @@ +//! The client role on the network: dialing a host over plain WebSocket +//! with Noise inside. +//! +//! [`connect_direct`] dials a host by address, either to pair with a code +//! or to reconnect with the host's pinned static key. The relay is another +//! connector later; everything above the carrier is shared. + +use crate::carrier::Carrier; +use crate::keys::{StaticKey, decode_key, encode_key}; +use crate::net::{HANDSHAKE_TIMEOUT, websocket_config}; +use crate::noise::{self, Initiate}; +use crate::pairing::PairingCode; + +/// What to dial for. +pub enum ConnectTarget { + /// First contact: pair with the code the host published. + Pair(PairingCode), + /// A host already paired, whose static key is pinned. + Host { host_key: String }, +} + +/// A carrier to the host at `address` plus the host's static key, which +/// the client pins after pairing and verifies afterwards. +pub struct Dialed { + pub carrier: Carrier, + pub host_key: String, +} + +/// Dial `address` (`host:port`) over plain WebSocket and run the Noise +/// handshake as `device`. +pub async fn connect_direct( + address: &str, + device: &StaticKey, + target: ConnectTarget, +) -> Result { + let url = format!("ws://{address}/"); + let (socket, _) = tokio::time::timeout( + HANDSHAKE_TIMEOUT, + tokio_tungstenite::connect_async_with_config(&url, Some(websocket_config()), true), + ) + .await + .map_err(|_| format!("connecting to {address} timed out"))? + .map_err(|error| format!("cannot connect to {address}: {error}"))?; + let initiate = match &target { + ConnectTarget::Pair(code) => Initiate::Pair { psk: code.psk() }, + ConnectTarget::Host { host_key } => Initiate::Session { + host_static: decode_key(host_key)?, + }, + }; + let established = tokio::time::timeout( + HANDSHAKE_TIMEOUT, + noise::initiate(socket, device.private(), initiate), + ) + .await + .map_err(|_| "the host did not finish the handshake in time".to_string())??; + let host_key = encode_key(&established.remote_static); + if let ConnectTarget::Host { host_key: pinned } = &target + && pinned != &host_key + { + return Err("the host's key does not match the pinned key".to_string()); + } + Ok(Dialed { + carrier: established.carrier, + host_key, + }) +} diff --git a/apps/maple-agent/crates/maple-remote/src/lib.rs b/apps/maple-agent/crates/maple-remote/src/lib.rs index af58cb4a8..0a3869d97 100644 --- a/apps/maple-agent/crates/maple-remote/src/lib.rs +++ b/apps/maple-agent/crates/maple-remote/src/lib.rs @@ -4,7 +4,8 @@ //! //! - [`carrier`]: a bidirectional stream of [`frame::Frame`]s. The //! in-process pair is for tests; [`noise`] delivers the same frames over -//! a WebSocket with Noise inside, and [`net`] dials and listens. +//! a WebSocket with Noise inside, [`listen`] accepts them for a host, +//! [`dial`] opens them for a client, and [`net`] holds what both share. //! - [`keys`], [`pairing`], [`devices`]: the static key of a host or a //! device, the one-time pairing code, and the host's paired device list. //! - [`hosts`], [`manager`]: the client's saved hosts, and the connectors @@ -31,9 +32,11 @@ pub mod carrier; pub mod client; pub mod devices; +pub mod dial; pub mod frame; pub mod hosts; pub mod keys; +pub mod listen; pub mod manager; pub mod net; pub mod noise; diff --git a/apps/maple-agent/crates/maple-remote/src/listen.rs b/apps/maple-agent/crates/maple-remote/src/listen.rs new file mode 100644 index 000000000..600516d86 --- /dev/null +++ b/apps/maple-agent/crates/maple-remote/src/listen.rs @@ -0,0 +1,162 @@ +//! The host role on the network: listening over plain WebSocket with +//! Noise inside. +//! +//! [`serve_listener`] accepts TCP connections on behalf of one +//! [`HostServer`], runs the handshake, registers a newly paired device, +//! and hands each established carrier to the server. Pairing failures are +//! counted per source address here; see [`PairingLimiter`]. + +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; + +use tokio::net::{TcpListener, TcpStream}; +use tokio_util::sync::CancellationToken; + +use crate::devices::DeviceStore; +use crate::keys::{StaticKey, encode_key}; +use crate::net::{HANDSHAKE_TIMEOUT, websocket_config}; +use crate::noise::{self, HandshakeMode, Respond}; +use crate::pairing::{PairingCode, PairingLimiter, PendingPairingStore}; +use crate::server::HostServer; + +/// The name a device carries until its first hello names it. +const UNNAMED_DEVICE: &str = "new device"; + +/// What a listening host needs besides the server: its key and its +/// device and pairing records. +pub struct HostStores { + pub key: StaticKey, + pub devices: Arc, + pub pending_pairing: Arc, + pub limiter: PairingLimiter, +} + +/// Accept connections until `shutdown` fires. +pub async fn serve_listener( + listener: TcpListener, + server: Arc, + stores: Arc, + shutdown: CancellationToken, +) -> Result<(), String> { + log::info!( + "listening on {} as host {}", + listener + .local_addr() + .map(|addr| addr.to_string()) + .unwrap_or_default(), + stores.key.public_id() + ); + loop { + let (stream, peer) = tokio::select! { + accepted = listener.accept() => match accepted { + Ok(accepted) => accepted, + Err(error) => { + log::warn!("accept failed: {error}"); + tokio::time::sleep(Duration::from_millis(100)).await; + continue; + } + }, + _ = shutdown.cancelled() => return Ok(()), + }; + let server = Arc::clone(&server); + let stores = Arc::clone(&stores); + let shutdown = shutdown.clone(); + tokio::spawn(async move { + if let Err(error) = handle_connection(stream, peer, server, stores, shutdown).await { + log::info!("connection from {peer} ended: {error}"); + } + }); + } +} + +async fn handle_connection( + stream: TcpStream, + peer: SocketAddr, + server: Arc, + stores: Arc, + shutdown: CancellationToken, +) -> Result<(), String> { + let _ = stream.set_nodelay(true); + // One deadline covers both handshakes. + let deadline = tokio::time::Instant::now() + HANDSHAKE_TIMEOUT; + let socket = tokio::time::timeout_at( + deadline, + tokio_tungstenite::accept_async_with_config(stream, Some(websocket_config())), + ) + .await + .map_err(|_| "handshake timed out".to_string())? + .map_err(|error| format!("websocket accept: {error}"))?; + let pending_code = match stores.pending_pairing.current() { + Some(pending) if stores.limiter.allows(peer.ip()) => Some(pending.code()?), + Some(_) => { + log::warn!("pairing attempts from {peer} are rate limited"); + None + } + None => None, + }; + let devices = Arc::clone(&stores.devices); + let is_paired = move |key: &[u8; 32]| devices.is_paired(&encode_key(key)); + let confirm_pairing = |key: &[u8; 32]| -> Result<(), String> { + let code = pending_code + .as_ref() + .ok_or_else(|| "no pairing code is pending".to_string())?; + stores.pending_pairing.consume_if(code)?; + stores.devices.insert(&encode_key(key), UNNAMED_DEVICE)?; + Ok(()) + }; + let established = tokio::time::timeout_at( + deadline, + noise::respond( + socket, + stores.key.private(), + Respond { + pairing_psk: pending_code.as_ref().map(PairingCode::psk), + is_paired: &is_paired, + confirm_pairing: &confirm_pairing, + }, + ), + ) + .await + .map_err(|_| "handshake timed out".to_string())?; + let established = match established { + Ok(established) => established, + Err(refused) => { + // Only a failed pairing counts against the address: a wrong + // code and a probe of the pairing pattern look the same. A + // session handshake a revoked device keeps retrying must not + // lock its address out of pairing again. + if refused.mode == Some(HandshakeMode::Pair) { + stores.limiter.record_failure(peer.ip()); + } + return Err(refused.message); + } + }; + let device_key = encode_key(&established.remote_static); + if established.mode == HandshakeMode::Pair { + log::info!("paired device {device_key} from {peer}"); + } + let connection = shutdown.child_token(); + // Revocation is an edit to the device file; a revoked device's live + // connection ends at the next check. + let revocation_watch = { + let devices = Arc::clone(&stores.devices); + let key = device_key.clone(); + let connection = connection.clone(); + tokio::spawn(async move { + loop { + tokio::time::sleep(Duration::from_secs(10)).await; + if !devices.is_paired(&key) { + log::info!("device {key} was revoked; disconnecting"); + connection.cancel(); + return; + } + } + }) + }; + let result = server + .serve_with_peer(established.carrier, Some(device_key), connection) + .await; + revocation_watch.abort(); + result +} diff --git a/apps/maple-agent/crates/maple-remote/src/net.rs b/apps/maple-agent/crates/maple-remote/src/net.rs index f846b1006..87f488a61 100644 --- a/apps/maple-agent/crates/maple-remote/src/net.rs +++ b/apps/maple-agent/crates/maple-remote/src/net.rs @@ -1,31 +1,18 @@ -//! Listening and dialing over plain WebSocket with Noise inside. +//! What both network roles share, and one place to reach either. //! -//! [`serve_listener`] accepts TCP connections on behalf of one -//! [`HostServer`], runs the handshake, registers a newly paired device, and -//! hands each established carrier to the server. [`connect_direct`] dials -//! a host by address for a client. The relay is another connector later; -//! everything above the carrier is shared. +//! The host role lives in [`crate::listen`] and the client role in +//! [`crate::dial`]; both are re-exported here. This module holds the +//! WebSocket configuration and the handshake budget they have in common. -use std::net::SocketAddr; -use std::sync::Arc; use std::time::Duration; -use tokio::net::{TcpListener, TcpStream}; use tokio_tungstenite::tungstenite::protocol::WebSocketConfig; -use tokio_util::sync::CancellationToken; -use crate::carrier::Carrier; -use crate::devices::DeviceStore; -use crate::keys::{StaticKey, decode_key, encode_key}; -use crate::noise::{self, HandshakeMode, Initiate, Respond}; -use crate::pairing::{PairingCode, PairingLimiter, PendingPairingStore}; -use crate::server::HostServer; - -/// The name a device carries until its first hello names it. -const UNNAMED_DEVICE: &str = "new device"; +pub use crate::dial::{ConnectTarget, Dialed, connect_direct}; +pub use crate::listen::{HostStores, serve_listener}; /// Time a peer gets to finish the WebSocket and Noise handshakes. -const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(15); +pub(crate) const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(15); /// Largest WebSocket message either side reads. A Noise message is at /// most 65535 bytes, so anything larger is not this protocol and is @@ -39,198 +26,6 @@ pub fn websocket_config() -> WebSocketConfig { .max_frame_size(Some(MAX_WEBSOCKET_MESSAGE_BYTES)) } -/// What a listening host needs besides the server: its key and its -/// device and pairing records. -pub struct HostStores { - pub key: StaticKey, - pub devices: Arc, - pub pending_pairing: Arc, - pub limiter: PairingLimiter, -} - -/// Accept connections until `shutdown` fires. -pub async fn serve_listener( - listener: TcpListener, - server: Arc, - stores: Arc, - shutdown: CancellationToken, -) -> Result<(), String> { - log::info!( - "listening on {} as host {}", - listener - .local_addr() - .map(|addr| addr.to_string()) - .unwrap_or_default(), - stores.key.public_id() - ); - loop { - let (stream, peer) = tokio::select! { - accepted = listener.accept() => match accepted { - Ok(accepted) => accepted, - Err(error) => { - log::warn!("accept failed: {error}"); - tokio::time::sleep(Duration::from_millis(100)).await; - continue; - } - }, - _ = shutdown.cancelled() => return Ok(()), - }; - let server = Arc::clone(&server); - let stores = Arc::clone(&stores); - let shutdown = shutdown.clone(); - tokio::spawn(async move { - if let Err(error) = handle_connection(stream, peer, server, stores, shutdown).await { - log::info!("connection from {peer} ended: {error}"); - } - }); - } -} - -async fn handle_connection( - stream: TcpStream, - peer: SocketAddr, - server: Arc, - stores: Arc, - shutdown: CancellationToken, -) -> Result<(), String> { - let _ = stream.set_nodelay(true); - // One deadline covers both handshakes. - let deadline = tokio::time::Instant::now() + HANDSHAKE_TIMEOUT; - let socket = tokio::time::timeout_at( - deadline, - tokio_tungstenite::accept_async_with_config(stream, Some(websocket_config())), - ) - .await - .map_err(|_| "handshake timed out".to_string())? - .map_err(|error| format!("websocket accept: {error}"))?; - let pending_code = match stores.pending_pairing.current() { - Some(pending) if stores.limiter.allows(peer.ip()) => Some(pending.code()?), - Some(_) => { - log::warn!("pairing attempts from {peer} are rate limited"); - None - } - None => None, - }; - let devices = Arc::clone(&stores.devices); - let is_paired = move |key: &[u8; 32]| devices.is_paired(&encode_key(key)); - let confirm_pairing = |key: &[u8; 32]| -> Result<(), String> { - let code = pending_code - .as_ref() - .ok_or_else(|| "no pairing code is pending".to_string())?; - stores.pending_pairing.consume_if(code)?; - stores.devices.insert(&encode_key(key), UNNAMED_DEVICE)?; - Ok(()) - }; - let established = tokio::time::timeout_at( - deadline, - noise::respond( - socket, - stores.key.private(), - Respond { - pairing_psk: pending_code.as_ref().map(PairingCode::psk), - is_paired: &is_paired, - confirm_pairing: &confirm_pairing, - }, - ), - ) - .await - .map_err(|_| "handshake timed out".to_string())?; - let established = match established { - Ok(established) => established, - Err(refused) => { - // Only a failed pairing counts against the address: a wrong - // code and a probe of the pairing pattern look the same. A - // session handshake a revoked device keeps retrying must not - // lock its address out of pairing again. - if refused.mode == Some(HandshakeMode::Pair) { - stores.limiter.record_failure(peer.ip()); - } - return Err(refused.message); - } - }; - let device_key = encode_key(&established.remote_static); - if established.mode == HandshakeMode::Pair { - log::info!("paired device {device_key} from {peer}"); - } - let connection = shutdown.child_token(); - // Revocation is an edit to the device file; a revoked device's live - // connection ends at the next check. - let revocation_watch = { - let devices = Arc::clone(&stores.devices); - let key = device_key.clone(); - let connection = connection.clone(); - tokio::spawn(async move { - loop { - tokio::time::sleep(Duration::from_secs(10)).await; - if !devices.is_paired(&key) { - log::info!("device {key} was revoked; disconnecting"); - connection.cancel(); - return; - } - } - }) - }; - let result = server - .serve_with_peer(established.carrier, Some(device_key), connection) - .await; - revocation_watch.abort(); - result -} - -/// What to dial for. -pub enum ConnectTarget { - /// First contact: pair with the code the host published. - Pair(PairingCode), - /// A host already paired, whose static key is pinned. - Host { host_key: String }, -} - -/// A carrier to the host at `address` plus the host's static key, which -/// the client pins after pairing and verifies afterwards. -pub struct Dialed { - pub carrier: Carrier, - pub host_key: String, -} - -/// Dial `address` (`host:port`) over plain WebSocket and run the Noise -/// handshake as `device`. -pub async fn connect_direct( - address: &str, - device: &StaticKey, - target: ConnectTarget, -) -> Result { - let url = format!("ws://{address}/"); - let (socket, _) = tokio::time::timeout( - HANDSHAKE_TIMEOUT, - tokio_tungstenite::connect_async_with_config(&url, Some(websocket_config()), true), - ) - .await - .map_err(|_| format!("connecting to {address} timed out"))? - .map_err(|error| format!("cannot connect to {address}: {error}"))?; - let initiate = match &target { - ConnectTarget::Pair(code) => Initiate::Pair { psk: code.psk() }, - ConnectTarget::Host { host_key } => Initiate::Session { - host_static: decode_key(host_key)?, - }, - }; - let established = tokio::time::timeout( - HANDSHAKE_TIMEOUT, - noise::initiate(socket, device.private(), initiate), - ) - .await - .map_err(|_| "the host did not finish the handshake in time".to_string())??; - let host_key = encode_key(&established.remote_static); - if let ConnectTarget::Host { host_key: pinned } = &target - && pinned != &host_key - { - return Err("the host's key does not match the pinned key".to_string()); - } - Ok(Dialed { - carrier: established.carrier, - host_key, - }) -} - #[cfg(test)] mod tests { use super::*; From 1d324082c12b4767c66efe2f47a2f6a35416a1f2 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Sun, 20 Sep 2026 00:12:07 -0500 Subject: [PATCH 15/37] Resume Claude only once it confirmed the session Claude gets its session id up front through --session-id and writes the session itself. The driver treated the id as resumable as soon as the process was spawned, so a CLI that died before Claude persisted anything left the agent passing --resume forever, and every later send repeated the same failure. The Claude client now records when it saw a system/init message or a successful result for its session, and the agent passes --resume only after that. A resume that fails without seeing the session clears the flag, so the next turn starts the session again under the same id rather than failing the same way. An unknown control_request subtype used to end the read loop and kill the turn. It now gets an error control_response for its request id and the turn carries on, so hook callbacks or future subtypes cannot take a delegated turn down. The "process exited before the turn finished" notice named Codex for both providers; it now uses the provider's catalog name, and both the provider check and the display name come from the integration catalog instead of two hand-written lists. Persisted Claude permission rows were titled with the raw claude_tool key; the request now carries the Claude tool's name so the row reads "Claude Code: use Bash". Swallowed transport failures log at debug with metadata only. Co-Authored-By: Claude Fable 5.1 --- .../src/agent/external_agents/claude.rs | 153 ++++++++++++++++-- .../src/agent/external_agents/mod.rs | 140 +++++++++++++--- .../src/agent/external_agents/tests.rs | 121 ++++++++++++++ .../external_agents/tests/claude_fixture.rs | 20 +++ apps/maple-agent/docs/external-agents.md | 20 ++- 5 files changed, 413 insertions(+), 41 deletions(-) diff --git a/apps/maple-agent/crates/maple-agent/src/agent/external_agents/claude.rs b/apps/maple-agent/crates/maple-agent/src/agent/external_agents/claude.rs index cc7d7a1ef..7ed8292fe 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/external_agents/claude.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/external_agents/claude.rs @@ -244,6 +244,35 @@ impl ControlResponse { } } +/// The reply to a control request Maple does not serve. Claude treats it +/// like any other failed request and carries on with the turn. +#[derive(Serialize)] +struct ControlErrorResponse { + #[serde(rename = "type")] + msg_type: &'static str, + response: ControlErrorBody, +} + +#[derive(Serialize)] +struct ControlErrorBody { + subtype: &'static str, + request_id: String, + error: String, +} + +impl ControlErrorResponse { + fn new(request_id: String, error: impl Into) -> Self { + Self { + msg_type: "control_response", + response: ControlErrorBody { + subtype: "error", + request_id, + error: error.into(), + }, + } + } +} + type Pending = StdMutex>>>; pub(super) struct Client { @@ -252,6 +281,12 @@ pub(super) struct Client { permissions: StdMutex>, next_id: AtomicU64, session: String, + /// Whether this process was started with `--resume` for `session`. + resumed: bool, + /// Set once Claude has confirmed `session` exists on its side: a + /// `system`/`init` message or a successful `result` for it. Until then + /// nothing is known to be on disk and a resume would fail. + session_seen: AtomicBool, interrupted: AtomicBool, closed: CancellationToken, sender: mpsc::Sender, @@ -262,6 +297,7 @@ impl Client { stdin: ChildStdin, stdout: ChildStdout, session: String, + resumed: bool, ) -> ( Arc, mpsc::Receiver, @@ -274,6 +310,8 @@ impl Client { permissions: StdMutex::new(HashMap::new()), next_id: AtomicU64::new(1), session, + resumed, + session_seen: AtomicBool::new(false), interrupted: AtomicBool::new(false), closed: CancellationToken::new(), sender, @@ -289,16 +327,52 @@ impl Client { &self.closed } + /// Whether this process was started with `--resume`. + pub(super) fn resumed(&self) -> bool { + self.resumed + } + + /// Whether Claude confirmed the session exists on its side, so a later + /// process can `--resume` it. + pub(super) fn session_persisted(&self) -> bool { + self.session_seen.load(Ordering::Relaxed) + } + + /// Note a message that proves Claude holds `session`. A message that + /// names another session id (a fork) is not that proof. + fn note_session(&self, message: &Value) { + match message["session_id"].as_str() { + Some(id) if id != self.session => { + log::debug!( + "Claude session {}: message names another session id", + self.session + ); + } + _ => self.session_seen.store(true, Ordering::Relaxed), + } + } + async fn write(&self, message: impl Serialize) -> Result<(), String> { let mut bytes = serde_json::to_vec(&message).map_err(|_| FAILURE.to_string())?; bytes.push(b'\n'); let mut writer = self.writer.lock().await; - let writer = writer.as_mut().ok_or(FAILURE)?; - writer - .write_all(&bytes) - .await - .map_err(|_| FAILURE.to_string())?; - writer.flush().await.map_err(|_| FAILURE.to_string()) + let writer = writer.as_mut().ok_or_else(|| { + log::debug!("Claude session {}: stdin already closed", self.session); + FAILURE.to_string() + })?; + let written = async { + writer.write_all(&bytes).await?; + writer.flush().await + } + .await; + written.map_err(|error| { + log::debug!( + "Claude session {}: stdin write failed: {}", + self.session, + error.kind() + ); + FAILURE.to_string() + }) } async fn control(&self, request: ControlRequestBody) -> Result { @@ -485,14 +559,33 @@ impl Client { let mut activity = Activity::default(); let mut completed = false; while let Some(line) = lines.next().await { - let Ok(line) = line else { - break; + let line = match line { + Ok(line) => line, + Err(error) => { + // The codec reports length and I/O problems only; the + // line itself never reaches the log. + log::debug!( + "Claude session {}: unreadable output: {error}", + self.session + ); + break; + } }; if line.trim().is_empty() { continue; } - let Ok(message) = serde_json::from_str::(&line) else { - break; + let message = match serde_json::from_str::(&line) { + Ok(message) => message, + Err(error) => { + // serde reports a position and category, not the text. + log::debug!( + "Claude session {}: output is not JSON ({} bytes, {:?})", + self.session, + line.len(), + error.classify() + ); + break; + } }; match message["type"].as_str() { Some("control_response") => { @@ -503,17 +596,53 @@ impl Client { let result = if response["subtype"] == "success" { Ok(response["response"].clone()) } else { + log::debug!( + "Claude session {}: control request {id} answered with {}", + self.session, + response["subtype"].as_str().unwrap_or("no subtype") + ); Err(FAILURE.into()) }; let _ = tx.send(result); } } Some("control_request") => { - if self.permission(message).await.is_err() { - break; + // Only permission prompts are served here. Anything else + // (hooks, MCP status, future subtypes) gets an error reply + // so Claude carries on instead of the turn dying. + if message["request"]["subtype"] == "can_use_tool" { + if self.permission(message).await.is_err() { + log::debug!( + "Claude session {}: permission request not accepted", + self.session + ); + break; + } + } else if let Some(id) = message["request_id"].as_str() { + log::debug!( + "Claude session {}: declining control request subtype {}", + self.session, + message["request"]["subtype"].as_str().unwrap_or("none") + ); + let declined = ControlErrorResponse::new( + id.to_string(), + "Maple does not serve this control request", + ); + if self.write(declined).await.is_err() { + break; + } } } + Some("system") if message["subtype"] == "init" => { + self.note_session(&message); + } _ => { + if message["type"] == "result" + && message["subtype"] == "success" + && message["is_error"] != true + { + self.note_session(&message); + } for (method, mut params) in activity.events(&message) { if method == "turn/completed" { completed = true; diff --git a/apps/maple-agent/crates/maple-agent/src/agent/external_agents/mod.rs b/apps/maple-agent/crates/maple-agent/src/agent/external_agents/mod.rs index bd2a7aea7..cdc20a983 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/external_agents/mod.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/external_agents/mod.rs @@ -21,6 +21,7 @@ use super::developer_tools::{ ArmedShellChild, build_external_agent_command, spawn_contained, text_result, }; use super::image_mediation::error_result; +use super::integrations::external_agent_selection; use super::tool_context::AgentToolContextSnapshot; use super::*; use app_server::{AgentClient, AppServerClient, RequestMethod, ServerMessage}; @@ -309,6 +310,38 @@ fn decision_row_status(decision: AgentPermissionDecision) -> &'static str { /// Title of a row for a turn Maple started itself. const SYNTHETIC_TURN_TITLE: &str = "External agent: answer delivered"; +/// The `tool_name` of a permission request relayed from Claude Code. The +/// desktop keys its card heading on it. +const CLAUDE_TOOL_PERMISSION: &str = "claude_tool"; +/// The argument key that carries the Claude tool's own name, so the card +/// and the persisted row can say which tool asked. +const CLAUDE_TOOL_ARGUMENT: &str = "tool"; + +/// The name the integration catalog gives a provider, for notices; an id +/// the catalog does not know is shown as-is. +fn provider_display_name(provider: &str) -> &str { + external_agent_selection(provider) + .map(|entry| entry.name) + .unwrap_or(provider) +} + +/// Record what a Claude process that just ended proved about its session. +/// Seeing the session marks it resumable. A resume that failed without +/// ever seeing it means Claude has no such session: the next process +/// starts it again under the same id instead of failing the same way +/// forever. A Codex process changes nothing here. +fn note_claude_process_end(state: &mut AgentState, client: &AgentClient, failed: bool) { + let AgentClient::Claude(client) = client else { + return; + }; + if client.session_persisted() { + state.claude_session_persisted = true; + } else if failed && client.resumed() { + log::debug!("Claude did not find the session to resume; the next turn starts it again"); + state.claude_session_persisted = false; + } +} + fn external_run_id(agent_id: &str) -> String { format!("external-{agent_id}") } @@ -397,7 +430,7 @@ impl ExternalAgentRegistry { } fn require_provider(provider: &str) -> Result<(), String> { - if matches!(provider.trim(), codex::PROVIDER_ID | claude::PROVIDER_ID) { + if external_agent_selection(provider.trim()).is_some() { Ok(()) } else { Err(format!( @@ -776,6 +809,12 @@ struct AgentProcess { struct AgentState { process: Option, thread_id: Option, + /// Claude only: whether the CLI confirmed the session named by + /// `thread_id` exists on its side. Claude gets the id up front with + /// `--session-id` and writes the session itself; until it reports the + /// session, `--resume` would fail, so the next process starts it again + /// under the same id. Codex reports its thread id after creating it. + claude_session_persisted: bool, turn: Option, activity: ExternalAgentActivity, /// Streamed agent messages of the current turn, in order. @@ -809,12 +848,9 @@ struct ExternalAgent { impl ExternalAgent { fn provider_name(&self) -> &str { - if self.provider == claude::PROVIDER_ID { - claude::PROVIDER_NAME - } else { - codex::PROVIDER_NAME - } + provider_display_name(&self.provider) } + fn new( provider: String, agent_id: String, @@ -829,6 +865,7 @@ impl ExternalAgent { state: Mutex::new(AgentState { process: None, thread_id: None, + claude_session_persisted: false, turn: None, activity: ExternalAgentActivity { provider: provider.clone(), @@ -1036,7 +1073,15 @@ impl ExternalAgent { return Ok(()); } } - let existing_thread = self.state.lock().await.thread_id.clone(); + let (existing_thread, claude_resume) = { + let state = self.state.lock().await; + ( + state.thread_id.clone(), + // Resume only a session Claude is known to hold; otherwise + // start it under the same id so the agent keeps its identity. + state.thread_id.is_some() && state.claude_session_persisted, + ) + }; let claude_thread = existing_thread .clone() .unwrap_or_else(claude::new_session_id); @@ -1045,7 +1090,7 @@ impl ExternalAgent { .ok_or("Install Claude Code and make sure `claude` is on PATH.")?; ( executable, - claude::command_args(&claude_thread, existing_thread.is_some(), model, effort), + claude::command_args(&claude_thread, claude_resume, model, effort), ) } else { let executable = codex::find_executable(call.login_path.as_deref()).ok_or_else(|| { @@ -1091,17 +1136,18 @@ impl ExternalAgent { .take() .ok_or_else(|| "Failed to open the agent stdout".to_string())?; let (client, receiver, reader) = if self.provider == claude::PROVIDER_ID { - let (client, receiver, reader) = claude::Client::new(stdin, stdout, claude_thread); + let (client, receiver, reader) = + claude::Client::new(stdin, stdout, claude_thread, claude_resume); (Arc::new(AgentClient::Claude(client)), receiver, reader) } else { let (client, receiver, reader) = AppServerClient::new(stdin, stdout); (Arc::new(AgentClient::Codex(client)), receiver, reader) }; - client - .request(RequestMethod::Initialize, codex::initialize_params()) - .await?; - client.initialized().await?; - let thread_id = { + let handshake = async { + client + .request(RequestMethod::Initialize, codex::initialize_params()) + .await?; + client.initialized().await?; let existing = self.state.lock().await.thread_id.clone(); let response = match &existing { Some(thread_id) => { @@ -1122,9 +1168,18 @@ impl ExternalAgent { } }; match existing { - Some(thread_id) => thread_id, + Some(thread_id) => Ok(thread_id), None => codex::thread_id_from_response(&response) - .ok_or_else(|| "The agent did not report a thread ID".to_string())?, + .ok_or_else(|| "The agent did not report a thread ID".to_string()), + } + }; + let thread_id = match handshake.await { + Ok(thread_id) => thread_id, + Err(error) => { + // A process that died in the handshake leaves the child to + // kill_on_drop; what it proved about the session still counts. + note_claude_process_end(&mut *self.state.lock().await, &client, true); + return Err(error); } }; let events = tokio::spawn(Arc::clone(self).consume_server_messages(receiver)); @@ -1170,6 +1225,11 @@ impl ExternalAgent { process.child.kill_and_wait().await; process.reader.abort(); // Do not abort process.events: it is this task. + note_claude_process_end( + &mut state, + &process.client, + status == "failed", + ); } drop(state); self.handle_event(event).await; @@ -1188,11 +1248,24 @@ impl ExternalAgent { } } // The process ended. A turn it owed an answer to is over. - let had_turn = self.state.lock().await.turn.is_some(); + let had_turn = { + let mut state = self.state.lock().await; + if let Some(client) = state + .process + .as_ref() + .map(|process| Arc::clone(&process.client)) + { + note_claude_process_end(&mut state, &client, true); + } + state.turn.is_some() + }; if had_turn { self.finish_turn( TurnOutcome::Failed, - Some("The Codex process exited before the turn finished.".to_string()), + Some(format!( + "The {} process exited before the turn finished.", + self.provider_name() + )), ) .await; } @@ -1355,10 +1428,16 @@ impl ExternalAgent { }; if self.provider == claude::PROVIDER_ID && method == "claude/tool/requestApproval" { let tool = params["tool"].as_str().unwrap_or("tool"); - let arguments = params["input"].as_object().cloned().unwrap_or_default(); + let mut arguments = params["input"].as_object().cloned().unwrap_or_default(); + // The card and the persisted row name the tool through this + // key; the answer Claude receives comes from its own copy of + // the input, so the display copy may carry it. + arguments + .entry(CLAUDE_TOOL_ARGUMENT) + .or_insert_with(|| json!(tool)); let request = AgentPermissionRequest { request_id: format!("{}-{}", self.agent_id, id.as_str().unwrap_or("request")), - tool_name: "claude_tool".into(), + tool_name: CLAUDE_TOOL_PERMISSION.into(), arguments, prompt: Some(format!("Claude Code wants to use {tool}")), }; @@ -1705,7 +1784,7 @@ impl ExternalAgent { }) .await .is_ok(); - let process = self.state.lock().await.process.take(); + let process = self.take_process().await; if let Some(mut process) = process { process.child.kill_and_wait().await; process.reader.abort(); @@ -1716,11 +1795,20 @@ impl ExternalAgent { } } + /// Detach the process for reclaiming, keeping what it proved about the + /// saved session. A Claude process cut short did not fail a resume. + async fn take_process(&self) -> Option { + let mut state = self.state.lock().await; + let process = state.process.take()?; + note_claude_process_end(&mut state, &process.client, false); + Some(process) + } + /// End the agent: interrupt, kill its process group, and close its turn. async fn shutdown(&self) { self.cancel.cancel(); self.interrupt().await; - let process = self.state.lock().await.process.take(); + let process = self.take_process().await; if let Some(mut process) = process { process.child.kill_and_wait().await; process.reader.abort(); @@ -2119,6 +2207,14 @@ pub(super) fn external_permission_item( title: Some(match request.tool_name.as_str() { "codex_command" => "Codex: run command".to_string(), "codex_file_change" => "Codex: change files".to_string(), + CLAUDE_TOOL_PERMISSION => format!( + "Claude Code: use {}", + request + .arguments + .get(CLAUDE_TOOL_ARGUMENT) + .and_then(Value::as_str) + .unwrap_or("tool") + ), other => other.to_string(), }), text: request.prompt.clone(), diff --git a/apps/maple-agent/crates/maple-agent/src/agent/external_agents/tests.rs b/apps/maple-agent/crates/maple-agent/src/agent/external_agents/tests.rs index 6ffb53385..11f4511ae 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/external_agents/tests.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/external_agents/tests.rs @@ -1197,6 +1197,11 @@ async fn claude_native_permission_denial_and_questions_use_maple_brokers() { let (key, entry) = pending; assert_eq!(entry.request.tool_name, "claude_tool"); assert_eq!(entry.request.arguments["command"], "cargo test"); + assert_eq!(entry.request.arguments["tool"], "Bash"); + assert_eq!( + external_permission_item(&entry.request, 1).title.as_deref(), + Some("Claude Code: use Bash") + ); harness .service .pending_permissions @@ -1306,3 +1311,119 @@ async fn claude_native_stop_withdraws_pending_permission() { assert!(!harness.log().contains("\"behavior\":\"allow\"")); harness.registry.shutdown_all(Duration::from_secs(5)).await; } + +/// The session flag each Claude launch in the fixture log was given: +/// `--session-id` or `--resume`, in launch order. +fn claude_session_flags(log: &str) -> Vec { + log.lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .filter_map(|entry| { + let args = entry.get("args")?.as_array()?; + args.iter() + .filter_map(Value::as_str) + .find(|arg| matches!(*arg, "--session-id" | "--resume")) + .map(str::to_string) + }) + .collect() +} + +fn claude_send(prompt: &str) -> AgentSendParams { + AgentSendParams { + provider: "claude".into(), + agent_id: "claude-1".into(), + prompt: prompt.into(), + background: false, + model: None, + effort: None, + } +} + +/// A Claude CLI that dies before it wrote the session leaves nothing to +/// resume. The next turn must start the session again under the same id, +/// not pass `--resume` and fail the same way on every send. A resume that +/// finds no session falls back the same way, once. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn claude_resumes_only_a_session_the_cli_confirmed() { + let harness = Harness::new("crash"); + fs::create_dir(harness.project.join("sub")).unwrap(); + harness.set_mode("claude-lost", GooseMode::Auto).await; + let result = harness + .registry + .start(harness.call("claude-lost", "r1"), claude_start(false)) + .await; + let text = result_text(&result); + assert!(text.contains("Status: failed"), "{text}"); + assert_eq!(claude_session_flags(&harness.log()), ["--session-id"]); + + // Nothing was persisted: the retry starts the session, and succeeds. + harness.install_fixture("claude", "approve"); + let result = harness + .registry + .send(harness.call("claude-lost", "r2"), claude_send("Again")) + .await; + let text = result_text(&result); + assert!(text.contains("Status: completed"), "{text}"); + assert_eq!( + claude_session_flags(&harness.log()), + ["--session-id", "--session-id"] + ); + + // Now the CLI has confirmed the session, so the next turn resumes it. + // That resume finds nothing (the session was lost on disk) and fails. + harness.install_fixture("claude", "resume-lost"); + let result = harness + .registry + .send(harness.call("claude-lost", "r3"), claude_send("Resume")) + .await; + let text = result_text(&result); + assert!(text.contains("Status: failed"), "{text}"); + assert_eq!( + claude_session_flags(&harness.log()), + ["--session-id", "--session-id", "--resume"] + ); + + // One failed resume is enough: the agent starts the session again + // under its id rather than repeating the failure. + harness.install_fixture("claude", "approve"); + let result = harness + .registry + .send(harness.call("claude-lost", "r4"), claude_send("Once more")) + .await; + let text = result_text(&result); + assert!(text.contains("Status: completed"), "{text}"); + assert_eq!( + claude_session_flags(&harness.log()), + ["--session-id", "--session-id", "--resume", "--session-id"] + ); + harness.registry.shutdown_all(Duration::from_secs(5)).await; +} + +/// A control request Maple does not serve gets an error reply for its id; +/// the turn goes on and the permission prompt behind it is still served. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn claude_declines_unknown_control_requests_and_keeps_the_turn() { + let harness = Harness::new("unknown-control"); + fs::create_dir(harness.project.join("sub")).unwrap(); + harness.set_mode("claude-hook", GooseMode::Auto).await; + let result = harness + .registry + .start(harness.call("claude-hook", "r1"), claude_start(false)) + .await; + let activity = result + .structured_content + .as_ref() + .unwrap_or_else(|| panic!("{}", result_text(&result)))[ACTIVITY_KEY] + .clone(); + assert_eq!(activity["status"], "completed", "{activity}"); + assert_eq!(activity["text"], "Allowed"); + let reply = harness + .log() + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .find(|entry| { + entry["type"] == "control_response" && entry["response"]["request_id"] == "hook-1" + }) + .expect("a reply to the unknown control request"); + assert_eq!(reply["response"]["subtype"], "error"); + harness.registry.shutdown_all(Duration::from_secs(5)).await; +} diff --git a/apps/maple-agent/crates/maple-agent/src/agent/external_agents/tests/claude_fixture.rs b/apps/maple-agent/crates/maple-agent/src/agent/external_agents/tests/claude_fixture.rs index f8397245f..8c71a7979 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/external_agents/tests/claude_fixture.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/external_agents/tests/claude_fixture.rs @@ -130,6 +130,12 @@ fn run() { } } Some("user") => { + // A CLI that dies before Claude wrote the session: nothing + // is on disk to resume. `resume-lost` does so only when asked + // to resume, like a session that was never persisted. + if mode == "crash" || (mode == "resume-lost" && args.contains(&"--resume")) { + return; + } send( &mut out, json!({"type": "system", "subtype": "init", "session_id": session}), @@ -187,6 +193,16 @@ fn run() { } else { ("Bash", json!({"command": "cargo test"})) }; + if mode == "unknown-control" { + // A control request Maple does not serve, ahead of the + // permission prompt. The turn must survive it. + send( + &mut out, + json!({"type": "control_request", "request_id": "hook-1", + "request": {"subtype": "hook_callback", "callback_id": "cb-1", "input": {}}, + }), + ); + } send( &mut out, json!({"type": "control_request", "request_id": "permission-1", @@ -195,6 +211,10 @@ fn run() { ); } Some("control_response") => { + if message["response"]["request_id"] == "hook-1" { + // Logged above; the test reads the reply from the log. + continue; + } let answer = &message["response"]["response"]; if mode == "question" { finish( diff --git a/apps/maple-agent/docs/external-agents.md b/apps/maple-agent/docs/external-agents.md index 31a4e7cec..e89addc42 100644 --- a/apps/maple-agent/docs/external-agents.md +++ b/apps/maple-agent/docs/external-agents.md @@ -124,15 +124,21 @@ bypass-permissions flag. Claude's rules decide which actions need approval; `can_use_tool` requests go to Maple's current permission mode. Allow all answers those requests automatically, and Read only shows a one-shot permission card. `AskUserQuestion` uses Maple's question card. Answers change only that call's -input; Maple never persists an allow rule in Claude's settings. +input; Maple never persists an allow rule in Claude's settings. Any other +`control_request` subtype (hooks, MCP status, future additions) gets an error +`control_response` for its request id, and the turn goes on. Each turn starts a contained Claude CLI process using `--session-id` initially -and `--resume` thereafter. This allows optional `model` and `effort` launch -arguments to change on each turn. The process runs in the requested project -directory with the shell tool's scoped environment and process containment. -Stop, task deletion, logout, and runtime shutdown reclaim Claude and its -descendants. A subsequent `agent_send` resumes the saved session. You can also -use `claude --resume` from a terminal. +and `--resume` once Claude has confirmed the session: a `system`/`init` +message or a successful `result` for that id. A CLI that dies before then +left nothing on disk, so the next turn starts the session again under the +same id instead of failing every resume. A resume that fails without seeing +the session falls back the same way, once. This allows optional `model` and +`effort` launch arguments to change on each turn. The process runs in the +requested project directory with the shell tool's scoped environment and +process containment. Stop, task deletion, logout, and runtime shutdown +reclaim Claude and its descendants. A subsequent `agent_send` resumes the +saved session. You can also use `claude --resume` from a terminal. Claude's text, Bash calls, successful Edit/Write/NotebookEdit calls, and TodoWrite items feed the existing activity row. Other tools continue to run From b4172d882835431823088d70cb740610cb7083ba Mon Sep 17 00:00:00 2001 From: benthecarman Date: Sun, 20 Sep 2026 00:05:49 -0500 Subject: [PATCH 16/37] Tidy the host seam BranchWatchers::watch released the map lock between looking a root up and inserting its watcher, so two clients watching one root at once left a watcher that the first unwatch dropped from under the other; the lookup and insert now happen under one guard, and a root that became a checkout while watched attaches its watcher on the next watch. set_session_defaults wrote the default model from the Settings snapshot, which could revert a model the chat screen had just saved; the model now has one writer. The runtime start loads the config once, permission decisions and modes use one set of constants, store errors name their operation and database, and swallowed failures log their metadata. The design doc's reach-around table notes what was built. Co-Authored-By: Claude Fable 5.1 --- .../crates/maple-agent/src/agent.rs | 14 ++- .../crates/maple-agent/src/agent/types.rs | 8 +- .../crates/maple-agent/src/host/git.rs | 117 +++++++++++++++--- .../crates/maple-agent/src/host/local.rs | 49 ++++---- .../crates/maple-agent/src/host/mod.rs | 12 +- .../crates/maple-agent/src/host/store.rs | 9 +- apps/maple-agent/docs/remote-development.md | 9 ++ 7 files changed, 162 insertions(+), 56 deletions(-) diff --git a/apps/maple-agent/crates/maple-agent/src/agent.rs b/apps/maple-agent/crates/maple-agent/src/agent.rs index e3a4e76b1..13a06fb5e 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent.rs @@ -103,7 +103,13 @@ use web_tools::WebToolState; const DEFAULT_AGENT_MODEL: &str = "glm-5-3"; const LEGACY_AGENT_DEFAULT_MODEL: &str = "auto:powerful"; const PREVIOUS_RECOMMENDED_AGENT_MODEL: &str = "glm-5-2"; -const DEFAULT_GOOSE_MODE: &str = "smart_approve"; +/// Permission policy name for "confirm each gated tool call". +pub const PERMISSION_MODE_SMART_APPROVE: &str = "smart_approve"; +/// Permission policy name for "ask before every tool call". +pub const PERMISSION_MODE_APPROVE: &str = "approve"; +/// Permission policy name for "approve every tool call". +pub const PERMISSION_MODE_AUTO: &str = "auto"; +const DEFAULT_GOOSE_MODE: &str = PERMISSION_MODE_SMART_APPROVE; // Keep Goose on its ActionRequired path so Maple can apply the currently selected // policy at every tool boundary, including when the user changes it mid-run. const GOOSE_PERMISSION_ROUTING_MODE: GooseMode = GooseMode::SmartApprove; @@ -9138,9 +9144,9 @@ fn is_caller_mediated_mode(mode: GooseMode) -> bool { fn parse_user_permission_mode(mode: &str) -> Result { match mode { - "auto" => Ok(GooseMode::Auto), - "approve" => Ok(GooseMode::Approve), - "smart_approve" => Ok(GooseMode::SmartApprove), + PERMISSION_MODE_AUTO => Ok(GooseMode::Auto), + PERMISSION_MODE_APPROVE => Ok(GooseMode::Approve), + PERMISSION_MODE_SMART_APPROVE => Ok(GooseMode::SmartApprove), _ => Err(format!("Unsupported Agent permission mode: {mode}")), } } diff --git a/apps/maple-agent/crates/maple-agent/src/agent/types.rs b/apps/maple-agent/crates/maple-agent/src/agent/types.rs index 8bee1a562..2ac00add4 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/types.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/types.rs @@ -595,7 +595,9 @@ pub enum AgentPermissionDecision { } impl AgentPermissionDecision { - pub(super) fn status(self) -> &'static str { + /// The wire spelling of the decision, as `AgentPermissionResponse` + /// carries it. + pub fn as_str(self) -> &'static str { match self { Self::AllowOnce => "allow_once", Self::DenyOnce => "deny_once", @@ -603,6 +605,10 @@ impl AgentPermissionDecision { } } + pub(super) fn status(self) -> &'static str { + self.as_str() + } + pub(super) fn goose_permission(self) -> Permission { match self { Self::AllowOnce => Permission::AllowOnce, diff --git a/apps/maple-agent/crates/maple-agent/src/host/git.rs b/apps/maple-agent/crates/maple-agent/src/host/git.rs index 25994aa89..e801b513f 100644 --- a/apps/maple-agent/crates/maple-agent/src/host/git.rs +++ b/apps/maple-agent/crates/maple-agent/src/host/git.rs @@ -80,7 +80,10 @@ struct BranchWatch { /// Clients watching this root. The watcher lives while any remain. watchers: usize, /// `None` when the root is not a checkout or the watch could not start. - _watcher: Option, + /// A later `watch` of the same root tries again, so a folder that is + /// initialised as a checkout while watched gets its watcher the next + /// time a client asks for it; nothing polls for `.git` in between. + watcher: Option, } /// One watcher per root, shared by every client that asked for it. @@ -94,29 +97,25 @@ impl BranchWatchers { /// Must run inside a Tokio runtime: the change reader is a task. pub fn watch(&self, root: String, events: Arc) { { + // The lookup and the insert happen under one guard: two clients + // watching the same root at once must share one watcher, not + // have the second overwrite the first with a count of one. + // `start_watcher` never awaits, so holding the lock is cheap. let mut roots = self .roots .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - if let Some(watch) = roots.get_mut(&root) { - watch.watchers += 1; - // A new client wants the current branch even though the - // watch is already running. - publish_branch(&events, root.clone()); - return; + let watch = roots.entry(root.clone()).or_insert_with(|| BranchWatch { + watchers: 0, + watcher: None, + }); + watch.watchers += 1; + if watch.watcher.is_none() { + watch.watcher = start_watcher(&root, Arc::clone(&events)); } } - let watcher = start_watcher(&root, Arc::clone(&events)); - self.roots - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .insert( - root.clone(), - BranchWatch { - watchers: 1, - _watcher: watcher, - }, - ); + // Every client wants the current branch, whether or not the watch + // was already running. publish_branch(&events, root); } @@ -144,6 +143,16 @@ impl BranchWatchers { .cloned() .collect() } + + /// `(clients, has a live watcher)` for `root`, when it is watched. + #[cfg(test)] + pub(crate) fn watch_state(&self, root: &str) -> Option<(usize, bool)> { + self.roots + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(root) + .map(|watch| (watch.watchers, watch.watcher.is_some())) + } } /// Read the branch off the async workers and publish it. @@ -323,6 +332,7 @@ mod tests { watchers.watch(path.clone(), Arc::clone(&hub)); watchers.watch(path.clone(), Arc::clone(&hub)); assert_eq!(watchers.watched_roots(), vec![path.clone()]); + assert_eq!(watchers.watch_state(&path), Some((2, true))); // Both watch calls report the branch. for _ in 0..2 { let event = tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv()) @@ -335,9 +345,78 @@ mod tests { )); } watchers.unwatch(&path); - assert_eq!(watchers.watched_roots(), vec![path.clone()]); + assert_eq!(watchers.watch_state(&path), Some((1, true))); watchers.unwatch(&path); assert!(watchers.watched_roots().is_empty()); let _ = std::fs::remove_dir_all(root); } + + /// Two clients that start watching one root at the same moment share + /// one watcher, and the first to leave does not take it with them. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn concurrent_watches_of_one_root_share_the_watcher() { + let root = temp_root("concurrent"); + let git = root.join(".git"); + std::fs::create_dir_all(&git).unwrap(); + std::fs::write(git.join("HEAD"), "ref: refs/heads/main\n").unwrap(); + let hub = Arc::new(HostEventHub::default()); + let mut rx = hub.subscribe(); + let watchers = Arc::new(BranchWatchers::default()); + let path = root.to_string_lossy().to_string(); + let tasks: Vec<_> = (0..2) + .map(|_| { + let watchers = Arc::clone(&watchers); + let hub = Arc::clone(&hub); + let path = path.clone(); + tokio::spawn(async move { watchers.watch(path, hub) }) + }) + .collect(); + for task in tasks { + task.await.unwrap(); + } + assert_eq!(watchers.watch_state(&path), Some((2, true))); + watchers.unwatch(&path); + assert_eq!(watchers.watch_state(&path), Some((1, true))); + + // The surviving watcher still reports a checkout. + std::fs::write(git.join("HEAD"), "ref: refs/heads/feature\n").unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + loop { + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + let event = tokio::time::timeout(remaining, rx.recv()) + .await + .expect("branch change reported") + .unwrap(); + if matches!( + event, + HostEvent::ProjectBranch { branch: Some(ref branch), .. } if branch == "feature" + ) { + break; + } + } + watchers.unwatch(&path); + assert!(watchers.watched_roots().is_empty()); + let _ = std::fs::remove_dir_all(root); + } + + /// A root that becomes a checkout after its first watch gets a watcher + /// on the next watch instead of staying blind forever. + #[tokio::test] + async fn a_later_watch_attaches_a_watcher_once_the_root_is_a_checkout() { + let root = temp_root("late"); + let hub = Arc::new(HostEventHub::default()); + let watchers = BranchWatchers::default(); + let path = root.to_string_lossy().to_string(); + watchers.watch(path.clone(), Arc::clone(&hub)); + assert_eq!(watchers.watch_state(&path), Some((1, false))); + + let git = root.join(".git"); + std::fs::create_dir_all(&git).unwrap(); + std::fs::write(git.join("HEAD"), "ref: refs/heads/main\n").unwrap(); + watchers.watch(path.clone(), Arc::clone(&hub)); + assert_eq!(watchers.watch_state(&path), Some((2, true))); + watchers.unwatch(&path); + watchers.unwatch(&path); + let _ = std::fs::remove_dir_all(root); + } } diff --git a/apps/maple-agent/crates/maple-agent/src/host/local.rs b/apps/maple-agent/crates/maple-agent/src/host/local.rs index f2bab1a57..e871ecb83 100644 --- a/apps/maple-agent/crates/maple-agent/src/host/local.rs +++ b/apps/maple-agent/crates/maple-agent/src/host/local.rs @@ -20,7 +20,7 @@ use super::{ }; use crate::agent::{ AgentConfig, AgentCreateSessionRequest, AgentDesktopQueueSnapshot, AgentIntegration, - AgentMcpServer, AgentPermissionModeRequest, AgentPermissionResponse, + AgentMcpServer, AgentPermissionDecision, AgentPermissionModeRequest, AgentPermissionResponse, AgentProjectRootRegistration, AgentProjectTrustStatus, AgentQueueControlRequest, AgentRenameSessionRequest, AgentRuntimeHandle, AgentRuntimeStatus, AgentSendMessageRequest, AgentSessionDetail, AgentSessionIntegrationKind, AgentSessionMcpServer, AgentSessionSummary, @@ -109,12 +109,6 @@ impl LocalHostBackend { &self.user_id } - /// The events hub, for callers that need to publish alongside the - /// runtime (tests, and the server later). - pub fn events(&self) -> &Arc { - &self.events - } - async fn handle(&self) -> Result { self.service.handle_for_user(&self.user_id).await } @@ -250,7 +244,15 @@ impl HostBackend for LocalHostBackend { }) .map(|session| session.id.clone()); let latest = match latest_id { - Some(id) => handle.load_session(id).await.ok(), + Some(id) => match handle.load_session(id.clone()).await { + Ok(detail) => Some(detail), + Err(error) => { + // Bootstrap still succeeds without the transcript; the + // client loads it on demand and sees the error then. + log::debug!("bootstrap could not load the latest task {id}: {error}"); + None + } + }, None => None, }; Ok(HostBootstrap { @@ -267,7 +269,8 @@ impl HostBackend for LocalHostBackend { request: Option, ) -> Result { let handle = self.handle().await?; - self.apply_harness(&handle.load_config().await?); + let config = handle.load_config().await?; + self.apply_harness(&config); let session = self.api_session().await?; // The agent falls back to the process working directory when no // root is given. That is right for `acp`, not for a client. @@ -276,14 +279,11 @@ impl HostBackend for LocalHostBackend { project_root: None, model, mode, - }) => { - let config = handle.load_config().await?; - Some(AgentStartRequest { - project_root: gui_project_root(&config), - model, - mode, - }) - } + }) => Some(AgentStartRequest { + project_root: gui_project_root(&config), + model, + mode, + }), other => other, }; tokio::time::timeout(RUNTIME_START_TIMEOUT, handle.start(session, request)) @@ -548,10 +548,12 @@ impl HostBackend for LocalHostBackend { session_id, request_id, decision: if allow { - "allow_once".to_string() + AgentPermissionDecision::AllowOnce } else { - "deny_once".to_string() - }, + AgentPermissionDecision::DenyOnce + } + .as_str() + .to_string(), }) .await } @@ -722,15 +724,16 @@ impl HostBackend for LocalHostBackend { Ok(session_defaults_from(&config)) } + /// Save the settings-screen defaults. `default_model` is not among + /// them: the chat screen owns it through [`Self::save_default_model`], + /// and a settings snapshot taken before a model change would put the + /// old model back if it were written here. async fn set_session_defaults(&self, defaults: HostSessionDefaults) -> Result<(), String> { let handle = self.handle().await?; let mut config = handle.load_config().await?; config.default_permission_mode = Some(normalize_permission_mode(&defaults.permission_mode)); config.default_web_enabled = Some(defaults.web_enabled); config.harness_instructions = Some(defaults.harness_instructions); - if let Some(model) = defaults.default_model.filter(|model| !model.is_empty()) { - config.default_model = model; - } handle.save_config(config.clone()).await?; self.apply_harness(&config); Ok(()) diff --git a/apps/maple-agent/crates/maple-agent/src/host/mod.rs b/apps/maple-agent/crates/maple-agent/src/host/mod.rs index b64525e38..e8610ac45 100644 --- a/apps/maple-agent/crates/maple-agent/src/host/mod.rs +++ b/apps/maple-agent/crates/maple-agent/src/host/mod.rs @@ -92,10 +92,9 @@ pub const DEFAULT_HARNESS_INSTRUCTIONS: &str = "You are a general-purpose AI agent called Maple, created by Maple AI. You run in the Maple app's Agent Mode; users know you simply as Maple."; -/// Permission policy name for "confirm each gated tool call". -pub const PERMISSION_MODE_SMART_APPROVE: &str = "smart_approve"; -/// Permission policy name for "approve every tool call". -pub const PERMISSION_MODE_AUTO: &str = "auto"; +// The permission policy names are the runtime's; the host speaks them on +// the wire unchanged. +pub use crate::agent::{PERMISSION_MODE_AUTO, PERMISSION_MODE_SMART_APPROVE}; /// Defaults a host applies to the tasks its clients create. Stored in the /// host's per-account config, so two hosts can differ. @@ -109,7 +108,10 @@ pub struct HostSessionDefaults { /// Opening system prompt text. Empty means /// [`DEFAULT_HARNESS_INSTRUCTIONS`]. pub harness_instructions: String, - /// The account's saved default model, if any. + /// The account's saved default model, if any. Read-only here: the + /// chat screen saves it through [`HostBackend::save_default_model`], + /// and [`HostBackend::set_session_defaults`] leaves it alone so a + /// stale settings snapshot cannot put an old model back. #[serde(default, skip_serializing_if = "Option::is_none")] pub default_model: Option, } diff --git a/apps/maple-agent/crates/maple-agent/src/host/store.rs b/apps/maple-agent/crates/maple-agent/src/host/store.rs index fdb0221e2..5e863ddd6 100644 --- a/apps/maple-agent/crates/maple-agent/src/host/store.rs +++ b/apps/maple-agent/crates/maple-agent/src/host/store.rs @@ -115,6 +115,7 @@ impl AccountStores { *guard = Some((path.to_path_buf(), open_summary_db(path)?)); } f(&guard.as_ref().expect("summary db opened above").1) + .map_err(|error| format!("{error} ({})", path.display())) } } @@ -138,12 +139,12 @@ pub(super) fn load_tool_summaries( ) -> Result, String> { let mut stmt = conn .prepare("SELECT item_id, summary FROM tool_summaries WHERE session_id = ?1") - .map_err(|error| error.to_string())?; + .map_err(|error| format!("Cannot prepare the tool summary query: {error}"))?; let rows = stmt .query_map([session_id], |row| Ok((row.get(0)?, row.get(1)?))) - .map_err(|error| error.to_string())?; + .map_err(|error| format!("Cannot read tool summaries for task {session_id}: {error}"))?; rows.collect::, _>>() - .map_err(|error| error.to_string()) + .map_err(|error| format!("Cannot read tool summaries for task {session_id}: {error}")) } pub(super) fn store_tool_summary( @@ -158,7 +159,7 @@ pub(super) fn store_tool_summary( [session_id, item_id, summary], ) .map(|_| ()) - .map_err(|error| error.to_string()) + .map_err(|error| format!("Cannot store the tool summary for task {session_id}: {error}")) } /// Aggregate one account's ledger. diff --git a/apps/maple-agent/docs/remote-development.md b/apps/maple-agent/docs/remote-development.md index b7e1c13e6..df99a355d 100644 --- a/apps/maple-agent/docs/remote-development.md +++ b/apps/maple-agent/docs/remote-development.md @@ -119,6 +119,15 @@ a host method or a pushed event. | UI opens `sessions.db` read-only every second for the context ring. | `HostBackend::context_usage(session)` and a pushed `ContextUsage` event during runs. | | UI opens `tool_summaries.db` read-write. | `HostBackend::tool_summaries(session)` and `set_tool_summary`. | +As built: the root check is `HostBackend::select_project_root(path)`, not a +separate `validate_project_root`; it registers the root and expands a leading +`~` against the host's home directory, since a typed path arrives as written +on the client. Context usage is not pushed: while a run is active the client +polls `HostBackend::context_usage(session, model)` every 5 seconds, and +re-reads only when the transcript changed since the last poll. The branch +event is `HostEvent::ProjectBranch`, and the summary writer is +`store_tool_summary`. + Image attachments already travel as bytes in `AgentImageUpload`. They move to a binary stream channel, chunked, so the control channel frame limit does not cap them. From 52cbff188667f3be2132bd129fab71975449e8a7 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Sun, 20 Sep 2026 00:18:21 -0500 Subject: [PATCH 17/37] Move the local host's helpers under host/local git.rs, directories.rs, and store.rs serve only the in-process host: the filesystem, the git dir, and the account's SQLite stores. They now live as private modules under host/local, so host/mod.rs holds the trait, the events, and every type that crosses the wire, and nothing else. DirectorySuggestion, UsageRow, and UsageSummary move into host/mod.rs for the same reason; they were defined next to the readers that fill them but are part of the client-facing surface. Every path other crates import from maple_agent::host is unchanged; the re-exports stay where they were. Co-Authored-By: Claude Fable 5.1 --- .../src/host/{ => local}/directories.rs | 12 +----- .../maple-agent/src/host/{ => local}/git.rs | 2 +- .../src/host/{local.rs => local/mod.rs} | 17 ++++++--- .../maple-agent/src/host/{ => local}/store.rs | 21 +---------- .../crates/maple-agent/src/host/mod.rs | 37 ++++++++++++++++--- 5 files changed, 46 insertions(+), 43 deletions(-) rename apps/maple-agent/crates/maple-agent/src/host/{ => local}/directories.rs (93%) rename apps/maple-agent/crates/maple-agent/src/host/{ => local}/git.rs (99%) rename apps/maple-agent/crates/maple-agent/src/host/{local.rs => local/mod.rs} (98%) rename apps/maple-agent/crates/maple-agent/src/host/{ => local}/store.rs (95%) diff --git a/apps/maple-agent/crates/maple-agent/src/host/directories.rs b/apps/maple-agent/crates/maple-agent/src/host/local/directories.rs similarity index 93% rename from apps/maple-agent/crates/maple-agent/src/host/directories.rs rename to apps/maple-agent/crates/maple-agent/src/host/local/directories.rs index 6a4cf30ab..3b31a8f92 100644 --- a/apps/maple-agent/crates/maple-agent/src/host/directories.rs +++ b/apps/maple-agent/crates/maple-agent/src/host/local/directories.rs @@ -6,17 +6,7 @@ use std::path::{Path, PathBuf}; -use serde::{Deserialize, Serialize}; - -/// One directory a typed root could mean. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DirectorySuggestion { - /// Absolute path. - pub path: String, - /// Last path component, for display. - pub name: String, -} +use crate::host::DirectorySuggestion; /// Most suggestions one answer carries. pub const SUGGESTION_LIMIT: usize = 50; diff --git a/apps/maple-agent/crates/maple-agent/src/host/git.rs b/apps/maple-agent/crates/maple-agent/src/host/local/git.rs similarity index 99% rename from apps/maple-agent/crates/maple-agent/src/host/git.rs rename to apps/maple-agent/crates/maple-agent/src/host/local/git.rs index e801b513f..c6fb6bf4f 100644 --- a/apps/maple-agent/crates/maple-agent/src/host/git.rs +++ b/apps/maple-agent/crates/maple-agent/src/host/local/git.rs @@ -10,7 +10,7 @@ use std::sync::{Arc, Mutex}; use notify::Watcher as _; -use super::{HostEvent, HostEventHub}; +use crate::host::{HostEvent, HostEventHub}; /// The directory that holds `HEAD` for a checkout, or `None` when `root` /// is not one. Supports worktrees, whose `.git` is a file that points at diff --git a/apps/maple-agent/crates/maple-agent/src/host/local.rs b/apps/maple-agent/crates/maple-agent/src/host/local/mod.rs similarity index 98% rename from apps/maple-agent/crates/maple-agent/src/host/local.rs rename to apps/maple-agent/crates/maple-agent/src/host/local/mod.rs index e871ecb83..d16554135 100644 --- a/apps/maple-agent/crates/maple-agent/src/host/local.rs +++ b/apps/maple-agent/crates/maple-agent/src/host/local/mod.rs @@ -2,7 +2,12 @@ //! //! The local window uses this directly. A server that publishes the same //! runtime to remote clients is a sibling consumer of the runtime handle, -//! not a layer over this type. +//! not a layer over this type. The submodules are what only a local host +//! needs: the filesystem, the git dir, and the account's SQLite stores. + +mod directories; +mod git; +mod store; use std::collections::HashMap; use std::path::PathBuf; @@ -11,12 +16,10 @@ use std::sync::Arc; use async_trait::async_trait; use tokio::sync::mpsc; -use super::directories::{self, DirectorySuggestion}; -use super::git::BranchWatchers; -use super::store::{self, AccountStores, UsageSummary}; use super::{ - ContextUsage, HostBackend, HostBootstrap, HostEvent, HostEventHub, HostId, HostSessionDefaults, - PERMISSION_MODE_AUTO, PERMISSION_MODE_SMART_APPROVE, effective_harness_instructions, + ContextUsage, DirectorySuggestion, HostBackend, HostBootstrap, HostEvent, HostEventHub, HostId, + HostSessionDefaults, PERMISSION_MODE_AUTO, PERMISSION_MODE_SMART_APPROVE, UsageSummary, + effective_harness_instructions, }; use crate::agent::{ AgentConfig, AgentCreateSessionRequest, AgentDesktopQueueSnapshot, AgentIntegration, @@ -30,6 +33,8 @@ use crate::agent::{ account_tool_summaries_db_path, }; use crate::maple_api::MapleApiSession; +use git::BranchWatchers; +use store::AccountStores; /// Where the local host gets the validated OpenSecret session it needs to /// start the runtime and rename tasks. The app implements this over its diff --git a/apps/maple-agent/crates/maple-agent/src/host/store.rs b/apps/maple-agent/crates/maple-agent/src/host/local/store.rs similarity index 95% rename from apps/maple-agent/crates/maple-agent/src/host/store.rs rename to apps/maple-agent/crates/maple-agent/src/host/local/store.rs index 5e863ddd6..b98097247 100644 --- a/apps/maple-agent/crates/maple-agent/src/host/store.rs +++ b/apps/maple-agent/crates/maple-agent/src/host/local/store.rs @@ -8,26 +8,7 @@ use std::path::{Path, PathBuf}; use std::sync::Mutex; -use serde::{Deserialize, Serialize}; - -/// One aggregated usage row: per session or per model. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct UsageRow { - pub label: String, - pub sessions: u64, - pub turns: u64, - pub total_tokens: i64, - pub cost: f64, -} - -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct UsageSummary { - pub totals: UsageRow, - pub by_model: Vec, - pub by_session: Vec, -} +use crate::host::{UsageRow, UsageSummary}; /// Open the goose sessions database for reading. Returns `None` when the /// file does not exist yet (read-only open never creates it). The busy diff --git a/apps/maple-agent/crates/maple-agent/src/host/mod.rs b/apps/maple-agent/crates/maple-agent/src/host/mod.rs index e8610ac45..4605edaab 100644 --- a/apps/maple-agent/crates/maple-agent/src/host/mod.rs +++ b/apps/maple-agent/crates/maple-agent/src/host/mod.rs @@ -11,10 +11,7 @@ //! Account-level concerns (sign-in, billing, audio) are not part of this //! surface: they use the client's own OpenSecret session and stay local. -pub mod directories; -pub mod git; pub mod local; -mod store; use std::collections::HashMap; use std::sync::Mutex; @@ -31,9 +28,7 @@ use crate::agent::{ AgentSubagent, RecentProjectRoot, SideQuestionTurn, }; -pub use directories::DirectorySuggestion; pub use local::{LegacySessionDefaults, LocalHostAuth, LocalHostBackend}; -pub use store::{UsageRow, UsageSummary}; /// Identifies a host on the client. The local host is [`HostId::local`]; /// a remote host is identified by its static public key. @@ -168,6 +163,38 @@ pub struct ContextUsage { pub limit: i64, } +/// One directory a typed root could mean; see +/// [`HostBackend::suggest_directories`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DirectorySuggestion { + /// Absolute path. + pub path: String, + /// Last path component, for display. + pub name: String, +} + +/// One aggregated usage row: per session or per model. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UsageRow { + pub label: String, + pub sessions: u64, + pub turns: u64, + pub total_tokens: i64, + pub cost: f64, +} + +/// The account's usage ledger, aggregated; see +/// [`HostBackend::usage_summary`]. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UsageSummary { + pub totals: UsageRow, + pub by_model: Vec, + pub by_session: Vec, +} + /// Fans one host's events out to every subscriber. The runtime's event /// sink for the local host; a server projects the same stream to its /// sockets. Subscribers that dropped their receiver are pruned on the From 21bdec50cb6d835919e96bcc27e285fd8f00d9c9 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Sun, 20 Sep 2026 00:05:45 -0500 Subject: [PATCH 18/37] Scope paired devices and pairing codes per account Hosting serves one account's runtime, but the device list and the pending pairing code lived beside the machine-wide host key. After a sign-out and another account signing in, "Allow remote connections" started hosting again and the previous account's devices reached the new account's runtime, and a code published for one account could pair a device into another. Both files now live under `/remote/accounts//`, like the client's hosts.json, so a host only admits devices paired into the account it serves. `serve pair` and `serve devices` resolve the account from the saved sign-in and refuse to run without one. The host key and the lock stay per machine. Nothing shipped with the old layout, so there is no migration. Co-Authored-By: Claude Fable 5.1 --- apps/maple-agent/README.md | 9 +- apps/maple-agent/app/src/hosting.rs | 97 ++++++++++++++++----- apps/maple-agent/app/src/serve.rs | 40 ++++++--- apps/maple-agent/app/src/ui/settings.rs | 8 +- apps/maple-agent/docs/remote-development.md | 18 ++-- 5 files changed, 125 insertions(+), 47 deletions(-) diff --git a/apps/maple-agent/README.md b/apps/maple-agent/README.md index 60db7aec4..f07e618b1 100644 --- a/apps/maple-agent/README.md +++ b/apps/maple-agent/README.md @@ -441,7 +441,10 @@ devices; the command and the window share the host key, the device list, and the lock, so only one of them serves at a time. A device is admitted by a one-time code: run `serve pair` on the host, enter the code in the app within five minutes, and both sides pin each other's key; later connections -need no code. Traffic is Noise-encrypted inside a plain WebSocket, so +need no code. Devices and codes belong to the account that is hosting: +`serve pair` and `serve devices` act on the saved sign-in and refuse to run +without one, and a device paired while one account was signed in is not +admitted after another account signs in. Traffic is Noise-encrypted inside a plain WebSocket, so pairing is the only gate and the listener binds every interface by default. Repeated wrong codes lock the source address out. Revoking a device ends its live connections within seconds. One `serve` per data root; a lock @@ -539,8 +542,8 @@ The roots follow the platform, the same way the Tauri app's | `/remote/host_key.json` | This machine's static Noise key as a host (mode 0600). | | `/remote/device_key.json` | This machine's static Noise key as a client device (mode 0600). | | `/agent/accounts//hosts.json` | Hosts this account paired with: key, name, addresses. | -| `/remote/devices.json` | Devices paired with this host. | -| `/remote/pending_pairing.json` | The pairing code `serve pair` published, until used or expired (mode 0600). | +| `/remote/accounts//devices.json` | Devices paired into this account on this host. Another account's host never admits them. | +| `/remote/accounts//pending_pairing.json` | The pairing code `serve pair` published for this account, until used or expired (mode 0600). | | `/remote/serve.lock`, `serve.json` | The running host's lock and its listen address. | | `/logs/maple-gpui.log` | Log file. Panics are logged here too. | diff --git a/apps/maple-agent/app/src/hosting.rs b/apps/maple-agent/app/src/hosting.rs index d84343cca..ce4671d2f 100644 --- a/apps/maple-agent/app/src/hosting.rs +++ b/apps/maple-agent/app/src/hosting.rs @@ -1,8 +1,10 @@ //! This machine as a host, for the desktop app and `maple-gpui serve`. //! -//! Both roles share one data root: the host key, the paired devices, the -//! pending pairing code, and the lock that keeps two servers off one root. -//! The desktop app starts hosting when "Allow remote connections" is on and +//! Both roles share one data root: the host key and the lock that keeps two +//! servers off one root are this machine's; the paired devices and the +//! pending pairing code belong to the account that is hosting, so a device +//! paired into one account never reaches another account's runtime. The +//! desktop app starts hosting when "Allow remote connections" is on and //! shows the state here in Settings; the command runs it in the foreground. use std::net::SocketAddr; @@ -28,10 +30,27 @@ use crate::backend::AgentBackend; #[cfg(feature = "desktop")] pub const DEFAULT_LISTEN: &str = "0.0.0.0:7130"; -/// Where the host keeps its key, its devices, the pending code, and its -/// lock. Created owner-only on first use. +/// Where this machine keeps its host key and the hosting lock. Created +/// owner-only on first use. pub fn remote_dir() -> Result { - let dir = crate::backend::local_data_root().join("remote"); + private_dir(crate::backend::local_data_root().join("remote")) +} + +/// Where the host keeps what belongs to one account: the devices paired +/// into it and the pairing code it currently accepts. Created owner-only +/// on first use. +pub fn account_remote_dir(user_id: &str) -> Result { + private_dir(account_remote_dir_under(&remote_dir()?, user_id)?) +} + +/// Pure path arithmetic behind [`account_remote_dir`]: the account's +/// directory under the machine's remote directory. +fn account_remote_dir_under(remote_dir: &Path, user_id: &str) -> Result { + let scope = maple_agent::maple_api::account_scope(user_id)?; + Ok(remote_dir.join("accounts").join(scope)) +} + +fn private_dir(dir: PathBuf) -> Result { std::fs::create_dir_all(&dir) .map_err(|error| format!("cannot create {}: {error}", dir.display()))?; #[cfg(unix)] @@ -42,12 +61,14 @@ pub fn remote_dir() -> Result { Ok(dir) } -pub fn device_store(dir: &Path) -> DeviceStore { - DeviceStore::new(dir.join("devices.json")) +/// The devices paired into the account whose directory is `account_dir`. +pub fn device_store(account_dir: &Path) -> DeviceStore { + DeviceStore::new(account_dir.join("devices.json")) } -pub fn pending_pairing_store(dir: &Path) -> PendingPairingStore { - PendingPairingStore::new(dir.join("pending_pairing.json")) +/// The pairing code the account whose directory is `account_dir` accepts. +pub fn pending_pairing_store(account_dir: &Path) -> PendingPairingStore { + PendingPairingStore::new(account_dir.join("pending_pairing.json")) } /// What a running host records for `serve pair` to describe. @@ -125,20 +146,22 @@ pub fn sd_notify(state: &str) { let _ = state; } -/// Publish a fresh pairing code for the running host to accept. -pub fn publish_pairing_code() -> Result<(PairingCode, PendingPairing), String> { - let dir = remote_dir()?; +/// Publish a fresh pairing code for a host of `user_id` to accept. +pub fn publish_pairing_code(user_id: &str) -> Result<(PairingCode, PendingPairing), String> { + let dir = account_remote_dir(user_id)?; let code = PairingCode::generate(); let pending = pending_pairing_store(&dir).publish(&code)?; Ok((code, pending)) } -pub fn list_devices() -> Result, String> { - device_store(&remote_dir()?).list() +/// The devices paired into `user_id` on this machine. +pub fn list_devices(user_id: &str) -> Result, String> { + device_store(&account_remote_dir(user_id)?).list() } -pub fn revoke_device(device: &str) -> Result { - device_store(&remote_dir()?).revoke(device) +/// Forget a device paired into `user_id`, by public key or by name. +pub fn revoke_device(user_id: &str, device: &str) -> Result { + device_store(&account_remote_dir(user_id)?).revoke(device) } /// A running host: its listener and the lock on the data root. Dropping @@ -154,8 +177,9 @@ pub struct Hosting { impl Hosting { /// Bind `listen` and serve the local host of `user_id` on the backend - /// runtime until [`Self::stop`]. Fails when another host holds the - /// data root or the address cannot be bound. + /// runtime until [`Self::stop`]. Only devices paired into `user_id` + /// are admitted. Fails when another host holds the data root or the + /// address cannot be bound. pub fn start( backend: &Arc, host: Arc, @@ -175,8 +199,9 @@ impl Hosting { })?; let key = StaticKey::load_or_create(&dir.join("host_key.json"))?; - let devices = Arc::new(device_store(&dir)); - let pending = Arc::new(pending_pairing_store(&dir)); + let account_dir = account_remote_dir(user_id)?; + let devices = Arc::new(device_store(&account_dir)); + let pending = Arc::new(pending_pairing_store(&account_dir)); let hook_devices = Arc::clone(&devices); let config = HostServerConfig { on_client_hello: Some(Arc::new(move |hello| { @@ -349,3 +374,33 @@ impl Drop for HostingController { self.stop(); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn devices_and_codes_live_under_the_account() { + let root = Path::new("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/data/remote"); + let a = account_remote_dir_under(root, "user-a").unwrap(); + let b = account_remote_dir_under(root, "user-b").unwrap(); + assert_ne!(a, b, "two accounts never share a device list"); + assert_eq!( + a, + account_remote_dir_under(root, " USER-A ").unwrap(), + "the scope follows the normalized account id" + ); + assert_eq!(a.parent().unwrap().parent().unwrap(), root); + assert_eq!(a.parent().unwrap().file_name().unwrap(), "accounts"); + assert_eq!( + device_store(&a).path(), + a.join("devices.json"), + "the device file sits in the account directory" + ); + assert_eq!( + pending_pairing_store(&a).path(), + a.join("pending_pairing.json") + ); + assert!(account_remote_dir_under(root, " ").is_err()); + } +} diff --git a/apps/maple-agent/app/src/serve.rs b/apps/maple-agent/app/src/serve.rs index 21ade6f44..daebe14e9 100644 --- a/apps/maple-agent/app/src/serve.rs +++ b/apps/maple-agent/app/src/serve.rs @@ -3,8 +3,9 @@ //! //! The host signs in on its own (`maple-gpui login`) and holds its own //! credentials; clients bring nothing but their device key. A one-time code -//! from `serve pair` admits a device; `serve devices` lists and revokes -//! them. One server per data root, enforced with a lock file. +//! from `serve pair` admits a device into the saved account; `serve devices` +//! lists and revokes that account's devices. One server per data root, +//! enforced with a lock file. use clap::{Args, Subcommand}; @@ -60,21 +61,32 @@ mod enabled { pub fn run(args: ServeArgs) -> Result<(), String> { match args.command.clone() { None => run_server(args), - Some(ServeCommand::Pair) => publish_code(), + Some(ServeCommand::Pair) => publish_code(&saved_account()?), Some(ServeCommand::Devices { command: DevicesCommand::List, - }) => list_devices(), + }) => list_devices(&saved_account()?), Some(ServeCommand::Devices { command: DevicesCommand::Revoke { device }, - }) => revoke_device(&device), + }) => revoke_device(&saved_account()?, &device), } } + const NO_SIGN_IN: &str = + "No saved Maple sign-in on this machine. Run `maple-gpui login` first."; + + /// The account the device commands act on: the one a host here would + /// serve. Read from the saved sign-in without contacting the server. + fn saved_account() -> Result { + AgentBackend::new(crate::configured_api_url())? + .saved_user_id() + .ok_or_else(|| NO_SIGN_IN.to_string()) + } + fn run_server(args: ServeArgs) -> Result<(), String> { let backend = Arc::new(AgentBackend::new(crate::configured_api_url())?); - let user_id = backend.restore_now().ok_or_else(|| { - "No saved Maple sign-in on this machine. Run `maple-gpui login` first.".to_string() - })?; + let user_id = backend + .restore_now() + .ok_or_else(|| NO_SIGN_IN.to_string())?; crate::adopt_legacy_session_defaults(&backend, &user_id); let host = backend.local_host(&user_id); let name = args.name.clone().unwrap_or_else(crate::env::hostname); @@ -121,8 +133,8 @@ mod enabled { std::future::pending::<()>().await; } - fn publish_code() -> Result<(), String> { - let (code, _pending) = hosting::publish_pairing_code()?; + fn publish_code(user_id: &str) -> Result<(), String> { + let (code, _pending) = hosting::publish_pairing_code(user_id)?; // The code goes to stdout so it can be piped; guidance to stderr. println!("{}", code.display()); eprintln!( @@ -142,8 +154,8 @@ mod enabled { Ok(()) } - fn list_devices() -> Result<(), String> { - let devices = hosting::list_devices()?; + fn list_devices(user_id: &str) -> Result<(), String> { + let devices = hosting::list_devices(user_id)?; if devices.is_empty() { eprintln!("No paired devices. Publish a code with `maple-gpui serve pair`."); return Ok(()); @@ -159,8 +171,8 @@ mod enabled { Ok(()) } - fn revoke_device(device: &str) -> Result<(), String> { - let removed = hosting::revoke_device(device)?; + fn revoke_device(user_id: &str, device: &str) -> Result<(), String> { + let removed = hosting::revoke_device(user_id, device)?; eprintln!( "Revoked {} ({}). A live connection from it ends within seconds.", removed.name, removed.public_key diff --git a/apps/maple-agent/app/src/ui/settings.rs b/apps/maple-agent/app/src/ui/settings.rs index d4e6b1d74..23f88a9e3 100644 --- a/apps/maple-agent/app/src/ui/settings.rs +++ b/apps/maple-agent/app/src/ui/settings.rs @@ -308,7 +308,7 @@ impl SettingsScreen { .unwrap_or(HostingStatus::Off); let paired_devices = hosting .as_ref() - .and_then(|_| crate::hosting::list_devices().ok()) + .and_then(|_| crate::hosting::list_devices(&user_id).ok()) .unwrap_or_default(); let host_field = |placeholder: &str, index: isize, cx: &mut Context| { let application_vim_enabled = settings.application_vim_enabled; @@ -950,7 +950,7 @@ impl SettingsScreen { } fn generate_pairing_code(&mut self, cx: &mut Context) { - match crate::hosting::publish_pairing_code() { + match crate::hosting::publish_pairing_code(&self.user_id) { Ok((code, pending)) => { self.pairing_code = Some((code.display(), pending.expires_ms)); self.hosts_notice = None; @@ -961,10 +961,10 @@ impl SettingsScreen { } fn revoke_device(&mut self, key: &str, cx: &mut Context) { - if let Err(message) = crate::hosting::revoke_device(key) { + if let Err(message) = crate::hosting::revoke_device(&self.user_id, key) { self.hosts_notice = Some(message); } - self.paired_devices = crate::hosting::list_devices().unwrap_or_default(); + self.paired_devices = crate::hosting::list_devices(&self.user_id).unwrap_or_default(); cx.notify(); } diff --git a/apps/maple-agent/docs/remote-development.md b/apps/maple-agent/docs/remote-development.md index df99a355d..7449330f5 100644 --- a/apps/maple-agent/docs/remote-development.md +++ b/apps/maple-agent/docs/remote-development.md @@ -405,7 +405,14 @@ As built: the first byte of the first handshake message names the pattern and doubles as the Noise prologue. In the pairing pattern the client sends the last handshake message, so the host sends one empty transport message to confirm the code; the client trusts nothing until it decrypts it. The -hello's device key must equal the key the handshake proved. Revocation is +hello's device key must equal the key the handshake proved. The pending +record and the device file are per account, under +`/remote/accounts//`: hosting serves one account's +runtime, so a device paired while account A was signed in is not admitted +by a host of account B, and a code published for A never pairs a device +into B. `serve pair` and `serve devices` resolve the account from the saved +sign-in and refuse to run without one. The host key and the lock stay per +machine. Revocation is an edit to the device file; the listener checks it every ten seconds and drops a revoked device's connection. @@ -531,8 +538,9 @@ window runs it behind the "Allow remote connections" setting, off by default, which takes effect at once from Settings > Hosts. That section also publishes pairing codes through the same pending file the CLI uses, and lists and revokes paired devices. The command and the window share the -host key, the device list, and the lock, so only one of them serves at a -time; the other reports who holds the root. +host key, the lock, and, for one account, the device list and the pending +code, so only one of them serves at a time; the other reports who holds +the root. ## Host CLI @@ -554,8 +562,8 @@ which the proxy mode uses. | --- | --- | --- | | `/remote/host_key` | host | static private key, 0600 | | `/remote/device_key` | client | static private key, 0600 | -| `/remote/devices.json` | host | paired devices | -| `/remote/pending_pairing.json` | host | one pending code, 0600, short-lived | +| `/remote/accounts//devices.json` | host | devices paired into that account | +| `/remote/accounts//pending_pairing.json` | host | one pending code for that account, 0600, short-lived | | `/remote/serve.lock` | host | single-server lock | | `/agent/accounts//hosts.json` | client | saved hosts per account | | `/agent/accounts//config.json` | host | existing `AgentConfig`, gains session defaults | From 87d18a61280010d37b82ecc5cbf0ca8f3d2d2de7 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Sun, 20 Sep 2026 00:12:24 -0500 Subject: [PATCH 19/37] Fix the desktop app's host and client roles Hosting::stop returned before the port was free, so a host started right after could take the lock and fail to bind; stop now waits for the listener. Whether a host was running was read from a pid, which only Linux could check; the serve.lock file lock is probed instead. Starting the window's host bound the port on the UI thread; it now runs on the backend runtime with a Starting state, and the toggle persists only once the host listens. The serve command did not compile alone, and refused to start when the account server was unreachable at boot, telling the operator to sign in; it now serves with the saved sign-in and retries in the background. Legacy session defaults were lost when the user upgraded while signed out; the login path adopts them too. macOS hosts were all named "maple"; the name comes from gethostname. The Hosts pane showed a stale online snapshot, a pairing code forever, and a code button while another process held the host; it reads live status, hides an expired or consumed code, and enables the button only while listening. Duplicate helpers and constants across the host and client modules are gone, and sd_notify lives with the command. Co-Authored-By: Claude Fable 5.1 --- apps/maple-agent/Cargo.lock | 1 + apps/maple-agent/README.md | 6 +- apps/maple-agent/app/Cargo.toml | 4 + apps/maple-agent/app/src/backend.rs | 20 +- apps/maple-agent/app/src/desktop.rs | 22 +- apps/maple-agent/app/src/env.rs | 52 ++- apps/maple-agent/app/src/hosting.rs | 361 ++++++++++++-------- apps/maple-agent/app/src/hosts.rs | 32 +- apps/maple-agent/app/src/main.rs | 30 +- apps/maple-agent/app/src/serve.rs | 129 ++++++- apps/maple-agent/app/src/ui/settings.rs | 220 +++++++++--- apps/maple-agent/docs/remote-development.md | 27 ++ 12 files changed, 654 insertions(+), 250 deletions(-) diff --git a/apps/maple-agent/Cargo.lock b/apps/maple-agent/Cargo.lock index 06231c865..7ab8d8198 100644 --- a/apps/maple-agent/Cargo.lock +++ b/apps/maple-agent/Cargo.lock @@ -5864,6 +5864,7 @@ dependencies = [ "gpui", "gpui_platform", "image 0.25.10", + "libc", "log", "maple-agent", "maple-billing", diff --git a/apps/maple-agent/README.md b/apps/maple-agent/README.md index f07e618b1..264fdd054 100644 --- a/apps/maple-agent/README.md +++ b/apps/maple-agent/README.md @@ -434,7 +434,11 @@ maple-gpui serve devices revoke DEV Forget a device by key or name. Runs this machine as a host for the desktop app on another machine, over a LAN or a Tailscale network. It reuses the sign-in saved by `login` or -the desktop app and hosts its own runtime. The desktop app can serve the +the desktop app and hosts its own runtime. Without a saved sign-in it +exits with a message; when the Maple server cannot be reached at start +(a unit that comes up before the network) it serves anyway, requests fail +until the sign-in goes through, and the sign-in is retried in the +background with growing pauses. The desktop app can serve the same way: Settings > Hosts > "Allow remote connections" (off by default) listens on the same port, publishes pairing codes, and lists paired devices; the command and the window share the host key, the device list, diff --git a/apps/maple-agent/app/Cargo.toml b/apps/maple-agent/app/Cargo.toml index 99e236031..b65600888 100644 --- a/apps/maple-agent/app/Cargo.toml +++ b/apps/maple-agent/app/Cargo.toml @@ -74,6 +74,10 @@ percent-encoding = "2" axum = { version = "0.8", optional = true } tower-http = { version = "0.6", features = ["cors"], optional = true } +# `gethostname`, for the name hosts and devices show each other. +[target.'cfg(unix)'.dependencies] +libc = "0.2" + # Used only to ask the compositor whether it draws window decorations; see # `ui::decorations`. gpui cannot answer that question. [target.'cfg(target_os = "linux")'.dependencies] diff --git a/apps/maple-agent/app/src/backend.rs b/apps/maple-agent/app/src/backend.rs index b39754867..4c224b338 100644 --- a/apps/maple-agent/app/src/backend.rs +++ b/apps/maple-agent/app/src/backend.rs @@ -291,6 +291,13 @@ pub fn local_data_root() -> PathBuf { base.join(APP_DIR_NAME) } +/// The agent runtime's directory layout under this app's roots. Other +/// modules that keep per-account files beside the runtime's take the +/// account directory from here instead of rebuilding the layout. +pub fn agent_paths() -> maple_agent::agent::AgentPathLayout { + maple_agent::agent::AgentPathLayout::from_app_roots(config_root(), local_data_root()) +} + fn env_dir(name: &str) -> Option { std::env::var_os(name) .map(PathBuf::from) @@ -535,8 +542,7 @@ impl AgentBackend { // built, including the login-time SDK client. let api_url = maple_agent::maple_api::validate_api_url(&api_url)?; let events = Arc::new(HostEventHub::default()); - let paths = - maple_agent::agent::AgentPathLayout::from_app_roots(config_root(), local_data_root()); + let paths = agent_paths(); // Keeps ACP bridge credentials out of desktop tool environments. let default_tool_context = maple_agent::agent::default_tool_context_spec()?; // The harness instructions are per account and reach the runtime @@ -697,12 +703,20 @@ impl AgentBackend { /// Restore a persisted session before the UI starts. Validates the /// credentials against the backend; returns the account id on success. pub fn restore_now(&self) -> Option { - match self.runtime.block_on(self.validate_persisted_auth()) { + match self.restore_outcome_now() { RestoreOutcome::Valid(user_id) => Some(user_id), RestoreOutcome::Rejected | RestoreOutcome::Unavailable => None, } } + /// [`Self::restore_now`] with the full outcome, for a command that + /// treats an unreachable server differently from a rejected sign-in. + /// `Rejected` also covers a missing sign-in; check + /// [`Self::saved_user_id`] first to tell them apart. + pub fn restore_outcome_now(&self) -> RestoreOutcome { + self.runtime.block_on(self.validate_persisted_auth()) + } + /// Validate the persisted credentials on the backend runtime while the /// UI already shows the account's local data. Calls that need the /// session wait for this to finish (see `session_for`). diff --git a/apps/maple-agent/app/src/desktop.rs b/apps/maple-agent/app/src/desktop.rs index dcb39985d..dd4153aea 100644 --- a/apps/maple-agent/app/src/desktop.rs +++ b/apps/maple-agent/app/src/desktop.rs @@ -126,6 +126,18 @@ impl MapleApp { ); let backend = self.backend.clone(); let host = backend.local_host(&user_id); + // Session defaults an older version kept in settings.json: the + // launch moved them for the saved account, but a user signed out + // at the upgrade binds an account here first. Idempotent, and done + // once per process: the in-memory copy is cleared below. + let legacy = self.settings.legacy_session_defaults(); + if !legacy.is_empty() { + let host = host.clone(); + backend.spawn(async move { + crate::adopt_legacy_session_defaults_into(&host, legacy).await; + }); + self.settings.legacy = Default::default(); + } let chat = cx.new(|cx| ChatScreen::new(backend.clone(), host, user_id.clone(), cx)); // Remote hosts: connect to every saved one and pump what they // report into the chat screen, batched like the local events. @@ -167,7 +179,9 @@ impl MapleApp { user_id.clone(), )); if self.settings.allow_remote_connections { - hosting.start(crate::hosting::DEFAULT_LISTEN); + // Binds on the backend runtime; the controller reports the + // outcome to Settings and the log. + backend.spawn(hosting.start(crate::serve::DEFAULT_LISTEN)); } self.hosting = Some(hosting); // The release check may have finished while the login screen was @@ -435,7 +449,7 @@ pub fn run() { // Name the resolved file so a launcher with its own XDG_CONFIG_HOME // makes itself visible: settings that look unsaved usually live in a // different root than the one this launch reads. - let startup_settings = crate::settings::load_settings(); + let mut startup_settings = crate::settings::load_settings(); log::debug!( "startup: settings loaded from {} at {} ms", crate::backend::app_config_root() @@ -448,9 +462,11 @@ pub fn run() { ); log::debug!("startup: backend ready at {} ms", crate::startup_elapsed()); // Session defaults an older version kept in settings.json belong to the - // account config now. Move them before the chat screen reads them. + // account config now. Move them before the chat screen reads them. With + // no saved account they wait for the sign-in (see `open_chat`). if let Some(user_id) = backend.saved_user_id() { crate::adopt_legacy_session_defaults(&backend, &user_id); + startup_settings.legacy = Default::default(); } gpui_platform::application() diff --git a/apps/maple-agent/app/src/env.rs b/apps/maple-agent/app/src/env.rs index 4db2be629..1e0a4578f 100644 --- a/apps/maple-agent/app/src/env.rs +++ b/apps/maple-agent/app/src/env.rs @@ -2,8 +2,9 @@ //! trimmed, and an empty value counts as unset so a stray `NAME=` in a //! launcher does not override a default with nothing. -// A headless build (no `desktop` feature) has no update check, the only -// caller of `env_flag`. +// A headless build (no `desktop` feature) leaves some helpers without a +// caller: `env_flag` belongs to the update check, `hostname` to the host +// and client roles the build may lack. #![cfg_attr(not(feature = "desktop"), allow(dead_code))] /// The trimmed value of `name`, or `None` when unset or blank. @@ -15,17 +16,37 @@ pub fn env_string(name: &str) -> Option { } /// This machine's name, for hosts and devices to show each other. -#[allow(dead_code)] +/// `HOSTNAME` in the environment overrides what the system reports. pub fn hostname() -> String { - if let Some(name) = env_string("HOSTNAME") { - return name; - } - if let Ok(name) = std::fs::read_to_string("/etc/hostname") - && !name.trim().is_empty() - { - return name.trim().to_string(); + env_string("HOSTNAME") + .or_else(system_hostname) + .unwrap_or_else(|| "maple".to_string()) +} + +/// The name the operating system reports, or `None` when it has none. +#[cfg(unix)] +fn system_hostname() -> Option { + // `gethostname` truncates to the buffer without a terminator when the + // name is longer; 256 exceeds every platform's HOST_NAME_MAX. + let mut buffer = [0u8; 256]; + // SAFETY: the buffer is valid for writes of its full length, and the + // call writes at most that many bytes. + let status = + unsafe { libc::gethostname(buffer.as_mut_ptr() as *mut libc::c_char, buffer.len()) }; + if status != 0 { + return None; } - "maple".to_string() + let end = buffer + .iter() + .position(|byte| *byte == 0) + .unwrap_or(buffer.len()); + let name = String::from_utf8_lossy(&buffer[..end]).trim().to_string(); + (!name.is_empty()).then_some(name) +} + +#[cfg(not(unix))] +fn system_hostname() -> Option { + env_string("COMPUTERNAME") } /// Whether `name` is set to `1`, `true`, or `yes` (case-insensitive). @@ -82,4 +103,13 @@ mod tests { } assert!(!with_var("MAPLE_TEST_FLAG", None, read)); } + + #[test] + fn hostname_prefers_the_override_and_never_comes_back_empty() { + assert_eq!(with_var("HOSTNAME", Some(" box "), hostname), "box"); + let system = with_var("HOSTNAME", None, hostname); + assert!(!system.is_empty()); + assert_eq!(system, system.trim()); + assert!(!system.contains('\0')); + } } diff --git a/apps/maple-agent/app/src/hosting.rs b/apps/maple-agent/app/src/hosting.rs index ce4671d2f..bab95cd5c 100644 --- a/apps/maple-agent/app/src/hosting.rs +++ b/apps/maple-agent/app/src/hosting.rs @@ -8,8 +8,6 @@ //! shows the state here in Settings; the command runs it in the foreground. use std::net::SocketAddr; -#[cfg(unix)] -use std::os::unix::ffi::OsStringExt as _; use std::path::{Path, PathBuf}; use std::sync::Arc; #[cfg(feature = "desktop")] @@ -24,11 +22,8 @@ use maple_remote::server::{HostIdentity, HostServer, HostServerConfig}; use maple_remote::wire::HostInfo; use tokio_util::sync::CancellationToken; -use crate::backend::AgentBackend; - -/// Default listen address. Not 8080, which the proxy mode uses. #[cfg(feature = "desktop")] -pub const DEFAULT_LISTEN: &str = "0.0.0.0:7130"; +use crate::backend::AgentBackend; /// Where this machine keeps its host key and the hosting lock. Created /// owner-only on first use. @@ -78,72 +73,70 @@ pub struct ServeState { pub listen: String, pub name: String, pub host_id: String, - pub pid: u32, } fn state_path(dir: &Path) -> PathBuf { dir.join("serve.json") } -/// The running host's state, or `None` when there is none or the recorded -/// process is gone (a crash leaves the file behind). -pub fn read_state(dir: &Path) -> Option { - let bytes = std::fs::read(state_path(dir)).ok()?; - let state: ServeState = serde_json::from_slice(&bytes).ok()?; - process_is_alive(state.pid).then_some(state) +fn lock_path(dir: &Path) -> PathBuf { + dir.join("serve.lock") } -#[cfg(target_os = "linux")] -fn process_is_alive(pid: u32) -> bool { - Path::new(&format!("/proc/{pid}")).exists() +fn open_lock(dir: &Path) -> Result { + let path = lock_path(dir); + std::fs::OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(&path) + .map_err(|error| format!("cannot open {}: {error}", path.display())) } -#[cfg(not(target_os = "linux"))] -fn process_is_alive(_pid: u32) -> bool { - true +/// Take the hosting lock for `dir`. One server per data root: two would +/// race the runtime's own account state and the pairing file. The lock is +/// held for as long as the returned file is open. +fn take_lock(dir: &Path) -> Result { + let lock = open_lock(dir)?; + match lock.try_lock() { + Ok(()) => Ok(lock), + Err(std::fs::TryLockError::WouldBlock) => Err( + "another Maple host already runs on this machine (`maple-gpui serve` or another window)" + .to_string(), + ), + Err(std::fs::TryLockError::Error(error)) => Err(format!( + "cannot lock {}: {error}", + lock_path(dir).display() + )), + } } -/// Tell systemd how the service is doing, when it asked (`NOTIFY_SOCKET` -/// set). Silent everywhere else. Only the main process may report, so the -/// caller is the `serve` command itself. -pub fn sd_notify(state: &str) { - #[cfg(unix)] - { - let Some(socket) = std::env::var_os("NOTIFY_SOCKET") else { - return; - }; - let mut path = socket.into_encoded_bytes(); - // An abstract socket is written with a leading `@`; the address - // needs a NUL there. - if path.first() == Some(&b'@') { - path[0] = 0; - } - let Ok(socket) = std::os::unix::net::UnixDatagram::unbound() else { - return; - }; - let sent = if path.first() == Some(&0) { - #[cfg(target_os = "linux")] - { - use std::os::linux::net::SocketAddrExt as _; - std::os::unix::net::SocketAddr::from_abstract_name(&path[1..]) - .and_then(|address| socket.send_to_addr(state.as_bytes(), &address)) - } - #[cfg(not(target_os = "linux"))] - { - Err(std::io::Error::other("abstract sockets are Linux only")) - } - } else { - socket.send_to( - state.as_bytes(), - std::path::PathBuf::from(std::ffi::OsString::from_vec(path)), - ) - }; - if let Err(error) = sent { - log::debug!("sd_notify failed: {error}"); +/// Whether a host holds the lock for `dir` right now. The probe takes the +/// lock and lets it go again, so it answers the same on every platform +/// and is never fooled by a crashed host's leftovers or a reused pid. +pub fn lock_is_held(dir: &Path) -> bool { + let Ok(probe) = open_lock(dir) else { + return false; + }; + match probe.try_lock() { + Ok(()) => false, + Err(std::fs::TryLockError::WouldBlock) => true, + Err(std::fs::TryLockError::Error(error)) => { + log::debug!("cannot probe the hosting lock: {error}"); + false } } - #[cfg(not(unix))] - let _ = state; +} + +/// The running host's state, or `None` when no host holds the lock (a +/// crash leaves the state file behind; the lock it does not). +pub fn read_state(dir: &Path) -> Option { + if !lock_is_held(dir) { + return None; + } + let bytes = std::fs::read(state_path(dir)).ok()?; + serde_json::from_slice(&bytes).ok() } /// Publish a fresh pairing code for a host of `user_id` to accept. @@ -154,6 +147,13 @@ pub fn publish_pairing_code(user_id: &str) -> Result<(PairingCode, PendingPairin Ok((code, pending)) } +/// The code a host of `user_id` accepts right now, if one is pending and +/// not yet expired or consumed. +#[cfg(feature = "desktop")] +pub fn pending_pairing_code(user_id: &str) -> Result, String> { + Ok(pending_pairing_store(&account_remote_dir(user_id)?).current()) +} + /// The devices paired into `user_id` on this machine. pub fn list_devices(user_id: &str) -> Result, String> { device_store(&account_remote_dir(user_id)?).list() @@ -164,40 +164,32 @@ pub fn revoke_device(user_id: &str, device: &str) -> Result>, dir: PathBuf, - _lock: std::fs::File, + lock: std::fs::File, } impl Hosting { - /// Bind `listen` and serve the local host of `user_id` on the backend - /// runtime until [`Self::stop`]. Only devices paired into `user_id` - /// are admitted. Fails when another host holds the data root or the - /// address cannot be bound. - pub fn start( - backend: &Arc, + /// Bind `listen` and serve the local host of `user_id` until + /// [`Self::stop`]. Only devices paired into `user_id` are admitted. + /// Fails when another host holds the data root or the address cannot + /// be bound. Runs on the backend runtime, whose context spawns the + /// listener task. + pub async fn start( host: Arc, user_id: &str, listen: &str, name: String, ) -> Result { let dir = remote_dir()?; - // One server per data root: two would race the runtime's own - // account state and the pairing file. - let lock_path = dir.join("serve.lock"); - let lock = std::fs::File::create(&lock_path) - .map_err(|error| format!("cannot open {}: {error}", lock_path.display()))?; - lock.try_lock().map_err(|_| { - "another Maple host already runs on this machine (`maple-gpui serve` or another window)" - .to_string() - })?; - + let lock = take_lock(&dir)?; let key = StaticKey::load_or_create(&dir.join("host_key.json"))?; let account_dir = account_remote_dir(user_id)?; let devices = Arc::new(device_store(&account_dir)); @@ -228,15 +220,12 @@ impl Hosting { name: name.clone(), user_id: Some(user_id.to_string()), }; - let server = HostServer::new(host.clone(), info, identity, config); - let listen_text = listen.to_string(); - let runtime = backend.runtime_handle(); - let listener = runtime.block_on(async { - host.apply_saved_harness().await?; - tokio::net::TcpListener::bind(&listen_text) - .await - .map_err(|error| format!("cannot listen on {listen_text}: {error}")) - })?; + // The saved harness reaches the runtime when it starts + // (`LocalHostBackend::start_runtime`); nothing to apply here. + let server = HostServer::new(host, info, identity, config); + let listener = tokio::net::TcpListener::bind(listen) + .await + .map_err(|error| format!("cannot listen on {listen}: {error}"))?; let local = listener.local_addr().map_err(|error| error.to_string())?; maple_agent::private_file::write_private_json( &state_path(&dir), @@ -244,7 +233,6 @@ impl Hosting { listen: local.to_string(), name: name.clone(), host_id: host_id.clone(), - pid: std::process::id(), }, ) .map_err(|error| format!("cannot write the serve state: {error}"))?; @@ -255,25 +243,32 @@ impl Hosting { pending_pairing: pending, limiter: PairingLimiter::default(), }); - runtime.spawn(serve_listener(listener, server, stores, shutdown.clone())); + let listener = tokio::spawn(serve_listener(listener, server, stores, shutdown.clone())); Ok(Self { listen: local, host_id, name, shutdown, + listener, dir, - _lock: lock, + lock, }) } - pub fn stop(&self) { + /// Stop serving: end the listener and its connections, then release + /// the lock once the port is free, so a host started next can bind. + /// Resolves when both are gone; callers on the UI thread spawn it on + /// the backend runtime. + pub async fn stop(self) { self.shutdown.cancel(); + match self.listener.await { + Ok(Ok(())) => log::info!("host {} stopped listening on {}", self.name, self.listen), + Ok(Err(error)) => log::warn!("the host listener ended with an error: {error}"), + Err(error) => log::warn!("the host listener task failed: {error}"), + } let _ = std::fs::remove_file(state_path(&self.dir)); - } - - /// Resolves when the listener stopped. - pub async fn stopped(&self) { - self.shutdown.cancelled().await; + // Last, after the listener let the port go. + drop(self.lock); } } @@ -282,18 +277,36 @@ impl Hosting { #[derive(Debug, Clone, PartialEq, Eq)] pub enum HostingStatus { Off, - Listening { listen: String, host_id: String }, + /// A start is in flight on the backend runtime. + Starting, + Listening { + listen: String, + host_id: String, + name: String, + }, + Failed(String), +} + +#[cfg(feature = "desktop")] +enum HostingState { + Off, + /// A start is in flight; the generation tells it whether it still + /// owns the outcome when it finishes. + Starting(u64), + Listening(Hosting), Failed(String), } /// The desktop app's host role: starts and stops hosting for the signed-in -/// account and answers Settings. +/// account and answers Settings. Starting and stopping run on the backend +/// runtime; the state answers at once. #[cfg(feature = "desktop")] pub struct HostingController { backend: Arc, host: Arc, user_id: String, - state: Mutex<(Option, Option)>, + state: Mutex, + generation: std::sync::atomic::AtomicU64, } #[cfg(feature = "desktop")] @@ -303,68 +316,103 @@ impl HostingController { backend, host, user_id, - state: Mutex::new((None, None)), + state: Mutex::new(HostingState::Off), + generation: std::sync::atomic::AtomicU64::new(0), } } - pub fn status(&self) -> HostingStatus { - let state = self - .state + fn lock_state(&self) -> std::sync::MutexGuard<'_, HostingState> { + self.state .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - match (&state.0, &state.1) { - (Some(hosting), _) => HostingStatus::Listening { + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + pub fn status(&self) -> HostingStatus { + match &*self.lock_state() { + HostingState::Off => HostingStatus::Off, + HostingState::Starting(_) => HostingStatus::Starting, + HostingState::Listening(hosting) => HostingStatus::Listening { listen: hosting.listen.to_string(), host_id: hosting.host_id.clone(), + name: hosting.name.clone(), }, - (None, Some(error)) => HostingStatus::Failed(error.clone()), - (None, None) => HostingStatus::Off, + HostingState::Failed(error) => HostingStatus::Failed(error.clone()), } } - /// Start hosting on `listen`. Already running is fine. - pub fn start(&self, listen: &str) -> HostingStatus { - let mut state = self - .state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - if state.0.is_none() { - match Hosting::start( - &self.backend, - Arc::clone(&self.host), - &self.user_id, - listen, - crate::env::hostname(), - ) { - Ok(hosting) => { - log::info!( - "hosting as {} ({}) on {}", - hosting.name, - hosting.host_id, - hosting.listen - ); - state.0 = Some(hosting); - state.1 = None; + /// Start hosting on `listen`. The state reads `Starting` at once; the + /// returned future does the work and resolves to the outcome, so run + /// it on the backend runtime. Already listening or starting resolves + /// to the current status without a second start. + pub fn start( + self: &Arc, + listen: &str, + ) -> impl std::future::Future + Send + 'static { + let this = Arc::clone(self); + let listen = listen.to_string(); + let generation = { + let mut state = self.lock_state(); + match &*state { + HostingState::Off | HostingState::Failed(_) => { + let generation = self + .generation + .fetch_add(1, std::sync::atomic::Ordering::Relaxed) + + 1; + *state = HostingState::Starting(generation); + Some(generation) } - Err(error) => { - log::warn!("hosting did not start: {error}"); - state.1 = Some(error); + HostingState::Starting(_) | HostingState::Listening(_) => None, + } + }; + async move { + let Some(generation) = generation else { + return this.status(); + }; + let result = Hosting::start( + Arc::clone(&this.host), + &this.user_id, + &listen, + crate::env::hostname(), + ) + .await; + let stale = { + let mut state = this.lock_state(); + if matches!(*state, HostingState::Starting(current) if current == generation) { + *state = match result { + Ok(hosting) => { + log::info!( + "hosting as {} ({}) on {}", + hosting.name, + hosting.host_id, + hosting.listen + ); + HostingState::Listening(hosting) + } + Err(error) => { + log::warn!("hosting did not start: {error}"); + HostingState::Failed(error) + } + }; + None + } else { + // A stop arrived while the start ran: the stop wins. + result.ok() } + }; + if let Some(hosting) = stale { + hosting.stop().await; } + this.status() } - drop(state); - self.status() } + /// Stop hosting. The state reads `Off` at once; the listener and the + /// lock go on the backend runtime. pub fn stop(&self) { - let mut state = self - .state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - if let Some(hosting) = state.0.take() { - hosting.stop(); + let previous = std::mem::replace(&mut *self.lock_state(), HostingState::Off); + if let HostingState::Listening(hosting) = previous { + self.backend.spawn(hosting.stop()); } - state.1 = None; } } @@ -403,4 +451,33 @@ mod tests { ); assert!(account_remote_dir_under(root, " ").is_err()); } + + #[test] + fn the_lock_probe_sees_a_held_lock_and_a_free_one() { + let dir = std::env::temp_dir().join(format!("maple-hosting-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + assert!(!lock_is_held(&dir), "no host runs on a fresh directory"); + assert!(read_state(&dir).is_none()); + let held = take_lock(&dir).unwrap(); + assert!(lock_is_held(&dir)); + assert!(take_lock(&dir).is_err(), "a second host is refused"); + maple_agent::private_file::write_private_json( + &state_path(&dir), + &ServeState { + listen: "127.0.0.1:7130".to_string(), + name: "box".to_string(), + host_id: "h".to_string(), + }, + ) + .unwrap(); + assert_eq!(read_state(&dir).unwrap().name, "box"); + drop(held); + assert!(!lock_is_held(&dir), "the probe leaves the lock free"); + assert!( + read_state(&dir).is_none(), + "a state file without its lock is a crashed host's leftover" + ); + assert!(take_lock(&dir).is_ok()); + let _ = std::fs::remove_dir_all(dir); + } } diff --git a/apps/maple-agent/app/src/hosts.rs b/apps/maple-agent/app/src/hosts.rs index ae7a65eb1..2562722fa 100644 --- a/apps/maple-agent/app/src/hosts.rs +++ b/apps/maple-agent/app/src/hosts.rs @@ -6,7 +6,6 @@ //! reports through one channel that the desktop shell pumps into the chat //! screen. -use std::path::PathBuf; use std::sync::Arc; use maple_remote::client::ClientConfig; @@ -18,34 +17,17 @@ use tokio::sync::mpsc; use crate::backend::AgentBackend; -/// Where this machine keeps its keys and host records for remote work. -fn remote_dir() -> Result { - let dir = crate::backend::local_data_root().join("remote"); - std::fs::create_dir_all(&dir) - .map_err(|error| format!("cannot create {}: {error}", dir.display()))?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt as _; - let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)); - } - Ok(dir) -} - -/// This device's static key, generated on first use. +/// This device's static key, generated on first use. It sits beside the +/// host key: one machine, two roles. pub fn device_key() -> Result { - StaticKey::load_or_create(&remote_dir()?.join("device_key.json")) + StaticKey::load_or_create(&crate::hosting::remote_dir()?.join("device_key.json")) } -/// The saved hosts of one account. +/// The saved hosts of one account, in the account's local data directory +/// so removing the account removes them. fn hosts_store(user_id: &str) -> Result { - let scope = maple_agent::maple_api::account_scope(user_id)?; - Ok(HostsStore::new( - crate::backend::local_data_root() - .join("agent") - .join("accounts") - .join(scope) - .join("hosts.json"), - )) + let dir = maple_agent::agent::account_local_data_dir(&crate::backend::agent_paths(), user_id)?; + Ok(HostsStore::new(dir.join("hosts.json"))) } /// The hello this device sends to every host. diff --git a/apps/maple-agent/app/src/main.rs b/apps/maple-agent/app/src/main.rs index 35d190ea6..1c8f69de1 100644 --- a/apps/maple-agent/app/src/main.rs +++ b/apps/maple-agent/app/src/main.rs @@ -313,21 +313,35 @@ fn run_acp() -> Result<(), String> { } /// Session defaults an older version kept in settings.json move into the -/// account config the first time an account is bound. Both the window and -/// the `acp` command bind an account, so both run this; the host ignores -/// values the config already holds, and the next settings save drops the -/// old keys. -#[cfg(any(feature = "desktop", feature = "acp"))] +/// account config the first time an account is bound. The window and the +/// `acp` and `serve` commands bind an account, so all of them run this +/// before the runtime reads its config; the host ignores values the config +/// already holds, and the next settings save drops the old keys. +#[cfg(any(feature = "desktop", feature = "acp", feature = "serve"))] pub(crate) fn adopt_legacy_session_defaults(backend: &std::sync::Arc, user_id: &str) { let legacy = settings::load_settings().legacy_session_defaults(); if legacy.is_empty() { return; } let host = backend.local_host(user_id); - match backend + backend .runtime_handle() - .block_on(host.migrate_session_defaults(legacy)) - { + .block_on(adopt_legacy_session_defaults_into(&host, legacy)); +} + +/// The migration itself, for a caller already on the backend runtime: the +/// window runs it again after a sign-in, since a user who was signed out +/// at the upgrade has no saved account for the launch-time pass to bind. +/// Idempotent: `migrate_session_defaults` keeps what the config holds. +#[cfg(any(feature = "desktop", feature = "acp", feature = "serve"))] +pub(crate) async fn adopt_legacy_session_defaults_into( + host: &maple_agent::host::LocalHostBackend, + legacy: maple_agent::host::LegacySessionDefaults, +) { + if legacy.is_empty() { + return; + } + match host.migrate_session_defaults(legacy).await { Ok(()) => settings::update_settings_in_background(|_| {}), Err(error) => log::warn!("session defaults were not migrated: {error}"), } diff --git a/apps/maple-agent/app/src/serve.rs b/apps/maple-agent/app/src/serve.rs index daebe14e9..368633d0d 100644 --- a/apps/maple-agent/app/src/serve.rs +++ b/apps/maple-agent/app/src/serve.rs @@ -52,15 +52,22 @@ pub use enabled::run; #[cfg(feature = "serve")] mod enabled { + #[cfg(unix)] + use std::os::unix::ffi::OsStringExt as _; use std::sync::Arc; use super::{DevicesCommand, ServeArgs, ServeCommand}; - use crate::backend::AgentBackend; + use crate::backend::{AgentBackend, RestoreOutcome}; use crate::hosting::{self, Hosting}; pub fn run(args: ServeArgs) -> Result<(), String> { - match args.command.clone() { - None => run_server(args), + let ServeArgs { + command, + listen, + name, + } = args; + match command { + None => run_server(&listen, name), Some(ServeCommand::Pair) => publish_code(&saved_account()?), Some(ServeCommand::Devices { command: DevicesCommand::List, @@ -71,6 +78,49 @@ mod enabled { } } + /// Tell systemd how the service is doing, when it asked + /// (`NOTIFY_SOCKET` set). Silent everywhere else. Only the main process + /// may report, so this is the command's alone. + fn sd_notify(state: &str) { + #[cfg(unix)] + { + let Some(socket) = std::env::var_os("NOTIFY_SOCKET") else { + return; + }; + let mut path = socket.into_encoded_bytes(); + // An abstract socket is written with a leading `@`; the + // address needs a NUL there. + if path.first() == Some(&b'@') { + path[0] = 0; + } + let Ok(socket) = std::os::unix::net::UnixDatagram::unbound() else { + return; + }; + let sent = if path.first() == Some(&0) { + #[cfg(target_os = "linux")] + { + use std::os::linux::net::SocketAddrExt as _; + std::os::unix::net::SocketAddr::from_abstract_name(&path[1..]) + .and_then(|address| socket.send_to_addr(state.as_bytes(), &address)) + } + #[cfg(not(target_os = "linux"))] + { + Err(std::io::Error::other("abstract sockets are Linux only")) + } + } else { + socket.send_to( + state.as_bytes(), + std::path::PathBuf::from(std::ffi::OsString::from_vec(path)), + ) + }; + if let Err(error) = sent { + log::debug!("sd_notify failed: {error}"); + } + } + #[cfg(not(unix))] + let _ = state; + } + const NO_SIGN_IN: &str = "No saved Maple sign-in on this machine. Run `maple-gpui login` first."; @@ -82,15 +132,38 @@ mod enabled { .ok_or_else(|| NO_SIGN_IN.to_string()) } - fn run_server(args: ServeArgs) -> Result<(), String> { + fn run_server(listen: &str, name: Option) -> Result<(), String> { let backend = Arc::new(AgentBackend::new(crate::configured_api_url())?); - let user_id = backend - .restore_now() + let saved = backend + .saved_user_id() .ok_or_else(|| NO_SIGN_IN.to_string())?; + let user_id = match backend.restore_outcome_now() { + RestoreOutcome::Valid(user_id) => user_id, + RestoreOutcome::Rejected => { + return Err( + "The saved Maple sign-in was rejected. Run `maple-gpui login` again." + .to_string(), + ); + } + RestoreOutcome::Unavailable => { + // A host that boots before the network (a systemd unit at + // login, a laptop off Wi-Fi) still serves: the sign-in is + // kept, requests report the server state, and the sign-in + // is retried behind them until it goes through. + log::warn!("the Maple server could not be reached; serving with the saved sign-in"); + eprintln!( + "The Maple server could not be reached. Serving anyway; requests fail \ + until the sign-in goes through, which is retried in the background." + ); + retry_sign_in(&backend); + saved + } + }; crate::adopt_legacy_session_defaults(&backend, &user_id); let host = backend.local_host(&user_id); - let name = args.name.clone().unwrap_or_else(crate::env::hostname); - let hosting = Hosting::start(&backend, host, &user_id, &args.listen, name)?; + let name = name.unwrap_or_else(crate::env::hostname); + let runtime = backend.runtime_handle(); + let hosting = runtime.block_on(Hosting::start(host, &user_id, listen, name))?; eprintln!( "Serving as host \"{}\" ({}) on {}.", hosting.name, hosting.host_id, hosting.listen @@ -98,22 +171,52 @@ mod enabled { eprintln!("Pair a device with `maple-gpui serve pair`. Stop with Ctrl-C."); // Under systemd (`Type=notify`) the unit is up once the port is // bound, not when the process forked. - hosting::sd_notify(&format!( + sd_notify(&format!( "READY=1\nSTATUS=Serving as {} on {}", hosting.name, hosting.listen )); - backend.runtime_handle().block_on(async { + runtime.block_on(async { tokio::select! { _ = tokio::signal::ctrl_c() => {} _ = terminate() => {} - _ = hosting.stopped() => {} } }); - hosting::sd_notify("STOPPING=1"); - hosting.stop(); + sd_notify("STOPPING=1"); + // Wait for the port and the lock to go: a restart right after + // (systemd's `Restart=`) must be able to bind. + runtime.block_on(hosting.stop()); Ok(()) } + /// Validate the saved sign-in again, with growing pauses, until the + /// server answers. Requests that need the session wait for each + /// attempt (`AgentBackend::restore_in_background`) and fail between + /// them. A rejection ends the retries; the host stays up so the + /// operator sees the error on the next request and in the log. + fn retry_sign_in(backend: &Arc) { + let runtime = backend.runtime_handle(); + let backend = Arc::clone(backend); + runtime.spawn(async move { + let mut pause = std::time::Duration::from_secs(5); + loop { + tokio::time::sleep(pause).await; + match backend.restore_in_background().await { + Ok(RestoreOutcome::Valid(_)) => { + log::info!("the saved sign-in went through"); + return; + } + Ok(RestoreOutcome::Rejected) => { + log::error!("the saved sign-in was rejected; run `maple-gpui login`"); + return; + } + Ok(RestoreOutcome::Unavailable) | Err(_) => { + pause = (pause * 2).min(std::time::Duration::from_secs(5 * 60)); + } + } + } + }); + } + /// Resolves on SIGTERM, which `systemctl stop` sends. Never resolves /// where there is no such signal. async fn terminate() { diff --git a/apps/maple-agent/app/src/ui/settings.rs b/apps/maple-agent/app/src/ui/settings.rs index 23f88a9e3..3ccbc2990 100644 --- a/apps/maple-agent/app/src/ui/settings.rs +++ b/apps/maple-agent/app/src/ui/settings.rs @@ -306,10 +306,6 @@ impl SettingsScreen { .as_ref() .map(|hosting| hosting.status()) .unwrap_or(HostingStatus::Off); - let paired_devices = hosting - .as_ref() - .and_then(|_| crate::hosting::list_devices(&user_id).ok()) - .unwrap_or_default(); let host_field = |placeholder: &str, index: isize, cx: &mut Context| { let application_vim_enabled = settings.application_vim_enabled; let placeholder = placeholder.to_string(); @@ -374,7 +370,7 @@ impl SettingsScreen { hosting, hosting_status, pairing_code: None, - paired_devices, + paired_devices: Vec::new(), saved_hosts, host_address, host_code, @@ -425,6 +421,10 @@ impl SettingsScreen { this.load_plan(cx); this.load_mcp_servers(cx); this.load_integrations(cx); + this.load_paired_devices(cx); + if matches!(this.hosting_status, HostingStatus::Starting) { + this.watch_hosting_start(cx); + } this } @@ -829,14 +829,13 @@ impl SettingsScreen { cx.notify(); } - /// Whether a host is connected right now: known at open, or connected - /// since (a host paired from this screen). + /// Whether a host is connected right now. The manager is the live + /// answer; `self.hosts` is the snapshot taken at open and would keep a + /// host that dropped since looking online. fn host_is_online(&self, id: &str) -> bool { - self.hosts.iter().any(|host| host.id.as_str() == id) - || self - .manager - .as_ref() - .is_some_and(|manager| manager.is_online(id)) + self.manager + .as_ref() + .is_some_and(|manager| manager.is_online(id)) } /// Re-render shortly, so a host that connects after pairing shows as @@ -932,40 +931,157 @@ impl SettingsScreen { cx.notify(); } - /// Turn this window's host role on or off. The setting persists; the - /// controller starts or stops now and reports. + /// Turn this window's host role on or off. Off persists at once. On + /// starts the host on the backend runtime and persists only once it + /// listens, so a failed start does not come back at the next launch. fn toggle_remote_connections(&mut self, cx: &mut Context) { - let next = !self.settings.allow_remote_connections; - self.edit_setting(move |settings| settings.allow_remote_connections = next, cx); let Some(hosting) = self.hosting.clone() else { return; }; - self.hosting_status = if next { - hosting.start(crate::hosting::DEFAULT_LISTEN) - } else { + let on = self.settings.allow_remote_connections + || matches!(self.hosting_status, HostingStatus::Starting); + if on { + self.edit_setting(|settings| settings.allow_remote_connections = false, cx); hosting.stop(); - HostingStatus::Off - }; + self.hosting_status = HostingStatus::Off; + cx.notify(); + return; + } + self.hosting_status = HostingStatus::Starting; cx.notify(); + self.call( + async move { Ok(hosting.start(crate::serve::DEFAULT_LISTEN).await) }, + cx, + |this, result, cx| { + let status = result.unwrap_or_else(HostingStatus::Failed); + if matches!(status, HostingStatus::Listening { .. }) { + this.edit_setting(|settings| settings.allow_remote_connections = true, cx); + } + this.hosting_status = status; + cx.notify(); + }, + ); } - fn generate_pairing_code(&mut self, cx: &mut Context) { - match crate::hosting::publish_pairing_code(&self.user_id) { - Ok((code, pending)) => { - self.pairing_code = Some((code.display(), pending.expires_ms)); - self.hosts_notice = None; + /// Re-read the controller until a start in flight resolves, so a + /// screen opened during the launch-time start shows the outcome. + fn watch_hosting_start(&self, cx: &mut Context) { + let Some(hosting) = self.hosting.clone() else { + return; + }; + let task = cx.spawn(async move |this, cx| { + loop { + cx.background_executor() + .timer(std::time::Duration::from_millis(500)) + .await; + let status = hosting.status(); + let settled = !matches!(status, HostingStatus::Starting); + let alive = this + .update(cx, |this, cx| { + if settled { + this.hosting_status = status; + cx.notify(); + } + }) + .is_ok(); + if settled || !alive { + return; + } } - Err(message) => self.hosts_notice = Some(message), + }); + crate::ui::task::retain(&self.bridged_tasks, task); + } + + fn load_paired_devices(&self, cx: &mut Context) { + if self.hosting.is_none() { + return; } - cx.notify(); + let user_id = self.user_id.clone(); + self.call( + async move { crate::hosting::list_devices(&user_id) }, + cx, + |this, result, cx| { + match result { + Ok(devices) => this.paired_devices = devices, + Err(message) => this.hosts_notice = Some(message), + } + cx.notify(); + }, + ); } - fn revoke_device(&mut self, key: &str, cx: &mut Context) { - if let Err(message) = crate::hosting::revoke_device(&self.user_id, key) { - self.hosts_notice = Some(message); + /// Publish a code for the running host. Only a listening host can + /// accept it, so the button is inert otherwise. + fn generate_pairing_code(&mut self, cx: &mut Context) { + if !matches!(self.hosting_status, HostingStatus::Listening { .. }) { + return; } - self.paired_devices = crate::hosting::list_devices(&self.user_id).unwrap_or_default(); - cx.notify(); + let user_id = self.user_id.clone(); + self.call( + async move { crate::hosting::publish_pairing_code(&user_id) }, + cx, + |this, result, cx| { + match result { + Ok((code, pending)) => { + this.pairing_code = Some((code.display(), pending.expires_ms)); + this.hosts_notice = None; + this.watch_pairing_code(cx); + } + Err(message) => this.hosts_notice = Some(message), + } + cx.notify(); + }, + ); + } + + /// Keep the shown code honest: it goes when the host consumed it (a + /// device paired, so the list is re-read) or it expired. + fn watch_pairing_code(&self, cx: &mut Context) { + let backend = self.backend.clone(); + let user_id = self.user_id.clone(); + let task = cx.spawn(async move |this, cx| { + loop { + cx.background_executor() + .timer(std::time::Duration::from_secs(2)) + .await; + let user_id = user_id.clone(); + let pending = backend + .spawn(async move { crate::hosting::pending_pairing_code(&user_id) }) + .await; + let valid = matches!(pending, Ok(Ok(Some(_)))); + let keep = this.update(cx, |this, cx| { + if this.pairing_code.is_none() { + return false; + } + if valid { + return true; + } + this.pairing_code = None; + this.load_paired_devices(cx); + cx.notify(); + false + }); + if !matches!(keep, Ok(true)) { + return; + } + } + }); + crate::ui::task::retain(&self.bridged_tasks, task); + } + + fn revoke_device(&mut self, key: &str, cx: &mut Context) { + let user_id = self.user_id.clone(); + let key = key.to_string(); + self.call( + async move { crate::hosting::revoke_device(&user_id, &key) }, + cx, + |this, result, cx| { + if let Err(message) = result { + this.hosts_notice = Some(message); + } + this.load_paired_devices(cx); + }, + ); } /// This machine's host role: the toggle, where it listens, the pairing @@ -980,20 +1096,31 @@ impl SettingsScreen { "Allow remote connections", "Serve this machine's tasks to paired devices on the LAN or a Tailscale \ network. Pairing is the only gate; traffic is end-to-end encrypted.", - self.settings.allow_remote_connections, + self.settings.allow_remote_connections + || matches!(self.hosting_status, HostingStatus::Starting), cx.listener(|this, _event, _window, cx| { this.toggle_remote_connections(cx); }), )); + let listening = matches!(self.hosting_status, HostingStatus::Listening { .. }); let status = match &self.hosting_status { HostingStatus::Off => "Not listening.".to_string(), - HostingStatus::Listening { listen, host_id } => { + HostingStatus::Starting => "Starting\u{2026}".to_string(), + HostingStatus::Listening { + listen, + host_id, + name, + } => { let short: String = host_id.chars().take(10).collect(); - format!( - "Listening on {listen} as \"{}\" (key {short}\u{2026}). Devices reach it at \ - this machine's LAN or Tailscale address and that port.", - crate::env::hostname() - ) + match listen.parse::() { + // Every interface: no single address to show. + Ok(address) if address.ip().is_unspecified() => format!( + "Listening on port {} as \"{name}\" (key {short}\u{2026}). Devices \ + reach it at this machine's LAN or Tailscale address and that port.", + address.port() + ), + _ => format!("Listening on {listen} as \"{name}\" (key {short}\u{2026})."), + } } HostingStatus::Failed(error) => format!("Not listening: {error}"), }; @@ -1003,7 +1130,9 @@ impl SettingsScreen { .text_color(gpui::rgb(theme::text_muted())) .child(status), ); - if self.settings.allow_remote_connections { + if self.settings.allow_remote_connections + || matches!(self.hosting_status, HostingStatus::Starting) + { pane = pane.child( div() .flex() @@ -1011,9 +1140,12 @@ impl SettingsScreen { .gap_3() .child( widgets::secondary_button("generate-pairing-code") - .on_click(cx.listener(|this, _event, _window, cx| { - this.generate_pairing_code(cx); - })) + .when(!listening, |button| button.opacity(0.5)) + .when(listening, |button| { + button.on_click(cx.listener(|this, _event, _window, cx| { + this.generate_pairing_code(cx); + })) + }) .child("Generate pairing code"), ) .when_some(self.pairing_code.as_ref(), |row, (code, _)| { diff --git a/apps/maple-agent/docs/remote-development.md b/apps/maple-agent/docs/remote-development.md index 7449330f5..191f3bf40 100644 --- a/apps/maple-agent/docs/remote-development.md +++ b/apps/maple-agent/docs/remote-development.md @@ -542,6 +542,21 @@ host key, the lock, and, for one account, the device list and the pending code, so only one of them serves at a time; the other reports who holds the root. +The window's host role has four states: off, starting, listening, and +failed. Starting runs on the backend runtime, never the UI thread, and +Settings shows it until it resolves; a stop that arrives meanwhile wins. +The setting persists as on only once the host listens, so a start that +failed (the port taken, another host on the root) does not come back at +the next launch; the failure shows in place. Listening names the host and +the address; when the host binds every interface it shows the port and +says to use the machine's LAN or Tailscale address. "Generate pairing +code" works only while listening, and the code stays on screen until the +host consumed it (the device list is then re-read) or it expired. Device +and pairing files are read and written off the UI thread. `stop` waits +for the listener before releasing the lock, so the next start can bind; +`serve pair` learns whether a host runs by probing that lock, not from a +pid. + ## Host CLI ``` @@ -556,6 +571,18 @@ like `acp`. It logs to the usual log file. The default host name is the machine hostname. The default port is 7130 unless taken; it is not 8080, which the proxy mode uses. +As built: a saved sign-in the server rejects exits with a message to run +`login` again. A server that cannot be reached at start does not: the +host serves with the saved sign-in, requests fail until it goes through, +and the sign-in is retried behind them with growing pauses (5 s up to 5 +min), so a unit that starts before the network recovers on its own. The +host name comes from `gethostname` (`HOSTNAME` in the environment +overrides it; `COMPUTERNAME` on Windows), so macOS hosts are no longer +all called "maple". Session defaults an older version kept in +settings.json are adopted into the account config by every mode that +binds an account, including the window after a sign-in, so a user who +was signed out at the upgrade keeps them. + ## Persistence | Path | Owner | Contents | From 3b5306c77137890512261d53a206e670b9dd8d43 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Sun, 20 Sep 2026 00:31:04 -0500 Subject: [PATCH 20/37] Gather the remote roles under app/src/remote `hosting.rs` (this machine as a host) and `hosts.rs` (this machine as a client) sat beside each other at the top level with names one letter apart. They move to `remote/host.rs` and `remote/client.rs`, and `remote/mod.rs` takes what both roles share: the remote directory, the account-scoped store paths, and the default listen address, which the `serve` command line reads in every build. `serve.rs` keeps the clap definitions, the command, its signals, and `sd_notify`. A move only: no behaviour changes. Call sites update their paths. Co-Authored-By: Claude Fable 5.1 --- apps/maple-agent/app/src/desktop.rs | 8 +- apps/maple-agent/app/src/main.rs | 5 +- .../app/src/{hosts.rs => remote/client.rs} | 2 +- .../app/src/{hosting.rs => remote/host.rs} | 71 +----------- apps/maple-agent/app/src/remote/mod.rs | 103 ++++++++++++++++++ apps/maple-agent/app/src/serve.rs | 17 ++- apps/maple-agent/app/src/ui/settings.rs | 12 +- apps/maple-agent/docs/remote-development.md | 4 +- 8 files changed, 129 insertions(+), 93 deletions(-) rename apps/maple-agent/app/src/{hosts.rs => remote/client.rs} (96%) rename apps/maple-agent/app/src/{hosting.rs => remote/host.rs} (84%) create mode 100644 apps/maple-agent/app/src/remote/mod.rs diff --git a/apps/maple-agent/app/src/desktop.rs b/apps/maple-agent/app/src/desktop.rs index dd4153aea..ad08e3550 100644 --- a/apps/maple-agent/app/src/desktop.rs +++ b/apps/maple-agent/app/src/desktop.rs @@ -37,7 +37,7 @@ struct MapleApp { /// Connections to the account's saved hosts; lives with the chat. hosts: Option>, /// This window's host role for the signed-in account. - hosting: Option>, + hosting: Option>, /// The chat screen is parked while settings is open so Back returns to /// it with its state intact. parked_chat: Option>, @@ -144,7 +144,7 @@ impl MapleApp { if let Some(previous) = self.hosts.take() { previous.shutdown(); } - match crate::hosts::start_manager(&backend, &user_id) { + match crate::remote::client::start_manager(&backend, &user_id) { Ok((manager, mut events)) => { self.hosts = Some(manager); let chat = chat.downgrade(); @@ -173,7 +173,7 @@ impl MapleApp { if let Some(previous) = self.hosting.take() { previous.stop(); } - let hosting = Arc::new(crate::hosting::HostingController::new( + let hosting = Arc::new(crate::remote::host::HostingController::new( backend.clone(), backend.local_host(&user_id), user_id.clone(), @@ -181,7 +181,7 @@ impl MapleApp { if self.settings.allow_remote_connections { // Binds on the backend runtime; the controller reports the // outcome to Settings and the log. - backend.spawn(hosting.start(crate::serve::DEFAULT_LISTEN)); + backend.spawn(hosting.start(crate::remote::DEFAULT_LISTEN)); } self.hosting = Some(hosting); // The release check may have finished while the login screen was diff --git a/apps/maple-agent/app/src/main.rs b/apps/maple-agent/app/src/main.rs index 1c8f69de1..0f56175bd 100644 --- a/apps/maple-agent/app/src/main.rs +++ b/apps/maple-agent/app/src/main.rs @@ -11,16 +11,13 @@ mod billing; #[cfg(feature = "desktop")] mod desktop; mod env; -#[cfg(feature = "serve")] -mod hosting; -#[cfg(feature = "desktop")] -mod hosts; #[cfg(feature = "desktop")] mod keymap; #[cfg(feature = "desktop")] mod notify; #[cfg(feature = "desktop")] mod platform; +mod remote; mod serve; mod settings; #[cfg(feature = "desktop")] diff --git a/apps/maple-agent/app/src/hosts.rs b/apps/maple-agent/app/src/remote/client.rs similarity index 96% rename from apps/maple-agent/app/src/hosts.rs rename to apps/maple-agent/app/src/remote/client.rs index 2562722fa..93b193f3b 100644 --- a/apps/maple-agent/app/src/hosts.rs +++ b/apps/maple-agent/app/src/remote/client.rs @@ -20,7 +20,7 @@ use crate::backend::AgentBackend; /// This device's static key, generated on first use. It sits beside the /// host key: one machine, two roles. pub fn device_key() -> Result { - StaticKey::load_or_create(&crate::hosting::remote_dir()?.join("device_key.json")) + StaticKey::load_or_create(&super::remote_dir()?.join("device_key.json")) } /// The saved hosts of one account, in the account's local data directory diff --git a/apps/maple-agent/app/src/hosting.rs b/apps/maple-agent/app/src/remote/host.rs similarity index 84% rename from apps/maple-agent/app/src/hosting.rs rename to apps/maple-agent/app/src/remote/host.rs index bab95cd5c..1c468c223 100644 --- a/apps/maple-agent/app/src/hosting.rs +++ b/apps/maple-agent/app/src/remote/host.rs @@ -14,58 +14,18 @@ use std::sync::Arc; use std::sync::Mutex; use maple_agent::host::LocalHostBackend; -use maple_remote::devices::{DeviceStore, PairedDevice}; +use maple_remote::devices::PairedDevice; use maple_remote::keys::StaticKey; use maple_remote::net::{HostStores, serve_listener}; -use maple_remote::pairing::{PairingCode, PairingLimiter, PendingPairing, PendingPairingStore}; +use maple_remote::pairing::{PairingCode, PairingLimiter, PendingPairing}; use maple_remote::server::{HostIdentity, HostServer, HostServerConfig}; use maple_remote::wire::HostInfo; use tokio_util::sync::CancellationToken; +use super::{account_remote_dir, device_store, pending_pairing_store, remote_dir}; #[cfg(feature = "desktop")] use crate::backend::AgentBackend; -/// Where this machine keeps its host key and the hosting lock. Created -/// owner-only on first use. -pub fn remote_dir() -> Result { - private_dir(crate::backend::local_data_root().join("remote")) -} - -/// Where the host keeps what belongs to one account: the devices paired -/// into it and the pairing code it currently accepts. Created owner-only -/// on first use. -pub fn account_remote_dir(user_id: &str) -> Result { - private_dir(account_remote_dir_under(&remote_dir()?, user_id)?) -} - -/// Pure path arithmetic behind [`account_remote_dir`]: the account's -/// directory under the machine's remote directory. -fn account_remote_dir_under(remote_dir: &Path, user_id: &str) -> Result { - let scope = maple_agent::maple_api::account_scope(user_id)?; - Ok(remote_dir.join("accounts").join(scope)) -} - -fn private_dir(dir: PathBuf) -> Result { - std::fs::create_dir_all(&dir) - .map_err(|error| format!("cannot create {}: {error}", dir.display()))?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt as _; - let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)); - } - Ok(dir) -} - -/// The devices paired into the account whose directory is `account_dir`. -pub fn device_store(account_dir: &Path) -> DeviceStore { - DeviceStore::new(account_dir.join("devices.json")) -} - -/// The pairing code the account whose directory is `account_dir` accepts. -pub fn pending_pairing_store(account_dir: &Path) -> PendingPairingStore { - PendingPairingStore::new(account_dir.join("pending_pairing.json")) -} - /// What a running host records for `serve pair` to describe. #[derive(serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] @@ -427,31 +387,6 @@ impl Drop for HostingController { mod tests { use super::*; - #[test] - fn devices_and_codes_live_under_the_account() { - let root = Path::new("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/data/remote"); - let a = account_remote_dir_under(root, "user-a").unwrap(); - let b = account_remote_dir_under(root, "user-b").unwrap(); - assert_ne!(a, b, "two accounts never share a device list"); - assert_eq!( - a, - account_remote_dir_under(root, " USER-A ").unwrap(), - "the scope follows the normalized account id" - ); - assert_eq!(a.parent().unwrap().parent().unwrap(), root); - assert_eq!(a.parent().unwrap().file_name().unwrap(), "accounts"); - assert_eq!( - device_store(&a).path(), - a.join("devices.json"), - "the device file sits in the account directory" - ); - assert_eq!( - pending_pairing_store(&a).path(), - a.join("pending_pairing.json") - ); - assert!(account_remote_dir_under(root, " ").is_err()); - } - #[test] fn the_lock_probe_sees_a_held_lock_and_a_free_one() { let dir = std::env::temp_dir().join(format!("maple-hosting-{}", uuid::Uuid::new_v4())); diff --git a/apps/maple-agent/app/src/remote/mod.rs b/apps/maple-agent/app/src/remote/mod.rs new file mode 100644 index 000000000..9944818cb --- /dev/null +++ b/apps/maple-agent/app/src/remote/mod.rs @@ -0,0 +1,103 @@ +//! Remote development: this machine as a host ([`host`]) and as a client +//! of other hosts ([`client`]), plus the files both roles keep under +//! `/remote/`. +//! +//! The machine owns its two static keys and the hosting lock; what belongs +//! to one account (the devices paired into it, the code it accepts, the +//! hosts it saved) lives under that account's scope, so accounts on one +//! machine never see each other's peers. + +#[cfg(feature = "desktop")] +pub mod client; +#[cfg(feature = "serve")] +pub mod host; + +#[cfg(feature = "serve")] +use std::path::{Path, PathBuf}; + +#[cfg(feature = "serve")] +use maple_remote::devices::DeviceStore; +#[cfg(feature = "serve")] +use maple_remote::pairing::PendingPairingStore; + +/// Default listen address for a host. Not 8080, which the proxy mode uses. +/// Read by the `serve` command line in every build, so it lives outside +/// the feature gate. +pub const DEFAULT_LISTEN: &str = "0.0.0.0:7130"; + +/// Where this machine keeps its keys and the hosting lock. Created +/// owner-only on first use. +#[cfg(feature = "serve")] +pub fn remote_dir() -> Result { + private_dir(crate::backend::local_data_root().join("remote")) +} + +/// Where the host keeps what belongs to one account: the devices paired +/// into it and the pairing code it currently accepts. Created owner-only +/// on first use. +#[cfg(feature = "serve")] +pub fn account_remote_dir(user_id: &str) -> Result { + private_dir(account_remote_dir_under(&remote_dir()?, user_id)?) +} + +/// Pure path arithmetic behind [`account_remote_dir`]: the account's +/// directory under the machine's remote directory. +#[cfg(feature = "serve")] +fn account_remote_dir_under(remote_dir: &Path, user_id: &str) -> Result { + let scope = maple_agent::maple_api::account_scope(user_id)?; + Ok(remote_dir.join("accounts").join(scope)) +} + +#[cfg(feature = "serve")] +fn private_dir(dir: PathBuf) -> Result { + std::fs::create_dir_all(&dir) + .map_err(|error| format!("cannot create {}: {error}", dir.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)); + } + Ok(dir) +} + +/// The devices paired into the account whose directory is `account_dir`. +#[cfg(feature = "serve")] +pub fn device_store(account_dir: &Path) -> DeviceStore { + DeviceStore::new(account_dir.join("devices.json")) +} + +/// The pairing code the account whose directory is `account_dir` accepts. +#[cfg(feature = "serve")] +pub fn pending_pairing_store(account_dir: &Path) -> PendingPairingStore { + PendingPairingStore::new(account_dir.join("pending_pairing.json")) +} + +#[cfg(all(test, feature = "serve"))] +mod tests { + use super::*; + + #[test] + fn devices_and_codes_live_under_the_account() { + let root = Path::new("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/data/remote"); + let a = account_remote_dir_under(root, "user-a").unwrap(); + let b = account_remote_dir_under(root, "user-b").unwrap(); + assert_ne!(a, b, "two accounts never share a device list"); + assert_eq!( + a, + account_remote_dir_under(root, " USER-A ").unwrap(), + "the scope follows the normalized account id" + ); + assert_eq!(a.parent().unwrap().parent().unwrap(), root); + assert_eq!(a.parent().unwrap().file_name().unwrap(), "accounts"); + assert_eq!( + device_store(&a).path(), + a.join("devices.json"), + "the device file sits in the account directory" + ); + assert_eq!( + pending_pairing_store(&a).path(), + a.join("pending_pairing.json") + ); + assert!(account_remote_dir_under(root, " ").is_err()); + } +} diff --git a/apps/maple-agent/app/src/serve.rs b/apps/maple-agent/app/src/serve.rs index 368633d0d..f0d4e1a29 100644 --- a/apps/maple-agent/app/src/serve.rs +++ b/apps/maple-agent/app/src/serve.rs @@ -9,8 +9,7 @@ use clap::{Args, Subcommand}; -/// Default port. Not 8080, which the proxy mode uses. -pub const DEFAULT_LISTEN: &str = "0.0.0.0:7130"; +use crate::remote::DEFAULT_LISTEN; #[derive(Debug, Clone, PartialEq, Eq, Args)] pub struct ServeArgs { @@ -58,7 +57,7 @@ mod enabled { use super::{DevicesCommand, ServeArgs, ServeCommand}; use crate::backend::{AgentBackend, RestoreOutcome}; - use crate::hosting::{self, Hosting}; + use crate::remote::host::{self, Hosting}; pub fn run(args: ServeArgs) -> Result<(), String> { let ServeArgs { @@ -160,10 +159,10 @@ mod enabled { } }; crate::adopt_legacy_session_defaults(&backend, &user_id); - let host = backend.local_host(&user_id); + let local_host = backend.local_host(&user_id); let name = name.unwrap_or_else(crate::env::hostname); let runtime = backend.runtime_handle(); - let hosting = runtime.block_on(Hosting::start(host, &user_id, listen, name))?; + let hosting = runtime.block_on(Hosting::start(local_host, &user_id, listen, name))?; eprintln!( "Serving as host \"{}\" ({}) on {}.", hosting.name, hosting.host_id, hosting.listen @@ -237,14 +236,14 @@ mod enabled { } fn publish_code(user_id: &str) -> Result<(), String> { - let (code, _pending) = hosting::publish_pairing_code(user_id)?; + let (code, _pending) = host::publish_pairing_code(user_id)?; // The code goes to stdout so it can be piped; guidance to stderr. println!("{}", code.display()); eprintln!( "Pairing code published. It admits one device and expires in {} minutes.", maple_remote::pairing::CODE_TTL.as_secs() / 60 ); - match hosting::read_state(&hosting::remote_dir()?) { + match host::read_state(&crate::remote::remote_dir()?) { Some(state) => eprintln!( "In the Maple app, add host \"{}\" at {} and enter the code.", state.name, state.listen @@ -258,7 +257,7 @@ mod enabled { } fn list_devices(user_id: &str) -> Result<(), String> { - let devices = hosting::list_devices(user_id)?; + let devices = host::list_devices(user_id)?; if devices.is_empty() { eprintln!("No paired devices. Publish a code with `maple-gpui serve pair`."); return Ok(()); @@ -275,7 +274,7 @@ mod enabled { } fn revoke_device(user_id: &str, device: &str) -> Result<(), String> { - let removed = hosting::revoke_device(user_id, device)?; + let removed = host::revoke_device(user_id, device)?; eprintln!( "Revoked {} ({}). A live connection from it ends within seconds.", removed.name, removed.public_key diff --git a/apps/maple-agent/app/src/ui/settings.rs b/apps/maple-agent/app/src/ui/settings.rs index 3ccbc2990..01b5965e9 100644 --- a/apps/maple-agent/app/src/ui/settings.rs +++ b/apps/maple-agent/app/src/ui/settings.rs @@ -19,7 +19,7 @@ use crate::ui::icons::icon; use crate::ui::text_input::TextInput; use crate::backend::AgentBackend; -use crate::hosting::{HostingController, HostingStatus}; +use crate::remote::host::{HostingController, HostingStatus}; use crate::settings::{self, AppSettings, PermissionMode}; use crate::shortcuts::{ ShortcutConflict, ShortcutConflictKind, ShortcutContextOverlap, ShortcutOverrides, @@ -950,7 +950,7 @@ impl SettingsScreen { self.hosting_status = HostingStatus::Starting; cx.notify(); self.call( - async move { Ok(hosting.start(crate::serve::DEFAULT_LISTEN).await) }, + async move { Ok(hosting.start(crate::remote::DEFAULT_LISTEN).await) }, cx, |this, result, cx| { let status = result.unwrap_or_else(HostingStatus::Failed); @@ -998,7 +998,7 @@ impl SettingsScreen { } let user_id = self.user_id.clone(); self.call( - async move { crate::hosting::list_devices(&user_id) }, + async move { crate::remote::host::list_devices(&user_id) }, cx, |this, result, cx| { match result { @@ -1018,7 +1018,7 @@ impl SettingsScreen { } let user_id = self.user_id.clone(); self.call( - async move { crate::hosting::publish_pairing_code(&user_id) }, + async move { crate::remote::host::publish_pairing_code(&user_id) }, cx, |this, result, cx| { match result { @@ -1046,7 +1046,7 @@ impl SettingsScreen { .await; let user_id = user_id.clone(); let pending = backend - .spawn(async move { crate::hosting::pending_pairing_code(&user_id) }) + .spawn(async move { crate::remote::host::pending_pairing_code(&user_id) }) .await; let valid = matches!(pending, Ok(Ok(Some(_)))); let keep = this.update(cx, |this, cx| { @@ -1073,7 +1073,7 @@ impl SettingsScreen { let user_id = self.user_id.clone(); let key = key.to_string(); self.call( - async move { crate::hosting::revoke_device(&user_id, &key) }, + async move { crate::remote::host::revoke_device(&user_id, &key) }, cx, |this, result, cx| { if let Err(message) = result { diff --git a/apps/maple-agent/docs/remote-development.md b/apps/maple-agent/docs/remote-development.md index 191f3bf40..786a38e3b 100644 --- a/apps/maple-agent/docs/remote-development.md +++ b/apps/maple-agent/docs/remote-development.md @@ -531,7 +531,9 @@ code, and paired devices with revoke. ### Desktop hosting -As built: `app/src/hosting.rs` holds the shared host role. `Hosting::start` +As built: `app/src/remote/` holds both roles: `host.rs` the shared host +role, `client.rs` the connection manager for saved hosts, and `mod.rs` +the files both keep under `/remote/`. `Hosting::start` takes the data-root lock, binds, and serves the account's local host on the backend runtime; the `serve` command runs it in the foreground, and the window runs it behind the "Allow remote connections" setting, off by From 4daa510de5cacf080fdd19379ca45738f054b348 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Sun, 20 Sep 2026 00:07:02 -0500 Subject: [PATCH 21/37] Route task calls by host and settle host state The screen's `host` field had two writers: selecting a task pointed it at that task's backend, and choosing a target pointed it at the target's, so a call about the selected task after the target moved reached the wrong host. `host` now means one thing, the target's backend; calls about a task go through the owner lookup, archiving a project archives each task on its own host, and signing out stops the local runtime. An offline host could become the target through the sidebar filter, the sidebar and screen disagreed once a filtered host dropped, a late bootstrap could resurrect an offline host's tasks, a failed bootstrap of the remembered host left startup waiting, per-host caches went stale between switches, the offline notice repeated on every retry, and the title flipped to "New Task" when the selected task's host dropped. Each is fixed, with a connection counter that drops stale answers and write-through caches. Host lists and labels are built when hosts change instead of per frame, the sidebar answers project trust through the screen instead of its own backend, and events file a session under the host they came from before the upsert. Co-Authored-By: Claude Fable 5.1 --- apps/maple-agent/app/src/ui/chat/composer.rs | 24 +- apps/maple-agent/app/src/ui/chat/dialogs.rs | 17 +- apps/maple-agent/app/src/ui/chat/mod.rs | 599 ++++++++++++------- apps/maple-agent/app/src/ui/chat/sidebar.rs | 96 +-- apps/maple-agent/app/src/ui/chat/tests.rs | 350 ++++++++++- 5 files changed, 782 insertions(+), 304 deletions(-) diff --git a/apps/maple-agent/app/src/ui/chat/composer.rs b/apps/maple-agent/app/src/ui/chat/composer.rs index a71377418..9e25efba8 100644 --- a/apps/maple-agent/app/src/ui/chat/composer.rs +++ b/apps/maple-agent/app/src/ui/chat/composer.rs @@ -63,7 +63,11 @@ impl ChatScreen { frame .flex_none() .child(status_dot(online)) - .child(div().whitespace_nowrap().child(self.target_host_name())) + .child( + div() + .whitespace_nowrap() + .child(self.target_host_label.clone()), + ) .child(icon("chevron-down", px(14.), color)) .on_mouse_down(gpui::MouseButton::Left, |_event, _window, cx| { cx.stop_propagation(); @@ -1310,12 +1314,13 @@ impl ChatScreen { .text_color(gpui::rgb(theme::text_muted())) .child("NEW TASKS RUN ON"), ); - for (id, name, online) in self.host_choices() { - let current = id == self.target_host; - let pick = id.clone(); + for host in &self.host_list { + let current = host.id == self.target_host; + let online = host.online; + let pick = host.id.clone(); menu = menu.child( div() - .id(SharedString::from(format!("host-menu-{id}"))) + .id(SharedString::from(format!("host-menu-{}", host.id))) .flex() .items_center() .gap_2() @@ -1338,7 +1343,7 @@ impl ChatScreen { .min_w_0() .line_clamp(1) .text_ellipsis() - .child(name), + .child(host.name.clone()), ) .when(!online, |row| { row.child( @@ -1385,7 +1390,7 @@ impl ChatScreen { /// box, the rows it matched, and the keys that drive it. pub(super) fn render_project_picker(&self, cx: &mut Context) -> gpui::Stateful
{ let picker = self.project_picker.as_ref(); - let rows: Vec = picker.map(|picker| picker.rows.clone()).unwrap_or_default(); + let rows: &[PickerRow] = picker.map(|picker| picker.rows.as_slice()).unwrap_or(&[]); let selected = picker.map(|picker| picker.selected).unwrap_or(0); let searching = picker.is_some_and(|picker| !picker.query.trim().is_empty()); let mut list = div() @@ -1535,9 +1540,12 @@ impl ChatScreen { ) .child( div() + .flex() + .gap_1() .text_sm() .text_color(gpui::rgb(theme::text_muted())) - .child(format!("on {}", self.target_host_name())), + .child("on") + .child(self.target_host_label.clone()), ), ) .when_some(self.root_input.clone(), |col, input| { diff --git a/apps/maple-agent/app/src/ui/chat/dialogs.rs b/apps/maple-agent/app/src/ui/chat/dialogs.rs index aa0163558..56b258266 100644 --- a/apps/maple-agent/app/src/ui/chat/dialogs.rs +++ b/apps/maple-agent/app/src/ui/chat/dialogs.rs @@ -3,8 +3,11 @@ //! confirmation, archiving, and leaving a task. They touch the canonical //! session list and the project context, which live here. +use std::sync::Arc; + use gpui::{Context, Div, div, prelude::*, px}; use maple_agent::agent::AgentProjectTrustStatus; +use maple_agent::host::HostBackend; use super::ChatScreen; use crate::ui::theme; @@ -388,6 +391,9 @@ impl ChatScreen { cx.notify(); return; } + // The project leaves the target host's list; each task under it + // archives on the host that owns it, since the same path may hold + // tasks of several hosts. let host = self.host.clone(); let path = root.to_string(); let fallback = self @@ -407,13 +413,17 @@ impl ChatScreen { .filter(|s| !s.archived && s.project_root == root) .map(|s| s.id.clone()) .collect(); + let archives: Vec<(Arc, String)> = task_ids + .iter() + .map(|id| (self.backend_for(id), id.clone())) + .collect(); let removed = path.clone(); let next_root = fallback.clone(); - let removed_task_ids = task_ids.clone(); + let removed_task_ids = task_ids; self.call( async move { - for id in task_ids { - host.set_session_archived(id.clone(), true).await?; + for (owner, id) in archives { + owner.set_session_archived(id, true).await?; } host.remove_project_root(path, fallback).await }, @@ -422,6 +432,7 @@ impl ChatScreen { match result { Ok(()) => { this.recent_roots.retain(|candidate| candidate != &removed); + this.cache_target_context(); for session in &mut this.sessions { if session.project_root == removed { session.archived = true; diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index 49fbd762b..45d2908a3 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -128,35 +128,42 @@ pub(super) struct ProjectPicker { /// One host the chat screen shows tasks from: the local host always, and /// each remote host as it connects or as a saved entry waiting to. -pub(crate) struct ChatHost { +pub(super) struct ChatHost { /// Absent for a saved host that is not connected. - pub(crate) backend: Option>, - pub(crate) name: String, - pub(crate) online: bool, - /// What the host's bootstrap reported, adopted when it becomes the - /// target of new tasks. - project_root: Option, - recent_roots: Vec, - session_defaults: Option, + pub(super) backend: Option>, + pub(super) name: String, + pub(super) online: bool, + /// Bumped on every connection state change. A call made on one + /// connection whose answer lands on another is dropped: the host was + /// re-read when it came back. + pub(super) connection: u64, + /// The host's project context, as its bootstrap reported it and as + /// the user changed it while the host was the target; adopted again + /// when the host becomes the target. + pub(super) project_root: Option, + pub(super) recent_roots: Vec, + pub(super) session_defaults: Option, } impl ChatHost { - fn local(backend: Arc) -> Self { + pub(super) fn local(backend: Arc) -> Self { Self { backend: Some(backend), - name: "This computer".to_string(), + name: LOCAL_HOST_NAME.to_string(), online: true, + connection: 0, project_root: None, recent_roots: Vec::new(), session_defaults: None, } } - fn saved(name: String) -> Self { + pub(super) fn saved(name: String) -> Self { Self { backend: None, name, online: false, + connection: 0, project_root: None, recent_roots: Vec::new(), session_defaults: None, @@ -164,6 +171,15 @@ impl ChatHost { } } +/// What reading a freshly connected host produced. +struct RemoteBootstrap { + boot: HostBootstrap, + /// Why its runtime did not start, if it did not. + start_error: Option, + /// Stored tool summaries of its latest task, when that task will open. + summaries: HashMap, +} + /// Emitted when the user opens app settings from the chat header. pub struct OpenSettings; @@ -186,6 +202,8 @@ const SIDEBAR_COLLAPSED_INSET: gpui::Pixels = px(220.); const CONTENT_WIDTH: gpui::Pixels = px(900.); /// Header title when no task is selected. const DEFAULT_TASK_TITLE: &str = "New Task"; +/// What the local host is called wherever hosts are listed. +pub(super) const LOCAL_HOST_NAME: &str = "This computer"; /// Recent projects the project menu lists above "New project…". pub(super) const ROOT_MENU_RECENTS: usize = 6; @@ -252,7 +270,9 @@ pub(crate) struct SpeechState { pub struct ChatScreen { /// Account-level calls: sign-out, billing, audio. backend: Arc, - /// The host of the selected task, and of new tasks: the target. + /// The target host's backend: where new tasks go and whose project + /// context the header shows. Calls about a task go to the host that + /// owns it, through [`Self::backend_for`] or [`Self::session_backend`]. host: Arc, /// Every host this screen knows, keyed by host id. hosts: HashMap, @@ -263,8 +283,15 @@ pub struct ChatScreen { target_host: HostId, /// The sidebar's host filter, so a task selection does not override it. host_filter: Option, - /// Host whose events are being applied, for the length of one batch. - event_host: Option, + /// Every known host, local first, then by name: what the sidebar + /// lists, the header chip offers, and Settings points at. Rebuilt + /// when `hosts` changes, not per frame. + host_list: Vec, + /// `host_list` or `session_hosts` changed since the sidebar last saw + /// them; the next sync pushes both. + hosts_dirty: bool, + /// The target host's name, for the header chip and the picker. + target_host_label: SharedString, user_id: String, /// The task list; its own entity so it renders only when it changes. sidebar: Entity, @@ -637,6 +664,7 @@ impl ChatScreen { } => self.set_session_archived(&session_id, archived, cx), SidebarEvent::RemoveRoot(root) => self.request_remove_root(&root, cx), SidebarEvent::SetTrust { path, trusted } => self.set_project_trust(path, trusted, cx), + SidebarEvent::ProjectTrust(root) => self.answer_project_trust(root, cx), SidebarEvent::ChooseProject => self.choose_root_dialog(cx), SidebarEvent::Notice(message) => { self.notice = Some(message); @@ -655,14 +683,18 @@ impl ChatScreen { let running: HashSet = self.active_runs.keys().cloned().collect(); let unread = self.completed_unread_sessions.clone(); let roots = self.recent_roots.clone(); - let hosts = self.sidebar_hosts(); - let session_hosts = self.session_hosts.clone(); + // The host list and the task-to-host map change rarely; they are + // pushed only when they did. + let hosts = std::mem::take(&mut self.hosts_dirty) + .then(|| (self.host_list.clone(), self.session_hosts.clone())); self.sidebar.update(cx, |sidebar, cx| { sidebar.set_sessions(sessions, cx); sidebar.set_selected(selected, cx); sidebar.set_activity(running, unread, cx); sidebar.set_recent_roots(roots, cx); - sidebar.set_hosts(hosts, session_hosts, cx); + if let Some((hosts, session_hosts)) = hosts { + sidebar.set_hosts(hosts, session_hosts, cx); + } }); self.refresh_selected_title(); } @@ -959,7 +991,7 @@ impl ChatScreen { ) -> Self { let settings = crate::settings::load_settings(); let weak = cx.entity().downgrade(); - let sidebar = cx.new(|cx| Sidebar::new(backend.clone(), host.clone(), weak, &settings, cx)); + let sidebar = cx.new(|cx| Sidebar::new(backend.clone(), weak, &settings, cx)); cx.subscribe(&sidebar, Self::on_sidebar_event).detach(); let hosts = HashMap::from([(HostId::local(), ChatHost::local(host.clone()))]); let restore_host = settings @@ -967,14 +999,16 @@ impl ChatScreen { .as_deref() .map(HostId::new) .filter(|host| !host.is_local()); - Self { + let mut this = Self { backend, host, hosts, session_hosts: HashMap::new(), target_host: HostId::local(), host_filter: None, - event_host: None, + host_list: Vec::new(), + hosts_dirty: false, + target_host_label: LOCAL_HOST_NAME.into(), user_id, sidebar, sessions: Vec::new(), @@ -1114,7 +1148,9 @@ impl ChatScreen { speech_generation: 0, tts_voice: settings.tts_voice, tts_speed: settings.tts_speed, - } + }; + this.hosts_changed(); + this } fn call( @@ -1272,12 +1308,19 @@ impl ChatScreen { ); } + /// Re-read the target host's recent projects. An answer that lands + /// after the target moved on belongs to the previous host and is + /// dropped: the new target's list was requested with it. fn refresh_roots(&self, cx: &mut Context) { let host = self.host.clone(); + let target = self.target_host.clone(); self.call( async move { host.recent_project_roots().await }, cx, - |this, result, cx| { + move |this, result, cx| { + if this.target_host != target { + return; + } if let Ok(roots) = result { this.apply_recent_roots(roots, cx); } @@ -1293,9 +1336,20 @@ impl ChatScreen { cx: &mut Context, ) { self.recent_roots = roots.into_iter().map(|root| root.path).collect(); + self.cache_target_context(); self.sync_sidebar(cx); } + /// Remember the visible project context as the target host's, so it + /// is what comes back when that host is the target again. + fn cache_target_context(&mut self) { + let Some(entry) = self.hosts.get_mut(&self.target_host) else { + return; + }; + entry.project_root.clone_from(&self.project_root); + entry.recent_roots.clone_from(&self.recent_roots); + } + /// Select the project context for new tasks without disturbing work that /// is already running in any session. fn select_project_root(&mut self, path: String, cx: &mut Context) { @@ -1316,7 +1370,6 @@ impl ChatScreen { // this point advances the generation and wins over the callback. let selection_generation = self.selection_generation; self.root_menu_open = false; - self.root_input = None; self.notice = None; cx.notify(); let host = self.host.clone(); @@ -1385,6 +1438,7 @@ impl ChatScreen { return false; } self.project_root = project_root; + self.cache_target_context(); self.project_root_changed(cx); self.check_project_trust(cx); self.refresh_slash_commands(cx); @@ -1563,12 +1617,12 @@ impl ChatScreen { }); cx.observe(&input, |this, input, cx| { let query = input.read(cx).text(); - // An arrow filled this text in; the rows already show it. - if this - .project_picker - .as_ref() - .is_some_and(|picker| picker.filled.as_deref() == Some(query.as_str())) - { + // The box reports every change, not only to its text. The + // rows already show this text when it is the query they + // were built from, or the path an arrow filled in. + if this.project_picker.as_ref().is_some_and(|picker| { + picker.query == query || picker.filled.as_deref() == Some(query.as_str()) + }) { return; } this.refresh_project_picker(query, cx); @@ -1609,6 +1663,7 @@ impl ChatScreen { picker.query = query.clone(); picker.typed = query.clone(); picker.filled = None; + picker.selected = 0; picker.generation += 1; let generation = picker.generation; self.rebuild_picker_rows(); @@ -1739,32 +1794,12 @@ impl ChatScreen { } } - /// The name of the host the picker and the host chip speak for. - pub(super) fn target_host_name(&self) -> String { - self.host_name(&self.target_host) - } - pub(super) fn target_host_online(&self) -> bool { self.hosts .get(&self.target_host) .is_some_and(|entry| entry.online) } - /// Hosts for the header chip's dropdown: local first, then by name. - pub(super) fn host_choices(&self) -> Vec<(HostId, String, bool)> { - let mut hosts: Vec<(HostId, String, bool)> = self - .hosts - .iter() - .map(|(id, entry)| (id.clone(), entry.name.clone(), entry.online)) - .collect(); - hosts.sort_by(|a, b| { - b.0.is_local() - .cmp(&a.0.is_local()) - .then_with(|| a.1.cmp(&b.1)) - }); - hosts - } - pub(super) fn toggle_host_menu(&mut self, cx: &mut Context) { self.root_menu_open = false; self.models_menu_open = false; @@ -1778,14 +1813,10 @@ impl ChatScreen { /// the project context follows that host. pub(super) fn pick_host(&mut self, host: HostId, cx: &mut Context) { self.host_menu_open = false; - if !self.hosts.get(&host).is_some_and(|entry| entry.online) { - self.notice = Some(format!("{} is offline", self.host_name(&host)).into()); - cx.notify(); - return; - } let changed = host != self.target_host; - self.set_target_host(host, cx); - if changed { + if !self.set_target_host(host.clone(), cx) { + self.notice = Some(format!("{} is offline", self.host_name(&host)).into()); + } else if changed { self.adopt_target_host_context(cx); } cx.notify(); @@ -1852,23 +1883,39 @@ impl ChatScreen { continue; }; let id = id.clone(); + let connection = entry.connection; self.call( async move { backend.list_sessions(None).await }, cx, move |this, result, cx| { - match result { - Ok(sessions) if id.is_local() => { - this.apply_session_list(sessions, generation, cx) - } - Ok(sessions) => this.apply_host_session_list(&id, sessions, cx), - Err(message) => this.notice = Some(message.into()), - } - cx.notify(); + this.apply_listed_sessions(&id, connection, generation, result, cx) }, ); } } + /// One host answered `refresh_sessions`. A list from a connection that + /// has since changed is stale: the host's tasks left with it, or its + /// new connection lists them again. + fn apply_listed_sessions( + &mut self, + host: &HostId, + connection: u64, + generation: u64, + result: Result, String>, + cx: &mut Context, + ) { + if !self.on_connection(host, connection) { + return; + } + match result { + Ok(sessions) if host.is_local() => self.apply_session_list(sessions, generation, cx), + Ok(sessions) => self.apply_host_session_list(host, sessions, cx), + Err(message) => self.notice = Some(message.into()), + } + cx.notify(); + } + /// Replace one host's tasks in the merged list. A task the list names /// leaves whatever host it was filed under: the list is the truth /// about where it lives, and one row per task is the invariant. @@ -1879,22 +1926,40 @@ impl ChatScreen { cx: &mut Context, ) { let listed: HashSet<&str> = sessions.iter().map(|session| session.id.as_str()).collect(); + let local = HostId::local(); let session_hosts = &self.session_hosts; self.sessions.retain(|session| { !listed.contains(session.id.as_str()) - && session_hosts.get(&session.id).unwrap_or(&HostId::local()) != host + && session_hosts.get(&session.id).unwrap_or(&local) != host + }); + // A task the host no longer lists is gone from it; the task on + // screen keeps its mapping so its calls still know where to go. + let selected = self.selected_session.clone(); + self.session_hosts.retain(|id, owner| { + owner != host || listed.contains(id.as_str()) || selected.as_deref() == Some(id) }); for session in &sessions { self.session_hosts.insert(session.id.clone(), host.clone()); } + self.hosts_dirty = true; self.sessions.extend(sessions); self.sessions .sort_by_key(|session| std::cmp::Reverse(session.updated_ms)); self.sync_sidebar(cx); } - /// The hosts as the sidebar lists them: local first, then by name. - fn sidebar_hosts(&self) -> Vec { + /// File `session_id` under `host` unless it has a host already. + fn file_session(&mut self, session_id: &str, host: &HostId) { + if self.session_hosts.contains_key(session_id) { + return; + } + self.session_hosts + .insert(session_id.to_string(), host.clone()); + self.hosts_dirty = true; + } + + /// Every known host, local first, then by name. + fn sorted_hosts(&self) -> Vec { let mut hosts: Vec = self .hosts .iter() @@ -1912,6 +1977,18 @@ impl ChatScreen { hosts } + /// `hosts` changed (a host came, went, or was renamed): rebuild what + /// renders from it. + fn hosts_changed(&mut self) { + self.host_list = self.sorted_hosts(); + self.hosts_dirty = true; + self.refresh_target_host_label(); + } + + fn refresh_target_host_label(&mut self) { + self.target_host_label = SharedString::from(self.host_name(&self.target_host)); + } + fn host_name(&self, id: &HostId) -> String { self.hosts .get(id) @@ -1946,6 +2023,22 @@ impl ChatScreen { } } + /// The sidebar opened a project's menu and asks whether the project is + /// trusted, on the target host; the answer goes back to the menu. + fn answer_project_trust(&mut self, root: String, cx: &mut Context) { + let host = self.host.clone(); + self.call( + async move { host.project_trust(root).await }, + cx, + |this, result, cx| { + if let Ok(status) = result { + this.sidebar + .update(cx, |sidebar, cx| sidebar.set_menu_trust(status, cx)); + } + }, + ); + } + /// Rename a task on the host that owns it. fn rename_session(&mut self, session_id: String, name: String, cx: &mut Context) { let host = self.backend_for(&session_id); @@ -1964,93 +2057,127 @@ impl ChatScreen { ); } - /// Point `host` at the backend that owns `session_id`, so the load and - /// every call for the selected task go to the right host. A task whose - /// host is offline keeps the previous backend; its calls report the - /// host as gone. - fn use_host_of(&mut self, session_id: &str) { - let owner = self.host_of(session_id); - if let Some(backend) = self - .hosts - .get(&owner) - .and_then(|entry| entry.backend.clone()) - { - self.host = backend; - } - } - /// Make `host` the target of new tasks. The selected task's host is - /// the target unless the sidebar filters on one. - fn set_target_host(&mut self, host: HostId, cx: &mut Context) { + /// the target unless the sidebar filters on one. Returns whether + /// `host` is the target: an offline or unknown host cannot take new + /// tasks and is refused. + fn set_target_host(&mut self, host: HostId, cx: &mut Context) -> bool { if self.target_host == host { - return; + return true; } - let Some(entry) = self.hosts.get(&host) else { - return; - }; - let Some(backend) = entry.backend.clone() else { - return; + let Some((backend, recent_roots, defaults)) = self + .hosts + .get(&host) + .filter(|entry| entry.online) + .and_then(|entry| { + Some(( + entry.backend.clone()?, + entry.recent_roots.clone(), + entry.session_defaults.clone(), + )) + }) + else { + return false; }; self.target_host = host; self.host = backend; self.host_menu_open = false; - self.recent_roots = entry.recent_roots.clone(); + self.refresh_target_host_label(); + self.recent_roots = recent_roots; + // New tasks take this host's defaults (web access, permission + // mode), whichever task is on screen. + if let Some(defaults) = defaults { + self.apply_session_defaults(&defaults, cx); + } self.refresh_roots(cx); self.refresh_slash_commands(cx); self.sync_sidebar(cx); + true } - /// Show the target host's saved project and defaults, for when the - /// target changed without a task selection. + /// Show the target host's saved project, for when the target changed + /// without a task selection. fn adopt_target_host_context(&mut self, cx: &mut Context) { - let Some(entry) = self.hosts.get(&self.target_host) else { + let Some(root) = self + .hosts + .get(&self.target_host) + .map(|entry| entry.project_root.clone()) + else { return; }; - let root = entry.project_root.clone(); - let defaults = entry.session_defaults.clone(); self.set_project_context(root, cx); - if let Some(defaults) = defaults { - self.apply_session_defaults(&defaults, cx); - } } - /// The sidebar filters on `host` (or on none): new tasks go there. + /// The sidebar filters on `host` (or on none): new tasks go there. A + /// filter on an offline host is refused, since it could not take them; + /// the sidebar then shows the filter that stands. fn set_host_filter(&mut self, host: Option, cx: &mut Context) { - self.host_filter = host.clone(); - let target = host.unwrap_or_else(|| { - self.selected_session + let target = match &host { + Some(host) => host.clone(), + None => self + .selected_session .as_deref() .map(|id| self.host_of(id)) - .unwrap_or_else(HostId::local) - }); + .filter(|owner| self.hosts.get(owner).is_some_and(|entry| entry.online)) + .unwrap_or_else(HostId::local), + }; let changed = target != self.target_host; - self.set_target_host(target, cx); + if !self.set_target_host(target.clone(), cx) { + self.notice = Some(format!("{} is offline", self.host_name(&target)).into()); + self.sync_host_filter(cx); + cx.notify(); + return; + } + self.host_filter = host; if changed { self.adopt_target_host_context(cx); } cx.notify(); } + /// Push the host filter to the sidebar, which shows it. + fn sync_host_filter(&mut self, cx: &mut Context) { + let filter = self.host_filter.clone(); + self.sidebar + .update(cx, |sidebar, cx| sidebar.show_host_filter(filter, cx)); + } + + /// `host` cannot be filtered on any more (offline or removed): a + /// filter naming it goes, on screen and in the sidebar. + fn clear_host_filter_for(&mut self, host: &HostId, cx: &mut Context) { + if self.host_filter.as_ref() != Some(host) { + return; + } + self.host_filter = None; + self.sync_host_filter(cx); + } + + /// `host` dropped while it was the target: new tasks go to the local + /// host, and with no task on screen the header shows its project. + fn fall_back_to_local_host(&mut self, host: &HostId, cx: &mut Context) { + self.clear_host_filter_for(host, cx); + if self.target_host != *host { + return; + } + self.set_target_host(HostId::local(), cx); + if self.selected_session.is_none() { + self.adopt_target_host_context(cx); + } + } + /// Hosts a settings screen can point at: every connected one. pub(crate) fn connected_hosts(&self) -> Vec { - let mut hosts: Vec = self - .hosts + self.host_list .iter() - .filter(|(_, entry)| entry.online) - .filter_map(|(id, entry)| { + .filter(|host| host.online) + .filter_map(|host| { Some(crate::ui::settings::SettingsHost { - id: id.clone(), - name: entry.name.clone(), - backend: entry.backend.clone()?, + id: host.id.clone(), + name: host.name.to_string(), + backend: self.hosts.get(&host.id)?.backend.clone()?, }) }) - .collect(); - hosts.sort_by(|a, b| { - b.id.is_local() - .cmp(&a.id.is_local()) - .then_with(|| a.name.cmp(&b.name)) - }); - hosts + .collect() } /// The saved host list changed: list saved hosts that are not @@ -2070,10 +2197,7 @@ impl ChatScreen { for id in removed { self.drop_host_sessions(&id); self.hosts.remove(&id); - if self.target_host == id { - self.host_filter = None; - self.set_target_host(HostId::local(), cx); - } + self.fall_back_to_local_host(&id, cx); } // A remembered host that is not saved any more is not coming back. if let Some(host) = self @@ -2092,6 +2216,7 @@ impl ChatScreen { } } } + self.hosts_changed(); self.sync_sidebar(cx); cx.notify(); } @@ -2112,30 +2237,27 @@ impl ChatScreen { .hosts .entry(host.clone()) .or_insert_with(|| ChatHost::saved(name.clone())); + let was_online = entry.online; entry.name = name; entry.online = online; + entry.connection += 1; + let connection = entry.connection; if let Some(backend) = backend { entry.backend = Some(backend); } + self.hosts_changed(); if online { - self.bootstrap_remote_host(host, cx); + self.bootstrap_remote_host(host, connection, cx); } else { self.drop_host_sessions(&host); - if self.target_host == host { - self.host_filter = None; - self.set_target_host(HostId::local(), cx); - } - if matches!(status, HostStatus::Offline { .. }) { + self.fall_back_to_local_host(&host, cx); + if let HostStatus::Offline { reason } = status { self.give_up_restore(&host, cx); - } - if let HostStatus::Offline { reason } = status - && reason != "removed" - && self - .hosts - .get(&host) - .is_some_and(|entry| entry.backend.is_some()) - { - self.notice = Some(format!("{}: {reason}", self.host_name(&host)).into()); + // The drop itself is news; the reconnect attempts that + // follow report the same thing until the host is back. + if was_online && reason != "removed" { + self.notice = Some(format!("{}: {reason}", self.host_name(&host)).into()); + } } self.sync_sidebar(cx); } @@ -2146,7 +2268,7 @@ impl ChatScreen { /// keeps its host mapping so its screen stays coherent. fn drop_host_sessions(&mut self, host: &HostId) { let selected = self.selected_session.clone(); - let gone: Vec = self + let gone: HashSet = self .session_hosts .iter() .filter(|(_, owner)| *owner == host) @@ -2158,13 +2280,15 @@ impl ChatScreen { self.completed_unread_sessions.remove(id); if selected.as_deref() != Some(id.as_str()) { self.session_hosts.remove(id); + self.hosts_dirty = true; } } } /// Read a freshly connected host: its tasks, roots, and defaults, and - /// start its runtime so it can run them. - fn bootstrap_remote_host(&mut self, host: HostId, cx: &mut Context) { + /// start its runtime so it can run them. `connection` names the + /// connection the read is for; an answer from an earlier one is stale. + fn bootstrap_remote_host(&mut self, host: HostId, connection: u64, cx: &mut Context) { let Some(backend) = self .hosts .get(&host) @@ -2190,24 +2314,54 @@ impl ChatScreen { }), None => HashMap::new(), }; - Ok::<_, String>((boot, start_error, summaries)) + Ok::<_, String>(RemoteBootstrap { + boot, + start_error, + summaries, + }) }, cx, - move |this, result, cx| { - match result { - Ok((boot, start_error, summaries)) => { - this.apply_remote_bootstrap(target, boot, start_error, summaries, cx); - } - Err(message) => { - this.notice = - Some(format!("{}: {message}", this.host_name(&target)).into()); - } - } - cx.notify(); - }, + move |this, result, cx| this.finish_remote_bootstrap(target, connection, result, cx), ); } + /// Whether `host` is still on the connection a call was made on. + fn on_connection(&self, host: &HostId, connection: u64) -> bool { + self.hosts + .get(host) + .is_some_and(|entry| entry.connection == connection) + } + + /// The bootstrap of `target` came back. A failure releases a startup + /// held for that host: it will not open its task. An answer from a + /// connection that has since dropped or been replaced is stale: the + /// host's tasks left with it, or its new connection reads it afresh. + fn finish_remote_bootstrap( + &mut self, + target: HostId, + connection: u64, + result: Result, + cx: &mut Context, + ) { + if !self.on_connection(&target, connection) { + return; + } + match result { + Ok(RemoteBootstrap { + boot, + start_error, + summaries, + }) => { + self.apply_remote_bootstrap(target, boot, start_error, summaries, cx); + } + Err(message) => { + self.notice = Some(format!("{}: {message}", self.host_name(&target)).into()); + self.give_up_restore(&target, cx); + } + } + cx.notify(); + } + /// A remote host answered its bootstrap. When it is the host the last /// new task ran on, it becomes the target again and its latest task /// opens, unless a task was chosen meanwhile. @@ -2271,26 +2425,14 @@ impl ChatScreen { } } - /// Everything the connection manager reports, in one batch. + /// Everything the connection manager reports, in one batch. A repaint + /// is requested once per batch, however many events change something. pub fn handle_manager_events(&mut self, events: Vec, cx: &mut Context) { - let mut pending: Option<(HostId, Vec)> = None; for event in events { - if let HostManagerEvent::Event { host, event } = event { - match &mut pending { - Some((current, batch)) if *current == host => batch.push(event), - _ => { - if let Some((host, batch)) = pending.take() { - self.handle_remote_host_events(host, batch, cx); - } - pending = Some((host, vec![event])); - } - } - continue; - } - if let Some((host, batch)) = pending.take() { - self.handle_remote_host_events(host, batch, cx); - } match event { + HostManagerEvent::Event { host, event } => { + self.handle_remote_host_events(host, vec![event], cx); + } HostManagerEvent::Status { host, name, @@ -2304,12 +2446,8 @@ impl ChatScreen { cx, ), HostManagerEvent::HostsChanged(hosts) => self.set_saved_hosts(hosts, cx), - HostManagerEvent::Event { .. } => unreachable!("handled above"), } } - if let Some((host, batch)) = pending.take() { - self.handle_remote_host_events(host, batch, cx); - } } /// Events from a remote host; dropped once it is offline. @@ -2322,7 +2460,7 @@ impl ChatScreen { if !self.hosts.get(&host).is_some_and(|entry| entry.online) { return; } - self.apply_events_from(host, events, cx); + self.apply_host_events(&host, events, cx); } /// Take a fresh session list. With nothing on screen, open the latest @@ -2413,6 +2551,7 @@ impl ChatScreen { // there too. self.session_hosts .insert(session.id.clone(), self.target_host.clone()); + self.hosts_dirty = true; let last_task_host = (!self.target_host.is_local()).then(|| self.target_host.to_string()); crate::settings::update_settings_in_background(move |settings| { settings.last_task_host = last_task_host; @@ -2467,7 +2606,6 @@ impl ChatScreen { if self.btw.is_some() && self.selected_session.as_deref() != Some(session_id) { self.close_side_thread(cx); } - self.use_host_of(session_id); self.load_session(session_id, LoadMode::Select, LOAD_RETRIES, cx); } @@ -2564,9 +2702,7 @@ impl ChatScreen { .collect(); match mode { LoadMode::Select => { - this.session_hosts - .entry(detail.session.id.clone()) - .or_insert(owner); + this.file_session(&detail.session.id, &owner); this.upsert_session(detail.session.clone(), cx); this.set_active_session(detail.session, detail.timeline, summaries, cx); this.queue = detail.queue.items; @@ -2658,6 +2794,9 @@ impl ChatScreen { /// Apply the host's session defaults: web access for new tasks, and /// the permission mode for sessions that still follow the default. fn apply_session_defaults(&mut self, defaults: &HostSessionDefaults, cx: &mut Context) { + if let Some(entry) = self.hosts.get_mut(&self.target_host) { + entry.session_defaults = Some(defaults.clone()); + } self.default_web_enabled = defaults.web_enabled; if self.uses_default_permission_mode { let mode = PermissionMode::parse(&defaults.permission_mode); @@ -3017,7 +3156,6 @@ impl ChatScreen { // The selected task's host becomes the target for new tasks unless // the sidebar filters on a host. let owner = self.host_of(&session.id); - self.use_host_of(&session.id); if self.host_filter.is_none() && owner != self.target_host { self.set_target_host(owner, cx); } @@ -3138,7 +3276,7 @@ impl ChatScreen { self.set_session_mcp(Vec::new()); return; }; - let host = self.host.clone(); + let host = self.backend_for(&session_id); let target = session_id.clone(); self.call( async move { host.list_session_mcp_servers(target.clone()).await }, @@ -3166,7 +3304,7 @@ impl ChatScreen { let Some(session_id) = self.selected_session.clone() else { return; }; - let host = self.host.clone(); + let host = self.backend_for(&session_id); let target = session_id.clone(); self.call( async move { @@ -3196,7 +3334,7 @@ impl ChatScreen { let previous = self.web_enabled; self.web_enabled = enabled; cx.notify(); - let host = self.host.clone(); + let host = self.backend_for(&session_id); let target = session_id.clone(); self.call( async move { host.set_session_web_enabled(target.clone(), enabled).await }, @@ -4341,7 +4479,7 @@ impl ChatScreen { let Some(run_id) = self.active_runs.get(&session_id).cloned() else { return; }; - let host = self.host.clone(); + let host = self.backend_for(&session_id); self.call( async move { host.cancel_run(run_id.clone()).await }, cx, @@ -4616,11 +4754,22 @@ impl ChatScreen { self.audio.cancel_recording(); } let backend = self.backend.clone(); - let host = self.host.clone(); + // The account signs out of this machine: its runtime stops, whatever + // host new tasks target. Remote runtimes belong to their hosts. + let local = self + .hosts + .get(&HostId::local()) + .and_then(|entry| entry.backend.clone()); let user_id = self.user_id.clone(); self.call( async move { - host.stop_runtime().await?; + if let Some(local) = local + && let Err(error) = local.stop_runtime().await + { + // Signing out must not hang on a runtime that will not + // stop; the logout clears the account either way. + log::warn!("Cannot stop the local runtime: {error}"); + } backend.logout_and_clear(&user_id).await }, cx, @@ -4735,13 +4884,10 @@ impl ChatScreen { if session.archived { self.completed_unread_sessions.remove(&session.id); } - let owner = self - .event_host - .clone() - .unwrap_or_else(|| self.target_host.clone()); - self.session_hosts - .entry(session.id.clone()) - .or_insert(owner); + // A task with no host yet was made on the target; one an event + // announced was filed under the event's host before this. + let target = self.target_host.clone(); + self.file_session(&session.id, &target); if let Some(existing) = self .sessions .iter_mut() @@ -4835,20 +4981,15 @@ impl ChatScreen { /// Apply a batch of the local host's events in one update. pub fn handle_host_events(&mut self, events: Vec, cx: &mut Context) { - self.apply_events_from(HostId::local(), events, cx); - } - - fn apply_events_from(&mut self, host: HostId, events: Vec, cx: &mut Context) { - self.event_host = Some(host); - self.apply_host_events(events, cx); - self.event_host = None; + self.apply_host_events(&HostId::local(), events, cx); } - fn apply_host_events(&mut self, events: Vec, cx: &mut Context) { + /// Apply what `host` reported. + fn apply_host_events(&mut self, host: &HostId, events: Vec, cx: &mut Context) { let mut changed = false; for event in events { changed |= match event { - HostEvent::Service(event) => self.apply_service_event(*event, cx), + HostEvent::Service(event) => self.apply_service_event(host, *event, cx), HostEvent::ProjectBranch { project_root, branch, @@ -4888,19 +5029,24 @@ impl ChatScreen { /// Route one backend service event into UI state. #[cfg(test)] pub fn handle_service_event(&mut self, event: AgentServiceEvent, cx: &mut Context) { - if self.apply_service_event(event, cx) { + if self.apply_service_event(&HostId::local(), event, cx) { cx.notify(); } } - /// Apply one event; returns false when nothing visible changed. - fn apply_service_event(&mut self, event: AgentServiceEvent, cx: &mut Context) -> bool { + /// Apply one event from `host`; returns false when nothing visible + /// changed. + fn apply_service_event( + &mut self, + host: &HostId, + event: AgentServiceEvent, + cx: &mut Context, + ) -> bool { match event { AgentServiceEvent::RuntimeStatus(mut status) => { - let scope = self.event_host.clone().unwrap_or_else(HostId::local); // A runtime that just started points Goose at the account's // skills, which the first scan ran before; ask again. - if scope.is_local() && status.running && !self.runtime_running_seen { + if host.is_local() && status.running && !self.runtime_running_seen { self.runtime_running_seen = true; self.refresh_slash_commands(cx); } @@ -4914,13 +5060,11 @@ impl ChatScreen { let mut merged: HashMap = self .active_runs .iter() - .filter(|(session_id, _)| self.host_of(session_id) != scope) + .filter(|(session_id, _)| self.host_of(session_id) != *host) .map(|(session_id, run_id)| (session_id.clone(), run_id.clone())) .collect(); for (session_id, run_id) in status.active_runs { - self.session_hosts - .entry(session_id.clone()) - .or_insert_with(|| scope.clone()); + self.file_session(&session_id, host); merged.insert(session_id, run_id); } if self.active_runs == merged { @@ -4932,9 +5076,11 @@ impl ChatScreen { self.sync_sidebar(cx); } AgentServiceEvent::SessionCreated(session) => { + self.file_session(&session.id, host); return self.upsert_session(session, cx); } AgentServiceEvent::SessionUpdated { session, .. } => { + self.file_session(&session.id, host); return self.upsert_session(session, cx); } AgentServiceEvent::TimelineItem { @@ -4985,7 +5131,10 @@ impl ChatScreen { session_id, run_id, event, - } => return self.handle_run_event(&session_id, &run_id, event, cx), + } => { + self.file_session(&session_id, host); + return self.handle_run_event(&session_id, &run_id, event, cx); + } AgentServiceEvent::SideQuestion { request_id, event, .. } => { @@ -5708,22 +5857,20 @@ impl ChatScreen { /// Cache the header title for the selected task. The header names what /// the pane shows: with no transcript on screen it is a new task, - /// whatever the list has selected. + /// whatever the list has selected. A task whose host dropped leaves + /// the list but stays on screen, and keeps the title it had. fn refresh_selected_title(&mut self) { if self.timeline.is_empty() && self.loading_session.is_none() { self.selected_title = DEFAULT_TASK_TITLE.into(); return; } - self.selected_title = self - .selected_session - .as_deref() - .and_then(|selected| { - self.sessions - .iter() - .position(|session| session.id == selected) - }) - .map(|index| SharedString::from(self.sessions[index].title.clone())) - .unwrap_or_else(|| DEFAULT_TASK_TITLE.into()); + let Some(selected) = self.selected_session.as_deref() else { + self.selected_title = DEFAULT_TASK_TITLE.into(); + return; + }; + if let Some(session) = self.sessions.iter().find(|session| session.id == selected) { + self.selected_title = SharedString::from(session.title.clone()); + } } /// Raise a desktop notification when enabled and the window is not diff --git a/apps/maple-agent/app/src/ui/chat/sidebar.rs b/apps/maple-agent/app/src/ui/chat/sidebar.rs index 78d63a764..875a8a469 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar.rs @@ -30,7 +30,7 @@ use crate::ui::text_input::TextInput; use crate::ui::theme; use crate::ui::titlebar; use crate::ui::widgets; -use maple_agent::host::{HostBackend, HostId}; +use maple_agent::host::HostId; use std::collections::BTreeMap; /// What the sidebar asks the screen to do. @@ -38,8 +38,6 @@ use std::collections::BTreeMap; pub(super) enum SidebarEvent { /// Show a task. Select(String), - /// A session changed on the backend (a rename); the screen owns the - /// canonical list and pushes it back. /// The user renamed a task; the screen sends it to the task's host. RenameTask { session_id: String, name: String }, /// Archive or restore a task. @@ -48,6 +46,9 @@ pub(super) enum SidebarEvent { RemoveRoot(String), /// Trust or untrust a project. SetTrust { path: String, trusted: bool }, + /// A project's menu opened: is the project trusted on the target + /// host? The screen answers through [`Sidebar::set_menu_trust`]. + ProjectTrust(String), /// Open the folder picker for a new project. ChooseProject, /// Something to tell the user. @@ -108,7 +109,6 @@ pub(super) struct SidebarRow { impl SidebarRow { fn build(session: &AgentSessionSummary, project_label: &str) -> Self { - let project_name = project_label; let id = &session.id; Self { id: Arc::from(id.as_str()), @@ -125,7 +125,7 @@ impl SidebarRow { menu_settle_id: SharedString::from(format!("settle-task-{id}")), menu_archive_id: SharedString::from(format!("archive-task-{id}")), title: SharedString::from(session.title.clone()), - project_name: SharedString::from(project_name.to_string()), + project_name: SharedString::from(project_label.to_string()), search: session.title.to_lowercase(), } } @@ -228,8 +228,6 @@ pub(super) struct SwitcherRoot { pub(super) struct Sidebar { /// Runs backend futures; see [`Self::call`]. backend: Arc, - /// The host whose tasks the rows show. - host: Arc, chat: WeakEntity, // What the screen pushes in. sessions: Vec, @@ -296,7 +294,6 @@ impl EventEmitter for Sidebar {} impl Sidebar { pub(super) fn new( backend: Arc, - host: Arc, chat: WeakEntity, settings: &crate::settings::AppSettings, cx: &mut Context, @@ -317,7 +314,6 @@ impl Sidebar { } } let application_vim_enabled = settings.application_vim_enabled; - let local_host_id = host.id().clone(); let search_chat = chat.clone(); let search_input = cx.new(move |cx| { TextInput::new("Search tasks", cx) @@ -335,7 +331,6 @@ impl Sidebar { .detach(); Self { backend, - host, chat, sessions: Vec::new(), selected: None, @@ -369,8 +364,8 @@ impl Sidebar { rename_input: None, rename_focus_pending: false, hosts: vec![SidebarHost { - id: local_host_id, - name: "This computer".into(), + id: HostId::local(), + name: super::LOCAL_HOST_NAME.into(), online: true, }], session_hosts: HashMap::new(), @@ -454,14 +449,6 @@ impl Sidebar { } self.hosts = hosts; self.session_hosts = session_hosts; - if self - .host_filter - .as_ref() - .is_some_and(|filter| !self.hosts.iter().any(|host| &host.id == filter)) - { - self.host_filter = None; - cx.emit(SidebarEvent::HostFilter(None)); - } self.rebuild_sections(); cx.notify(); } @@ -471,7 +458,7 @@ impl Sidebar { self.session_hosts .get(session_id) .cloned() - .unwrap_or_else(|| self.host.id().clone()) + .unwrap_or_else(HostId::local) } fn host_name(&self, id: &HostId) -> Option<&SharedString> { @@ -481,11 +468,23 @@ impl Sidebar { .map(|host| &host.name) } - /// Show only `host`'s tasks, or every host's. The screen learns of it - /// so new tasks target that host. + /// The user chose a host to show tasks from, or every host. The screen + /// learns of it so new tasks target that host. An offline host cannot + /// take new tasks, so it cannot be the filter either. pub(super) fn set_host_filter(&mut self, host: Option, cx: &mut Context) { self.switcher_menu_open = false; self.menu_selected = None; + if let Some(offline) = host + .as_ref() + .and_then(|host| self.hosts.iter().find(|entry| &entry.id == host)) + .filter(|entry| !entry.online) + { + cx.emit(SidebarEvent::Notice( + format!("{} is offline", offline.name).into(), + )); + cx.notify(); + return; + } if self.host_filter == host { cx.notify(); return; @@ -496,6 +495,17 @@ impl Sidebar { cx.notify(); } + /// The screen's host filter, pushed here when it reset it (the host + /// dropped or was removed); the screen already knows. + pub(super) fn show_host_filter(&mut self, host: Option, cx: &mut Context) { + if self.host_filter == host { + return; + } + self.host_filter = host; + self.rebuild_sections(); + cx.notify(); + } + pub(super) fn set_recent_roots(&mut self, roots: Vec, cx: &mut Context) { if self.recent_roots == roots { return; @@ -664,6 +674,11 @@ impl Sidebar { self.switcher_menu_open } + #[cfg(test)] + pub(super) fn host_filter(&self) -> Option<&HostId> { + self.host_filter.as_ref() + } + #[cfg(test)] pub(super) fn task_menu(&self) -> Option<&str> { self.task_menu.as_deref() @@ -1311,10 +1326,7 @@ impl Sidebar { // the rename, else the local host. let names: BTreeMap = self.project_names.clone().into_iter().collect(); - let host_id = self - .host_filter - .clone() - .unwrap_or_else(|| self.host.id().clone()); + let host_id = self.host_filter.clone().unwrap_or_else(HostId::local); persist_settings(move |settings| { settings.host_state_mut(&host_id).project_names = names; }); @@ -1351,21 +1363,23 @@ impl Sidebar { self.project_menu = None; } else { self.project_menu = Some(root.to_string()); - let host = self.host.clone(); - let path = root.to_string(); - self.call( - async move { host.project_trust(path).await }, - cx, - |this, result, cx| { - if let Ok(status) = result - && this.project_menu.as_deref() == Some(status.path.as_str()) - { - this.menu_trust = Some(status); - cx.notify(); - } - }, - ); + // The screen knows the target host and asks it. + cx.emit(SidebarEvent::ProjectTrust(root.to_string())); + } + cx.notify(); + } + + /// The screen's answer to [`SidebarEvent::ProjectTrust`]; shown while + /// that project's menu is still the open one. + pub(super) fn set_menu_trust( + &mut self, + status: AgentProjectTrustStatus, + cx: &mut Context, + ) { + if self.project_menu.as_deref() != Some(status.path.as_str()) { + return; } + self.menu_trust = Some(status); cx.notify(); } diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index 11c307b38..c03e3c425 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -1064,23 +1064,26 @@ mod state_tests { event, }; // A stream for a closed question changes nothing. - assert!( - !this.apply_service_event( - event("btw-1", SideQuestionEvent::Chunk("old".into())), - cx - ) - ); + assert!(!this.apply_service_event( + &HostId::local(), + event("btw-1", SideQuestionEvent::Chunk("old".into())), + cx + )); assert!(this.apply_service_event( + &HostId::local(), event("btw-2", SideQuestionEvent::Chunk("Because ".into())), cx )); - assert!( - this.apply_service_event( - event("btw-2", SideQuestionEvent::Chunk("so.".into())), - cx - ) - ); - assert!(this.apply_service_event(event("btw-2", SideQuestionEvent::Finished), cx)); + assert!(this.apply_service_event( + &HostId::local(), + event("btw-2", SideQuestionEvent::Chunk("so.".into())), + cx + )); + assert!(this.apply_service_event( + &HostId::local(), + event("btw-2", SideQuestionEvent::Finished), + cx + )); let btw = this.btw.as_ref().expect("panel stays open"); assert_eq!(btw.turns[0].answer, "Because so."); assert_eq!(btw.revision, 2); @@ -1098,7 +1101,11 @@ mod state_tests { // While the thread is open, a plain message joins it instead of // going to the task; a command still runs as a command. let live_id = this.btw.as_ref().unwrap().request_id.clone(); - assert!(this.apply_service_event(event(&live_id, SideQuestionEvent::Finished), cx)); + assert!(this.apply_service_event( + &HostId::local(), + event(&live_id, SideQuestionEvent::Finished), + cx + )); this.btw.as_mut().unwrap().turns[1].answer = "Then that.".into(); this.send_text("plain follow-up".to_string(), cx); let btw = this.btw.as_ref().expect("thread continues"); @@ -1532,7 +1539,7 @@ mod state_tests { fn test_task_calls_go_to_the_owning_host(cx: &mut TestAppContext) { cx.executor().allow_parking(); let screen = screen(cx); - screen.update(cx, |this, _cx| { + screen.update(cx, |this, cx| { let thin = |backend: &Arc| Arc::as_ptr(backend) as *const (); let remote = HostId::new("remote-key".to_string()); let remote_backend = this.backend.local_host("other") as Arc; @@ -1540,7 +1547,7 @@ mod state_tests { assert_ne!(thin(&remote_backend), thin(&local_backend)); this.hosts .insert(remote.clone(), ChatHost::local(remote_backend.clone())); - this.session_hosts.insert("r1".to_string(), remote); + this.session_hosts.insert("r1".to_string(), remote.clone()); this.selected_session = Some("r1".to_string()); // New tasks still target the local host. @@ -1548,6 +1555,33 @@ mod state_tests { assert_eq!(thin(&this.session_backend()), thin(&remote_backend)); assert_eq!(thin(&this.backend_for("r1")), thin(&remote_backend)); assert_eq!(thin(&this.backend_for("s1")), thin(&local_backend)); + + // With the sidebar filtered on the local host, opening the + // remote task leaves the target where it is: `host` is the + // target's backend and the task's calls go to its own host. + this.host_filter = Some(HostId::local()); + this.set_active_session( + summary_at("r1", "Remote", "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/work/remote"), + vec![user_item("u1", "hi")], + HashMap::new(), + cx, + ); + assert!(this.target_host.is_local()); + assert_eq!(thin(&this.host), thin(&local_backend)); + assert_eq!(thin(&this.session_backend()), thin(&remote_backend)); + + // Without the filter the selection moves the target along, and + // `host` follows the target. + this.host_filter = None; + this.set_active_session( + summary_at("r1", "Remote", "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/work/remote"), + vec![user_item("u1", "hi")], + HashMap::new(), + cx, + ); + assert_eq!(this.target_host, remote); + assert_eq!(thin(&this.host), thin(&remote_backend)); + assert_eq!(thin(&this.session_backend()), thin(&remote_backend)); }); } @@ -1575,6 +1609,263 @@ mod state_tests { }); } + /// A host's bootstrap and task list carry the connection they were + /// read on. An answer that lands after the host dropped or came back + /// is stale and changes nothing: the new connection reads it afresh. + #[gpui::test] + fn test_answers_from_an_earlier_connection_are_dropped(cx: &mut TestAppContext) { + use maple_agent::host::HostSessionDefaults; + cx.executor().allow_parking(); + let screen = screen(cx); + screen.update(cx, |this, cx| { + let remote = HostId::new("remote-key".to_string()); + let backend = this.backend.local_host("other") as Arc; + this.set_remote_host_status( + remote.clone(), + "Box".to_string(), + HostStatus::Online, + Some(backend), + cx, + ); + let current = this.hosts[&remote].connection; + let boot = |title: &str| HostBootstrap { + project_root: Some("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/work/remote".to_string()), + sessions: vec![summary_at("r1", title, "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/work/remote")], + recent_roots: vec!["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/work/remote".to_string()], + latest: None, + session_defaults: HostSessionDefaults::default(), + }; + let stale = RemoteBootstrap { + boot: boot("Stale"), + start_error: None, + summaries: HashMap::new(), + }; + this.finish_remote_bootstrap(remote.clone(), current - 1, Ok(stale), cx); + assert!(this.sessions.iter().all(|session| session.id != "r1")); + this.apply_listed_sessions( + &remote, + current - 1, + this.selection_generation, + Ok(vec![summary_at("r1", "Stale", "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/work/remote")]), + cx, + ); + assert!(this.sessions.iter().all(|session| session.id != "r1")); + + let fresh = RemoteBootstrap { + boot: boot("Fresh"), + start_error: None, + summaries: HashMap::new(), + }; + this.finish_remote_bootstrap(remote.clone(), current, Ok(fresh), cx); + assert_eq!( + this.sessions + .iter() + .find(|session| session.id == "r1") + .map(|session| session.title.as_str()), + Some("Fresh") + ); + assert_eq!(this.host_of("r1"), remote); + }); + } + + /// The project chosen while a host is the target is what comes back + /// when that host is the target again, not what its bootstrap said. + #[gpui::test] + fn test_target_switch_keeps_each_hosts_newest_project(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + let screen = screen(cx); + screen.update(cx, |this, cx| { + this.selected_session = None; + this.trust_prompts = false; + let remote = HostId::new("remote-key".to_string()); + let backend = this.backend.local_host("other") as Arc; + let mut entry = ChatHost::local(backend); + entry.project_root = Some("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/work/remote".to_string()); + this.hosts.insert(remote.clone(), entry); + if let Some(local) = this.hosts.get_mut(&HostId::local()) { + local.project_root = Some("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/work/first".to_string()); + } + this.set_project_context(Some("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/work/first".to_string()), cx); + // The user picks another project on the local host. + this.set_project_context(Some("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/work/second".to_string()), cx); + assert_eq!( + this.hosts[&HostId::local()].project_root.as_deref(), + Some("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/work/second") + ); + + this.pick_host(remote.clone(), cx); + assert_eq!(this.project_root.as_deref(), Some("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/work/remote")); + this.pick_host(HostId::local(), cx); + assert_eq!(this.project_root.as_deref(), Some("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/work/second")); + }); + } + + /// A remembered host that connects but fails its bootstrap will not + /// open its task either; startup goes on with the local auto-select. + #[gpui::test] + fn test_failed_bootstrap_releases_startup(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + let screen = screen(cx); + screen.update(cx, |this, cx| { + let remote = HostId::new("remote-key".to_string()); + let backend = this.backend.local_host("other") as Arc; + this.hosts.insert(remote.clone(), ChatHost::local(backend)); + this.restore_host = Some(remote.clone()); + this.selected_session = None; + this.finish_remote_bootstrap(remote, 0, Err("no runtime".to_string()), cx); + assert_eq!(this.restore_host, None); + assert!(this.target_host.is_local()); + assert!( + this.notice + .as_deref() + .is_some_and(|n| n.contains("no runtime")) + ); + }); + } + + /// An offline host cannot take new tasks, so neither the filter nor + /// the header chip may make it the target; the filter that stands + /// is what the sidebar shows. + #[gpui::test] + fn test_offline_host_cannot_become_the_target(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + let screen = screen(cx); + screen.update(cx, |this, cx| { + let remote = HostId::new("remote-key".to_string()); + this.hosts + .insert(remote.clone(), ChatHost::saved("Box".to_string())); + this.hosts_changed(); + this.sync_sidebar(cx); + // The sidebar refuses the row itself. + this.sidebar.update(cx, |sidebar, cx| { + sidebar.set_host_filter(Some(remote.clone()), cx); + assert_eq!(sidebar.host_filter(), None); + }); + // And the screen refuses a filter that reached it anyway. + this.set_host_filter(Some(remote.clone()), cx); + assert!(this.target_host.is_local()); + assert_eq!(this.host_filter, None); + assert!( + this.notice + .as_deref() + .is_some_and(|n| n.contains("offline")) + ); + this.notice = None; + this.pick_host(remote, cx); + assert!(this.target_host.is_local()); + assert!(this.notice.is_some()); + }); + } + + /// When the target host drops, new tasks go to the local host, the + /// sidebar's filter follows, the header shows the local project, and + /// only the drop itself raises a notice: the reconnect attempts that + /// follow report nothing new. + #[gpui::test] + fn test_target_host_drop_falls_back_to_local(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + let screen = screen(cx); + screen.update(cx, |this, cx| { + let remote = HostId::new("remote-key".to_string()); + let backend = this.backend.local_host("other") as Arc; + let mut entry = ChatHost::local(backend); + entry.name = "Box".to_string(); + entry.project_root = Some("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/work/remote".to_string()); + this.hosts.insert(remote.clone(), entry); + if let Some(local) = this.hosts.get_mut(&HostId::local()) { + local.project_root = Some("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/work/local".to_string()); + } + this.selected_session = None; + this.hosts_changed(); + this.sync_sidebar(cx); + // As after a click on the sidebar's host row. + this.sidebar.update(cx, |sidebar, cx| { + sidebar.show_host_filter(Some(remote.clone()), cx); + }); + this.set_host_filter(Some(remote.clone()), cx); + assert_eq!(this.target_host, remote); + assert_eq!(this.sidebar.read(cx).host_filter(), Some(&remote)); + assert_eq!(this.project_root.as_deref(), Some("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/work/remote")); + + this.set_remote_host_status( + remote.clone(), + "Box".to_string(), + HostStatus::Offline { + reason: "connection lost".to_string(), + }, + None, + cx, + ); + assert!(this.target_host.is_local()); + assert_eq!(this.host_filter, None); + assert_eq!(this.sidebar.read(cx).host_filter(), None); + assert_eq!(this.project_root.as_deref(), Some("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/work/local")); + assert!( + this.notice + .as_deref() + .is_some_and(|n| n.contains("connection lost")) + ); + + // A failed reconnect is not news. + this.notice = None; + this.set_remote_host_status( + remote.clone(), + "Box".to_string(), + HostStatus::Connecting, + None, + cx, + ); + this.set_remote_host_status( + remote, + "Box".to_string(), + HostStatus::Offline { + reason: "unreachable".to_string(), + }, + None, + cx, + ); + assert_eq!(this.notice, None); + }); + } + + /// The task on screen keeps its title when its host drops, though its + /// row leaves the list until the host is back. + #[gpui::test] + fn test_selected_task_keeps_its_title_when_its_host_drops(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + let screen = screen(cx); + screen.update(cx, |this, cx| { + let remote = HostId::new("remote-key".to_string()); + let backend = this.backend.local_host("other") as Arc; + this.hosts.insert(remote.clone(), ChatHost::local(backend)); + this.apply_host_session_list( + &remote, + vec![summary_at("r1", "Remote", "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/work/remote")], + cx, + ); + this.set_active_session( + summary_at("r1", "Remote", "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/work/remote"), + vec![user_item("u1", "hi")], + HashMap::new(), + cx, + ); + assert_eq!(this.selected_title.as_ref(), "Remote"); + + this.set_remote_host_status( + remote, + "Box".to_string(), + HostStatus::Offline { + reason: "connection lost".to_string(), + }, + None, + cx, + ); + assert!(this.sessions.iter().all(|session| session.id != "r1")); + assert_eq!(this.selected_session.as_deref(), Some("r1")); + assert_eq!(this.selected_title.as_ref(), "Remote"); + }); + } + /// The header names what the pane shows: an empty pane is a new task /// even while the list still marks a row. #[gpui::test] @@ -1729,17 +2020,23 @@ mod state_tests { mode: None, active_runs: HashMap::new(), }; - assert!( - !this.apply_service_event(AgentServiceEvent::RuntimeStatus(status.clone()), cx) - ); - assert!( - this.apply_service_event(AgentServiceEvent::SessionCreated(summary("s1", "A")), cx) - ); - assert!( - !this - .apply_service_event(AgentServiceEvent::SessionCreated(summary("s1", "A")), cx) - ); + assert!(!this.apply_service_event( + &HostId::local(), + AgentServiceEvent::RuntimeStatus(status.clone()), + cx + )); + assert!(this.apply_service_event( + &HostId::local(), + AgentServiceEvent::SessionCreated(summary("s1", "A")), + cx + )); + assert!(!this.apply_service_event( + &HostId::local(), + AgentServiceEvent::SessionCreated(summary("s1", "A")), + cx + )); assert!(this.apply_service_event( + &HostId::local(), AgentServiceEvent::SessionCreated(summary("s1", "A renamed")), cx )); @@ -2765,6 +3062,7 @@ mod state_tests { assert!(!this.active_runs.contains_key("s2")); this.apply_service_event( + &HostId::local(), AgentServiceEvent::RuntimeStatus(AgentRuntimeStatus { running: true, project_root: Some("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/work/alpha".to_string()), From e2cadca4dbd575ba3a92faf28dd9cfeeca0542ec Mon Sep 17 00:00:00 2001 From: benthecarman Date: Sun, 20 Sep 2026 00:29:24 -0500 Subject: [PATCH 22/37] Choose projects through the picker dialog alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The project chip opened a dropdown of recent projects with a "New project…" row, which opened the typed picker, whose local host added a "Browse…" row for the native folder dialog. Three ways to reach the same choice, two of them local-only, with a key context, three actions, a focus handle, and a folder-picker guard of their own. The chip and its shortcut now open the picker directly, on every host alike, and the native folder dialog goes with the dropdown. The picker highlights the current project's row when it opens. The RootMenu key context, its shortcut slots and settings label, and the tests that drove the dropdown go; a windowed test drives the picker from the shortcut. Co-Authored-By: Claude Fable 5.1 --- apps/maple-agent/README.md | 11 +- apps/maple-agent/app/src/keymap.rs | 61 +---- apps/maple-agent/app/src/shortcuts.rs | 20 +- .../maple-agent/app/src/ui/application_vim.rs | 8 +- apps/maple-agent/app/src/ui/chat/commands.rs | 39 +-- apps/maple-agent/app/src/ui/chat/composer.rs | 84 +------ apps/maple-agent/app/src/ui/chat/mod.rs | 235 +++--------------- .../maple-agent/app/src/ui/chat/navigation.rs | 16 -- apps/maple-agent/app/src/ui/chat/tests.rs | 161 ++++++------ apps/maple-agent/app/src/ui/settings.rs | 1 - apps/maple-agent/docs/remote-development.md | 9 +- 11 files changed, 129 insertions(+), 516 deletions(-) diff --git a/apps/maple-agent/README.md b/apps/maple-agent/README.md index 264fdd054..8c211ace6 100644 --- a/apps/maple-agent/README.md +++ b/apps/maple-agent/README.md @@ -142,12 +142,11 @@ sidebar, badged with the host name once more than one host is known, and the project switcher filters by host. A host chip in the header, shown once more than one host is known, names the host new tasks run on and switches it. The host the last new task ran on is the target again at the next -launch once it connects. Choosing a project opens one picker for every host: a search box over -the host's recent projects and folders, a row that opens a typed path, and -on the local host a row for the system folder picker. Host-scoped settings -(defaults, system prompt, integrations, usage) get a host selector when -more than one host is connected. Offline hosts stay listed without their -tasks until they return. +launch once it connects. Choosing a project opens one picker for every +host: a search box over the host's recent projects and folders and a row +that opens a typed path. Host-scoped settings (defaults, system prompt, +integrations, usage) get a host selector when more than one host is +connected. Offline hosts stay listed without their tasks until they return. ### Integrations preview diff --git a/apps/maple-agent/app/src/keymap.rs b/apps/maple-agent/app/src/keymap.rs index 1ddee8db0..9784235ee 100644 --- a/apps/maple-agent/app/src/keymap.rs +++ b/apps/maple-agent/app/src/keymap.rs @@ -27,7 +27,6 @@ pub(crate) enum ShortcutCategory { Application, Chat, Transcript, - ProjectMenu, TextEditing, ComposerVim, ApplicationVim, @@ -39,7 +38,6 @@ impl ShortcutCategory { Self::Application => "Application", Self::Chat => "Chat", Self::Transcript => "Transcript", - Self::ProjectMenu => "Project Menu", Self::TextEditing => "Text Editing", Self::ComposerVim => "Composer Vim", Self::ApplicationVim => "Application Vim", @@ -63,9 +61,6 @@ enum SlotAction { PickQuestionOption(usize), CopySelection, SelectAllTranscript, - RootMenuPrevious, - RootMenuNext, - RootMenuConfirm, Backspace, Delete, Left, @@ -133,9 +128,6 @@ impl SlotAction { Self::PickQuestionOption(index) => Box::new(chat::PickQuestionOption { index }), Self::CopySelection => Box::new(chat::CopySelection), Self::SelectAllTranscript => Box::new(chat::SelectAllTranscript), - Self::RootMenuPrevious => Box::new(chat::RootMenuPrevious), - Self::RootMenuNext => Box::new(chat::RootMenuNext), - Self::RootMenuConfirm => Box::new(chat::RootMenuConfirm), Self::Backspace => Box::new(text_input::Backspace), Self::Delete => Box::new(text_input::Delete), Self::Left => Box::new(text_input::Left), @@ -394,30 +386,6 @@ pub(crate) fn catalog() -> Vec { "secondary-a", SlotAction::SelectAllTranscript, ), - slot( - "project_menu.previous", - "Previous project menu item", - ShortcutCategory::ProjectMenu, - Some("RootMenu"), - "up", - SlotAction::RootMenuPrevious, - ), - slot( - "project_menu.next", - "Next project menu item", - ShortcutCategory::ProjectMenu, - Some("RootMenu"), - "down", - SlotAction::RootMenuNext, - ), - slot( - "project_menu.confirm", - "Choose project menu item", - ShortcutCategory::ProjectMenu, - Some("RootMenu"), - "enter", - SlotAction::RootMenuConfirm, - ), ]); add_application_vim_slots(&mut slots); @@ -842,30 +810,6 @@ fn add_application_vim_slots(slots: &mut Vec) { )); } - for (id, label, sequence, action) in [ - ( - "application_vim.project_menu.previous", - "Application Vim previous project menu item", - "k", - SlotAction::ApplicationVimPrevious, - ), - ( - "application_vim.project_menu.next", - "Application Vim next project menu item", - "j", - SlotAction::ApplicationVimNext, - ), - ] { - slots.push(slot( - id, - label, - ShortcutCategory::ApplicationVim, - Some(application_vim::ROOT_MENU_CONTEXT), - sequence, - action, - )); - } - slots.push(slot( "application_vim.input.escape", "Return from an application Vim text field", @@ -1212,7 +1156,7 @@ mod tests { #[test] fn catalog_contains_existing_shortcuts_and_the_application_vim_layer() { let catalog = catalog(); - assert_eq!(catalog.len(), 159); + assert_eq!(catalog.len(), 154); let ids = catalog.iter().map(|slot| slot.id).collect::>(); assert_eq!(ids.len(), catalog.len()); assert_eq!( @@ -1227,11 +1171,10 @@ mod tests { .iter() .filter(|slot| slot.category == ShortcutCategory::ApplicationVim) .count(), - 41 + 39 ); for (id, sequence) in [ ("app.quit", "secondary-q"), - ("project_menu.confirm", "enter"), ("text_input.character_palette", "ctrl-cmd-space"), ("application_vim.previous_annotation", "[ d"), ("application_vim.next_annotation", "] d"), diff --git a/apps/maple-agent/app/src/shortcuts.rs b/apps/maple-agent/app/src/shortcuts.rs index af782f350..9deea810a 100644 --- a/apps/maple-agent/app/src/shortcuts.rs +++ b/apps/maple-agent/app/src/shortcuts.rs @@ -340,13 +340,11 @@ enum KnownContext { Global, Chat, Transcript, - RootMenu, TextInput, ComposerNormal, ComposerVisual, ComposerInsert, ApplicationVimRoot, - ApplicationVimRootMenu, ApplicationVimOtherInput, ApplicationVimComposerNormal, Other, @@ -357,13 +355,11 @@ fn known_context(context: Option<&str>) -> KnownContext { None => KnownContext::Global, Some("Chat") => KnownContext::Chat, Some("Transcript") => KnownContext::Transcript, - Some("RootMenu") => KnownContext::RootMenu, Some("TextInput") => KnownContext::TextInput, Some(vim_actions::NORMAL_CONTEXT) => KnownContext::ComposerNormal, Some(vim_actions::VISUAL_CONTEXT) => KnownContext::ComposerVisual, Some(vim_actions::INSERT_CONTEXT) => KnownContext::ComposerInsert, Some(application_vim::ROOT_CONTEXT) => KnownContext::ApplicationVimRoot, - Some(application_vim::ROOT_MENU_CONTEXT) => KnownContext::ApplicationVimRootMenu, Some(application_vim::OTHER_INPUT_CONTEXT) => KnownContext::ApplicationVimOtherInput, Some(application_vim::COMPOSER_NORMAL_CONTEXT) => { KnownContext::ApplicationVimComposerNormal @@ -389,9 +385,7 @@ fn context_overlap(left: Option<&str>, right: Option<&str>) -> Option, right: Option<&str>) -> Option, right: Option<&str>) -> Option { self.execute_application_vim(command, window, cx) } - ChatCommand::ChooseProject => self.toggle_root_menu(cx), + ChatCommand::ChooseProject => self.toggle_project_picker(cx), ChatCommand::CopySelection => self.copy_selected_text(cx), ChatCommand::Escape if self.application_vim_enabled => { self.application_escape(window, cx) @@ -70,9 +67,6 @@ impl ChatScreen { } ChatCommand::PreviousTask => self.step_task(-1, cx), ChatCommand::RespondPermission { allow } => self.respond_permission(allow, cx), - ChatCommand::RootMenuConfirm => self.confirm_root_menu(cx), - ChatCommand::RootMenuNext => self.step_root_menu(1, cx), - ChatCommand::RootMenuPrevious => self.step_root_menu(-1, cx), ChatCommand::SelectAllTranscript => self.select_all_text(cx), ChatCommand::ToggleArchived => self.toggle_archived_visibility(cx), ChatCommand::ToggleSidebar => self.toggle_sidebar_visibility(cx), @@ -170,33 +164,6 @@ impl ChatScreen { self.execute_command(ChatCommand::PreviousTask, window, cx); } - pub(super) fn root_menu_confirm( - &mut self, - _: &RootMenuConfirm, - window: &mut Window, - cx: &mut Context, - ) { - self.execute_command(ChatCommand::RootMenuConfirm, window, cx); - } - - pub(super) fn root_menu_next( - &mut self, - _: &RootMenuNext, - window: &mut Window, - cx: &mut Context, - ) { - self.execute_command(ChatCommand::RootMenuNext, window, cx); - } - - pub(super) fn root_menu_previous( - &mut self, - _: &RootMenuPrevious, - window: &mut Window, - cx: &mut Context, - ) { - self.execute_command(ChatCommand::RootMenuPrevious, window, cx); - } - pub(super) fn select_all_transcript( &mut self, _: &SelectAllTranscript, diff --git a/apps/maple-agent/app/src/ui/chat/composer.rs b/apps/maple-agent/app/src/ui/chat/composer.rs index 9e25efba8..49b7d8e58 100644 --- a/apps/maple-agent/app/src/ui/chat/composer.rs +++ b/apps/maple-agent/app/src/ui/chat/composer.rs @@ -12,7 +12,7 @@ use super::commands::ChatCommand; use super::transcript::{render_plan_row, render_subagent_row}; use super::{ COMPOSER_PLACEHOLDER, ChatScreen, DraftImage, OpenSettingsSection, PickerRow, PickerRowKind, - ROOT_MENU_RECENTS, SIDE_THREAD_PLACEHOLDER, SIDEBAR_COLLAPSED_INSET, Section, + SIDE_THREAD_PLACEHOLDER, SIDEBAR_COLLAPSED_INSET, Section, }; use crate::ui::icons::{icon, spinner}; use crate::ui::markdown; @@ -83,7 +83,7 @@ impl ChatScreen { Some("folder-open"), self.project_label.clone(), true, - self.root_menu_open, + self.project_picker.is_some(), false, ) .flex_none() @@ -156,82 +156,6 @@ impl ChatScreen { .child(motion::rise_in(menu, "composer-menu-reveal")) } - /// The project menu, opened from the header chip. Rendered as an - /// overlay in the chat pane, right under the header. - pub(super) fn render_root_menu(&self, cx: &mut Context) -> Option
{ - if !self.root_menu_open { - return None; - } - let mut menu = Self::menu_panel(cx).w(px(480.)).max_w_full(); - { - for (index, path) in self.recent_roots.iter().take(ROOT_MENU_RECENTS).enumerate() { - let is_current = self.project_root.as_deref() == Some(path.as_str()); - menu = menu.child( - div() - .id(gpui::SharedString::from(format!("root-{}", path))) - .px_3() - .py_1() - .text_sm() - .text_color(gpui::rgb(if is_current { - theme::accent() - } else { - theme::text_primary() - })) - // A path: keep the file name, drop the start. - .line_clamp(1) - .text_ellipsis_start() - .when(self.root_menu_selected == Some(index), |row| { - row.bg(gpui::rgb(theme::bg_input())) - }) - .hover(|style| style.bg(gpui::rgb(theme::bg_input())).cursor_pointer()) - .on_click({ - let path = path.clone(); - cx.listener(move |this, _event, _window, cx| { - this.select_project_root(path.clone(), cx); - }) - }) - .child(path.clone()), - ); - } - let choose_row = self.recent_roots.len().min(ROOT_MENU_RECENTS); - menu = menu.child( - div() - .id("root-choose") - .px_3() - .py_1() - .text_sm() - .text_color(gpui::rgb(theme::text_secondary())) - .when(self.root_menu_selected == Some(choose_row), |row| { - row.bg(gpui::rgb(theme::bg_input())) - }) - .hover(|style| style.bg(gpui::rgb(theme::bg_input())).cursor_pointer()) - .on_click(cx.listener(|this, _event, _window, cx| { - this.choose_root_dialog(cx); - })) - .child("New project…"), - ); - } - Some( - div() - .absolute() - .top(px(40.)) - .left_4() - .when(self.sidebar_collapsed, |menu| { - menu.left(SIDEBAR_COLLAPSED_INSET) - }) - .child( - menu.key_context(if self.application_vim_enabled { - "RootMenu ApplicationVim" - } else { - "RootMenu" - }) - .when_some(self.root_menu_focus.clone(), |menu, focus| { - menu.track_focus(&focus) - }), - ), - ) - } - /// The open composer menu as an overlay. The panel floats above the /// chip row, bottom-anchored so it grows upward over the transcript /// instead of pushing the layout around. @@ -1001,7 +925,6 @@ impl ChatScreen { ) .on_click(cx.listener( |this, _event, _window, cx| { - this.root_menu_open = false; this.mode_menu_open = false; this.mcp_menu_open = false; this.models_menu_open = !this.models_menu_open; @@ -1020,7 +943,6 @@ impl ChatScreen { ) .on_click(cx.listener( |this, _event, _window, cx| { - this.root_menu_open = false; this.models_menu_open = false; this.mcp_menu_open = false; this.mode_menu_open = !this.mode_menu_open; @@ -1043,7 +965,6 @@ impl ChatScreen { ) .on_click(cx.listener( |this, _event, _window, cx| { - this.root_menu_open = false; this.models_menu_open = false; this.mode_menu_open = false; this.mcp_menu_open = !this.mcp_menu_open; @@ -1419,7 +1340,6 @@ impl ChatScreen { PickerRowKind::Recent => "folder-open", PickerRowKind::Suggestion => "folder", PickerRowKind::OpenPath => "search", - PickerRowKind::Browse => "folder-open", }; list = list.child( div() diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index 45d2908a3..9dfd4bde6 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -67,9 +67,6 @@ gpui::actions!( NextTask, OpenAppSettings, PreviousTask, - RootMenuConfirm, - RootMenuNext, - RootMenuPrevious, SelectAllTranscript, ToggleArchived, ToggleSidebar, @@ -95,8 +92,6 @@ pub(super) enum PickerRowKind { Suggestion, /// The typed text itself, when it looks like a path. OpenPath, - /// The system folder picker; the local host only. - Browse, } #[derive(Clone)] @@ -118,9 +113,6 @@ pub(super) struct ProjectPicker { /// What the rows were built from: the typed text, or the path an /// arrow filled in. query: String, - /// The text the user typed, kept while arrows fill the box so a row - /// with no path (Browse) restores it. - typed: String, /// The text an arrow just filled in; the box reporting it back is not /// a new query, so the rows stay put while the highlight moves. filled: Option, @@ -205,9 +197,6 @@ const DEFAULT_TASK_TITLE: &str = "New Task"; /// What the local host is called wherever hosts are listed. pub(super) const LOCAL_HOST_NAME: &str = "This computer"; -/// Recent projects the project menu lists above "New project…". -pub(super) const ROOT_MENU_RECENTS: usize = 6; - /// How long a notice stays before it clears itself. const NOTICE_TTL: std::time::Duration = std::time::Duration::from_secs(8); @@ -386,12 +375,6 @@ pub struct ChatScreen { /// Existing tasks always execute in their own persisted project root. project_root: Option, recent_roots: Vec, - root_menu_open: bool, - /// Row the project menu highlights for the keyboard, if any. - root_menu_selected: Option, - /// Focus for the open project menu, so plain arrow keys reach it - /// instead of the composer's text handling. - root_menu_focus: Option, /// Focus for whichever modal dialog is open, so Enter and Escape /// reach it instead of the composer. Created the first time a dialog /// opens: creating it up front shifts the window's focus-id order, @@ -402,8 +385,6 @@ pub struct ChatScreen { /// Whether the project-trust question may open its dialog. Tests that /// drive typing turn it off, since the dialog rightly takes focus. trust_prompts: bool, - /// The menu was just opened and still needs the focus. - root_menu_focus_pending: bool, /// The project picker's search box, created once. root_input: Option>, /// The picker just opened; the next render moves keyboard focus into @@ -428,8 +409,6 @@ pub struct ChatScreen { /// Root whose branch the host reports to this screen; the host owns /// the git dir watch. Replaced when the root changes. watched_root: Option, - /// A native folder picker is open; more clicks must not open another. - root_picker_open: bool, /// Sidebar hidden; a toggle in the main pane brings it back. sidebar_collapsed: bool, /// Images staged for the next message. @@ -629,7 +608,6 @@ impl ChatScreen { this.markdown_cache.attach(weak.clone(), cx.to_async()); this.selection = Some(cx.new(|_| rich_text::TextSelection::default())); this.transcript_focus = Some(cx.focus_handle()); - this.root_menu_focus = Some(cx.focus_handle()); this.application_focus = Some(cx.focus_handle()); this.initialize_application_vim_surface(); this @@ -665,7 +643,7 @@ impl ChatScreen { SidebarEvent::RemoveRoot(root) => self.request_remove_root(&root, cx), SidebarEvent::SetTrust { path, trusted } => self.set_project_trust(path, trusted, cx), SidebarEvent::ProjectTrust(root) => self.answer_project_trust(root, cx), - SidebarEvent::ChooseProject => self.choose_root_dialog(cx), + SidebarEvent::ChooseProject => self.open_project_picker(cx), SidebarEvent::Notice(message) => { self.notice = Some(message); cx.notify(); @@ -1059,13 +1037,9 @@ impl ChatScreen { uses_default_permission_mode: std::env::var("MAPLE_PERMISSION_MODE").is_err(), project_root: None, recent_roots: Vec::new(), - root_menu_open: false, - root_menu_selected: None, - root_menu_focus: None, dialog_focus: None, dialog_focus_pending: false, trust_prompts: true, - root_menu_focus_pending: false, sidebar_collapsed: false, draft_images: Vec::new(), draft_counter: 0, @@ -1094,7 +1068,6 @@ impl ChatScreen { project_branch: None, branch_label: None, watched_root: None, - root_picker_open: false, selection_generation: 0, reload_generation: 0, attachment_images: HashMap::new(), @@ -1369,7 +1342,6 @@ impl ChatScreen { // that fails leaves loads in flight alive, while a task clicked after // this point advances the generation and wins over the callback. let selection_generation = self.selection_generation; - self.root_menu_open = false; self.notice = None; cx.notify(); let host = self.host.clone(); @@ -1446,51 +1418,6 @@ impl ChatScreen { true } - /// Open the project picker for the target host. Every host, local or - /// remote, goes through the same dialog; the local host adds a row for - /// the system folder picker. - fn choose_root_dialog(&mut self, cx: &mut Context) { - self.open_project_picker(cx); - } - - /// The platform folder picker through gpui: NSOpenPanel on macOS, the - /// common file dialog on Windows, the XDG portal on Linux. The panel - /// closes with the app, so quit is never blocked on it. When it cannot - /// open (a Linux desktop with no portal), the project picker's typed - /// entry is the fallback. - fn browse_local_folder(&mut self, cx: &mut Context) { - if !self.begin_root_picker(cx) { - return; - } - let receiver = cx.prompt_for_paths(gpui::PathPromptOptions { - files: false, - directories: true, - multiple: false, - prompt: None, - }); - let bridge = cx.spawn(async move |this, cx| { - let picked = receiver.await; - this.update(cx, |this, cx| { - this.root_picker_open = false; - match picked { - Ok(Ok(Some(paths))) => { - if let Some(path) = paths.into_iter().next() { - this.select_project_root(path.to_string_lossy().into_owned(), cx); - } - } - // Cancelled, or the picker dropped its channel. - Ok(Ok(None)) | Err(_) => {} - Ok(Err(_)) => this.open_project_picker(cx), - } - cx.notify(); - }) - .ok(); - }); - // The portal dialog completes on its own thread; retained so the - // bridge dies here (see ChatScreen::call). - crate::ui::task::retain(&self.bridged_tasks, bridge); - } - /// The root changed: update the header label, then ask the host for /// its branch. fn project_root_changed(&mut self, cx: &mut Context) { @@ -1560,23 +1487,23 @@ impl ChatScreen { cx.notify(); } - /// Claim the folder picker. One at a time: several at once each - /// applied their own result and stalled the app. Returns `false` when - /// a picker or a project selection is already in progress. - fn begin_root_picker(&mut self, cx: &mut Context) -> bool { - if self.root_picker_open || self.root_selecting { - return false; + /// The project chip or its shortcut: open the picker, or close it when + /// it is open. + fn toggle_project_picker(&mut self, cx: &mut Context) { + if self.project_picker.is_some() { + self.close_project_picker(cx); + } else { + self.open_project_picker(cx); } - self.root_picker_open = true; - self.root_menu_open = false; - cx.notify(); - true } /// Open the project picker: a search box over the target host's recent - /// projects and folders. The search box is created once and reused. + /// projects and folders, the one way to choose a project on any host. + /// The search box is created once and reused. fn open_project_picker(&mut self, cx: &mut Context) { - self.root_menu_open = false; + self.models_menu_open = false; + self.mode_menu_open = false; + self.mcp_menu_open = false; self.host_menu_open = false; if self.root_input.is_none() { let chat = cx.entity().downgrade(); @@ -1638,7 +1565,6 @@ impl ChatScreen { generation: 0, suggestions: Vec::new(), query: String::new(), - typed: String::new(), filled: None, }); self.root_input_focus_pending = true; @@ -1661,12 +1587,19 @@ impl ChatScreen { return; }; picker.query = query.clone(); - picker.typed = query.clone(); picker.filled = None; picker.selected = 0; picker.generation += 1; let generation = picker.generation; self.rebuild_picker_rows(); + // With nothing typed, the current project's row starts highlighted. + if query.trim().is_empty() + && let Some(current) = self.project_root.as_deref() + && let Some(picker) = self.project_picker.as_mut() + && let Some(index) = picker.rows.iter().position(|row| row.path == current) + { + picker.selected = index; + } let host = self.host.clone(); self.call( async move { host.suggest_directories(query).await }, @@ -1688,8 +1621,7 @@ impl ChatScreen { } /// Rows in display order: the typed path itself when it looks like - /// one, recent projects that match, the host's folders, and the system - /// picker for the local host. + /// one, recent projects that match, then the host's folders. fn rebuild_picker_rows(&mut self) { let Some(mut picker) = self.project_picker.take() else { return; @@ -1732,21 +1664,13 @@ impl ChatScreen { }, ); } - if self.target_host.is_local() { - rows.push(PickerRow { - kind: PickerRowKind::Browse, - path: String::new(), - title: "Browse\u{2026}".into(), - subtitle: Some("Choose a folder with the system picker".into()), - }); - } picker.selected = picker.selected.min(rows.len().saturating_sub(1)); picker.rows = rows; self.project_picker = Some(picker); } /// Move the highlight and put the highlighted path in the search box, - /// as a shell completes; a row with no path restores the typed text. + /// as a shell completes. pub(super) fn project_picker_move(&mut self, delta: isize, cx: &mut Context) { let Some(picker) = self.project_picker.as_mut() else { return; @@ -1756,12 +1680,7 @@ impl ChatScreen { return; } picker.selected = (picker.selected as isize + delta).rem_euclid(len as isize) as usize; - let row = &picker.rows[picker.selected]; - let text = if row.path.is_empty() { - picker.typed.clone() - } else { - row.path.clone() - }; + let text = picker.rows[picker.selected].path.clone(); picker.filled = Some(text.clone()); if let Some(input) = self.root_input.clone() { input.update(cx, |input, cx| input.set_text(&text, cx)); @@ -1776,22 +1695,18 @@ impl ChatScreen { self.activate_picker_row(index, cx); } - /// Open the project a row names, or the system picker. + /// Open the project a row names. pub(super) fn activate_picker_row(&mut self, index: usize, cx: &mut Context) { - let Some(row) = self + let Some(path) = self .project_picker .as_ref() - .and_then(|picker| picker.rows.get(index).cloned()) + .and_then(|picker| picker.rows.get(index)) + .map(|row| row.path.clone()) else { return; }; self.close_project_picker(cx); - match row.kind { - PickerRowKind::Browse => self.browse_local_folder(cx), - PickerRowKind::Recent | PickerRowKind::Suggestion | PickerRowKind::OpenPath => { - self.select_project_root(row.path, cx); - } - } + self.select_project_root(path, cx); } pub(super) fn target_host_online(&self) -> bool { @@ -1801,7 +1716,6 @@ impl ChatScreen { } pub(super) fn toggle_host_menu(&mut self, cx: &mut Context) { - self.root_menu_open = false; self.models_menu_open = false; self.mode_menu_open = false; self.mcp_menu_open = false; @@ -1822,19 +1736,6 @@ impl ChatScreen { cx.notify(); } - /// Open or close the project menu from the header chip. - pub fn toggle_root_menu(&mut self, cx: &mut Context) { - self.models_menu_open = false; - self.mode_menu_open = false; - self.mcp_menu_open = false; - self.root_menu_open = !self.root_menu_open; - self.root_menu_selected = None; - // The next frame moves the focus; from there the arrow keys and - // Enter reach the menu instead of the composer. - self.root_menu_focus_pending = self.root_menu_open; - cx.notify(); - } - /// Text for the header project chip. pub fn project_label(&self) -> String { self.project_root @@ -3122,7 +3023,6 @@ impl ChatScreen { self.lightbox = None; self.permission_responding = false; self.models_menu_open = false; - self.root_menu_open = false; self.mcp_menu_open = false; } @@ -3683,51 +3583,6 @@ impl ChatScreen { } } - /// Rows the project menu offers: the recent roots it lists, then - /// "New project…". - fn root_menu_rows(&self) -> usize { - self.recent_roots.len().min(ROOT_MENU_RECENTS) + 1 - } - - /// Move the menu highlight. A menu is short, so it wraps at both - /// ends instead of stopping. - fn step_root_menu(&mut self, delta: isize, cx: &mut Context) { - if !self.root_menu_open { - return; - } - let rows = self.root_menu_rows() as isize; - let next = match self.root_menu_selected { - Some(current) => (current as isize + delta).rem_euclid(rows), - // Nothing highlighted: enter the menu from the end the key - // comes from. - None if delta < 0 => rows - 1, - None => 0, - }; - self.root_menu_selected = Some(next as usize); - cx.notify(); - } - - /// Enter on the highlighted menu row: switch to that project, or - /// open the folder picker on the last row. - fn confirm_root_menu(&mut self, cx: &mut Context) { - if !self.root_menu_open { - return; - } - let Some(index) = self.root_menu_selected else { - return; - }; - let recent = self - .recent_roots - .iter() - .take(ROOT_MENU_RECENTS) - .nth(index) - .cloned(); - match recent { - Some(path) => self.select_project_root(path, cx), - None => self.choose_root_dialog(cx), - } - } - /// Alt-Up / Alt-Down: open the task before or after the selected one /// in the order the sidebar shows them. fn step_task(&mut self, delta: isize, cx: &mut Context) { @@ -3840,14 +3695,12 @@ impl ChatScreen { } /// Escape with nothing else to dismiss: close whichever chip menu is - /// open (including the folder picker). Reports whether one was open. + /// open. Reports whether one was open. fn close_menus_on_escape(&mut self, cx: &mut Context) -> bool { - if self.models_menu_open || self.mode_menu_open || self.mcp_menu_open || self.root_menu_open - { + if self.models_menu_open || self.mode_menu_open || self.mcp_menu_open { self.models_menu_open = false; self.mode_menu_open = false; self.mcp_menu_open = false; - self.root_menu_open = false; cx.notify(); return true; } @@ -4269,7 +4122,6 @@ impl ChatScreen { self.models_menu_open = true; self.mode_menu_open = false; self.mcp_menu_open = false; - self.root_menu_open = false; cx.notify(); } else { let query = args.to_string(); @@ -4405,7 +4257,6 @@ impl ChatScreen { self.models_menu_open = false; self.mode_menu_open = false; self.mcp_menu_open = false; - self.root_menu_open = false; self.list_state.scroll_to_end(); if !run_active { self.awaiting_first_token = true; @@ -4782,7 +4633,6 @@ impl ChatScreen { fn pick_model(&mut self, model: String, cx: &mut Context) { self.selected_model = Some(model.clone()); self.models_menu_open = false; - self.root_menu_open = false; cx.notify(); self.refresh_vision(cx); // Remember the choice across launches via the agent config. @@ -5449,25 +5299,6 @@ impl Render for ChatScreen { let handle = input.read(cx).focus_handle(cx); window.focus(&handle, cx); } - } else if self.root_menu_focus_pending { - self.root_menu_focus_pending = false; - if let Some(handle) = self.root_menu_focus.clone() { - window.focus(&handle, cx); - } - } else if !self.root_menu_open - && let Some(handle) = self.root_menu_focus.clone() - && handle.is_focused(window) - { - // The menu closed while it held focus. Application Vim returns - // to its proxy; Standard mode keeps the legacy composer return. - if self.application_vim_enabled { - if let Some(handle) = self.application_focus.clone() { - window.focus(&handle, cx); - } - } else if let Some(composer) = self.composer.clone() { - let handle = composer.read(cx).focus_handle(cx); - window.focus(&handle, cx); - } } // A window with no focused element has no dispatch path: menu // items validate as unavailable and shortcuts fall through. The @@ -5604,9 +5435,6 @@ impl Render for ChatScreen { .on_action(cx.listener(Self::toggle_archived)) .on_action(cx.listener(Self::open_app_settings)) .on_action(cx.listener(Self::choose_project)) - .on_action(cx.listener(Self::root_menu_previous)) - .on_action(cx.listener(Self::root_menu_next)) - .on_action(cx.listener(Self::root_menu_confirm)) .on_action(cx.listener(Self::previous_task)) .on_action(cx.listener(Self::next_task)) .on_action(cx.listener(Self::allow_permission)) @@ -5676,7 +5504,6 @@ impl Render for ChatScreen { ) }) .child(main) - .children(self.render_root_menu(cx)) .children(self.render_host_menu(cx)), ), ) diff --git a/apps/maple-agent/app/src/ui/chat/navigation.rs b/apps/maple-agent/app/src/ui/chat/navigation.rs index dfbbfe4f7..5a72e3724 100644 --- a/apps/maple-agent/app/src/ui/chat/navigation.rs +++ b/apps/maple-agent/app/src/ui/chat/navigation.rs @@ -314,12 +314,6 @@ impl ChatScreen { count: usize, cx: &mut Context, ) { - if self.root_menu_open { - for _ in 0..count { - self.step_root_menu(direction, cx); - } - return; - } if self .sidebar .update(cx, |sidebar, cx| sidebar.step_popup(direction, count, cx)) @@ -361,12 +355,6 @@ impl ChatScreen { } fn select_application_edge(&mut self, first: bool, cx: &mut Context) { - if self.root_menu_open { - let len = self.root_menu_rows(); - self.root_menu_selected = (len > 0).then_some(if first { 0 } else { len - 1 }); - cx.notify(); - return; - } if self .sidebar .update(cx, |sidebar, cx| sidebar.popup_edge(first, cx)) @@ -540,10 +528,6 @@ impl ChatScreen { } fn activate_application_selection(&mut self, window: &mut Window, cx: &mut Context) { - if self.root_menu_open { - self.confirm_root_menu(cx); - return; - } if self .sidebar .update(cx, |sidebar, cx| sidebar.activate_popup(cx)) diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index c03e3c425..37f5c4392 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -829,12 +829,10 @@ mod state_tests { this.models_menu_open = true; this.mode_menu_open = true; this.mcp_menu_open = true; - this.root_menu_open = true; this.send_text("hello".to_string(), cx); assert!(!this.models_menu_open); assert!(!this.mode_menu_open); assert!(!this.mcp_menu_open); - assert!(!this.root_menu_open); }); } @@ -1917,22 +1915,6 @@ mod state_tests { }); } - #[gpui::test] - fn test_second_picker_click_is_ignored(cx: &mut TestAppContext) { - cx.executor().allow_parking(); - let screen = screen(cx); - screen.update(cx, |this, cx| { - this.root_menu_open = true; - assert!(this.begin_root_picker(cx)); - assert!(this.root_picker_open); - assert!(!this.root_menu_open); - // A second click while the picker is open must not start another. - this.root_menu_open = true; - assert!(!this.begin_root_picker(cx)); - assert!(this.root_menu_open); - }); - } - #[gpui::test] fn test_project_picker_rows_follow_the_query(cx: &mut TestAppContext) { use crate::ui::chat::{PickerRowKind, ProjectPicker}; @@ -1947,45 +1929,43 @@ mod state_tests { }; screen.update(cx, |this, cx| { this.recent_roots = vec!["/home/me/alpha".to_string(), "/home/me/beta".to_string()]; + this.project_root = Some("/home/me/beta".to_string()); this.open_project_picker(cx); assert!( this.root_input_focus_pending, "typing must land in the picker" ); let picker = this.project_picker.as_ref().expect("picker open"); - // Recent projects first; the local host ends with the system picker. + // Recent projects first, the current one highlighted. assert_eq!( kinds(picker), vec![ (PickerRowKind::Recent, "/home/me/alpha".to_string()), (PickerRowKind::Recent, "/home/me/beta".to_string()), - (PickerRowKind::Browse, String::new()), ] ); + assert_eq!(picker.selected, 1); - // A typed path is offered as is, above what matched it. + // A typed path is offered as is, above what matched it, and a + // new query starts at the top. this.refresh_project_picker("/srv/work/".to_string(), cx); let picker = this.project_picker.as_ref().expect("picker open"); assert_eq!( kinds(picker), - vec![ - (PickerRowKind::OpenPath, "/srv/work".to_string()), - (PickerRowKind::Browse, String::new()), - ] + vec![(PickerRowKind::OpenPath, "/srv/work".to_string())] ); + assert_eq!(picker.selected, 0); // Plain text filters the recents. this.refresh_project_picker("BETA".to_string(), cx); let picker = this.project_picker.as_ref().expect("picker open"); assert_eq!( kinds(picker), - vec![ - (PickerRowKind::Recent, "/home/me/beta".to_string()), - (PickerRowKind::Browse, String::new()), - ] + vec![(PickerRowKind::Recent, "/home/me/beta".to_string())] ); // Arrows wrap; Escape closes and hands the keyboard back. + this.refresh_project_picker("home".to_string(), cx); this.project_picker_move(-1, cx); assert_eq!(this.project_picker.as_ref().map(|p| p.selected), Some(1)); this.project_picker_move(1, cx); @@ -1997,14 +1977,19 @@ mod state_tests { }); } + /// The project chip and its shortcut open the picker, and close it + /// again while it is open. #[gpui::test] - fn test_escape_closes_root_menu(cx: &mut TestAppContext) { + fn test_choose_project_toggles_the_picker(cx: &mut TestAppContext) { cx.executor().allow_parking(); let screen = screen(cx); screen.update(cx, |this, cx| { - this.root_menu_open = true; - this.close_menus_on_escape(cx); - assert!(!this.root_menu_open); + this.models_menu_open = true; + this.toggle_project_picker(cx); + assert!(this.project_picker.is_some()); + assert!(!this.models_menu_open, "the picker closes the chip menus"); + this.toggle_project_picker(cx); + assert!(this.project_picker.is_none()); }); } @@ -3486,7 +3471,7 @@ mod state_tests { cx.run_until_parked(); // Arrows move the highlight and fill the box with its path; the - // rows stay put while they do. Browse restores the typed text. + // rows stay put while they do. let picker_state = |cx: &mut gpui::VisualTestContext| { cx.update(|_window, app| { let this = chat.read(app); @@ -3503,16 +3488,23 @@ mod state_tests { ) }) }; + let last_path = |cx: &mut gpui::VisualTestContext| { + cx.update(|_window, app| { + let this = chat.read(app); + let picker = this.project_picker.as_ref().expect("picker open"); + picker.rows.last().expect("rows").path.clone() + }) + }; // The local host also lists this machine's home folders after // the recents, so only the count's stability is asserted. let rows = picker_state(cx).1; cx.simulate_keystrokes("down"); cx.run_until_parked(); assert_eq!(picker_state(cx), (1, rows, "/home/me/beta".to_string())); - // Up from the top wraps to the last row, Browse, which has no path. + // Up from the top wraps to the last row. cx.simulate_keystrokes("up up"); cx.run_until_parked(); - assert_eq!(picker_state(cx), (rows - 1, rows, String::new())); + assert_eq!(picker_state(cx), (rows - 1, rows, last_path(cx))); cx.simulate_keystrokes("down"); cx.run_until_parked(); assert_eq!(picker_state(cx), (0, rows, "/home/me/alpha".to_string())); @@ -3809,10 +3801,13 @@ mod state_tests { // Once no focus transition consumes Escape, the same central route // still reaches Chat's legacy menu-close behavior. - chat.update(cx, |this, cx| this.toggle_root_menu(cx)); - assert!(cx.update(|_window, app| chat.read(app).root_menu_open)); + chat.update(cx, |this, cx| { + this.models_menu_open = true; + cx.notify(); + }); + assert!(cx.update(|_window, app| chat.read(app).models_menu_open)); cx.simulate_keystrokes("escape"); - assert!(!cx.update(|_window, app| chat.read(app).root_menu_open)); + assert!(!cx.update(|_window, app| chat.read(app).models_menu_open)); // ga from composer Normal selects the newest assistant and returns // focus to the application proxy rather than leaving a stale chord @@ -4025,11 +4020,11 @@ mod state_tests { }); } - /// Ctrl-P opens the project menu, takes the focus off the composer, - /// and walks its rows with plain arrow keys. Closing the menu gives - /// the composer its focus back. + /// Ctrl-P opens the project picker with the keyboard in its search + /// box, the arrows walk its rows, Enter opens the highlighted project, + /// and the composer takes the keyboard back when the picker closes. #[gpui::test] - fn test_project_menu_walks_with_arrows_and_application_vim_jk(cx: &mut TestAppContext) { + fn test_project_shortcut_opens_the_picker(cx: &mut TestAppContext) { cx.executor().allow_parking(); struct ChatHost { chat: Entity, @@ -4058,6 +4053,7 @@ mod state_tests { cx, ); chat.selected_session = Some("s1".to_string()); + chat.trust_prompts = false; chat.recent_roots = vec![absolute_fixture_root("one"), absolute_fixture_root("two")]; chat.booting = false; chat @@ -4070,66 +4066,53 @@ mod state_tests { cx.update(|window, app| window.focus(&composer_handle, app)); cx.simulate_keystrokes("secondary-p"); - assert!(cx.update(|_window, app| chat.read(app).root_menu_open)); - assert_ne!( + cx.run_until_parked(); + let search_handle = cx.update(|_window, app| { + let this = chat.read(app); + assert!(this.project_picker.is_some()); + this.root_input.clone().unwrap().focus_handle(app) + }); + assert_eq!( cx.update(|window, app| window.focused(app)), - Some(composer_handle.clone()), - "the open menu must hold the focus, or the arrows type instead" + Some(search_handle), + "the open picker must hold the focus, or the arrows type instead" ); - // Two recent roots and the "New project…" row: down, down, down - // wraps back to the first. + // Down highlights the second recent project and fills it in. cx.simulate_keystrokes("down"); + cx.run_until_parked(); assert_eq!( - cx.update(|_window, app| chat.read(app).root_menu_selected), - Some(0) - ); - cx.simulate_keystrokes("down down"); - assert_eq!( - cx.update(|_window, app| chat.read(app).root_menu_selected), - Some(2) - ); - cx.simulate_keystrokes("down"); - assert_eq!( - cx.update(|_window, app| chat.read(app).root_menu_selected), - Some(0) - ); - cx.simulate_keystrokes("up"); - assert_eq!( - cx.update(|_window, app| chat.read(app).root_menu_selected), - Some(2) + cx.update(|_window, app| chat.read(app).project_picker.as_ref().map(|p| p.selected)), + Some(1) ); - // Enter on a recent root asks for the switch and closes the menu; - // the composer takes the typing back. - cx.simulate_keystrokes("up up"); - assert_eq!( - cx.update(|_window, app| chat.read(app).root_menu_selected), - Some(0) - ); + // Enter asks for the switch and closes the picker; the composer + // takes the typing back. The host may have answered the switch + // already (the fixture path need not exist), in which case its + // answer is on screen instead of the switch in flight. cx.simulate_keystrokes("enter"); - assert!(!cx.update(|_window, app| chat.read(app).root_menu_open)); + cx.run_until_parked(); + cx.update(|_window, app| { + let this = chat.read(app); + assert!(this.project_picker.is_none()); + assert!( + this.root_selecting || this.notice.is_some(), + "Enter opens the highlighted project" + ); + }); assert_eq!( cx.update(|window, app| window.focused(app)), Some(composer_handle), - "closing the menu must hand the focus back" + "closing the picker must hand the focus back" ); - // Application Vim adds its own context to the focused menu, so its - // j/k aliases are live without disturbing the legacy arrow bindings. - chat.update(cx, |this, cx| this.set_application_vim_enabled(true, cx)); + // The shortcut closes an open picker too. cx.simulate_keystrokes("secondary-p"); - assert!(cx.update(|_window, app| chat.read(app).root_menu_open)); - cx.simulate_keystrokes("j j"); - assert_eq!( - cx.update(|_window, app| chat.read(app).root_menu_selected), - Some(1) - ); - cx.simulate_keystrokes("k"); - assert_eq!( - cx.update(|_window, app| chat.read(app).root_menu_selected), - Some(0) - ); + cx.run_until_parked(); + assert!(cx.update(|_window, app| chat.read(app).project_picker.is_some())); + cx.simulate_keystrokes("secondary-p"); + cx.run_until_parked(); + assert!(cx.update(|_window, app| chat.read(app).project_picker.is_none())); } /// The open composer menu (model picker and friends) floats above the diff --git a/apps/maple-agent/app/src/ui/settings.rs b/apps/maple-agent/app/src/ui/settings.rs index 01b5965e9..5f0f051ce 100644 --- a/apps/maple-agent/app/src/ui/settings.rs +++ b/apps/maple-agent/app/src/ui/settings.rs @@ -3782,7 +3782,6 @@ fn shortcut_context_label(context: Option<&str>) -> &str { None => "Global", Some("Chat") => "Chat", Some("Transcript") => "Transcript", - Some("RootMenu") => "Project menu", Some("TextInput") => "Text fields", Some(crate::ui::text_input::vim_actions::NORMAL_CONTEXT) => "Composer — Normal", Some(crate::ui::text_input::vim_actions::VISUAL_CONTEXT) => "Composer — Visual", diff --git a/apps/maple-agent/docs/remote-development.md b/apps/maple-agent/docs/remote-development.md index 786a38e3b..4743866b7 100644 --- a/apps/maple-agent/docs/remote-development.md +++ b/apps/maple-agent/docs/remote-development.md @@ -55,7 +55,8 @@ These are settled. Each maps to a question answered during planning. - Discovery. Manual address entry. No mDNS. The desktop app shows its listen addresses and pairing code in Settings. - Project roots. Text entry plus the host's recent roots plus host-side - directory suggestions. No native folder picker for remote hosts. + directory suggestions. No native folder picker on any host, so local + and remote selection are the same dialog. - Session defaults are per host. Default permission mode, default web, harness instructions, and default model move from app settings into the host's per-account config. @@ -505,9 +506,9 @@ shows its saved project and opens its latest task there. A remembered host that reports offline or is no longer saved releases startup to the local auto-select. Project selection is one dialog for every host (after Paseo's add-project flow): a search box over the host's recent -projects and its directory suggestions, an "Open this path" row when the -text looks like a path, and a "Browse…" row for the system folder -picker on the local host only. Arrows, Enter, and Escape drive it. +projects and its directory suggestions, and an "Open this path" row when +the text looks like a path; there is no native folder picker on any host. +Arrows, Enter, and Escape drive it. ### Settings scoping From 9cf8c2ba5ccc933a13d94a13fff4a9928a3d1d6c Mon Sep 17 00:00:00 2001 From: benthecarman Date: Sun, 20 Sep 2026 00:36:44 -0500 Subject: [PATCH 23/37] Move host and picker code out of chat/mod.rs The chat screen's module held the multi-host state machine and the project picker alongside everything else, at close to six thousand lines. Both have their own vocabulary and tests, and neither needs the rest of the file to be read with it. `hosts.rs` takes the host entry, the connection bookkeeping, the target and filter logic, the connection manager's events, and the header's host chip menu. `picker.rs` takes the picker's rows and dialog. Nothing changes behavior: the functions move as they are and become visible to the parent module, and the fields they use stay on the screen. Co-Authored-By: Claude Fable 5.1 --- apps/maple-agent/app/src/ui/chat/composer.rs | 308 +------ apps/maple-agent/app/src/ui/chat/hosts.rs | 781 ++++++++++++++++ apps/maple-agent/app/src/ui/chat/mod.rs | 904 +------------------ apps/maple-agent/app/src/ui/chat/picker.rs | 453 ++++++++++ apps/maple-agent/app/src/ui/chat/tests.rs | 5 +- 5 files changed, 1248 insertions(+), 1203 deletions(-) create mode 100644 apps/maple-agent/app/src/ui/chat/hosts.rs create mode 100644 apps/maple-agent/app/src/ui/chat/picker.rs diff --git a/apps/maple-agent/app/src/ui/chat/composer.rs b/apps/maple-agent/app/src/ui/chat/composer.rs index 49b7d8e58..7ab13cd0c 100644 --- a/apps/maple-agent/app/src/ui/chat/composer.rs +++ b/apps/maple-agent/app/src/ui/chat/composer.rs @@ -9,10 +9,11 @@ use maple_agent::agent::{AgentSlashCommand, SideQuestionTurn}; use super::cache::MarkdownKind; use super::commands::ChatCommand; +use super::hosts::status_dot; use super::transcript::{render_plan_row, render_subagent_row}; use super::{ - COMPOSER_PLACEHOLDER, ChatScreen, DraftImage, OpenSettingsSection, PickerRow, PickerRowKind, - SIDE_THREAD_PLACEHOLDER, SIDEBAR_COLLAPSED_INSET, Section, + COMPOSER_PLACEHOLDER, ChatScreen, DraftImage, OpenSettingsSection, SIDE_THREAD_PLACEHOLDER, + SIDEBAR_COLLAPSED_INSET, Section, }; use crate::ui::icons::{icon, spinner}; use crate::ui::markdown; @@ -1186,310 +1187,11 @@ pub(super) fn slash_entries_for(token: &str, skills: &[AgentSlashCommand]) -> Ve .collect() } +impl ChatScreen {} + /// One control in the composer chip row. `active` means its menu is /// open; `highlight` means the feature it toggles is on, shown in the /// accent so the two states never look alike. -/// A small live-state dot: green online, muted otherwise. -fn status_dot(online: bool) -> Div { - div() - .flex_none() - .size(px(8.)) - .rounded_full() - .bg(gpui::rgb(if online { - theme::status_success() - } else { - theme::text_muted() - })) -} - -impl ChatScreen { - /// The host chip's dropdown: every known host with its state, then a - /// way to the Hosts settings. - pub(super) fn render_host_menu(&self, cx: &mut Context) -> Option
{ - if !self.host_menu_open { - return None; - } - let mut menu = div() - .id("host-menu") - .occlude() - .flex() - .flex_col() - .w(px(300.)) - .py_1() - .rounded(theme::RADIUS_MD) - .bg(gpui::rgb(theme::bg_elevated())) - .border_1() - .border_color(gpui::rgb(theme::border())) - .shadow_md() - .on_mouse_down_out(cx.listener(|this, _event, _window, cx| { - this.host_menu_open = false; - cx.notify(); - })) - .child( - div() - .px_3() - .pt_1() - .pb_1() - .text_xs() - .font_weight(gpui::FontWeight::SEMIBOLD) - .text_color(gpui::rgb(theme::text_muted())) - .child("NEW TASKS RUN ON"), - ); - for host in &self.host_list { - let current = host.id == self.target_host; - let online = host.online; - let pick = host.id.clone(); - menu = menu.child( - div() - .id(SharedString::from(format!("host-menu-{}", host.id))) - .flex() - .items_center() - .gap_2() - .px_3() - .py_1p5() - .text_sm() - .text_color(gpui::rgb(if online { - theme::text_primary() - } else { - theme::text_muted() - })) - .hover(|style| style.bg(gpui::rgb(theme::bg_input())).cursor_pointer()) - .on_click(cx.listener(move |this, _event, _window, cx| { - this.pick_host(pick.clone(), cx); - })) - .child(status_dot(online)) - .child( - div() - .flex_1() - .min_w_0() - .line_clamp(1) - .text_ellipsis() - .child(host.name.clone()), - ) - .when(!online, |row| { - row.child( - div() - .text_xs() - .text_color(gpui::rgb(theme::text_muted())) - .child("offline"), - ) - }) - .when(current, |row| { - row.child(icon("check", px(14.), theme::accent())) - }), - ); - } - menu = menu - .child(div().h(px(1.)).mx_2().my_1().bg(gpui::rgb(theme::border()))) - .child( - div() - .id("host-menu-manage") - .px_3() - .py_1p5() - .text_sm() - .text_color(gpui::rgb(theme::text_secondary())) - .hover(|style| style.bg(gpui::rgb(theme::bg_input())).cursor_pointer()) - .on_click(cx.listener(|this, _event, _window, cx| { - this.host_menu_open = false; - cx.emit(OpenSettingsSection(Section::Hosts)); - })) - .child("Manage hosts\u{2026}"), - ); - Some( - div() - .absolute() - .top(px(40.)) - .left_4() - .when(self.sidebar_collapsed, |menu| { - menu.left(SIDEBAR_COLLAPSED_INSET) - }) - .child(menu), - ) - } - - /// The project picker: a centered dialog over the pane with a search - /// box, the rows it matched, and the keys that drive it. - pub(super) fn render_project_picker(&self, cx: &mut Context) -> gpui::Stateful
{ - let picker = self.project_picker.as_ref(); - let rows: &[PickerRow] = picker.map(|picker| picker.rows.as_slice()).unwrap_or(&[]); - let selected = picker.map(|picker| picker.selected).unwrap_or(0); - let searching = picker.is_some_and(|picker| !picker.query.trim().is_empty()); - let mut list = div() - .id("project-picker-rows") - .flex() - .flex_col() - .max_h(px(380.)) - .overflow_y_scroll(); - if rows.is_empty() { - list = list.child( - div() - .px_3() - .py_3() - .text_sm() - .text_color(gpui::rgb(theme::text_muted())) - .child(if searching { - "No folders match" - } else { - "No recent projects on this host yet; type a path" - }), - ); - } - for (index, row) in rows.iter().enumerate() { - let is_selected = index == selected; - let glyph = match row.kind { - PickerRowKind::Recent => "folder-open", - PickerRowKind::Suggestion => "folder", - PickerRowKind::OpenPath => "search", - }; - list = list.child( - div() - .id(SharedString::from(format!("project-picker-row-{index}"))) - .flex() - .items_center() - .gap_3() - .px_3() - .py_2() - .rounded(theme::RADIUS_SM) - .when(is_selected, |row| row.bg(gpui::rgb(theme::bg_input()))) - .hover(|style| style.bg(gpui::rgb(theme::bg_input())).cursor_pointer()) - .on_click(cx.listener(move |this, _event, _window, cx| { - this.activate_picker_row(index, cx); - })) - .child(icon(glyph, px(16.), theme::text_muted())) - .child( - div() - .flex() - .flex_col() - .min_w_0() - .flex_1() - .child( - div() - .text_sm() - .text_color(gpui::rgb(theme::text_primary())) - .line_clamp(1) - .text_ellipsis() - .child(row.title.clone()), - ) - .when_some(row.subtitle.clone(), |col, subtitle| { - col.child( - div() - .text_xs() - .text_color(gpui::rgb(theme::text_muted())) - .line_clamp(1) - .text_ellipsis() - .child(subtitle), - ) - }), - ) - .when(is_selected, |row| { - row.child( - div() - .text_xs() - .text_color(gpui::rgb(theme::text_muted())) - .child("Enter"), - ) - }), - ); - } - let hint = |keys: &'static str, label: &'static str| { - div() - .flex() - .items_center() - .gap_1() - .text_xs() - .text_color(gpui::rgb(theme::text_muted())) - .child( - div() - .px_1() - .rounded(theme::RADIUS_SM) - .bg(gpui::rgb(theme::bg_input())) - .font_family(crate::assets::FONT_MONO) - .child(keys), - ) - .child(label) - }; - div() - .id("project-picker-backdrop") - .absolute() - .size_full() - .top_0() - .left_0() - .occlude() - .bg(theme::scrim()) - .flex() - .items_start() - .justify_center() - .pt(px(96.)) - .on_click(cx.listener(|this, _event, _window, cx| { - this.close_project_picker(cx); - })) - .child( - div() - .id("project-picker") - .role(gpui::Role::Dialog) - .aria_label("Choose a project") - .w(px(640.)) - .max_w_full() - .rounded(theme::RADIUS_XL) - .shadow_lg() - .bg(gpui::rgb(theme::bg_elevated())) - .border_1() - .border_color(gpui::rgb(theme::border())) - .flex() - .flex_col() - .on_click(|_event, _window, cx| cx.stop_propagation()) - .child( - div() - .flex() - .flex_col() - .gap_2() - .px_4() - .pt_4() - .pb_2() - .child( - div() - .flex() - .items_baseline() - .gap_2() - .child( - div() - .text_lg() - .font_weight(gpui::FontWeight::SEMIBOLD) - .text_color(gpui::rgb(theme::text_primary())) - .child("Choose a project"), - ) - .child( - div() - .flex() - .gap_1() - .text_sm() - .text_color(gpui::rgb(theme::text_muted())) - .child("on") - .child(self.target_host_label.clone()), - ), - ) - .when_some(self.root_input.clone(), |col, input| { - col.child(widgets::input_frame().text_sm().child(input)) - }), - ) - .child(div().px_2().pb_2().child(list)) - .child( - div() - .flex() - .items_center() - .gap_4() - .px_4() - .py_2() - .border_t_1() - .border_color(gpui::rgb(theme::border())) - .child(hint("\u{2191}\u{2193}", "Navigate")) - .child(hint("Enter", "Open")) - .child(hint("Esc", "Close")), - ), - ) - } -} - fn chip( id: &'static str, leading: Option<&'static str>, diff --git a/apps/maple-agent/app/src/ui/chat/hosts.rs b/apps/maple-agent/app/src/ui/chat/hosts.rs new file mode 100644 index 000000000..d598547b2 --- /dev/null +++ b/apps/maple-agent/app/src/ui/chat/hosts.rs @@ -0,0 +1,781 @@ +//! The hosts the chat screen shows tasks from: the local host and every +//! saved remote host, which one new tasks target, the sidebar's host +//! filter, and the header's host chip. Calls about a task go to the host +//! that owns it, whatever host new tasks target. + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use gpui::{Context, Div, SharedString, div, prelude::*, px}; +use maple_agent::agent::AgentSessionSummary; +use maple_agent::host::{HostBackend, HostBootstrap, HostEvent, HostId, HostSessionDefaults}; +use maple_remote::hosts::SavedHost; +use maple_remote::manager::{HostManagerEvent, HostStatus}; + +use super::{ChatScreen, OpenSettingsSection, SIDEBAR_COLLAPSED_INSET, Section, sidebar}; +use crate::ui::icons::icon; +use crate::ui::theme; + +/// What the local host is called wherever hosts are listed. +pub(super) const LOCAL_HOST_NAME: &str = "This computer"; + +/// One host the chat screen shows tasks from: the local host always, and +/// each remote host as it connects or as a saved entry waiting to. +pub(super) struct ChatHost { + /// Absent for a saved host that is not connected. + pub(super) backend: Option>, + pub(super) name: String, + pub(super) online: bool, + /// Bumped on every connection state change. A call made on one + /// connection whose answer lands on another is dropped: the host was + /// re-read when it came back. + pub(super) connection: u64, + /// The host's project context, as its bootstrap reported it and as + /// the user changed it while the host was the target; adopted again + /// when the host becomes the target. + pub(super) project_root: Option, + pub(super) recent_roots: Vec, + pub(super) session_defaults: Option, +} + +impl ChatHost { + pub(super) fn local(backend: Arc) -> Self { + Self { + backend: Some(backend), + name: LOCAL_HOST_NAME.to_string(), + online: true, + connection: 0, + project_root: None, + recent_roots: Vec::new(), + session_defaults: None, + } + } + + pub(super) fn saved(name: String) -> Self { + Self { + backend: None, + name, + online: false, + connection: 0, + project_root: None, + recent_roots: Vec::new(), + session_defaults: None, + } + } +} + +/// What reading a freshly connected host produced. +pub(super) struct RemoteBootstrap { + pub(super) boot: HostBootstrap, + /// Why its runtime did not start, if it did not. + pub(super) start_error: Option, + /// Stored tool summaries of its latest task, when that task will open. + pub(super) summaries: HashMap, +} + +impl ChatScreen { + /// Every known host, local first, then by name. + pub(super) fn sorted_hosts(&self) -> Vec { + let mut hosts: Vec = self + .hosts + .iter() + .map(|(id, entry)| sidebar::SidebarHost { + id: id.clone(), + name: SharedString::from(entry.name.clone()), + online: entry.online, + }) + .collect(); + hosts.sort_by(|a, b| { + b.id.is_local() + .cmp(&a.id.is_local()) + .then_with(|| a.name.as_ref().cmp(b.name.as_ref())) + }); + hosts + } + + /// `hosts` changed (a host came, went, or was renamed): rebuild what + /// renders from it. + pub(super) fn hosts_changed(&mut self) { + self.host_list = self.sorted_hosts(); + self.hosts_dirty = true; + self.refresh_target_host_label(); + } + + pub(super) fn refresh_target_host_label(&mut self) { + self.target_host_label = SharedString::from(self.host_name(&self.target_host)); + } + + pub(super) fn host_name(&self, id: &HostId) -> String { + self.hosts + .get(id) + .map(|entry| entry.name.clone()) + .unwrap_or_else(|| id.to_string()) + } + + /// The host that owns `session_id`; the local host when unknown. + pub(super) fn host_of(&self, session_id: &str) -> HostId { + self.session_hosts + .get(session_id) + .cloned() + .unwrap_or_else(HostId::local) + } + + /// The backend that owns `session_id`: every call about that task goes + /// there, whatever host new tasks target. The target's backend stands + /// in for a task whose host is unknown. + pub(super) fn backend_for(&self, session_id: &str) -> Arc { + let owner = self.host_of(session_id); + self.hosts + .get(&owner) + .and_then(|entry| entry.backend.clone()) + .unwrap_or_else(|| self.host.clone()) + } + + /// The backend of the task on screen; the target's with none. + pub(super) fn session_backend(&self) -> Arc { + match self.selected_session.as_deref() { + Some(session_id) => self.backend_for(session_id), + None => self.host.clone(), + } + } + + /// File `session_id` under `host` unless it has a host already. + pub(super) fn file_session(&mut self, session_id: &str, host: &HostId) { + if self.session_hosts.contains_key(session_id) { + return; + } + self.session_hosts + .insert(session_id.to_string(), host.clone()); + self.hosts_dirty = true; + } + + /// Make `host` the target of new tasks. The selected task's host is + /// the target unless the sidebar filters on one. Returns whether + /// `host` is the target: an offline or unknown host cannot take new + /// tasks and is refused. + pub(super) fn set_target_host(&mut self, host: HostId, cx: &mut Context) -> bool { + if self.target_host == host { + return true; + } + let Some((backend, recent_roots, defaults)) = self + .hosts + .get(&host) + .filter(|entry| entry.online) + .and_then(|entry| { + Some(( + entry.backend.clone()?, + entry.recent_roots.clone(), + entry.session_defaults.clone(), + )) + }) + else { + return false; + }; + self.target_host = host; + self.host = backend; + self.host_menu_open = false; + self.refresh_target_host_label(); + self.recent_roots = recent_roots; + // New tasks take this host's defaults (web access, permission + // mode), whichever task is on screen. + if let Some(defaults) = defaults { + self.apply_session_defaults(&defaults, cx); + } + self.refresh_roots(cx); + self.refresh_slash_commands(cx); + self.sync_sidebar(cx); + true + } + + /// Show the target host's saved project, for when the target changed + /// without a task selection. + pub(super) fn adopt_target_host_context(&mut self, cx: &mut Context) { + let Some(root) = self + .hosts + .get(&self.target_host) + .map(|entry| entry.project_root.clone()) + else { + return; + }; + self.set_project_context(root, cx); + } + + /// The sidebar filters on `host` (or on none): new tasks go there. A + /// filter on an offline host is refused, since it could not take them; + /// the sidebar then shows the filter that stands. + pub(super) fn set_host_filter(&mut self, host: Option, cx: &mut Context) { + let target = match &host { + Some(host) => host.clone(), + None => self + .selected_session + .as_deref() + .map(|id| self.host_of(id)) + .filter(|owner| self.hosts.get(owner).is_some_and(|entry| entry.online)) + .unwrap_or_else(HostId::local), + }; + let changed = target != self.target_host; + if !self.set_target_host(target.clone(), cx) { + self.notice = Some(format!("{} is offline", self.host_name(&target)).into()); + self.sync_host_filter(cx); + cx.notify(); + return; + } + self.host_filter = host; + if changed { + self.adopt_target_host_context(cx); + } + cx.notify(); + } + + /// Push the host filter to the sidebar, which shows it. + pub(super) fn sync_host_filter(&mut self, cx: &mut Context) { + let filter = self.host_filter.clone(); + self.sidebar + .update(cx, |sidebar, cx| sidebar.show_host_filter(filter, cx)); + } + + /// `host` cannot be filtered on any more (offline or removed): a + /// filter naming it goes, on screen and in the sidebar. + pub(super) fn clear_host_filter_for(&mut self, host: &HostId, cx: &mut Context) { + if self.host_filter.as_ref() != Some(host) { + return; + } + self.host_filter = None; + self.sync_host_filter(cx); + } + + /// `host` dropped while it was the target: new tasks go to the local + /// host, and with no task on screen the header shows its project. + pub(super) fn fall_back_to_local_host(&mut self, host: &HostId, cx: &mut Context) { + self.clear_host_filter_for(host, cx); + if self.target_host != *host { + return; + } + self.set_target_host(HostId::local(), cx); + if self.selected_session.is_none() { + self.adopt_target_host_context(cx); + } + } + + /// Hosts a settings screen can point at: every connected one. + pub(crate) fn connected_hosts(&self) -> Vec { + self.host_list + .iter() + .filter(|host| host.online) + .filter_map(|host| { + Some(crate::ui::settings::SettingsHost { + id: host.id.clone(), + name: host.name.to_string(), + backend: self.hosts.get(&host.id)?.backend.clone()?, + }) + }) + .collect() + } + + /// The saved host list changed: list saved hosts that are not + /// connected as offline, and drop hosts that were removed. + pub fn set_saved_hosts(&mut self, saved: Vec, cx: &mut Context) { + let keep: HashSet = saved + .iter() + .map(|host| HostId::new(host.id.clone())) + .chain(std::iter::once(HostId::local())) + .collect(); + let removed: Vec = self + .hosts + .keys() + .filter(|id| !keep.contains(id)) + .cloned() + .collect(); + for id in removed { + self.drop_host_sessions(&id); + self.hosts.remove(&id); + self.fall_back_to_local_host(&id, cx); + } + // A remembered host that is not saved any more is not coming back. + if let Some(host) = self + .restore_host + .clone() + .filter(|host| !keep.contains(host)) + { + self.give_up_restore(&host, cx); + } + for host in saved { + let id = HostId::new(host.id); + match self.hosts.get_mut(&id) { + Some(entry) => entry.name = host.name, + None => { + self.hosts.insert(id, ChatHost::saved(host.name)); + } + } + } + self.hosts_changed(); + self.sync_sidebar(cx); + cx.notify(); + } + + /// A remote host's connection changed. Online: adopt it and read its + /// tasks. Otherwise its tasks leave the list until it is back; the + /// task on screen stays readable. + pub fn set_remote_host_status( + &mut self, + host: HostId, + name: String, + status: HostStatus, + backend: Option>, + cx: &mut Context, + ) { + let online = status == HostStatus::Online; + let entry = self + .hosts + .entry(host.clone()) + .or_insert_with(|| ChatHost::saved(name.clone())); + let was_online = entry.online; + entry.name = name; + entry.online = online; + entry.connection += 1; + let connection = entry.connection; + if let Some(backend) = backend { + entry.backend = Some(backend); + } + self.hosts_changed(); + if online { + self.bootstrap_remote_host(host, connection, cx); + } else { + self.drop_host_sessions(&host); + self.fall_back_to_local_host(&host, cx); + if let HostStatus::Offline { reason } = status { + self.give_up_restore(&host, cx); + // The drop itself is news; the reconnect attempts that + // follow report the same thing until the host is back. + if was_online && reason != "removed" { + self.notice = Some(format!("{}: {reason}", self.host_name(&host)).into()); + } + } + self.sync_sidebar(cx); + } + cx.notify(); + } + + /// Forget a host's tasks in the list and their runs. The selected task + /// keeps its host mapping so its screen stays coherent. + pub(super) fn drop_host_sessions(&mut self, host: &HostId) { + let selected = self.selected_session.clone(); + let gone: HashSet = self + .session_hosts + .iter() + .filter(|(_, owner)| *owner == host) + .map(|(id, _)| id.clone()) + .collect(); + self.sessions.retain(|session| !gone.contains(&session.id)); + for id in &gone { + self.active_runs.remove(id); + self.completed_unread_sessions.remove(id); + if selected.as_deref() != Some(id.as_str()) { + self.session_hosts.remove(id); + self.hosts_dirty = true; + } + } + } + + /// Read a freshly connected host: its tasks, roots, and defaults, and + /// start its runtime so it can run them. `connection` names the + /// connection the read is for; an answer from an earlier one is stale. + pub(super) fn bootstrap_remote_host( + &mut self, + host: HostId, + connection: u64, + cx: &mut Context, + ) { + let Some(backend) = self + .hosts + .get(&host) + .and_then(|entry| entry.backend.clone()) + else { + return; + }; + let target = host.clone(); + let restoring = self.restore_host.as_ref() == Some(&host); + self.call( + async move { + let boot = backend.bootstrap().await?; + let start_error = backend.start_runtime(None).await.err(); + // Only the restored host opens its latest task, so only it + // needs that task's stored summaries. + let summaries = match boot.latest.as_ref().filter(|_| restoring) { + Some(detail) => backend + .tool_summaries(detail.session.id.clone()) + .await + .unwrap_or_else(|error| { + log::warn!("Cannot load tool summaries: {error}"); + HashMap::new() + }), + None => HashMap::new(), + }; + Ok::<_, String>(RemoteBootstrap { + boot, + start_error, + summaries, + }) + }, + cx, + move |this, result, cx| this.finish_remote_bootstrap(target, connection, result, cx), + ); + } + + /// Whether `host` is still on the connection a call was made on. + pub(super) fn on_connection(&self, host: &HostId, connection: u64) -> bool { + self.hosts + .get(host) + .is_some_and(|entry| entry.connection == connection) + } + + /// The bootstrap of `target` came back. A failure releases a startup + /// held for that host: it will not open its task. An answer from a + /// connection that has since dropped or been replaced is stale: the + /// host's tasks left with it, or its new connection reads it afresh. + pub(super) fn finish_remote_bootstrap( + &mut self, + target: HostId, + connection: u64, + result: Result, + cx: &mut Context, + ) { + if !self.on_connection(&target, connection) { + return; + } + match result { + Ok(RemoteBootstrap { + boot, + start_error, + summaries, + }) => { + self.apply_remote_bootstrap(target, boot, start_error, summaries, cx); + } + Err(message) => { + self.notice = Some(format!("{}: {message}", self.host_name(&target)).into()); + self.give_up_restore(&target, cx); + } + } + cx.notify(); + } + + /// A remote host answered its bootstrap. When it is the host the last + /// new task ran on, it becomes the target again and its latest task + /// opens, unless a task was chosen meanwhile. + pub(super) fn apply_remote_bootstrap( + &mut self, + target: HostId, + boot: HostBootstrap, + start_error: Option, + summaries: HashMap, + cx: &mut Context, + ) { + if let Some(entry) = self.hosts.get_mut(&target) { + entry.project_root = boot.project_root.clone(); + entry.recent_roots = boot.recent_roots.clone(); + entry.session_defaults = Some(boot.session_defaults.clone()); + } + self.apply_host_session_list(&target, boot.sessions, cx); + if let Some(error) = start_error { + self.notice = Some( + format!( + "{}: runtime failed to start: {error}", + self.host_name(&target) + ) + .into(), + ); + } + let restoring = self.restore_host.as_ref() == Some(&target); + if restoring { + self.restore_host = None; + if self.selected_session.is_none() && self.host_filter.is_none() { + self.set_target_host(target.clone(), cx); + } + } + if self.target_host == target { + self.recent_roots = boot.recent_roots; + self.adopt_target_host_context(cx); + if let Some(detail) = boot + .latest + .filter(|_| restoring && self.selected_session.is_none()) + { + let summaries = summaries + .into_iter() + .map(|(id, summary)| (id, SharedString::from(summary))) + .collect(); + self.upsert_session(detail.session.clone(), cx); + self.set_active_session(detail.session, detail.timeline, summaries, cx); + self.queue = detail.queue.items; + } + } + } + + /// The remembered host will not come: stop holding startup for it and + /// let the local auto-select run. + pub(super) fn give_up_restore(&mut self, host: &HostId, cx: &mut Context) { + if self.restore_host.as_ref() != Some(host) { + return; + } + self.restore_host = None; + if self.selected_session.is_none() { + self.refresh_sessions(cx); + } + } + + /// Everything the connection manager reports, in one batch. A repaint + /// is requested once per batch, however many events change something. + pub fn handle_manager_events(&mut self, events: Vec, cx: &mut Context) { + for event in events { + match event { + HostManagerEvent::Event { host, event } => { + self.handle_remote_host_events(host, vec![event], cx); + } + HostManagerEvent::Status { + host, + name, + status, + backend, + } => self.set_remote_host_status( + host, + name, + status, + backend.map(|backend| backend as Arc), + cx, + ), + HostManagerEvent::HostsChanged(hosts) => self.set_saved_hosts(hosts, cx), + } + } + } + + /// Events from a remote host; dropped once it is offline. + pub fn handle_remote_host_events( + &mut self, + host: HostId, + events: Vec, + cx: &mut Context, + ) { + if !self.hosts.get(&host).is_some_and(|entry| entry.online) { + return; + } + self.apply_host_events(&host, events, cx); + } + + /// Replace one host's tasks in the merged list. A task the list names + /// leaves whatever host it was filed under: the list is the truth + /// about where it lives, and one row per task is the invariant. + pub(super) fn apply_host_session_list( + &mut self, + host: &HostId, + sessions: Vec, + cx: &mut Context, + ) { + let listed: HashSet<&str> = sessions.iter().map(|session| session.id.as_str()).collect(); + let local = HostId::local(); + let session_hosts = &self.session_hosts; + self.sessions.retain(|session| { + !listed.contains(session.id.as_str()) + && session_hosts.get(&session.id).unwrap_or(&local) != host + }); + // A task the host no longer lists is gone from it; the task on + // screen keeps its mapping so its calls still know where to go. + let selected = self.selected_session.clone(); + self.session_hosts.retain(|id, owner| { + owner != host || listed.contains(id.as_str()) || selected.as_deref() == Some(id) + }); + for session in &sessions { + self.session_hosts.insert(session.id.clone(), host.clone()); + } + self.hosts_dirty = true; + self.sessions.extend(sessions); + self.sessions + .sort_by_key(|session| std::cmp::Reverse(session.updated_ms)); + self.sync_sidebar(cx); + } + + /// Re-read every connected host's task list. The sidebar groups tasks + /// by project, so each host lists every root; a task's stored root + /// remains authoritative when it is opened or run. + pub(super) fn refresh_sessions(&self, cx: &mut Context) { + let generation = self.selection_generation; + for (id, entry) in &self.hosts { + let Some(backend) = entry.backend.clone().filter(|_| entry.online) else { + continue; + }; + let id = id.clone(); + let connection = entry.connection; + self.call( + async move { backend.list_sessions(None).await }, + cx, + move |this, result, cx| { + this.apply_listed_sessions(&id, connection, generation, result, cx) + }, + ); + } + } + + /// One host answered `refresh_sessions`. A list from a connection that + /// has since changed is stale: the host's tasks left with it, or its + /// new connection lists them again. + pub(super) fn apply_listed_sessions( + &mut self, + host: &HostId, + connection: u64, + generation: u64, + result: Result, String>, + cx: &mut Context, + ) { + if !self.on_connection(host, connection) { + return; + } + match result { + Ok(sessions) if host.is_local() => self.apply_session_list(sessions, generation, cx), + Ok(sessions) => self.apply_host_session_list(host, sessions, cx), + Err(message) => self.notice = Some(message.into()), + } + cx.notify(); + } + + pub(super) fn toggle_host_menu(&mut self, cx: &mut Context) { + self.models_menu_open = false; + self.mode_menu_open = false; + self.mcp_menu_open = false; + self.host_menu_open = !self.host_menu_open; + cx.notify(); + } + + /// The user chose a host in the header chip: new tasks go there, and + /// the project context follows that host. + pub(super) fn pick_host(&mut self, host: HostId, cx: &mut Context) { + self.host_menu_open = false; + let changed = host != self.target_host; + if !self.set_target_host(host.clone(), cx) { + self.notice = Some(format!("{} is offline", self.host_name(&host)).into()); + } else if changed { + self.adopt_target_host_context(cx); + } + cx.notify(); + } + + pub(super) fn target_host_online(&self) -> bool { + self.hosts + .get(&self.target_host) + .is_some_and(|entry| entry.online) + } + + /// The host chip's dropdown: every known host with its state, then a + /// way to the Hosts settings. + pub(super) fn render_host_menu(&self, cx: &mut Context) -> Option
{ + if !self.host_menu_open { + return None; + } + let mut menu = div() + .id("host-menu") + .occlude() + .flex() + .flex_col() + .w(px(300.)) + .py_1() + .rounded(theme::RADIUS_MD) + .bg(gpui::rgb(theme::bg_elevated())) + .border_1() + .border_color(gpui::rgb(theme::border())) + .shadow_md() + .on_mouse_down_out(cx.listener(|this, _event, _window, cx| { + this.host_menu_open = false; + cx.notify(); + })) + .child( + div() + .px_3() + .pt_1() + .pb_1() + .text_xs() + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(gpui::rgb(theme::text_muted())) + .child("NEW TASKS RUN ON"), + ); + for host in &self.host_list { + let current = host.id == self.target_host; + let online = host.online; + let pick = host.id.clone(); + menu = menu.child( + div() + .id(SharedString::from(format!("host-menu-{}", host.id))) + .flex() + .items_center() + .gap_2() + .px_3() + .py_1p5() + .text_sm() + .text_color(gpui::rgb(if online { + theme::text_primary() + } else { + theme::text_muted() + })) + .hover(|style| style.bg(gpui::rgb(theme::bg_input())).cursor_pointer()) + .on_click(cx.listener(move |this, _event, _window, cx| { + this.pick_host(pick.clone(), cx); + })) + .child(status_dot(online)) + .child( + div() + .flex_1() + .min_w_0() + .line_clamp(1) + .text_ellipsis() + .child(host.name.clone()), + ) + .when(!online, |row| { + row.child( + div() + .text_xs() + .text_color(gpui::rgb(theme::text_muted())) + .child("offline"), + ) + }) + .when(current, |row| { + row.child(icon("check", px(14.), theme::accent())) + }), + ); + } + menu = menu + .child(div().h(px(1.)).mx_2().my_1().bg(gpui::rgb(theme::border()))) + .child( + div() + .id("host-menu-manage") + .px_3() + .py_1p5() + .text_sm() + .text_color(gpui::rgb(theme::text_secondary())) + .hover(|style| style.bg(gpui::rgb(theme::bg_input())).cursor_pointer()) + .on_click(cx.listener(|this, _event, _window, cx| { + this.host_menu_open = false; + cx.emit(OpenSettingsSection(Section::Hosts)); + })) + .child("Manage hosts\u{2026}"), + ); + Some( + div() + .absolute() + .top(px(40.)) + .left_4() + .when(self.sidebar_collapsed, |menu| { + menu.left(SIDEBAR_COLLAPSED_INSET) + }) + .child(menu), + ) + } +} + +/// A small live-state dot: green online, muted otherwise. +pub(super) fn status_dot(online: bool) -> Div { + div() + .flex_none() + .size(px(8.)) + .rounded_full() + .bg(gpui::rgb(if online { + theme::status_success() + } else { + theme::text_muted() + })) +} diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index 9dfd4bde6..e2d886aa7 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -15,9 +15,7 @@ use maple_agent::agent::{ AgentSendMessageRequest, AgentServiceEvent, AgentSessionMcpServer, AgentSessionSummary, AgentSlashCommand, AgentSubagent, AgentTimelineItem, SideQuestionEvent, }; -use maple_agent::host::{HostBackend, HostBootstrap, HostEvent, HostId, HostSessionDefaults}; -use maple_remote::hosts::SavedHost; -use maple_remote::manager::{HostManagerEvent, HostStatus}; +use maple_agent::host::{HostBackend, HostEvent, HostId, HostSessionDefaults}; use crate::backend::{AgentBackend, PendingPermission, PendingQuestion}; use crate::ui::icons::{icon, spinner, wordmark}; @@ -33,8 +31,10 @@ mod cache; mod commands; mod composer; mod dialogs; +mod hosts; mod images; mod navigation; +mod picker; mod queue; mod sidebar; mod speech; @@ -46,7 +46,9 @@ mod transcript; use self::cache::{DerivedCache, INLINE_PARSE_LIMIT, MarkdownCache, MarkdownKind}; use self::commands::ChatCommand; use self::composer::{SideQuestionPanel, SlashEntry, slash_entries_for}; +use self::hosts::{ChatHost, LOCAL_HOST_NAME}; use self::navigation::ApplicationVimState; +use self::picker::ProjectPicker; #[cfg(test)] use self::sidebar::SessionActivity; use self::sidebar::{Sidebar, SidebarEvent, root_display_name, session_summary_eq}; @@ -83,95 +85,6 @@ pub struct PickQuestionOption { pub struct LoggedOut; -/// What one row of the project picker stands for. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(super) enum PickerRowKind { - /// A project this host used before. - Recent, - /// A folder the host found for the typed text. - Suggestion, - /// The typed text itself, when it looks like a path. - OpenPath, -} - -#[derive(Clone)] -pub(super) struct PickerRow { - pub(super) kind: PickerRowKind, - pub(super) path: String, - pub(super) title: SharedString, - pub(super) subtitle: Option, -} - -/// The project picker while it is open: one search box over the target -/// host's recent projects and folders, keyboard-navigable. -pub(super) struct ProjectPicker { - pub(super) rows: Vec, - pub(super) selected: usize, - /// Bumped per folder request; a late answer is dropped. - generation: u64, - suggestions: Vec, - /// What the rows were built from: the typed text, or the path an - /// arrow filled in. - query: String, - /// The text an arrow just filled in; the box reporting it back is not - /// a new query, so the rows stay put while the highlight moves. - filled: Option, -} - -/// One host the chat screen shows tasks from: the local host always, and -/// each remote host as it connects or as a saved entry waiting to. -pub(super) struct ChatHost { - /// Absent for a saved host that is not connected. - pub(super) backend: Option>, - pub(super) name: String, - pub(super) online: bool, - /// Bumped on every connection state change. A call made on one - /// connection whose answer lands on another is dropped: the host was - /// re-read when it came back. - pub(super) connection: u64, - /// The host's project context, as its bootstrap reported it and as - /// the user changed it while the host was the target; adopted again - /// when the host becomes the target. - pub(super) project_root: Option, - pub(super) recent_roots: Vec, - pub(super) session_defaults: Option, -} - -impl ChatHost { - pub(super) fn local(backend: Arc) -> Self { - Self { - backend: Some(backend), - name: LOCAL_HOST_NAME.to_string(), - online: true, - connection: 0, - project_root: None, - recent_roots: Vec::new(), - session_defaults: None, - } - } - - pub(super) fn saved(name: String) -> Self { - Self { - backend: None, - name, - online: false, - connection: 0, - project_root: None, - recent_roots: Vec::new(), - session_defaults: None, - } - } -} - -/// What reading a freshly connected host produced. -struct RemoteBootstrap { - boot: HostBootstrap, - /// Why its runtime did not start, if it did not. - start_error: Option, - /// Stored tool summaries of its latest task, when that task will open. - summaries: HashMap, -} - /// Emitted when the user opens app settings from the chat header. pub struct OpenSettings; @@ -194,8 +107,6 @@ const SIDEBAR_COLLAPSED_INSET: gpui::Pixels = px(220.); const CONTENT_WIDTH: gpui::Pixels = px(900.); /// Header title when no task is selected. const DEFAULT_TASK_TITLE: &str = "New Task"; -/// What the local host is called wherever hosts are listed. -pub(super) const LOCAL_HOST_NAME: &str = "This computer"; /// How long a notice stays before it clears itself. const NOTICE_TTL: std::time::Duration = std::time::Duration::from_secs(8); @@ -1487,255 +1398,6 @@ impl ChatScreen { cx.notify(); } - /// The project chip or its shortcut: open the picker, or close it when - /// it is open. - fn toggle_project_picker(&mut self, cx: &mut Context) { - if self.project_picker.is_some() { - self.close_project_picker(cx); - } else { - self.open_project_picker(cx); - } - } - - /// Open the project picker: a search box over the target host's recent - /// projects and folders, the one way to choose a project on any host. - /// The search box is created once and reused. - fn open_project_picker(&mut self, cx: &mut Context) { - self.models_menu_open = false; - self.mode_menu_open = false; - self.mcp_menu_open = false; - self.host_menu_open = false; - if self.root_input.is_none() { - let chat = cx.entity().downgrade(); - let key_chat = chat.clone(); - let arrow_chat = chat.clone(); - let application_vim_enabled = self.application_vim_enabled; - let input = cx.new(move |cx| { - TextInput::new("Search folders or enter a path\u{2026}", cx) - .with_tab_index(0) - .application_vim(application_vim_enabled) - .on_key(move |event, _text, _window, cx| { - let Some(chat) = key_chat.upgrade() else { - return false; - }; - match event.keystroke.key.as_str() { - "enter" => chat.update(cx, |chat, cx| chat.project_picker_confirm(cx)), - "escape" => chat.update(cx, |chat, cx| chat.close_project_picker(cx)), - _ => return false, - } - true - }) - .on_vertical(move |delta, _window, cx| { - // The move writes the highlighted path back into - // this input, which is mid-update here: defer it. - let chat = arrow_chat.clone(); - cx.defer(move |cx| { - if let Some(chat) = chat.upgrade() { - chat.update(cx, |chat, cx| chat.project_picker_move(delta, cx)); - } - }); - true - }) - .on_application_escape(move |_window, cx| { - if let Some(chat) = chat.upgrade() { - chat.update(cx, |chat, cx| chat.close_project_picker(cx)); - } - }) - }); - cx.observe(&input, |this, input, cx| { - let query = input.read(cx).text(); - // The box reports every change, not only to its text. The - // rows already show this text when it is the query they - // were built from, or the path an arrow filled in. - if this.project_picker.as_ref().is_some_and(|picker| { - picker.query == query || picker.filled.as_deref() == Some(query.as_str()) - }) { - return; - } - this.refresh_project_picker(query, cx); - }) - .detach(); - self.root_input = Some(input); - } else if let Some(input) = self.root_input.clone() { - input.update(cx, |input, cx| input.set_text("", cx)); - } - self.project_picker = Some(ProjectPicker { - rows: Vec::new(), - selected: 0, - generation: 0, - suggestions: Vec::new(), - query: String::new(), - filled: None, - }); - self.root_input_focus_pending = true; - self.refresh_project_picker(String::new(), cx); - cx.notify(); - } - - pub(super) fn close_project_picker(&mut self, cx: &mut Context) { - if self.project_picker.take().is_some() { - // The composer takes the keyboard back. - self.screen_focus_pending = true; - cx.notify(); - } - } - - /// The search text changed: rebuild the rows now from what is known - /// and ask the host for folders that match. - fn refresh_project_picker(&mut self, query: String, cx: &mut Context) { - let Some(picker) = self.project_picker.as_mut() else { - return; - }; - picker.query = query.clone(); - picker.filled = None; - picker.selected = 0; - picker.generation += 1; - let generation = picker.generation; - self.rebuild_picker_rows(); - // With nothing typed, the current project's row starts highlighted. - if query.trim().is_empty() - && let Some(current) = self.project_root.as_deref() - && let Some(picker) = self.project_picker.as_mut() - && let Some(index) = picker.rows.iter().position(|row| row.path == current) - { - picker.selected = index; - } - let host = self.host.clone(); - self.call( - async move { host.suggest_directories(query).await }, - cx, - move |this, result, cx| { - let Some(picker) = this.project_picker.as_mut() else { - return; - }; - if picker.generation != generation { - return; - } - if let Ok(suggestions) = result { - picker.suggestions = suggestions; - this.rebuild_picker_rows(); - cx.notify(); - } - }, - ); - } - - /// Rows in display order: the typed path itself when it looks like - /// one, recent projects that match, then the host's folders. - fn rebuild_picker_rows(&mut self) { - let Some(mut picker) = self.project_picker.take() else { - return; - }; - let query = picker.query.trim().to_string(); - let needle = query.to_lowercase(); - let mut rows: Vec = Vec::new(); - for root in &self.recent_roots { - if !needle.is_empty() && !root.to_lowercase().contains(&needle) { - continue; - } - rows.push(PickerRow { - kind: PickerRowKind::Recent, - path: root.clone(), - title: SharedString::from(root_display_name(root)), - subtitle: Some(SharedString::from(root.clone())), - }); - } - for suggestion in &picker.suggestions { - if rows.iter().any(|row| row.path == suggestion.path) { - continue; - } - rows.push(PickerRow { - kind: PickerRowKind::Suggestion, - path: suggestion.path.clone(), - title: SharedString::from(suggestion.name.clone()), - subtitle: Some(SharedString::from(suggestion.path.clone())), - }); - } - let looks_like_path = query.starts_with('/') || query.starts_with('~'); - let typed = query.trim_end_matches('/').to_string(); - if looks_like_path && !typed.is_empty() && !rows.iter().any(|row| row.path == typed) { - rows.insert( - 0, - PickerRow { - kind: PickerRowKind::OpenPath, - path: typed.clone(), - title: "Open this path".into(), - subtitle: Some(SharedString::from(typed)), - }, - ); - } - picker.selected = picker.selected.min(rows.len().saturating_sub(1)); - picker.rows = rows; - self.project_picker = Some(picker); - } - - /// Move the highlight and put the highlighted path in the search box, - /// as a shell completes. - pub(super) fn project_picker_move(&mut self, delta: isize, cx: &mut Context) { - let Some(picker) = self.project_picker.as_mut() else { - return; - }; - let len = picker.rows.len(); - if len == 0 { - return; - } - picker.selected = (picker.selected as isize + delta).rem_euclid(len as isize) as usize; - let text = picker.rows[picker.selected].path.clone(); - picker.filled = Some(text.clone()); - if let Some(input) = self.root_input.clone() { - input.update(cx, |input, cx| input.set_text(&text, cx)); - } - cx.notify(); - } - - pub(super) fn project_picker_confirm(&mut self, cx: &mut Context) { - let Some(index) = self.project_picker.as_ref().map(|picker| picker.selected) else { - return; - }; - self.activate_picker_row(index, cx); - } - - /// Open the project a row names. - pub(super) fn activate_picker_row(&mut self, index: usize, cx: &mut Context) { - let Some(path) = self - .project_picker - .as_ref() - .and_then(|picker| picker.rows.get(index)) - .map(|row| row.path.clone()) - else { - return; - }; - self.close_project_picker(cx); - self.select_project_root(path, cx); - } - - pub(super) fn target_host_online(&self) -> bool { - self.hosts - .get(&self.target_host) - .is_some_and(|entry| entry.online) - } - - pub(super) fn toggle_host_menu(&mut self, cx: &mut Context) { - self.models_menu_open = false; - self.mode_menu_open = false; - self.mcp_menu_open = false; - self.host_menu_open = !self.host_menu_open; - cx.notify(); - } - - /// The user chose a host in the header chip: new tasks go there, and - /// the project context follows that host. - pub(super) fn pick_host(&mut self, host: HostId, cx: &mut Context) { - self.host_menu_open = false; - let changed = host != self.target_host; - if !self.set_target_host(host.clone(), cx) { - self.notice = Some(format!("{} is offline", self.host_name(&host)).into()); - } else if changed { - self.adopt_target_host_context(cx); - } - cx.notify(); - } - /// Text for the header project chip. pub fn project_label(&self) -> String { self.project_root @@ -1774,156 +1436,6 @@ impl ChatScreen { ); } - /// Re-read every connected host's task list. The sidebar groups tasks - /// by project, so each host lists every root; a task's stored root - /// remains authoritative when it is opened or run. - fn refresh_sessions(&self, cx: &mut Context) { - let generation = self.selection_generation; - for (id, entry) in &self.hosts { - let Some(backend) = entry.backend.clone().filter(|_| entry.online) else { - continue; - }; - let id = id.clone(); - let connection = entry.connection; - self.call( - async move { backend.list_sessions(None).await }, - cx, - move |this, result, cx| { - this.apply_listed_sessions(&id, connection, generation, result, cx) - }, - ); - } - } - - /// One host answered `refresh_sessions`. A list from a connection that - /// has since changed is stale: the host's tasks left with it, or its - /// new connection lists them again. - fn apply_listed_sessions( - &mut self, - host: &HostId, - connection: u64, - generation: u64, - result: Result, String>, - cx: &mut Context, - ) { - if !self.on_connection(host, connection) { - return; - } - match result { - Ok(sessions) if host.is_local() => self.apply_session_list(sessions, generation, cx), - Ok(sessions) => self.apply_host_session_list(host, sessions, cx), - Err(message) => self.notice = Some(message.into()), - } - cx.notify(); - } - - /// Replace one host's tasks in the merged list. A task the list names - /// leaves whatever host it was filed under: the list is the truth - /// about where it lives, and one row per task is the invariant. - fn apply_host_session_list( - &mut self, - host: &HostId, - sessions: Vec, - cx: &mut Context, - ) { - let listed: HashSet<&str> = sessions.iter().map(|session| session.id.as_str()).collect(); - let local = HostId::local(); - let session_hosts = &self.session_hosts; - self.sessions.retain(|session| { - !listed.contains(session.id.as_str()) - && session_hosts.get(&session.id).unwrap_or(&local) != host - }); - // A task the host no longer lists is gone from it; the task on - // screen keeps its mapping so its calls still know where to go. - let selected = self.selected_session.clone(); - self.session_hosts.retain(|id, owner| { - owner != host || listed.contains(id.as_str()) || selected.as_deref() == Some(id) - }); - for session in &sessions { - self.session_hosts.insert(session.id.clone(), host.clone()); - } - self.hosts_dirty = true; - self.sessions.extend(sessions); - self.sessions - .sort_by_key(|session| std::cmp::Reverse(session.updated_ms)); - self.sync_sidebar(cx); - } - - /// File `session_id` under `host` unless it has a host already. - fn file_session(&mut self, session_id: &str, host: &HostId) { - if self.session_hosts.contains_key(session_id) { - return; - } - self.session_hosts - .insert(session_id.to_string(), host.clone()); - self.hosts_dirty = true; - } - - /// Every known host, local first, then by name. - fn sorted_hosts(&self) -> Vec { - let mut hosts: Vec = self - .hosts - .iter() - .map(|(id, entry)| sidebar::SidebarHost { - id: id.clone(), - name: SharedString::from(entry.name.clone()), - online: entry.online, - }) - .collect(); - hosts.sort_by(|a, b| { - b.id.is_local() - .cmp(&a.id.is_local()) - .then_with(|| a.name.as_ref().cmp(b.name.as_ref())) - }); - hosts - } - - /// `hosts` changed (a host came, went, or was renamed): rebuild what - /// renders from it. - fn hosts_changed(&mut self) { - self.host_list = self.sorted_hosts(); - self.hosts_dirty = true; - self.refresh_target_host_label(); - } - - fn refresh_target_host_label(&mut self) { - self.target_host_label = SharedString::from(self.host_name(&self.target_host)); - } - - fn host_name(&self, id: &HostId) -> String { - self.hosts - .get(id) - .map(|entry| entry.name.clone()) - .unwrap_or_else(|| id.to_string()) - } - - /// The host that owns `session_id`; the local host when unknown. - fn host_of(&self, session_id: &str) -> HostId { - self.session_hosts - .get(session_id) - .cloned() - .unwrap_or_else(HostId::local) - } - - /// The backend that owns `session_id`: every call about that task goes - /// there, whatever host new tasks target. The target's backend stands - /// in for a task whose host is unknown. - pub(super) fn backend_for(&self, session_id: &str) -> Arc { - let owner = self.host_of(session_id); - self.hosts - .get(&owner) - .and_then(|entry| entry.backend.clone()) - .unwrap_or_else(|| self.host.clone()) - } - - /// The backend of the task on screen; the target's with none. - pub(super) fn session_backend(&self) -> Arc { - match self.selected_session.as_deref() { - Some(session_id) => self.backend_for(session_id), - None => self.host.clone(), - } - } - /// The sidebar opened a project's menu and asks whether the project is /// trusted, on the target host; the answer goes back to the menu. fn answer_project_trust(&mut self, root: String, cx: &mut Context) { @@ -1958,412 +1470,6 @@ impl ChatScreen { ); } - /// Make `host` the target of new tasks. The selected task's host is - /// the target unless the sidebar filters on one. Returns whether - /// `host` is the target: an offline or unknown host cannot take new - /// tasks and is refused. - fn set_target_host(&mut self, host: HostId, cx: &mut Context) -> bool { - if self.target_host == host { - return true; - } - let Some((backend, recent_roots, defaults)) = self - .hosts - .get(&host) - .filter(|entry| entry.online) - .and_then(|entry| { - Some(( - entry.backend.clone()?, - entry.recent_roots.clone(), - entry.session_defaults.clone(), - )) - }) - else { - return false; - }; - self.target_host = host; - self.host = backend; - self.host_menu_open = false; - self.refresh_target_host_label(); - self.recent_roots = recent_roots; - // New tasks take this host's defaults (web access, permission - // mode), whichever task is on screen. - if let Some(defaults) = defaults { - self.apply_session_defaults(&defaults, cx); - } - self.refresh_roots(cx); - self.refresh_slash_commands(cx); - self.sync_sidebar(cx); - true - } - - /// Show the target host's saved project, for when the target changed - /// without a task selection. - fn adopt_target_host_context(&mut self, cx: &mut Context) { - let Some(root) = self - .hosts - .get(&self.target_host) - .map(|entry| entry.project_root.clone()) - else { - return; - }; - self.set_project_context(root, cx); - } - - /// The sidebar filters on `host` (or on none): new tasks go there. A - /// filter on an offline host is refused, since it could not take them; - /// the sidebar then shows the filter that stands. - fn set_host_filter(&mut self, host: Option, cx: &mut Context) { - let target = match &host { - Some(host) => host.clone(), - None => self - .selected_session - .as_deref() - .map(|id| self.host_of(id)) - .filter(|owner| self.hosts.get(owner).is_some_and(|entry| entry.online)) - .unwrap_or_else(HostId::local), - }; - let changed = target != self.target_host; - if !self.set_target_host(target.clone(), cx) { - self.notice = Some(format!("{} is offline", self.host_name(&target)).into()); - self.sync_host_filter(cx); - cx.notify(); - return; - } - self.host_filter = host; - if changed { - self.adopt_target_host_context(cx); - } - cx.notify(); - } - - /// Push the host filter to the sidebar, which shows it. - fn sync_host_filter(&mut self, cx: &mut Context) { - let filter = self.host_filter.clone(); - self.sidebar - .update(cx, |sidebar, cx| sidebar.show_host_filter(filter, cx)); - } - - /// `host` cannot be filtered on any more (offline or removed): a - /// filter naming it goes, on screen and in the sidebar. - fn clear_host_filter_for(&mut self, host: &HostId, cx: &mut Context) { - if self.host_filter.as_ref() != Some(host) { - return; - } - self.host_filter = None; - self.sync_host_filter(cx); - } - - /// `host` dropped while it was the target: new tasks go to the local - /// host, and with no task on screen the header shows its project. - fn fall_back_to_local_host(&mut self, host: &HostId, cx: &mut Context) { - self.clear_host_filter_for(host, cx); - if self.target_host != *host { - return; - } - self.set_target_host(HostId::local(), cx); - if self.selected_session.is_none() { - self.adopt_target_host_context(cx); - } - } - - /// Hosts a settings screen can point at: every connected one. - pub(crate) fn connected_hosts(&self) -> Vec { - self.host_list - .iter() - .filter(|host| host.online) - .filter_map(|host| { - Some(crate::ui::settings::SettingsHost { - id: host.id.clone(), - name: host.name.to_string(), - backend: self.hosts.get(&host.id)?.backend.clone()?, - }) - }) - .collect() - } - - /// The saved host list changed: list saved hosts that are not - /// connected as offline, and drop hosts that were removed. - pub fn set_saved_hosts(&mut self, saved: Vec, cx: &mut Context) { - let keep: HashSet = saved - .iter() - .map(|host| HostId::new(host.id.clone())) - .chain(std::iter::once(HostId::local())) - .collect(); - let removed: Vec = self - .hosts - .keys() - .filter(|id| !keep.contains(id)) - .cloned() - .collect(); - for id in removed { - self.drop_host_sessions(&id); - self.hosts.remove(&id); - self.fall_back_to_local_host(&id, cx); - } - // A remembered host that is not saved any more is not coming back. - if let Some(host) = self - .restore_host - .clone() - .filter(|host| !keep.contains(host)) - { - self.give_up_restore(&host, cx); - } - for host in saved { - let id = HostId::new(host.id); - match self.hosts.get_mut(&id) { - Some(entry) => entry.name = host.name, - None => { - self.hosts.insert(id, ChatHost::saved(host.name)); - } - } - } - self.hosts_changed(); - self.sync_sidebar(cx); - cx.notify(); - } - - /// A remote host's connection changed. Online: adopt it and read its - /// tasks. Otherwise its tasks leave the list until it is back; the - /// task on screen stays readable. - pub fn set_remote_host_status( - &mut self, - host: HostId, - name: String, - status: HostStatus, - backend: Option>, - cx: &mut Context, - ) { - let online = status == HostStatus::Online; - let entry = self - .hosts - .entry(host.clone()) - .or_insert_with(|| ChatHost::saved(name.clone())); - let was_online = entry.online; - entry.name = name; - entry.online = online; - entry.connection += 1; - let connection = entry.connection; - if let Some(backend) = backend { - entry.backend = Some(backend); - } - self.hosts_changed(); - if online { - self.bootstrap_remote_host(host, connection, cx); - } else { - self.drop_host_sessions(&host); - self.fall_back_to_local_host(&host, cx); - if let HostStatus::Offline { reason } = status { - self.give_up_restore(&host, cx); - // The drop itself is news; the reconnect attempts that - // follow report the same thing until the host is back. - if was_online && reason != "removed" { - self.notice = Some(format!("{}: {reason}", self.host_name(&host)).into()); - } - } - self.sync_sidebar(cx); - } - cx.notify(); - } - - /// Forget a host's tasks in the list and their runs. The selected task - /// keeps its host mapping so its screen stays coherent. - fn drop_host_sessions(&mut self, host: &HostId) { - let selected = self.selected_session.clone(); - let gone: HashSet = self - .session_hosts - .iter() - .filter(|(_, owner)| *owner == host) - .map(|(id, _)| id.clone()) - .collect(); - self.sessions.retain(|session| !gone.contains(&session.id)); - for id in &gone { - self.active_runs.remove(id); - self.completed_unread_sessions.remove(id); - if selected.as_deref() != Some(id.as_str()) { - self.session_hosts.remove(id); - self.hosts_dirty = true; - } - } - } - - /// Read a freshly connected host: its tasks, roots, and defaults, and - /// start its runtime so it can run them. `connection` names the - /// connection the read is for; an answer from an earlier one is stale. - fn bootstrap_remote_host(&mut self, host: HostId, connection: u64, cx: &mut Context) { - let Some(backend) = self - .hosts - .get(&host) - .and_then(|entry| entry.backend.clone()) - else { - return; - }; - let target = host.clone(); - let restoring = self.restore_host.as_ref() == Some(&host); - self.call( - async move { - let boot = backend.bootstrap().await?; - let start_error = backend.start_runtime(None).await.err(); - // Only the restored host opens its latest task, so only it - // needs that task's stored summaries. - let summaries = match boot.latest.as_ref().filter(|_| restoring) { - Some(detail) => backend - .tool_summaries(detail.session.id.clone()) - .await - .unwrap_or_else(|error| { - log::warn!("Cannot load tool summaries: {error}"); - HashMap::new() - }), - None => HashMap::new(), - }; - Ok::<_, String>(RemoteBootstrap { - boot, - start_error, - summaries, - }) - }, - cx, - move |this, result, cx| this.finish_remote_bootstrap(target, connection, result, cx), - ); - } - - /// Whether `host` is still on the connection a call was made on. - fn on_connection(&self, host: &HostId, connection: u64) -> bool { - self.hosts - .get(host) - .is_some_and(|entry| entry.connection == connection) - } - - /// The bootstrap of `target` came back. A failure releases a startup - /// held for that host: it will not open its task. An answer from a - /// connection that has since dropped or been replaced is stale: the - /// host's tasks left with it, or its new connection reads it afresh. - fn finish_remote_bootstrap( - &mut self, - target: HostId, - connection: u64, - result: Result, - cx: &mut Context, - ) { - if !self.on_connection(&target, connection) { - return; - } - match result { - Ok(RemoteBootstrap { - boot, - start_error, - summaries, - }) => { - self.apply_remote_bootstrap(target, boot, start_error, summaries, cx); - } - Err(message) => { - self.notice = Some(format!("{}: {message}", self.host_name(&target)).into()); - self.give_up_restore(&target, cx); - } - } - cx.notify(); - } - - /// A remote host answered its bootstrap. When it is the host the last - /// new task ran on, it becomes the target again and its latest task - /// opens, unless a task was chosen meanwhile. - fn apply_remote_bootstrap( - &mut self, - target: HostId, - boot: HostBootstrap, - start_error: Option, - summaries: HashMap, - cx: &mut Context, - ) { - if let Some(entry) = self.hosts.get_mut(&target) { - entry.project_root = boot.project_root.clone(); - entry.recent_roots = boot.recent_roots.clone(); - entry.session_defaults = Some(boot.session_defaults.clone()); - } - self.apply_host_session_list(&target, boot.sessions, cx); - if let Some(error) = start_error { - self.notice = Some( - format!( - "{}: runtime failed to start: {error}", - self.host_name(&target) - ) - .into(), - ); - } - let restoring = self.restore_host.as_ref() == Some(&target); - if restoring { - self.restore_host = None; - if self.selected_session.is_none() && self.host_filter.is_none() { - self.set_target_host(target.clone(), cx); - } - } - if self.target_host == target { - self.recent_roots = boot.recent_roots; - self.adopt_target_host_context(cx); - if let Some(detail) = boot - .latest - .filter(|_| restoring && self.selected_session.is_none()) - { - let summaries = summaries - .into_iter() - .map(|(id, summary)| (id, SharedString::from(summary))) - .collect(); - self.upsert_session(detail.session.clone(), cx); - self.set_active_session(detail.session, detail.timeline, summaries, cx); - self.queue = detail.queue.items; - } - } - } - - /// The remembered host will not come: stop holding startup for it and - /// let the local auto-select run. - fn give_up_restore(&mut self, host: &HostId, cx: &mut Context) { - if self.restore_host.as_ref() != Some(host) { - return; - } - self.restore_host = None; - if self.selected_session.is_none() { - self.refresh_sessions(cx); - } - } - - /// Everything the connection manager reports, in one batch. A repaint - /// is requested once per batch, however many events change something. - pub fn handle_manager_events(&mut self, events: Vec, cx: &mut Context) { - for event in events { - match event { - HostManagerEvent::Event { host, event } => { - self.handle_remote_host_events(host, vec![event], cx); - } - HostManagerEvent::Status { - host, - name, - status, - backend, - } => self.set_remote_host_status( - host, - name, - status, - backend.map(|backend| backend as Arc), - cx, - ), - HostManagerEvent::HostsChanged(hosts) => self.set_saved_hosts(hosts, cx), - } - } - } - - /// Events from a remote host; dropped once it is offline. - pub fn handle_remote_host_events( - &mut self, - host: HostId, - events: Vec, - cx: &mut Context, - ) { - if !self.hosts.get(&host).is_some_and(|entry| entry.online) { - return; - } - self.apply_host_events(&host, events, cx); - } - /// Take a fresh session list. With nothing on screen, open the latest /// task of the visible project or create one, but only when no task or /// project was selected since the list was requested: a click whose diff --git a/apps/maple-agent/app/src/ui/chat/picker.rs b/apps/maple-agent/app/src/ui/chat/picker.rs new file mode 100644 index 000000000..8854ba11d --- /dev/null +++ b/apps/maple-agent/app/src/ui/chat/picker.rs @@ -0,0 +1,453 @@ +//! The project picker: one dialog for every host, with a search box over +//! the target host's recent projects and folder suggestions, driven by +//! the keyboard. + +use gpui::{Context, Div, SharedString, div, prelude::*, px}; + +use super::ChatScreen; +use super::sidebar::root_display_name; +use crate::ui::icons::icon; +use crate::ui::text_input::TextInput; +use crate::ui::theme; +use crate::ui::widgets; + +/// What one row of the project picker stands for. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum PickerRowKind { + /// A project this host used before. + Recent, + /// A folder the host found for the typed text. + Suggestion, + /// The typed text itself, when it looks like a path. + OpenPath, +} + +#[derive(Clone)] +pub(super) struct PickerRow { + pub(super) kind: PickerRowKind, + pub(super) path: String, + pub(super) title: SharedString, + pub(super) subtitle: Option, +} + +/// The project picker while it is open: one search box over the target +/// host's recent projects and folders, keyboard-navigable. +pub(super) struct ProjectPicker { + pub(super) rows: Vec, + pub(super) selected: usize, + /// Bumped per folder request; a late answer is dropped. + generation: u64, + suggestions: Vec, + /// What the rows were built from: the typed text, or the path an + /// arrow filled in. + query: String, + /// The text an arrow just filled in; the box reporting it back is not + /// a new query, so the rows stay put while the highlight moves. + filled: Option, +} + +impl ChatScreen { + /// The project chip or its shortcut: open the picker, or close it when + /// it is open. + pub(super) fn toggle_project_picker(&mut self, cx: &mut Context) { + if self.project_picker.is_some() { + self.close_project_picker(cx); + } else { + self.open_project_picker(cx); + } + } + + /// Open the project picker: a search box over the target host's recent + /// projects and folders, the one way to choose a project on any host. + /// The search box is created once and reused. + pub(super) fn open_project_picker(&mut self, cx: &mut Context) { + self.models_menu_open = false; + self.mode_menu_open = false; + self.mcp_menu_open = false; + self.host_menu_open = false; + if self.root_input.is_none() { + let chat = cx.entity().downgrade(); + let key_chat = chat.clone(); + let arrow_chat = chat.clone(); + let application_vim_enabled = self.application_vim_enabled; + let input = cx.new(move |cx| { + TextInput::new("Search folders or enter a path\u{2026}", cx) + .with_tab_index(0) + .application_vim(application_vim_enabled) + .on_key(move |event, _text, _window, cx| { + let Some(chat) = key_chat.upgrade() else { + return false; + }; + match event.keystroke.key.as_str() { + "enter" => chat.update(cx, |chat, cx| chat.project_picker_confirm(cx)), + "escape" => chat.update(cx, |chat, cx| chat.close_project_picker(cx)), + _ => return false, + } + true + }) + .on_vertical(move |delta, _window, cx| { + // The move writes the highlighted path back into + // this input, which is mid-update here: defer it. + let chat = arrow_chat.clone(); + cx.defer(move |cx| { + if let Some(chat) = chat.upgrade() { + chat.update(cx, |chat, cx| chat.project_picker_move(delta, cx)); + } + }); + true + }) + .on_application_escape(move |_window, cx| { + if let Some(chat) = chat.upgrade() { + chat.update(cx, |chat, cx| chat.close_project_picker(cx)); + } + }) + }); + cx.observe(&input, |this, input, cx| { + let query = input.read(cx).text(); + // The box reports every change, not only to its text. The + // rows already show this text when it is the query they + // were built from, or the path an arrow filled in. + if this.project_picker.as_ref().is_some_and(|picker| { + picker.query == query || picker.filled.as_deref() == Some(query.as_str()) + }) { + return; + } + this.refresh_project_picker(query, cx); + }) + .detach(); + self.root_input = Some(input); + } else if let Some(input) = self.root_input.clone() { + input.update(cx, |input, cx| input.set_text("", cx)); + } + self.project_picker = Some(ProjectPicker { + rows: Vec::new(), + selected: 0, + generation: 0, + suggestions: Vec::new(), + query: String::new(), + filled: None, + }); + self.root_input_focus_pending = true; + self.refresh_project_picker(String::new(), cx); + cx.notify(); + } + + pub(super) fn close_project_picker(&mut self, cx: &mut Context) { + if self.project_picker.take().is_some() { + // The composer takes the keyboard back. + self.screen_focus_pending = true; + cx.notify(); + } + } + + /// The search text changed: rebuild the rows now from what is known + /// and ask the host for folders that match. + pub(super) fn refresh_project_picker(&mut self, query: String, cx: &mut Context) { + let Some(picker) = self.project_picker.as_mut() else { + return; + }; + picker.query = query.clone(); + picker.filled = None; + picker.selected = 0; + picker.generation += 1; + let generation = picker.generation; + self.rebuild_picker_rows(); + // With nothing typed, the current project's row starts highlighted. + if query.trim().is_empty() + && let Some(current) = self.project_root.as_deref() + && let Some(picker) = self.project_picker.as_mut() + && let Some(index) = picker.rows.iter().position(|row| row.path == current) + { + picker.selected = index; + } + let host = self.host.clone(); + self.call( + async move { host.suggest_directories(query).await }, + cx, + move |this, result, cx| { + let Some(picker) = this.project_picker.as_mut() else { + return; + }; + if picker.generation != generation { + return; + } + if let Ok(suggestions) = result { + picker.suggestions = suggestions; + this.rebuild_picker_rows(); + cx.notify(); + } + }, + ); + } + + /// Rows in display order: the typed path itself when it looks like + /// one, recent projects that match, then the host's folders. + pub(super) fn rebuild_picker_rows(&mut self) { + let Some(mut picker) = self.project_picker.take() else { + return; + }; + let query = picker.query.trim().to_string(); + let needle = query.to_lowercase(); + let mut rows: Vec = Vec::new(); + for root in &self.recent_roots { + if !needle.is_empty() && !root.to_lowercase().contains(&needle) { + continue; + } + rows.push(PickerRow { + kind: PickerRowKind::Recent, + path: root.clone(), + title: SharedString::from(root_display_name(root)), + subtitle: Some(SharedString::from(root.clone())), + }); + } + for suggestion in &picker.suggestions { + if rows.iter().any(|row| row.path == suggestion.path) { + continue; + } + rows.push(PickerRow { + kind: PickerRowKind::Suggestion, + path: suggestion.path.clone(), + title: SharedString::from(suggestion.name.clone()), + subtitle: Some(SharedString::from(suggestion.path.clone())), + }); + } + let looks_like_path = query.starts_with('/') || query.starts_with('~'); + let typed = query.trim_end_matches('/').to_string(); + if looks_like_path && !typed.is_empty() && !rows.iter().any(|row| row.path == typed) { + rows.insert( + 0, + PickerRow { + kind: PickerRowKind::OpenPath, + path: typed.clone(), + title: "Open this path".into(), + subtitle: Some(SharedString::from(typed)), + }, + ); + } + picker.selected = picker.selected.min(rows.len().saturating_sub(1)); + picker.rows = rows; + self.project_picker = Some(picker); + } + + /// Move the highlight and put the highlighted path in the search box, + /// as a shell completes. + pub(super) fn project_picker_move(&mut self, delta: isize, cx: &mut Context) { + let Some(picker) = self.project_picker.as_mut() else { + return; + }; + let len = picker.rows.len(); + if len == 0 { + return; + } + picker.selected = (picker.selected as isize + delta).rem_euclid(len as isize) as usize; + let text = picker.rows[picker.selected].path.clone(); + picker.filled = Some(text.clone()); + if let Some(input) = self.root_input.clone() { + input.update(cx, |input, cx| input.set_text(&text, cx)); + } + cx.notify(); + } + + pub(super) fn project_picker_confirm(&mut self, cx: &mut Context) { + let Some(index) = self.project_picker.as_ref().map(|picker| picker.selected) else { + return; + }; + self.activate_picker_row(index, cx); + } + + /// Open the project a row names. + pub(super) fn activate_picker_row(&mut self, index: usize, cx: &mut Context) { + let Some(path) = self + .project_picker + .as_ref() + .and_then(|picker| picker.rows.get(index)) + .map(|row| row.path.clone()) + else { + return; + }; + self.close_project_picker(cx); + self.select_project_root(path, cx); + } + + /// The project picker: a centered dialog over the pane with a search + /// box, the rows it matched, and the keys that drive it. + pub(super) fn render_project_picker(&self, cx: &mut Context) -> gpui::Stateful
{ + let picker = self.project_picker.as_ref(); + let rows: &[PickerRow] = picker.map(|picker| picker.rows.as_slice()).unwrap_or(&[]); + let selected = picker.map(|picker| picker.selected).unwrap_or(0); + let searching = picker.is_some_and(|picker| !picker.query.trim().is_empty()); + let mut list = div() + .id("project-picker-rows") + .flex() + .flex_col() + .max_h(px(380.)) + .overflow_y_scroll(); + if rows.is_empty() { + list = list.child( + div() + .px_3() + .py_3() + .text_sm() + .text_color(gpui::rgb(theme::text_muted())) + .child(if searching { + "No folders match" + } else { + "No recent projects on this host yet; type a path" + }), + ); + } + for (index, row) in rows.iter().enumerate() { + let is_selected = index == selected; + let glyph = match row.kind { + PickerRowKind::Recent => "folder-open", + PickerRowKind::Suggestion => "folder", + PickerRowKind::OpenPath => "search", + }; + list = list.child( + div() + .id(SharedString::from(format!("project-picker-row-{index}"))) + .flex() + .items_center() + .gap_3() + .px_3() + .py_2() + .rounded(theme::RADIUS_SM) + .when(is_selected, |row| row.bg(gpui::rgb(theme::bg_input()))) + .hover(|style| style.bg(gpui::rgb(theme::bg_input())).cursor_pointer()) + .on_click(cx.listener(move |this, _event, _window, cx| { + this.activate_picker_row(index, cx); + })) + .child(icon(glyph, px(16.), theme::text_muted())) + .child( + div() + .flex() + .flex_col() + .min_w_0() + .flex_1() + .child( + div() + .text_sm() + .text_color(gpui::rgb(theme::text_primary())) + .line_clamp(1) + .text_ellipsis() + .child(row.title.clone()), + ) + .when_some(row.subtitle.clone(), |col, subtitle| { + col.child( + div() + .text_xs() + .text_color(gpui::rgb(theme::text_muted())) + .line_clamp(1) + .text_ellipsis() + .child(subtitle), + ) + }), + ) + .when(is_selected, |row| { + row.child( + div() + .text_xs() + .text_color(gpui::rgb(theme::text_muted())) + .child("Enter"), + ) + }), + ); + } + let hint = |keys: &'static str, label: &'static str| { + div() + .flex() + .items_center() + .gap_1() + .text_xs() + .text_color(gpui::rgb(theme::text_muted())) + .child( + div() + .px_1() + .rounded(theme::RADIUS_SM) + .bg(gpui::rgb(theme::bg_input())) + .font_family(crate::assets::FONT_MONO) + .child(keys), + ) + .child(label) + }; + div() + .id("project-picker-backdrop") + .absolute() + .size_full() + .top_0() + .left_0() + .occlude() + .bg(theme::scrim()) + .flex() + .items_start() + .justify_center() + .pt(px(96.)) + .on_click(cx.listener(|this, _event, _window, cx| { + this.close_project_picker(cx); + })) + .child( + div() + .id("project-picker") + .role(gpui::Role::Dialog) + .aria_label("Choose a project") + .w(px(640.)) + .max_w_full() + .rounded(theme::RADIUS_XL) + .shadow_lg() + .bg(gpui::rgb(theme::bg_elevated())) + .border_1() + .border_color(gpui::rgb(theme::border())) + .flex() + .flex_col() + .on_click(|_event, _window, cx| cx.stop_propagation()) + .child( + div() + .flex() + .flex_col() + .gap_2() + .px_4() + .pt_4() + .pb_2() + .child( + div() + .flex() + .items_baseline() + .gap_2() + .child( + div() + .text_lg() + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(gpui::rgb(theme::text_primary())) + .child("Choose a project"), + ) + .child( + div() + .flex() + .gap_1() + .text_sm() + .text_color(gpui::rgb(theme::text_muted())) + .child("on") + .child(self.target_host_label.clone()), + ), + ) + .when_some(self.root_input.clone(), |col, input| { + col.child(widgets::input_frame().text_sm().child(input)) + }), + ) + .child(div().px_2().pb_2().child(list)) + .child( + div() + .flex() + .items_center() + .gap_4() + .px_4() + .py_2() + .border_t_1() + .border_color(gpui::rgb(theme::border())) + .child(hint("\u{2191}\u{2193}", "Navigate")) + .child(hint("Enter", "Open")) + .child(hint("Esc", "Close")), + ), + ) + } +} diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index 37f5c4392..8fb95549f 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -6,11 +6,14 @@ mod state_tests { use crate::ui::chat::cache::{INLINE_PARSE_LIMIT, MAX_DIFF_LINES, ORDINAL_SPACING}; use crate::ui::chat::composer::SideThreadTurn; + use crate::ui::chat::hosts::RemoteBootstrap; use crate::ui::chat::images::{MAX_DRAFT_IMAGES, encode_data_url}; use crate::ui::chat::sidebar::RenameTarget; use crate::ui::chat::transcript::{diff_lines_for, maple_display_text, tool_label_title}; use crate::ui::chat::*; use gpui::TestAppContext; + use maple_agent::host::HostBootstrap; + use maple_remote::manager::HostStatus; fn summary(id: &str, title: &str) -> AgentSessionSummary { summary_at(id, title, "/tmp/proj") @@ -1917,7 +1920,7 @@ mod state_tests { #[gpui::test] fn test_project_picker_rows_follow_the_query(cx: &mut TestAppContext) { - use crate::ui::chat::{PickerRowKind, ProjectPicker}; + use crate::ui::chat::picker::{PickerRowKind, ProjectPicker}; cx.executor().allow_parking(); let screen = screen(cx); let kinds = |picker: &ProjectPicker| { From e3948f2cbd3c5441f23444507193e0b18723045c Mon Sep 17 00:00:00 2001 From: benthecarman Date: Sun, 20 Sep 2026 01:11:32 -0500 Subject: [PATCH 24/37] Drop the wire shims kept for compatibility Nothing speaks this protocol yet, so nothing needs the old shapes. The integration setup method leaves the wire instead of being refused: the client answers locally and the server reports it as unknown. The network module no longer re-exports the listen and dial halves; callers name the role they use. Co-Authored-By: Claude Fable 5.1 --- apps/maple-agent/app/src/remote/host.rs | 2 +- apps/maple-agent/crates/maple-remote/src/client.rs | 5 ++--- apps/maple-agent/crates/maple-remote/src/manager.rs | 2 +- apps/maple-agent/crates/maple-remote/src/net.rs | 11 +++-------- apps/maple-agent/crates/maple-remote/src/server.rs | 6 ------ apps/maple-agent/crates/maple-remote/src/wire.rs | 5 ----- .../maple-agent/crates/maple-remote/tests/loopback.rs | 9 ++------- .../crates/maple-remote/tests/transport.rs | 3 ++- apps/maple-agent/docs/remote-development.md | 2 +- 9 files changed, 12 insertions(+), 33 deletions(-) diff --git a/apps/maple-agent/app/src/remote/host.rs b/apps/maple-agent/app/src/remote/host.rs index 1c468c223..7a6bcbf74 100644 --- a/apps/maple-agent/app/src/remote/host.rs +++ b/apps/maple-agent/app/src/remote/host.rs @@ -16,7 +16,7 @@ use std::sync::Mutex; use maple_agent::host::LocalHostBackend; use maple_remote::devices::PairedDevice; use maple_remote::keys::StaticKey; -use maple_remote::net::{HostStores, serve_listener}; +use maple_remote::listen::{HostStores, serve_listener}; use maple_remote::pairing::{PairingCode, PairingLimiter, PendingPairing}; use maple_remote::server::{HostIdentity, HostServer, HostServerConfig}; use maple_remote::wire::HostInfo; diff --git a/apps/maple-agent/crates/maple-remote/src/client.rs b/apps/maple-agent/crates/maple-remote/src/client.rs index 2dafba878..1efba1d58 100644 --- a/apps/maple-agent/crates/maple-remote/src/client.rs +++ b/apps/maple-agent/crates/maple-remote/src/client.rs @@ -84,7 +84,7 @@ impl Default for ClientConfig { type Pending = oneshot::Sender>; /// Why a remote client cannot set up a curated integration. -pub const SETUP_IS_LOCAL: &str = "set up integrations on the host itself"; +const SETUP_IS_LOCAL: &str = "set up integrations on the host itself"; /// Most timeline items reserved up front on the host's announced length. const MAX_PREALLOCATED_ITEMS: usize = 4096; @@ -821,8 +821,7 @@ impl HostBackend for RemoteHostBackend { } /// The permission flow behind a setup runs on the host's own screen; - /// the host refuses this over the wire, so answer without a round - /// trip. The method stays on the wire for compatibility. + /// there is no wire method for it, so answer here. async fn setup_integration(&self, _id: String) -> Result, String> { Err(SETUP_IS_LOCAL.to_string()) } diff --git a/apps/maple-agent/crates/maple-remote/src/manager.rs b/apps/maple-agent/crates/maple-remote/src/manager.rs index d4aad5fec..98874a9a3 100644 --- a/apps/maple-agent/crates/maple-remote/src/manager.rs +++ b/apps/maple-agent/crates/maple-remote/src/manager.rs @@ -17,9 +17,9 @@ use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; use crate::client::{ClientConfig, RemoteHostBackend}; +use crate::dial::{ConnectTarget, connect_direct}; use crate::hosts::{HostConnection, HostsStore, SavedHost}; use crate::keys::StaticKey; -use crate::net::{ConnectTarget, connect_direct}; use crate::pairing::PairingCode; use crate::wire::ClientHello; diff --git a/apps/maple-agent/crates/maple-remote/src/net.rs b/apps/maple-agent/crates/maple-remote/src/net.rs index 87f488a61..871db5d6b 100644 --- a/apps/maple-agent/crates/maple-remote/src/net.rs +++ b/apps/maple-agent/crates/maple-remote/src/net.rs @@ -1,16 +1,11 @@ -//! What both network roles share, and one place to reach either. -//! -//! The host role lives in [`crate::listen`] and the client role in -//! [`crate::dial`]; both are re-exported here. This module holds the -//! WebSocket configuration and the handshake budget they have in common. +//! What both network roles share: the WebSocket configuration and the +//! handshake budget. The host role is [`crate::listen`], the client role +//! [`crate::dial`]. use std::time::Duration; use tokio_tungstenite::tungstenite::protocol::WebSocketConfig; -pub use crate::dial::{ConnectTarget, Dialed, connect_direct}; -pub use crate::listen::{HostStores, serve_listener}; - /// Time a peer gets to finish the WebSocket and Noise handshakes. pub(crate) const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(15); diff --git a/apps/maple-agent/crates/maple-remote/src/server.rs b/apps/maple-agent/crates/maple-remote/src/server.rs index c0393566c..c5c1b206c 100644 --- a/apps/maple-agent/crates/maple-remote/src/server.rs +++ b/apps/maple-agent/crates/maple-remote/src/server.rs @@ -751,12 +751,6 @@ impl Connection { IntegrationRequest::SetEnabled { id, enabled } => { Self::ok(host.set_integration_enabled(id, enabled).await) } - // The permission flow behind a setup runs on the host's own - // screen; `HostBackend` documents it as local-only. - IntegrationRequest::Setup { .. } => Err(RpcError::new( - code::INVALID_REQUEST, - crate::client::SETUP_IS_LOCAL, - )), } } diff --git a/apps/maple-agent/crates/maple-remote/src/wire.rs b/apps/maple-agent/crates/maple-remote/src/wire.rs index e4f78df4d..111befad3 100644 --- a/apps/maple-agent/crates/maple-remote/src/wire.rs +++ b/apps/maple-agent/crates/maple-remote/src/wire.rs @@ -350,10 +350,6 @@ pub enum IntegrationRequest { List, #[serde(rename = "integration.set_enabled")] SetEnabled { id: String, enabled: bool }, - /// Refused over the wire; the flow behind it runs on the host's own - /// screen. Kept so the method name stays reserved. - #[serde(rename = "integration.setup")] - Setup { id: String }, } /// A request decoded into its domain. @@ -442,7 +438,6 @@ pub const INTEGRATION_METHODS: &[&str] = &[ "integration.save_mcp", "integration.list", "integration.set_enabled", - "integration.setup", ]; /// Whether a host answers `method` at all. Deciding this by name, rather diff --git a/apps/maple-agent/crates/maple-remote/tests/loopback.rs b/apps/maple-agent/crates/maple-remote/tests/loopback.rs index 409cb9fd2..339f3bc5b 100644 --- a/apps/maple-agent/crates/maple-remote/tests/loopback.rs +++ b/apps/maple-agent/crates/maple-remote/tests/loopback.rs @@ -452,7 +452,7 @@ async fn a_second_hello_is_refused_and_the_hook_runs_once() { } #[tokio::test] -async fn integration_setup_is_refused_over_the_wire() { +async fn integration_setup_is_not_a_wire_method() { let host = FakeHost::new(0); let (client, _serving) = connect(Arc::clone(&host), HostServerConfig::default()).await; let error = client.setup_integration("x".to_string()).await.unwrap_err(); @@ -471,12 +471,7 @@ async fn integration_setup_is_refused_over_the_wire() { ) .await; let error = response.error.expect("refused"); - assert_eq!(error.code, rpc::code::INVALID_REQUEST); - assert!( - error.message.contains("on the host itself"), - "{}", - error.message - ); + assert_eq!(error.code, rpc::code::METHOD_NOT_FOUND); } #[tokio::test] diff --git a/apps/maple-agent/crates/maple-remote/tests/transport.rs b/apps/maple-agent/crates/maple-remote/tests/transport.rs index f52e30a76..d4f532c72 100644 --- a/apps/maple-agent/crates/maple-remote/tests/transport.rs +++ b/apps/maple-agent/crates/maple-remote/tests/transport.rs @@ -12,8 +12,9 @@ use futures_util::{SinkExt, StreamExt}; use maple_agent::host::HostBackend; use maple_remote::client::RemoteHostBackend; use maple_remote::devices::DeviceStore; +use maple_remote::dial::{ConnectTarget, connect_direct}; use maple_remote::keys::StaticKey; -use maple_remote::net::{ConnectTarget, HostStores, connect_direct, serve_listener}; +use maple_remote::listen::{HostStores, serve_listener}; use maple_remote::pairing::{PairingCode, PairingLimiter, PendingPairingStore}; use maple_remote::server::{HostServer, HostServerConfig}; use maple_remote::wire::{ClientHello, HostInfo}; diff --git a/apps/maple-agent/docs/remote-development.md b/apps/maple-agent/docs/remote-development.md index 4743866b7..7d08fc94c 100644 --- a/apps/maple-agent/docs/remote-development.md +++ b/apps/maple-agent/docs/remote-development.md @@ -241,7 +241,7 @@ model.list, model.supports_vision, model.slash_commands, model.resolve_slash_command integration.list_session_mcp, integration.set_session_mcp, integration.list_mcp, integration.save_mcp, integration.list, -integration.set_enabled, integration.setup +integration.set_enabled ``` Host events arrive as the `event` notification with a per-connection From b9c2b0bee0f408a4af734780b43b9adfc8015528 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Sun, 20 Sep 2026 12:28:16 -0500 Subject: [PATCH 25/37] Stream image uploads ahead of run.send Images travelled inline in the run.send request as base64 data URLs, so the 4 MiB control frame limit capped them well under the host's own 10 MB limit per image, and a large attachment failed at the sender. Either side can now open and receive streams, clients on odd channels and hosts on even, with one sender and one receiver implementation shared by both roles and the receiver acknowledging every stream with its own Close. The client streams each image first, on a channel it opens with purpose "upload", an id it minted, the media type, and the byte length, then waits for the host's Close acknowledging the bytes. The request names the uploads by id; the host resolves them, rebuilds each data URL, and consumes them before it calls send_message. Inline attachments are refused with INVALID_PARAMS, as is an unknown or incomplete id, and a request naming several ids consumes all of them or none. The host keeps uploads per connection: at most 10 MiB each, four in flight, sixteen finished and unreferenced with the oldest dropped, all gone with the connection. A stream that declares more than the limit, sends more than it declared, or ends short is refused on its channel and the connection stays usable. The handshake gains the uploadStreams feature: the host refuses uploads from a client that lacks it, and the client refuses to send attachments to a host that lacks it, telling the user to update the host. HostBackend::send_message keeps its shape on both sides; the conversion is internal to the remote client and server. Loopback tests cover a 6 MB attachment, an oversized upload, an unknown id, inline images, and teardown. Co-Authored-By: Claude Fable 5.1 --- .../crates/maple-remote/src/client.rs | 274 +++++++- .../crates/maple-remote/src/frame.rs | 10 +- .../crates/maple-remote/src/lib.rs | 8 +- .../crates/maple-remote/src/server.rs | 131 +++- .../crates/maple-remote/src/streams.rs | 645 +++++++++++------- .../crates/maple-remote/src/uploads.rs | 411 +++++++++++ .../crates/maple-remote/src/wire.rs | 52 +- .../crates/maple-remote/tests/common/mod.rs | 7 +- .../crates/maple-remote/tests/loopback.rs | 300 +++++++- 9 files changed, 1552 insertions(+), 286 deletions(-) create mode 100644 apps/maple-agent/crates/maple-remote/src/uploads.rs diff --git a/apps/maple-agent/crates/maple-remote/src/client.rs b/apps/maple-agent/crates/maple-remote/src/client.rs index 1efba1d58..1784c0e12 100644 --- a/apps/maple-agent/crates/maple-remote/src/client.rs +++ b/apps/maple-agent/crates/maple-remote/src/client.rs @@ -20,8 +20,8 @@ use std::time::Duration; use async_trait::async_trait; use maple_agent::agent::{ - AgentCreateSessionRequest, AgentDesktopQueueSnapshot, AgentIntegration, AgentMcpServer, - AgentProjectRootRegistration, AgentProjectTrustStatus, AgentRuntimeStatus, + AgentCreateSessionRequest, AgentDesktopQueueSnapshot, AgentImageUpload, AgentIntegration, + AgentMcpServer, AgentProjectRootRegistration, AgentProjectTrustStatus, AgentRuntimeStatus, AgentSendMessageRequest, AgentSessionDetail, AgentSessionIntegrationKind, AgentSessionMcpServer, AgentSessionSummary, AgentSlashCommand, AgentStartRequest, AgentSubagent, RecentProjectRoot, SideQuestionTurn, @@ -39,11 +39,14 @@ use crate::carrier::Carrier; use crate::frame::{CONTROL_CHANNEL, Frame, FrameKind}; use crate::outbound::{self, DEFAULT_MAX_OUTBOUND_BYTES, Outbound}; use crate::rpc::{self, Message, Request as RpcRequest, RpcError}; -use crate::streams::StreamReceivers; +use crate::streams::{ + ATTACHMENT_PURPOSE, MAX_IMAGE_BYTES, Opener, StreamOpen, StreamReceivers, StreamResult, + StreamSenders, UPLOAD_PURPOSE, close_frame, decode_credit, +}; use crate::wire::{ AttachmentHandle, BootstrapSnapshot, ClientHello, EVENT_METHOD, EventEnvelope, HostHello, HostRequest, IntegrationRequest, ModelRequest, PROTOCOL_VERSION, ProjectRequest, RunRequest, - SessionRequest, SessionSnapshot, TimelinePage, + SessionRequest, SessionSnapshot, TimelinePage, UPLOAD_STREAMS_FEATURE, UploadRef, has_feature, }; #[derive(Debug, Clone)] @@ -89,6 +92,122 @@ const SETUP_IS_LOCAL: &str = "set up integrations on the host itself"; /// Most timeline items reserved up front on the host's announced length. const MAX_PREALLOCATED_ITEMS: usize = 4096; +/// Attachment streams the host opens to answer `session.read_attachment`, +/// paired with the request that asked. A request registers its waiter +/// before it goes out; a stream for a request nobody waits on is refused. +struct AttachmentReads { + receivers: StreamReceivers, + waiters: std::sync::Mutex>>, +} + +impl Default for AttachmentReads { + fn default() -> Self { + Self { + receivers: StreamReceivers::new(MAX_IMAGE_BYTES), + waiters: std::sync::Mutex::new(HashMap::new()), + } + } +} + +impl AttachmentReads { + /// Wait for the stream that answers `request_id`. + fn expect(&self, request_id: u64) -> oneshot::Receiver { + let (tx, rx) = oneshot::channel(); + self.waiters + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(request_id, tx); + rx + } + + fn resolve(&self, request_id: u64, result: StreamResult) { + let waiter = self + .waiters + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&request_id); + if let Some(waiter) = waiter { + let _ = waiter.send(result); + } + } + + /// The request failed or gave up waiting: forget its waiter and any + /// stream already opened for it, so late frames are dropped. + fn abandon(&self, request_id: u64) { + self.waiters + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&request_id); + self.receivers.abandon(&request_id); + } + + /// A frame on a host-opened channel. Returns the frame to send back: + /// credit, or a `Close` refusing a stream the host should stop. + fn on_frame(&self, frame: &Frame) -> Option { + match frame.kind { + FrameKind::Open => { + let open: StreamOpen = match serde_json::from_slice(&frame.payload) { + Ok(open) => open, + Err(error) => { + return Some(close_frame(frame.channel, Some(&error.to_string()))); + } + }; + let expected = open.request_id.filter(|request_id| { + self.waiters + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .contains_key(request_id) + }); + let Some(request_id) = expected.filter(|_| open.purpose == ATTACHMENT_PURPOSE) + else { + return Some(close_frame( + frame.channel, + Some("no request awaits this stream"), + )); + }; + match self.receivers.accept(frame.channel, request_id, open.len) { + Ok(()) => None, + Err(reason) => { + self.resolve(request_id, Err(reason.clone())); + Some(close_frame(frame.channel, Some(&reason))) + } + } + } + FrameKind::Data => match self.receivers.on_data(frame.channel, &frame.payload) { + Ok(credit) => credit, + Err((request_id, reason)) => { + self.resolve(request_id, Err(reason.clone())); + Some(close_frame(frame.channel, Some(&reason))) + } + }, + FrameKind::Close => { + if let Some((request_id, result)) = + self.receivers.on_close(frame.channel, &frame.payload) + { + self.resolve(request_id, result); + } + None + } + // The host does not grant credit on its own stream. + FrameKind::Credit => None, + } + } + + /// The connection ended: every waiter fails. + fn fail_all(&self, reason: &str) { + self.receivers.clear(); + let waiters = std::mem::take( + &mut *self + .waiters + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner), + ); + for (_, waiter) in waiters { + let _ = waiter.send(Err(reason.to_string())); + } + } +} + pub struct RemoteHostBackend { id: HostId, hello: HostHello, @@ -97,7 +216,10 @@ pub struct RemoteHostBackend { pending: Mutex>, next_id: AtomicU64, events: Arc, - streams: StreamReceivers, + /// Streams this client opens: uploads ahead of `run.send`. + senders: StreamSenders, + /// Streams the host opens: attachment reads. + attachments: AttachmentReads, closed: watch::Sender>, tasks: std::sync::Mutex>>, } @@ -181,7 +303,8 @@ impl RemoteHostBackend { pending: Mutex::new(HashMap::new()), next_id: AtomicU64::new(2), events: Arc::new(HostEventHub::default()), - streams: StreamReceivers::default(), + senders: StreamSenders::new(Opener::Client), + attachments: AttachmentReads::default(), closed: closed_tx, tasks: std::sync::Mutex::new(vec![reader, writer]), }); @@ -233,7 +356,8 @@ impl RemoteHostBackend { for (_, pending) in self.pending.lock().await.drain() { let _ = pending.send(Err(RpcError::host(reason))); } - self.streams.fail_all(reason); + self.senders.fail_all(reason); + self.attachments.fail_all(reason); let tasks = std::mem::take( &mut *self .tasks @@ -247,17 +371,25 @@ impl RemoteHostBackend { async fn on_frame(&self, frame: Frame, expected_seq: &mut u64) { if frame.channel != CONTROL_CHANNEL { - match frame.kind { - FrameKind::Open => self.streams.on_open(frame.channel, &frame.payload), - FrameKind::Data => { - if let Some(credit) = self.streams.on_data(frame.channel, &frame.payload) - && let Err(error) = self.out.try_send(credit) + match Opener::of_channel(frame.channel) { + // A stream this client is sending on: the host's flow + // control and its acknowledgement or refusal. + Opener::Client => match frame.kind { + FrameKind::Credit => { + if let Some(credit) = decode_credit(&frame.payload) { + self.senders.credit(frame.channel, credit); + } + } + FrameKind::Close => self.senders.on_close(frame.channel, &frame.payload), + FrameKind::Open | FrameKind::Data => {} + }, + Opener::Host => { + if let Some(reply) = self.attachments.on_frame(&frame) + && let Err(error) = self.out.try_send(reply) { self.mark_closed(&error).await; } } - FrameKind::Close => self.streams.on_close(frame.channel, &frame.payload), - FrameKind::Credit => {} } return; } @@ -376,6 +508,53 @@ impl RemoteHostBackend { } } + /// Stream each image to the host ahead of `run.send`, one after the + /// other, and return what the request names them by. A failed upload + /// fails the whole send. + async fn upload_attachments( + &self, + attachments: Vec, + ) -> Result, String> { + if attachments.is_empty() { + return Ok(Vec::new()); + } + if !has_feature(&self.hello.features, UPLOAD_STREAMS_FEATURE) { + return Err("this host cannot receive image attachments; update the host".to_string()); + } + let mut uploads = Vec::with_capacity(attachments.len()); + for AgentImageUpload { name, data_url } in attachments { + // Decode once and let the data URL go, so only the bytes + // stay in memory while they stream. + let (mime, bytes) = decode_data_url(&data_url)?; + drop(data_url); + if bytes.len() > MAX_IMAGE_BYTES { + return Err(format!("{name} is too large (max 10MB)")); + } + let upload_id = uuid::Uuid::new_v4().to_string(); + let mut sender = self.senders.open( + &self.out, + StreamOpen { + purpose: UPLOAD_PURPOSE.to_string(), + request_id: None, + upload_id: Some(upload_id.clone()), + mime: Some(mime), + len: Some(bytes.len() as u64), + }, + )?; + let bytes = &bytes; + let transfer = async move { + sender.send_all(bytes).await?; + sender.wait_for_ack().await + }; + tokio::time::timeout(self.config.long_request_timeout, transfer) + .await + .map_err(|_| format!("uploading {name} timed out"))? + .map_err(|error| format!("uploading {name} failed: {error}"))?; + uploads.push(UploadRef { upload_id, name }); + } + Ok(uploads) + } + /// Fetch every page of a snapshot's timeline. `expected_len` is the /// host's word and only sizes the first allocation, within reason. async fn page_timeline( @@ -587,6 +766,9 @@ impl HostBackend for RemoteHostBackend { session_id, attachment_id, }; + // The open frame precedes the answer on the same ordered carrier, + // so the waiter must exist before the request goes out. + let receiver = self.attachments.expect(request_id); // The host may have opened the stream before its answer failed or // the wait ran out; whatever it opened for this request goes too. let value = match self @@ -595,30 +777,29 @@ impl HostBackend for RemoteHostBackend { { Ok(value) => value, Err(error) => { - self.streams.abandon_request(request_id); + self.attachments.abandon(request_id); return Err(error); } }; let handle: AttachmentHandle = serde_json::from_value(value).map_err(|error| error.to_string())?; - // The open frame preceded the answer on the same ordered carrier, - // so the collector for this request exists. - let receiver = self - .streams - .take_by_request(request_id) - .ok_or_else(|| format!("no stream {} for the attachment", handle.stream))?; match tokio::time::timeout(self.config.request_timeout, receiver).await { Ok(Ok(result)) => result, Ok(Err(_)) => Err("attachment transfer was cut off".to_string()), Err(_) => { - self.streams.abandon_request(request_id); - Err("attachment transfer timed out".to_string()) + self.attachments.abandon(request_id); + Err(format!( + "attachment transfer on stream {} timed out", + handle.stream + )) } } } - async fn send_message(&self, request: AgentSendMessageRequest) -> Result { - self.call(&RunRequest::Send { request }).await + async fn send_message(&self, mut request: AgentSendMessageRequest) -> Result { + let attachments = std::mem::take(&mut request.attachments); + let uploads = self.upload_attachments(attachments).await?; + self.call(&RunRequest::Send { request, uploads }).await } async fn cancel_run(&self, run_id: String) -> Result<(), String> { @@ -843,3 +1024,46 @@ impl HostBackend for RemoteHostBackend { self.call(&HostRequest::UsageSummary).await } } + +/// The media type and bytes of a `data:;base64,` URL, the +/// shape the composer builds and the runtime stores. +fn decode_data_url(data_url: &str) -> Result<(String, Vec), String> { + use base64::Engine as _; + let (header, data) = data_url + .split_once(',') + .ok_or_else(|| "Image attachment must be a base64 data URL".to_string())?; + let mime = header + .strip_prefix("data:") + .and_then(|value| value.strip_suffix(";base64")) + .filter(|mime| !mime.is_empty()) + .ok_or_else(|| "Image attachment must be a base64 data URL".to_string())?; + let bytes = base64::engine::general_purpose::STANDARD + .decode(data) + .map_err(|_| "Image attachment is not valid base64".to_string())?; + if bytes.is_empty() { + return Err("Image attachment cannot be empty".to_string()); + } + Ok((mime.to_string(), bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn data_urls_decode_to_their_type_and_bytes() { + let (mime, bytes) = decode_data_url("data:image/png;base64,AQID").unwrap(); + assert_eq!(mime, "image/png"); + assert_eq!(bytes, [1, 2, 3]); + for bad in [ + "AQID", + "http://x,AQID", + "data:image/png,AQID", + "data:;base64,AQID", + "data:image/png;base64,", + "data:image/png;base64,!!", + ] { + assert!(decode_data_url(bad).is_err(), "{bad}"); + } + } +} diff --git a/apps/maple-agent/crates/maple-remote/src/frame.rs b/apps/maple-agent/crates/maple-remote/src/frame.rs index bd2b42d6f..d7a01e2e2 100644 --- a/apps/maple-agent/crates/maple-remote/src/frame.rs +++ b/apps/maple-agent/crates/maple-remote/src/frame.rs @@ -3,8 +3,8 @@ //! Every message after the handshake is one frame: //! `[channel: u16 BE][kind: u8][payload]`. Channel 0 is the control //! channel and carries one JSON-RPC message per `Data` frame. Channels 1 -//! and up are binary streams opened by the side that sends the data; see -//! [`crate::streams`]. +//! and up are binary streams opened by the side that sends the data: +//! clients open odd channels, hosts even ones. See [`crate::streams`]. use bytes::{BufMut, Bytes, BytesMut}; @@ -37,8 +37,10 @@ pub enum FrameKind { Open = 0, /// Bytes on a stream, or one JSON-RPC message on channel 0. Data = 1, - /// Ends a stream. Payload: empty, or a JSON - /// [`crate::streams::StreamClose`] naming an error. + /// Ends a stream. From the sender: the bytes are complete, or a JSON + /// [`crate::streams::StreamClose`] names why it stopped. From the + /// receiver: it took the bytes, or a `StreamClose` names why it + /// refused them. Close = 2, /// The receiver grants the sender more `Data` frames on a stream. /// Payload: a u32 BE count. diff --git a/apps/maple-agent/crates/maple-remote/src/lib.rs b/apps/maple-agent/crates/maple-remote/src/lib.rs index 0a3869d97..19de1fccb 100644 --- a/apps/maple-agent/crates/maple-remote/src/lib.rs +++ b/apps/maple-agent/crates/maple-remote/src/lib.rs @@ -12,9 +12,10 @@ //! that keep them connected and forward their events. //! - [`frame`]: `[channel][kind][payload]`. Channel 0 is control and carries //! JSON-RPC 2.0 ([`rpc`]). Other channels are binary streams with -//! credit-based flow control ([`streams`]). Every frame a side sends goes -//! through one byte-bounded queue ([`outbound`]); overflow closes the -//! connection rather than blocking the host. +//! credit-based flow control ([`streams`]); the host keeps the images a +//! client streams ahead of `run.send` in [`uploads`]. Every frame a side +//! sends goes through one byte-bounded queue ([`outbound`]); overflow +//! closes the connection rather than blocking the host. //! - [`wire`]: the methods, grouped by domain, and the handshake. //! - [`server`]: [`server::HostServer`] publishes any //! [`maple_agent::host::HostBackend`] to connections. @@ -45,6 +46,7 @@ pub mod pairing; pub mod rpc; pub mod server; pub mod streams; +pub mod uploads; pub mod wire; pub use client::RemoteHostBackend; diff --git a/apps/maple-agent/crates/maple-remote/src/server.rs b/apps/maple-agent/crates/maple-remote/src/server.rs index c5c1b206c..f1235a315 100644 --- a/apps/maple-agent/crates/maple-remote/src/server.rs +++ b/apps/maple-agent/crates/maple-remote/src/server.rs @@ -13,10 +13,11 @@ use std::collections::HashMap; use std::sync::Arc; +use std::sync::OnceLock; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, Instant}; -use maple_agent::agent::AgentSessionDetail; +use maple_agent::agent::{AgentImageUpload, AgentSendMessageRequest, AgentSessionDetail}; use maple_agent::host::{HostBackend, HostBootstrap}; use serde::Serialize; use serde_json::Value; @@ -28,11 +29,15 @@ use crate::carrier::Carrier; use crate::frame::{CONTROL_CHANNEL, Frame, FrameKind}; use crate::outbound::{self, DEFAULT_MAX_OUTBOUND_BYTES, Outbound}; use crate::rpc::{self, Message, Request as RpcRequest, Response, RpcError, code}; -use crate::streams::{StreamOpen, StreamSenders, decode_credit}; +use crate::streams::{ + ATTACHMENT_PURPOSE, Opener, StreamOpen, StreamSenders, close_frame, decode_credit, +}; +use crate::uploads::Uploads; use crate::wire::{ - AttachmentHandle, BootstrapSnapshot, ClientHello, EventEnvelope, HostHello, HostInfo, + AttachmentHandle, BootstrapSnapshot, ClientHello, EventEnvelope, Features, HostHello, HostInfo, HostRequest, IntegrationRequest, ModelRequest, PROTOCOL_VERSION, ProjectRequest, Request, - RunRequest, SessionRequest, SessionSnapshot, decode_request, features, + RunRequest, SessionRequest, SessionSnapshot, UPLOAD_STREAMS_FEATURE, UploadRef, decode_request, + features, has_feature, }; /// What the host tells clients about itself in the handshake. @@ -137,7 +142,9 @@ impl HostServer { let connection = Arc::new(Connection { server: Arc::clone(&self), out, - streams: StreamSenders::default(), + senders: StreamSenders::new(Opener::Host), + uploads: Uploads::default(), + client_features: OnceLock::new(), snapshots: Mutex::new(Vec::new()), watched_roots: Mutex::new(HashMap::new()), ready: AtomicBool::new(false), @@ -247,7 +254,13 @@ impl HostServer { struct Connection { server: Arc, out: Outbound, - streams: StreamSenders, + /// Streams this host opens: attachment reads. + senders: StreamSenders, + /// Streams the client opens: images ahead of `run.send`. Dropped + /// with the connection. + uploads: Uploads, + /// What the client's hello advertised. + client_features: OnceLock, /// Snapshots the client pages through, least recently paged first. /// Replaced by the next load of the same task; at most /// [`MAX_KEPT_SNAPSHOTS`]. @@ -292,6 +305,12 @@ impl Connection { } } + fn client_has(&self, feature: &str) -> bool { + self.client_features + .get() + .is_some_and(|features| has_feature(features, feature)) + } + fn close_reason(&self) -> Option { self.close_reason .lock() @@ -340,13 +359,7 @@ impl Connection { /// Route one inbound frame. Requests are answered on their own task. fn on_frame(self: &Arc, frame: Frame) -> Result<(), String> { if frame.channel != CONTROL_CHANNEL { - if frame.kind == FrameKind::Credit - && let Some(credit) = decode_credit(&frame.payload) - { - self.streams.credit(frame.channel, credit); - } - // Clients do not send streams yet; other kinds are ignored. - return Ok(()); + return self.on_stream_frame(frame); } let message = rpc::decode(&frame.payload)?; match message { @@ -364,6 +377,42 @@ impl Connection { Ok(()) } + /// Route a frame on a stream channel by which side opened it. + fn on_stream_frame(&self, frame: Frame) -> Result<(), String> { + match Opener::of_channel(frame.channel) { + // A stream this host is sending on: the client's flow control + // and its acknowledgement or refusal. + Opener::Host => match frame.kind { + FrameKind::Credit => { + if let Some(credit) = decode_credit(&frame.payload) { + self.senders.credit(frame.channel, credit); + } + } + FrameKind::Close => self.senders.on_close(frame.channel, &frame.payload), + FrameKind::Open | FrameKind::Data => {} + }, + // A stream the client is sending on: an upload. Before the + // handshake, or from a client that did not advertise uploads, + // an open is refused and anything else is dropped. + Opener::Client => { + let reply = if self.client_has(UPLOAD_STREAMS_FEATURE) { + self.uploads.on_frame(&frame) + } else if frame.kind == FrameKind::Open { + Some(close_frame( + frame.channel, + Some("uploads need a handshake that advertises uploadStreams"), + )) + } else { + None + }; + if let Some(reply) = reply { + self.out.try_send(reply)?; + } + } + } + Ok(()) + } + async fn handle(self: Arc, request: RpcRequest) { let id = request.id; let decoded = match decode_request(&request.method, request.params) { @@ -474,6 +523,7 @@ impl Connection { )); return; } + let _ = self.client_features.set(hello.features.clone()); self.respond_ok(id, &answer); self.ready_notify.notify_one(); if let Some(hook) = &self.server.config.on_client_hello { @@ -626,13 +676,15 @@ impl Connection { .read_image_attachment(session_id, attachment_id) .await .map_err(RpcError::host)?; - let sender = self - .streams + let mut sender = self + .senders .open( &self.out, StreamOpen { - purpose: "attachment".to_string(), + purpose: ATTACHMENT_PURPOSE.to_string(), request_id: Some(request_id), + upload_id: None, + mime: None, len: Some(bytes.len() as u64), }, ) @@ -660,7 +712,10 @@ impl Connection { async fn run_controller(&self, request: RunRequest) -> Result { let host = self.host(); match request { - RunRequest::Send { request } => Self::ok(host.send_message(request).await), + RunRequest::Send { request, uploads } => { + let request = self.resolve_uploads(request, uploads)?; + Self::ok(host.send_message(request).await) + } RunRequest::Cancel { run_id } => Self::ok(host.cancel_run(run_id).await), RunRequest::CancelQueued { session_id, @@ -707,6 +762,48 @@ impl Connection { } } + /// Replace the upload ids a `run.send` names with the images the + /// client streamed, consuming them. Images inline in the request are + /// refused: they would be capped by the control frame limit. + fn resolve_uploads( + &self, + mut request: AgentSendMessageRequest, + uploads: Vec, + ) -> Result { + if !request.attachments.is_empty() { + return Err(RpcError::new( + code::INVALID_PARAMS, + "this host takes images as upload streams, not inline in run.send; update the client", + )); + } + if uploads.is_empty() { + return Ok(request); + } + if !self.client_has(UPLOAD_STREAMS_FEATURE) { + return Err(RpcError::new( + code::INVALID_PARAMS, + "run.send names uploads but the client did not advertise uploadStreams", + )); + } + let ids: Vec = uploads + .iter() + .map(|upload| upload.upload_id.clone()) + .collect(); + let images = self + .uploads + .take_all(&ids) + .map_err(|reason| RpcError::new(code::INVALID_PARAMS, reason))?; + request.attachments = uploads + .into_iter() + .zip(images) + .map(|(upload, image)| AgentImageUpload { + name: upload.name, + data_url: image.data_url(), + }) + .collect(); + Ok(request) + } + async fn model_controller(&self, request: ModelRequest) -> Result { let host = self.host(); match request { diff --git a/apps/maple-agent/crates/maple-remote/src/streams.rs b/apps/maple-agent/crates/maple-remote/src/streams.rs index 93e7f64c8..71c11de83 100644 --- a/apps/maple-agent/crates/maple-remote/src/streams.rs +++ b/apps/maple-agent/crates/maple-remote/src/streams.rs @@ -1,19 +1,29 @@ //! Binary streams on channels 1 and up, with credit-based flow control. //! -//! The side that sends the bytes opens the stream with an `Open` frame, -//! sends `Data` frames while it holds credit, and ends with `Close`. The -//! receiver starts the sender with [`INITIAL_CREDIT`] frames and grants -//! more as it consumes, so one slow transfer can never fill the -//! connection's outbound queue. Attachments use this today; a PTY would -//! use the same frames with its own `purpose`. +//! Either side opens a stream with an `Open` frame, sends `Data` frames +//! while it holds credit, and ends with `Close`. Clients open odd channels +//! and hosts even ones, so neither side's numbering collides with the +//! other's. The receiver starts the sender with [`INITIAL_CREDIT`] frames +//! and grants more as it consumes, so one slow transfer can never fill the +//! connection's outbound queue. The receiver answers with its own `Close` +//! once it has the bytes, or earlier to refuse them, so a sender that +//! needs an acknowledgement can wait for one. +//! +//! The host sends attachments this way (`session.read_attachment`) and +//! the client sends uploads ahead of `run.send` (see [`crate::uploads`]). +//! A PTY would use the same frames with its own `purpose`. +//! +//! This module holds what both directions share: the sender, and a +//! collector that gathers one stream's bytes under a limit. How a stream +//! is paired with the request or upload it belongs to is each side's own. use std::collections::HashMap; -use std::sync::Mutex; use std::sync::atomic::{AtomicU16, Ordering}; +use std::sync::{Arc, Mutex}; use bytes::Bytes; use serde::{Deserialize, Serialize}; -use tokio::sync::{mpsc, oneshot}; +use tokio::sync::mpsc; use crate::frame::{Frame, FrameKind, MAX_STREAM_FRAME_BYTES}; use crate::outbound::Outbound; @@ -24,22 +34,41 @@ pub const INITIAL_CREDIT: u32 = 16; /// this many. pub const CREDIT_REFILL: u32 = 8; +/// The host's own limit per image, which bounds a stream in either +/// direction. +pub const MAX_IMAGE_BYTES: usize = 10 * 1024 * 1024; + +/// `purpose` of a stream the host opens to answer `session.read_attachment`. +pub const ATTACHMENT_PURPOSE: &str = "attachment"; +/// `purpose` of a stream the client opens to upload an image ahead of +/// `run.send`. +pub const UPLOAD_PURPOSE: &str = "upload"; + /// Payload of an `Open` frame. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct StreamOpen { - /// What the bytes are: `attachment` today. + /// What the bytes are: [`ATTACHMENT_PURPOSE`] or [`UPLOAD_PURPOSE`]. pub purpose: String, - /// The JSON-RPC request this stream answers, so the receiver can pair - /// the bytes with the response that names the channel. + /// For an attachment: the JSON-RPC request this stream answers, so + /// the receiver can pair the bytes with the response that names the + /// channel. #[serde(default, skip_serializing_if = "Option::is_none")] pub request_id: Option, - /// Total bytes, when known up front. + /// For an upload: the id the client minted, which its `run.send` + /// names. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub upload_id: Option, + /// For an upload: the media type of the bytes. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mime: Option, + /// Total bytes, when known up front. The receiver holds the sender to + /// it. #[serde(default, skip_serializing_if = "Option::is_none")] pub len: Option, } -/// Payload of a `Close` frame that ends a stream early. +/// Payload of a `Close` frame that ends or refuses a stream early. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct StreamClose { @@ -47,6 +76,31 @@ pub struct StreamClose { pub error: Option, } +/// Which side opened a channel. Clients take the odd channels and hosts +/// the even ones. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Opener { + Client, + Host, +} + +impl Opener { + pub fn of_channel(channel: u16) -> Self { + if channel % 2 == 1 { + Self::Client + } else { + Self::Host + } + } + + fn first_channel(self) -> u16 { + match self { + Self::Client => 1, + Self::Host => 2, + } + } +} + fn credit_frame(channel: u16, credit: u32) -> Frame { Frame { channel, @@ -60,14 +114,54 @@ pub fn decode_credit(payload: &[u8]) -> Option { Some(u32::from_be_bytes(bytes)) } +/// A `Close` frame: empty to end or acknowledge a stream, or naming the +/// error that ends it early. +pub fn close_frame(channel: u16, error: Option<&str>) -> Frame { + let payload = match error { + Some(error) => serde_json::to_vec(&StreamClose { + error: Some(error.to_string()), + }) + .unwrap_or_default(), + None => Vec::new(), + }; + Frame { + channel, + kind: FrameKind::Close, + payload: Bytes::from(payload), + } +} + +/// The error in a `Close` payload, if it names one. +fn close_error(payload: &[u8]) -> Option { + if payload.is_empty() { + return None; + } + serde_json::from_slice::(payload) + .ok() + .and_then(|close| close.error) +} + // ---- Sending side ------------------------------------------------------------- +/// What the peer tells a sender about its stream. +enum Signal { + Credit(u32), + /// The peer closed the stream: acknowledged it, or refused it with + /// the error. + Closed(Result<(), String>), +} + +type Signals = Arc>>>; + /// One open stream the local side is sending on. pub struct StreamSender { channel: u16, out: Outbound, - credits: mpsc::UnboundedReceiver, + signals: mpsc::UnboundedReceiver, + registry: Signals, available: u32, + /// The `Close` frame went out; dropping the sender sends nothing. + finished: bool, } impl StreamSender { @@ -76,15 +170,19 @@ impl StreamSender { } /// Send every byte in frames of at most [`MAX_STREAM_FRAME_BYTES`], - /// waiting for credit between frames, then close the stream. - pub async fn send_all(mut self, bytes: &[u8]) -> Result<(), String> { + /// waiting for credit between frames, then close the stream. Fails + /// when the peer closes the stream first. + pub async fn send_all(&mut self, bytes: &[u8]) -> Result<(), String> { for chunk in bytes.chunks(MAX_STREAM_FRAME_BYTES) { while self.available == 0 { - self.available += self - .credits - .recv() - .await - .ok_or_else(|| "stream receiver went away".to_string())?; + match self.signals.recv().await { + Some(Signal::Credit(credit)) => self.available += credit, + Some(Signal::Closed(Err(error))) => return Err(error), + Some(Signal::Closed(Ok(()))) => { + return Err("the receiver closed the stream early".to_string()); + } + None => return Err("the connection ended".to_string()), + } } self.out.try_send(Frame { channel: self.channel, @@ -93,214 +191,275 @@ impl StreamSender { })?; self.available -= 1; } - self.out.try_send(Frame { - channel: self.channel, - kind: FrameKind::Close, - payload: Bytes::new(), - }) + self.out.try_send(close_frame(self.channel, None))?; + self.finished = true; + Ok(()) } - /// End the stream with an error instead of bytes. No sender needs - /// this yet: the host answers a failed read with an RPC error before - /// it opens a stream. - #[cfg(test)] - pub fn fail(self, error: &str) -> Result<(), String> { - let payload = serde_json::to_vec(&StreamClose { - error: Some(error.to_string()), - }) - .unwrap_or_default(); - self.out.try_send(Frame { - channel: self.channel, - kind: FrameKind::Close, - payload: Bytes::from(payload), - }) + /// Wait for the peer's `Close`: its acknowledgement that it took the + /// bytes, or the error it refused them with. + pub async fn wait_for_ack(mut self) -> Result<(), String> { + loop { + match self.signals.recv().await { + Some(Signal::Credit(_)) => continue, + Some(Signal::Closed(outcome)) => return outcome, + None => return Err("the connection ended".to_string()), + } + } } } -/// The streams one connection is sending, keyed by channel, so incoming -/// `Credit` frames reach the right sender. +impl Drop for StreamSender { + /// A sender dropped mid-stream tells the peer to forget the bytes, + /// so a timed-out transfer does not sit in the peer's limits until + /// the connection ends. + fn drop(&mut self) { + if !self.finished { + let _ = self + .out + .try_send(close_frame(self.channel, Some("the sender gave up"))); + } + self.registry + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&self.channel); + } +} + +/// The streams one connection is sending, keyed by channel, so the peer's +/// `Credit` and `Close` frames reach the right sender. pub struct StreamSenders { + opener: Opener, next_channel: AtomicU16, - credits: Mutex>>, + signals: Signals, } -impl Default for StreamSenders { - fn default() -> Self { +impl StreamSenders { + pub fn new(opener: Opener) -> Self { Self { - next_channel: AtomicU16::new(1), - credits: Mutex::new(HashMap::new()), + opener, + next_channel: AtomicU16::new(opener.first_channel()), + signals: Arc::default(), } } -} -impl StreamSenders { /// Open a stream: queues the `Open` frame and returns the sender, which /// starts with [`INITIAL_CREDIT`]. pub fn open(&self, out: &Outbound, open: StreamOpen) -> Result { + let payload = serde_json::to_vec(&open).map_err(|error| error.to_string())?; let channel = self.allocate_channel(); - let (credit_tx, credit_rx) = mpsc::unbounded_channel(); - self.credits + let (signal_tx, signal_rx) = mpsc::unbounded_channel(); + self.signals .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) - .insert(channel, credit_tx); - let payload = serde_json::to_vec(&open).map_err(|error| error.to_string())?; - out.try_send(Frame { + .insert(channel, signal_tx); + if let Err(error) = out.try_send(Frame { channel, kind: FrameKind::Open, payload: Bytes::from(payload), - })?; + }) { + self.signals + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&channel); + return Err(error); + } Ok(StreamSender { channel, out: out.clone(), - credits: credit_rx, + signals: signal_rx, + registry: Arc::clone(&self.signals), available: INITIAL_CREDIT, + finished: false, }) } fn allocate_channel(&self) -> u16 { loop { - let channel = self.next_channel.fetch_add(1, Ordering::Relaxed); - // Channel 0 is control; wrap past it. + // Stepping by two keeps this side's parity; wrapping past + // u16::MAX keeps it too, since 65536 is even. + let channel = self.next_channel.fetch_add(2, Ordering::Relaxed); if channel != 0 { return channel; } } } - /// The peer granted `credit` more frames on `channel`. - pub fn credit(&self, channel: u16, credit: u32) { - let mut credits = self - .credits + /// This side's parity. + pub fn opener(&self) -> Opener { + self.opener + } + + fn signal(&self, channel: u16, signal: Signal) { + let mut signals = self + .signals .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - if let Some(sender) = credits.get(&channel) - && sender.send(credit).is_err() + if let Some(sender) = signals.get(&channel) + && sender.send(signal).is_err() { - credits.remove(&channel); + signals.remove(&channel); + } + } + + /// The peer granted `credit` more frames on `channel`. + pub fn credit(&self, channel: u16, credit: u32) { + self.signal(channel, Signal::Credit(credit)); + } + + /// The peer closed `channel`: acknowledged the bytes, or refused them. + pub fn on_close(&self, channel: u16, payload: &[u8]) { + let outcome = match close_error(payload) { + Some(error) => Err(error), + None => Ok(()), + }; + self.signal(channel, Signal::Closed(outcome)); + } + + /// The connection ended: every sender still waiting fails. + pub fn fail_all(&self, reason: &str) { + let signals = std::mem::take( + &mut *self + .signals + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner), + ); + for (_, sender) in signals { + let _ = sender.send(Signal::Closed(Err(reason.to_string()))); } } } // ---- Receiving side ----------------------------------------------------------- -struct Collector { +struct Collector { + key: K, bytes: Vec, + declared_len: Option, consumed_since_credit: u32, - done: Option>, - /// The request this stream answers, so an abandoned request can drop - /// its collector. - request_id: Option, } /// The collected bytes of one stream, or why it ended early. pub type StreamResult = Result, String>; -/// The streams one connection is receiving. Bytes are collected whole; -/// the request that asked for them awaits the result by request id. -#[derive(Default)] -pub struct StreamReceivers { - open: Mutex>, - by_request: Mutex>>, +/// The streams one connection is receiving, keyed by channel. Bytes are +/// collected whole under a byte limit and the length the sender declared; +/// a stream that crosses either is dropped and its key returned, so the +/// side can tell the sender. `K` names what a stream belongs to: the +/// request it answers on the client, the upload id on the host. +pub struct StreamReceivers { + open: Mutex>>, + max_bytes: usize, } -impl StreamReceivers { - /// An `Open` frame arrived. - pub fn on_open(&self, channel: u16, payload: &[u8]) { - let open: StreamOpen = match serde_json::from_slice(payload) { - Ok(open) => open, - Err(error) => { - log::debug!("ignoring stream open on channel {channel}: {error}"); - return; - } - }; - let (done_tx, done_rx) = oneshot::channel(); - self.open - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .insert( - channel, - Collector { - bytes: Vec::with_capacity(open.len.unwrap_or(0).min(64 * 1024 * 1024) as usize), - consumed_since_credit: 0, - done: Some(done_tx), - request_id: open.request_id, - }, - ); - if let Some(request_id) = open.request_id { - self.by_request - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .insert(request_id, done_rx); +impl StreamReceivers { + pub fn new(max_bytes: usize) -> Self { + Self { + open: Mutex::new(HashMap::new()), + max_bytes, } } - /// A `Data` frame arrived. Returns a credit frame to send back when the - /// sender has earned more. - pub fn on_data(&self, channel: u16, payload: &[u8]) -> Option { + /// Start collecting `channel` under `key`. Refuses a declared length + /// over the limit and a channel already collecting. + pub fn accept(&self, channel: u16, key: K, len: Option) -> Result<(), String> { + if let Some(len) = len + && len > self.max_bytes as u64 + { + return Err(format!( + "{len} bytes exceeds the {} byte limit", + self.max_bytes + )); + } let mut open = self .open .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - let collector = open.get_mut(&channel)?; - collector.bytes.extend_from_slice(payload); - collector.consumed_since_credit += 1; - if collector.consumed_since_credit >= CREDIT_REFILL { - collector.consumed_since_credit = 0; - return Some(credit_frame(channel, CREDIT_REFILL)); + if open.contains_key(&channel) { + return Err(format!("channel {channel} is already open")); } - None + open.insert( + channel, + Collector { + key, + bytes: Vec::with_capacity(len.unwrap_or(0) as usize), + declared_len: len, + consumed_since_credit: 0, + }, + ); + Ok(()) } - /// A `Close` frame arrived: the bytes are complete, or the sender - /// reported an error. - pub fn on_close(&self, channel: u16, payload: &[u8]) { - let Some(mut collector) = self + /// A `Data` frame arrived. Returns a credit frame to send back when the + /// sender has earned more, or the key and reason of a stream that + /// crossed its limit, which is dropped. + pub fn on_data(&self, channel: u16, payload: &[u8]) -> Result, (K, String)> { + let mut open = self .open .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .remove(&channel) - else { - return; + .unwrap_or_else(std::sync::PoisonError::into_inner); + let Some(collector) = open.get_mut(&channel) else { + return Ok(None); }; - let error = if payload.is_empty() { - None + let total = collector.bytes.len() + payload.len(); + let over = if collector + .declared_len + .is_some_and(|declared| total as u64 > declared) + { + Some(format!( + "more bytes than the {} declared", + collector.declared_len.unwrap_or(0) + )) + } else if total > self.max_bytes { + Some(format!("exceeds the {} byte limit", self.max_bytes)) } else { - serde_json::from_slice::(payload) - .ok() - .and_then(|close| close.error) + None }; - if let Some(done) = collector.done.take() { - let _ = done.send(match error { - Some(error) => Err(error), - None => Ok(std::mem::take(&mut collector.bytes)), - }); + if let Some(reason) = over { + let collector = open.remove(&channel).expect("just found"); + return Err((collector.key, reason)); + } + collector.bytes.extend_from_slice(payload); + collector.consumed_since_credit += 1; + if collector.consumed_since_credit >= CREDIT_REFILL { + collector.consumed_since_credit = 0; + return Ok(Some(credit_frame(channel, CREDIT_REFILL))); } + Ok(None) } - /// The receiver for the stream that answers `request_id`, once its - /// `Open` frame has arrived. - pub fn take_by_request(&self, request_id: u64) -> Option> { - self.by_request + /// A `Close` frame arrived: the bytes are complete, the sender + /// reported an error, or the stream ended short of its declared + /// length. + pub fn on_close(&self, channel: u16, payload: &[u8]) -> Option<(K, StreamResult)> { + let collector = self + .open .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) - .remove(&request_id) + .remove(&channel)?; + let result = match close_error(payload) { + Some(error) => Err(error), + None => match collector.declared_len { + Some(declared) if declared != collector.bytes.len() as u64 => Err(format!( + "ended after {} of the {declared} bytes declared", + collector.bytes.len() + )), + _ => Ok(collector.bytes), + }, + }; + Some((collector.key, result)) } - /// The request that asked for a stream failed or gave up waiting: - /// forget its receiver and any collector already opened for it, so - /// late frames on that channel are dropped instead of kept. - pub fn abandon_request(&self, request_id: u64) { - self.by_request - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .remove(&request_id); + /// Drop whatever is collecting under `key`, so late frames on that + /// channel are dropped instead of kept. + pub fn abandon(&self, key: &K) { self.open .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) - .retain(|_, collector| collector.request_id != Some(request_id)); + .retain(|_, collector| collector.key != *key); } - /// Streams still collecting, for tests and diagnostics. + /// Streams still collecting. pub fn open_count(&self) -> usize { self.open .lock() @@ -308,17 +467,12 @@ impl StreamReceivers { .len() } - /// The connection ended: every open stream fails. - pub fn fail_all(&self, reason: &str) { - let mut open = self - .open + /// The connection ended: drop every open stream. + pub fn clear(&self) { + self.open .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - for (_, mut collector) in open.drain() { - if let Some(done) = collector.done.take() { - let _ = done.send(Err(reason.to_string())); - } - } + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clear(); } } @@ -327,113 +481,140 @@ mod tests { use super::*; use crate::outbound; + fn open(len: Option) -> StreamOpen { + StreamOpen { + purpose: ATTACHMENT_PURPOSE.into(), + request_id: Some(9), + upload_id: None, + mime: None, + len, + } + } + #[tokio::test] async fn a_stream_crosses_with_credit_and_collects_whole() { let (out, mut queue) = outbound::channel(usize::MAX); - let senders = StreamSenders::default(); - let receivers = StreamReceivers::default(); + let senders = StreamSenders::new(Opener::Host); + let receivers = StreamReceivers::::new(MAX_IMAGE_BYTES); let payload = vec![7u8; MAX_STREAM_FRAME_BYTES * 20 + 5]; - let sender = senders - .open( - &out, - StreamOpen { - purpose: "attachment".into(), - request_id: Some(9), - len: Some(payload.len() as u64), - }, - ) + let mut sender = senders + .open(&out, open(Some(payload.len() as u64))) .unwrap(); let channel = sender.channel(); - let sending = tokio::spawn(async move { sender.send_all(&payload).await }); + let sending = tokio::spawn(async move { + sender.send_all(&payload).await?; + sender.wait_for_ack().await + }); // Pump frames from the sender's queue into the receiver, returning - // credit the way the peer would. - let mut delivered = None; - loop { + // credit and the final acknowledgement the way the peer would. + let delivered = loop { let frame = queue.recv().await.expect("frame"); match frame.kind { FrameKind::Open => { - receivers.on_open(frame.channel, &frame.payload); - delivered = receivers.take_by_request(9); + let open: StreamOpen = serde_json::from_slice(&frame.payload).unwrap(); + receivers + .accept(frame.channel, open.request_id.unwrap(), open.len) + .unwrap(); } FrameKind::Data => { - if let Some(credit) = receivers.on_data(frame.channel, &frame.payload) { + if let Some(credit) = receivers.on_data(frame.channel, &frame.payload).unwrap() + { senders.credit(credit.channel, decode_credit(&credit.payload).unwrap()); } } FrameKind::Close => { - receivers.on_close(frame.channel, &frame.payload); - break; + let delivered = receivers.on_close(frame.channel, &frame.payload); + senders.on_close(frame.channel, &[]); + break delivered; } FrameKind::Credit => unreachable!(), } - } + }; sending.await.unwrap().unwrap(); - assert_eq!(channel, 1); - let bytes = delivered.unwrap().await.unwrap().unwrap(); + assert_eq!(channel, 2, "hosts open even channels"); + assert_eq!(Opener::of_channel(channel), Opener::Host); + let (key, bytes) = delivered.unwrap(); + assert_eq!(key, 9); + let bytes = bytes.unwrap(); assert_eq!(bytes.len(), MAX_STREAM_FRAME_BYTES * 20 + 5); assert!(bytes.iter().all(|byte| *byte == 7)); + assert_eq!(receivers.open_count(), 0); } #[tokio::test] - async fn a_failed_stream_reports_its_error_and_connection_loss_fails_the_rest() { + async fn a_dropped_sender_reports_an_error_and_connection_loss_fails_the_rest() { let (out, mut queue) = outbound::channel(usize::MAX); - let senders = StreamSenders::default(); - let receivers = StreamReceivers::default(); - let sender = senders - .open( - &out, - StreamOpen { - purpose: "attachment".into(), - request_id: Some(1), - len: None, - }, - ) - .unwrap(); - sender.fail("missing").unwrap(); - let open = queue.recv().await.unwrap(); - receivers.on_open(open.channel, &open.payload); - let rx = receivers.take_by_request(1).unwrap(); + let senders = StreamSenders::new(Opener::Client); + let receivers = StreamReceivers::::new(MAX_IMAGE_BYTES); + let sender = senders.open(&out, open(None)).unwrap(); + assert_eq!(sender.channel(), 1, "clients open odd channels"); + drop(sender); + let open_frame = queue.recv().await.unwrap(); + receivers.accept(open_frame.channel, 1, None).unwrap(); let close = queue.recv().await.unwrap(); - receivers.on_close(close.channel, &close.payload); - assert_eq!(rx.await.unwrap(), Err("missing".to_string())); - - let other = senders - .open( - &out, - StreamOpen { - purpose: "attachment".into(), - request_id: Some(2), - len: None, - }, - ) - .unwrap(); - drop(other); - let open = queue.recv().await.unwrap(); - receivers.on_open(open.channel, &open.payload); - let rx = receivers.take_by_request(2).unwrap(); - receivers.fail_all("connection lost"); - assert_eq!(rx.await.unwrap(), Err("connection lost".to_string())); + assert_eq!(close.kind, FrameKind::Close); + let (key, result) = receivers.on_close(close.channel, &close.payload).unwrap(); + assert_eq!(key, 1); + assert_eq!(result, Err("the sender gave up".to_string())); + + let other = senders.open(&out, open(None)).unwrap(); + assert_eq!(other.channel(), 3); + senders.fail_all("connection lost"); + assert_eq!(other.wait_for_ack().await.unwrap_err(), "connection lost"); + receivers.accept(5, 2, None).unwrap(); + receivers.clear(); + assert_eq!(receivers.open_count(), 0); + } + + #[tokio::test] + async fn a_refusal_stops_the_sender() { + let (out, mut queue) = outbound::channel(usize::MAX); + let senders = StreamSenders::new(Opener::Client); + let mut sender = senders.open(&out, open(None)).unwrap(); + let channel = sender.channel(); + senders.on_close(channel, &close_frame(channel, Some("too big")).payload); + // Enough frames to run out of credit and hear the refusal. + let payload = vec![0u8; MAX_STREAM_FRAME_BYTES * (INITIAL_CREDIT as usize + 1)]; + assert_eq!(sender.send_all(&payload).await.unwrap_err(), "too big"); + drop(sender); + let mut kinds = Vec::new(); + while let Some(frame) = queue.try_recv() { + kinds.push(frame.kind); + } + assert_eq!(kinds[0], FrameKind::Open); + assert_eq!( + *kinds.last().unwrap(), + FrameKind::Close, + "the drop tells the peer" + ); } #[test] - fn an_abandoned_request_drops_its_receiver_and_collector() { - let receivers = StreamReceivers::default(); - let open = serde_json::to_vec(&StreamOpen { - purpose: "attachment".into(), - request_id: Some(5), - len: None, - }) - .unwrap(); - receivers.on_open(3, &open); - assert_eq!(receivers.open_count(), 1); - receivers.abandon_request(5); - assert_eq!(receivers.open_count(), 0); - assert!(receivers.take_by_request(5).is_none()); + fn a_receiver_holds_the_sender_to_its_limits() { + let receivers = StreamReceivers::<&str>::new(10); + assert!(receivers.accept(1, "big", Some(11)).is_err()); + receivers.accept(1, "declared", Some(4)).unwrap(); assert!( - receivers.on_data(3, b"late").is_none(), - "late frames are dropped" + receivers.accept(1, "again", None).is_err(), + "channel in use" ); - receivers.abandon_request(6); + assert_eq!(receivers.on_data(1, b"12345").unwrap_err().0, "declared"); + assert!(receivers.on_data(1, b"late").unwrap().is_none()); + + receivers.accept(3, "short", Some(4)).unwrap(); + assert!(receivers.on_data(3, b"12").unwrap().is_none()); + let (key, result) = receivers.on_close(3, &[]).unwrap(); + assert_eq!(key, "short"); + assert!(result.unwrap_err().contains("2 of the 4")); + + receivers.accept(5, "unbounded", None).unwrap(); + assert!(receivers.on_data(5, &[0; 10]).unwrap().is_none()); + assert_eq!(receivers.on_data(5, b"1").unwrap_err().0, "unbounded"); + + receivers.accept(7, "abandoned", None).unwrap(); + receivers.abandon(&"abandoned"); + assert_eq!(receivers.open_count(), 0); + assert!(receivers.on_close(7, &[]).is_none()); } } diff --git a/apps/maple-agent/crates/maple-remote/src/uploads.rs b/apps/maple-agent/crates/maple-remote/src/uploads.rs new file mode 100644 index 000000000..e5277229c --- /dev/null +++ b/apps/maple-agent/crates/maple-remote/src/uploads.rs @@ -0,0 +1,411 @@ +//! Uploads: image bytes a client sends ahead of `run.send`, on streams it +//! opens. +//! +//! An image inline in a `run.send` request would be capped by the control +//! frame limit, so the client streams it first under an id it minted and +//! the request names the id. One connection keeps its own uploads: at most +//! [`MAX_UPLOADS_IN_FLIGHT`] collecting and [`MAX_COMPLETED_UPLOADS`] +//! finished but not yet named by a request, oldest dropped first. A +//! `run.send` consumes the uploads it names; the connection's teardown +//! drops the rest. + +use std::collections::{HashMap, VecDeque}; +use std::sync::Mutex; + +use base64::Engine as _; + +use crate::frame::{Frame, FrameKind}; +use crate::streams::{MAX_IMAGE_BYTES, StreamOpen, StreamReceivers, UPLOAD_PURPOSE, close_frame}; + +/// Bytes one upload may carry: the host's own limit per image. +pub const MAX_UPLOAD_BYTES: usize = MAX_IMAGE_BYTES; +/// Uploads one connection may have collecting at once. +pub const MAX_UPLOADS_IN_FLIGHT: usize = 4; +/// Finished uploads one connection keeps until a `run.send` names them. +pub const MAX_COMPLETED_UPLOADS: usize = 16; + +/// Longest upload id or media type accepted, so neither can pad a log or +/// an error. +const MAX_LABEL_CHARS: usize = 128; + +/// One finished upload. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Upload { + pub mime: String, + pub bytes: Vec, +} + +impl Upload { + /// The `data:` URL the runtime takes, rebuilt the way the client + /// built it before it streamed the bytes. + pub fn data_url(&self) -> String { + let mut url = + String::with_capacity(self.mime.len() + 16 + self.bytes.len().div_ceil(3) * 4); + url.push_str("data:"); + url.push_str(&self.mime); + url.push_str(";base64,"); + base64::engine::general_purpose::STANDARD.encode_string(&self.bytes, &mut url); + url + } +} + +/// The uploads of one connection. +pub struct Uploads { + receivers: StreamReceivers, + /// Media types of the uploads still collecting, by id. + in_flight: Mutex>, + /// Finished uploads, oldest first. + completed: Mutex>, +} + +impl Default for Uploads { + fn default() -> Self { + Self { + receivers: StreamReceivers::new(MAX_UPLOAD_BYTES), + in_flight: Mutex::new(HashMap::new()), + completed: Mutex::new(VecDeque::new()), + } + } +} + +impl Uploads { + /// A frame on a client-opened channel. Returns the frame to send + /// back: credit while the bytes flow, a `Close` acknowledging a + /// finished upload, or a `Close` naming why one was refused. + pub fn on_frame(&self, frame: &Frame) -> Option { + match frame.kind { + FrameKind::Open => self + .on_open(frame.channel, &frame.payload) + .err() + .map(|reason| close_frame(frame.channel, Some(&reason))), + FrameKind::Data => match self.receivers.on_data(frame.channel, &frame.payload) { + Ok(credit) => credit, + Err((upload_id, reason)) => { + self.forget_in_flight(&upload_id); + log::debug!("upload {upload_id} refused: {reason}"); + Some(close_frame(frame.channel, Some(&reason))) + } + }, + FrameKind::Close => { + let (upload_id, result) = self.receivers.on_close(frame.channel, &frame.payload)?; + let mime = self.forget_in_flight(&upload_id)?; + match result { + Ok(bytes) => { + self.keep(upload_id, Upload { mime, bytes }); + Some(close_frame(frame.channel, None)) + } + Err(reason) => { + log::debug!("upload {upload_id} ended early: {reason}"); + Some(close_frame(frame.channel, Some(&reason))) + } + } + } + // A client does not grant credit on its own stream. + FrameKind::Credit => None, + } + } + + fn on_open(&self, channel: u16, payload: &[u8]) -> Result<(), String> { + let open: StreamOpen = + serde_json::from_slice(payload).map_err(|error| format!("bad stream open: {error}"))?; + if open.purpose != UPLOAD_PURPOSE { + return Err(format!("a client cannot open a {} stream", open.purpose)); + } + let upload_id = open + .upload_id + .filter(|id| is_clean_label(id)) + .ok_or_else(|| "an upload needs an id".to_string())?; + let mime = open + .mime + .filter(|mime| is_clean_label(mime) && !mime.contains([',', ';'])) + .ok_or_else(|| "an upload needs a media type".to_string())?; + let len = open + .len + .ok_or_else(|| "an upload needs its length".to_string())?; + let mut in_flight = self + .in_flight + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if in_flight.len() >= MAX_UPLOADS_IN_FLIGHT { + return Err(format!( + "at most {MAX_UPLOADS_IN_FLIGHT} uploads may be in flight" + )); + } + if in_flight.contains_key(&upload_id) || self.has_completed(&upload_id) { + return Err(format!("upload {upload_id} already exists")); + } + self.receivers + .accept(channel, upload_id.clone(), Some(len)) + .map_err(|reason| format!("upload {upload_id} refused: {reason}"))?; + in_flight.insert(upload_id, mime); + Ok(()) + } + + fn forget_in_flight(&self, upload_id: &str) -> Option { + self.in_flight + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(upload_id) + } + + fn has_completed(&self, upload_id: &str) -> bool { + self.completed + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .iter() + .any(|(id, _)| id == upload_id) + } + + /// Keep a finished upload, dropping the oldest past the limit. + fn keep(&self, upload_id: String, upload: Upload) { + let mut completed = self + .completed + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + completed.push_back((upload_id, upload)); + while completed.len() > MAX_COMPLETED_UPLOADS { + if let Some((dropped, _)) = completed.pop_front() { + log::debug!("upload {dropped} dropped: too many unreferenced uploads"); + } + } + } + + /// Take every upload named, in order, or none of them: a request that + /// names one unknown id must not consume the others. + pub fn take_all(&self, upload_ids: &[String]) -> Result, String> { + let mut completed = self + .completed + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut positions = Vec::with_capacity(upload_ids.len()); + for upload_id in upload_ids { + let position = completed + .iter() + .position(|(id, _)| id == upload_id) + .filter(|position| !positions.contains(position)) + .ok_or_else(|| format!("unknown or incomplete upload {upload_id}"))?; + positions.push(position); + } + // Remove from the back so earlier positions stay valid, then + // restore the order the request named them in. + let mut order: Vec<(usize, usize)> = positions.into_iter().enumerate().collect(); + order.sort_by_key(|(_, position)| std::cmp::Reverse(*position)); + let mut taken: Vec<(usize, Upload)> = order + .into_iter() + .filter_map(|(index, position)| { + completed + .remove(position) + .map(|(_, upload)| (index, upload)) + }) + .collect(); + taken.sort_by_key(|(index, _)| *index); + Ok(taken.into_iter().map(|(_, upload)| upload).collect()) + } + + /// Uploads still collecting. + pub fn in_flight_count(&self) -> usize { + self.receivers.open_count() + } + + /// Uploads finished and not yet named. + pub fn completed_count(&self) -> usize { + self.completed + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .len() + } +} + +/// Short, printable, and without whitespace: fit for a log line and for +/// the header of a `data:` URL. +fn is_clean_label(label: &str) -> bool { + !label.is_empty() + && label.len() <= MAX_LABEL_CHARS + && label.bytes().all(|byte| byte.is_ascii_graphic()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::frame::MAX_STREAM_FRAME_BYTES; + use crate::streams::{CREDIT_REFILL, StreamClose}; + use bytes::Bytes; + + fn open_frame(channel: u16, upload_id: &str, len: u64) -> Frame { + let open = StreamOpen { + purpose: UPLOAD_PURPOSE.into(), + request_id: None, + upload_id: Some(upload_id.into()), + mime: Some("image/png".into()), + len: Some(len), + }; + Frame { + channel, + kind: FrameKind::Open, + payload: Bytes::from(serde_json::to_vec(&open).unwrap()), + } + } + + fn data_frame(channel: u16, bytes: &[u8]) -> Frame { + Frame { + channel, + kind: FrameKind::Data, + payload: Bytes::copy_from_slice(bytes), + } + } + + fn refusal(frame: Option) -> String { + let frame = frame.expect("a refusal"); + assert_eq!(frame.kind, FrameKind::Close); + serde_json::from_slice::(&frame.payload) + .unwrap() + .error + .expect("an error") + } + + fn upload(uploads: &Uploads, channel: u16, id: &str, bytes: &[u8]) { + assert!( + uploads + .on_frame(&open_frame(channel, id, bytes.len() as u64)) + .is_none() + ); + for chunk in bytes.chunks(MAX_STREAM_FRAME_BYTES) { + let _ = uploads.on_frame(&data_frame(channel, chunk)); + } + let ack = uploads + .on_frame(&close_frame(channel, None)) + .expect("an acknowledgement"); + assert_eq!(ack.kind, FrameKind::Close); + assert!(ack.payload.is_empty(), "{:?}", ack.payload); + } + + #[test] + fn an_upload_is_collected_acknowledged_and_consumed_once() { + let uploads = Uploads::default(); + let bytes: Vec = (0..(MAX_STREAM_FRAME_BYTES * CREDIT_REFILL as usize + 3)) + .map(|i| (i % 7) as u8) + .collect(); + assert!( + uploads + .on_frame(&open_frame(1, "u1", bytes.len() as u64)) + .is_none() + ); + let mut credits = 0; + for chunk in bytes.chunks(MAX_STREAM_FRAME_BYTES) { + if let Some(frame) = uploads.on_frame(&data_frame(1, chunk)) { + assert_eq!(frame.kind, FrameKind::Credit); + credits += 1; + } + } + assert_eq!(credits, 1); + assert_eq!(uploads.in_flight_count(), 1); + let ack = uploads.on_frame(&close_frame(1, None)).unwrap(); + assert!(ack.payload.is_empty()); + assert_eq!(uploads.in_flight_count(), 0); + assert_eq!(uploads.completed_count(), 1); + + let taken = uploads.take_all(&["u1".to_string()]).unwrap(); + assert_eq!(taken[0].bytes, bytes); + assert_eq!(taken[0].mime, "image/png"); + assert!(taken[0].data_url().starts_with("data:image/png;base64,")); + assert_eq!(uploads.completed_count(), 0); + assert!(uploads.take_all(&["u1".to_string()]).is_err(), "consumed"); + } + + #[test] + fn opens_are_validated_and_limited() { + let uploads = Uploads::default(); + assert!( + refusal(uploads.on_frame(&open_frame(1, "big", MAX_UPLOAD_BYTES as u64 + 1))) + .contains("byte limit") + ); + assert!(refusal(uploads.on_frame(&open_frame(1, "", 1))).contains("id")); + assert!(refusal(uploads.on_frame(&open_frame(1, "with space", 1))).contains("id")); + let mut bad_mime = StreamOpen { + purpose: UPLOAD_PURPOSE.into(), + request_id: None, + upload_id: Some("m".into()), + mime: Some("image/png;base64,AAAA".into()), + len: Some(1), + }; + let frame = |open: &StreamOpen| Frame { + channel: 1, + kind: FrameKind::Open, + payload: Bytes::from(serde_json::to_vec(open).unwrap()), + }; + assert!(refusal(uploads.on_frame(&frame(&bad_mime))).contains("media type")); + bad_mime.mime = Some("image/png".into()); + bad_mime.len = None; + assert!(refusal(uploads.on_frame(&frame(&bad_mime))).contains("length")); + bad_mime.len = Some(1); + bad_mime.purpose = "attachment".into(); + assert!(refusal(uploads.on_frame(&frame(&bad_mime))).contains("cannot open")); + assert!( + refusal(uploads.on_frame(&Frame { + channel: 1, + kind: FrameKind::Open, + payload: Bytes::from_static(b"nope"), + })) + .contains("bad stream open") + ); + + for index in 0..MAX_UPLOADS_IN_FLIGHT { + assert!( + uploads + .on_frame(&open_frame(1 + 2 * index as u16, &format!("f{index}"), 10)) + .is_none() + ); + } + assert!(refusal(uploads.on_frame(&open_frame(99, "one-more", 10))).contains("in flight")); + assert!(refusal(uploads.on_frame(&open_frame(101, "f0", 10))).contains("in flight")); + // A refused stream mid-way frees its slot. + assert!(refusal(uploads.on_frame(&data_frame(1, &[0; 11]))).contains("declared")); + assert_eq!(uploads.in_flight_count(), MAX_UPLOADS_IN_FLIGHT - 1); + assert!(refusal(uploads.on_frame(&open_frame(101, "f1", 10))).contains("already exists")); + assert!(uploads.on_frame(&open_frame(101, "f0", 10)).is_none()); + // A short stream is an error, not a completed upload. + assert!(refusal(uploads.on_frame(&close_frame(3, None))).contains("of the 10")); + assert_eq!(uploads.completed_count(), 0); + // Late frames on a dropped channel draw no answer. + assert!(uploads.on_frame(&data_frame(3, b"x")).is_none()); + assert!(uploads.on_frame(&close_frame(3, None)).is_none()); + } + + #[test] + fn completed_uploads_are_capped_and_taken_all_or_none() { + let uploads = Uploads::default(); + for index in 0..(MAX_COMPLETED_UPLOADS + 2) { + upload(&uploads, 1, &format!("c{index}"), b"abc"); + } + assert_eq!(uploads.completed_count(), MAX_COMPLETED_UPLOADS); + assert!( + uploads.take_all(&["c0".to_string()]).is_err(), + "the oldest were dropped" + ); + assert!(uploads.take_all(&["c1".to_string()]).is_err()); + let error = uploads + .take_all(&["c2".to_string(), "nope".to_string()]) + .unwrap_err(); + assert!(error.contains("nope"), "{error}"); + assert_eq!( + uploads.completed_count(), + MAX_COMPLETED_UPLOADS, + "a failed take consumes nothing" + ); + assert!( + uploads + .take_all(&["c2".to_string(), "c2".to_string()]) + .is_err(), + "one upload cannot be named twice" + ); + let taken = uploads + .take_all(&["c5".to_string(), "c2".to_string()]) + .unwrap(); + assert_eq!(taken.len(), 2); + assert_eq!(uploads.completed_count(), MAX_COMPLETED_UPLOADS - 2); + assert!(uploads.take_all(&[]).unwrap().is_empty()); + // A completed id is reserved until it is taken. + assert!(refusal(uploads.on_frame(&open_frame(1, "c3", 1))).contains("already exists")); + assert!(uploads.on_frame(&open_frame(1, "c2", 1)).is_none()); + } +} diff --git a/apps/maple-agent/crates/maple-remote/src/wire.rs b/apps/maple-agent/crates/maple-remote/src/wire.rs index 111befad3..f77d98bb0 100644 --- a/apps/maple-agent/crates/maple-remote/src/wire.rs +++ b/apps/maple-agent/crates/maple-remote/src/wire.rs @@ -27,16 +27,31 @@ pub const PROTOCOL_VERSION: u32 = 1; /// Feature flags a side advertises. Absent means off. pub type Features = BTreeMap; +/// The client streams images ahead of `run.send` and names them by upload +/// id; the host refuses images inline in the request. See +/// [`crate::uploads`]. +pub const UPLOAD_STREAMS_FEATURE: &str = "uploadStreams"; + /// Features this build implements. Both sides send the same table; a /// client gates a new call on the host's answer. pub fn features() -> Features { let mut features = Features::new(); - for name in ["timelinePaging", "attachmentStreams", "ping"] { + for name in [ + "timelinePaging", + "attachmentStreams", + "ping", + UPLOAD_STREAMS_FEATURE, + ] { features.insert(name.to_string(), true); } features } +/// Whether `features` advertises `name`. +pub fn has_feature(features: &Features, name: &str) -> bool { + features.get(name).copied().unwrap_or(false) +} + /// The notification method that carries host events. pub const EVENT_METHOD: &str = "event"; @@ -146,6 +161,16 @@ pub struct AttachmentHandle { pub len: u64, } +/// An image the client streamed to the host before `run.send`, named by +/// the id it minted for the upload stream. The media type travelled with +/// the stream; the name is what the message shows. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UploadRef { + pub upload_id: String, + pub name: String, +} + // ---- Domain requests -------------------------------------------------------- // // Each variant is one method. A variant with fields carries them as the @@ -263,8 +288,15 @@ pub enum SessionRequest { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "method", content = "params", rename_all_fields = "camelCase")] pub enum RunRequest { + /// `request.attachments` must be empty on the wire: images are + /// streamed first and named in `uploads`. The host rebuilds the + /// request's attachments from them before it sends the message. #[serde(rename = "run.send")] - Send { request: AgentSendMessageRequest }, + Send { + request: AgentSendMessageRequest, + #[serde(default)] + uploads: Vec, + }, #[serde(rename = "run.cancel")] Cancel { run_id: String }, #[serde(rename = "run.cancel_queued")] @@ -649,5 +681,21 @@ mod tests { #[test] fn features_table_has_this_builds_flags() { assert_eq!(features().get("timelinePaging"), Some(&true)); + assert!(has_feature(&features(), UPLOAD_STREAMS_FEATURE)); + assert!(!has_feature(&Features::new(), UPLOAD_STREAMS_FEATURE)); + } + + #[test] + fn a_send_without_uploads_decodes_from_an_older_client() { + let params = serde_json::json!({ + "request": {"sessionId": "s1", "text": "hi", "model": null, "mode": null} + }); + match decode_request("run.send", params).unwrap() { + Request::Run(RunRequest::Send { request, uploads }) => { + assert_eq!(request.session_id, "s1"); + assert!(uploads.is_empty()); + } + other => panic!("wrong decode: {other:?}"), + } } } diff --git a/apps/maple-agent/crates/maple-remote/tests/common/mod.rs b/apps/maple-agent/crates/maple-remote/tests/common/mod.rs index 27ea6bbfa..858e2f2d0 100644 --- a/apps/maple-agent/crates/maple-remote/tests/common/mod.rs +++ b/apps/maple-agent/crates/maple-remote/tests/common/mod.rs @@ -33,6 +33,8 @@ pub struct FakeHost { pub loads: AtomicUsize, /// Every root passed to `unwatch_project_root`, in order. pub unwatched: std::sync::Mutex>, + /// Every request passed to `send_message`, in order. + pub sent: std::sync::Mutex>, } pub fn summary(id: &str) -> AgentSessionSummary { @@ -75,6 +77,7 @@ impl FakeHost { attachment: (0..(600 * 1024)).map(|i| (i % 251) as u8).collect(), loads: AtomicUsize::new(0), unwatched: std::sync::Mutex::new(Vec::new()), + sent: std::sync::Mutex::new(Vec::new()), }) } @@ -234,7 +237,9 @@ impl HostBackend for FakeHost { Ok(self.attachment.clone()) } async fn send_message(&self, request: AgentSendMessageRequest) -> Result { - Ok(format!("run-for-{}", request.session_id)) + let run = format!("run-for-{}", request.session_id); + self.sent.lock().unwrap().push(request); + Ok(run) } async fn cancel_run(&self, _: String) -> Result<(), String> { Ok(()) diff --git a/apps/maple-agent/crates/maple-remote/tests/loopback.rs b/apps/maple-agent/crates/maple-remote/tests/loopback.rs index 339f3bc5b..b39aa449b 100644 --- a/apps/maple-agent/crates/maple-remote/tests/loopback.rs +++ b/apps/maple-agent/crates/maple-remote/tests/loopback.rs @@ -7,14 +7,17 @@ use std::sync::Arc; use std::sync::atomic::Ordering; use std::time::Duration; +use bytes::Bytes; use common::{FakeHost, client_config, hello, identity, info, summary}; -use maple_agent::agent::{AgentSendMessageRequest, AgentServiceEvent}; +use maple_agent::agent::{AgentImageUpload, AgentSendMessageRequest, AgentServiceEvent}; use maple_agent::host::{ContextUsage, HostBackend, HostEvent}; use maple_remote::carrier::{Carrier, FrameSink, FrameStream, in_process_pair}; use maple_remote::client::RemoteHostBackend; -use maple_remote::frame::Frame; +use maple_remote::frame::{CONTROL_CHANNEL, Frame, FrameKind}; use maple_remote::rpc::{self, Message, Response}; use maple_remote::server::{HostServer, HostServerConfig, MAX_WATCHED_ROOTS}; +use maple_remote::streams::{StreamClose, StreamOpen, UPLOAD_PURPOSE}; +use maple_remote::uploads::MAX_UPLOAD_BYTES; use maple_remote::wire::{EventEnvelope, HostHello, PROTOCOL_VERSION}; /// Start a server on a fresh pair and connect a client through it. @@ -564,3 +567,296 @@ async fn requests_before_the_handshake_and_unknown_methods_are_refused() { }; assert_eq!(response.error.unwrap().code, rpc::code::METHOD_NOT_FOUND); } + +fn send_request(attachments: Vec) -> AgentSendMessageRequest { + AgentSendMessageRequest { + session_id: "s1".to_string(), + text: "look".to_string(), + model: Some("m1".to_string()), + context_limit: None, + mode: None, + vision_capable: true, + steer: false, + queue_id: None, + attachments, + } +} + +fn image(name: &str, len: usize) -> AgentImageUpload { + use base64::Engine as _; + let bytes: Vec = (0..len).map(|i| (i % 253) as u8).collect(); + AgentImageUpload { + name: name.to_string(), + data_url: format!( + "data:image/png;base64,{}", + base64::engine::general_purpose::STANDARD.encode(&bytes) + ), + } +} + +#[tokio::test] +async fn a_large_attachment_streams_to_the_host_ahead_of_the_send() { + let host = FakeHost::new(0); + let (client, serving) = connect(Arc::clone(&host), HostServerConfig::default()).await; + let big = image("screen.png", 6 * 1024 * 1024); + let small = image("icon.png", 300); + let run = client + .send_message(send_request(vec![big.clone(), small.clone()])) + .await + .unwrap(); + assert_eq!(run, "run-for-s1"); + { + let sent = host.sent.lock().unwrap(); + assert_eq!(sent.len(), 1); + let request = &sent[0]; + assert_eq!(request.text, "look"); + assert_eq!(request.model.as_deref(), Some("m1")); + assert!(request.vision_capable); + assert_eq!(request.attachments.len(), 2); + assert_eq!(request.attachments[0].name, "screen.png"); + assert_eq!( + request.attachments[0].data_url, big.data_url, + "the host rebuilt the same data URL from the streamed bytes" + ); + assert_eq!(request.attachments[1].name, "icon.png"); + assert_eq!(request.attachments[1].data_url, small.data_url); + } + // A second send reuses nothing: the uploads were consumed. + client + .send_message(send_request(vec![image("again.png", 10)])) + .await + .unwrap(); + assert_eq!( + host.sent.lock().unwrap()[1].attachments[0].name, + "again.png" + ); + client.close().await; + tokio::time::timeout(Duration::from_secs(5), serving) + .await + .unwrap() + .unwrap() + .unwrap(); +} + +/// The next frame on a stream channel, skipping control traffic. +async fn raw_stream_frame(stream: &mut Box) -> Frame { + loop { + let frame = stream.recv().await.expect("a stream frame"); + if frame.channel != CONTROL_CHANNEL { + return frame; + } + } +} + +fn open_upload(channel: u16, upload_id: &str, len: u64) -> Frame { + let open = StreamOpen { + purpose: UPLOAD_PURPOSE.to_string(), + request_id: None, + upload_id: Some(upload_id.to_string()), + mime: Some("image/png".to_string()), + len: Some(len), + }; + Frame { + channel, + kind: FrameKind::Open, + payload: serde_json::to_vec(&open).unwrap().into(), + } +} + +fn refusal(frame: &Frame) -> String { + assert_eq!(frame.kind, FrameKind::Close); + serde_json::from_slice::(&frame.payload) + .unwrap() + .error + .expect("a refusal names its reason") +} + +#[tokio::test] +async fn an_oversized_upload_is_refused_and_the_connection_stays_usable() { + let host = FakeHost::new(0); + // The client refuses before it opens a stream. + let (client, _serving) = connect(Arc::clone(&host), HostServerConfig::default()).await; + let error = client + .send_message(send_request(vec![image("huge.png", MAX_UPLOAD_BYTES + 1)])) + .await + .unwrap_err(); + assert!(error.contains("too large"), "{error}"); + assert!(host.sent.lock().unwrap().is_empty()); + assert_eq!( + client + .send_message(send_request(vec![image("ok.png", 64)])) + .await + .unwrap(), + "run-for-s1" + ); + + // The host refuses a stream that declares too much, or sends more + // than it declared, and keeps answering afterwards. + let (client_side, host_side) = in_process_pair(8); + let server = HostServer::new(host, info(), identity(), HostServerConfig::default()); + let _serving = tokio::spawn(server.serve(host_side)); + let (mut sink, mut stream) = raw_handshake(client_side).await; + sink.send(open_upload( + 1, + "declared-too-much", + MAX_UPLOAD_BYTES as u64 + 1, + )) + .await + .unwrap(); + let reply = raw_stream_frame(&mut stream).await; + assert_eq!(reply.channel, 1); + assert!(refusal(&reply).contains("byte limit")); + + sink.send(open_upload(3, "lied", 4)).await.unwrap(); + sink.send(Frame { + channel: 3, + kind: FrameKind::Data, + payload: vec![0u8; 5].into(), + }) + .await + .unwrap(); + let reply = raw_stream_frame(&mut stream).await; + assert_eq!(reply.channel, 3); + assert!(refusal(&reply).contains("declared")); + + let ping = raw_call( + &mut sink, + &mut stream, + 2, + "host.ping", + serde_json::json!({}), + ) + .await; + assert!(ping.error.is_none()); + let send = raw_call( + &mut sink, + &mut stream, + 3, + "run.send", + serde_json::json!({ + "request": send_request(Vec::new()), + "uploads": [{"uploadId": "lied", "name": "x.png"}] + }), + ) + .await; + let error = send.error.expect("a refused upload is not usable"); + assert_eq!(error.code, rpc::code::INVALID_PARAMS); +} + +#[tokio::test] +async fn a_send_naming_an_unknown_upload_or_inline_images_is_invalid_params() { + let host = FakeHost::new(0); + let (client_side, host_side) = in_process_pair(8); + let server = HostServer::new( + Arc::clone(&host) as Arc, + info(), + identity(), + HostServerConfig::default(), + ); + let _serving = tokio::spawn(server.serve(host_side)); + let (mut sink, mut stream) = raw_handshake(client_side).await; + let unknown = raw_call( + &mut sink, + &mut stream, + 2, + "run.send", + serde_json::json!({ + "request": send_request(Vec::new()), + "uploads": [{"uploadId": "nope", "name": "x.png"}] + }), + ) + .await; + let error = unknown.error.expect("refused"); + assert_eq!(error.code, rpc::code::INVALID_PARAMS); + assert!(error.message.contains("nope"), "{}", error.message); + + let inline = raw_call( + &mut sink, + &mut stream, + 3, + "run.send", + serde_json::json!({ "request": send_request(vec![image("x.png", 8)]) }), + ) + .await; + let error = inline.error.expect("refused"); + assert_eq!(error.code, rpc::code::INVALID_PARAMS); + assert!( + error.message.contains("update the client"), + "{}", + error.message + ); + assert!(host.sent.lock().unwrap().is_empty()); + + // A host that does not advertise uploads gets no attachments. + let (client_side, host_side) = in_process_pair(16); + let peer = tokio::spawn(raw_host(host_side, Vec::new())); + let mut config = client_config(); + config.ping_interval = Duration::from_secs(3600); + let client = RemoteHostBackend::connect(client_side, hello(), config) + .await + .unwrap(); + let error = client + .send_message(send_request(vec![image("x.png", 8)])) + .await + .unwrap_err(); + assert!(error.contains("update the host"), "{error}"); + client.close().await; + let _ = tokio::time::timeout(Duration::from_secs(5), peer).await; +} + +#[tokio::test] +async fn uploads_die_with_their_connection() { + let host = FakeHost::new(0); + let server = HostServer::new( + Arc::clone(&host) as Arc, + info(), + identity(), + HostServerConfig::default(), + ); + let (client_side, host_side) = in_process_pair(8); + let serving = tokio::spawn(Arc::clone(&server).serve(host_side)); + let (mut sink, mut stream) = raw_handshake(client_side).await; + sink.send(open_upload(1, "kept", 3)).await.unwrap(); + sink.send(Frame { + channel: 1, + kind: FrameKind::Data, + payload: b"abc".to_vec().into(), + }) + .await + .unwrap(); + sink.send(Frame { + channel: 1, + kind: FrameKind::Close, + payload: Bytes::new(), + }) + .await + .unwrap(); + let ack = raw_stream_frame(&mut stream).await; + assert_eq!((ack.channel, ack.kind), (1, FrameKind::Close)); + assert!(ack.payload.is_empty(), "the host acknowledged the upload"); + sink.close().await; + drop(stream); + tokio::time::timeout(Duration::from_secs(5), serving) + .await + .unwrap() + .unwrap() + .unwrap(); + + let (client_side, host_side) = in_process_pair(8); + let _serving = tokio::spawn(server.serve(host_side)); + let (mut sink, mut stream) = raw_handshake(client_side).await; + let send = raw_call( + &mut sink, + &mut stream, + 2, + "run.send", + serde_json::json!({ + "request": send_request(Vec::new()), + "uploads": [{"uploadId": "kept", "name": "x.png"}] + }), + ) + .await; + let error = send.error.expect("the upload went with its connection"); + assert_eq!(error.code, rpc::code::INVALID_PARAMS); + assert!(host.sent.lock().unwrap().is_empty()); +} From a165f762da1e50f9a38b250a5562242c9a78ddda Mon Sep 17 00:00:00 2001 From: benthecarman Date: Sun, 20 Sep 2026 12:26:14 -0500 Subject: [PATCH 26/37] Describe remote development as built The design doc had accumulated "As built" notes while its main text kept describing the original plan: a host router, connector traits, host-level cursors, logical sessions, and files in places they no longer live. An audit against the code listed every mismatch. The main text now says what the code does, with the planning decisions kept as recorded, a limits section for the constants the code enforces, a testing section naming the tests that exist, a "Not built" section for what the plan promised and the code does not do, and the relay constraints kept for later. The image upload streams are described as built: the wire shapes, the channel parity, the receiver's acknowledging close, the limits, and the feature gate. Co-Authored-By: Claude Fable 5.1 --- apps/maple-agent/docs/remote-development.md | 1190 +++++++++++-------- 1 file changed, 691 insertions(+), 499 deletions(-) diff --git a/apps/maple-agent/docs/remote-development.md b/apps/maple-agent/docs/remote-development.md index 7d08fc94c..697d3fe55 100644 --- a/apps/maple-agent/docs/remote-development.md +++ b/apps/maple-agent/docs/remote-development.md @@ -1,34 +1,33 @@ # Remote development -Status: design agreed 2026-09-19; all five implementation steps are built. -What remains is listed under "Out of scope for this release" and in the -"As built" and "Still to do" notes below. When code and this document +Status: built. This document describes the feature as it is in the code. +What the plan promised and the code does not do is under "Not built"; the +enclave relay is under "Later: relay". When code and this document disagree, the code wins; update this document in the same change. A Maple host runs the agent runtime and serves it over the network. A Maple -client is the desktop app, which drives one or more hosts. The first release -covers direct connections over a LAN or a Tailscale network. A later release -adds a blind relay through the OpenSecret enclave without changing the -protocol above the transport. +client is the desktop app, which drives its own local host and any number +of remote hosts. Connections are direct, over a LAN or a Tailscale network. +A later release adds a blind relay through the OpenSecret enclave without +changing the protocol above the transport. -## Glossary +## Goal and scope -Use these words and no synonyms. Do not use "machine", "server", "daemon", -"remote", or "peer" for any of them in code, UI, or docs. +Full parity: a client connected to a remote host gets what the local window +has, through the same `HostBackend` trait the window uses. The host owns +the runtime, the filesystem, the git checkout, the SQLite stores, the +integrations, and project trust. The client owns sign-in, billing, audio, +notifications, and its own display settings. -| Term | Meaning | -| --- | --- | -| Host | A process that runs the agent runtime and accepts client connections. Every app instance is its own local host. Identified by its static Noise public key. | -| Client | The desktop app acting as a consumer of a host. | -| Device | A client identity, one static Noise key pair. One person may have several devices. | -| Connection | One way to reach a host: an address for a direct connection, or the relay later. A host has one or more connections. | -| Pairing | The one-time exchange that gives a device and a host each other's static key. | -| Session | A Maple task with its timeline, owned by exactly one host. | -| Generation | A UUID a host mints at boot. Every cursor is scoped to it. | +Out of scope: the relay, mDNS, a directory browser beyond suggestions, +remote sign-in, cached task lists for offline hosts, terminals, CUA over the +wire, running the client with the local runtime disabled, and connection +probing with automatic switching. ## Decisions -These are settled. Each maps to a question answered during planning. +These are the answers given during planning, kept as recorded. Where the +build differs in detail, the sections below say what the code does. - Full parity. A client connected to a remote host gets everything the local window has: queue, steering, permissions, questions, integrations, Codex and @@ -69,158 +68,171 @@ These are settled. Each maps to a question answered during planning. - Reserved. A generic binary stream channel type in the framing so a PTY can be added later without a protocol change. CUA stays host-local. -## Out of scope for this release +## Glossary + +Use these words for these concepts in this document and in new UI copy. +The transport code says "peer" for the other end of a connection, and UI +copy says "this machine" for the computer the app runs on; neither names a +host or a device. -Relay implementation, mDNS, a remote directory browser beyond suggestions, -remote re-authentication, cached session lists for offline hosts, terminals, -CUA over the wire, running the client with the local runtime disabled, and -connection probing with automatic switching between connections. +| Term | Meaning | +| --- | --- | +| Host | A process that runs the agent runtime and accepts client connections. Every app instance is its own local host. Identified by its static Noise public key; the local host's id is `local`. | +| Client | The desktop app acting as a consumer of a host. | +| Device | A client identity, one static Noise key pair. One person may have several devices. | +| Connection | One way to reach a host. Today the only kind is `direct`, an address. A host has one or more connections. | +| Pairing | The one-time exchange that gives a device and a host each other's static key. | +| Session | A Maple task with its timeline, owned by exactly one host. The UI says "task". | +| Generation | A UUID a host mints when its process starts. Every event sequence is scoped to it. | ## Architecture ### The backend seam -`app/src/backend.rs` is today the only module that imports `maple_agent`. It -is a concrete struct held as `Arc`. It splits in two. - -Account-level concerns stay in the local backend and never go over the wire: -sign-in and OAuth, billing, audio transcription and speech, update checks, -opening URLs, and desktop notifications. These use the client's own +`app/src/backend.rs` keeps the account-level concerns that never go over +the wire: sign-in and OAuth, billing, audio transcription and speech, update +checks, opening URLs, and desktop notifications. These use the client's own OpenSecret session. -Session-level concerns move behind a per-host trait, `HostBackend` in -`crates/maple-agent/src/host/`, with two implementations: - -- `LocalHostBackend` wraps `AgentRuntimeHandle` in process. The local window - uses this directly. `AgentBackend::local_host` creates one per account. -- `RemoteHostBackend` speaks the wire protocol to a remote host. - -A `HostRouter` owns one `HostBackend` per connected host, keyed by host id, -and presents the merged view the UI reads. The UI never holds a host backend -directly. Every UI call that names a session carries `(host_id, session_id)`. - -The host side is `HostServer`. It consumes the same `AgentRuntimeHandle` the -local window uses. Fan-out happens at the `AgentEventSink` level: the runtime -emits once, and the server projects to every subscribed socket. The -`LocalHostBackend` and `HostServer` are siblings, not layers. - -`HostServer` is split into per-domain controllers from the first commit. Each -controller owns a set of request types and a non-async dispatch that returns -immediately on a miss. Do not grow one session controller. - -### Reach-arounds that close - -Four places in the UI touch the host filesystem directly today. Each becomes -a host method or a pushed event. - -| Today | After | +Everything a client drives on a host is the `HostBackend` trait in +`crates/maple-agent/src/host/mod.rs`, with two implementations: + +- `LocalHostBackend` (`crates/maple-agent/src/host/local/`) wraps + `AgentRuntimeHandle` in process and owns the host-side pieces the UI must + not reach into: the filesystem, the git dir, and the account's SQLite + stores. `AgentBackend::local_host` hands out one per account. +- `RemoteHostBackend` (`crates/maple-remote/src/client.rs`) speaks the wire + to a `HostServer`. One instance is one connection; when the connection + ends the instance is dead and its owner reconnects with a fresh one. + +Hosts push `HostEvent`s: `Service` (a runtime event), `ProjectBranch`, and +`Resync`. `HostEventHub` fans one host's events out to every subscriber; it +is the runtime's event sink for the local host, and `HostServer` subscribes +to it for every connection. `LocalHostBackend` and `HostServer` are +siblings over the same runtime handle, not layers. + +`HostServer` (`crates/maple-remote/src/server.rs`) serves any number of +connections. Requests are dispatched by the domain prefix of the method to +one controller each: `host`, `project`, `session`, `run`, `model`, +`integration`. A controller is a plain `match` over its domain's request +enum. + +There is no router type. The chat screen (`app/src/ui/chat/hosts.rs`) keeps +one `ChatHost` entry per known host, maps every task id to the host that +owns it (`session_hosts`), and points its single `host` handle at the host +new tasks target. A call about a task goes to `backend_for(session_id)`; +every other call goes to the target. The local host is named +"This computer". + +### What moved to the host + +Four places in the UI used to touch the host filesystem directly. Each is +now a host method or a pushed event. + +| Before | Now | | --- | --- | -| Native folder picker returns a local path used as `project_root` (`ui/chat/mod.rs`), and `backend.rs` checks `is_dir()` in the UI process. | `HostBackend::suggest_directories(query, cwd)` returns entries from the host. `HostBackend::validate_project_root(path)` runs on the host. Recent roots come from the host. | -| Git branch read and `notify` watcher run in the UI process. | Host owns the watcher and pushes `SessionGitState` events. Rate limited per root. | -| UI opens `sessions.db` read-only every second for the context ring. | `HostBackend::context_usage(session)` and a pushed `ContextUsage` event during runs. | -| UI opens `tool_summaries.db` read-write. | `HostBackend::tool_summaries(session)` and `set_tool_summary`. | - -As built: the root check is `HostBackend::select_project_root(path)`, not a -separate `validate_project_root`; it registers the root and expands a leading -`~` against the host's home directory, since a typed path arrives as written -on the client. Context usage is not pushed: while a run is active the client -polls `HostBackend::context_usage(session, model)` every 5 seconds, and -re-reads only when the transcript changed since the last poll. The branch -event is `HostEvent::ProjectBranch`, and the summary writer is -`store_tool_summary`. - -Image attachments already travel as bytes in `AgentImageUpload`. They move to -a binary stream channel, chunked, so the control channel frame limit does not -cap them. +| Native folder picker returned a local path; `is_dir()` ran in the UI process. | `HostBackend::select_project_root(path)` registers the root on the host: a leading `~` expands against the host's home directory (a typed path arrives as written on the client), the path is canonicalized, and a non-directory is an error. `suggest_directories(query)` answers from the host: an empty query lists the home directory, `~` means home, hidden directories appear only for a dot prefix, at most 50 entries. Recent roots come from the host. | +| Git branch read and `notify` watcher ran in the UI process. | The host owns one watcher per root, shared and reference-counted across the clients that asked for it, and pushes `HostEvent::ProjectBranch` when a watch starts and whenever `HEAD` changes. Access-only filesystem events are dropped; there is no other rate limit. | +| UI opened `sessions.db` every second for the context ring. | The client polls `HostBackend::context_usage(session, model)`: while a run is active on the task on screen, a poller ticks every 5 s and re-reads only when the task's timeline changed since the last tick. Nothing is pushed. | +| UI opened `tool_summaries.db` read-write. | `HostBackend::tool_summaries(session)` and `store_tool_summary`. | + +Image attachments a task already holds are read with +`read_image_attachment` and travel on a binary stream from the host. +Images the user attaches to a new message travel on a binary stream from +the client ahead of `run.send`, which names them by upload id (see +"Streams and credit"). Neither direction is bounded by the control +frame limit. ### Local-only capabilities -Anything that must run where the window is stays gated on "this host is the -local host": desktop notifications, opening a URL, and later "reveal in file -manager". Nothing else may branch on locality. +Integration setup runs where the window is. `RemoteHostBackend:: +setup_integration` answers "set up integrations on the host itself" without +a wire call; there is no `integration.setup` method. Desktop notifications +are the client's: it raises them for the tasks it shows when notifications +are on and the window is not focused, and nothing in that path asks which +host owns the task. CUA stays host-local; the client learns nothing about a +host's CUA status. ### Integrations on a remote host -Claude Code, Codex, custom MCP servers, the shell tool, and project trust run -where the runtime runs, which is the host. A remote client sees their output -and answers their permission cards. Installation status, setup, and the -enabled toggles are host-side settings, edited from the client through the -host selector in Settings. - -The external-agent gate requires a desktop session of type User. "Desktop" -means created by a desktop-class client, local or remote. Sessions created by -a remote client are desktop sessions. Sessions created by ACP callers stay -excluded as today. Do not read "desktop" as "the local window". - -Embedded CUA attaches to a remotely driven task the same way it attaches to a -locally driven one, with two conditions that are properties of the host: - -- The host needs a live graphical session: a logged-in user session on - macOS, or a running compositor with the desktop portal on Linux. A `serve` - process started over SSH on a machine with no logged-in desktop reports - CUA as unavailable. -- Setup happens at the host's screen. macOS attributes Accessibility and - Screen Recording grants to the app identity that asks, after a direct user - action on the UI thread, and the prompt appears on the host's display. The - Linux portal consent prompt also appears on the host's display. The client - therefore shows the host's CUA status and, when grants are missing, says - to grant them on the host. It never triggers the grant flow remotely. - -On macOS the grants belong to the bundle identity, so a CUA-capable host is -the desktop app with "Allow remote connections" on, or `serve` launched from -inside the same staged bundle. That is the recommended CUA host -configuration. The client cannot see the host's screen; screenshots reach it -only as timeline items, as today. - -## Protocol +Claude Code, Codex, custom MCP servers, the shell tool, and project trust +run where the runtime runs. A remote client sees their output and answers +their permission cards. The enabled toggles and the MCP server list are +host-side (`integration.list`, `integration.set_enabled`, +`integration.list_mcp`, `integration.save_mcp`), edited from the client +through the host selector that Settings shows on host-scoped sections once +more than one host is connected. -The protocol lives in a new crate, `crates/maple-remote`. It holds wire types, -framing, sequences, the Noise layer, `HostServer`, and the client. It depends -on `crates/maple-agent` for domain types and never on `app`. +A session a remote client creates goes through the host's +`create_session`, the same call the window makes, so it is a desktop +session like the window's. Sessions created by ACP callers stay out of +every client's task list. -### Carrier - -WebSocket. Direct connections use plain `ws://` because Noise encrypts inside -the WebSocket. The relay later uses `wss://` to the enclave with the same -Noise inside. The enclave ingress is Cloudflare, nginx, socat, then axum, with -a 300 second idle timeout, so keepalives are mandatory. `perMessageDeflate` is -off. +## Protocol -A connector trait abstracts dialing: +The protocol lives in `crates/maple-remote`. Bottom up: `carrier`, `noise`, +`listen`, `dial`, `net`; `keys`, `pairing`, `devices`; `hosts`, `manager`; +`frame`, `rpc`, `streams`, `outbound`; `wire`; `server`; `client`. It +depends on `crates/maple-agent` for domain types and never on `app`. -```rust -trait Connector { - async fn connect(&self, target: &Connection) -> Result>; -} -``` +### Carrier and framing -This release ships `DirectConnector`. `RelayConnector` arrives with the relay -and rendezvous by device public key. +A `Carrier` is a struct of two boxed halves, a `FrameSink` and a +`FrameStream`. `carrier::in_process_pair` connects two in one process for +tests. The network carrier is a plain `ws://` WebSocket with Noise inside: +`listen::serve_listener` accepts connections for one `HostServer`, +`dial::connect_direct` opens one for a client, and `net` holds what both +share. There is no connector trait; the relay is a second dial function +later. -### Framing +Every WebSocket binary message is exactly one Noise transport message, and +both roles cap WebSocket messages at 65535 bytes, the Noise maximum. A +frame is cut into pieces of at most 65535 - 16 - 1 bytes; each piece +carries one continuation byte (`1` more follows, `0` last) before the frame +bytes. A peer that reassembles past the largest control frame plus its +header is cut off. -Every WebSocket message after the handshake is one Noise transport message. -Its plaintext is: +The plaintext of a frame is: ``` -[channel: u16][kind: u8][payload] +[channel: u16 BE][kind: u8][payload] ``` -Channel 0 is control and carries JSON. Other channels carry binary streams -opened by a control message. Kinds are `open`, `data`, `close`, `credit`. -Binary channels have explicit per-channel credit-based flow control so a slow -file upload cannot starve control. The control channel frame limit is 4 MiB; -anything larger belongs on a stream or must be paged. - -Reserved now, unused in this release: a stream kind for a PTY, which carries -`{rows, cols, intent: claim | update}` resizes. Passive rendering never sends -a claim. - -### Messages - -Control messages are JSON-RPC 2.0. Methods are namespaced by domain and -mirror `HostBackend`; the request enums in `crates/maple-remote/src/wire.rs` -are the source of truth: +Channel 0 is control and carries one JSON-RPC message per `Data` frame. +Channels 1 and up are binary streams. Kinds are `Open` (0), `Data` (1), +`Close` (2), and `Credit` (3). The control frame limit is 4 MiB; anything +larger belongs on a stream or must be paged. A stream data frame is at most +256 KiB. A short header, an unknown kind, or an oversized payload closes +the connection. + +Every frame a side sends goes through one byte-bounded outbound queue, +64 MiB by default. A frame that would push the count past the limit is +refused and marks the connection for closing: the peer has stopped +draining, and the host never waits on a client. Each connection's +forwarder serializes its own copy of a broadcast; nothing is serialized +once and shared. + +### Control channel and methods + +Control messages are JSON-RPC 2.0 with numeric ids. Requests get exactly +one response. The `event` notification carries host events from host to +client. The keepalive is the `host.ping` request. The host answers requests +concurrently, each on its own task, so a slow call never delays the ping. + +Error codes: the JSON-RPC reserved `INVALID_REQUEST`, `METHOD_NOT_FOUND`, +and `INVALID_PARAMS`, plus `HANDSHAKE_REFUSED` (-32000, the connection +closes after the answer), `NOT_READY` (-32001, `host.hello` has not been +sent), and `HOST_ERROR` (-32002, the host's `HostBackend` returned an +error; the message is the user-facing text). There is no parse error: a +control frame the host cannot decode closes the connection, because +nothing in it can be trusted to carry an id. The client drops an +undecodable control frame and logs it. + +Methods are namespaced by domain and mirror `HostBackend`. The request +enums in `crates/maple-remote/src/wire.rs` are the source of truth; each is +a serde enum tagged by `method` with the variant's fields as camelCase +`params`, and the `*_METHODS` constants list every name (a test checks +them against the enums): ``` host.hello, host.ping, host.bootstrap, host.start_runtime, @@ -244,409 +256,589 @@ integration.list_mcp, integration.save_mcp, integration.list, integration.set_enabled ``` -Host events arrive as the `event` notification with a per-connection -sequence. Device methods arrive with pairing. +There are no response enums. A response is the `HostBackend` return type +serialized, with four exceptions: `host.bootstrap` answers a +`BootstrapSnapshot` (the bootstrap with the newest task's timeline +stripped, plus `latestTimelineLen`), `session.load` answers a +`SessionSnapshot` (the detail with an empty timeline, plus `timelineLen`), +`session.timeline` answers a `TimelinePage` (`items`, `hasMore`), and +`session.read_attachment` answers an `AttachmentHandle` (`stream`, `len`). +`host.ping` answers `{}`. There are no `device.*` methods. -Each domain has its own request and response enums and its own controller on -the host. There is no single message union. +Compatibility rules for everything in `wire`: -Rules for every type on the wire: - -- Every optional field has `#[serde(default)]`. Enums that may grow are - `#[non_exhaustive]` on the wire with an `Unknown` fallback on decode. -- Schemas are append-only. Never remove a field, never make an optional field - required, never narrow a type. A field you stop sending stays accepted. +- Schemas are append-only. New params are `Option` with a serde default; + unknown fields are ignored on both sides; a field that stops being sent + stays accepted. Enums are not `#[non_exhaustive]`. - Every compatibility shim carries a dated tag: `// COMPAT(name): added in vX.Y, remove after YYYY-MM-DD once host floor >= vX.Y.` `rg 'COMPAT\('` is the cleanup backlog. -- No fallback paths for new features. If the host lacks a capability, the - client says "update the host" in one place and does nothing else. - -`AgentServiceEvent`, `AgentRunEvent`, and every request and response type in -`crates/maple-agent/src/agent/types.rs` and `timeline.rs` gain the missing -`Serialize` or `Deserialize` derives. This freezes the wire contract, so -review those types with that in mind. - -### Handshake +- `protocol` is a tripwire, bumped only for a change no feature flag can + express. Real evolution goes through the `features` bags in the + handshake. Today both sides send the same table and neither gates a + call on the other's flags; the client checks only the protocol version. + +`AgentServiceEvent`, `AgentRunEvent`, and the request and response types +in `crates/maple-agent/src/agent/types.rs` carry `Serialize` and +`Deserialize`. That is the wire contract; review those types with that in +mind. + +### Streams and credit + +Either side sends streams. The side that sends the bytes opens a stream +with an `Open` frame on a free channel: clients open odd channels, hosts +even ones, so the two directions never collide. The `Open` payload is +JSON: `purpose` (`attachment` or `upload`), `requestId` (for an +attachment, the JSON-RPC request the stream answers), `uploadId` and +`mime` (for an upload), and `len`, required for uploads. The receiver +starts the sender with 16 frames of credit and grants 8 more each time +it has consumed 8, so one slow transfer never fills the connection's +outbound queue. The sender ends the stream with an empty `Close`, or +early with `Close { "error": "..." }`; a sender dropped mid-stream sends +`Close { "error": "the sender gave up" }` so the peer frees the slot. The +receiver answers every stream with its own `Close`: empty to acknowledge +it, or with an error to refuse it at the `Open`, mid-stream, or when it +ended short of `len`. The receiver collects the bytes whole and +preallocates at most 64 MiB on the announced length. + +An attachment read works like this: the host reads the bytes, opens the +stream with the request id, then answers the RPC with the channel and +length. The open frame precedes the answer on the same ordered carrier, so +the client pairs the collector with the answer by request id and waits for +it. A failed read is an RPC error before any stream opens. When the RPC +fails or the wait runs out, the client abandons the request and drops any +collector opened for it, so late frames are discarded. + +#### Uploads + +An image the user attaches to a message travels before the message. For +each attachment the client mints an upload id, opens a stream with +`purpose: "upload"`, the id, the mime type, and the byte length, sends +the bytes under credit control, closes, and waits for the host's +acknowledging `Close`. It then sends `run.send` with `{ request, +uploads: [{ uploadId, name }] }`; the request's own `attachments` must be +empty on the wire. The host rebuilds each image from the stored bytes +and the mime type before calling the runtime. A request that names an +unknown or incomplete id is `INVALID_PARAMS`, and a request naming +several ids consumes all of them or none. + +The host keeps at most 4 uploads in flight and 16 completed but not yet +referenced per connection, dropping the oldest, and at most 10 MiB each; +the client refuses larger images before opening a stream. Upload ids and +mime types are short printable ASCII, and a mime type may not contain +`,` or `;`, so it cannot alter the rebuilt data URL. Every upload dies +with its connection. Both sides advertise `uploadStreams`; a host without +it makes the client refuse attachments with "update the host", and a +client without it that sends inline images is refused with "update the +client". + +### Sequence and resync + +`HostServer` subscribes a connection to the host's events before it +answers the hello, so nothing is lost between the two; the forwarder holds +events until the client is ready. Every event on a connection carries one +monotonic `seq`, starting after the `seq` in the host's hello (0). The +client accepts `seq == expected` and treats anything else as a gap: it +publishes `HostEvent::Resync`, then the event. On `Resync` the UI re-reads +the task list and reloads the task on screen. A reconnect is a new +connection and a new `RemoteHostBackend`, and always resyncs. + +The host keeps no event log and no per-entity cursors. `session.load` +builds a snapshot, keeps it for the connection (at most 8, least recently +paged first), and `session.timeline` pages it by item count and by bytes +(200 items or 1 MiB per page, at least one item always fits) until +`hasMore` is false. A page for a task the connection never loaded loads it +first. The bootstrap's newest task is paged the same way. Live events +emitted while a snapshot loads are also in the snapshot; applying them +again is idempotent because timeline items are keyed by id. + +Each socket is independent: there is no logical client session across +sockets, and nothing survives a dropped socket. Permission answers go +straight to the runtime, which removes the pending request on the first +answer; a second answer to the same request fails as a host error ("No +pending Agent Mode permission request found"). Queue edits and steering +are last write wins with the host authoritative. + +### Handshakes + +Two Noise handshakes, both with the `snow` crate's default resolver: +pairing runs `Noise_XXpsk3_25519_ChaChaPoly_BLAKE2s` with the one-time code +as the pre-shared key, and every later connection runs +`Noise_IK_25519_ChaChaPoly_BLAKE2s` with the host's pinned static key. The +first byte of the client's first message names the handshake (`1` pair, +`2` session) and selects the prologue (`maple-remote-v1/pair` or +`maple-remote-v1/session`), so a relay cannot swap one for the other. In +the pairing pattern the client sends the last handshake message, so the +host sends one empty transport message once it has spent the code and +recorded the device; the client trusts nothing until it decrypts it. In +the session pattern the client's static key arrives in the first message, +and the host refuses an unpaired key before answering anything. The client +refuses a host whose static key does not match the pinned key. + +After the Noise handshake the client sends `host.hello` as its first +request, and the host answers with its own hello: -After the Noise handshake, each side sends one `hello`: +```json +{ + "protocol": 1, + "appVersion": "0.1.0", + "pcrEnvironment": "Production", + "features": { "timelinePaging": true, "attachmentStreams": true, + "uploadStreams": true, "ping": true }, + "device": { "publicKey": "...", "name": "bens-laptop", "userId": "..." } +} +``` ```json { "protocol": 1, - "app_version": "0.1.0", - "pcr_environment": "Production", + "appVersion": "0.1.0", + "pcrEnvironment": "Production", "generation": "uuid", - "features": { "session_defaults": true, "directory_suggestions": true }, - "device": { "public_key": "...", "name": "bens-laptop", "user_id": "..." } + "seq": 0, + "features": { "timelinePaging": true, "attachmentStreams": true, + "uploadStreams": true, "ping": true }, + "host": { "id": "", "name": "workstation", "userId": "..." } } ``` -`protocol` is a tripwire, not a negotiation channel. It is refused on -mismatch and is only bumped for a change that cannot be expressed as a -feature. All real evolution goes through `features`. A mismatched -`pcr_environment` is always refused, because the two binaries cannot share a -backend. `generation` on the host side scopes every cursor. +The host refuses the hello with `HANDSHAKE_REFUSED` and closes when the +protocol differs, when `pcrEnvironment` differs (two binaries built for +different enclaves cannot share a backend), or when the hello names a +different device key than the handshake proved. Any other method before +the hello gets `NOT_READY`; a second hello gets `INVALID_REQUEST`. The +device name is cleaned before it reaches a log or the device list: control +characters removed, at most 64 characters. `HostServerConfig:: +on_client_hello` runs once per connection after the hello is accepted; the +app's hook records the device's claimed name, user id, and last-seen time. ### Liveness -Four separate budgets. None of them may be inferred from another. +Four budgets. None is inferred from another. | Budget | Value | | --- | --- | -| Connect | 15 s | -| Application ping | Client pings every 10 s, 15 s timeout, reconnect after 2 consecutive misses | -| Host lease | 45 s, claimed by the first ping, renewed by any inbound activity, checked every 10 s, force close on expiry | -| RPC | 60 s default. A timeout is an operation failure, never proof the socket is dead | - -Reconnect uses full-jitter exponential backoff from 1 s to a 30 s cap, -reset on a successful `hello`. Returning to the foreground probes the -connection with a 3 s deadline and bypasses backoff on failure. +| Connect | 15 s: the host gives the WebSocket and Noise handshakes one deadline; the client gives the WebSocket connect, the Noise handshake, and the hello answer 15 s each | +| Application ping | Client sends `host.ping` every 10 s with a 15 s timeout; 2 consecutive misses close the connection | +| Host lease | 45 s, running from the moment the connection is created, renewed by any inbound frame, checked every 10 s, close on expiry | +| RPC | 60 s default; 90 s for `host.start_runtime`. A timeout is an operation failure, never proof the socket is dead | -## Delivery guarantees +A closing host connection waits up to 2 s for its queued frames (a +refusal, an error answer) to reach the peer before the writer is +abandoned, then releases every project watch the connection placed. -Two lanes. Live events are for immediacy. Fetches are authoritative. Catch-up -is paged but complete. +Reconnect uses full-jitter exponential backoff: an exponential delay from +1 s to a 30 s cap, jittered between half and all of it, reset on a +successful connection. There is no foreground probe. -### Host-level state - -The session list, runtime status, and host settings are entities with -independent monotonic sequences under one host generation. The host keeps -only the latest projection per entity plus bounded tombstones. There is no -event log. - -The client sends `{generation, after_seq}`. The host answers either -`changes` with entities above the cursor plus `removals: [{id, seq}]`, or -`snapshot` with `reason: generation_changed` when the cursor is missing, -expired, or from another generation. Repeated identical updates do not bump a -sequence. - -### Session timelines - -As built: every event on a connection carries one monotonic `seq`. The -client accepts `seq == expected`, and treats anything else as a gap. On a -gap it publishes `HostEvent::Resync` and the UI re-reads the task list and -reloads the task on screen. A reconnect is a new connection and always -resyncs. The host keeps no event log: `session.load` builds a snapshot, -keeps it for the connection, and `session.timeline` pages it by item count -and by bytes until `has_more` is false. The bootstrap's newest task is -paged the same way. Live events emitted while a snapshot loads are also in -the snapshot; applying them again is idempotent because timeline items are -keyed by id. - -Still to do here: per-session epochs so a client can tell a compaction or -history replacement from a gap, and a bounded first resume that fetches a -latest tail with `has_older` instead of the whole snapshot. Both are -additive: new fields on `SessionSnapshot` and `TimelinePage`, gated by a -feature flag. +### Pairing -Permission prompts and questions unanswered while no client was connected -block the run and appear in the next snapshot. +1. On the host, `maple-gpui serve pair` or the desktop app's "Generate + pairing code" button writes a pending pairing record to + `/remote/accounts//pending_pairing.json` at mode + 0600 and shows the code. The running listener reads the file on every + incoming connection, so a running host needs no restart. A record is + valid for 5 minutes and is spent by the first pairing that completes; + an expired record is removed when it is next read. +2. The code is 80 bits of randomness shown as sixteen Crockford base32 + characters in groups of four (`XXXX-XXXX-XXXX-XXXX`). Parsing accepts + any case, ignores dashes and spaces, and maps the usual confusables. + The pre-shared key is SHA-256 over a domain tag and the code. +3. The client dials the address with the code and runs the pairing + handshake. Both sides learn each other's static key in it. +4. Before confirming, the host spends the code (a code already spent or + replaced refuses the pairing, so of two devices racing on one code + exactly one succeeds) and records the device in + `/remote/accounts//devices.json`: public key, the + name "new device" until the hello names it, claimed user id, paired + at, last seen. The client saves the host in its per-account + `hosts.json`: public key, name (the host's announced name unless the + user gave one), the address, paired at. Pairing again with a known key + merges into the saved record. +5. Failed pairing handshakes are counted per source address: 5 in 10 + minutes lock the address out of pairing until the window passes, with + at most 1024 addresses tracked (past that the address with the oldest + failure is forgotten). Only pairing-mode failures count, so a revoked + device that keeps retrying a session handshake cannot lock its address + out of pairing again. A locked-out address is offered no pre-shared + key; its session handshakes still work. Failures never delete the + pending record. + +Hosting serves one account's runtime, so devices and codes belong to the +account that is hosting: a device paired while account A was signed in is +not admitted by a host of account B, and a code published for A never +pairs a device into B. `serve pair` and `serve devices` resolve the account +from the saved sign-in and refuse to run without one. The host key and the +lock stay per machine. + +Revocation is an edit to the device file. The listener checks every 10 s +whether each connected device is still paired and drops a revoked device's +connection at the next check. A revoked device's next dial is refused in +the handshake. + +### Devices and hosts stores + +Keys are X25519 pairs generated on first use and stored as +`{ "private": "...", "public": "..." }` (base64url, no padding) at mode +0600. Keys never appear in logs; `Debug` on a key shows only the public +half, and `Debug` on a code or pending record hides the code. + +The client's `hosts.json` is one file per account: -### Fan-out +```json +{ + "hosts": [ + { + "id": "", + "name": "workstation", + "connections": [ + { "kind": "direct", "address": "100.64.0.7:7130" }, + { "kind": "direct", "address": "192.168.1.20:7130" } + ], + "pairedAtMs": 0 + } + ] +} +``` -A logical client session is keyed by device. A second socket from the same -device attaches to the same logical session. Each physical socket owns its -own features and subscriptions and never borrows a sibling's. When the last -socket drops, the logical session survives for 90 s before teardown. +Adding a connection whose host presents an already-known public key merges +into that host. Loading salvages per entry: a malformed connection is +dropped, not the host, and a malformed host is dropped, not the file. -Permission responses go through an in-flight guard. The first response is -submitted. A second response to the same request fails with a real error so -the losing client can show "answered on another device". The resolution is -broadcast to every subscribed client. A resolution event that races the -response is buffered and dispatched after. +The host's `devices.json` is `{ "devices": [ { "publicKey", "name", +"userId", "pairedAtMs", "lastSeenMs" } ] }`. Revoking by name is refused +when several devices share it; revoke by key. The pending record is +`{ "code", "createdMs", "expiresMs" }`. -Queue edits and steering are last write wins with the host authoritative. +## Host role -Presence is not delivery. Focus, visibility, and heartbeat may route desktop -notifications. They never gate which events a subscribed socket receives. +### The serve command -### Slow clients +``` +maple-gpui serve Listen for paired clients. +maple-gpui serve pair Publish a one-time pairing code. +maple-gpui serve devices list Paired devices. +maple-gpui serve devices revoke DEV Forget a device by key or name. -Each physical socket has a bounded outbound queue, 64 MiB. The host -serializes a broadcast once, filters out sockets already over the limit, -then checks exact byte length per remaining socket. A frame that would cross -the limit closes that socket. The run never waits, and sibling sockets are -untouched. The closed client comes back through its ordinary reconnect and -catch-up path. +--listen ADDR:PORT bind address (default 0.0.0.0:7130, env MAPLE_SERVE_LISTEN) +--name NAME host name clients show (default: hostname, env MAPLE_SERVE_NAME) +``` -## Security +`serve` binds every interface by default, because pairing is the gate; +give one address (a Tailscale IP) to narrow it. The default port is 7130, +not 8080, which the proxy mode uses. A port that cannot be bound is an +error; nothing else is tried. The host name comes from `HOSTNAME` in the +environment, else `gethostname`, else "maple". + +`serve` requires a saved sign-in and exits with a message otherwise. A +saved sign-in the server rejects exits with a message to run `login` +again. A server that cannot be reached at start does not: the host serves +with the saved sign-in, requests fail until it goes through, and the +sign-in is retried behind them with growing pauses (5 s, doubling to +5 minutes), so a unit that starts before the network recovers on its own. +Session defaults an older version kept in `settings.json` are adopted into +the account config by every mode that binds an account, including the +window after a sign-in. + +`serve pair` prints the code on stdout and guidance on stderr, including +the running host's name and address when one runs; it learns that from +`serve.json`, read only while the hosting lock is held, so a crashed host's +leftover state is ignored. `serve devices revoke` accepts a public key or +a name. + +`serve` runs under systemd: it stops on SIGTERM as well as Ctrl-C, and when +`NOTIFY_SOCKET` is set (`Type=notify`) it sends `READY=1` once the port is +bound and `STOPPING=1` on the way out. Stopping waits for the listener and +its connections to end before releasing the lock, so a restart right after +can bind. A user unit: + +```ini +[Unit] +Description=Maple host +After=network-online.target +Wants=network-online.target + +[Service] +Type=notify +NotifyAccess=main +ExecStart=%h/.local/bin/maple-gpui serve --listen 100.64.0.7:7130 +Restart=on-failure +RestartSec=5 +TimeoutStopSec=15 + +[Install] +WantedBy=default.target +``` -### Keys +Run `maple-gpui login` once as that user first, then +`systemctl --user enable --now maple-serve`; `loginctl enable-linger` keeps +it up after logout. -Each host and each device has one static X25519 key pair, generated on first -use and stored at mode 0600 under `/remote/`. Host identity and -device identity are these public keys. Keys are never logged. +### Desktop hosting -### Pairing +`app/src/remote/` holds both roles: `host.rs` the host role, `client.rs` +the connection manager for saved hosts, and `mod.rs` the files both keep +under `/remote/`. `Hosting::start` takes the data-root lock, +loads the host key, binds, writes `serve.json`, and serves the account's +local host on the backend runtime. The `serve` command runs it in the +foreground; the window runs it behind the "Allow remote connections" +setting, off by default. The command and the window share the host key, +the lock, and, for one account, the device list and the pending code, so +only one of them serves at a time; the other reports who holds the root. +The lock is a file lock, so a crashed host leaves nothing that blocks the +next start. + +The window's host role (`HostingController`) has four states: off, +starting, listening, and failed. Starting runs on the backend runtime, +never the UI thread; a stop that arrives meanwhile wins. The setting +persists as on only once the host listens, so a start that failed (the +port taken, another host on the root) does not come back at the next +launch; the failure shows in place. Turning the setting off stops hosting +at once. At launch, hosting starts when the setting is on. + +### Files -1. On the host, `maple-gpui serve pair` writes a pending pairing record into - `/remote/pending_pairing.json` at mode 0600 and prints the - code. The desktop app's "Generate pairing code" button does the same. The - running server watches this file. A record is valid for 5 minutes and is - consumed once. -2. The code is at least 80 bits of entropy, rendered as words or base32. -3. The client connects and runs a Noise handshake with a pre-shared-key - pattern, using the code as the PSK. Both sides learn and pin each other's - static public key in that handshake. -4. The host stores the device in `/remote/devices.json`: public - key, name, claimed user id, paired at, last seen. The client stores the - host in its per-account `hosts.json`: public key, display name, - connections, paired at. -5. Failed pairing attempts are rate limited per source address with lockout - after repeated failures. The pending record is deleted after one failure - window. - -After pairing, every connection uses Noise IK with the pinned statics. A peer -whose static key does not match the pinned key is refused, and the client -shows a host identity error rather than re-pairing silently. Noise gives -counters and rekeying, so there is no replay window within a session. - -As built: the first byte of the first handshake message names the pattern -and doubles as the Noise prologue. In the pairing pattern the client sends -the last handshake message, so the host sends one empty transport message -to confirm the code; the client trusts nothing until it decrypts it. The -hello's device key must equal the key the handshake proved. The pending -record and the device file are per account, under -`/remote/accounts//`: hosting serves one account's -runtime, so a device paired while account A was signed in is not admitted -by a host of account B, and a code published for A never pairs a device -into B. `serve pair` and `serve devices` resolve the account from the saved -sign-in and refuse to run without one. The host key and the lock stay per -machine. Revocation is -an edit to the device file; the listener checks it every ten seconds and -drops a revoked device's connection. - -Implementation uses the `snow` crate with its default resolver, which builds -on the `x25519-dalek`, `chacha20poly1305`, and `sha2` crates already in the -lockfile through the Rust SDK. Do not hand-build the handshake. - -### Authority +| Path | Owner | Contents | +| --- | --- | --- | +| `/remote/host_key.json` | host | This machine's static Noise key as a host, 0600 | +| `/remote/device_key.json` | client | This machine's static Noise key as a client device, 0600 | +| `/remote/serve.lock`, `serve.json` | host | The running host's lock and its listen address, name, and key | +| `/remote/accounts//devices.json` | host | Devices paired into this account on this host | +| `/remote/accounts//pending_pairing.json` | host | The pairing code published for this account, until used or expired, 0600 | +| `/agent/accounts//hosts.json` | client | Hosts this account paired with: key, name, addresses | +| `/agent/accounts//config.json` | host | Existing `AgentConfig`, including session defaults | +| `/settings.json` | client | Client settings, including `allow_remote_connections`, `last_task_host`, and per-host UI state under `hosts` | + +`` is the SHA-256 of the account's user id. The `remote/` +directories are created owner-only. + +## Client role + +### Connection manager + +`maple_remote::manager::HostManager` runs one connector task per saved +host on the backend runtime. A connector dials the host's connections in +order and uses the first that completes a handshake, hands the UI a +connected `RemoteHostBackend`, forwards the host's events, and reconnects +with the backoff above when the connection ends. Pairing dials with the +code, saves the host, and starts its connector on the connection the +pairing opened. Everything the UI needs arrives as `HostManagerEvent`s on +one channel: `Status { host, name, status, backend }` (`backend` is +present exactly when the status is `Online`), `Event { host, event }`, and +`HostsChanged(saved hosts)`. The desktop shell pumps that channel into the +chat screen in batches of up to 256, like the local host's events. + +The status states are `Connecting`, `Online`, and `Offline { reason }`. +Removing a host cancels its connector and reports +`Offline { reason: "removed" }`. A replaced or removed connector says +nothing more once it is superseded. The manager also answers +`is_online(id)` for the settings screen, and `rename` exists on it and +the store, but no UI calls it. + +### Client settings + +`/settings.json` stays client-only: theme, fonts, vim modes, +shortcut overrides, notifications, reduce motion, window state, TTS voice +and speed, and the tool details and tool summaries display defaults. It +also holds `allow_remote_connections` (default false), `last_task_host` +(the remote host the last new task was created on; absent when it was the +local host), and `hosts`, a map from host id to `HostUiState`: pinned +tasks, settled and unsettled tasks, and project display names keyed by +path on that host. + +Per host, in the host's per-account `AgentConfig`: default permission +mode, default web enabled, harness instructions, and the default model. +`HostSessionDefaults` carries all four; `set_session_defaults` writes the +first three and leaves `default_model` alone, so a stale settings snapshot +cannot put an old model back; the chat screen saves the model through +`save_default_model`. Values an older app kept in `settings.json` migrate +once into the local host's config; values the config already holds win. + +### Sidebar and tasks + +The sidebar merges tasks across hosts, one row per task, sorted as before. +`HostBootstrap` reads a host's saved project root, task list, recent +roots, newest task, and session defaults in one call; a remote host is +read that way when it connects, and its runtime is started. When more than +one host is known, each row shows the host name after the project name +(`project · host`), and the project switcher menu gains a host block above +the project rows: every host, then "All hosts". Offline hosts stay listed, +grayed, so a host that dropped is still visible; their tasks leave the +list until the host is back (the task on screen stays readable), and a +filter on an offline host is refused with a notice. New tasks go to the +host the filter names, else the selected task's host, else the local host. + +When a host drops after having been online, a notice names it once; the +reconnect attempts that follow report nothing more until it is back. An +answer from a connection that has since dropped or been replaced is +stale and is discarded. + +### Project selection + +Choosing a project is one dialog for every host (`app/src/ui/chat/ +picker.rs`): a search box over the target host's recent projects and its +directory suggestions, and an "Open this path" row when the text starts +with `/` or `~`. Arrows, Enter, and Escape drive it. There is no native +folder picker on any host. + +### Host chip and restore + +A host chip in the header, shown only when more than one host is known, +names the host new tasks run on with a status dot and switches it from a +dropdown; the sidebar filter and the selected task move the target too. +Switching the target adopts that host's project root, recent roots, and +session defaults. + +The host the last new task ran on is saved in the client settings and is +the target again at the next launch: startup holds the local auto-select +until that host connects, then makes it the target if nothing was chosen +meanwhile, shows its saved project, and opens its latest task with its +stored tool summaries. A remembered host that reports offline, is no +longer saved, or fails its bootstrap releases startup to the local +auto-select. + +### Settings + +Settings has one Hosts pane for both roles. For the client role it pairs +(address, code, optional name) and lists the saved hosts with their +connection state and a remove action; there is no rename UI. For the host +role it shows the "Allow remote connections" toggle and its state: +"Not listening", "Starting", the bound socket, or, when the host binds +every interface, the port with a note to use the machine's LAN or +Tailscale address. "Generate pairing code" works only while listening; the +code stays on screen until the host consumed it (the device list is then +re-read) or it expired. Below that, the paired devices with a revoke +action. Device and pairing files are read and written off the UI thread. + +Host-scoped sections (session defaults, system prompt, integrations, MCP +servers, usage) get a "Host" selector once more than one host is +connected; choosing a host re-reads everything the section shows from +that host. + +## Authority and trust A paired device has the same reach as the desktop window on that host. -Project trust is enforced host-side as today. Sessions created by ACP callers -stay hidden from clients as they are hidden from the desktop today. The host -records the claimed user id for display only; the pairing code is the whole -proof. - -Revocation on the host drops that device's live connections immediately. - -### Listening - -The host binds `0.0.0.0` on a configurable port by default, because pairing -is the gate, with a flag to bind one interface such as a Tailscale address. A -lock file under the data root prevents two servers on one data root. The -desktop app does not listen until "Allow remote connections" is on. +Project trust is enforced host-side. Sessions created by ACP callers stay +hidden from clients. The host records the claimed user id for display +only; the pairing code is the whole proof, and the pinned static keys are +the identity afterwards. The host never logs access or refresh tokens, plaintext prompts, or -credential-bearing environments, per the repository security rules. Pairing -codes are never logged. - -## Client - -### Hosts and connections - -A saved host is: +credential-bearing environments, per the repository security rules. +Pairing codes and private keys are never logged. -```json -{ - "id": "", - "name": "workstation", - "connections": [ - { "kind": "direct", "address": "100.64.0.7:7130" }, - { "kind": "direct", "address": "192.168.1.20:7130" } - ], - "paired_at_ms": 0 -} -``` +## Limits and constants -Adding a connection whose host presents an already-known public key merges -into that host rather than creating a second one. Loading `hosts.json` uses -per-entry salvage: a malformed connection is dropped, not the host. Saved -hosts live per account scope, so switching accounts hides other accounts' -hosts. - -This release tries connections in order and uses the first that completes a -handshake. Concurrent probing with latency-based switching and hysteresis is -the follow-up that lands with the relay. - -As built: `maple_remote::manager::HostManager` runs one connector per -saved host on the backend runtime and reports status, events, and list -changes on one channel; the desktop shell pumps that channel into the chat -screen in batches, like the local host's events. The chat screen keeps one -entry per host, maps every task to its host, and points its single "host" -handle at the selected task's host, so every existing call site drives the -right host without knowing it. The local host is named "This computer". - -### Lifecycle - -Saved hosts auto-connect on launch and reconnect with jittered backoff. An -offline host shows as a collapsed status row in the sidebar with no sessions. -The status states are `idle`, `connecting`, `online`, `offline`, `error`, -with `last_online_at`, and the client distinguishes "error before first -success" from "error after ready" so it can show a reconnecting banner over -data it already has. - -### Sidebar and new tasks - -The sidebar merges sessions across hosts, sorted as today, with a host filter -that defaults to all. Rows show a host badge only when more than one host is -known. New tasks target the host selected in the sidebar filter, else the -selected task's host. - -As built: the host filter lives in the project switcher menu, above the -project rows, with every host listed and offline hosts grayed. The badge is -the host name after the project name on each row. A host chip in the -header, shown once more than one host is known, names the target host with -a status dot and switches it from a dropdown; the filter and the selection -still move the target too. The host the last new task ran on is saved in -the client settings (`last_task_host`) and is the target again at the next -launch: startup holds the local auto-select until that host connects, then -shows its saved project and opens its latest task there. A remembered host -that reports offline or is no longer saved releases startup to the local -auto-select. Project selection is one dialog for every host -(after Paseo's add-project flow): a search box over the host's recent -projects and its directory suggestions, and an "Open this path" row when -the text looks like a path; there is no native folder picker on any host. -Arrows, Enter, and Escape drive it. - -### Settings scoping - -Client-only, stays in `/maple-gpui/settings.json`: theme, fonts, vim -modes, shortcut overrides, notifications, reduce motion, window state, TTS -voice and speed. - -Per host, moves into the host's per-account `AgentConfig`: default -permission mode, default web enabled, harness instructions, default model, -tool details and tool summaries display defaults if they affect the run. -Existing values migrate silently into the local host's config on first run. - -State keyed by absolute path today re-keys by `(host_id, path)`: pinned -roots, project names, pinned tasks, settled and unsettled tasks. - -Host-scoped sections in Settings get a host selector. Integrations, project -trust, and custom MCP servers are host-side as today. Settings also gains a -Hosts section for the client role, with add, rename, and remove, and a Remote -access section for the host role, with the listen toggle, addresses, pairing -code, and paired devices with revoke. - -### Desktop hosting - -As built: `app/src/remote/` holds both roles: `host.rs` the shared host -role, `client.rs` the connection manager for saved hosts, and `mod.rs` -the files both keep under `/remote/`. `Hosting::start` -takes the data-root lock, binds, and serves the account's local host on the -backend runtime; the `serve` command runs it in the foreground, and the -window runs it behind the "Allow remote connections" setting, off by -default, which takes effect at once from Settings > Hosts. That section -also publishes pairing codes through the same pending file the CLI uses, -and lists and revokes paired devices. The command and the window share the -host key, the lock, and, for one account, the device list and the pending -code, so only one of them serves at a time; the other reports who holds -the root. - -The window's host role has four states: off, starting, listening, and -failed. Starting runs on the backend runtime, never the UI thread, and -Settings shows it until it resolves; a stop that arrives meanwhile wins. -The setting persists as on only once the host listens, so a start that -failed (the port taken, another host on the root) does not come back at -the next launch; the failure shows in place. Listening names the host and -the address; when the host binds every interface it shows the port and -says to use the machine's LAN or Tailscale address. "Generate pairing -code" works only while listening, and the code stays on screen until the -host consumed it (the device list is then re-read) or it expired. Device -and pairing files are read and written off the UI thread. `stop` waits -for the listener before releasing the lock, so the next start can bind; -`serve pair` learns whether a host runs by probing that lock, not from a -pid. - -## Host CLI - -``` -maple-gpui serve [--listen ADDR:PORT] [--name NAME] -maple-gpui serve pair -maple-gpui serve devices list -maple-gpui serve devices revoke -``` - -`serve` requires a saved sign-in and exits with a clear message otherwise, -like `acp`. It logs to the usual log file. The default host name is the -machine hostname. The default port is 7130 unless taken; it is not 8080, -which the proxy mode uses. - -As built: a saved sign-in the server rejects exits with a message to run -`login` again. A server that cannot be reached at start does not: the -host serves with the saved sign-in, requests fail until it goes through, -and the sign-in is retried behind them with growing pauses (5 s up to 5 -min), so a unit that starts before the network recovers on its own. The -host name comes from `gethostname` (`HOSTNAME` in the environment -overrides it; `COMPUTERNAME` on Windows), so macOS hosts are no longer -all called "maple". Session defaults an older version kept in -settings.json are adopted into the account config by every mode that -binds an account, including the window after a sign-in, so a user who -was signed out at the upgrade keeps them. - -## Persistence - -| Path | Owner | Contents | +| Constant | Value | Where | | --- | --- | --- | -| `/remote/host_key` | host | static private key, 0600 | -| `/remote/device_key` | client | static private key, 0600 | -| `/remote/accounts//devices.json` | host | devices paired into that account | -| `/remote/accounts//pending_pairing.json` | host | one pending code for that account, 0600, short-lived | -| `/remote/serve.lock` | host | single-server lock | -| `/agent/accounts//hosts.json` | client | saved hosts per account | -| `/agent/accounts//config.json` | host | existing `AgentConfig`, gains session defaults | +| Control frame | 4 MiB | `frame::MAX_CONTROL_FRAME_BYTES` | +| Stream data frame | 256 KiB | `frame::MAX_STREAM_FRAME_BYTES` | +| WebSocket message | 65535 bytes | `net::MAX_WEBSOCKET_MESSAGE_BYTES` | +| Outbound queue | 64 MiB | `outbound::DEFAULT_MAX_OUTBOUND_BYTES` | +| Stream credit | 16 initial, 8 refill | `streams::INITIAL_CREDIT`, `CREDIT_REFILL` | +| Kept snapshots per connection | 8 | `server::MAX_KEPT_SNAPSHOTS` | +| Watched roots per connection | 64 | `server::MAX_WATCHED_ROOTS` | +| Timeline page | 200 items, 1 MiB | `HostServerConfig` | +| Close flush | 2 s | `server::CLOSE_FLUSH_TIMEOUT` | +| Lease | 45 s, checked every 10 s | `HostServerConfig` | +| Handshake / connect | 15 s | `net::HANDSHAKE_TIMEOUT`, `ClientConfig::connect_timeout` | +| Ping | every 10 s, 15 s timeout, 2 misses | `ClientConfig` | +| RPC | 60 s; 90 s for runtime start | `ClientConfig` | +| Backoff | 1 s to 30 s, full jitter | `manager` | +| Pairing code | 16 chars, 80 bits, 5 min | `pairing` | +| Pairing limiter | 5 failures per 10 min, 1024 addresses | `pairing::PairingLimiter` | +| Revocation check | every 10 s | `listen` | +| Device name | 64 chars, no control characters | `devices::MAX_DEVICE_NAME_CHARS` | +| Directory suggestions | 50 | `directories::SUGGESTION_LIMIT` | +| Context usage poll | every 5 s while a run is active | `ui/chat/mod.rs` | ## Testing -- Loopback integration tests in `crates/maple-remote`: a `HostServer` over - an in-process carrier with XDG-isolated data roots, a `RemoteHostBackend` - client, and a scripted fake runtime. Cover subscribe, gap recovery, epoch - change, generation change, slow-socket close, permission race, and device - revoke mid-connection. -- Noise tests: pairing success, wrong code, expired code, reused code, pinned - key mismatch, and rate limit lockout. -- Compatibility tests: every wire type round-trips with unknown fields - present and with optional fields absent. -- The existing UI tests keep passing against `LocalHostBackend` with no - behavioral change after the seam extraction. - -## Implementation order - -Logical commits in this order. The first two change nothing a user sees. - -1. Seam. Extract `HostBackend` and `HostRouter`, wrap the runtime in - `LocalHostBackend`, add the missing serde derives, close the four - reach-arounds with host methods and events, re-key path-based settings by - host, and migrate session defaults into `AgentConfig`. Add the glossary - to this document's neighbors where terms appear. -2. Protocol crate. Add `crates/maple-remote` with wire types per domain, - framing, sequences, generation, `HostServer` with per-domain controllers, - and `RemoteHostBackend`, over an in-process carrier. Loopback tests. -3. Transport and security. WebSocket carrier, Noise with `snow`, pairing, - device and host key stores, devices file, rate limiting, lease and ping, - slow-socket close. The `serve` subcommand with `pair` and `devices`. -4. Client. `DirectConnector`, saved hosts with merge-on-identity and - salvage, auto-connect and jittered reconnect, merged sidebar with filter - and host badge, new-task targeting, Hosts settings section, host selector - on host-scoped settings. -5. Desktop serving. "Allow remote connections" toggle, listen addresses, - pairing code UI, paired devices with revoke, lock file shared with - `serve`. - -## Relay, later +Unit tests sit beside each module in `crates/maple-remote/src/`: frame +round trips and refusals, the WebSocket cap, Noise reassembly limits, +outbound overflow and oversized frames, stream credit and abandonment, +pairing codes and the limiter, the device and hosts stores, request +decoding and the method lists, and manager removal and backoff. + +Integration tests in `crates/maple-remote/tests/` run a `HostServer` over +a scripted `FakeHost`: + +- `loopback.rs`, over the in-process carrier: handshake refusal for a + different environment and protocol; snapshots page completely and + calls round-trip; events arrive in order through the hub; attachments + stream whole and a missing one is an error; a sequence gap publishes a + resync before the event; a client that stops draining is closed without + blocking the host; a quiet peer loses its lease and a pinging client + keeps it; a second hello is refused and the hook runs once; integration + setup is not a wire method; watches are capped and released when the + connection ends; requests before the handshake and unknown methods are + refused; a large attachment streams to the host ahead of the send; an + oversized upload is refused and the connection stays usable; a send + naming an unknown upload or carrying inline images is invalid params; + uploads die with their connection. +- `transport.rs`, over a real listener with Noise: a device pairs, + reconnects, and is refused once revoked (on its next dial); repeated + wrong codes lock the address out; one code pairs exactly one of two + racing devices; a revoked device reconnecting does not lock out pairing + again; large frames cross the Noise carrier in pieces; an oversized + WebSocket message is refused by both roles. + +Not covered: two clients answering one permission prompt, a revoked +device's live connection being dropped mid-session, and the desktop +app's host and client roles end to end. + +## Not built + +The plan promised these; the code does not do them. + +- A pending pairing record deleted after a failure window. Failures only + feed the limiter. +- Host-level state cursors: generation-scoped entity sequences, + `changes`/`removals`/`snapshot` answers, and tombstones. The client + re-reads everything on `Resync` and on reconnect. +- Per-session epochs and a bounded first resume with `has_older`. +- `device.*` RPC methods. +- `#[non_exhaustive]` wire enums with an `Unknown` fallback. +- General feature gating. The client checks the protocol version and + the `uploadStreams` feature; nothing else is gated. +- Logical client sessions surviving 90 s across sockets, the permission + in-flight guard, and "answered on another device". Each socket is + independent; a second answer is a host error. +- A broadcast serialized once and filtered per socket. Each connection's + forwarder serializes independently. +- A foreground probe with a 3 s deadline that bypasses backoff. +- A lease claimed by the first ping. The lease runs from connection + creation. +- A default port used "unless taken". A taken port is a hard error. +- Integration setup edited from the client. Setup is refused remotely and + done on the host. +- The client showing the host's CUA status. +- Pinned roots re-keyed by host, and host-side tool details and tool + summaries display defaults. Those display defaults stay client-side. +- Streams opened by a control message. A stream opens with an `Open` + frame on its channel, and the RPC answer names the channel. +- A host rename UI. The store and manager can rename; nothing calls them. +- Loopback tests for epoch change, generation change, a permission race, + and revoke mid-connection. +- Compatibility tests that round-trip every wire type with unknown + fields; only the hello is tested that way. + +## Later: relay The relay lives inside the OpenSecret enclave and sees only ciphertext. Design constraints to honor when it arrives, so nothing above changes: +- The relay uses `wss://` to the enclave with the same Noise inside. The + enclave ingress is Cloudflare, nginx, socat, then axum, with a 300 s + idle timeout; the 10 s application ping already satisfies it, so the + ingress needs only an nginx upgrade block. - One persistent host-to-relay connection carrying a mux with explicit - per-stream flow control. The relay buffers nothing and never drops a frame - on the host's behalf. No dial-back-per-client topology. + per-stream flow control. The relay buffers nothing and never drops a + frame on the host's behalf. No dial-back-per-client topology. - Rendezvous by device public key. The host publishes reachability through the user's encrypted KV store. -- `RelayConnector` is a second `Connector`. Hosts gain a `relay` connection - kind. Connection probing with first-available activation and hysteresis - lands here. -- Keepalive under 300 s is already mandatory, so the enclave ingress needs - only an nginx upgrade block. +- A second dial function beside `connect_direct`, and a `relay` variant of + `HostConnection`. Connection probing with first-available activation and + hysteresis lands here. +- The handshake's mode byte doubles as the Noise prologue, so a relay + cannot swap the pairing and session handshakes. From efa87e0b0b84cd77d488a5bfd7734f6c118022ab Mon Sep 17 00:00:00 2001 From: benthecarman Date: Sun, 20 Sep 2026 13:48:53 -0500 Subject: [PATCH 27/37] Show the pairing code large, mono, with a copy button The published code was muted text in the theme's secondary color, hard to read in dark mode, and nothing on the pane let the user select or copy it. The code is now a monospaced chip in the primary text color, and the shared copy button beside it puts it on the clipboard. Co-Authored-By: Claude Fable 5.1 --- apps/maple-agent/app/src/ui/settings.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/apps/maple-agent/app/src/ui/settings.rs b/apps/maple-agent/app/src/ui/settings.rs index 5f0f051ce..c856d5f8e 100644 --- a/apps/maple-agent/app/src/ui/settings.rs +++ b/apps/maple-agent/app/src/ui/settings.rs @@ -1149,11 +1149,30 @@ impl SettingsScreen { .child("Generate pairing code"), ) .when_some(self.pairing_code.as_ref(), |row, (code, _)| { + // The code is read across the room or copied into + // another machine: large, monospaced, and selectable + // by a copy button rather than a drag. + let code: gpui::SharedString = code.clone().into(); row.child( div() + .px_3() + .py_1p5() + .rounded(theme::RADIUS_MD) + .bg(gpui::rgb(theme::bg_input())) + .border_1() + .border_color(gpui::rgb(theme::border())) + .font_family(crate::assets::FONT_MONO) + .text_lg() .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(gpui::rgb(theme::text_primary())) .child(code.clone()), ) + .child(widgets::copy_button( + "copy-pairing-code", + code, + None, + Some(cx.entity_id()), + )) .child( div() .text_xs() From 067f33becbca002d54e2c5641d92cf652b06534c Mon Sep 17 00:00:00 2001 From: benthecarman Date: Mon, 21 Sep 2026 00:35:21 -0500 Subject: [PATCH 28/37] Name the open task's host in the header The header's host chip named the host new tasks would run on. With a task open it read as the host that task runs on, which is wrong as soon as the target moves: a tester switched the chip to a remote host, kept chatting in a local task, and the agent answered from the local checkout while the chip said the remote host. With a task open the header now shows that task's host as a badge; a task never moves, so there is nothing to switch. The chip that picks the host for new tasks appears only on the new-task screen. Both carry a tooltip saying which they mean. Co-Authored-By: Claude Fable 5.1 --- apps/maple-agent/README.md | 7 ++- apps/maple-agent/app/src/ui/chat/composer.rs | 63 ++++++++++++++------ apps/maple-agent/app/src/ui/chat/hosts.rs | 4 ++ apps/maple-agent/app/src/ui/chat/mod.rs | 17 ++++++ apps/maple-agent/app/src/ui/chat/tests.rs | 37 ++++++++++++ apps/maple-agent/docs/remote-development.md | 9 ++- 6 files changed, 113 insertions(+), 24 deletions(-) diff --git a/apps/maple-agent/README.md b/apps/maple-agent/README.md index 8c211ace6..6c61a99b8 100644 --- a/apps/maple-agent/README.md +++ b/apps/maple-agent/README.md @@ -139,9 +139,10 @@ host running `maple-gpui serve` (address plus the one-time code the host printed) and lists the paired hosts with their connection state. Saved hosts connect at launch and reconnect with backoff. Their tasks join the sidebar, badged with the host name once more than one host is known, and -the project switcher filters by host. A host chip in the header, shown once -more than one host is known, names the host new tasks run on and switches -it. The host the last new task ran on is the target again at the next +the project switcher filters by host. With a task open the header names +the host it runs on; on the new-task screen a chip there names the host +new tasks run on and switches it. Both appear once more than one host is +known. The host the last new task ran on is the target again at the next launch once it connects. Choosing a project opens one picker for every host: a search box over the host's recent projects and folders and a row that opens a typed path. Host-scoped settings (defaults, system prompt, diff --git a/apps/maple-agent/app/src/ui/chat/composer.rs b/apps/maple-agent/app/src/ui/chat/composer.rs index 7ab13cd0c..d0bbe073b 100644 --- a/apps/maple-agent/app/src/ui/chat/composer.rs +++ b/apps/maple-agent/app/src/ui/chat/composer.rs @@ -57,26 +57,53 @@ impl ChatScreen { .child(title), ) .when(self.hosts.len() > 1, |row| { - // Which host new tasks go to. One host needs no chip. - let online = self.target_host_online(); - let (frame, color) = chip_frame("host-picker", self.host_menu_open, false); - row.child( - frame - .flex_none() - .child(status_dot(online)) - .child( + // One host needs no chip. With a task open the header names + // the host that task runs on, as a badge: the task cannot + // move, so there is nothing to switch. On the new-task screen + // the chip picks the host the task will be created on. + match self.selected_host.as_ref() { + Some((owner, name)) => { + let online = self.hosts.get(owner).is_some_and(|entry| entry.online); + row.child( div() - .whitespace_nowrap() - .child(self.target_host_label.clone()), + .id("task-host") + .h_8() + .flex() + .flex_none() + .items_center() + .gap_1() + .px_2() + .text_xs() + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(gpui::rgb(theme::text_secondary())) + .tooltip(widgets::tooltip("This task runs here", None)) + .child(status_dot(online)) + .child(div().whitespace_nowrap().child(name.clone())), ) - .child(icon("chevron-down", px(14.), color)) - .on_mouse_down(gpui::MouseButton::Left, |_event, _window, cx| { - cx.stop_propagation(); - }) - .on_click(cx.listener(|this, _event, _window, cx| { - this.toggle_host_menu(cx); - })), - ) + } + None => { + let online = self.target_host_online(); + let (frame, color) = chip_frame("host-picker", self.host_menu_open, false); + row.child( + frame + .flex_none() + .tooltip(widgets::tooltip("New tasks run here", None)) + .child(status_dot(online)) + .child( + div() + .whitespace_nowrap() + .child(self.target_host_label.clone()), + ) + .child(icon("chevron-down", px(14.), color)) + .on_mouse_down(gpui::MouseButton::Left, |_event, _window, cx| { + cx.stop_propagation(); + }) + .on_click(cx.listener(|this, _event, _window, cx| { + this.toggle_host_menu(cx); + })), + ) + } + } }) .child( chip( diff --git a/apps/maple-agent/app/src/ui/chat/hosts.rs b/apps/maple-agent/app/src/ui/chat/hosts.rs index d598547b2..15bff3159 100644 --- a/apps/maple-agent/app/src/ui/chat/hosts.rs +++ b/apps/maple-agent/app/src/ui/chat/hosts.rs @@ -103,6 +103,10 @@ impl ChatScreen { pub(super) fn refresh_target_host_label(&mut self) { self.target_host_label = SharedString::from(self.host_name(&self.target_host)); + // A rename or a first status reaches the badge through here too. + if let Some((owner, _)) = self.selected_host.clone() { + self.selected_host = Some((owner.clone(), SharedString::from(self.host_name(&owner)))); + } } pub(super) fn host_name(&self, id: &HostId) -> String { diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index e2d886aa7..7ed7c1938 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -192,6 +192,9 @@ pub struct ChatScreen { hosts_dirty: bool, /// The target host's name, for the header chip and the picker. target_host_label: SharedString, + /// The host of the task on screen and its name, for the header badge; + /// `None` on the new-task screen, where the header shows the target. + selected_host: Option<(HostId, SharedString)>, user_id: String, /// The task list; its own entity so it renders only when it changes. sidebar: Entity, @@ -898,6 +901,7 @@ impl ChatScreen { host_list: Vec::new(), hosts_dirty: false, target_host_label: LOCAL_HOST_NAME.into(), + selected_host: None, user_id, sidebar, sessions: Vec::new(), @@ -4793,6 +4797,7 @@ impl ChatScreen { /// whatever the list has selected. A task whose host dropped leaves /// the list but stays on screen, and keeps the title it had. fn refresh_selected_title(&mut self) { + self.refresh_selected_host(); if self.timeline.is_empty() && self.loading_session.is_none() { self.selected_title = DEFAULT_TASK_TITLE.into(); return; @@ -4806,6 +4811,18 @@ impl ChatScreen { } } + /// Cache the host the task on screen belongs to. The header names it + /// while a task is open, whatever host new tasks target, so the chip + /// never implies a local task runs on the remote host it was switched + /// to. + fn refresh_selected_host(&mut self) { + self.selected_host = self.selected_session.as_deref().map(|selected| { + let owner = self.host_of(selected); + let name = SharedString::from(self.host_name(&owner)); + (owner, name) + }); + } + /// Raise a desktop notification when enabled and the window is not /// focused. `tag` names the task, so a later alert about the same task /// replaces the earlier one instead of stacking. diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index 8fb95549f..93935aff2 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -1586,6 +1586,43 @@ mod state_tests { }); } + /// The header names the host of the task on screen, not the target: + /// a local task stays labelled local after the target moves to a + /// remote host, and the new-task screen shows no task host at all. + #[gpui::test] + fn test_header_names_the_open_tasks_host(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + let screen = screen(cx); + screen.update(cx, |this, cx| { + let remote = HostId::new("remote-key".to_string()); + let remote_backend = this.backend.local_host("other") as Arc; + let mut entry = ChatHost::local(remote_backend); + entry.name = "Box".to_string(); + this.hosts.insert(remote.clone(), entry); + this.hosts_changed(); + this.sessions = vec![summary_at("s1", "Local", "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/work/local")]; + this.selected_session = Some("s1".to_string()); + this.replace_timeline(vec![user_item("u1", "hi")]); + assert_eq!( + this.selected_host.as_ref().map(|(_, name)| name.as_ref()), + Some(LOCAL_HOST_NAME) + ); + + // Retargeting new tasks does not relabel the open task. + this.host_filter = Some(remote.clone()); + assert!(this.set_target_host(remote.clone(), cx)); + assert_eq!(this.target_host_label.as_ref(), "Box"); + assert_eq!( + this.selected_host.as_ref().map(|(_, name)| name.as_ref()), + Some(LOCAL_HOST_NAME) + ); + + // The new-task screen has no task host; the target shows. + this.clear_selected_session_presentation(cx); + assert_eq!(this.selected_host, None); + }); + } + /// A remembered host that stays offline releases startup to the local /// auto-select instead of holding the window empty. #[gpui::test] diff --git a/apps/maple-agent/docs/remote-development.md b/apps/maple-agent/docs/remote-development.md index 697d3fe55..9682e7fc2 100644 --- a/apps/maple-agent/docs/remote-development.md +++ b/apps/maple-agent/docs/remote-development.md @@ -683,9 +683,12 @@ folder picker on any host. ### Host chip and restore -A host chip in the header, shown only when more than one host is known, -names the host new tasks run on with a status dot and switches it from a -dropdown; the sidebar filter and the selected task move the target too. +With a task open, the header names the host that task runs on as a +badge with a status dot; a task never moves, so there is nothing to +switch. On the new-task screen the same place holds a chip that names +the host new tasks run on and switches it from a dropdown; the sidebar +filter and the selected task move the target too. Both appear only when +more than one host is known. Switching the target adopts that host's project root, recent roots, and session defaults. From 4f12b89e8794a25ced083cffb294cc3a276ef5d9 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Mon, 21 Sep 2026 00:43:59 -0500 Subject: [PATCH 29/37] Spawn the Claude fixture itself as the slow descendant The "slow" fixture mode left a child behind to prove cancellation kills a CLI's descendants, and reached for the sleep binary by an absolute path that a host without /bin/sleep does not have, so the test timed out waiting for the child's pid. The child is now the fixture binary itself in a sleeping mode, which every host that can run the test has. Co-Authored-By: Claude Fable 5.1 --- .../agent/external_agents/tests/claude_fixture.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/maple-agent/crates/maple-agent/src/agent/external_agents/tests/claude_fixture.rs b/apps/maple-agent/crates/maple-agent/src/agent/external_agents/tests/claude_fixture.rs index 8c71a7979..8b0bd2e21 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/external_agents/tests/claude_fixture.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/external_agents/tests/claude_fixture.rs @@ -43,6 +43,12 @@ fn run() { return; } let mode = std::env::var(FIXTURE_MODE).unwrap(); + if mode == "sleeper" { + // A descendant the "slow" CLI leaves behind: this same binary, + // so the test assumes no `sleep` on the host's paths. + std::thread::sleep(std::time::Duration::from_secs(1000)); + return; + } if args == ["auth", "status", "--json"] { fs::write( std::env::var(FIXTURE_PID_FILE).unwrap(), @@ -165,7 +171,11 @@ fn run() { // Deliberately outlives the CLI to test Maple's process // group cleanup, including descendants after interrupt. #[allow(clippy::zombie_processes)] - let child = Command::new("/bin/sleep").arg("1000").spawn().unwrap(); + let child = Command::new(std::env::current_exe().unwrap()) + .args(std::env::args().skip(1)) + .env(FIXTURE_MODE, "sleeper") + .spawn() + .unwrap(); fs::write(format!("{pid_file}.child"), child.id().to_string()).unwrap(); continue; } From 63bcff5cb9f0804fcb1132de65a499f0dd9a3ec6 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Mon, 21 Sep 2026 01:02:23 -0500 Subject: [PATCH 30/37] Create a blank task again on a newly picked host "New Task" creates a task on the target the moment it is clicked, so the new-task screen already has a task behind it on that host. Picking another host in the header then changed only where the next task would go; the first message went to the task that already existed, on the host it was created on. A tester switched the chip to a remote host on the new-task screen twice and both runs stayed local while the header named the remote host. A blank task now follows the target: switching the host, the sidebar filter, or the remembered host at launch leaves the blank task behind and creates one on the new host, and the header keeps offering the target chip while the task is blank rather than a badge for the host the blank task happened to be created on. Co-Authored-By: Claude Fable 5.1 --- apps/maple-agent/app/src/ui/chat/hosts.rs | 28 +++++++++++++-- apps/maple-agent/app/src/ui/chat/mod.rs | 24 ++++++++++++- apps/maple-agent/app/src/ui/chat/tests.rs | 44 +++++++++++++++++++++++ 3 files changed, 92 insertions(+), 4 deletions(-) diff --git a/apps/maple-agent/app/src/ui/chat/hosts.rs b/apps/maple-agent/app/src/ui/chat/hosts.rs index 15bff3159..16abcc5da 100644 --- a/apps/maple-agent/app/src/ui/chat/hosts.rs +++ b/apps/maple-agent/app/src/ui/chat/hosts.rs @@ -226,7 +226,7 @@ impl ChatScreen { } self.host_filter = host; if changed { - self.adopt_target_host_context(cx); + self.follow_target_change(cx); } cx.notify(); } @@ -492,7 +492,14 @@ impl ChatScreen { let restoring = self.restore_host.as_ref() == Some(&target); if restoring { self.restore_host = None; - if self.selected_session.is_none() && self.host_filter.is_none() { + if (self.selected_session.is_none() || self.selection_is_blank()) + && self.host_filter.is_none() + { + // A blank task created on the local host meanwhile does + // not pin the target; the remembered host wins. + if self.selection_is_blank() { + self.clear_selected_session_presentation(cx); + } self.set_target_host(target.clone(), cx); } } @@ -655,11 +662,26 @@ impl ChatScreen { if !self.set_target_host(host.clone(), cx) { self.notice = Some(format!("{} is offline", self.host_name(&host)).into()); } else if changed { - self.adopt_target_host_context(cx); + self.follow_target_change(cx); } cx.notify(); } + /// The target moved: show its project and defaults. A blank task on + /// screen was created on the old target the moment "New Task" was + /// clicked; leave it behind and create the task again on the new + /// one, so the first message runs where the header says. + pub(super) fn follow_target_change(&mut self, cx: &mut Context) { + let blank = self.selection_is_blank(); + if blank { + self.clear_selected_session_presentation(cx); + } + self.adopt_target_host_context(cx); + if blank { + self.new_session(cx); + } + } + pub(super) fn target_host_online(&self) -> bool { self.hosts .get(&self.target_host) diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index 7ed7c1938..705d4c089 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -1510,7 +1510,23 @@ impl ChatScreen { } } - fn new_session(&mut self, cx: &mut Context) { + /// A task on screen that nothing has happened to yet: no messages, + /// no run, no load in flight. "New Task" creates one on the target + /// the moment it is clicked, so the pane shows the empty hero while a + /// task already exists on that host. Such a task follows a target + /// change instead of pinning the host it was created on. + pub(super) fn selection_is_blank(&self) -> bool { + match self.selected_session.as_deref() { + Some(selected) => { + self.timeline.is_empty() + && self.loading_session.is_none() + && !self.active_runs.contains_key(selected) + } + None => false, + } + } + + pub(super) fn new_session(&mut self, cx: &mut Context) { // A boot-time auto-create and a user click can race; one only. if self.session_setup_pending { return; @@ -4816,6 +4832,12 @@ impl ChatScreen { /// never implies a local task runs on the remote host it was switched /// to. fn refresh_selected_host(&mut self) { + if self.timeline.is_empty() && self.loading_session.is_none() { + // The new-task screen, even when a blank task already backs + // it: the header offers the target chip, not a badge. + self.selected_host = None; + return; + } self.selected_host = self.selected_session.as_deref().map(|selected| { let owner = self.host_of(selected); let name = SharedString::from(self.host_name(&owner)); diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index 93935aff2..d08b616c1 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -1623,6 +1623,50 @@ mod state_tests { }); } + /// "New Task" creates a blank task on the target at once. Picking + /// another host afterwards must not leave the first message on the + /// old host: the blank task is dropped and created again on the new + /// target, and the header keeps offering the target chip meanwhile. + #[gpui::test] + fn test_switching_host_on_a_blank_task_recreates_it_there(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + let screen = screen(cx); + screen.update(cx, |this, cx| { + let remote = HostId::new("remote-key".to_string()); + let remote_backend = this.backend.local_host("other") as Arc; + let mut entry = ChatHost::local(remote_backend); + entry.name = "Box".to_string(); + entry.project_root = Some("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/work/remote".to_string()); + this.hosts.insert(remote.clone(), entry); + this.hosts_changed(); + this.project_root = Some("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/work/local".to_string()); + + // The blank task "New Task" made on the local host. + this.sessions = vec![summary_at("blank", "New Task", "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/work/local")]; + this.session_hosts + .insert("blank".to_string(), HostId::local()); + this.selected_session = Some("blank".to_string()); + this.replace_timeline(Vec::new()); + assert!(this.selection_is_blank()); + assert_eq!( + this.selected_host, None, + "a blank task shows the target chip" + ); + + this.pick_host(remote.clone(), cx); + assert_eq!(this.target_host, remote); + assert_eq!( + this.selected_session, None, + "the local blank task is left behind" + ); + assert!( + this.session_setup_pending, + "a task is being created on the new host" + ); + assert_eq!(this.project_root.as_deref(), Some("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/work/remote")); + }); + } + /// A remembered host that stays offline releases startup to the local /// auto-select instead of holding the window empty. #[gpui::test] From 13772e5a6174e81ffc1c6a326de0bea4e547eaae Mon Sep 17 00:00:00 2001 From: benthecarman Date: Mon, 21 Sep 2026 01:26:42 -0500 Subject: [PATCH 31/37] Carry the build hash in both hellos The hello only named the package version, and every checkout of the prototype is 0.1.0, so a client could not tell which build a host was running or whether the two sides matched. Both hellos now carry the git revision `build.rs` bakes in as an optional `build` field; an older peer that omits it still parses. The host logs the client's version and build on connect, and the client exposes the host's through `host_version()`. The `--version` format moves into one helper in the app's `env` module so the hello, the startup log line, and clap print the same string. Co-Authored-By: Claude Fable 5.1 --- apps/maple-agent/app/src/env.rs | 29 +++++++ apps/maple-agent/app/src/main.rs | 12 ++- apps/maple-agent/app/src/remote/client.rs | 3 +- apps/maple-agent/app/src/remote/host.rs | 3 +- .../crates/maple-remote/src/client.rs | 6 ++ .../crates/maple-remote/src/manager.rs | 1 + .../crates/maple-remote/src/server.rs | 10 ++- .../crates/maple-remote/src/wire.rs | 82 +++++++++++++++++++ .../crates/maple-remote/tests/common/mod.rs | 2 + .../crates/maple-remote/tests/loopback.rs | 1 + 10 files changed, 137 insertions(+), 12 deletions(-) diff --git a/apps/maple-agent/app/src/env.rs b/apps/maple-agent/app/src/env.rs index 1e0a4578f..fdeade1d8 100644 --- a/apps/maple-agent/app/src/env.rs +++ b/apps/maple-agent/app/src/env.rs @@ -7,6 +7,23 @@ // and client roles the build may lack. #![cfg_attr(not(feature = "desktop"), allow(dead_code))] +/// The package version of this binary. +pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// The git revision `build.rs` baked in (`abc1234`, or `abc1234-dirty`), +/// or `None` when the build ran outside a git checkout. Sent to peers so +/// two builds of one version can be told apart. +pub fn build_hash() -> Option<&'static str> { + option_env!("MAPLE_GIT_HASH").filter(|hash| !hash.is_empty() && *hash != "unknown") +} + +/// The `--version` string: package version plus the git revision, so a +/// running binary can be matched back to a checkout. `unknown` stands in +/// for a missing revision. +pub fn version_string() -> String { + maple_remote::wire::version_label(APP_VERSION, Some(build_hash().unwrap_or("unknown"))) +} + /// The trimmed value of `name`, or `None` when unset or blank. pub fn env_string(name: &str) -> Option { std::env::var(name) @@ -81,6 +98,18 @@ mod tests { out } + #[test] + fn version_string_names_the_package_version_and_the_revision() { + let version = version_string(); + match build_hash() { + Some(hash) => { + assert_ne!(hash, "unknown"); + assert_eq!(version, format!("{APP_VERSION} ({hash})")); + } + None => assert_eq!(version, format!("{APP_VERSION} (unknown)")), + } + } + #[test] fn string_trims_and_drops_blank() { let read = || env_string("MAPLE_TEST_STRING"); diff --git a/apps/maple-agent/app/src/main.rs b/apps/maple-agent/app/src/main.rs index 0f56175bd..0a6db9250 100644 --- a/apps/maple-agent/app/src/main.rs +++ b/apps/maple-agent/app/src/main.rs @@ -39,13 +39,11 @@ fn disabled_mode(mode: &str, feature: &str) -> ! { /// Command line for the binary. With no subcommand it opens the desktop /// window; `acp` and `proxy` run headless services. -/// The `--version` string: package version plus the git revision baked in -/// by `build.rs`, so a running binary can be matched back to a checkout. -/// Clap wants a `&'static str` and both inputs are compile-time constants, -/// but `format!` is still runtime, hence the leak of one small string. +/// The `--version` string from [`env::version_string`]. Clap wants a +/// `&'static str` and both inputs are compile-time constants, but +/// `format!` is still runtime, hence the leak of one small string. fn version_string() -> &'static str { - let hash = option_env!("MAPLE_GIT_HASH").unwrap_or("unknown"); - Box::leak(format!("{} ({})", env!("CARGO_PKG_VERSION"), hash).into_boxed_str()) + Box::leak(env::version_string().into_boxed_str()) } #[derive(Debug, Parser)] @@ -439,7 +437,7 @@ fn init_logging(output: LogOutput) { })); log::info!( "maple-gpui {} starting; log file: {}", - env!("CARGO_PKG_VERSION"), + env::version_string(), log_dir.join("maple-gpui.log").display() ); } diff --git a/apps/maple-agent/app/src/remote/client.rs b/apps/maple-agent/app/src/remote/client.rs index 93b193f3b..02d71f43f 100644 --- a/apps/maple-agent/app/src/remote/client.rs +++ b/apps/maple-agent/app/src/remote/client.rs @@ -34,7 +34,8 @@ fn hosts_store(user_id: &str) -> Result { fn client_hello(device: &StaticKey, user_id: &str) -> Result { Ok(ClientHello { protocol: PROTOCOL_VERSION, - app_version: env!("CARGO_PKG_VERSION").to_string(), + app_version: crate::env::APP_VERSION.to_string(), + build: crate::env::build_hash().map(str::to_string), pcr_environment: format!( "{:?}", maple_agent::open_secret_config::configured_pcr0_environment()? diff --git a/apps/maple-agent/app/src/remote/host.rs b/apps/maple-agent/app/src/remote/host.rs index 7a6bcbf74..38c9067ea 100644 --- a/apps/maple-agent/app/src/remote/host.rs +++ b/apps/maple-agent/app/src/remote/host.rs @@ -168,7 +168,8 @@ impl Hosting { ..Default::default() }; let identity = HostIdentity { - app_version: env!("CARGO_PKG_VERSION").to_string(), + app_version: crate::env::APP_VERSION.to_string(), + build: crate::env::build_hash().map(str::to_string), pcr_environment: format!( "{:?}", maple_agent::open_secret_config::configured_pcr0_environment()? diff --git a/apps/maple-agent/crates/maple-remote/src/client.rs b/apps/maple-agent/crates/maple-remote/src/client.rs index 1784c0e12..afa2dfd5f 100644 --- a/apps/maple-agent/crates/maple-remote/src/client.rs +++ b/apps/maple-agent/crates/maple-remote/src/client.rs @@ -334,6 +334,12 @@ impl RemoteHostBackend { &self.hello } + /// The host's package version and, when its build knew it, the git + /// revision it was built from. + pub fn host_version(&self) -> (String, Option) { + (self.hello.app_version.clone(), self.hello.build.clone()) + } + /// Resolves with the reason once the connection is gone. pub fn closed(&self) -> watch::Receiver> { self.closed.subscribe() diff --git a/apps/maple-agent/crates/maple-remote/src/manager.rs b/apps/maple-agent/crates/maple-remote/src/manager.rs index 98874a9a3..f2230dc8a 100644 --- a/apps/maple-agent/crates/maple-remote/src/manager.rs +++ b/apps/maple-agent/crates/maple-remote/src/manager.rs @@ -396,6 +396,7 @@ mod tests { let hello = ClientHello { protocol: crate::wire::PROTOCOL_VERSION, app_version: "0.1.0".to_string(), + build: None, pcr_environment: "Development".to_string(), features: crate::wire::features(), device: crate::wire::DeviceInfo { diff --git a/apps/maple-agent/crates/maple-remote/src/server.rs b/apps/maple-agent/crates/maple-remote/src/server.rs index f1235a315..0022c12d2 100644 --- a/apps/maple-agent/crates/maple-remote/src/server.rs +++ b/apps/maple-agent/crates/maple-remote/src/server.rs @@ -37,13 +37,15 @@ use crate::wire::{ AttachmentHandle, BootstrapSnapshot, ClientHello, EventEnvelope, Features, HostHello, HostInfo, HostRequest, IntegrationRequest, ModelRequest, PROTOCOL_VERSION, ProjectRequest, Request, RunRequest, SessionRequest, SessionSnapshot, UPLOAD_STREAMS_FEATURE, UploadRef, decode_request, - features, has_feature, + features, has_feature, version_label, }; /// What the host tells clients about itself in the handshake. #[derive(Debug, Clone)] pub struct HostIdentity { pub app_version: String, + /// The git revision this host was built from, when the build knew it. + pub build: Option, pub pcr_environment: String, } @@ -491,13 +493,15 @@ impl Connection { return; } log::info!( - "client {} ({}) connected", + "client {} ({}) connected running maple-gpui {}", hello.device.name, - hello.device.public_key + hello.device.public_key, + version_label(&hello.app_version, hello.build.as_deref()) ); let answer = HostHello { protocol: PROTOCOL_VERSION, app_version: identity.app_version.clone(), + build: identity.build.clone(), pcr_environment: identity.pcr_environment.clone(), generation: self.server.generation.clone(), seq: 0, diff --git a/apps/maple-agent/crates/maple-remote/src/wire.rs b/apps/maple-agent/crates/maple-remote/src/wire.rs index f77d98bb0..7d847395f 100644 --- a/apps/maple-agent/crates/maple-remote/src/wire.rs +++ b/apps/maple-agent/crates/maple-remote/src/wire.rs @@ -52,6 +52,15 @@ pub fn has_feature(features: &Features, name: &str) -> bool { features.get(name).copied().unwrap_or(false) } +/// A peer's version for display: `0.1.0 (63bcff5c)`, or just `0.1.0` +/// when the peer sent no build. +pub fn version_label(version: &str, build: Option<&str>) -> String { + match build { + Some(build) => format!("{version} ({build})"), + None => version.to_string(), + } +} + /// The notification method that carries host events. pub const EVENT_METHOD: &str = "event"; @@ -61,6 +70,10 @@ pub const EVENT_METHOD: &str = "event"; pub struct ClientHello { pub protocol: u32, pub app_version: String, + /// The git revision the client was built from, when its build knew + /// it. Absent from an older client. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub build: Option, /// The compile-time OpenSecret environment (`Production` or /// `Development`). Two binaries built for different enclaves cannot /// share a backend, so a mismatch is refused. @@ -89,6 +102,11 @@ pub struct DeviceInfo { pub struct HostHello { pub protocol: u32, pub app_version: String, + /// The git revision the host was built from, when its build knew it. + /// Absent from an older host. Shown beside the version so two builds + /// of one version can be told apart. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub build: Option, pub pcr_environment: String, /// Minted when the host process started; every sequence is scoped to /// it. A different value after a reconnect means every cursor is @@ -674,8 +692,72 @@ mod tests { let hello: ClientHello = serde_json::from_value(params).unwrap(); assert!(hello.features.is_empty()); assert_eq!(hello.device.user_id, None); + assert_eq!(hello.build, None); let json = serde_json::to_value(&hello).unwrap(); assert!(json.get("somethingNew").is_none()); + assert!(json.get("build").is_none(), "an absent build is not sent"); + } + + #[test] + fn hellos_round_trip_with_and_without_a_build() { + let mut client = ClientHello { + protocol: PROTOCOL_VERSION, + app_version: "0.1.0".to_string(), + build: Some("63bcff5c".to_string()), + pcr_environment: "Production".to_string(), + features: features(), + device: DeviceInfo { + public_key: "k".to_string(), + name: "laptop".to_string(), + user_id: None, + }, + }; + let json = serde_json::to_value(&client).unwrap(); + assert_eq!(json["build"], "63bcff5c"); + assert_eq!(serde_json::from_value::(json).unwrap(), client); + client.build = None; + let json = serde_json::to_value(&client).unwrap(); + assert!(json.get("build").is_none()); + assert_eq!(serde_json::from_value::(json).unwrap(), client); + + let mut host = HostHello { + protocol: PROTOCOL_VERSION, + app_version: "0.1.0".to_string(), + build: Some("63bcff5c".to_string()), + pcr_environment: "Production".to_string(), + generation: "gen".to_string(), + seq: 0, + features: features(), + host: HostInfo { + id: "h".to_string(), + name: "workstation".to_string(), + user_id: None, + }, + }; + let json = serde_json::to_value(&host).unwrap(); + assert_eq!(json["build"], "63bcff5c"); + assert_eq!(serde_json::from_value::(json).unwrap(), host); + host.build = None; + let json = serde_json::to_value(&host).unwrap(); + assert!(json.get("build").is_none()); + assert_eq!(serde_json::from_value::(json).unwrap(), host); + + // What an older host sends: no `build` at all. + let older: HostHello = serde_json::from_value(serde_json::json!({ + "protocol": 1, + "appVersion": "0.1.0", + "pcrEnvironment": "Production", + "generation": "gen", + "seq": 0, + "host": {"id": "h", "name": "workstation"} + })) + .unwrap(); + assert_eq!(older.build, None); + assert_eq!( + version_label(&older.app_version, older.build.as_deref()), + "0.1.0" + ); + assert_eq!(version_label("0.1.0", Some("63bcff5c")), "0.1.0 (63bcff5c)"); } #[test] diff --git a/apps/maple-agent/crates/maple-remote/tests/common/mod.rs b/apps/maple-agent/crates/maple-remote/tests/common/mod.rs index 858e2f2d0..b7942ce1b 100644 --- a/apps/maple-agent/crates/maple-remote/tests/common/mod.rs +++ b/apps/maple-agent/crates/maple-remote/tests/common/mod.rs @@ -367,6 +367,7 @@ impl HostBackend for FakeHost { pub fn identity() -> HostIdentity { HostIdentity { app_version: "0.1.0".to_string(), + build: Some("abc1234".to_string()), pcr_environment: "Development".to_string(), } } @@ -383,6 +384,7 @@ pub fn hello() -> ClientHello { ClientHello { protocol: PROTOCOL_VERSION, app_version: "0.1.0".to_string(), + build: None, pcr_environment: "Development".to_string(), features: maple_remote::wire::features(), device: DeviceInfo { diff --git a/apps/maple-agent/crates/maple-remote/tests/loopback.rs b/apps/maple-agent/crates/maple-remote/tests/loopback.rs index b39aa449b..ea4002d13 100644 --- a/apps/maple-agent/crates/maple-remote/tests/loopback.rs +++ b/apps/maple-agent/crates/maple-remote/tests/loopback.rs @@ -208,6 +208,7 @@ async fn raw_host(mut carrier: Carrier, frames: Vec) { let answer = HostHello { protocol: PROTOCOL_VERSION, app_version: "0.1.0".to_string(), + build: None, pcr_environment: "Development".to_string(), generation: "gen".to_string(), seq: 0, From c425602c3f300b97eb822d0a0b451645fcea4b19 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Mon, 21 Sep 2026 01:31:18 -0500 Subject: [PATCH 32/37] Record each host's version on connect The manager knew whether a host was online but not what it was running, so nothing could say that a host was behind the app. It now remembers the version and build from every successful hello, answers them through `host_version(id)` for callers that poll the way `is_online` is polled, and forgets them once the connection ends. The saved host also keeps `lastSeenVersion` and `lastSeenBuild`, written on every hello, so an offline host still shows what it ran last. A hosts file written before these fields still loads. The `Status` event is unchanged: the chat screen destructures it field by field, and it does not need the version. The transport test's loopback host moves into the shared fixture so the manager can be tested against a real handshake. Co-Authored-By: Claude Fable 5.1 --- .../crates/maple-remote/src/hosts.rs | 122 +++++++++++++++++- .../crates/maple-remote/src/manager.rs | 76 ++++++++++- .../crates/maple-remote/tests/common/mod.rs | 76 ++++++++++- .../crates/maple-remote/tests/manager.rs | 85 ++++++++++++ .../crates/maple-remote/tests/transport.rs | 75 +---------- 5 files changed, 354 insertions(+), 80 deletions(-) create mode 100644 apps/maple-agent/crates/maple-remote/tests/manager.rs diff --git a/apps/maple-agent/crates/maple-remote/src/hosts.rs b/apps/maple-agent/crates/maple-remote/src/hosts.rs index 9ce485ff4..113181f65 100644 --- a/apps/maple-agent/crates/maple-remote/src/hosts.rs +++ b/apps/maple-agent/crates/maple-remote/src/hosts.rs @@ -38,6 +38,14 @@ pub struct SavedHost { pub name: String, pub connections: Vec, pub paired_at_ms: u64, + /// The version the host announced at the last successful hello, so an + /// offline host still shows what it ran. Absent until it connected once + /// on a build that records it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_seen_version: Option, + /// The build the host announced then, when its build knew it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_seen_build: Option, } #[derive(Debug, Default, Serialize, Deserialize)] @@ -73,11 +81,19 @@ fn salvage_host(value: serde_json::Value) -> Option { .collect() }) .unwrap_or_default(); + let string = |key: &str| { + object + .get(key) + .and_then(serde_json::Value::as_str) + .map(str::to_string) + }; Some(SavedHost { id, name, connections, paired_at_ms, + last_seen_version: string("lastSeenVersion"), + last_seen_build: string("lastSeenBuild"), }) } @@ -131,7 +147,8 @@ impl HostsStore { /// Save a host. A host with the same key already saved keeps its /// record and gains the new connections; the name changes only when - /// the saved one is empty. + /// the saved one is empty, and the last seen version only when the + /// new record names one. pub fn upsert(&self, host: SavedHost) -> Result { let _guard = self .lock @@ -148,6 +165,10 @@ impl HostsStore { if saved.name.trim().is_empty() { saved.name = host.name; } + if host.last_seen_version.is_some() { + saved.last_seen_version = host.last_seen_version; + saved.last_seen_build = host.last_seen_build; + } saved.clone() } None => { @@ -177,6 +198,32 @@ impl HostsStore { self.write(&hosts) } + /// Record what a host announced at a successful hello. A host that is + /// no longer saved is ignored; an unchanged version is not rewritten. + pub fn record_last_seen( + &self, + id: &str, + version: &str, + build: Option<&str>, + ) -> Result<(), String> { + let _guard = self + .lock + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut hosts = self.read()?; + let Some(host) = hosts.iter_mut().find(|host| host.id == id) else { + return Ok(()); + }; + if host.last_seen_version.as_deref() == Some(version) + && host.last_seen_build.as_deref() == build + { + return Ok(()); + } + host.last_seen_version = Some(version.to_string()); + host.last_seen_build = build.map(str::to_string); + self.write(&hosts) + } + pub fn remove(&self, id: &str) -> Result<(), String> { let _guard = self .lock @@ -205,6 +252,8 @@ mod tests { address: "100.64.0.7:7130".into(), }], paired_at_ms: 0, + last_seen_version: None, + last_seen_build: None, }) .unwrap(); // Pairing again over another address merges into the same host. @@ -221,6 +270,8 @@ mod tests { }, ], paired_at_ms: 0, + last_seen_version: None, + last_seen_build: None, }) .unwrap(); assert_eq!(merged.name, "workstation"); @@ -252,4 +303,73 @@ mod tests { assert!(store.list().unwrap().is_empty()); let _ = std::fs::remove_dir_all(dir); } + + #[test] + fn the_last_seen_version_persists_and_a_file_without_one_still_loads() { + let dir = std::env::temp_dir().join(format!("maple-hosts-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + let store = HostsStore::new(dir.join("hosts.json")); + // A file written before the version was recorded. + std::fs::write( + store.path(), + r#"{"hosts":[{"id":"k1","name":"box","pairedAtMs":5,"connections":[]}]}"#, + ) + .unwrap(); + let host = store.get("k1").unwrap().unwrap(); + assert_eq!(host.last_seen_version, None); + assert_eq!(host.last_seen_build, None); + assert!( + !std::fs::read_to_string(store.path()) + .unwrap() + .contains("lastSeen"), + "nothing is written until a hello is recorded" + ); + + // Recording an unknown host is not an error and writes nothing. + store.record_last_seen("k9", "0.1.0", None).unwrap(); + assert_eq!(store.list().unwrap().len(), 1); + + store + .record_last_seen("k1", "0.1.0", Some("63bcff5c")) + .unwrap(); + let host = store.get("k1").unwrap().unwrap(); + assert_eq!(host.last_seen_version.as_deref(), Some("0.1.0")); + assert_eq!(host.last_seen_build.as_deref(), Some("63bcff5c")); + let text = std::fs::read_to_string(store.path()).unwrap(); + assert!(text.contains(r#""lastSeenVersion": "0.1.0""#), "{text}"); + assert!(text.contains(r#""lastSeenBuild": "63bcff5c""#), "{text}"); + + // A host that lost its build keeps the version and drops the build. + store.record_last_seen("k1", "0.2.0", None).unwrap(); + let host = store.get("k1").unwrap().unwrap(); + assert_eq!(host.last_seen_version.as_deref(), Some("0.2.0")); + assert_eq!(host.last_seen_build, None); + + // Pairing again does not erase what was seen unless the new record + // names a version. + let merged = store + .upsert(SavedHost { + id: "k1".into(), + name: String::new(), + connections: Vec::new(), + paired_at_ms: 0, + last_seen_version: None, + last_seen_build: None, + }) + .unwrap(); + assert_eq!(merged.last_seen_version.as_deref(), Some("0.2.0")); + let merged = store + .upsert(SavedHost { + id: "k1".into(), + name: String::new(), + connections: Vec::new(), + paired_at_ms: 0, + last_seen_version: Some("0.3.0".into()), + last_seen_build: Some("abc1234".into()), + }) + .unwrap(); + assert_eq!(merged.last_seen_version.as_deref(), Some("0.3.0")); + assert_eq!(merged.last_seen_build.as_deref(), Some("abc1234")); + let _ = std::fs::remove_dir_all(dir); + } } diff --git a/apps/maple-agent/crates/maple-remote/src/manager.rs b/apps/maple-agent/crates/maple-remote/src/manager.rs index f2230dc8a..4293bf535 100644 --- a/apps/maple-agent/crates/maple-remote/src/manager.rs +++ b/apps/maple-agent/crates/maple-remote/src/manager.rs @@ -21,7 +21,7 @@ use crate::dial::{ConnectTarget, connect_direct}; use crate::hosts::{HostConnection, HostsStore, SavedHost}; use crate::keys::StaticKey; use crate::pairing::PairingCode; -use crate::wire::ClientHello; +use crate::wire::{ClientHello, version_label}; /// Reconnect backoff: full jitter between half and all of an exponential /// delay from `BACKOFF_FLOOR` to `BACKOFF_CAP`. @@ -35,11 +35,28 @@ pub enum HostStatus { Offline { reason: String }, } +/// What a host announced about its build at the hello. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HostVersion { + /// The host's package version. + pub version: String, + /// The git revision it was built from, when its build knew it. + pub build: Option, +} + +impl HostVersion { + /// `0.1.0 (63bcff5c)`, or `0.1.0` without a build. + pub fn label(&self) -> String { + version_label(&self.version, self.build.as_deref()) + } +} + /// What the manager tells the UI. #[derive(Clone)] pub enum HostManagerEvent { /// A host's connection state changed. `backend` is present exactly - /// when the status is `Online`. + /// when the status is `Online`; the version it announced is answered + /// by [`HostManager::host_version`] and saved on the host's record. Status { host: HostId, name: String, @@ -75,6 +92,8 @@ pub struct HostManager { /// Hosts with a live connection right now, for callers that did not /// watch the event stream (the settings screen). online: Mutex>, + /// What each host with a live connection announced at its hello. + versions: Mutex>, shutdown: CancellationToken, } @@ -95,6 +114,7 @@ impl HostManager { events, connectors: Mutex::new(HashMap::new()), online: Mutex::new(HashSet::new()), + versions: Mutex::new(HashMap::new()), shutdown: CancellationToken::new(), }), receiver, @@ -113,6 +133,16 @@ impl HostManager { .contains(id) } + /// What `id` announced at the hello of its live connection, or `None` + /// while it is offline; the saved host keeps the last seen version. + pub fn host_version(&self, id: &str) -> Option { + self.versions + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(id) + .cloned() + } + fn set_online(&self, id: &str, online: bool) { let mut set = self .online @@ -125,6 +155,24 @@ impl HostManager { } } + /// Remember what a connection's hello announced, or forget it once the + /// connection is gone. The saved record keeps the last seen version + /// so an offline host still shows what it ran. + fn set_version(&self, id: &str, version: Option) { + let mut versions = self + .versions + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + match version { + Some(version) => { + versions.insert(id.to_string(), version); + } + None => { + versions.remove(id); + } + } + } + /// Whether `token` still belongs to the connector registered for `id`. /// A connector that was replaced or removed keeps running until it /// notices its cancellation; nothing it says after that may reach the @@ -178,6 +226,7 @@ impl HostManager { RemoteHostBackend::connect(dialed.carrier, self.hello.clone(), self.config.clone()) .await?; let announced = backend.host_hello().host.clone(); + let (last_seen_version, last_seen_build) = backend.host_version(); let host = self.store.upsert(SavedHost { id: dialed.host_key.clone(), name: name @@ -185,6 +234,8 @@ impl HostManager { .unwrap_or(announced.name), connections: vec![HostConnection::Direct { address }], paired_at_ms: 0, + last_seen_version: Some(last_seen_version), + last_seen_build, })?; self.emit(HostManagerEvent::HostsChanged(self.store.list()?)); self.spawn_connector(host.clone(), Some(backend)); @@ -208,6 +259,7 @@ impl HostManager { token.cancel(); } self.set_online(id, false); + self.set_version(id, None); let name = self .store .get(id)? @@ -260,9 +312,10 @@ impl HostManager { }); } }; - let online = |online: bool| { + let online = |online: Option| { if self.is_current(&host.id, &cancel) { - self.set_online(&host.id, online); + self.set_online(&host.id, online.is_some()); + self.set_version(&host.id, online); } }; let mut attempt: u32 = 0; @@ -285,10 +338,19 @@ impl HostManager { match backend { Ok(backend) => { attempt = 0; - online(true); + let (version, build) = backend.host_version(); + // Every successful hello refreshes the record, so an + // offline host shows the build it ran most recently. + if let Err(error) = + self.store + .record_last_seen(&host.id, &version, build.as_deref()) + { + log::warn!("cannot record the host's version: {error}"); + } + online(Some(HostVersion { version, build })); status(&name, HostStatus::Online, Some(Arc::clone(&backend))); let reason = self.forward_events(&id, &backend, &cancel).await; - online(false); + online(None); backend.close().await; status(&name, HostStatus::Offline { reason }, None); if cancel.is_cancelled() { @@ -440,6 +502,8 @@ mod tests { name: "slow".to_string(), connections: vec![HostConnection::Direct { address }], paired_at_ms: 0, + last_seen_version: None, + last_seen_build: None, }) .unwrap(); manager.start(); diff --git a/apps/maple-agent/crates/maple-remote/tests/common/mod.rs b/apps/maple-agent/crates/maple-remote/tests/common/mod.rs index b7942ce1b..0530ddede 100644 --- a/apps/maple-agent/crates/maple-remote/tests/common/mod.rs +++ b/apps/maple-agent/crates/maple-remote/tests/common/mod.rs @@ -20,9 +20,83 @@ use maple_agent::host::{ HostSessionDefaults, UsageSummary, }; use maple_remote::client::ClientConfig; -use maple_remote::server::HostIdentity; +use maple_remote::devices::DeviceStore; +use maple_remote::keys::StaticKey; +use maple_remote::listen::{HostStores, serve_listener}; +use maple_remote::pairing::{PairingLimiter, PendingPairingStore}; +use maple_remote::server::{HostIdentity, HostServer, HostServerConfig}; use maple_remote::wire::{ClientHello, DeviceInfo, HostInfo, PROTOCOL_VERSION}; use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; + +/// A host listening on a real loopback port, with its stores in a +/// temporary directory that goes when the host is dropped. +pub struct Host { + pub address: String, + pub key: StaticKey, + pub devices: Arc, + pub pending: Arc, + pub shutdown: CancellationToken, + _dir: TempDir, +} + +pub struct TempDir(pub std::path::PathBuf); + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// Serve `fake` on a loopback port with `identity()` and a device hook +/// that records every accepted hello, like the app's. +pub async fn start_host(fake: Arc) -> Host { + let dir = std::env::temp_dir().join(format!("maple-transport-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + let key = StaticKey::load_or_create(&dir.join("host_key.json")).unwrap(); + let devices = Arc::new(DeviceStore::new(dir.join("devices.json"))); + let pending = Arc::new(PendingPairingStore::new(dir.join("pending_pairing.json"))); + let hook_devices = Arc::clone(&devices); + let config = HostServerConfig { + on_client_hello: Some(Arc::new(move |hello: &ClientHello| { + hook_devices + .touch( + &hello.device.public_key, + &hello.device.name, + hello.device.user_id.as_deref(), + ) + .unwrap(); + })), + ..Default::default() + }; + let server = HostServer::new( + fake, + HostInfo { + id: key.public_id(), + ..info() + }, + identity(), + config, + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap().to_string(); + let shutdown = CancellationToken::new(); + let stores = Arc::new(HostStores { + key: key.clone(), + devices: Arc::clone(&devices), + pending_pairing: Arc::clone(&pending), + limiter: PairingLimiter::new(3, Duration::from_secs(60)), + }); + tokio::spawn(serve_listener(listener, server, stores, shutdown.clone())); + Host { + address, + key, + devices, + pending, + shutdown, + _dir: TempDir(dir), + } +} /// A host whose answers are fixed and whose calls are counted. pub struct FakeHost { diff --git a/apps/maple-agent/crates/maple-remote/tests/manager.rs b/apps/maple-agent/crates/maple-remote/tests/manager.rs new file mode 100644 index 000000000..849d5e9ca --- /dev/null +++ b/apps/maple-agent/crates/maple-remote/tests/manager.rs @@ -0,0 +1,85 @@ +//! The connection manager against a host on a real loopback port: pairing +//! records what the host announced, and the record outlives the +//! connection. + +mod common; + +use std::sync::Arc; +use std::time::Duration; + +use common::{FakeHost, client_config, hello, start_host}; +use maple_remote::hosts::HostsStore; +use maple_remote::keys::StaticKey; +use maple_remote::manager::{HostManager, HostManagerEvent, HostStatus, HostVersion}; +use maple_remote::pairing::PairingCode; +use tokio::sync::mpsc; + +async fn next_status( + events: &mut mpsc::UnboundedReceiver, + wanted: impl Fn(&HostStatus) -> bool, +) -> HostStatus { + loop { + let event = tokio::time::timeout(Duration::from_secs(5), events.recv()) + .await + .expect("an event in time") + .expect("the manager is alive"); + if let HostManagerEvent::Status { status, .. } = event + && wanted(&status) + { + return status; + } + } +} + +#[tokio::test] +async fn pairing_records_the_hosts_version_and_keeps_it_once_offline() { + let fake = FakeHost::new(1); + let host = start_host(Arc::clone(&fake)).await; + let dir = std::env::temp_dir().join(format!("maple-manager-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + let device = StaticKey::generate().unwrap(); + let mut client_hello = hello(); + client_hello.device.public_key = device.public_id(); + let store = Arc::new(HostsStore::new(dir.join("hosts.json"))); + let (manager, mut events) = HostManager::new(device, client_hello, store, client_config()); + + let code = PairingCode::generate(); + host.pending.publish(&code).unwrap(); + let saved = manager + .pair(&host.address, code, None) + .await + .expect("pairing"); + assert_eq!(saved.id, host.key.public_id()); + // The hello is what `identity()` in the fixture announces. + let expected = HostVersion { + version: "0.1.0".to_string(), + build: Some("abc1234".to_string()), + }; + assert_eq!(saved.last_seen_version.as_deref(), Some("0.1.0")); + assert_eq!(saved.last_seen_build.as_deref(), Some("abc1234")); + + next_status(&mut events, |status| *status == HostStatus::Online).await; + assert!(manager.is_online(&saved.id)); + assert_eq!(manager.host_version(&saved.id), Some(expected.clone())); + assert_eq!(expected.label(), "0.1.0 (abc1234)"); + let record = manager.store().get(&saved.id).unwrap().unwrap(); + assert_eq!(record.last_seen_version.as_deref(), Some("0.1.0")); + assert_eq!(record.last_seen_build.as_deref(), Some("abc1234")); + + // The host goes away: the live version goes with the connection, the + // saved record keeps what it last announced. + host.shutdown.cancel(); + drop(host); + next_status(&mut events, |status| { + matches!(status, HostStatus::Offline { .. }) + }) + .await; + assert!(!manager.is_online(&saved.id)); + assert_eq!(manager.host_version(&saved.id), None); + let record = manager.store().get(&saved.id).unwrap().unwrap(); + assert_eq!(record.last_seen_version.as_deref(), Some("0.1.0")); + assert_eq!(record.last_seen_build.as_deref(), Some("abc1234")); + + manager.shutdown(); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/apps/maple-agent/crates/maple-remote/tests/transport.rs b/apps/maple-agent/crates/maple-remote/tests/transport.rs index d4f532c72..7f7b974c7 100644 --- a/apps/maple-agent/crates/maple-remote/tests/transport.rs +++ b/apps/maple-agent/crates/maple-remote/tests/transport.rs @@ -7,84 +7,15 @@ mod common; use std::sync::Arc; use std::time::Duration; -use common::{FakeHost, client_config, hello, identity, info}; +use common::{FakeHost, client_config, hello, start_host}; use futures_util::{SinkExt, StreamExt}; use maple_agent::host::HostBackend; use maple_remote::client::RemoteHostBackend; -use maple_remote::devices::DeviceStore; use maple_remote::dial::{ConnectTarget, connect_direct}; use maple_remote::keys::StaticKey; -use maple_remote::listen::{HostStores, serve_listener}; -use maple_remote::pairing::{PairingCode, PairingLimiter, PendingPairingStore}; -use maple_remote::server::{HostServer, HostServerConfig}; -use maple_remote::wire::{ClientHello, HostInfo}; +use maple_remote::pairing::PairingCode; +use maple_remote::wire::ClientHello; use tokio_tungstenite::tungstenite::Message; -use tokio_util::sync::CancellationToken; - -struct Host { - address: String, - key: StaticKey, - devices: Arc, - pending: Arc, - shutdown: CancellationToken, - _dir: TempDir, -} - -struct TempDir(std::path::PathBuf); - -impl Drop for TempDir { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - } -} - -async fn start_host(fake: Arc) -> Host { - let dir = std::env::temp_dir().join(format!("maple-transport-{}", uuid::Uuid::new_v4())); - std::fs::create_dir_all(&dir).unwrap(); - let key = StaticKey::load_or_create(&dir.join("host_key.json")).unwrap(); - let devices = Arc::new(DeviceStore::new(dir.join("devices.json"))); - let pending = Arc::new(PendingPairingStore::new(dir.join("pending_pairing.json"))); - let hook_devices = Arc::clone(&devices); - let config = HostServerConfig { - on_client_hello: Some(Arc::new(move |hello: &ClientHello| { - hook_devices - .touch( - &hello.device.public_key, - &hello.device.name, - hello.device.user_id.as_deref(), - ) - .unwrap(); - })), - ..Default::default() - }; - let server = HostServer::new( - fake, - HostInfo { - id: key.public_id(), - ..info() - }, - identity(), - config, - ); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let address = listener.local_addr().unwrap().to_string(); - let shutdown = CancellationToken::new(); - let stores = Arc::new(HostStores { - key: key.clone(), - devices: Arc::clone(&devices), - pending_pairing: Arc::clone(&pending), - limiter: PairingLimiter::new(3, Duration::from_secs(60)), - }); - tokio::spawn(serve_listener(listener, server, stores, shutdown.clone())); - Host { - address, - key, - devices, - pending, - shutdown, - _dir: TempDir(dir), - } -} fn device_hello(device: &StaticKey) -> ClientHello { let mut hello = hello(); From 847e6bbc46c6eee381ebd83f8d580b4570c6f46b Mon Sep 17 00:00:00 2001 From: benthecarman Date: Mon, 21 Sep 2026 01:35:50 -0500 Subject: [PATCH 33/37] Show each host's version in Settings > Hosts A host row said only online or offline, so nothing told the user that a host was running an older build than the app talking to it. Each row now shows the version and build the host announced, muted beside its state ("last seen" when offline), and a short line when it matters: behind this app, a different build of the same version, newer than this app, or a newer release the update check found. The rows are computed when the list or a host's state changes, not in render. Nothing pushes status changes to the settings screen, so while the Hosts section is shown it polls the manager once a second and re-renders only when a row changed; a flipped connection re-reads the saved list, since the hello that just completed rewrote that host's last seen version. Co-Authored-By: Claude Fable 5.1 --- apps/maple-agent/app/src/ui/settings.rs | 208 ++++++++++---- apps/maple-agent/app/src/ui/settings/hosts.rs | 269 ++++++++++++++++++ 2 files changed, 427 insertions(+), 50 deletions(-) create mode 100644 apps/maple-agent/app/src/ui/settings/hosts.rs diff --git a/apps/maple-agent/app/src/ui/settings.rs b/apps/maple-agent/app/src/ui/settings.rs index c856d5f8e..6f213cfd8 100644 --- a/apps/maple-agent/app/src/ui/settings.rs +++ b/apps/maple-agent/app/src/ui/settings.rs @@ -30,16 +30,18 @@ use crate::ui::widgets; use maple_agent::host::{HostBackend, HostId, HostSessionDefaults, UsageSummary}; use maple_remote::devices::PairedDevice; use maple_remote::hosts::SavedHost; -use maple_remote::manager::HostManager; +use maple_remote::manager::{HostManager, HostVersion}; use maple_remote::pairing::PairingCode; mod account; mod api_keys; mod billing; +mod hosts; mod navigation; use self::account::AccountState; use self::api_keys::ApiKeysState; use self::billing::BillingState; +use self::hosts::HostRow; use self::navigation::{GeneralTarget, SettingsApplicationVimState, SettingsTarget}; /// Emitted when the user leaves settings. @@ -164,6 +166,13 @@ pub struct SettingsScreen { paired_devices: Vec, /// The saved host list the Hosts section shows. saved_hosts: Vec, + /// `saved_hosts` as rendered: connection state, version, and how it + /// compares with this app. Rebuilt when the list or a host's state + /// changes, not in render. + host_rows: Vec, + /// Bumped each time the Hosts section starts polling the manager, so + /// a stale poll loop stops instead of running beside the new one. + hosts_watch: u64, /// The add-host form. host_address: Entity, host_code: Entity, @@ -322,6 +331,11 @@ impl SettingsScreen { .as_ref() .and_then(|manager| manager.store().list().ok()) .unwrap_or_default(); + let host_rows = hosts::rows(&saved_hosts, &Self::app_version(), latest_release(), |id| { + manager + .as_ref() + .and_then(|manager| manager.host_version(id)) + }); let prompt_text = defaults.effective_harness_instructions(); let application_vim_enabled = settings.application_vim_enabled; let application_focus = cx.focus_handle(); @@ -361,7 +375,7 @@ impl SettingsScreen { let application_anchor = ScrollAnchor::for_handle(pane_scroll.clone()); let application_vim = SettingsApplicationVimState::new(section); let application_focus_pending = settings.application_vim_enabled; - let this = Self { + let mut this = Self { bridged_tasks: std::cell::RefCell::new(Vec::new()), backend, host, @@ -372,6 +386,8 @@ impl SettingsScreen { pairing_code: None, paired_devices: Vec::new(), saved_hosts, + host_rows, + hosts_watch: 0, host_address, host_code, host_name, @@ -425,9 +441,20 @@ impl SettingsScreen { if matches!(this.hosting_status, HostingStatus::Starting) { this.watch_hosting_start(cx); } + if this.section == Section::Hosts { + this.watch_hosts(cx); + } this } + /// What this app would announce in its own hello. + fn app_version() -> HostVersion { + HostVersion { + version: crate::env::APP_VERSION.to_string(), + build: crate::env::build_hash().map(str::to_string), + } + } + fn load_plan(&self, cx: &mut Context) { let backend = self.backend.clone(); let user_id = self.user_id.clone(); @@ -838,6 +865,66 @@ impl SettingsScreen { .is_some_and(|manager| manager.is_online(id)) } + /// What a host's live connection announced, or `None` while offline. + fn host_version(&self, id: &str) -> Option { + self.manager + .as_ref() + .and_then(|manager| manager.host_version(id)) + } + + /// Recompute the Hosts rows from the saved list and the manager's + /// live state. Returns whether anything shown changed. + fn rebuild_host_rows(&mut self) -> bool { + let rows = hosts::rows( + &self.saved_hosts, + &Self::app_version(), + latest_release(), + |id| self.host_version(id), + ); + if rows == self.host_rows { + return false; + } + self.host_rows = rows; + true + } + + /// Poll the manager once a second while the Hosts section is shown, + /// so a host that connects or drops updates its row; nothing pushes + /// status changes to this screen. Re-renders only when a row changed. + /// A flipped connection state re-reads the saved list too, since the + /// hello that just completed rewrote that host's last seen version. + fn watch_hosts(&mut self, cx: &mut Context) { + self.hosts_watch = self.hosts_watch.wrapping_add(1); + let generation = self.hosts_watch; + let task = cx.spawn(async move |this, cx| { + loop { + cx.background_executor() + .timer(std::time::Duration::from_secs(1)) + .await; + let keep_going = this.update(cx, |this, cx| { + if this.section != Section::Hosts || this.hosts_watch != generation { + return false; + } + let flipped = this + .host_rows + .iter() + .any(|row| this.host_is_online(&row.id) != row.online); + if flipped { + this.reload_saved_hosts(); + cx.notify(); + } else if this.rebuild_host_rows() { + cx.notify(); + } + true + }); + if !matches!(keep_going, Ok(true)) { + return; + } + } + }); + crate::ui::task::retain(&self.bridged_tasks, task); + } + /// Re-render shortly, so a host that connects after pairing shows as /// online without the user leaving the screen. fn refresh_hosts_soon(&self, cx: &mut Context) { @@ -866,6 +953,7 @@ impl SettingsScreen { { self.saved_hosts = hosts; } + self.rebuild_host_rows(); } /// Pair with the host in the form. The manager saves it and connects; @@ -1268,63 +1356,71 @@ impl SettingsScreen { .child("No hosts yet."), ); } - for host in &self.saved_hosts { - let online = self.host_is_online(&host.id); - let id = host.id.clone(); - let short_id: String = host.id.chars().take(10).collect(); - let connections = host - .connections - .iter() - .map(|connection| connection.label().to_string()) - .collect::>() - .join(", "); + for row in &self.host_rows { + let online = row.online; + let id = row.id.clone(); + let mut column = div() + .flex() + .flex_col() + .min_w_0() + .child( + div() + .flex() + .items_center() + .gap_2() + .child( + div() + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(gpui::rgb(theme::text_primary())) + .child(row.name.clone()), + ) + .child( + div() + .text_xs() + .text_color(gpui::rgb(if online { + theme::accent() + } else { + theme::text_muted() + })) + .child(if online { "online" } else { "offline" }), + ) + .when_some(row.version.clone(), |line, version| { + line.child( + div() + .text_xs() + .text_color(gpui::rgb(theme::text_muted())) + .child(version), + ) + }), + ) + .child( + div() + .text_xs() + .text_color(gpui::rgb(theme::text_muted())) + .line_clamp(1) + .text_ellipsis() + .child(row.detail.clone()), + ); + for note in &row.notes { + column = column.child( + div() + .text_xs() + .text_color(gpui::rgb(theme::text_primary())) + .child(note.clone()), + ); + } pane = pane.child( widgets::card_row() - .id(gpui::SharedString::from(format!("host-{}", host.id))) + .id(gpui::SharedString::from(format!("host-{}", row.id))) .flex() .items_center() .justify_between() .gap_4() - .child( - div() - .flex() - .flex_col() - .min_w_0() - .child( - div() - .flex() - .items_center() - .gap_2() - .child( - div() - .font_weight(gpui::FontWeight::MEDIUM) - .text_color(gpui::rgb(theme::text_primary())) - .child(host.name.clone()), - ) - .child( - div() - .text_xs() - .text_color(gpui::rgb(if online { - theme::accent() - } else { - theme::text_muted() - })) - .child(if online { "online" } else { "offline" }), - ), - ) - .child( - div() - .text_xs() - .text_color(gpui::rgb(theme::text_muted())) - .line_clamp(1) - .text_ellipsis() - .child(format!("{connections} \u{b7} key {short_id}\u{2026}")), - ), - ) + .child(column) .child( widgets::ghost_button(gpui::SharedString::from(format!( "remove-host-{}", - host.id + row.id ))) .on_click(cx.listener(move |this, _event, _window, cx| { this.remove_host(&id, cx); @@ -2097,7 +2193,14 @@ impl SettingsScreen { } // A dropdown belongs to the pane that opened it. self.close_setting_menu(cx); + let entering_hosts = section == Section::Hosts && self.section != Section::Hosts; self.section = section; + if entering_hosts { + // The list is a snapshot from open; hosts may have connected + // or recorded a version since. + self.reload_saved_hosts(); + self.watch_hosts(cx); + } if self.settings.application_vim_enabled { self.application_vim.section = section; self.reconcile_application_vim_target(); @@ -2111,6 +2214,11 @@ impl SettingsScreen { } } +/// The newest Agent release the update check found, for the Hosts rows. +fn latest_release() -> Option<&'static str> { + crate::update::available().map(|update| update.version.as_str()) +} + fn merge_shortcut_overrides(settings: &mut AppSettings, shortcut_overrides: ShortcutOverrides) { settings.shortcut_overrides = shortcut_overrides; } diff --git a/apps/maple-agent/app/src/ui/settings/hosts.rs b/apps/maple-agent/app/src/ui/settings/hosts.rs new file mode 100644 index 000000000..c643f778a --- /dev/null +++ b/apps/maple-agent/app/src/ui/settings/hosts.rs @@ -0,0 +1,269 @@ +//! The rows of the Hosts pane: each saved host with its connection state +//! and the version it announced, compared with this app's own. Rows are +//! computed when the list or a host's state changes, never in render. + +use gpui::SharedString; +use maple_remote::hosts::SavedHost; +use maple_remote::manager::HostVersion; +use semver::Version; + +/// How a host's build relates to this app's. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Relation { + /// The same version and, where both are known, the same build. + Same, + /// The same version built from another revision. + DifferentBuild, + /// The host's version is lower. + Behind, + /// The host's version is higher. + Newer, + /// One of the versions is not semver; nothing can be said. + Unknown, +} + +pub fn relation(host: &HostVersion, app: &HostVersion) -> Relation { + let (Ok(host_version), Ok(app_version)) = + (Version::parse(&host.version), Version::parse(&app.version)) + else { + return Relation::Unknown; + }; + match host_version.cmp(&app_version) { + std::cmp::Ordering::Less => Relation::Behind, + std::cmp::Ordering::Greater => Relation::Newer, + std::cmp::Ordering::Equal => match (&host.build, &app.build) { + (Some(host_build), Some(app_build)) if host_build != app_build => { + Relation::DifferentBuild + } + _ => Relation::Same, + }, + } +} + +/// The lines shown under a host's version: how it relates to this app, +/// and the newest release the update check found when that is newer than +/// the host. `latest` is that release's version, if any. +pub fn notes(host: &HostVersion, app: &HostVersion, latest: Option<&str>) -> Vec { + let mut notes = Vec::new(); + match relation(host, app) { + Relation::Behind => notes.push("Behind this app; update the host".to_string()), + Relation::Newer => notes.push("Newer than this app; update this app".to_string()), + Relation::DifferentBuild => notes.push("Different build from this app".to_string()), + Relation::Same | Relation::Unknown => {} + } + if let Some(latest) = latest + && let (Ok(latest_version), Ok(host_version)) = + (Version::parse(latest), Version::parse(&host.version)) + && latest_version > host_version + { + notes.push(format!("Update available: {latest}")); + } + notes +} + +/// One saved host as the pane shows it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HostRow { + pub id: String, + pub name: SharedString, + pub online: bool, + /// `0.1.0 (63bcff5c)` while online, `last seen 0.1.0 (63bcff5c)` + /// while offline; absent when the host never announced a version. + pub version: Option, + pub notes: Vec, + /// The addresses and the start of the key. + pub detail: SharedString, +} + +/// The rows for `saved`. `live` answers what a host's current connection +/// announced, or `None` while it is offline; an offline host shows what it +/// announced last. +pub fn rows( + saved: &[SavedHost], + app: &HostVersion, + latest: Option<&str>, + live: impl Fn(&str) -> Option, +) -> Vec { + saved + .iter() + .map(|host| { + let connected = live(&host.id); + let online = connected.is_some(); + let announced = connected.or_else(|| { + host.last_seen_version.clone().map(|version| HostVersion { + version, + build: host.last_seen_build.clone(), + }) + }); + let version = announced.as_ref().map(|announced| { + if online { + announced.label() + } else { + format!("last seen {}", announced.label()) + } + }); + let notes = announced + .as_ref() + .map(|announced| notes(announced, app, latest)) + .unwrap_or_default(); + let short_id: String = host.id.chars().take(10).collect(); + let connections = host + .connections + .iter() + .map(|connection| connection.label().to_string()) + .collect::>() + .join(", "); + HostRow { + id: host.id.clone(), + name: host.name.clone().into(), + online, + version: version.map(Into::into), + notes: notes.into_iter().map(Into::into).collect(), + detail: format!("{connections} \u{b7} key {short_id}\u{2026}").into(), + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use maple_remote::hosts::HostConnection; + + fn version(version: &str, build: Option<&str>) -> HostVersion { + HostVersion { + version: version.to_string(), + build: build.map(str::to_string), + } + } + + #[test] + fn a_host_is_compared_by_version_then_by_build() { + let app = version("0.2.0", Some("63bcff5c")); + assert_eq!( + relation(&version("0.1.9", Some("63bcff5c")), &app), + Relation::Behind + ); + assert_eq!( + relation(&version("0.2.0", Some("63bcff5c")), &app), + Relation::Same + ); + assert_eq!( + relation(&version("0.2.0", Some("abc1234")), &app), + Relation::DifferentBuild + ); + assert_eq!( + relation(&version("0.2.1", Some("abc1234")), &app), + Relation::Newer + ); + // A side without a build cannot be told apart by it. + assert_eq!(relation(&version("0.2.0", None), &app), Relation::Same); + assert_eq!( + relation(&version("0.2.0", Some("abc1234")), &version("0.2.0", None)), + Relation::Same + ); + assert_eq!(relation(&version("v0.2.0", None), &app), Relation::Unknown); + } + + #[test] + fn notes_name_the_relation_and_an_available_update() { + let app = version("0.2.0", Some("63bcff5c")); + assert_eq!( + notes(&version("0.1.9", Some("63bcff5c")), &app, None), + vec!["Behind this app; update the host"] + ); + assert!(notes(&version("0.2.0", Some("63bcff5c")), &app, None).is_empty()); + assert_eq!( + notes(&version("0.2.0", Some("abc1234")), &app, None), + vec!["Different build from this app"] + ); + assert_eq!( + notes(&version("0.3.0", None), &app, None), + vec!["Newer than this app; update this app"] + ); + // The update note joins the relation and stands alone when the + // host matches this app but a newer release exists. + assert_eq!( + notes(&version("0.1.9", None), &app, Some("0.3.0")), + vec![ + "Behind this app; update the host", + "Update available: 0.3.0" + ] + ); + assert_eq!( + notes(&version("0.2.0", Some("63bcff5c")), &app, Some("0.3.0")), + vec!["Update available: 0.3.0"] + ); + // A release the host already runs, or older, is not offered. + assert!( + notes( + &version("0.3.0", None), + &version("0.3.0", None), + Some("0.3.0") + ) + .is_empty() + ); + assert!( + notes( + &version("0.3.0", None), + &version("0.3.0", None), + Some("0.2.0") + ) + .is_empty() + ); + assert!(notes(&version("nope", None), &app, Some("also nope")).is_empty()); + } + + #[test] + fn rows_show_the_live_version_online_and_the_last_seen_one_offline() { + let saved = |id: &str, last_seen: Option<(&str, Option<&str>)>| SavedHost { + id: id.to_string(), + name: format!("host {id}"), + connections: vec![HostConnection::Direct { + address: "100.64.0.7:7130".to_string(), + }], + paired_at_ms: 0, + last_seen_version: last_seen.map(|(version, _)| version.to_string()), + last_seen_build: last_seen.and_then(|(_, build)| build.map(str::to_string)), + }; + let app = version("0.2.0", Some("63bcff5c")); + let hosts = vec![ + saved("online-host", Some(("0.1.0", None))), + saved("offline-host", Some(("0.1.5", Some("abc1234")))), + saved("never-seen", None), + ]; + let rows = rows(&hosts, &app, Some("0.2.0"), |id| { + (id == "online-host").then(|| version("0.2.0", Some("abc1234"))) + }); + assert_eq!(rows.len(), 3); + + // Online: the connection's hello wins over what was saved. + assert!(rows[0].online); + assert_eq!(rows[0].version.as_deref(), Some("0.2.0 (abc1234)")); + assert_eq!(rows[0].notes, vec!["Different build from this app"]); + assert_eq!(rows[0].name.as_ref(), "host online-host"); + assert_eq!( + rows[0].detail.as_ref(), + "100.64.0.7:7130 \u{b7} key online-hos\u{2026}" + ); + + // Offline: the last seen version, still compared. + assert!(!rows[1].online); + assert_eq!( + rows[1].version.as_deref(), + Some("last seen 0.1.5 (abc1234)") + ); + assert_eq!( + rows[1].notes, + vec![ + "Behind this app; update the host", + "Update available: 0.2.0" + ] + ); + + // Never connected on a build that records versions: nothing to say. + assert!(!rows[2].online); + assert_eq!(rows[2].version, None); + assert!(rows[2].notes.is_empty()); + } +} From 4ee05f2339833ff73f8698dc1e6021ef093460f3 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Mon, 21 Sep 2026 01:39:56 -0500 Subject: [PATCH 34/37] Document the hello build and host version line Name the optional `build` field in both hellos, the connect log line that includes it, the `lastSeenVersion` and `lastSeenBuild` fields on a saved host, the manager's `host_version(id)`, and the version line and comparison copy the Hosts pane shows, so the protocol and settings descriptions match the source. Co-Authored-By: Claude Fable 5.1 --- apps/maple-agent/README.md | 13 +++++-- apps/maple-agent/docs/remote-development.md | 42 +++++++++++++++++---- 2 files changed, 44 insertions(+), 11 deletions(-) diff --git a/apps/maple-agent/README.md b/apps/maple-agent/README.md index 6c61a99b8..86d8b0e9c 100644 --- a/apps/maple-agent/README.md +++ b/apps/maple-agent/README.md @@ -136,8 +136,12 @@ Cargo manifests and lockfile; Research has an independent dependency graph. Tasks can run on another machine. Settings > Hosts pairs this device with a host running `maple-gpui serve` (address plus the one-time code the host -printed) and lists the paired hosts with their connection state. Saved -hosts connect at launch and reconnect with backoff. Their tasks join the +printed) and lists the paired hosts with their connection state. Each row +also shows the version and build the host announced ("last seen" while it +is offline) and says when the host is behind this app, a different build +of the same version, newer than this app, or older than a release the +update check found. Saved hosts connect at launch and reconnect with +backoff. Their tasks join the sidebar, badged with the host name once more than one host is known, and the project switcher filters by host. With a task open the header names the host it runs on; on the new-task screen a chip there names the host @@ -452,7 +456,8 @@ admitted after another account signs in. Traffic is Noise-encrypted inside a pla pairing is the only gate and the listener binds every interface by default. Repeated wrong codes lock the source address out. Revoking a device ends its live connections within seconds. One `serve` per data root; a lock -file refuses a second. See [`docs/remote-development.md`](docs/remote-development.md). +file refuses a second. Each connect is logged with the client's name, +key, version, and build. See [`docs/remote-development.md`](docs/remote-development.md). `serve` is written to run under systemd: it stops cleanly on SIGTERM as well as Ctrl-C, reports `READY=1` once the port is bound and `STOPPING=1` @@ -545,7 +550,7 @@ The roots follow the platform, the same way the Tauri app's | `/agent/acp/accounts//config.json` | ACP configuration. | | `/remote/host_key.json` | This machine's static Noise key as a host (mode 0600). | | `/remote/device_key.json` | This machine's static Noise key as a client device (mode 0600). | -| `/agent/accounts//hosts.json` | Hosts this account paired with: key, name, addresses. | +| `/agent/accounts//hosts.json` | Hosts this account paired with: key, name, addresses, last seen version and build. | | `/remote/accounts//devices.json` | Devices paired into this account on this host. Another account's host never admits them. | | `/remote/accounts//pending_pairing.json` | The pairing code `serve pair` published for this account, until used or expired (mode 0600). | | `/remote/serve.lock`, `serve.json` | The running host's lock and its listen address. | diff --git a/apps/maple-agent/docs/remote-development.md b/apps/maple-agent/docs/remote-development.md index 9682e7fc2..dfc2ad8b7 100644 --- a/apps/maple-agent/docs/remote-development.md +++ b/apps/maple-agent/docs/remote-development.md @@ -382,6 +382,7 @@ request, and the host answers with its own hello: { "protocol": 1, "appVersion": "0.1.0", + "build": "63bcff5c", "pcrEnvironment": "Production", "features": { "timelinePaging": true, "attachmentStreams": true, "uploadStreams": true, "ping": true }, @@ -393,6 +394,7 @@ request, and the host answers with its own hello: { "protocol": 1, "appVersion": "0.1.0", + "build": "63bcff5c", "pcrEnvironment": "Production", "generation": "uuid", "seq": 0, @@ -402,6 +404,13 @@ request, and the host answers with its own hello: } ``` +`build` is the git revision `build.rs` baked into the binary (`abc1234`, +or `abc1234-dirty`), so two builds of one package version can be told +apart; it is optional and absent from a build outside a git checkout or +from an older peer. The host logs the client's name, key, version, and +build on connect ("client bens-laptop (...) connected running maple-gpui +0.1.0 (63bcff5c)"); the client keeps the host's for the Settings screen. + The host refuses the hello with `HANDSHAKE_REFUSED` and closes when the protocol differs, when `pcrEnvironment` differs (two binaries built for different enclaves cannot share a backend), or when the hello names a @@ -495,15 +504,20 @@ The client's `hosts.json` is one file per account: { "kind": "direct", "address": "100.64.0.7:7130" }, { "kind": "direct", "address": "192.168.1.20:7130" } ], - "pairedAtMs": 0 + "pairedAtMs": 0, + "lastSeenVersion": "0.1.0", + "lastSeenBuild": "63bcff5c" } ] } ``` Adding a connection whose host presents an already-known public key merges -into that host. Loading salvages per entry: a malformed connection is -dropped, not the host, and a malformed host is dropped, not the file. +into that host. `lastSeenVersion` and `lastSeenBuild` are what the host +announced at its most recent hello and are rewritten on every connect; +both are optional, so a file from before they existed still loads. Loading +salvages per entry: a malformed connection is dropped, not the host, and a +malformed host is dropped, not the file. The host's `devices.json` is `{ "devices": [ { "publicKey", "name", "userId", "pairedAtMs", "lastSeenMs" } ] }`. Revoking by name is refused @@ -632,8 +646,10 @@ The status states are `Connecting`, `Online`, and `Offline { reason }`. Removing a host cancels its connector and reports `Offline { reason: "removed" }`. A replaced or removed connector says nothing more once it is superseded. The manager also answers -`is_online(id)` for the settings screen, and `rename` exists on it and -the store, but no UI calls it. +`is_online(id)` and `host_version(id)` (the version and build the live +connection's hello announced, `None` while offline) for the settings +screen, and writes that version to the saved host on every successful +hello. `rename` exists on it and the store, but no UI calls it. ### Client settings @@ -704,8 +720,17 @@ auto-select. Settings has one Hosts pane for both roles. For the client role it pairs (address, code, optional name) and lists the saved hosts with their -connection state and a remove action; there is no rename UI. For the host -role it shows the "Allow remote connections" toggle and its state: +connection state and a remove action; there is no rename UI. Each row +shows the version and build the host announced, as `0.1.0 (63bcff5c)` +while online and `last seen 0.1.0 (63bcff5c)` while offline, and a line +comparing it with this app: "Behind this app; update the host" when the +host's version is lower, "Different build from this app" when only the +build differs, "Newer than this app; update this app" when it is higher, +and "Update available: " when the update check found a release +newer than the host. The rows are computed when the list or a host's +state changes; while the pane is shown it polls the manager once a +second and re-renders only when a row changed. For the host role it +shows the "Allow remote connections" toggle and its state: "Not listening", "Starting", the bound socket, or, when the host binds every interface, the port with a note to use the machine's LAN or Tailscale address. "Generate pairing code" works only while listening; the @@ -785,6 +810,9 @@ a scripted `FakeHost`: racing devices; a revoked device reconnecting does not lock out pairing again; large frames cross the Noise carrier in pieces; an oversized WebSocket message is refused by both roles. +- `manager.rs`, the connection manager over the same listener: pairing + records the host's version and build, `host_version` answers them while + online and `None` once the host is gone, and the saved host keeps them. Not covered: two clients answering one permission prompt, a revoked device's live connection being dropped mid-session, and the desktop From 9ef3abccb8568ce23db9d25b7210aa91f93ab3e8 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Mon, 21 Sep 2026 02:12:07 -0500 Subject: [PATCH 35/37] Create the task on the first send, not New Task "New Task" created a session on the target host the moment it was clicked and selected it behind the empty screen. Switching the host or project afterwards did not move that task, so a tester's first message ran locally while the header named the remote host, and every click that sent nothing left an empty row in the host's database. New Task now clears the selection and shows the empty screen for the target host's project without creating anything. The composer works on a draft: the permission mode, model, web access, integration toggles, and staged images live on the screen only. The first send builds the create request from the draft, creates the task on the current target, applies web access and integration toggles the request cannot carry, and then runs the send against the new id. A pending first send is kept as a continuation and run from finish_new_session; the existing selection generation drops it when the user navigated away meanwhile, and the one-create-at-a-time fence keeps a second Enter from starting another task. A failed create reports the error and gives the text back to the composer. Slash commands that act on a task and /btw create the task the same way before running; commands that need none run at once. The boot auto-select starts a draft when no task matches the visible project. Co-Authored-By: Claude Fable 5.1 --- apps/maple-agent/README.md | 5 +- apps/maple-agent/app/src/ui/chat/mod.rs | 353 +++++++++++++++++--- apps/maple-agent/app/src/ui/chat/tests.rs | 303 +++++++++++++++-- apps/maple-agent/docs/remote-development.md | 8 +- 4 files changed, 594 insertions(+), 75 deletions(-) diff --git a/apps/maple-agent/README.md b/apps/maple-agent/README.md index 86d8b0e9c..7e77799b6 100644 --- a/apps/maple-agent/README.md +++ b/apps/maple-agent/README.md @@ -146,8 +146,9 @@ sidebar, badged with the host name once more than one host is known, and the project switcher filters by host. With a task open the header names the host it runs on; on the new-task screen a chip there names the host new tasks run on and switches it. Both appear once more than one host is -known. The host the last new task ran on is the target again at the next -launch once it connects. Choosing a project opens one picker for every +known. The task itself is created there when its first message is sent. +The host the last new task ran on is the target again at the next launch +once it connects. Choosing a project opens one picker for every host: a search box over the host's recent projects and folders and a row that opens a typed path. Host-scoped settings (defaults, system prompt, integrations, usage) get a host selector when more than one host is diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index 705d4c089..1b8d1aceb 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -11,9 +11,10 @@ use gpui::{ Window, div, prelude::*, px, }; use maple_agent::agent::{ - AgentCreateSessionRequest, AgentImageUpload, AgentProjectTrustStatus, AgentQueuedMessage, - AgentSendMessageRequest, AgentServiceEvent, AgentSessionMcpServer, AgentSessionSummary, - AgentSlashCommand, AgentSubagent, AgentTimelineItem, SideQuestionEvent, + AgentCreateSessionRequest, AgentImageUpload, AgentMcpServer, AgentMcpTransport, + AgentProjectTrustStatus, AgentQueuedMessage, AgentSendMessageRequest, AgentServiceEvent, + AgentSessionIntegrationKind, AgentSessionMcpServer, AgentSessionSummary, AgentSlashCommand, + AgentSubagent, AgentTimelineItem, SideQuestionEvent, }; use maple_agent::host::{HostBackend, HostEvent, HostId, HostSessionDefaults}; @@ -167,6 +168,44 @@ pub(crate) struct SpeechState { pub playing: bool, } +/// What the composer asked for while no task existed. The first send +/// creates the task on the target host and then runs this against it. +#[derive(Debug, Clone, PartialEq, Eq)] +enum FirstSend { + /// Plain text (with the staged images) for the new task. + Message { text: String, steer: bool }, + /// A `/command` that needs a task, as typed. + Command(String), + /// A `/btw` question for the new task. + SideQuestion(String), +} + +/// An integration toggled on the draft, applied once the task exists. +#[derive(Debug, Clone, PartialEq, Eq)] +struct DraftMcpChange { + name: String, + kind: AgentSessionIntegrationKind, + enabled: bool, +} + +/// The row the draft's integrations chip shows for a configured MCP +/// server: what a task created on the target host would start with. +fn draft_mcp_row(server: AgentMcpServer) -> AgentSessionMcpServer { + AgentSessionMcpServer { + display_name: server.name.clone(), + name: server.name, + kind: AgentSessionIntegrationKind::Mcp, + description: server.description, + transport: match server.transport { + AgentMcpTransport::Stdio { .. } => "stdio", + AgentMcpTransport::StreamableHttp { .. } => "streamable_http", + } + .to_string(), + enabled: server.enabled, + available: true, + } +} + pub struct ChatScreen { /// Account-level calls: sign-out, billing, audio. backend: Arc, @@ -218,6 +257,14 @@ pub struct ChatScreen { permission_responding: bool, /// Suppresses duplicate session creation while one is in flight. session_setup_pending: bool, + /// The send that is creating the task, run once the task lands. A + /// selection change meanwhile drops it. + pending_first_send: Option, + /// Integration toggles made on the draft. The create request's name + /// list would replace the host's integration defaults wholesale, so + /// each toggle is applied to the new task the way the chip applies it + /// to a live one. + draft_mcp_changes: Vec, /// An ask_user question waiting for the user's text answer. /// Questions waiting for the user, oldest first. The model can issue /// several ask_user calls in one turn; every card must be answerable. @@ -913,6 +960,8 @@ impl ChatScreen { pending_permissions: Vec::new(), permission_responding: false, session_setup_pending: false, + pending_first_send: None, + draft_mcp_changes: Vec::new(), pending_questions: Vec::new(), pending_question_input: None, context_fraction: None, @@ -1511,10 +1560,7 @@ impl ChatScreen { } /// A task on screen that nothing has happened to yet: no messages, - /// no run, no load in flight. "New Task" creates one on the target - /// the moment it is clicked, so the pane shows the empty hero while a - /// task already exists on that host. Such a task follows a target - /// change instead of pinning the host it was created on. + /// no run, no load in flight. pub(super) fn selection_is_blank(&self) -> bool { match self.selected_session.as_deref() { Some(selected) => { @@ -1526,23 +1572,92 @@ impl ChatScreen { } } + /// "New Task": show the empty screen for the target host's project and + /// create nothing. The task is created on the target the moment the + /// first message is sent, so a host or project switched before then + /// moves it, and a click that sends nothing leaves no empty row on + /// any host. pub(super) fn new_session(&mut self, cx: &mut Context) { - // A boot-time auto-create and a user click can race; one only. + // The first send is creating its task; a click now would + // supersede it and lose the message. if self.session_setup_pending { return; } if self.root_selecting { - // The visible project is about to change; a task created now - // would land under the old one. + // The visible project is about to change; a draft started now + // would supersede the selection landing. self.notice = Some("Wait for the project selection to finish, then try again".into()); cx.notify(); return; } + // Starting a draft is navigation: a task load in flight must not + // land on top of the empty screen. + self.begin_navigation(); + self.loading_session = None; + self.clear_selected_session_presentation(cx); + // The draft starts from the target host's defaults; the chips edit + // it on screen only, until the task exists. + self.web_enabled = self.default_web_enabled; + self.refresh_draft_mcp(cx); + self.refresh_selected_title(); + cx.notify(); + } + + /// Create the task the draft describes on the target host, then run + /// `action` against it. The composer clears now, as for a send; every + /// failure gives the text back. + fn create_for_first_send(&mut self, action: FirstSend, cx: &mut Context) { + self.slash_selected = None; + if let Some(composer) = self.composer.clone() { + composer.update(cx, |input, cx| input.clear(cx)); + } + if self.root_selecting { + self.notice = Some("Wait for the project selection to finish, then try again".into()); + self.restore_first_send(action, cx); + cx.notify(); + return; + } let Some(request) = self.new_session_request() else { self.notice = Some("Choose a project before creating a task".into()); + self.restore_first_send(action, cx); cx.notify(); return; }; + self.pending_first_send = Some(action); + self.notice = Some("Creating the task…".into()); + cx.notify(); + self.create_session(request, cx); + } + + /// Put a first send that did not happen back into the composer. + fn restore_first_send(&mut self, action: FirstSend, cx: &mut Context) { + let text = match action { + FirstSend::Message { text, .. } | FirstSend::Command(text) => text, + FirstSend::SideQuestion(question) => format!("/btw {question}"), + }; + if let Some(composer) = self.composer.clone() { + composer.update(cx, |input, cx| input.set_text(&text, cx)); + } + } + + /// Run the send the task was created for. + fn run_first_send(&mut self, session_id: &str, action: FirstSend, cx: &mut Context) { + self.notice = None; + match action { + FirstSend::Message { text, steer } => { + self.send_to_session(session_id, text, steer, None, cx) + } + FirstSend::Command(text) => { + self.try_command(Some(session_id), &text, cx); + } + FirstSend::SideQuestion(question) => self.ask_side_question(session_id, &question, cx), + } + } + + /// Create a task on the target host from `request`, then apply the + /// draft's web access and integration toggles, which the request + /// cannot carry. + fn create_session(&mut self, request: AgentCreateSessionRequest, cx: &mut Context) { // Creating a task is a navigation intent, but the generation moves // only when the task lands: a failed create leaves loads in flight // alive, and a task or project selected meanwhile supersedes the @@ -1550,23 +1665,69 @@ impl ChatScreen { let selection_generation = self.selection_generation; self.session_setup_pending = true; let host = self.host.clone(); + let web_enabled = self.web_enabled; + let mcp_changes = self.draft_mcp_changes.clone(); self.call( async move { - host.create_session(Some(request)) - .await - .map(|detail| detail.session) + let mut session = host.create_session(Some(request)).await?.session; + // The task exists from here on: a draft setting that does + // not apply is reported, not a reason to lose the task. + let mut warning = None; + if session.web_enabled != web_enabled { + match host + .set_session_web_enabled(session.id.clone(), web_enabled) + .await + { + Ok(updated) => session = updated, + Err(message) => { + warning = Some(format!("Could not change web access: {message}")) + } + } + } + for change in mcp_changes { + if let Err(message) = host + .set_session_mcp_server_enabled( + session.id.clone(), + change.name.clone(), + change.kind, + change.enabled, + ) + .await + { + warning = Some(format!("Could not change {}: {message}", change.name)); + } + } + Ok((session, warning)) }, cx, move |this, result, cx| match result { - Ok(session) => this.finish_new_session(session, selection_generation, cx), - Err(message) => { - this.session_setup_pending = false; - this.notice = Some(message.into()); + Ok((session, warning)) => { + this.finish_new_session(session, selection_generation, cx); + if let Some(warning) = warning { + this.notice = Some(warning.into()); + cx.notify(); + } } + Err(message) => this.fail_new_session(message, cx), }, ); } + /// The create failed: nothing exists, so the draft stays as it was, + /// with the message back in the composer. + fn fail_new_session(&mut self, message: String, cx: &mut Context) { + self.session_setup_pending = false; + self.notice = Some(message.into()); + if let Some(action) = self.pending_first_send.take() + && self.selected_session.is_none() + { + // The composer belongs to the task on screen; a draft left for + // another task does not land in it. + self.restore_first_send(action, cx); + } + cx.notify(); + } + fn finish_new_session( &mut self, session: AgentSessionSummary, @@ -1589,17 +1750,23 @@ impl ChatScreen { self.upsert_session(session.clone(), cx); if self.selection_generation == selection_generation { self.begin_navigation(); + let session_id = session.id.clone(); self.set_active_session(session, Vec::new(), HashMap::new(), cx); - if !self.default_web_enabled { - self.set_web_enabled(false, cx); + if let Some(action) = self.pending_first_send.take() { + self.run_first_send(&session_id, action, cx); } return; } // Creation still succeeded, but an older callback must never override - // a newer task or project choice. If the project choice left the view - // empty while the one-create-at-a-time fence was held, let it settle - // now that another task may be created. + // a newer task or project choice. The message it was created for has + // no screen to go back to; say so rather than send it into a task + // the user left. + if self.pending_first_send.take().is_some() { + self.notice = Some("Message not sent: another task was opened first".into()); + } + // If the project choice left the view empty while the + // one-create-at-a-time fence was held, let it settle now. self.sync_sidebar(cx); if self.selected_session.is_none() { self.refresh_sessions(cx); @@ -1613,7 +1780,7 @@ impl ChatScreen { Some(AgentCreateSessionRequest { project_root: Some(self.project_root.clone()?), title: None, - model: None, + model: self.selected_model.clone(), context_limit: None, // Persist the composer's mode — the saved default until the // user picks one for this task — so the created row, its @@ -1621,6 +1788,8 @@ impl ChatScreen { // runtime's SmartApprove startup default, and adopting that // summary would reset the chip to Ask First. mode: Some(self.permission_mode.as_str().to_string()), + // Integration toggles are applied after creation: a name list + // here would also decide the host's curated integrations. mcp_server_names: None, system_prompt: None, }) @@ -1825,6 +1994,11 @@ impl ChatScreen { entry.session_defaults = Some(defaults.clone()); } self.default_web_enabled = defaults.web_enabled; + if self.selected_session.is_none() { + // The draft has no record of its own; the chip shows the + // default the task will be created with. + self.web_enabled = defaults.web_enabled; + } if self.uses_default_permission_mode { let mode = PermissionMode::parse(&defaults.permission_mode); if mode != self.permission_mode { @@ -2122,6 +2296,7 @@ impl ChatScreen { // Release the hold while the previous session id is still selected. self.abandon_queue_edit(cx); self.selected_session = None; + self.draft_mcp_changes.clear(); self.sync_sidebar_selection(cx); self.set_queue(Vec::new()); self.replace_timeline(Vec::new()); @@ -2320,14 +2495,63 @@ impl ChatScreen { ); } + /// Load the integrations a task created on the target host would start + /// with, for the draft's chip: the host's configured MCP servers with + /// their defaults. Curated integrations join once the task exists. + fn refresh_draft_mcp(&mut self, cx: &mut Context) { + self.draft_mcp_changes.clear(); + self.set_session_mcp(Vec::new()); + let host = self.host.clone(); + let target = self.target_host.clone(); + self.call( + async move { host.list_mcp_servers().await }, + cx, + move |this, result, cx| { + if this.selected_session.is_some() || this.target_host != target { + return; + } + match result { + Ok(servers) => { + this.draft_mcp_changes.clear(); + this.set_session_mcp(servers.into_iter().map(draft_mcp_row).collect()); + } + Err(message) => log::debug!("mcp list failed: {message}"), + } + cx.notify(); + }, + ); + } + fn toggle_session_mcp( &mut self, name: String, - kind: maple_agent::agent::AgentSessionIntegrationKind, + kind: AgentSessionIntegrationKind, enabled: bool, cx: &mut Context, ) { let Some(session_id) = self.selected_session.clone() else { + // The draft: flip the row on screen; the task applies it when + // it is created. + if let Some(server) = self + .session_mcp + .iter_mut() + .find(|server| server.name == name && server.kind == kind) + { + server.enabled = enabled; + } + self.mcp_enabled_count = self + .session_mcp + .iter() + .filter(|server| server.enabled) + .count(); + self.draft_mcp_changes + .retain(|change| !(change.name == name && change.kind == kind)); + self.draft_mcp_changes.push(DraftMcpChange { + name, + kind, + enabled, + }); + cx.notify(); return; }; let host = self.backend_for(&session_id); @@ -2352,9 +2576,12 @@ impl ChatScreen { } /// Persist the web flag for the selected task; the runtime applies it - /// on the next turn. + /// on the next turn. On the draft it is only screen state, applied + /// when the task is created. fn set_web_enabled(&mut self, enabled: bool, cx: &mut Context) { let Some(session_id) = self.selected_session.clone() else { + self.web_enabled = enabled; + cx.notify(); return; }; let previous = self.web_enabled; @@ -3068,16 +3295,20 @@ impl ChatScreen { } fn send_text_with(&mut self, text: String, steer: bool, cx: &mut Context) { - let Some(session_id) = self.selected_session.clone() else { - self.notice = Some("Create a task first".into()); - cx.notify(); - return; - }; if self.booting { self.notice = Some("Agent runtime is still starting".into()); cx.notify(); return; } + // With no task the first send creates one on the target host and + // runs there. One create at a time: while it is in flight the text + // stays in the composer. + let session_id = self.selected_session.clone(); + if session_id.is_none() && self.session_setup_pending { + self.notice = Some("Creating the task…".into()); + cx.notify(); + return; + } // A side question never touches the run, so it is allowed while the // run waits on a question. While the side thread is open every plain // message goes to it; `/btw` still works and other commands run. @@ -3093,7 +3324,16 @@ impl ChatScreen { if let Some(composer) = self.composer.clone() { composer.update(cx, |input, cx| input.clear(cx)); } - self.ask_side_question(&session_id, question, cx); + match session_id { + Some(session_id) => self.ask_side_question(&session_id, question, cx), + None if question.is_empty() => { + self.notice = Some("Type /btw followed by a question".into()); + cx.notify(); + } + None => { + self.create_for_first_send(FirstSend::SideQuestion(question.to_string()), cx) + } + } return; } if self.current_question().is_some() { @@ -3123,10 +3363,10 @@ impl ChatScreen { if let Some(composer) = self.composer.clone() { composer.update(cx, |input, cx| input.clear(cx)); } - self.try_command(&session_id, &format!("/{command}"), cx); + self.try_command(session_id.as_deref(), &format!("/{command}"), cx); return; } - if self.try_command(&session_id, text.trim(), cx) { + if self.try_command(session_id.as_deref(), text.trim(), cx) { // Commands never echo into the transcript; drop the typed text // so the palette cannot survive the execution. self.slash_selected = None; @@ -3135,8 +3375,13 @@ impl ChatScreen { } return; } - let queue_id = self.queue_edit.as_ref().map(|edit| edit.queue_id.clone()); - self.send_to_session(&session_id, text, steer, queue_id, cx); + match session_id { + Some(session_id) => { + let queue_id = self.queue_edit.as_ref().map(|edit| edit.queue_id.clone()); + self.send_to_session(&session_id, text, steer, queue_id, cx); + } + None => self.create_for_first_send(FirstSend::Message { text, steer }, cx), + } } /// The command Enter should run when the slash palette is open: the @@ -3207,8 +3452,15 @@ impl ChatScreen { } /// Execute a `/command` when it matches a built-in or a skill. Unknown - /// commands fall through and are sent to the model as plain text. - fn try_command(&mut self, session_id: &str, text: &str, cx: &mut Context) -> bool { + /// commands fall through and are sent to the model as plain text. With + /// no task (`session_id` is `None`) a command that acts on one creates + /// the task first and runs once it exists. + fn try_command( + &mut self, + session_id: Option<&str>, + text: &str, + cx: &mut Context, + ) -> bool { let Some(body) = text.strip_prefix('/') else { return false; }; @@ -3219,7 +3471,19 @@ impl ChatScreen { if name.is_empty() || name.contains('/') { return false; } - let session_id = session_id.to_string(); + let is_skill = self + .slash_commands + .iter() + .any(|command| command.name.eq_ignore_ascii_case(name)); + let needs_task = matches!(name, "compact" | "btw") || is_skill; + let session_id = match session_id { + Some(session_id) => Some(session_id.to_string()), + None if needs_task => { + self.create_for_first_send(FirstSend::Command(text.to_string()), cx); + return true; + } + None => None, + }; match name { "compact" => { self.compact_now(cx); @@ -3269,7 +3533,9 @@ impl ChatScreen { true } "btw" => { - self.ask_side_question(&session_id, args, cx); + if let Some(session_id) = session_id { + self.ask_side_question(&session_id, args, cx); + } true } "help" => { @@ -3280,13 +3546,12 @@ impl ChatScreen { } _ => { // Skill commands resolve into the prompt that loads them. - if !self - .slash_commands - .iter() - .any(|command| command.name.eq_ignore_ascii_case(name)) - { + if !is_skill { return false; } + let Some(session_id) = session_id else { + return true; + }; let host = self.host.clone(); let working_dir = self.project_root.clone(); let command = name.to_string(); diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index d08b616c1..20e48ea47 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -1112,7 +1112,7 @@ mod state_tests { let btw = this.btw.as_ref().expect("thread continues"); assert_eq!(btw.turns.len(), 3); assert_eq!(btw.turns[2].question, "plain follow-up"); - assert!(this.try_command("s1", "/web", cx)); + assert!(this.try_command(Some("s1"), "/web", cx)); assert_eq!(this.btw.as_ref().unwrap().turns.len(), 3); this.close_side_thread(cx); assert!(this.btw.is_none()); @@ -1136,14 +1136,14 @@ mod state_tests { screen.update(cx, |this, cx| { this.booting = false; this.web_enabled = true; - assert!(this.try_command("s1", "/web", cx)); + assert!(this.try_command(Some("s1"), "/web", cx)); assert!(!this.web_enabled); - assert!(this.try_command("s1", "/model", cx)); + assert!(this.try_command(Some("s1"), "/model", cx)); assert!(this.models_menu_open); // Unknown commands fall through to a normal send. - assert!(!this.try_command("s1", "/definitely-not-a-command", cx)); + assert!(!this.try_command(Some("s1"), "/definitely-not-a-command", cx)); // Paths that merely start with a slash are not commands. - assert!(!this.try_command("s1", "/etc/hosts is a path", cx)); + assert!(!this.try_command(Some("s1"), "/etc/hosts is a path", cx)); }); } @@ -1157,7 +1157,7 @@ mod state_tests { description: "Deploy the app".to_string(), input_hint: None, }]; - assert!(this.try_command("s1", "/deploy staging", cx)); + assert!(this.try_command(Some("s1"), "/deploy staging", cx)); assert_eq!( this.notice.as_ref().map(SharedString::as_ref), Some("Loading skill…") @@ -1427,11 +1427,13 @@ mod state_tests { this.apply_session_list(vec![summary_at("s1", "Local", "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/work/beta")], requested, cx); - // Nothing local under /work/alpha: a new task starts rather - // than the remote one opening and dragging the target along. - assert!(this.session_setup_pending); + // Nothing local under /work/alpha: the empty screen shows + // rather than the remote task opening and dragging the target + // along, and no task is created for it. + assert!(!this.session_setup_pending); assert_eq!(this.selected_session, None); assert!(this.target_host.is_local()); + assert_eq!(this.selected_title.as_ref(), "New Task"); }); } @@ -1623,12 +1625,11 @@ mod state_tests { }); } - /// "New Task" creates a blank task on the target at once. Picking - /// another host afterwards must not leave the first message on the - /// old host: the blank task is dropped and created again on the new - /// target, and the header keeps offering the target chip meanwhile. + /// No task exists until the first message: picking another host on + /// the empty screen moves the target and its project context, and + /// creates nothing anywhere. #[gpui::test] - fn test_switching_host_on_a_blank_task_recreates_it_there(cx: &mut TestAppContext) { + fn test_switching_host_on_the_empty_screen_creates_nothing(cx: &mut TestAppContext) { cx.executor().allow_parking(); let screen = screen(cx); screen.update(cx, |this, cx| { @@ -1640,30 +1641,257 @@ mod state_tests { this.hosts.insert(remote.clone(), entry); this.hosts_changed(); this.project_root = Some("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/work/local".to_string()); - - // The blank task "New Task" made on the local host. - this.sessions = vec![summary_at("blank", "New Task", "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/work/local")]; - this.session_hosts - .insert("blank".to_string(), HostId::local()); - this.selected_session = Some("blank".to_string()); + this.selected_session = None; this.replace_timeline(Vec::new()); - assert!(this.selection_is_blank()); assert_eq!( this.selected_host, None, - "a blank task shows the target chip" + "the empty screen shows the target chip" ); this.pick_host(remote.clone(), cx); assert_eq!(this.target_host, remote); + assert_eq!(this.selected_session, None); + assert!( + !this.session_setup_pending, + "nothing is created on the new host" + ); + assert_eq!(this.project_root.as_deref(), Some("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/work/remote")); + assert_eq!(this.selected_host, None); + // The first message now goes to the new target. + this.booting = false; + this.send_text("hello".to_string(), cx); + assert!(this.session_setup_pending); + assert_eq!(this.target_host, remote); + }); + } + + /// "New Task" clears the selection and shows the empty screen with the + /// target's project; no task is created until something is sent. + #[gpui::test] + fn test_new_task_clears_the_selection_and_creates_nothing(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + let screen = screen(cx); + screen.update(cx, |this, cx| { + this.project_root = Some("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/work/alpha".to_string()); + this.sessions = vec![summary_at("s1", "Hello", "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/work/alpha")]; + this.selected_session = Some("s1".to_string()); + this.replace_timeline(vec![user_item("u1", "hi")]); + this.loading_session = Some("s1".to_string()); + this.default_web_enabled = false; + this.web_enabled = true; + let generation = this.selection_generation; + + this.new_session(cx); + + assert_eq!(this.selected_session, None); + assert!(!this.session_setup_pending); + assert!(this.pending_first_send.is_none()); + assert!(this.timeline.is_empty()); + assert_eq!(this.loading_session, None); + assert_eq!(this.selected_title.as_ref(), "New Task"); + assert_eq!(this.project_root.as_deref(), Some("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/work/alpha")); + assert!( + this.selection_generation > generation, + "a draft is navigation" + ); + assert!(!this.web_enabled, "the draft takes the host's web default"); + assert_eq!(this.sessions.len(), 1, "no row was added anywhere"); + }); + } + + /// The first send on the empty screen creates the task on the target + /// host, carrying the draft's mode and model, and sends once the task + /// lands. + #[gpui::test] + fn test_first_send_creates_the_task_on_the_target_then_sends(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + let screen = screen(cx); + screen.update(cx, |this, cx| { + this.booting = false; + this.selected_session = None; + this.project_root = Some("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/work/alpha".to_string()); + this.selected_model = Some("model-x".to_string()); + let request = this.new_session_request().expect("draft request"); + assert_eq!(request.model.as_deref(), Some("model-x")); + assert_eq!(request.mcp_server_names, None); + let generation = this.selection_generation; + + this.send_text("hello".to_string(), cx); + + assert!(this.session_setup_pending); + assert_eq!( + this.pending_first_send, + Some(FirstSend::Message { + text: "hello".to_string(), + steer: false, + }) + ); assert_eq!( this.selected_session, None, - "the local blank task is left behind" + "nothing is selected until the task lands" ); - assert!( - this.session_setup_pending, - "a task is being created on the new host" + + // The create lands: the task is selected, filed under the + // target, and the message goes out to it. + this.finish_new_session( + summary_at("created", "New Task", "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/work/alpha"), + generation, + cx, ); - assert_eq!(this.project_root.as_deref(), Some("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/work/remote")); + assert!(!this.session_setup_pending); + assert!(this.pending_first_send.is_none()); + assert_eq!(this.selected_session.as_deref(), Some("created")); + assert_eq!(this.host_of("created"), this.target_host); + assert!(this.awaiting_first_token, "the send was dispatched"); + }); + } + + /// Enter while the first send is still creating its task does not + /// start a second create; the new text waits in the composer. + #[gpui::test] + fn test_send_during_a_pending_create_does_not_start_another(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + let screen = screen(cx); + screen.update(cx, |this, cx| { + this.booting = false; + this.selected_session = None; + this.project_root = Some("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/work/alpha".to_string()); + this.send_text("first".to_string(), cx); + assert!(this.session_setup_pending); + + this.send_text("second".to_string(), cx); + + assert_eq!( + this.pending_first_send, + Some(FirstSend::Message { + text: "first".to_string(), + steer: false, + }) + ); + assert!(this.notice.is_some()); + // "New Task" cannot supersede the create either. + let generation = this.selection_generation; + this.new_session(cx); + assert_eq!(this.selection_generation, generation); + }); + } + + /// A failed create leaves nothing behind and gives the message back. + #[gpui::test] + fn test_failed_create_keeps_the_draft(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + let screen = screen(cx); + screen.update(cx, |this, cx| { + this.booting = false; + this.selected_session = None; + this.project_root = Some("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/work/alpha".to_string()); + this.send_text("hello".to_string(), cx); + assert!(this.session_setup_pending); + + this.fail_new_session("no runtime".to_string(), cx); + + assert!(!this.session_setup_pending); + assert!(this.pending_first_send.is_none()); + assert_eq!(this.selected_session, None); + assert_eq!( + this.notice.as_ref().map(SharedString::as_ref), + Some("no runtime") + ); + }); + } + + /// Commands that act on a task, and `/btw`, create the task first on + /// the empty screen; commands that do not need one run at once. + #[gpui::test] + fn test_commands_on_the_empty_screen_create_the_task_first(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + let screen = screen(cx); + screen.update(cx, |this, cx| { + this.booting = false; + this.selected_session = None; + this.project_root = Some("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/work/alpha".to_string()); + this.web_enabled = true; + + // No task needed: the draft's web chip flips on screen only. + assert!(this.try_command(None, "/web", cx)); + assert!(!this.web_enabled); + assert!(!this.session_setup_pending); + + this.send_text("/btw what is this".to_string(), cx); + assert!(this.session_setup_pending); + assert_eq!( + this.pending_first_send, + Some(FirstSend::SideQuestion("what is this".to_string())) + ); + this.fail_new_session("no runtime".to_string(), cx); + + this.slash_commands = vec![AgentSlashCommand { + name: "deploy".to_string(), + description: "Deploy the app".to_string(), + input_hint: None, + }]; + assert!(this.try_command(None, "/deploy staging", cx)); + assert_eq!( + this.pending_first_send, + Some(FirstSend::Command("/deploy staging".to_string())) + ); + // Unknown commands still fall through to a normal send. + this.fail_new_session("no runtime".to_string(), cx); + assert!(!this.try_command(None, "/definitely-not-a-command", cx)); + }); + } + + /// Chip changes on the empty screen are draft state: nothing is + /// persisted, and the create applies them once the task exists. + #[gpui::test] + fn test_draft_chips_change_only_the_screen(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + let screen = screen(cx); + screen.update(cx, |this, cx| { + this.selected_session = None; + this.web_enabled = true; + this.set_web_enabled(false, cx); + assert!(!this.web_enabled); + + this.set_session_mcp(vec![draft_mcp_row(AgentMcpServer { + name: "docs".to_string(), + description: String::new(), + enabled: true, + timeout_seconds: 30, + transport: AgentMcpTransport::Stdio { + command: "docs-mcp".to_string(), + environment: Vec::new(), + }, + })]); + assert_eq!(this.mcp_enabled_count, 1); + this.toggle_session_mcp( + "docs".to_string(), + AgentSessionIntegrationKind::Mcp, + false, + cx, + ); + assert_eq!(this.mcp_enabled_count, 0); + assert!(!this.session_mcp[0].enabled); + assert_eq!( + this.draft_mcp_changes, + vec![DraftMcpChange { + name: "docs".to_string(), + kind: AgentSessionIntegrationKind::Mcp, + enabled: false, + }] + ); + // Toggling back replaces the entry instead of stacking. + this.toggle_session_mcp( + "docs".to_string(), + AgentSessionIntegrationKind::Mcp, + true, + cx, + ); + assert_eq!(this.draft_mcp_changes.len(), 1); + assert!(this.draft_mcp_changes[0].enabled); + // A task selection ends the draft. + this.clear_selected_session_presentation(cx); + assert!(this.draft_mcp_changes.is_empty()); }); } @@ -1968,6 +2196,8 @@ mod state_tests { }); } + /// While a project selection is landing, neither "New Task" nor a + /// first send may run ahead of it. #[gpui::test] fn test_new_task_waits_for_a_project_selection(cx: &mut TestAppContext) { cx.executor().allow_parking(); @@ -1975,9 +2205,17 @@ mod state_tests { screen.update(cx, |this, cx| { this.project_root = Some("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/work/alpha".to_string()); this.root_selecting = true; + let generation = this.selection_generation; this.new_session(cx); - assert!(!this.session_setup_pending); + assert_eq!(this.selection_generation, generation); + assert_eq!(this.selected_session.as_deref(), Some("s1")); assert!(this.notice.is_some()); + + this.booting = false; + this.selected_session = None; + this.send_text("hello".to_string(), cx); + assert!(!this.session_setup_pending); + assert!(this.pending_first_send.is_none()); }); } @@ -3002,6 +3240,10 @@ mod state_tests { this.selected_session = Some("newer".to_string()); this.selection_generation = 2; this.session_setup_pending = true; + this.pending_first_send = Some(FirstSend::Message { + text: "hello".to_string(), + steer: false, + }); this.finish_new_session(summary_at("created", "Created", "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/work/alpha"), 1, cx); @@ -3009,6 +3251,11 @@ mod state_tests { assert_eq!(this.selected_session.as_deref(), Some("newer")); assert_eq!(this.project_root.as_deref(), Some("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/work/beta")); assert!(this.sessions.iter().any(|session| session.id == "created")); + // The message it was created for is not sent into a task the + // user left; it is reported instead. + assert!(this.pending_first_send.is_none()); + assert!(!this.awaiting_first_token); + assert!(this.notice.is_some()); }); } diff --git a/apps/maple-agent/docs/remote-development.md b/apps/maple-agent/docs/remote-development.md index dfc2ad8b7..7b0e236dc 100644 --- a/apps/maple-agent/docs/remote-development.md +++ b/apps/maple-agent/docs/remote-development.md @@ -683,6 +683,11 @@ grayed, so a host that dropped is still visible; their tasks leave the list until the host is back (the task on screen stays readable), and a filter on an offline host is refused with a notice. New tasks go to the host the filter names, else the selected task's host, else the local host. +"New Task" shows an empty draft and creates nothing; the task is created +on the target host when the first message is sent, carrying the draft's +mode, model, web access, and integration toggles, so the host or project +can still change before then and a draft that sends nothing leaves no +row behind. When a host drops after having been online, a notice names it once; the reconnect attempts that follow report nothing more until it is back. An @@ -706,7 +711,8 @@ the host new tasks run on and switches it from a dropdown; the sidebar filter and the selected task move the target too. Both appear only when more than one host is known. Switching the target adopts that host's project root, recent roots, and -session defaults. +session defaults; the draft on screen follows, since no task exists until +its first message is sent. The host the last new task ran on is saved in the client settings and is the target again at the next launch: startup holds the local auto-select From 7a771768b38991c72259a5d84f33cb7803fe71f4 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Mon, 21 Sep 2026 02:12:59 -0500 Subject: [PATCH 36/37] Drop the blank-task workaround for host switches A host switch on the empty screen used to drop the blank task created on the old target and create it again on the new one, so the first message would run where the header said. With the task created only on the first send there is no blank task to move: the empty screen is a draft with no session behind it, and the target host is read when the message is sent. Remove selection_is_blank and its callers. A target change now only adopts the new host's project context and reloads the draft's integration rows; the remembered-host restore at launch no longer checks for a blank local task; and the header's task badge follows the selection directly, since an empty pane means no task rather than a blank one. Co-Authored-By: Claude Fable 5.1 --- apps/maple-agent/app/src/ui/chat/hosts.rs | 48 ++++++++++------------- apps/maple-agent/app/src/ui/chat/mod.rs | 21 +--------- 2 files changed, 22 insertions(+), 47 deletions(-) diff --git a/apps/maple-agent/app/src/ui/chat/hosts.rs b/apps/maple-agent/app/src/ui/chat/hosts.rs index 16abcc5da..714dfdf66 100644 --- a/apps/maple-agent/app/src/ui/chat/hosts.rs +++ b/apps/maple-agent/app/src/ui/chat/hosts.rs @@ -257,7 +257,7 @@ impl ChatScreen { } self.set_target_host(HostId::local(), cx); if self.selected_session.is_none() { - self.adopt_target_host_context(cx); + self.follow_target_change(cx); } } @@ -492,31 +492,30 @@ impl ChatScreen { let restoring = self.restore_host.as_ref() == Some(&target); if restoring { self.restore_host = None; - if (self.selected_session.is_none() || self.selection_is_blank()) - && self.host_filter.is_none() - { - // A blank task created on the local host meanwhile does - // not pin the target; the remembered host wins. - if self.selection_is_blank() { - self.clear_selected_session_presentation(cx); - } + if self.selected_session.is_none() && self.host_filter.is_none() { self.set_target_host(target.clone(), cx); } } if self.target_host == target { self.recent_roots = boot.recent_roots; self.adopt_target_host_context(cx); - if let Some(detail) = boot + match boot .latest .filter(|_| restoring && self.selected_session.is_none()) { - let summaries = summaries - .into_iter() - .map(|(id, summary)| (id, SharedString::from(summary))) - .collect(); - self.upsert_session(detail.session.clone(), cx); - self.set_active_session(detail.session, detail.timeline, summaries, cx); - self.queue = detail.queue.items; + Some(detail) => { + let summaries = summaries + .into_iter() + .map(|(id, summary)| (id, SharedString::from(summary))) + .collect(); + self.upsert_session(detail.session.clone(), cx); + self.set_active_session(detail.session, detail.timeline, summaries, cx); + self.queue = detail.queue.items; + } + // The draft on screen is for this host: its chip lists + // what a task created there starts with. + None if self.selected_session.is_none() => self.refresh_draft_mcp(cx), + None => {} } } } @@ -667,18 +666,13 @@ impl ChatScreen { cx.notify(); } - /// The target moved: show its project and defaults. A blank task on - /// screen was created on the old target the moment "New Task" was - /// clicked; leave it behind and create the task again on the new - /// one, so the first message runs where the header says. + /// The target moved: show its project and defaults. No task exists + /// until the first message is sent, so the draft on screen simply + /// follows; the message will run where the header says. pub(super) fn follow_target_change(&mut self, cx: &mut Context) { - let blank = self.selection_is_blank(); - if blank { - self.clear_selected_session_presentation(cx); - } self.adopt_target_host_context(cx); - if blank { - self.new_session(cx); + if self.selected_session.is_none() { + self.refresh_draft_mcp(cx); } } diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index 1b8d1aceb..9ea247268 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -1559,19 +1559,6 @@ impl ChatScreen { } } - /// A task on screen that nothing has happened to yet: no messages, - /// no run, no load in flight. - pub(super) fn selection_is_blank(&self) -> bool { - match self.selected_session.as_deref() { - Some(selected) => { - self.timeline.is_empty() - && self.loading_session.is_none() - && !self.active_runs.contains_key(selected) - } - None => false, - } - } - /// "New Task": show the empty screen for the target host's project and /// create nothing. The task is created on the target the moment the /// first message is sent, so a host or project switched before then @@ -5095,14 +5082,8 @@ impl ChatScreen { /// Cache the host the task on screen belongs to. The header names it /// while a task is open, whatever host new tasks target, so the chip /// never implies a local task runs on the remote host it was switched - /// to. + /// to. The new-task screen has no task, so it shows the target chip. fn refresh_selected_host(&mut self) { - if self.timeline.is_empty() && self.loading_session.is_none() { - // The new-task screen, even when a blank task already backs - // it: the header offers the target chip, not a badge. - self.selected_host = None; - return; - } self.selected_host = self.selected_session.as_deref().map(|selected| { let owner = self.host_of(selected); let name = SharedString::from(self.host_name(&owner)); From c80c871cb950ab8b41dc94f1163fb75fa2fa5092 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Mon, 21 Sep 2026 02:36:54 -0500 Subject: [PATCH 37/37] Treat an empty task as the draft it is Older builds persisted a task on every New Task click, so hosts still hold empty tasks. The boot auto-select opened the newest task under the visible project without caring that it was empty, and the header then showed it as an open task: a badge for the host it sat on instead of the chip that picks a host, over a pane that looked like the new-task screen. A host switch would also have left the first message on that task's host. The auto-select now skips empty tasks on both the client and the host side, an empty selected task shows the target chip like the new-task screen, and switching the target leaves such a task behind so the send creates one on the new host. Co-Authored-By: Claude Fable 5.1 --- apps/maple-agent/app/src/ui/chat/hosts.rs | 6 +++ apps/maple-agent/app/src/ui/chat/mod.rs | 24 +++++++++++ apps/maple-agent/app/src/ui/chat/tests.rs | 40 +++++++++++++++++++ .../crates/maple-agent/src/host/local/mod.rs | 6 ++- 4 files changed, 75 insertions(+), 1 deletion(-) diff --git a/apps/maple-agent/app/src/ui/chat/hosts.rs b/apps/maple-agent/app/src/ui/chat/hosts.rs index 714dfdf66..51900f3b6 100644 --- a/apps/maple-agent/app/src/ui/chat/hosts.rs +++ b/apps/maple-agent/app/src/ui/chat/hosts.rs @@ -670,6 +670,12 @@ impl ChatScreen { /// until the first message is sent, so the draft on screen simply /// follows; the message will run where the header says. pub(super) fn follow_target_change(&mut self, cx: &mut Context) { + if self.selection_is_draft() { + // An empty task pinned to the old target would take the first + // message there; leave it and let the send create the task + // on the new one. + self.clear_selected_session_presentation(cx); + } self.adopt_target_host_context(cx); if self.selected_session.is_none() { self.refresh_draft_mcp(cx); diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index 9ea247268..4dd9e4a7d 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -1548,7 +1548,10 @@ impl ChatScreen { .sessions .iter() .find(|session| { + // An empty task (a draft an older build persisted) is not + // worth opening; the draft screen is the same thing. !session.archived + && session.message_count > 0 && Some(&session.project_root) == root.as_ref() && self.host_of(&session.id) == self.target_host }) @@ -1564,6 +1567,21 @@ impl ChatScreen { /// first message is sent, so a host or project switched before then /// moves it, and a click that sends nothing leaves no empty row on /// any host. + /// A selected task nothing has happened to: no messages, no run, no + /// load in flight. Older builds persisted one on every New Task + /// click, so such tasks still exist and can be opened. The screen + /// treats one like the draft it is. + pub(super) fn selection_is_draft(&self) -> bool { + match self.selected_session.as_deref() { + Some(selected) => { + self.timeline.is_empty() + && self.loading_session.is_none() + && !self.active_runs.contains_key(selected) + } + None => false, + } + } + pub(super) fn new_session(&mut self, cx: &mut Context) { // The first send is creating its task; a click now would // supersede it and lose the message. @@ -5084,6 +5102,12 @@ impl ChatScreen { /// never implies a local task runs on the remote host it was switched /// to. The new-task screen has no task, so it shows the target chip. fn refresh_selected_host(&mut self) { + if self.selection_is_draft() { + // Nothing has happened to the task on screen: the header + // offers the target chip, as on the new-task screen. + self.selected_host = None; + return; + } self.selected_host = self.selected_session.as_deref().map(|selected| { let owner = self.host_of(selected); let name = SharedString::from(self.host_name(&owner)); diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index 20e48ea47..4a0329b8f 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -1895,6 +1895,46 @@ mod state_tests { }); } + /// An empty task persisted by an older build is neither opened by the + /// boot auto-select nor shown as an open task: with one selected the + /// header offers the target chip, and a host switch leaves it behind. + #[gpui::test] + fn test_an_empty_task_counts_as_a_draft(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + let screen = screen(cx); + screen.update(cx, |this, cx| { + let remote = HostId::new("remote-key".to_string()); + let remote_backend = this.backend.local_host("other") as Arc; + let mut entry = ChatHost::local(remote_backend); + entry.name = "Box".to_string(); + this.hosts.insert(remote.clone(), entry); + this.hosts_changed(); + this.project_root = Some("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/work/local".to_string()); + this.selected_session = None; + + // The auto-select skips the empty task and starts a draft. + let mut empty = summary_at("blank", "New Task", "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/work/local"); + empty.message_count = 0; + let requested = this.selection_generation; + this.apply_session_list(vec![empty.clone()], requested, cx); + assert_eq!(this.selected_session, None); + + // Opened by hand, it still reads as a draft. + this.selected_session = Some("blank".to_string()); + this.replace_timeline(Vec::new()); + assert!(this.selection_is_draft()); + assert_eq!( + this.selected_host, None, + "the header offers the target chip" + ); + + // A host switch leaves the empty task behind. + this.pick_host(remote.clone(), cx); + assert_eq!(this.target_host, remote); + assert_eq!(this.selected_session, None); + }); + } + /// A remembered host that stays offline releases startup to the local /// auto-select instead of holding the window empty. #[gpui::test] diff --git a/apps/maple-agent/crates/maple-agent/src/host/local/mod.rs b/apps/maple-agent/crates/maple-agent/src/host/local/mod.rs index d16554135..7a94241d1 100644 --- a/apps/maple-agent/crates/maple-agent/src/host/local/mod.rs +++ b/apps/maple-agent/crates/maple-agent/src/host/local/mod.rs @@ -245,7 +245,11 @@ impl HostBackend for LocalHostBackend { let latest_id = sessions .iter() .find(|session| { - !session.archived && Some(&session.project_root) == project_root.as_ref() + // An empty task is a draft an older build persisted; the + // client's own draft screen stands in for it. + !session.archived + && session.message_count > 0 + && Some(&session.project_root) == project_root.as_ref() }) .map(|session| session.id.clone()); let latest = match latest_id {