From 860a2ab33998cac235b71780dc53dd4f83def5e0 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:54:27 +0000 Subject: [PATCH 1/8] Share Maple Agent desktop and ACP through a local host --- apps/maple-agent/Cargo.lock | 4 + apps/maple-agent/README.md | 62 +- apps/maple-agent/app/Cargo.toml | 8 +- apps/maple-agent/app/src/backend.rs | 3117 ++--------------- apps/maple-agent/app/src/backend/local.rs | 2852 +++++++++++++++ apps/maple-agent/app/src/billing.rs | 6 +- apps/maple-agent/app/src/desktop.rs | 10 + apps/maple-agent/app/src/host.rs | 642 ++++ apps/maple-agent/app/src/host/ipc.rs | 370 ++ apps/maple-agent/app/src/host/protocol.rs | 131 + apps/maple-agent/app/src/host/receipts.rs | 153 + apps/maple-agent/app/src/main.rs | 32 +- apps/maple-agent/app/src/ui/chat/commands.rs | 3 +- apps/maple-agent/app/src/ui/chat/mod.rs | 96 +- apps/maple-agent/app/src/ui/chat/tests.rs | 120 +- apps/maple-agent/app/src/ui/login.rs | 20 +- .../app/src/ui/settings/navigation.rs | 10 +- .../crates/maple-agent/src/acp/convert.rs | 50 +- .../crates/maple-agent/src/acp/handler.rs | 20 +- .../crates/maple-agent/src/acp/mod.rs | 291 +- .../crates/maple-agent/src/acp/session.rs | 27 +- .../crates/maple-agent/src/acp/shared.rs | 345 ++ .../crates/maple-agent/src/acp/tests.rs | 184 +- .../crates/maple-agent/src/agent.rs | 58 +- .../maple-agent/src/agent/attachments.rs | 2 +- .../crates/maple-agent/src/agent/cua/task.rs | 19 +- .../src/agent/external_agents/mod.rs | 1 + .../src/agent/external_agents/task.rs | 31 +- .../src/agent/integrations/tests.rs | 41 + .../crates/maple-agent/src/agent/mcp/task.rs | 20 +- .../crates/maple-agent/src/agent/provider.rs | 2 +- .../crates/maple-agent/src/agent/questions.rs | 97 +- .../crates/maple-agent/src/agent/runs.rs | 243 +- .../crates/maple-agent/src/agent/runtime.rs | 208 +- .../crates/maple-agent/src/agent/surface.rs | 55 +- .../maple-agent/src/agent/surface/tests.rs | 59 +- .../crates/maple-agent/src/agent/tasks.rs | 83 +- .../crates/maple-agent/src/agent/tests.rs | 2 +- .../crates/maple-agent/src/agent/thinking.rs | 9 + .../crates/maple-agent/src/agent/timeline.rs | 11 + .../crates/maple-agent/src/agent/types.rs | 93 +- .../crates/maple-agent/src/maple_api.rs | 8 +- .../crates/maple-agent/src/private_file.rs | 2 +- .../crates/maple-billing/src/lib.rs | 8 +- apps/maple-agent/docs/local-host.md | 103 + apps/maple-agent/docs/scenario-checklist.md | 11 + 46 files changed, 6206 insertions(+), 3513 deletions(-) create mode 100644 apps/maple-agent/app/src/backend/local.rs create mode 100644 apps/maple-agent/app/src/host.rs create mode 100644 apps/maple-agent/app/src/host/ipc.rs create mode 100644 apps/maple-agent/app/src/host/protocol.rs create mode 100644 apps/maple-agent/app/src/host/receipts.rs create mode 100644 apps/maple-agent/crates/maple-agent/src/acp/shared.rs create mode 100644 apps/maple-agent/docs/local-host.md diff --git a/apps/maple-agent/Cargo.lock b/apps/maple-agent/Cargo.lock index 069b755d8..c54e046eb 100644 --- a/apps/maple-agent/Cargo.lock +++ b/apps/maple-agent/Cargo.lock @@ -5211,6 +5211,7 @@ dependencies = [ "gpui_platform", "httparse", "image", + "libc", "log", "maple-agent", "maple-billing", @@ -5228,8 +5229,11 @@ dependencies = [ "semver", "serde", "serde_json", + "sha2 0.10.9", "spellbook", + "tempfile", "tokio", + "tokio-util", "tower-http", "unicode-segmentation", "uuid", diff --git a/apps/maple-agent/README.md b/apps/maple-agent/README.md index f12556450..5a491829c 100644 --- a/apps/maple-agent/README.md +++ b/apps/maple-agent/README.md @@ -35,13 +35,12 @@ 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. +`app/src/backend.rs` is the asynchronous client facade. `app/src/host.rs` +starts or joins one OS-local host; `backend/local.rs` owns account credentials, +local persistence, and the Maple runtime inside that host. GPUI owns presentation +and native permission prompts. The ACP adapter forwards stdio to the same host, +which projects shared task events onto ACP. Pi remains the agent loop; it has no +knowledge of frontends or IPC. See [local host](docs/local-host.md). The runtime began as a copy of Research’s Tauri agent (now `apps/maple-research/frontend/src-tauri/src`) with Tauri removed, and now runs @@ -397,6 +396,7 @@ maple-agent Open the desktop app. maple-agent acp Serve the Agent Client Protocol on stdio. maple-agent proxy [FLAGS] Serve an OpenAI-compatible HTTP endpoint. maple-agent login Sign in with email and password from a terminal. +maple-agent host stop Stop this profile's host and its running tasks. maple-agent --version Print the version. ``` @@ -410,26 +410,33 @@ desktop-only for now. ### `maple-agent acp` -Runs a standalone ACP agent over stdio for editors and ACP clients. It -reuses the sign-in saved by the desktop app and hosts its own runtime, so -the desktop app does not need to run. Logs go to the log file only; stdout -is the ACP channel. If no sign-in is saved, it exits with a message that -tells the user to sign in from the desktop app first. - -Each ACP session is a Maple task. `session/new` creates one in the client's -working directory; session lists show it from its first prompt, and one -closed before that is removed. `session/load` opens any task of that -directory and replays its history. While a session is open its task runs -for that client only: its commands keep the PATH the client started Maple -with, the client's HTTP MCP servers run beside the account's, and the -desktop's own tools (the plan, questions, external agents and computer use) -are left out. Tasks created over ACP stay out of the desktop task list. -Maple has no session modes: every tool call runs without asking, and a -project with skills or instructions of its own asks the client's user once -whether to trust it. Use a task in one place at a time: the desktop app and -`maple-agent acp` are separate processes, and running one task from both at -once can split its history. Both parts stay in the task's file, but only -one shows. +Connects editors and ACP clients to the shared local host over stdio. It +can start the host without opening a window; desktop later joins that host. +Both interfaces use the same sign-in, tasks, tools, history, and one active run +per task. Logs go to the profile log; stdout contains only ACP frames. +Sign in through desktop or `maple-agent login` first. + +`session/new` creates a draft in the client's working directory. Its first +accepted prompt makes it visible in desktop and ACP lists. `session/load` +replays a task's history, including the partial answer of a running task. +Closing the ACP pipe detaches the client. Explicit `session/cancel` stops shared +work; the optional `session/close` capability is not advertised because it means +cancel in ACP. Some clients send cancel themselves when closing a turn. + +Model and supported thinking levels use standard configuration options. Maple +also supports the `_session/steering` extension used by the Codex and Claude ACP +adapters: it injects into an active run at Pi's next steering opportunity. +Ordinary overlapping `session/prompt` calls retain ACP's busy response; clients +should use the advertised steering extension. Questions use negotiated form +elicitation or a connected desktop; with neither, the tool asks the agent to use +plain text. Project trust remains an explicit choice; tools do not ask for +individual approval and there are no session modes. + +Client-supplied HTTP MCP bindings are temporary and become unavailable on +disconnect. Conflicting server names are refused. Transient stdio MCP injection +remains disabled; configure local stdio servers in Maple. Tools execute on the +host's filesystem with its normal login-shell PATH. See [lifecycle and client +compatibility](docs/local-host.md) before configuring an ACP launcher. ### `maple-agent proxy` @@ -503,6 +510,7 @@ shows unpackaged builds; packaged Dev and Prod append `maple-agent-dev` and | `/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//AGENTS.md`, `skills/`, `prompts/`, `SYSTEM.md` | The account's own instructions, skills and prompt templates, as Pi's agent folder holds them, and a `SYSTEM.md` that replaces Pi's default prompt ahead of Maple's opening instructions. | +| `/send-receipts.db` | Private send admission/results for reconnect reconciliation; no prompt contents. | | `/auth.json` | Sign-in credentials (mode 0600). Device-local; never in a roaming profile. | | `/agent/accounts//integrations.json` | Per-account defaults for the integrations on this device. | | `/agent/accounts//sessions/` | One session file (JSONL) per task, and `tasks.db`, the task index (SQLite, WAL). | diff --git a/apps/maple-agent/app/Cargo.toml b/apps/maple-agent/app/Cargo.toml index 50fd76ad8..80c304c6a 100644 --- a/apps/maple-agent/app/Cargo.toml +++ b/apps/maple-agent/app/Cargo.toml @@ -50,6 +50,8 @@ rand = "0.8.6" tokio = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +sha2 = "0.10" +tokio-util = { workspace = true } semver = "1" log = { workspace = true } env_logger = "0.11" @@ -78,10 +80,14 @@ tower-http = { version = "0.6", features = ["cors"], optional = true } [target.'cfg(target_os = "linux")'.dependencies] wayland-client = { version = "0.31", optional = true } +[target.'cfg(unix)'.dependencies] +libc = "0.2" + [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.61", features = ["Win32_Networking_WinSock"] } +windows-sys = { version = "0.61", features = ["Win32_Networking_WinSock", "Win32_Security_Authorization", "Win32_Foundation", "Win32_System_Threading"] } [dev-dependencies] +tempfile = "3" parking_lot = "0.12" # The gpui test harness. Keep this out of [dependencies]: with test-support on, # App::shutdown skips the on_app_quit futures and logs a timeout on every quit. diff --git a/apps/maple-agent/app/src/backend.rs b/apps/maple-agent/app/src/backend.rs index fbdb52a99..8a759c420 100644 --- a/apps/maple-agent/app/src/backend.rs +++ b/apps/maple-agent/app/src/backend.rs @@ -1,797 +1,202 @@ -//! Backend boundary 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 module is the desktop frontend's boundary. A headless build (no -// `desktop` feature) uses only a few entry points, so the rest is unused -// there by design. #![cfg_attr(not(feature = "desktop"), allow(dead_code))] -use std::collections::HashMap; -use std::future::Future; -use std::path::{Path, PathBuf}; -#[cfg(target_os = "macos")] -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, AgentTaskState, MapleAgentHostResources, - MapleAgentService, RecentProjectRoot, -}; -use maple_agent::maple_api::{ - MapleApiAuthEventSink, MapleApiAuthRequest, MapleApiAuthSnapshot, MapleApiAuthState, -}; -use maple_agent::open_secret_config::configured_pcr0_environment; -use maple_sdk::OpenSecretClient; -use tokio::runtime::Runtime; -use tokio::sync::mpsc; -use uuid::Uuid; - -use crate::hosted_oauth::{ATTEMPT_TIMEOUT, HandoffListener}; - -#[derive(Debug, Clone)] -pub struct PendingQuestion { - pub session_id: String, - pub request_id: String, - /// One or more related questions answered together in one card. - pub questions: Vec, -} +//! Desktop and CLI facade for the profile's shared local host. +//! The host implementation retains the existing backend operations in `local`; +//! only this module knows about IPC. GPUI never owns a second agent runtime. -// Re-exported for the settings screens; a headless build has no reader. #[cfg_attr(not(feature = "desktop"), allow(unused_imports))] -pub use maple_agent::maple_api::{ - MapleAccount, MapleAccountError, MapleApiKey, MapleApiKeyCreated, MapleLoginMethod, -}; - -/// The signed-in account identity. -#[derive(Debug, Clone)] -pub struct AuthSession { - pub user_id: String, -} - -/// Result of the background credential validation started by -/// [`AgentBackend::restore_in_background`]. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum RestoreOutcome { - /// The server accepted the saved credentials; the session is installed. - Valid(String), - /// The server rejected the saved credentials; they were cleared. - Rejected, - /// Offline, timeout, or a server fault. The credentials may still be - /// good, so they are kept for the next launch. - Unavailable, -} +pub use local::*; +pub(crate) mod local; -/// 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, -} +use crate::host::{RemoteClient, protocol::Request}; +use maple_agent::agent::*; +use serde::{Serialize, de::DeserializeOwned}; +use std::{collections::HashMap, future::Future, sync::Arc}; +use tokio::{runtime::Runtime, sync::mpsc}; pub struct AgentBackend { runtime: Runtime, - service: MapleAgentService, - auth: MapleApiAuthState, + client: BackendClient, api_url: String, - persisted_auth: Arc, - pending_oauth: Arc, - client_id: Uuid, - event_rx: tokio::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 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: ( - tokio::sync::watch::Sender, - tokio::sync::watch::Receiver, - ), -} - -fn configured_client_id() -> Uuid { - client_id_from(Some(&crate::profile::runtime_value( - "MAPLE_CLIENT_ID", - crate::profile::CLIENT_ID, - ))) -} - -/// The client id to send: local `MAPLE_CLIENT_ID` when it is a UUID, else the -/// compiled public project id. A malformed local override is logged instead of silently -/// pointing a test build at production. -fn client_id_from(configured: Option<&str>) -> Uuid { - let default = || DEFAULT_CLIENT_ID.parse().expect("valid uuid"); - let Some(value) = configured else { - return default(); - }; - match value.parse() { - Ok(id) => id, - Err(error) => { - log::warn!( - "MAPLE_CLIENT_ID {value:?} is not a UUID ({error}); using the default client id" - ); - default() - } - } -} - -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 = crate::profile::DATA_NAMESPACE; -/// Directory name used before the package rename. An existing directory is -/// adopted in place on first start; see [`adopt_legacy_app_dirs`]. -const LEGACY_APP_DIR_NAME: &str = "maple-gpui"; - -/// Rename state written under the previous directory name into the current -/// one, once per distinct root, before anything opens files. Only a missing -/// current directory is filled: an already-current or partially migrated -/// layout is never touched. Returns one line per root for the caller to log -/// once logging is up. -pub fn adopt_legacy_app_dirs() -> Vec { - if crate::profile::PACKAGED { - return Vec::new(); - } - let mut notes = Vec::new(); - let mut seen: Vec = Vec::new(); - for root in [config_root(), local_data_root()] { - if seen.contains(&root) { - continue; - } - if let Some(note) = adopt_legacy_app_dir(&root, !crate::profile::PACKAGED) { - notes.push(note); - } - seen.push(root); - } - notes -} - -fn adopt_legacy_app_dir(root: &Path, allow_legacy: bool) -> Option { - if !allow_legacy { - return None; - } - let legacy = root.parent()?.join(LEGACY_APP_DIR_NAME); - if root.exists() || !legacy.is_dir() { - return None; - } - Some(match std::fs::rename(&legacy, root) { - Ok(()) => format!("adopted {} as {}", legacy.display(), root.display()), - Err(error) => format!( - "could not adopt {} as {}: {error}", - legacy.display(), - root.display() - ), - }) -} - -/// Maple's public OpenSecret project id. The backend rejects unknown -/// client ids, so this must match the registered project. -const DEFAULT_CLIENT_ID: &str = crate::profile::CLIENT_ID; - -/// OAuth providers supported by the OpenSecret backend. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum OAuthProvider { - Github, - Google, - Apple, -} - -impl OAuthProvider { - pub fn label(self) -> &'static str { - match self { - Self::Github => "GitHub", - Self::Google => "Google", - Self::Apple => "Apple", - } - } } -const OAUTH_CANCELLED_MESSAGE: &str = "Sign in was cancelled. Start again."; - -/// V2 binds the provider callback to the SDK session that started it. Keep -/// that exact client until the one permitted completion, and revoke work -/// already in flight when the user cancels or starts another sign-in. -struct OAuthAttempt { - provider: OAuthProvider, - client: Arc, - cancelled: tokio::sync::watch::Sender, -} - -impl OAuthAttempt { - async fn while_active( - &self, - operation: impl Future>, - ) -> Result { - let mut cancelled = self.cancelled.subscribe(); - if *cancelled.borrow() { - return Err(OAUTH_CANCELLED_MESSAGE.to_string()); - } - tokio::select! { - biased; - _ = cancelled.changed() => Err(OAUTH_CANCELLED_MESSAGE.to_string()), - result = operation => result, - } - } +enum BackendClient { + Host(RemoteClient), + #[cfg(test)] + InProcess(Box), } -struct PendingOAuth { - attempt: Arc, - state: Option, - completing: bool, - handoff: Option, +// Borrowed facade parameters become owned messages at the process boundary. +macro_rules! wire_type { + ((&str)) => { String }; + ((Option<&str>)) => { Option }; + (($ty:ty)) => { $ty }; } - -struct HostedHandoff { - listener: HandoffListener, - prepared: maple_sdk::PreparedNativeOAuthHandoff, +macro_rules! local_arg { + ($value:ident, (&str)) => { + &$value + }; + ($value:ident, (Option<&str>)) => { + $value.as_deref() + }; + ($value:ident, ($ty:ty)) => { + $value + }; } - -#[derive(Default)] -struct PendingOAuthStore(std::sync::Mutex>); - -impl PendingOAuthStore { - fn begin(&self, provider: OAuthProvider, client: OpenSecretClient) -> Arc { - let attempt = Arc::new(OAuthAttempt { - provider, - client: Arc::new(client), - cancelled: tokio::sync::watch::channel(false).0, - }); - let mut pending = self.0.lock().expect("pending OAuth lock"); - if let Some(previous) = pending.replace(PendingOAuth { - attempt: Arc::clone(&attempt), - state: None, - completing: false, - handoff: None, - }) { - previous.attempt.cancelled.send_replace(true); - } - attempt - } - - fn set_state(&self, attempt: &Arc, state: String) -> Result<(), String> { - let mut pending = self.0.lock().expect("pending OAuth lock"); - let flow = pending - .as_mut() - .filter(|flow| Arc::ptr_eq(&flow.attempt, attempt)) - .ok_or_else(|| OAUTH_CANCELLED_MESSAGE.to_string())?; - flow.state = Some(state); - Ok(()) - } - - fn complete(&self, provider: OAuthProvider, state: &str) -> Result, String> { - let mut pending = self.0.lock().expect("pending OAuth lock"); - let flow = pending - .as_mut() - .filter(|flow| { - flow.attempt.provider == provider - && flow.state.as_deref() == Some(state) - && !flow.completing - && flow.handoff.is_none() - }) - .ok_or_else(|| { - "This callback does not match the pending sign in. Start again.".to_string() - })?; - flow.completing = true; - Ok(Arc::clone(&flow.attempt)) - } - - fn set_handoff( - self: &Arc, - attempt: &Arc, - handoff: HostedHandoff, - ) -> Result<(), String> { - let mut pending = self.0.lock().expect("pending OAuth lock"); - let flow = pending - .as_mut() - .filter(|flow| Arc::ptr_eq(&flow.attempt, attempt)) - .ok_or_else(|| OAUTH_CANCELLED_MESSAGE.to_string())?; - flow.handoff = Some(handoff); - drop(pending); - self.expire_handoff_after(attempt, ATTEMPT_TIMEOUT); - Ok(()) - } - - fn expire_handoff_after( - self: &Arc, - attempt: &Arc, - timeout: std::time::Duration, - ) { - // Expire even if the UI is closed or never begins waiting. Weak - // references cannot keep a backend or completed attempt alive. - let store = Arc::downgrade(self); - let weak_attempt = Arc::downgrade(attempt); - let mut cancelled = attempt.cancelled.subscribe(); - tokio::spawn(async move { - tokio::select! { - _ = cancelled.changed() => {}, - _ = tokio::time::sleep(timeout) => { - if let (Some(store), Some(attempt)) = (store.upgrade(), weak_attempt.upgrade()) { - store.clear(&attempt); - } - } +macro_rules! authorize_arg { + ($backend:ident, user_id, $value:ident) => { + $backend.authorize_account(&$value)?; + }; + ($backend:ident, $name:ident, $value:ident) => {}; +} +macro_rules! operations { + ($( $name:ident ( $( $arg:ident : $ty:tt ),* ) -> $result:ty; )*) => { + impl AgentBackend { + $(pub async fn $name(&self, $($arg: unpack_type!($ty)),*) -> Result<$result, String> { + self.call(stringify!($name), ($($arg,)* )).await + })* + } + async fn dispatch_inner(backend: &LocalBackend, request: Request) -> Result { + match request.method.as_str() { + $(stringify!($name) => { + let ($($arg,)*): ($(wire_type!($ty),)*) = serde_json::from_value(request.params) + .map_err(|_| "Invalid host request parameters".to_string())?; + $(authorize_arg!(backend, $arg, $arg);)* + let value = backend.$name($(local_arg!($arg, $ty)),*).await?; + serde_json::to_value(value).map_err(|_| "Could not encode host response".to_string()) + })* + _ => dispatch_special(backend, request).await, } - }); - } - - fn take_handoff( - &self, - provider: OAuthProvider, - ) -> Result<(Arc, HostedHandoff), String> { - let mut pending = self.0.lock().expect("pending OAuth lock"); - let flow = pending - .as_mut() - .filter(|flow| flow.attempt.provider == provider && !flow.completing) - .ok_or_else(|| OAUTH_CANCELLED_MESSAGE.to_string())?; - let handoff = flow - .handoff - .take() - .ok_or_else(|| OAUTH_CANCELLED_MESSAGE.to_string())?; - flow.completing = true; - Ok((Arc::clone(&flow.attempt), handoff)) - } - - fn clear(&self, attempt: &Arc) { - let mut pending = self.0.lock().expect("pending OAuth lock"); - if pending - .as_ref() - .is_some_and(|flow| Arc::ptr_eq(&flow.attempt, attempt)) - && let Some(flow) = pending.take() - { - flow.attempt.cancelled.send_replace(true); - } - } - - fn cancel(&self) { - if let Some(flow) = self.0.lock().expect("pending OAuth lock").take() { - flow.attempt.cancelled.send_replace(true); } } } - -/// A dropped initiation/completion must not leave a usable pending flow. -/// Clearing is scoped to this attempt so late cleanup preserves a replacement. -struct OAuthAttemptGuard<'a> { - store: &'a PendingOAuthStore, - attempt: &'a Arc, - retain: bool, +macro_rules! unpack_type { + (($ty:ty)) => { + $ty + }; } -impl Drop for OAuthAttemptGuard<'_> { - fn drop(&mut self) { - if !self.retain { - self.store.clear(self.attempt); - } - } +operations! { + login(email: (String), password: (String)) -> AuthSession; + logout_and_clear(user_id: (&str)) -> (); + oauth_start(provider: (OAuthProvider)) -> String; + oauth_wait_for_handoff(provider: (OAuthProvider)) -> AuthSession; + oauth_complete(provider: (OAuthProvider), redirected_url: (String)) -> AuthSession; + account(user_id: (&str)) -> MapleAccount; + request_verification_email(user_id: (&str)) -> (); + change_password(user_id: (&str), current_password: (String), new_password: (String)) -> (); + request_account_deletion(user_id: (&str)) -> String; + confirm_account_deletion(user_id: (&str), confirmation_code: (String), plaintext_secret: (String)) -> (); + list_api_keys(user_id: (&str)) -> Vec; + create_api_key(user_id: (&str), name: (String)) -> MapleApiKeyCreated; + delete_api_key(user_id: (&str), name: (&str)) -> (); + plan_usage(user_id: (&str)) -> Option; + billing_status(user_id: (&str)) -> crate::billing::BillingStatus; + billing_products() -> Vec; + open_billing_portal(user_id: (&str)) -> (); + start_checkout(user_id: (&str), product_id: (String)) -> (); + start_runtime(user_id: (&str), request: (Option)) -> AgentRuntimeStatus; + stop_runtime(user_id: (&str)) -> AgentRuntimeStatus; + recent_project_roots(user_id: (&str)) -> Vec; + select_project_root(user_id: (&str), path: (String)) -> AgentProjectRootRegistration; + list_sessions(user_id: (&str), project_root: (Option)) -> Vec; + local_bootstrap(user_id: (&str)) -> LocalBootstrap; + create_session(user_id: (&str), request: (Option)) -> AgentSessionDetail; + load_session(user_id: (&str), session_id: (&str)) -> AgentSessionDetail; + set_session_state(user_id: (&str), session_id: (&str), state: (AgentTaskState)) -> AgentSessionSummary; + delete_session(user_id: (&str), session_id: (&str)) -> (); + remove_project_root(user_id: (&str), path: (String), fallback: (Option)) -> (); + rename_session(user_id: (&str), session_id: (&str), title: (String)) -> AgentSessionSummary; + project_trust(user_id: (&str), path: (String)) -> AgentProjectTrustStatus; + set_project_trust(user_id: (&str), path: (String), trusted: (bool)) -> AgentProjectTrustStatus; + cancel_queued_message(user_id: (&str), session_id: (&str), queue_id: (&str)) -> AgentDesktopQueueSnapshot; + begin_queued_message_edit(user_id: (&str), session_id: (&str), queue_id: (&str)) -> (); + end_queued_message_edit(user_id: (&str), session_id: (&str), queue_id: (&str)) -> (); + send_message(user_id: (&str), request: (AgentSendMessageRequest)) -> String; + read_image_attachment(user_id: (&str), session_id: (&str), attachment_id: (&str)) -> Vec; + cancel_run(user_id: (&str), run_id: (&str)) -> (); + available_model_ids(user_id: (&str)) -> Vec; + model_catalog_entry(user_id: (&str), model: (&str)) -> Option; + audio_capabilities(user_id: (&str)) -> maple_agent::agent::AudioCapabilities; + synthesize_speech(user_id: (&str), text: (String), voice: (String), speed: (f32)) -> Vec; + transcribe_audio(user_id: (&str), wav: (Vec)) -> String; + list_session_mcp_servers(user_id: (&str), session_id: (&str)) -> Vec; + set_session_mcp_server_enabled(user_id: (&str), session_id: (&str), name: (&str), kind: (maple_agent::agent::AgentSessionIntegrationKind), enabled: (bool)) -> Vec; + list_mcp_servers(user_id: (&str)) -> Vec; + list_integrations(user_id: (&str)) -> Vec; + set_integration_enabled(user_id: (&str), id: (&str), enabled: (bool)) -> Vec; + setup_integration(user_id: (&str), id: (&str), permissions: (AgentIntegrationPermissions)) -> Vec; + save_mcp_servers(user_id: (&str), servers: (Vec)) -> Vec; + set_session_web_enabled(user_id: (&str), session_id: (&str), enabled: (bool)) -> AgentSessionSummary; + compact_session(user_id: (&str), session_id: (&str)) -> (); + session_subagents(user_id: (&str), session_id: (&str)) -> Vec; + cancel_external_agent(user_id: (&str), session_id: (&str), agent_id: (&str)) -> (); + list_slash_commands(user_id: (&str), working_dir: (Option)) -> Vec; + resolve_slash_command(user_id: (&str), working_dir: (Option), command: (String), args: (String)) -> Option; + summarize_tool_call(user_id: (&str), session_id: (&str), tool_name: (String), input: (Option), output_text: (String)) -> Option; + summarize_thinking(user_id: (&str), session_id: (&str), thinking_text: (String)) -> Option; + ask_side_question(user_id: (&str), session_id: (&str), request_id: (String), prior: (Vec), question: (String)) -> (); + context_usage(user_id: (&str), session_id: (&str), model: (Option<&str>)) -> Option<(i64, i64)>; + answer_question(user_id: (&str), request_id: (&str), answer: (String)) -> bool; + thinking_state(user_id: (&str), session_id: (Option<&str>), model: (&str)) -> maple_agent::agent::AgentThinkingState; + set_thinking_level(user_id: (&str), session_id: (Option<&str>), model: (&str), level: (maple_agent::agent::ThinkingLevel)) -> maple_agent::agent::AgentThinkingState; + save_default_model(user_id: (&str), model: (String)) -> (); } 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 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 -/// every platform so tests and portable installs can redirect it. -fn config_root() -> PathBuf { - let base = env_dir("XDG_CONFIG_HOME") - .or_else(dirs::config_dir) - .unwrap_or_else(|| PathBuf::from(".")); - base.join(APP_DIR_NAME) -} - -/// Root for device-local data: session history, attachments, logs, and -/// credentials. Mirrors Tauri's `app_local_data_dir`: `~/.local/share` on -/// Linux, `~/Library/Application Support` on macOS, `%LOCALAPPDATA%` on -/// Windows. `XDG_DATA_HOME` overrides it on every platform. -pub fn local_data_root() -> PathBuf { - let base = env_dir("XDG_DATA_HOME") - .or_else(dirs::data_local_dir) - .unwrap_or_else(|| PathBuf::from(".")); - base.join(APP_DIR_NAME) -} - -fn env_dir(name: &str) -> Option { - std::env::var_os(name) - .map(PathBuf::from) - .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 and the -/// Documents/Maple workspace cannot be created. 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 folder when it still exists and was not -/// removed, otherwise Documents/Maple unless the user removed it. Home is -/// only the last resort. Never the process working directory. The -/// workspace may not exist yet: `gui_start_root` creates it. -fn gui_project_root(config: &maple_agent::agent::AgentConfig) -> Option { - let workspace = maple_agent::agent::default_maple_workspace_path(); - maple_agent::agent::startup_project_root(config, workspace.as_deref()) - .or_else(fallback_project_root) -} - -/// `gui_project_root` for a runtime start, which needs the folder on disk. -/// Documents/Maple is created here, the first time a start uses it. If -/// that fails (Documents access denied, for example), the start uses home. -fn gui_start_root(config: &maple_agent::agent::AgentConfig) -> Option { - let root = gui_project_root(config)?; - if !maple_agent::agent::is_default_maple_workspace(std::path::Path::new(&root)) { - return Some(root); - } - match maple_agent::agent::ensure_default_maple_workspace() { - Ok(path) => Some(path.to_string_lossy().to_string()), - Err(error) => { - log::warn!("{error}"); - fallback_project_root() - } - } -} - -/// Recent projects, with the default Maple workspace offered when the user -/// has not removed it. Existing saved order is left as it is. Listing does -/// not create the folder; choosing or opening it does. -fn offered_project_roots( - config: &maple_agent::agent::AgentConfig, - roots: Vec, -) -> Vec { - let workspace = maple_agent::agent::default_maple_workspace_path(); - maple_agent::agent::include_default_maple_workspace( - roots, - &config.removed_project_roots, - workspace.as_deref(), - ) -} - -#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] -struct PersistedAuthRecord { - user_id: String, - api_url: String, - access_token: String, - refresh_token: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - session_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - revision: Option, -} - -impl PersistedAuthRecord { - fn from_snapshot(api_url: &str, snapshot: &MapleApiAuthSnapshot) -> Self { - Self { - user_id: snapshot.user_id.clone(), - api_url: api_url.to_string(), - access_token: snapshot.access_token.clone(), - refresh_token: snapshot.refresh_token.clone(), - session_id: Some(snapshot.session_id.clone()), - revision: Some(snapshot.revision), - } - } - - fn owns_snapshot(&self, snapshot: &MapleApiAuthSnapshot) -> bool { - self.user_id == snapshot.user_id - && self.session_id.as_deref() == Some(snapshot.session_id.as_str()) - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -struct PersistedAuthOwner { - user_id: String, - session_id: String, - revision: u64, -} - -impl PersistedAuthOwner { - fn from_snapshot(snapshot: &MapleApiAuthSnapshot) -> Self { - Self { - user_id: snapshot.user_id.clone(), - session_id: snapshot.session_id.clone(), - revision: snapshot.revision, - } - } - - fn matches(&self, snapshot: &MapleApiAuthSnapshot) -> bool { - self.user_id == snapshot.user_id && self.session_id == snapshot.session_id - } -} - -#[derive(Default)] -struct PersistedAuthState { - owner: Option, -} - -/// Serializes persisted credential ownership with token-rotation callbacks. -/// A queued callback may update only the exact in-memory auth session that -/// currently owns the file; sign-out compare-clears that same opaque session. -struct PersistedAuthStore { - path: PathBuf, - api_url: String, - state: std::sync::Mutex, -} - -impl PersistedAuthStore { - fn new(path: PathBuf, api_url: String) -> Self { - Self { - path, + /// UI state tests have no authenticated host and must not launch processes + /// or attach to a developer's session. Runtime/IPC contracts are tested separately. + #[cfg(test)] + pub fn for_test(api_url: String, instructions: String) -> Result { + let runtime = Runtime::new().map_err(|e| e.to_string())?; + let guard = runtime.enter(); + let mut backend = LocalBackend::new(api_url.clone(), instructions)?; + backend.ui_fixture = true; + drop(guard); + Ok(Self { + runtime, + client: BackendClient::InProcess(Box::new(backend)), api_url, - state: std::sync::Mutex::new(PersistedAuthState::default()), - } - } - - fn read_record(&self) -> Option { - let bytes = std::fs::read(&self.path).ok()?; - let record: PersistedAuthRecord = serde_json::from_slice(&bytes).ok()?; - (record.api_url == self.api_url).then_some(record) - } - - fn load(&self) -> Option { - let mut state = self - .state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let record = self.read_record()?; - state.owner = record - .session_id - .as_ref() - .map(|session_id| PersistedAuthOwner { - user_id: record.user_id.clone(), - session_id: session_id.clone(), - revision: record.revision.unwrap_or(0), - }); - Some(record) - } - - fn claim_and_persist(&self, snapshot: &MapleApiAuthSnapshot) -> bool { - let mut state = self - .state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - if state - .owner - .as_ref() - .is_some_and(|owner| owner.matches(snapshot) && snapshot.revision < owner.revision) - { - log::debug!( - "ignoring stale persisted auth claim (revision {})", - snapshot.revision - ); - return false; - } - state.owner = Some(PersistedAuthOwner::from_snapshot(snapshot)); - self.write_locked(&PersistedAuthRecord::from_snapshot(&self.api_url, snapshot)) - } - - fn persist_rotation(&self, snapshot: &MapleApiAuthSnapshot) { - let mut state = self - .state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let Some(owner) = state.owner.as_mut() else { - log::debug!( - "ignoring rotated credentials for an unowned persisted auth session (revision {})", - snapshot.revision - ); - return; - }; - if !owner.matches(snapshot) || snapshot.revision < owner.revision { - log::debug!( - "ignoring stale rotated credentials (revision {})", - snapshot.revision - ); - return; - } - owner.revision = snapshot.revision; - self.write_locked(&PersistedAuthRecord::from_snapshot(&self.api_url, snapshot)); - } - - fn clear_if_owned(&self, snapshot: &MapleApiAuthSnapshot) { - let mut state = self - .state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - if state - .owner - .as_ref() - .is_some_and(|owner| owner.matches(snapshot)) - { - state.owner = None; - } - if self - .read_record() - .is_some_and(|record| record.owns_snapshot(snapshot)) - { - self.remove_locked(); - } - } - - fn clear_record_if_unchanged(&self, expected: &PersistedAuthRecord) { - let mut state = self - .state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - if self.read_record().as_ref() != Some(expected) { - return; - } - if state - .owner - .as_ref() - .is_some_and(|owner| expected.session_id.as_deref() == Some(owner.session_id.as_str())) - { - state.owner = None; - } - self.remove_locked(); - } - - fn write_locked(&self, record: &PersistedAuthRecord) -> bool { - if let Err(error) = maple_agent::private_file::write_private_json(&self.path, record) { - log::error!( - "Cannot save credentials to {}: {error}. Sign in is needed again at the next start.", - self.path.display() - ); - return false; - } - true - } - - fn remove_locked(&self) { - if let Err(error) = std::fs::remove_file(&self.path) - && error.kind() != std::io::ErrorKind::NotFound - { - log::error!( - "Cannot remove credentials at {}: {error}", - self.path.display() - ); - } - } -} - -struct PersistAuthSink { - store: Arc, -} - -impl MapleApiAuthEventSink for PersistAuthSink { - fn auth_changed(&self, snapshot: &MapleApiAuthSnapshot) { - // Tokens never reach the log; only the revision does. - log::debug!( - "persisting rotated credentials (revision {})", - snapshot.revision - ); - // Keep this small write synchronous with the session publication. A - // detached writer could run after sign-out and resurrect credentials; - // the store mutex also orders it against compare-and-clear. - self.store.persist_rotation(snapshot); + }) } -} -impl AgentBackend { - pub fn new(api_url: String, harness_instructions: String) -> Result { - // Enforce the credential-bearing URL policy before any client is - // built, including the login-time SDK client. + pub fn new(api_url: String, _harness_instructions: String) -> Result { let api_url = maple_agent::maple_api::validate_api_url(&api_url)?; - let (event_tx, event_rx) = mpsc::unbounded_channel(); - let paths = - maple_agent::agent::AgentPathLayout::from_app_roots(config_root(), local_data_root()); - let service = MapleAgentService::new(MapleAgentHostResources::new( - paths, - Arc::new(ChannelEventSink(event_tx)), - harness_instructions, - )); - let runtime = - Runtime::new().map_err(|error| format!("failed to start runtime: {error}"))?; - let persisted_auth = Arc::new(PersistedAuthStore::new(Self::auth_file(), api_url.clone())); - // reqwest clients must be built inside a Tokio runtime context; one - // built outside never completes a request. - let billing = { - let _guard = runtime.enter(); - crate::billing::BillingClient::new(crate::billing::configured_billing_api_url())? - }; + let runtime = Runtime::new().map_err(|e| format!("Could not start host client: {e}"))?; + let client = runtime.block_on(RemoteClient::connect(&api_url))?; Ok(Self { runtime, - service, - auth: MapleApiAuthState::new(), + client: BackendClient::Host(client), api_url, - persisted_auth, - pending_oauth: Arc::new(PendingOAuthStore::default()), - client_id: configured_client_id(), - event_rx: tokio::sync::Mutex::new(Some(event_rx)), - billing, - billing_tokens: tokio::sync::Mutex::new(HashMap::new()), - 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); - } - - pub fn api_url(&self) -> &str { - &self.api_url + async fn call( + &self, + method: &str, + params: impl Serialize, + ) -> Result { + match &self.client { + BackendClient::Host(client) => client.call(method, params).await, + #[cfg(test)] + BackendClient::InProcess(backend) => { + let value = dispatch_inner( + backend, + Request { + id: 0, + method: method.into(), + send_id: None, + params: serde_json::to_value(params).map_err(|e| e.to_string())?, + }, + ) + .await?; + serde_json::from_value(value).map_err(|e| e.to_string()) + } + } } - - /// The backend runtime, for command-line modes that block on one call. pub fn runtime_handle(&self) -> tokio::runtime::Handle { self.runtime.handle().clone() } - - /// Run a backend future on the backend runtime. The returned handle is a - /// plain future, so the UI executor can await it without owning Tokio. pub fn spawn(&self, future: F) -> tokio::task::JoinHandle where F: Future + Send + 'static, @@ -799,2091 +204,169 @@ 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() { - return Err("Enter an email address".to_string()); - } - Ok(email) - } - - /// Sign in with email and password through the OpenSecret SDK, then hand - /// the validated credentials to the agent runtime. - pub async fn login(&self, email: String, password: String) -> Result { - let email = Self::normalize_email(&email)?; - if password.is_empty() { - return Err("Enter a password".to_string()); + pub async fn take_events(&self) -> Option> { + match &self.client { + BackendClient::Host(client) => client.take_events().await, + #[cfg(test)] + BackendClient::InProcess(backend) => { + let mut source = backend.take_events().await?; + let (sender, receiver) = mpsc::channel(256); + self.spawn(async move { + while let Some(event) = source.recv().await { + if sender.send(event).await.is_err() { + break; + } + } + }); + Some(receiver) + } } - self.cancel_oauth(); - let environment = configured_pcr0_environment()?; - let client = OpenSecretClient::new_with_pcr0_environment(self.api_url.clone(), environment) - .map_err(|_| "Maple API authentication failed".to_string())?; - let response = client - .login(email.clone(), password, self.client_id) - .await - .map_err(|_| "Sign in failed. Check your email and password.".to_string())?; - let user_id = response.id.to_string(); - let snapshot = self - .auth - .set_auth( - self.auth_sink(), - MapleApiAuthRequest { - user_id: user_id.clone(), - api_url: self.api_url.clone(), - access_token: response.access_token, - refresh_token: Some(response.refresh_token), - }, - ) - .await - .map_err(|message| { - // Keep validation detail out of the UI; it can echo the - // configured URL back to the user. - log::debug!("set_auth failed during sign in: {message}"); - "Sign in failed. Try again.".to_string() - })?; - self.persist_auth(&snapshot); - Ok(AuthSession { user_id }) - } - - /// Credentials are device-local, like the web app's `localStorage`. - /// They must not sit in a roaming profile (`%APPDATA%`), so they live in - /// the local data root rather than next to `settings.json`. - fn auth_file() -> std::path::PathBuf { - local_data_root().join("auth.json") } - - fn persist_auth(&self, snapshot: &MapleApiAuthSnapshot) -> bool { - self.persisted_auth.claim_and_persist(snapshot) + pub fn api_url(&self) -> &str { + &self.api_url } - /// The sink the runtime calls when the SDK rotates the token pair - /// during an API call. It writes the new pair to `auth.json` so the - /// next launch does not restore stale tokens. - fn auth_sink(&self) -> Arc { - Arc::new(PersistAuthSink { - store: Arc::clone(&self.persisted_auth), - }) + pub fn saved_user_id(&self) -> Option { + match &self.client { + BackendClient::Host(client) => client.user_id(), + #[cfg(test)] + BackendClient::InProcess(_) => None, + } } - - fn load_persisted_auth(&self) -> Option { - self.persisted_auth.load() + pub fn uses_hosted_oauth(&self) -> bool { + crate::profile::AUTH_ORIGIN.is_some() } - - /// The account id saved by a previous sign-in, without validating it. - /// A local file read, so the UI may show the account's data at once - /// while [`Self::restore_in_background`] validates the credentials. - pub fn saved_user_id(&self) -> Option { - self.load_persisted_auth().map(|record| record.user_id) + pub fn configured_model(&self) -> Option { + std::env::var("MAPLE_MODEL").ok() } - - /// 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()) { - RestoreOutcome::Valid(user_id) => Some(user_id), - RestoreOutcome::Rejected | RestoreOutcome::Unavailable => None, + pub fn default_start_request(&self) -> AgentStartRequest { + AgentStartRequest { + project_root: None, + model: self.configured_model(), } } - /// 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`). pub fn restore_in_background(self: &Arc) -> tokio::task::JoinHandle { - let _ = self.restore_pending.0.send(true); - let this = self.clone(); - self.runtime.spawn(async move { - let outcome = this.validate_persisted_auth().await; - let _ = this.restore_pending.0.send(false); - outcome + let backend = self.clone(); + self.spawn(async move { + backend + .call("restore_auth", ()) + .await + .unwrap_or(RestoreOutcome::Unavailable) }) } - - /// Validate the saved credentials with the server and install the - /// session on success. Definitive rejections clear the saved file. - async fn validate_persisted_auth(&self) -> RestoreOutcome { - self.cancel_oauth(); - let Some(persisted) = self.load_persisted_auth() else { - return RestoreOutcome::Rejected; - }; - let request_record = persisted.clone(); - let request = MapleApiAuthRequest { - user_id: request_record.user_id, - api_url: self.api_url.clone(), - access_token: request_record.access_token, - refresh_token: request_record.refresh_token, - }; - let result = match tokio::time::timeout( - std::time::Duration::from_secs(30), - self.auth.set_auth(self.auth_sink(), request), - ) - .await - { - Ok(Ok(snapshot)) => Ok(snapshot), - Ok(Err(error)) => Err(error), - Err(_) => Err("timeout".to_string()), - }; - match result { - Ok(snapshot) => { - self.persist_auth(&snapshot); - RestoreOutcome::Valid(snapshot.user_id) - } - Err(error) if maple_agent::maple_api::is_auth_rejection(&error) => { - log::debug!("persisted auth rejected: {error:?}"); - self.persisted_auth.clear_record_if_unchanged(&persisted); - RestoreOutcome::Rejected - } - Err(error) => { - // Offline, timeout, or a server fault: the credentials may - // still be good, so keep them for the next launch. - log::warn!("persisted auth could not be validated: {error}"); - RestoreOutcome::Unavailable - } - } - } - - /// 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> { - self.wait_for_restore().await; - self.auth.session_for(user_id).await - } - - async fn wait_for_restore(&self) { - let mut pending = self.restore_pending.1.clone(); - while *pending.borrow() { - if pending.changed().await.is_err() { - break; + pub fn set_harness_instructions(self: &Arc, instructions: String) { + let backend = self.clone(); + self.spawn(async move { + if let Err(error) = backend + .call::<()>("set_harness_instructions", (instructions,)) + .await + { + log::warn!("Could not update host instructions: {error}"); } - } - } - - /// Sign out completely: invalidate the live session first, then remove - /// only the persisted credentials that this sign-out observed. - pub async fn logout_and_clear(&self, user_id: &str) -> Result<(), String> { - self.clear_session(user_id, true).await + }); } - - /// Drop the live session and the persisted record. `revoke` also sends - /// `POST /logout`; a deleted account has no session left to report. - async fn clear_session(&self, user_id: &str, revoke: bool) -> Result<(), String> { - self.cancel_oauth(); - self.wait_for_restore().await; - let auth_snapshot = self.auth.auth_snapshot_for(user_id).await.ok(); - let persisted_without_session = auth_snapshot - .is_none() - .then(|| self.load_persisted_auth()) - .flatten(); - - // Report the sign-out to the server first, best effort: an offline - // sign-out must still complete locally, and the session is - // invalidated below whatever the server said. (The backend's - // logout route does not revoke the refresh token yet.) - if revoke - && auth_snapshot.is_some() - && let Ok(session) = self.auth.session_for(user_id).await - { - match tokio::time::timeout(std::time::Duration::from_secs(5), session.logout()).await { - Ok(Ok(())) => {} - Ok(Err(error)) => log::debug!("server logout failed: {error}"), - Err(_) => log::debug!("server logout timed out"), - } - } - - let result = self.auth.clear_auth(user_id).await; - if result.is_ok() { - if let Some(snapshot) = auth_snapshot.as_ref() { - self.persisted_auth.clear_if_owned(snapshot); - } else if let Some(persisted) = persisted_without_session.as_ref() { - // Preserve the old offline/logout behavior without letting a - // concurrent sign-in's replacement record be removed. - self.persisted_auth.clear_record_if_unchanged(persisted); - } - } - result + pub fn cancel_oauth(self: &Arc) { + let backend = self.clone(); + self.spawn(async move { + let _ = backend.call::<()>("cancel_oauth", ()).await; + }); } - - fn oauth_client(&self) -> Result { - let environment = configured_pcr0_environment()?; - OpenSecretClient::new_with_pcr0_environment(self.api_url.clone(), environment) - .map_err(|_| "Maple API authentication failed".to_string()) + pub fn begin_integration_setup(&self, id: &str) -> Result { + // Native permission prompts remain on the signed app's UI thread. + maple_agent::agent::begin_integration_setup(&AgentSetupIntegrationRequest { id: id.into() }) } - - async fn publish_session( + pub fn load_tool_summaries_blocking( &self, - user_id: String, - access_token: String, - refresh_token: Option, - ) -> Result { - self.publish_session_with_persistence(user_id, access_token, refresh_token) - .await - .map(|(session, _)| session) + user_id: &str, + session_id: &str, + ) -> Result, String> { + self.runtime + .block_on(self.call("load_tool_summaries", (user_id, session_id))) } - - async fn publish_session_with_persistence( + pub fn store_tool_summary_blocking( &self, - user_id: String, - access_token: String, - refresh_token: Option, - ) -> Result<(AuthSession, bool), String> { - self.auth - .set_auth( - self.auth_sink(), - MapleApiAuthRequest { - user_id, - api_url: self.api_url.clone(), - access_token, - refresh_token, - }, - ) - .await - .map_err(|message| { - // Keep validation detail out of the UI; it can echo the - // configured URL back to the user. - log::debug!("set_auth failed during sign in: {message}"); - "Sign in failed. Try again.".to_string() - }) - .map(|snapshot| { - let persisted = self.persist_auth(&snapshot); - ( - AuthSession { - user_id: snapshot.user_id.clone(), - }, - persisted, - ) - }) - } - - pub fn uses_hosted_oauth(&self) -> bool { - crate::profile::AUTH_ORIGIN.is_some() + user_id: &str, + session_id: &str, + item_id: &str, + summary: &str, + ) -> Result<(), String> { + self.runtime.block_on(self.call( + "store_tool_summary", + (user_id, session_id, item_id, summary), + )) } - /// Begin an OAuth flow: returns the authorization URL to open in a - /// browser (also opens it via the system browser). - pub async fn oauth_start(&self, provider: OAuthProvider) -> Result { - let attempt = self.pending_oauth.begin(provider, self.oauth_client()?); - let mut guard = OAuthAttemptGuard { - store: &self.pending_oauth, - attempt: &attempt, - retain: false, - }; - let client = &attempt.client; - if let Some(origin) = crate::profile::AUTH_ORIGIN { - let auth_url = attempt - .while_active(async { - let prepared = client - .prepare_native_oauth_handoff() - .await - .map_err(|_| "Could not prepare browser sign-in. Try again.".to_string())?; - let listener = HandoffListener::bind()?; - let provider_name = match provider { - OAuthProvider::Github => "github", - OAuthProvider::Google => "google", - OAuthProvider::Apple => "apple", - }; - let url = listener.start_url( - origin, - provider_name, - prepared.session_id(), - prepared.request_id(), - ); - self.pending_oauth - .set_handoff(&attempt, HostedHandoff { listener, prepared })?; - Ok(url) - }) - .await?; - if webbrowser::open(&auth_url).is_err() { - return Err( - "Could not open your browser. Check your default browser and try again." - .to_string(), - ); - } - guard.retain = true; - return Ok(auth_url); - } - let client_id = self.client_id; - let (auth_url, state) = attempt - .while_active(async { - Ok(match provider { - OAuthProvider::Github => { - let response = client - .initiate_github_auth(client_id, None) - .await - .map_err(|_| "Could not start GitHub sign in".to_string())?; - (response.auth_url, response.state) - } - OAuthProvider::Google => { - let response = client - .initiate_google_auth(client_id, None) - .await - .map_err(|_| "Could not start Google sign in".to_string())?; - (response.auth_url, response.state) - } - OAuthProvider::Apple => { - let response = client - .initiate_apple_auth(client_id, None) - .await - .map_err(|_| "Could not start Apple sign in".to_string())?; - (response.auth_url, response.state) - } - }) - }) - .await?; - self.pending_oauth.set_state(&attempt, state)?; - if webbrowser::open(&auth_url).is_err() { - // No system browser available: the UI still shows the URL. - log::debug!("failed to open system browser for OAuth"); - } - guard.retain = true; - Ok(auth_url) - } - - pub fn cancel_oauth(&self) { - self.pending_oauth.cancel(); - } - - /// Wait on the existing listener and consume exactly the SDK handle that - /// created the advertised target. Cancellation closes the listener/socket; - /// malformed callbacks leave it available until the attempt deadline. - pub async fn oauth_wait_for_handoff( - &self, - provider: OAuthProvider, - ) -> Result { - let (attempt, handoff) = self.pending_oauth.take_handoff(provider)?; - let _guard = OAuthAttemptGuard { - store: &self.pending_oauth, - attempt: &attempt, - retain: false, - }; - attempt - .while_active(async { - let HostedHandoff { listener, prepared } = handoff; - let (grant, browser) = listener.receive(&prepared).await?; - let outcome = async { - let response = attempt - .client - .redeem_native_oauth_handoff(prepared, grant) - .await - .map_err(|_| { - "Browser sign-in failed or expired. Start again.".to_string() - })?; - self.publish_session_with_persistence( - response.id.to_string(), - response.access_token, - Some(response.refresh_token), - ) - .await - } - .await; - drop(listener); - browser.finish( - outcome - .as_ref() - .map(|(_, persisted)| *persisted) - .map_err(|_| ()), - ); - // No await after native publication: a late Back or failed browser - // response must not hide an already committed account from the UI. - outcome.map(|(session, _)| session) - }) - .await - } - - /// Complete an OAuth flow from the redirected URL (pasted by the user or - /// captured from a loopback redirect). - pub async fn oauth_complete( - &self, - provider: OAuthProvider, - redirected_url: String, - ) -> Result { - let Some((code, state)) = parse_oauth_callback(&redirected_url) else { - return Err( - "Paste the full URL you were redirected to (it contains code and state)" - .to_string(), - ); - }; - let attempt = self.pending_oauth.complete(provider, &state)?; - let _guard = OAuthAttemptGuard { - store: &self.pending_oauth, - attempt: &attempt, - retain: false, - }; - attempt - .while_active(async { - let client = &attempt.client; - let response = match provider { - OAuthProvider::Github => client - .handle_github_callback(code, state, String::new()) - .await - .map_err(|_| "GitHub sign in failed".to_string())?, - OAuthProvider::Google => client - .handle_google_callback(code, state, String::new()) - .await - .map_err(|_| "Google sign in failed".to_string())?, - OAuthProvider::Apple => client - .handle_apple_callback(code, state, String::new()) - .await - .map_err(|_| "Apple sign in failed".to_string())?, - }; - self.publish_session( - response.id.to_string(), - response.access_token, - Some(response.refresh_token), - ) - .await - }) - .await - } - - /// The signed-in account's profile from the backend. - pub async fn account(&self, user_id: &str) -> Result { - let session = self.session_for(user_id).await?; - session - .account() - .await - .map_err(|error| account_error_message(error, "Could not load the account")) - } - - /// Email a fresh verification code to the account's address. - pub async fn request_verification_email(&self, user_id: &str) -> Result<(), String> { - let session = self.session_for(user_id).await?; - session - .request_verification_email() - .await - .map_err(|error| account_error_message(error, "Could not send the verification email")) - } - - /// Change the account password. The rotated token pair is persisted - /// through the auth sink before this returns. - pub async fn change_password( - &self, - user_id: &str, - current_password: String, - new_password: String, - ) -> Result<(), String> { - if current_password.is_empty() { - return Err("Enter your current password".to_string()); - } - validate_new_password(&new_password)?; - let session = self.session_for(user_id).await?; - session - .change_password(current_password, new_password) - .await - .map_err(|error| match error { - // The route answers 401 for a wrong current password; a - // dead session would have failed the session lookup first. - MapleAccountError::Unauthorized => "The current password is incorrect".to_string(), - other => account_error_message(other, "Could not change the password"), - }) - } - - /// Start deleting the account: the server emails a confirmation code. - /// Returns the client-held secret the confirmation step must present. - pub async fn request_account_deletion(&self, user_id: &str) -> Result { - let session = self.session_for(user_id).await?; - let (plaintext, hashed) = maple_agent::maple_api::new_confirmation_secret(); - session - .request_account_deletion(hashed) - .await - .map_err(|error| account_error_message(error, "Could not start account deletion"))?; - Ok(plaintext) - } - - /// Delete the account for good. The agent runtime stops first, then - /// the server deletes the account, then the local credentials go. A - /// server failure leaves the session usable. - pub async fn confirm_account_deletion( - &self, - user_id: &str, - confirmation_code: String, - plaintext_secret: String, - ) -> Result<(), String> { - let code = confirmation_code.trim().to_string(); - if code.is_empty() { - 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?; - session - .confirm_account_deletion(code, plaintext_secret) - .await - .map_err(|error| match error { - MapleAccountError::Status(400) => { - "That confirmation code is wrong or has expired".to_string() - } - other => account_error_message(other, "Could not delete the account"), - })?; - if let Err(error) = self.clear_session(user_id, false).await { - log::warn!("local sign-out after account deletion failed: {error}"); - } - Ok(()) - } - - /// The account's API keys, newest first. - pub async fn list_api_keys(&self, user_id: &str) -> Result, String> { - let session = self.session_for(user_id).await?; - let mut keys = session - .list_api_keys() - .await - .map_err(|error| api_key_error_message(error, "Could not load the API keys"))?; - keys.sort_by(|a, b| b.created_at.cmp(&a.created_at)); - Ok(keys) - } - - /// Create an API key named `name`. The key value in the result is the - /// only copy; the server never returns it again. - pub async fn create_api_key( - &self, - user_id: &str, - name: String, - ) -> Result { - let name = validate_api_key_name(&name)?; - let session = self.session_for(user_id).await?; - session - .create_api_key(name) - .await - .map_err(|error| api_key_error_message(error, "Could not create the API key")) - } - - pub async fn delete_api_key(&self, user_id: &str, name: &str) -> Result<(), String> { - let session = self.session_for(user_id).await?; - session - .delete_api_key(name) - .await - .map_err(|error| api_key_error_message(error, "Could not delete the API key")) - } - - /// Plan usage for the sidebar card from the Maple billing API. Returns - /// `None` when the subscription has no token meter. - pub async fn plan_usage( - &self, - user_id: &str, - ) -> Result, String> { - let status = self.billing_status(user_id).await?; - let plan = crate::billing::PlanUsage::from_status(&status, chrono::Local::now()); - log::debug!("plan usage: {plan:?}"); - Ok(plan) - } - - /// The full subscription status for the Billing section. - pub async fn billing_status( - &self, - user_id: &str, - ) -> Result { - self.billing_call(user_id, |billing, token| async move { - billing.subscription_status(&token).await - }) - .await - } - - /// The plans on sale. Public on the billing API, no token needed. - pub async fn billing_products(&self) -> Result, String> { - tokio::time::timeout(std::time::Duration::from_secs(20), self.billing.products()) - .await - .unwrap_or_else(|_| { - Err(crate::billing::BillingError::Other( - "billing request timed out".to_string(), - )) - }) - .map_err(|error| error.to_string()) - } - - /// Open the Stripe customer portal in the system browser. - pub async fn open_billing_portal(&self, user_id: &str) -> Result<(), String> { - let url = self - .billing_call(user_id, |billing, token| async move { - billing - .portal_url(&token, crate::billing::PORTAL_RETURN_URL) - .await - }) - .await - .map_err(|error| billing_failure(error, "Could not open the subscription portal"))?; - open_in_browser(&url, "the subscription portal") - } - - /// Start a Stripe checkout for `product_id` in the system browser. The - /// account email is sent when there is one; guests check out without. - pub async fn start_checkout(&self, user_id: &str, product_id: String) -> Result<(), String> { - let email = self.account(user_id).await?.email.unwrap_or_default(); - let request = Arc::new(crate::billing::CheckoutRequest { - email, - product_id, - success_url: crate::billing::CHECKOUT_SUCCESS_URL.to_string(), - cancel_url: crate::billing::CHECKOUT_CANCEL_URL.to_string(), - quantity: None, - }); - let url = self - .billing_call(user_id, |billing, token| { - let request = Arc::clone(&request); - async move { billing.checkout_url(&token, &request).await } - }) - .await - .map_err(|error| billing_failure(error, "Could not start checkout"))?; - open_in_browser(&url, "checkout") - } - - /// Run one billing request with the cached token, minting a token - /// first when there is none and once more after a 401. - async fn billing_call(&self, user_id: &str, request: F) -> Result - where - F: Fn(crate::billing::BillingClient, String) -> Fut, - Fut: Future>, - { - use crate::billing::BillingError; - let session = self.session_for(user_id).await?; - let cached = self.billing_tokens.lock().await.get(user_id).cloned(); - let mut token = match cached { - Some(token) => token, - None => self.mint_billing_token(&session, user_id).await?, - }; - let mut result = self - .timed_billing(request(self.billing.clone(), token)) - .await; - if matches!(result, Err(BillingError::Unauthorized)) { - // The cached token expired or was revoked: mint one and retry once. - token = self.mint_billing_token(&session, user_id).await?; - result = self - .timed_billing(request(self.billing.clone(), token)) - .await; - } - match result { - Ok(value) => Ok(value), - Err(BillingError::Unauthorized) => { - self.billing_tokens.lock().await.remove(user_id); - Err(BillingError::Unauthorized.to_string()) - } - Err(BillingError::Other(error)) => Err(error), - } - } - - async fn timed_billing( - &self, - request: impl Future>, - ) -> Result { - tokio::time::timeout(std::time::Duration::from_secs(20), request) - .await - .unwrap_or_else(|_| { - Err(crate::billing::BillingError::Other( - "billing request timed out".to_string(), - )) - }) - } - - async fn mint_billing_token( - &self, - session: &Arc, - user_id: &str, - ) -> Result { - let token = session - .third_party_token(self.billing.base_url().to_string()) - .await?; - self.billing_tokens - .lock() - .await - .insert(user_id.to_string(), token.clone()); - 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 Documents/Maple instead. - let request = match request { - Some(AgentStartRequest { - project_root: None, - model, - }) => { - let config = handle.load_config().await?; - Some(AgentStartRequest { - project_root: gui_start_root(&config), - model, - }) - } - 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> { - self.runtime.block_on(async { - 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: - // `initialize` answers immediately and the first `session/new` - // awaits this shared start. - let starting = handle.clone(); - let runtime_start = maple_agent::acp::shared_runtime_start(async move { - starting.start(session, None).await.map(|_| ()) - }); - let config = maple_agent::acp::load_acp_config(&local_data_root(), user_id)?; - let result = maple_agent::acp::serve_stdio(handle.clone(), config, runtime_start).await; - if let Err(error) = handle.stop().await { - log::warn!("failed to stop the agent runtime after ACP: {error}"); - } - result - }) - } - - /// Roots recently used by this account, most recent first. - pub async fn recent_project_roots( - &self, - user_id: &str, - ) -> Result, String> { - let handle = self.service.handle_for_user(user_id).await?; - let config = handle.load_config().await?; - let roots = handle.list_recent_project_roots().await?; - Ok(offered_project_roots(&config, roots)) - } - - /// 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, the - /// process launch directory, and the default Maple workspace 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 { - let handle = self.service.handle_for_user(user_id).await?; - // The listed Maple workspace is created the first time it is chosen. - if maple_agent::agent::is_default_maple_workspace(std::path::Path::new(path.trim())) { - maple_agent::agent::ensure_default_maple_workspace()?; - } - let mut registration = handle.save_recent_project_root(path).await?; - // The screen shows these roots as they are: offer the workspace in - // them the same way the project list does. - registration.roots = offered_project_roots( - ®istration.config, - std::mem::take(&mut registration.roots), - ); - Ok(registration) - } - - 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 = - offered_project_roots(&config, handle.list_recent_project_roots().await?) - .into_iter() - .map(|root| root.path) - .collect(); - // Same choice the screen's auto-select makes: the newest unarchived - // task under the root that the runtime will start in. An empty task - // is a draft an older build persisted; the screen's own new-task - // draft stands in for it. - let latest_id = sessions - .iter() - .find(|session| { - session.state != AgentTaskState::Archived - && session.message_count > 0 - && 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 - } - - /// Move a task between active, settled, and archived. The runtime - /// refuses to settle or archive a task while it runs. - pub async fn set_session_state( - &self, - user_id: &str, - session_id: &str, - state: AgentTaskState, - ) -> Result { - self.service - .handle_for_user(user_id) - .await? - .set_session_state(session_id.to_string(), state) - .await - } - - /// Delete a task and everything stored for it. The runtime refuses - /// while the task runs or an external surface holds it. - pub async fn delete_session(&self, user_id: &str, session_id: &str) -> Result<(), String> { - self.service - .handle_for_user(user_id) - .await? - .delete_session(session_id.to_string()) - .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 - } - - /// What the catalog says about a model: its vision and context window. - /// None when the catalog cannot be read. - pub async fn model_catalog_entry( - &self, - user_id: &str, - model: &str, - ) -> Result, String> { - self.service - .handle_for_user(user_id) - .await? - .model_catalog_entry(model) - .await - } - - /// Which voice endpoints the account offers. - pub async fn audio_capabilities( - &self, - user_id: &str, - ) -> Result { - self.service - .handle_for_user(user_id) - .await? - .audio_capabilities() - .await - } - - /// WAV audio for `text` in the given voice. - pub async fn synthesize_speech( - &self, - user_id: &str, - text: String, - voice: String, - speed: f32, - ) -> Result, String> { - self.service - .handle_for_user(user_id) - .await? - .synthesize_speech(&text, &voice, speed) - .await - } - - /// Transcript text for a WAV recording. - pub async fn transcribe_audio(&self, user_id: &str, wav: Vec) -> Result { - self.service - .handle_for_user(user_id) - .await? - .transcribe_audio(wav) - .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( - &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 - } - - /// Start a curated integration's host-owned permission flow from the UI - /// thread that received the user's setup action. - pub fn begin_integration_setup( - &self, - id: &str, - ) -> Result { - maple_agent::agent::begin_integration_setup(&AgentSetupIntegrationRequest { - id: id.to_string(), - }) - } - - /// 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 - } - - /// 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 or prompt template's prompt; - /// `None` when the command matches neither. - pub async fn resolve_slash_command( - &self, - user_id: &str, - working_dir: Option, - command: String, - args: String, - ) -> Result, String> { - let service = self.service.clone(); - let user_id = user_id.to_string(); - tokio::task::spawn_blocking(move || { - service.resolve_slash_command(Some(&user_id), 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()) - }) - } - - /// A session's context usage: (context tokens, context limit). The - /// tokens are the runtime's count of the task's context; 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> { - if self.account_scope(user_id).is_none() { - return Ok(None); - } - let handle = self.service.handle_for_user(user_id).await?; - 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) => handle - .context_limit_for_model(model) - .await? - .unwrap_or(200_000), - None => 200_000, - }, - }; - let tokens = handle.session_context_tokens(session_id).await?; - Ok(tokens.map(|tokens| (i64::try_from(tokens).unwrap_or(i64::MAX), 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 - } - - /// Standard start request for this app: the saved project root (see - /// `start_runtime`) with the configured model. - pub fn default_start_request(&self) -> AgentStartRequest { - AgentStartRequest { - project_root: None, - model: std::env::var("MAPLE_MODEL").ok(), - } - } - - pub async fn thinking_state( - &self, - user_id: &str, - session_id: Option<&str>, - model: &str, - ) -> Result { - self.service - .handle_for_user(user_id) - .await? - .thinking_state(session_id, model) - .await - } - - pub async fn set_thinking_level( - &self, - user_id: &str, - session_id: Option<&str>, - model: &str, - level: maple_agent::agent::ThinkingLevel, - ) -> Result { - self.service - .handle_for_user(user_id) - .await? - .set_thinking_level(session_id, model, level) - .await - } - - /// 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()) + pub async fn saved_model(&self, user_id: &str) -> Option { + self.call("saved_model", (user_id,)).await.ok().flatten() } } -const MACOS_ACCESSIBILITY_SETTINGS_URL: &str = - "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"; -const MACOS_SCREEN_RECORDING_SETTINGS_URL: &str = - "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture"; - -/// The settings pane that grants the permission the integration still needs. -/// -/// Which permission that is comes from `AgentIntegrationPermissions`, so the -/// pane Maple opens and the notice telling the user what to do there cannot -/// disagree about the order. -fn next_integration_setup_settings_url( - permissions: &AgentIntegrationPermissions, -) -> Option<&'static str> { - match permissions.first_missing()? { - AgentIntegrationPermissionKind::Accessibility => Some(MACOS_ACCESSIBILITY_SETTINGS_URL), - AgentIntegrationPermissionKind::ScreenRecording => { - Some(MACOS_SCREEN_RECORDING_SETTINGS_URL) - } - // The user installs a compositor helper themselves; there is no - // settings pane that grants it. - AgentIntegrationPermissionKind::DesktopHelper => None, - } -} - -async fn open_integration_setup_settings( - permissions: &AgentIntegrationPermissions, -) -> Result<(), String> { - let Some(url) = next_integration_setup_settings_url(permissions) else { - return Ok(()); +async fn dispatch_special( + backend: &LocalBackend, + request: Request, +) -> Result { + let invalid = |_| "Invalid host request parameters".to_string(); + let encode = |value| { + serde_json::to_value(value).map_err(|_| "Could not encode host response".to_string()) }; - #[cfg(target_os = "macos")] - { - let status = - tokio::task::spawn_blocking(move || Command::new("/usr/bin/open").arg(url).status()) - .await - .map_err(|error| format!("Failed to start macOS System Settings: {error}"))? - .map_err(|error| format!("Failed to open macOS System Settings: {error}"))?; - if !status.success() { - return Err(format!( - "Failed to open macOS System Settings (exit status {:?})", - status.code() - )); - } - } - #[cfg(not(target_os = "macos"))] - let _ = url; - Ok(()) -} - -/// Extract `code` and `state` query parameters from an OAuth redirect URL. -/// Values are form-decoded: Google codes carry `%2F`, and a browser may -/// encode a space in `state` as `+`. -fn parse_oauth_callback(url: &str) -> Option<(String, String)> { - let query = url.split_once('?')?.1; - let mut code = None; - let mut state = None; - for pair in query.split(['&', '#']) { - if let Some((key, value)) = pair.split_once('=') { - match key { - "code" => code = Some(decode_query_value(value)), - "state" => state = Some(decode_query_value(value)), - _ => {} - } - } - } - Some((code?, state?)) -} - -/// `application/x-www-form-urlencoded` decoding of one query value. -fn decode_query_value(value: &str) -> String { - let spaced = value.replace('+', " "); - percent_encoding::percent_decode_str(&spaced) - .decode_utf8_lossy() - .into_owned() -} - -/// A user-facing message for a failed billing action; the server detail -/// goes to the log, not the screen. -fn billing_failure(error: String, fallback: &str) -> String { - log::warn!("{fallback}: {error}"); - format!("{fallback}. Try again, or use the pricing page on the web.") -} - -/// Hand a billing URL to the system browser. Only `https` URLs are opened: -/// the billing API is trusted, but a bad deploy must not launch anything -/// else through the browser handler. -pub fn open_in_browser(url: &str, what: &str) -> Result<(), String> { - if !url.starts_with("https://") { - log::warn!("refusing to open a non-https {what} URL"); - return Err(format!("Could not open {what}: unexpected link")); - } - webbrowser::open(url).map_err(|error| { - log::warn!("failed to open {what} in the browser: {error}"); - format!("Could not open {what} in your browser") - }) -} - -/// The server's API key name rule: 1 to 50 characters after trimming. -pub const MAX_API_KEY_NAME_LENGTH: usize = 50; - -pub fn validate_api_key_name(name: &str) -> Result { - let name = name.trim(); - if name.is_empty() { - return Err("Give the key a name".to_string()); - } - if name.chars().count() > MAX_API_KEY_NAME_LENGTH { - return Err(format!( - "Use at most {MAX_API_KEY_NAME_LENGTH} characters for the key name" - )); - } - Ok(name.to_string()) -} - -/// API key errors by status, matching the web app's wording. -fn api_key_error_message(error: MapleAccountError, fallback: &str) -> String { - match error { - MapleAccountError::Unauthorized => "API keys need a Pro, Max, or Team plan".to_string(), - MapleAccountError::Status(409) => "A key with that name already exists".to_string(), - MapleAccountError::Status(400) => "That key name is not allowed".to_string(), - MapleAccountError::Status(404) => "That key no longer exists".to_string(), - MapleAccountError::Status(429) => "You have reached the API key limit".to_string(), - other => account_error_message(other, fallback), - } -} - -/// Minimum password length, the same rule as Maple's web forms. -pub const MIN_PASSWORD_LENGTH: usize = 8; - -/// The web app's password rule: at least eight characters. -pub fn validate_new_password(password: &str) -> Result<(), String> { - if password.chars().count() < MIN_PASSWORD_LENGTH { - return Err(format!( - "Use at least {MIN_PASSWORD_LENGTH} characters for the new password" - )); - } - Ok(()) -} - -/// A user-facing message for an account call that failed. Backend detail -/// stays in the log; the status alone picks the wording. -fn account_error_message(error: MapleAccountError, fallback: &str) -> String { - match error { - MapleAccountError::Unauthorized => { - maple_agent::maple_api::AUTH_REJECTED_MESSAGE.to_string() - } - MapleAccountError::Status(status) => { - log::debug!("account request failed with status {status}"); - format!("{fallback}. Try again.") - } - MapleAccountError::Other(message) => { - log::debug!("account request failed: {message}"); - format!("{fallback}. Check your connection and try again.") - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn oauth_test_client() -> OpenSecretClient { - // Construct the SDK without making any network request. - OpenSecretClient::new("http://127.0.0.1:1").unwrap() - } - - #[tokio::test] - async fn oauth_completion_keeps_the_originating_client_and_consumes_only_its_callback() { - let store = PendingOAuthStore::default(); - let started = store.begin(OAuthProvider::Github, oauth_test_client()); - store - .set_state(&started, "fixture-state".to_string()) - .unwrap(); - - assert!( - store - .complete(OAuthProvider::Google, "fixture-state") - .is_err() - ); - assert!( - store - .complete(OAuthProvider::Github, "other-state") - .is_err() - ); - let completed = store - .complete(OAuthProvider::Github, "fixture-state") - .unwrap(); - assert!(Arc::ptr_eq(&started.client, &completed.client)); - assert!( - store - .complete(OAuthProvider::Github, "fixture-state") - .is_err() - ); - store.clear(&completed); - assert!( - store - .complete(OAuthProvider::Github, "fixture-state") - .is_err() - ); - } - - #[tokio::test] - async fn hosted_deadline_cancels_its_attempt_but_not_a_replacement() { - let store = Arc::new(PendingOAuthStore::default()); - let expired = store.begin(OAuthProvider::Google, oauth_test_client()); - store.expire_handoff_after(&expired, std::time::Duration::from_millis(10)); - let result = tokio::time::timeout( - std::time::Duration::from_secs(1), - expired.while_active(std::future::pending::>()), - ) - .await - .unwrap(); - assert_eq!(result, Err(OAUTH_CANCELLED_MESSAGE.to_string())); - assert!(store.0.lock().unwrap().is_none()); - - let old = store.begin(OAuthProvider::Google, oauth_test_client()); - store.expire_handoff_after(&old, std::time::Duration::from_millis(10)); - let replacement = store.begin(OAuthProvider::Apple, oauth_test_client()); - tokio::time::sleep(std::time::Duration::from_millis(20)).await; - assert!(!*replacement.cancelled.borrow()); - assert!(Arc::ptr_eq( - &store.0.lock().unwrap().as_ref().unwrap().attempt, - &replacement - )); - // Waiting on the hosted path cannot claim or consume a legacy attempt. - assert!(store.take_handoff(OAuthProvider::Apple).is_err()); - store - .set_state(&replacement, "fixture-state".to_string()) - .unwrap(); - assert!( - store - .complete(OAuthProvider::Apple, "fixture-state") - .is_ok() - ); - } - - #[tokio::test] - async fn oauth_replacement_rejects_late_start_and_preserves_new_attempt_during_old_cleanup() { - let store = PendingOAuthStore::default(); - let old = store.begin(OAuthProvider::Github, oauth_test_client()); - let old_guard = OAuthAttemptGuard { - store: &store, - attempt: &old, - retain: false, - }; - let current = store.begin(OAuthProvider::Google, oauth_test_client()); - assert!(store.set_state(&old, "old-state".to_string()).is_err()); - drop(old_guard); - store - .set_state(¤t, "current-state".to_string()) - .unwrap(); - let completed = store - .complete(OAuthProvider::Google, "current-state") - .unwrap(); - assert!(Arc::ptr_eq(&completed.client, ¤t.client)); - - assert!( - old.while_active::<()>(async { panic!("cancelled work must not run") }) - .await - .is_err() - ); - } - - #[tokio::test] - async fn oauth_cancellation_discards_an_inflight_completion_before_publication() { - let store = Arc::new(PendingOAuthStore::default()); - let attempt = store.begin(OAuthProvider::Apple, oauth_test_client()); - store - .set_state(&attempt, "fixture-state".to_string()) - .unwrap(); - let (entered_tx, entered_rx) = tokio::sync::oneshot::channel(); - let (release_tx, release_rx) = tokio::sync::oneshot::channel(); - let published = Arc::new(std::sync::atomic::AtomicBool::new(false)); - let task_store = Arc::clone(&store); - let task_published = Arc::clone(&published); - let task = tokio::spawn(async move { - let attempt = task_store - .complete(OAuthProvider::Apple, "fixture-state") - .unwrap(); - let _guard = OAuthAttemptGuard { - store: &task_store, - attempt: &attempt, - retain: false, - }; - attempt - .while_active(async { - entered_tx.send(()).unwrap(); - release_rx.await.unwrap(); - task_published.store(true, std::sync::atomic::Ordering::SeqCst); - Ok(()) - }) - .await - }); - entered_rx.await.unwrap(); - store.cancel(); - let _ = release_tx.send(()); - let result = tokio::time::timeout(std::time::Duration::from_secs(2), task) - .await - .unwrap() - .unwrap(); - assert_eq!(result, Err(OAUTH_CANCELLED_MESSAGE.to_string())); - assert!(!published.load(std::sync::atomic::Ordering::SeqCst)); - assert!( - store - .complete(OAuthProvider::Apple, "fixture-state") - .is_err() - ); - } - - fn auth_snapshot( - user_id: &str, - session_id: &str, - revision: u64, - access_token: &str, - ) -> MapleApiAuthSnapshot { - MapleApiAuthSnapshot { - user_id: user_id.to_string(), - access_token: access_token.to_string(), - refresh_token: Some(format!("refresh-{access_token}")), - native_instance_id: "native-test".to_string(), - session_id: session_id.to_string(), - revision, - } - } - - #[test] - fn persisted_auth_compare_clear_preserves_a_new_session_and_rejects_late_rotation() { - let root = std::env::temp_dir().join(format!( - "maple-persisted-auth-race-{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&root).unwrap(); - let path = root.join("auth.json"); - let store = PersistedAuthStore::new(path.clone(), "https://api.example".to_string()); - let old = auth_snapshot("same-user", "session-old", 2, "old-token"); - let new = auth_snapshot("same-user", "session-new", 1, "new-token"); - - store.claim_and_persist(&old); - let mut newer_old = old.clone(); - newer_old.revision = 3; - newer_old.access_token = "newer-old-token".to_string(); - store.persist_rotation(&newer_old); - store.claim_and_persist(&old); - assert_eq!( - store.read_record().unwrap().access_token, - "newer-old-token", - "a late same-session claim must not roll back a newer rotation" - ); - - store.claim_and_persist(&new); - store.clear_if_owned(&old); - assert_eq!( - store.read_record().unwrap().access_token, - "new-token", - "a delayed old logout must not erase a new same-account session" - ); - - let mut late_old = old.clone(); - late_old.revision = 3; - late_old.access_token = "late-old-token".to_string(); - store.persist_rotation(&late_old); - assert_eq!(store.read_record().unwrap().access_token, "new-token"); - - store.clear_if_owned(&new); - assert!(!path.exists()); - let mut late_new = new; - late_new.revision = 2; - late_new.access_token = "late-new-token".to_string(); - store.persist_rotation(&late_new); - assert!( - !path.exists(), - "a rotation published after sign-out must not recreate auth.json" - ); - std::fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn persistence_reports_write_failure_and_successful_restore() { - let root = std::env::temp_dir().join(format!("maple-auth-persistence-{}", Uuid::new_v4())); - std::fs::create_dir_all(&root).unwrap(); - let path = root.join("auth.json"); - let store = PersistedAuthStore::new(path.clone(), "https://api.example".to_string()); - let snapshot = auth_snapshot("fixture-user", "fixture-session", 1, "fixture-token"); - std::fs::create_dir(&path).unwrap(); - assert!( - !store.claim_and_persist(&snapshot), - "a directory cannot be replaced by credentials" - ); - assert!(store.load().is_none()); - std::fs::remove_dir(&path).unwrap(); - assert!(store.claim_and_persist(&snapshot)); - let restored = PersistedAuthStore::new(path, "https://api.example".to_string()) - .load() - .unwrap(); - assert_eq!(restored.user_id, snapshot.user_id); - assert_eq!(restored.access_token, snapshot.access_token); - std::fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn rejected_restore_clears_only_the_record_that_was_loaded() { - let root = std::env::temp_dir().join(format!( - "maple-persisted-auth-restore-race-{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&root).unwrap(); - let path = root.join("auth.json"); - let store = PersistedAuthStore::new(path.clone(), "https://api.example".to_string()); - let old = auth_snapshot("old-user", "session-old", 1, "old-token"); - let new = auth_snapshot("new-user", "session-new", 1, "new-token"); - - store.claim_and_persist(&old); - let loaded = store.load().unwrap(); - store.claim_and_persist(&new); - store.clear_record_if_unchanged(&loaded); - assert_eq!(store.read_record().unwrap().user_id, "new-user"); - - store.clear_if_owned(&new); - std::fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn oauth_callback_values_are_form_decoded() { - let url = "http://localhost/cb?state=ab%20cd+ef&code=4%2F0AX4XfWh%3Dz&x=1#frag"; - assert_eq!( - parse_oauth_callback(url), - Some(("4/0AX4XfWh=z".to_string(), "ab cd ef".to_string())) - ); - assert_eq!( - parse_oauth_callback("http://localhost/cb?code=plain&state=s"), - Some(("plain".to_string(), "s".to_string())) - ); - assert_eq!(parse_oauth_callback("http://localhost/cb?code=only"), None); - assert_eq!(parse_oauth_callback("http://localhost/cb"), None); - } - - #[test] - fn malformed_client_id_falls_back_to_default() { - let default: Uuid = DEFAULT_CLIENT_ID.parse().unwrap(); - assert_eq!(client_id_from(None), default); - assert_eq!(client_id_from(Some("not-a-uuid")), default); - let custom = "123e4567-e89b-12d3-a456-426614174000"; - assert_eq!( - client_id_from(Some(custom)), - custom.parse::().unwrap() - ); - } - - fn macos_permissions( - accessibility: bool, - screen_recording: bool, - ) -> AgentIntegrationPermissions { - AgentIntegrationPermissions::default() - .with(AgentIntegrationPermissionKind::Accessibility, accessibility) - .with( - AgentIntegrationPermissionKind::ScreenRecording, - screen_recording, - ) - } - - #[test] - fn integration_setup_opens_one_missing_permission_at_a_time() { - assert_eq!( - next_integration_setup_settings_url(&macos_permissions(false, false)), - Some(MACOS_ACCESSIBILITY_SETTINGS_URL) - ); - assert_eq!( - next_integration_setup_settings_url(&macos_permissions(true, false)), - Some(MACOS_SCREEN_RECORDING_SETTINGS_URL) - ); - assert_eq!( - next_integration_setup_settings_url(&macos_permissions(true, true)), - None - ); - } -} - -#[cfg(test)] -mod app_dir_tests { - use super::{APP_DIR_NAME, LEGACY_APP_DIR_NAME, adopt_legacy_app_dir}; - - #[test] - fn legacy_app_dir_is_adopted_only_when_the_current_one_is_absent() { - let base = std::env::temp_dir().join(format!( - "maple-agent-adopt-{}-{}", - std::process::id(), - std::time::SystemTime::UNIX_EPOCH - .elapsed() - .map(|d| d.as_nanos()) - .unwrap_or_default() - )); - let legacy = base.join(LEGACY_APP_DIR_NAME); - let root = base.join(APP_DIR_NAME); - std::fs::create_dir_all(legacy.join("logs")).unwrap(); - std::fs::write(legacy.join("auth.json"), "saved").unwrap(); - - let note = adopt_legacy_app_dir(&root, true).expect("first start adopts the legacy dir"); - assert!(note.starts_with("adopted "), "{note}"); - assert_eq!( - std::fs::read_to_string(root.join("auth.json")).unwrap(), - "saved" - ); - assert!(root.join("logs").is_dir()); - assert!(!legacy.exists()); - - // Nothing to do once the current directory exists. - assert_eq!(adopt_legacy_app_dir(&root, true), None); - - // A legacy directory that reappears next to a current one is left alone. - std::fs::create_dir_all(&legacy).unwrap(); - std::fs::write(legacy.join("auth.json"), "stale").unwrap(); - assert_eq!(adopt_legacy_app_dir(&root, true), None); - assert_eq!( - std::fs::read_to_string(root.join("auth.json")).unwrap(), - "saved" - ); - assert!(legacy.join("auth.json").is_file()); - - // No legacy directory: nothing happens and nothing is created. - let fresh = base.join("fresh").join(APP_DIR_NAME); - std::fs::create_dir_all(fresh.parent().unwrap()).unwrap(); - assert_eq!(adopt_legacy_app_dir(&fresh, true), None); - assert!(!fresh.exists()); - - std::fs::remove_dir_all(&base).unwrap(); - } - - #[test] - fn packaged_profiles_never_adopt_legacy_local_or_research_state() { - let base = std::env::temp_dir().join(format!( - "maple-agent-profile-isolation-{}-{}", - std::process::id(), - std::time::SystemTime::UNIX_EPOCH - .elapsed() - .unwrap() - .as_nanos() - )); - for namespace in [ - "maple-gpui", - "maple-agent", - "cloud.opensecret.maple", - "cloud.opensecret.maple.dev", - ] { - let path = base.join(namespace); - std::fs::create_dir_all(&path).unwrap(); - std::fs::write(path.join("auth.json"), namespace).unwrap(); - } - for namespace in ["maple-agent-dev", "maple-agent-prod"] { - let root = base.join(namespace); - assert!(adopt_legacy_app_dir(&root, false).is_none()); - assert!(!root.exists()); - std::fs::create_dir_all(&root).unwrap(); - std::fs::write(root.join("auth.json"), namespace).unwrap(); - } - for namespace in [ - "maple-gpui", - "maple-agent", - "cloud.opensecret.maple", - "cloud.opensecret.maple.dev", - "maple-agent-dev", - "maple-agent-prod", - ] { - assert_eq!( - std::fs::read_to_string(base.join(namespace).join("auth.json")).unwrap(), - namespace - ); - } - assert_eq!(APP_DIR_NAME, crate::profile::DATA_NAMESPACE); - std::fs::remove_dir_all(base).unwrap(); + match request.method.as_str() { + "restore_auth" => { + serde_json::to_value(backend.validate_persisted_auth().await).map_err(|e| e.to_string()) + } + "cancel_oauth" => { + backend.cancel_oauth(); + Ok(serde_json::Value::Null) + } + "set_harness_instructions" => { + let (instructions,): (String,) = + serde_json::from_value(request.params).map_err(invalid)?; + backend.set_harness_instructions(instructions); + Ok(serde_json::Value::Null) + } + "saved_model" => { + let (user_id,): (String,) = serde_json::from_value(request.params).map_err(invalid)?; + backend.authorize_account(&user_id)?; + serde_json::to_value(backend.saved_model(&user_id).await).map_err(|e| e.to_string()) + } + "load_tool_summaries" => { + let (user_id, session_id): (String, String) = + serde_json::from_value(request.params).map_err(invalid)?; + backend.authorize_account(&user_id)?; + encode(backend.load_tool_summaries_blocking(&user_id, &session_id)?) + } + "store_tool_summary" => { + let (user_id, session_id, item_id, summary): (String, String, String, String) = + serde_json::from_value(request.params).map_err(invalid)?; + backend.authorize_account(&user_id)?; + backend.store_tool_summary_blocking(&user_id, &session_id, &item_id, &summary)?; + Ok(serde_json::Value::Null) + } + _ => Err("Unknown Maple host operation".into()), + } +} + +pub(crate) async fn dispatch( + backend: &LocalBackend, + request: Request, +) -> Result { + // Authentication changes cannot interleave with an operation admitted for + // the previous account. Cancelling an OAuth wait must remain independent. + if request.method == "cancel_oauth" { + return dispatch_inner(backend, request).await; + } + if matches!( + request.method.as_str(), + "login" + | "logout_and_clear" + | "oauth_wait_for_handoff" + | "oauth_complete" + | "restore_auth" + | "confirm_account_deletion" + ) { + let _account = backend.account_gate.write().await; + dispatch_inner(backend, request).await + } else { + let _account = backend.account_gate.read().await; + dispatch_inner(backend, request).await } } diff --git a/apps/maple-agent/app/src/backend/local.rs b/apps/maple-agent/app/src/backend/local.rs new file mode 100644 index 000000000..dccb7ee91 --- /dev/null +++ b/apps/maple-agent/app/src/backend/local.rs @@ -0,0 +1,2852 @@ +//! Account, persistence, and runtime operations owned by the local host. + +// 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 +// there by design. +#![cfg_attr(not(feature = "desktop"), allow(dead_code))] + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +#[cfg(target_os = "macos")] +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, AgentTaskState, MapleAgentHostResources, + MapleAgentService, RecentProjectRoot, +}; +use maple_agent::maple_api::{ + MapleApiAuthEventSink, MapleApiAuthRequest, MapleApiAuthSnapshot, MapleApiAuthState, +}; +use maple_agent::open_secret_config::configured_pcr0_environment; +use maple_sdk::OpenSecretClient; +use tokio::runtime::Handle; +use tokio::sync::mpsc; +use uuid::Uuid; + +use crate::hosted_oauth::{ATTEMPT_TIMEOUT, HandoffListener}; + +#[derive(Debug, Clone)] +pub struct PendingQuestion { + pub session_id: String, + pub request_id: String, + /// One or more related questions answered together in one card. + pub questions: Vec, +} + +// Re-exported for the settings screens; a headless build has no reader. +#[cfg_attr(not(feature = "desktop"), allow(unused_imports))] +pub use maple_agent::maple_api::{ + MapleAccount, MapleAccountError, MapleApiKey, MapleApiKeyCreated, MapleLoginMethod, +}; + +/// The signed-in account identity. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct AuthSession { + pub user_id: String, +} + +/// Result of the background credential validation started by +/// the frontend at startup. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum RestoreOutcome { + /// The server accepted the saved credentials; the session is installed. + Valid(String), + /// The server rejected the saved credentials; they were cleared. + Rejected, + /// Offline, timeout, or a server fault. The credentials may still be + /// good, so they are kept for the next launch. + 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. +#[derive(serde::Serialize, serde::Deserialize)] +pub struct LocalBootstrap { + pub project_root: Option, + pub sessions: Vec, + pub recent_roots: Vec, + pub latest: Option, + pub questions: Vec, +} + +pub struct LocalBackend { + #[cfg(test)] + pub(super) ui_fixture: bool, + pub(super) account_gate: tokio::sync::RwLock<()>, + restore_lock: tokio::sync::Mutex<()>, + service: MapleAgentService, + auth: MapleApiAuthState, + api_url: String, + persisted_auth: Arc, + pending_oauth: Arc, + client_id: Uuid, + event_rx: tokio::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 app-owned tool summary store; keyed by account + /// scope path so a user switch reopens it. + summary_db: std::sync::Mutex>, +} + +pub(crate) fn configured_client_id() -> Uuid { + client_id_from(Some(&crate::profile::runtime_value( + "MAPLE_CLIENT_ID", + crate::profile::CLIENT_ID, + ))) +} + +/// The client id to send: local `MAPLE_CLIENT_ID` when it is a UUID, else the +/// compiled public project id. A malformed local override is logged instead of silently +/// pointing a test build at production. +fn client_id_from(configured: Option<&str>) -> Uuid { + let default = || DEFAULT_CLIENT_ID.parse().expect("valid uuid"); + let Some(value) = configured else { + return default(); + }; + match value.parse() { + Ok(id) => id, + Err(error) => { + log::warn!( + "MAPLE_CLIENT_ID {value:?} is not a UUID ({error}); using the default client id" + ); + default() + } + } +} + +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 = crate::profile::DATA_NAMESPACE; +/// Directory name used before the package rename. An existing directory is +/// adopted in place on first start; see [`adopt_legacy_app_dirs`]. +const LEGACY_APP_DIR_NAME: &str = "maple-gpui"; + +/// Rename state written under the previous directory name into the current +/// one, once per distinct root, before anything opens files. Only a missing +/// current directory is filled: an already-current or partially migrated +/// layout is never touched. Returns one line per root for the caller to log +/// once logging is up. +pub fn adopt_legacy_app_dirs() -> Vec { + if crate::profile::PACKAGED { + return Vec::new(); + } + let mut notes = Vec::new(); + let mut seen: Vec = Vec::new(); + for root in [config_root(), local_data_root()] { + if seen.contains(&root) { + continue; + } + if let Some(note) = adopt_legacy_app_dir(&root, !crate::profile::PACKAGED) { + notes.push(note); + } + seen.push(root); + } + notes +} + +fn adopt_legacy_app_dir(root: &Path, allow_legacy: bool) -> Option { + if !allow_legacy { + return None; + } + let legacy = root.parent()?.join(LEGACY_APP_DIR_NAME); + if root.exists() || !legacy.is_dir() { + return None; + } + Some(match std::fs::rename(&legacy, root) { + Ok(()) => format!("adopted {} as {}", legacy.display(), root.display()), + Err(error) => format!( + "could not adopt {} as {}: {error}", + legacy.display(), + root.display() + ), + }) +} + +/// Maple's public OpenSecret project id. The backend rejects unknown +/// client ids, so this must match the registered project. +const DEFAULT_CLIENT_ID: &str = crate::profile::CLIENT_ID; + +/// OAuth providers supported by the OpenSecret backend. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum OAuthProvider { + Github, + Google, + Apple, +} + +impl OAuthProvider { + pub fn label(self) -> &'static str { + match self { + Self::Github => "GitHub", + Self::Google => "Google", + Self::Apple => "Apple", + } + } +} + +const OAUTH_CANCELLED_MESSAGE: &str = "Sign in was cancelled. Start again."; + +/// V2 binds the provider callback to the SDK session that started it. Keep +/// that exact client until the one permitted completion, and revoke work +/// already in flight when the user cancels or starts another sign-in. +struct OAuthAttempt { + provider: OAuthProvider, + client: Arc, + cancelled: tokio::sync::watch::Sender, +} + +impl OAuthAttempt { + async fn while_active( + &self, + operation: impl Future>, + ) -> Result { + let mut cancelled = self.cancelled.subscribe(); + if *cancelled.borrow() { + return Err(OAUTH_CANCELLED_MESSAGE.to_string()); + } + tokio::select! { + biased; + _ = cancelled.changed() => Err(OAUTH_CANCELLED_MESSAGE.to_string()), + result = operation => result, + } + } +} + +struct PendingOAuth { + attempt: Arc, + state: Option, + completing: bool, + handoff: Option, +} + +struct HostedHandoff { + listener: HandoffListener, + prepared: maple_sdk::PreparedNativeOAuthHandoff, +} + +#[derive(Default)] +struct PendingOAuthStore(std::sync::Mutex>); + +impl PendingOAuthStore { + fn begin(&self, provider: OAuthProvider, client: OpenSecretClient) -> Arc { + let attempt = Arc::new(OAuthAttempt { + provider, + client: Arc::new(client), + cancelled: tokio::sync::watch::channel(false).0, + }); + let mut pending = self.0.lock().expect("pending OAuth lock"); + if let Some(previous) = pending.replace(PendingOAuth { + attempt: Arc::clone(&attempt), + state: None, + completing: false, + handoff: None, + }) { + previous.attempt.cancelled.send_replace(true); + } + attempt + } + + fn set_state(&self, attempt: &Arc, state: String) -> Result<(), String> { + let mut pending = self.0.lock().expect("pending OAuth lock"); + let flow = pending + .as_mut() + .filter(|flow| Arc::ptr_eq(&flow.attempt, attempt)) + .ok_or_else(|| OAUTH_CANCELLED_MESSAGE.to_string())?; + flow.state = Some(state); + Ok(()) + } + + fn complete(&self, provider: OAuthProvider, state: &str) -> Result, String> { + let mut pending = self.0.lock().expect("pending OAuth lock"); + let flow = pending + .as_mut() + .filter(|flow| { + flow.attempt.provider == provider + && flow.state.as_deref() == Some(state) + && !flow.completing + && flow.handoff.is_none() + }) + .ok_or_else(|| { + "This callback does not match the pending sign in. Start again.".to_string() + })?; + flow.completing = true; + Ok(Arc::clone(&flow.attempt)) + } + + fn set_handoff( + self: &Arc, + attempt: &Arc, + handoff: HostedHandoff, + ) -> Result<(), String> { + let mut pending = self.0.lock().expect("pending OAuth lock"); + let flow = pending + .as_mut() + .filter(|flow| Arc::ptr_eq(&flow.attempt, attempt)) + .ok_or_else(|| OAUTH_CANCELLED_MESSAGE.to_string())?; + flow.handoff = Some(handoff); + drop(pending); + self.expire_handoff_after(attempt, ATTEMPT_TIMEOUT); + Ok(()) + } + + fn expire_handoff_after( + self: &Arc, + attempt: &Arc, + timeout: std::time::Duration, + ) { + // Expire even if the UI is closed or never begins waiting. Weak + // references cannot keep a backend or completed attempt alive. + let store = Arc::downgrade(self); + let weak_attempt = Arc::downgrade(attempt); + let mut cancelled = attempt.cancelled.subscribe(); + tokio::spawn(async move { + tokio::select! { + _ = cancelled.changed() => {}, + _ = tokio::time::sleep(timeout) => { + if let (Some(store), Some(attempt)) = (store.upgrade(), weak_attempt.upgrade()) { + store.clear(&attempt); + } + } + } + }); + } + + fn take_handoff( + &self, + provider: OAuthProvider, + ) -> Result<(Arc, HostedHandoff), String> { + let mut pending = self.0.lock().expect("pending OAuth lock"); + let flow = pending + .as_mut() + .filter(|flow| flow.attempt.provider == provider && !flow.completing) + .ok_or_else(|| OAUTH_CANCELLED_MESSAGE.to_string())?; + let handoff = flow + .handoff + .take() + .ok_or_else(|| OAUTH_CANCELLED_MESSAGE.to_string())?; + flow.completing = true; + Ok((Arc::clone(&flow.attempt), handoff)) + } + + fn clear(&self, attempt: &Arc) { + let mut pending = self.0.lock().expect("pending OAuth lock"); + if pending + .as_ref() + .is_some_and(|flow| Arc::ptr_eq(&flow.attempt, attempt)) + && let Some(flow) = pending.take() + { + flow.attempt.cancelled.send_replace(true); + } + } + + fn cancel(&self) { + if let Some(flow) = self.0.lock().expect("pending OAuth lock").take() { + flow.attempt.cancelled.send_replace(true); + } + } +} + +/// A dropped initiation/completion must not leave a usable pending flow. +/// Clearing is scoped to this attempt so late cleanup preserves a replacement. +struct OAuthAttemptGuard<'a> { + store: &'a PendingOAuthStore, + attempt: &'a Arc, + retain: bool, +} + +impl Drop for OAuthAttemptGuard<'_> { + fn drop(&mut self) { + if !self.retain { + self.store.clear(self.attempt); + } + } +} + +impl LocalBackend { + /// 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 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 +/// every platform so tests and portable installs can redirect it. +fn config_root() -> PathBuf { + let base = env_dir("XDG_CONFIG_HOME") + .or_else(dirs::config_dir) + .unwrap_or_else(|| PathBuf::from(".")); + base.join(APP_DIR_NAME) +} + +/// Root for device-local data: session history, attachments, logs, and +/// credentials. Mirrors Tauri's `app_local_data_dir`: `~/.local/share` on +/// Linux, `~/Library/Application Support` on macOS, `%LOCALAPPDATA%` on +/// Windows. `XDG_DATA_HOME` overrides it on every platform. +pub(crate) fn host_data_root() -> PathBuf { + local_data_root() +} + +pub fn local_data_root() -> PathBuf { + let base = env_dir("XDG_DATA_HOME") + .or_else(dirs::data_local_dir) + .unwrap_or_else(|| PathBuf::from(".")); + base.join(APP_DIR_NAME) +} + +fn env_dir(name: &str) -> Option { + std::env::var_os(name) + .map(PathBuf::from) + .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 and the +/// Documents/Maple workspace cannot be created. 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 folder when it still exists and was not +/// removed, otherwise Documents/Maple unless the user removed it. Home is +/// only the last resort. Never the process working directory. The +/// workspace may not exist yet: `gui_start_root` creates it. +fn gui_project_root(config: &maple_agent::agent::AgentConfig) -> Option { + let workspace = maple_agent::agent::default_maple_workspace_path(); + maple_agent::agent::startup_project_root(config, workspace.as_deref()) + .or_else(fallback_project_root) +} + +/// `gui_project_root` for a runtime start, which needs the folder on disk. +/// Documents/Maple is created here, the first time a start uses it. If +/// that fails (Documents access denied, for example), the start uses home. +fn gui_start_root(config: &maple_agent::agent::AgentConfig) -> Option { + let root = gui_project_root(config)?; + if !maple_agent::agent::is_default_maple_workspace(std::path::Path::new(&root)) { + return Some(root); + } + match maple_agent::agent::ensure_default_maple_workspace() { + Ok(path) => Some(path.to_string_lossy().to_string()), + Err(error) => { + log::warn!("{error}"); + fallback_project_root() + } + } +} + +/// Recent projects, with the default Maple workspace offered when the user +/// has not removed it. Existing saved order is left as it is. Listing does +/// not create the folder; choosing or opening it does. +fn offered_project_roots( + config: &maple_agent::agent::AgentConfig, + roots: Vec, +) -> Vec { + let workspace = maple_agent::agent::default_maple_workspace_path(); + maple_agent::agent::include_default_maple_workspace( + roots, + &config.removed_project_roots, + workspace.as_deref(), + ) +} + +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +struct PersistedAuthRecord { + user_id: String, + api_url: String, + access_token: String, + refresh_token: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + revision: Option, +} + +impl PersistedAuthRecord { + fn from_snapshot(api_url: &str, snapshot: &MapleApiAuthSnapshot) -> Self { + Self { + user_id: snapshot.user_id.clone(), + api_url: api_url.to_string(), + access_token: snapshot.access_token.clone(), + refresh_token: snapshot.refresh_token.clone(), + session_id: Some(snapshot.session_id.clone()), + revision: Some(snapshot.revision), + } + } + + fn owns_snapshot(&self, snapshot: &MapleApiAuthSnapshot) -> bool { + self.user_id == snapshot.user_id + && self.session_id.as_deref() == Some(snapshot.session_id.as_str()) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct PersistedAuthOwner { + user_id: String, + session_id: String, + revision: u64, +} + +impl PersistedAuthOwner { + fn from_snapshot(snapshot: &MapleApiAuthSnapshot) -> Self { + Self { + user_id: snapshot.user_id.clone(), + session_id: snapshot.session_id.clone(), + revision: snapshot.revision, + } + } + + fn matches(&self, snapshot: &MapleApiAuthSnapshot) -> bool { + self.user_id == snapshot.user_id && self.session_id == snapshot.session_id + } +} + +#[derive(Default)] +struct PersistedAuthState { + owner: Option, +} + +/// Serializes persisted credential ownership with token-rotation callbacks. +/// A queued callback may update only the exact in-memory auth session that +/// currently owns the file; sign-out compare-clears that same opaque session. +struct PersistedAuthStore { + path: PathBuf, + api_url: String, + state: std::sync::Mutex, +} + +impl PersistedAuthStore { + fn new(path: PathBuf, api_url: String) -> Self { + Self { + path, + api_url, + state: std::sync::Mutex::new(PersistedAuthState::default()), + } + } + + fn read_record(&self) -> Option { + let bytes = std::fs::read(&self.path).ok()?; + let record: PersistedAuthRecord = serde_json::from_slice(&bytes).ok()?; + (record.api_url == self.api_url).then_some(record) + } + + fn load(&self) -> Option { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let record = self.read_record()?; + state.owner = record + .session_id + .as_ref() + .map(|session_id| PersistedAuthOwner { + user_id: record.user_id.clone(), + session_id: session_id.clone(), + revision: record.revision.unwrap_or(0), + }); + Some(record) + } + + fn claim_and_persist(&self, snapshot: &MapleApiAuthSnapshot) -> bool { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if state + .owner + .as_ref() + .is_some_and(|owner| owner.matches(snapshot) && snapshot.revision < owner.revision) + { + log::debug!( + "ignoring stale persisted auth claim (revision {})", + snapshot.revision + ); + return false; + } + state.owner = Some(PersistedAuthOwner::from_snapshot(snapshot)); + self.write_locked(&PersistedAuthRecord::from_snapshot(&self.api_url, snapshot)) + } + + fn persist_rotation(&self, snapshot: &MapleApiAuthSnapshot) { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let Some(owner) = state.owner.as_mut() else { + log::debug!( + "ignoring rotated credentials for an unowned persisted auth session (revision {})", + snapshot.revision + ); + return; + }; + if !owner.matches(snapshot) || snapshot.revision < owner.revision { + log::debug!( + "ignoring stale rotated credentials (revision {})", + snapshot.revision + ); + return; + } + owner.revision = snapshot.revision; + self.write_locked(&PersistedAuthRecord::from_snapshot(&self.api_url, snapshot)); + } + + fn clear_if_owned(&self, snapshot: &MapleApiAuthSnapshot) { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if state + .owner + .as_ref() + .is_some_and(|owner| owner.matches(snapshot)) + { + state.owner = None; + } + if self + .read_record() + .is_some_and(|record| record.owns_snapshot(snapshot)) + { + self.remove_locked(); + } + } + + fn clear_record_if_unchanged(&self, expected: &PersistedAuthRecord) { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if self.read_record().as_ref() != Some(expected) { + return; + } + if state + .owner + .as_ref() + .is_some_and(|owner| expected.session_id.as_deref() == Some(owner.session_id.as_str())) + { + state.owner = None; + } + self.remove_locked(); + } + + fn write_locked(&self, record: &PersistedAuthRecord) -> bool { + if let Err(error) = maple_agent::private_file::write_private_json(&self.path, record) { + log::error!( + "Cannot save credentials to {}: {error}. Sign in is needed again at the next start.", + self.path.display() + ); + return false; + } + true + } + + fn remove_locked(&self) { + if let Err(error) = std::fs::remove_file(&self.path) + && error.kind() != std::io::ErrorKind::NotFound + { + log::error!( + "Cannot remove credentials at {}: {error}", + self.path.display() + ); + } + } +} + +struct PersistAuthSink { + store: Arc, +} + +impl MapleApiAuthEventSink for PersistAuthSink { + fn auth_changed(&self, snapshot: &MapleApiAuthSnapshot) { + // Tokens never reach the log; only the revision does. + log::debug!( + "persisting rotated credentials (revision {})", + snapshot.revision + ); + // Keep this small write synchronous with the session publication. A + // detached writer could run after sign-out and resurrect credentials; + // the store mutex also orders it against compare-and-clear. + self.store.persist_rotation(snapshot); + } +} + +impl LocalBackend { + pub fn new(api_url: String, harness_instructions: 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 paths = + maple_agent::agent::AgentPathLayout::from_app_roots(config_root(), local_data_root()); + let service = MapleAgentService::new(MapleAgentHostResources::new( + paths, + Arc::new(ChannelEventSink(event_tx)), + harness_instructions, + )); + service.manage_question_availability(); + let runtime = Handle::try_current() + .map_err(|error| format!("Host runtime is unavailable: {error}"))?; + let persisted_auth = Arc::new(PersistedAuthStore::new(Self::auth_file(), api_url.clone())); + // reqwest clients must be built inside a Tokio runtime context; one + // built outside never completes a request. + let billing = { + let _guard = runtime.enter(); + crate::billing::BillingClient::new(crate::billing::configured_billing_api_url())? + }; + Ok(Self { + #[cfg(test)] + ui_fixture: false, + restore_lock: tokio::sync::Mutex::new(()), + account_gate: tokio::sync::RwLock::new(()), + service, + auth: MapleApiAuthState::new(), + api_url, + persisted_auth, + pending_oauth: Arc::new(PendingOAuthStore::default()), + client_id: configured_client_id(), + event_rx: tokio::sync::Mutex::new(Some(event_rx)), + billing, + billing_tokens: tokio::sync::Mutex::new(HashMap::new()), + summary_db: std::sync::Mutex::new(None), + }) + } + + /// 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); + } + + pub(crate) fn question_responder(&self) -> maple_agent::agent::QuestionResponder { + self.service.question_responder() + } + + pub(super) fn authorize_account(&self, user_id: &str) -> Result<(), String> { + #[cfg(test)] + if self.ui_fixture { + return Ok(()); + } + if self.saved_user_id().as_deref() == Some(user_id) { + Ok(()) + } else { + Err("The host account changed. Reopen this screen before continuing.".into()) + } + } + + fn require_signed_out(&self) -> Result<(), String> { + if self.saved_user_id().is_some() { + Err("Maple is already signed in. Sign out before changing accounts.".into()) + } else { + Ok(()) + } + } + + /// 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() { + return Err("Enter an email address".to_string()); + } + Ok(email) + } + + /// Sign in with email and password through the OpenSecret SDK, then hand + /// the validated credentials to the agent runtime. + pub async fn login(&self, email: String, password: String) -> Result { + let _account = self.restore_lock.lock().await; + self.require_signed_out()?; + let email = Self::normalize_email(&email)?; + if password.is_empty() { + return Err("Enter a password".to_string()); + } + self.cancel_oauth(); + let environment = configured_pcr0_environment()?; + let client = OpenSecretClient::new_with_pcr0_environment(self.api_url.clone(), environment) + .map_err(|_| "Maple API authentication failed".to_string())?; + let response = client + .login(email.clone(), password, self.client_id) + .await + .map_err(|_| "Sign in failed. Check your email and password.".to_string())?; + let user_id = response.id.to_string(); + let snapshot = self + .auth + .set_auth( + self.auth_sink(), + MapleApiAuthRequest { + user_id: user_id.clone(), + api_url: self.api_url.clone(), + access_token: response.access_token, + refresh_token: Some(response.refresh_token), + }, + ) + .await + .map_err(|message| { + // Keep validation detail out of the UI; it can echo the + // configured URL back to the user. + log::debug!("set_auth failed during sign in: {message}"); + "Sign in failed. Try again.".to_string() + })?; + self.persist_auth(&snapshot); + Ok(AuthSession { user_id }) + } + + /// Credentials are device-local, like the web app's `localStorage`. + /// They must not sit in a roaming profile (`%APPDATA%`), so they live in + /// the local data root rather than next to `settings.json`. + fn auth_file() -> std::path::PathBuf { + local_data_root().join("auth.json") + } + + fn persist_auth(&self, snapshot: &MapleApiAuthSnapshot) -> bool { + self.persisted_auth.claim_and_persist(snapshot) + } + + /// The sink the runtime calls when the SDK rotates the token pair + /// during an API call. It writes the new pair to `auth.json` so the + /// next launch does not restore stale tokens. + fn auth_sink(&self) -> Arc { + Arc::new(PersistAuthSink { + store: Arc::clone(&self.persisted_auth), + }) + } + + fn load_persisted_auth(&self) -> Option { + self.persisted_auth.load() + } + + /// The account id saved by a previous sign-in, without validating it. + /// A local file read, so the UI may show the account's data at once + /// while the host validates the credentials. + pub fn saved_user_id(&self) -> Option { + self.load_persisted_auth().map(|record| record.user_id) + } + + /// Validate the saved credentials with the server and install the + /// session on success. Definitive rejections clear the saved file. + pub(super) async fn validate_persisted_auth(&self) -> RestoreOutcome { + let _restore = self.restore_lock.lock().await; + if let Some(user_id) = self.saved_user_id() + && self.auth.session_for(&user_id).await.is_ok() + { + return RestoreOutcome::Valid(user_id); + } + self.cancel_oauth(); + let Some(persisted) = self.load_persisted_auth() else { + return RestoreOutcome::Rejected; + }; + let request_record = persisted.clone(); + let request = MapleApiAuthRequest { + user_id: request_record.user_id, + api_url: self.api_url.clone(), + access_token: request_record.access_token, + refresh_token: request_record.refresh_token, + }; + let result = match tokio::time::timeout( + std::time::Duration::from_secs(30), + self.auth.set_auth(self.auth_sink(), request), + ) + .await + { + Ok(Ok(snapshot)) => Ok(snapshot), + Ok(Err(error)) => Err(error), + Err(_) => Err("timeout".to_string()), + }; + match result { + Ok(snapshot) => { + self.persist_auth(&snapshot); + RestoreOutcome::Valid(snapshot.user_id) + } + Err(error) if maple_agent::maple_api::is_auth_rejection(&error) => { + log::debug!("persisted auth rejected: {error:?}"); + let _ = self.service.sign_out(&persisted.user_id).await; + self.persisted_auth.clear_record_if_unchanged(&persisted); + RestoreOutcome::Rejected + } + Err(error) => { + // Offline, timeout, or a server fault: the credentials may + // still be good, so keep them for the next launch. + log::warn!("persisted auth could not be validated: {error}"); + RestoreOutcome::Unavailable + } + } + } + + /// 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> { + if self.auth.session_for(user_id).await.is_err() { + let _ = self.validate_persisted_auth().await; + } + self.auth.session_for(user_id).await + } + + /// Sign out completely: invalidate the live session first, then remove + /// only the persisted credentials that this sign-out observed. + pub async fn logout_and_clear(&self, user_id: &str) -> Result<(), String> { + self.clear_session(user_id, true).await + } + + /// Drop the live session and the persisted record. `revoke` also sends + /// `POST /logout`; a deleted account has no session left to report. + async fn clear_session(&self, user_id: &str, revoke: bool) -> Result<(), String> { + let _account = self.restore_lock.lock().await; + self.service.sign_out(user_id).await?; + self.cancel_oauth(); + let auth_snapshot = self.auth.auth_snapshot_for(user_id).await.ok(); + let persisted_without_session = auth_snapshot + .is_none() + .then(|| self.load_persisted_auth()) + .flatten(); + + // Report the sign-out to the server first, best effort: an offline + // sign-out must still complete locally, and the session is + // invalidated below whatever the server said. (The backend's + // logout route does not revoke the refresh token yet.) + if revoke + && auth_snapshot.is_some() + && let Ok(session) = self.auth.session_for(user_id).await + { + match tokio::time::timeout(std::time::Duration::from_secs(5), session.logout()).await { + Ok(Ok(())) => {} + Ok(Err(error)) => log::debug!("server logout failed: {error}"), + Err(_) => log::debug!("server logout timed out"), + } + } + + let result = self.auth.clear_auth(user_id).await; + if result.is_ok() { + if let Some(snapshot) = auth_snapshot.as_ref() { + self.persisted_auth.clear_if_owned(snapshot); + } else if let Some(persisted) = persisted_without_session.as_ref() { + // Preserve the old offline/logout behavior without letting a + // concurrent sign-in's replacement record be removed. + self.persisted_auth.clear_record_if_unchanged(persisted); + } + } + result + } + + fn oauth_client(&self) -> Result { + let environment = configured_pcr0_environment()?; + OpenSecretClient::new_with_pcr0_environment(self.api_url.clone(), environment) + .map_err(|_| "Maple API authentication failed".to_string()) + } + + async fn publish_session( + &self, + user_id: String, + access_token: String, + refresh_token: Option, + ) -> Result { + self.publish_session_with_persistence(user_id, access_token, refresh_token) + .await + .map(|(session, _)| session) + } + + async fn publish_session_with_persistence( + &self, + user_id: String, + access_token: String, + refresh_token: Option, + ) -> Result<(AuthSession, bool), String> { + let _account = self.restore_lock.lock().await; + self.require_signed_out()?; + self.auth + .set_auth( + self.auth_sink(), + MapleApiAuthRequest { + user_id, + api_url: self.api_url.clone(), + access_token, + refresh_token, + }, + ) + .await + .map_err(|message| { + // Keep validation detail out of the UI; it can echo the + // configured URL back to the user. + log::debug!("set_auth failed during sign in: {message}"); + "Sign in failed. Try again.".to_string() + }) + .map(|snapshot| { + let persisted = self.persist_auth(&snapshot); + ( + AuthSession { + user_id: snapshot.user_id.clone(), + }, + persisted, + ) + }) + } + + /// Begin an OAuth flow: returns the authorization URL to open in a + /// browser (also opens it via the system browser). + pub async fn oauth_start(&self, provider: OAuthProvider) -> Result { + let attempt = self.pending_oauth.begin(provider, self.oauth_client()?); + let mut guard = OAuthAttemptGuard { + store: &self.pending_oauth, + attempt: &attempt, + retain: false, + }; + let client = &attempt.client; + if let Some(origin) = crate::profile::AUTH_ORIGIN { + let auth_url = attempt + .while_active(async { + let prepared = client + .prepare_native_oauth_handoff() + .await + .map_err(|_| "Could not prepare browser sign-in. Try again.".to_string())?; + let listener = HandoffListener::bind()?; + let provider_name = match provider { + OAuthProvider::Github => "github", + OAuthProvider::Google => "google", + OAuthProvider::Apple => "apple", + }; + let url = listener.start_url( + origin, + provider_name, + prepared.session_id(), + prepared.request_id(), + ); + self.pending_oauth + .set_handoff(&attempt, HostedHandoff { listener, prepared })?; + Ok(url) + }) + .await?; + if webbrowser::open(&auth_url).is_err() { + return Err( + "Could not open your browser. Check your default browser and try again." + .to_string(), + ); + } + guard.retain = true; + return Ok(auth_url); + } + let client_id = self.client_id; + let (auth_url, state) = attempt + .while_active(async { + Ok(match provider { + OAuthProvider::Github => { + let response = client + .initiate_github_auth(client_id, None) + .await + .map_err(|_| "Could not start GitHub sign in".to_string())?; + (response.auth_url, response.state) + } + OAuthProvider::Google => { + let response = client + .initiate_google_auth(client_id, None) + .await + .map_err(|_| "Could not start Google sign in".to_string())?; + (response.auth_url, response.state) + } + OAuthProvider::Apple => { + let response = client + .initiate_apple_auth(client_id, None) + .await + .map_err(|_| "Could not start Apple sign in".to_string())?; + (response.auth_url, response.state) + } + }) + }) + .await?; + self.pending_oauth.set_state(&attempt, state)?; + if webbrowser::open(&auth_url).is_err() { + // No system browser available: the UI still shows the URL. + log::debug!("failed to open system browser for OAuth"); + } + guard.retain = true; + Ok(auth_url) + } + + pub fn cancel_oauth(&self) { + self.pending_oauth.cancel(); + } + + /// Wait on the existing listener and consume exactly the SDK handle that + /// created the advertised target. Cancellation closes the listener/socket; + /// malformed callbacks leave it available until the attempt deadline. + pub async fn oauth_wait_for_handoff( + &self, + provider: OAuthProvider, + ) -> Result { + let (attempt, handoff) = self.pending_oauth.take_handoff(provider)?; + let _guard = OAuthAttemptGuard { + store: &self.pending_oauth, + attempt: &attempt, + retain: false, + }; + attempt + .while_active(async { + let HostedHandoff { listener, prepared } = handoff; + let (grant, browser) = listener.receive(&prepared).await?; + let outcome = async { + let response = attempt + .client + .redeem_native_oauth_handoff(prepared, grant) + .await + .map_err(|_| { + "Browser sign-in failed or expired. Start again.".to_string() + })?; + self.publish_session_with_persistence( + response.id.to_string(), + response.access_token, + Some(response.refresh_token), + ) + .await + } + .await; + drop(listener); + browser.finish( + outcome + .as_ref() + .map(|(_, persisted)| *persisted) + .map_err(|_| ()), + ); + // No await after native publication: a late Back or failed browser + // response must not hide an already committed account from the UI. + outcome.map(|(session, _)| session) + }) + .await + } + + /// Complete an OAuth flow from the redirected URL (pasted by the user or + /// captured from a loopback redirect). + pub async fn oauth_complete( + &self, + provider: OAuthProvider, + redirected_url: String, + ) -> Result { + let Some((code, state)) = parse_oauth_callback(&redirected_url) else { + return Err( + "Paste the full URL you were redirected to (it contains code and state)" + .to_string(), + ); + }; + let attempt = self.pending_oauth.complete(provider, &state)?; + let _guard = OAuthAttemptGuard { + store: &self.pending_oauth, + attempt: &attempt, + retain: false, + }; + attempt + .while_active(async { + let client = &attempt.client; + let response = match provider { + OAuthProvider::Github => client + .handle_github_callback(code, state, String::new()) + .await + .map_err(|_| "GitHub sign in failed".to_string())?, + OAuthProvider::Google => client + .handle_google_callback(code, state, String::new()) + .await + .map_err(|_| "Google sign in failed".to_string())?, + OAuthProvider::Apple => client + .handle_apple_callback(code, state, String::new()) + .await + .map_err(|_| "Apple sign in failed".to_string())?, + }; + self.publish_session( + response.id.to_string(), + response.access_token, + Some(response.refresh_token), + ) + .await + }) + .await + } + + /// The signed-in account's profile from the backend. + pub async fn account(&self, user_id: &str) -> Result { + let session = self.session_for(user_id).await?; + session + .account() + .await + .map_err(|error| account_error_message(error, "Could not load the account")) + } + + /// Email a fresh verification code to the account's address. + pub async fn request_verification_email(&self, user_id: &str) -> Result<(), String> { + let session = self.session_for(user_id).await?; + session + .request_verification_email() + .await + .map_err(|error| account_error_message(error, "Could not send the verification email")) + } + + /// Change the account password. The rotated token pair is persisted + /// through the auth sink before this returns. + pub async fn change_password( + &self, + user_id: &str, + current_password: String, + new_password: String, + ) -> Result<(), String> { + if current_password.is_empty() { + return Err("Enter your current password".to_string()); + } + validate_new_password(&new_password)?; + let session = self.session_for(user_id).await?; + session + .change_password(current_password, new_password) + .await + .map_err(|error| match error { + // The route answers 401 for a wrong current password; a + // dead session would have failed the session lookup first. + MapleAccountError::Unauthorized => "The current password is incorrect".to_string(), + other => account_error_message(other, "Could not change the password"), + }) + } + + /// Start deleting the account: the server emails a confirmation code. + /// Returns the client-held secret the confirmation step must present. + pub async fn request_account_deletion(&self, user_id: &str) -> Result { + let session = self.session_for(user_id).await?; + let (plaintext, hashed) = maple_agent::maple_api::new_confirmation_secret(); + session + .request_account_deletion(hashed) + .await + .map_err(|error| account_error_message(error, "Could not start account deletion"))?; + Ok(plaintext) + } + + /// Delete the account for good. The agent runtime stops first, then + /// the server deletes the account, then the local credentials go. A + /// server failure leaves the session usable. + pub async fn confirm_account_deletion( + &self, + user_id: &str, + confirmation_code: String, + plaintext_secret: String, + ) -> Result<(), String> { + let code = confirmation_code.trim().to_string(); + if code.is_empty() { + 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?; + session + .confirm_account_deletion(code, plaintext_secret) + .await + .map_err(|error| match error { + MapleAccountError::Status(400) => { + "That confirmation code is wrong or has expired".to_string() + } + other => account_error_message(other, "Could not delete the account"), + })?; + if let Err(error) = self.clear_session(user_id, false).await { + log::warn!("local sign-out after account deletion failed: {error}"); + } + Ok(()) + } + + /// The account's API keys, newest first. + pub async fn list_api_keys(&self, user_id: &str) -> Result, String> { + let session = self.session_for(user_id).await?; + let mut keys = session + .list_api_keys() + .await + .map_err(|error| api_key_error_message(error, "Could not load the API keys"))?; + keys.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + Ok(keys) + } + + /// Create an API key named `name`. The key value in the result is the + /// only copy; the server never returns it again. + pub async fn create_api_key( + &self, + user_id: &str, + name: String, + ) -> Result { + let name = validate_api_key_name(&name)?; + let session = self.session_for(user_id).await?; + session + .create_api_key(name) + .await + .map_err(|error| api_key_error_message(error, "Could not create the API key")) + } + + pub async fn delete_api_key(&self, user_id: &str, name: &str) -> Result<(), String> { + let session = self.session_for(user_id).await?; + session + .delete_api_key(name) + .await + .map_err(|error| api_key_error_message(error, "Could not delete the API key")) + } + + /// Plan usage for the sidebar card from the Maple billing API. Returns + /// `None` when the subscription has no token meter. + pub async fn plan_usage( + &self, + user_id: &str, + ) -> Result, String> { + let status = self.billing_status(user_id).await?; + let plan = crate::billing::PlanUsage::from_status(&status, chrono::Local::now()); + log::debug!("plan usage: {plan:?}"); + Ok(plan) + } + + /// The full subscription status for the Billing section. + pub async fn billing_status( + &self, + user_id: &str, + ) -> Result { + self.billing_call(user_id, |billing, token| async move { + billing.subscription_status(&token).await + }) + .await + } + + /// The plans on sale. Public on the billing API, no token needed. + pub async fn billing_products(&self) -> Result, String> { + tokio::time::timeout(std::time::Duration::from_secs(20), self.billing.products()) + .await + .unwrap_or_else(|_| { + Err(crate::billing::BillingError::Other( + "billing request timed out".to_string(), + )) + }) + .map_err(|error| error.to_string()) + } + + /// Open the Stripe customer portal in the system browser. + pub async fn open_billing_portal(&self, user_id: &str) -> Result<(), String> { + let url = self + .billing_call(user_id, |billing, token| async move { + billing + .portal_url(&token, crate::billing::PORTAL_RETURN_URL) + .await + }) + .await + .map_err(|error| billing_failure(error, "Could not open the subscription portal"))?; + open_in_browser(&url, "the subscription portal") + } + + /// Start a Stripe checkout for `product_id` in the system browser. The + /// account email is sent when there is one; guests check out without. + pub async fn start_checkout(&self, user_id: &str, product_id: String) -> Result<(), String> { + let email = self.account(user_id).await?.email.unwrap_or_default(); + let request = Arc::new(crate::billing::CheckoutRequest { + email, + product_id, + success_url: crate::billing::CHECKOUT_SUCCESS_URL.to_string(), + cancel_url: crate::billing::CHECKOUT_CANCEL_URL.to_string(), + quantity: None, + }); + let url = self + .billing_call(user_id, |billing, token| { + let request = Arc::clone(&request); + async move { billing.checkout_url(&token, &request).await } + }) + .await + .map_err(|error| billing_failure(error, "Could not start checkout"))?; + open_in_browser(&url, "checkout") + } + + /// Run one billing request with the cached token, minting a token + /// first when there is none and once more after a 401. + async fn billing_call(&self, user_id: &str, request: F) -> Result + where + F: Fn(crate::billing::BillingClient, String) -> Fut, + Fut: Future>, + { + use crate::billing::BillingError; + let session = self.session_for(user_id).await?; + let cached = self.billing_tokens.lock().await.get(user_id).cloned(); + let mut token = match cached { + Some(token) => token, + None => self.mint_billing_token(&session, user_id).await?, + }; + let mut result = self + .timed_billing(request(self.billing.clone(), token)) + .await; + if matches!(result, Err(BillingError::Unauthorized)) { + // The cached token expired or was revoked: mint one and retry once. + token = self.mint_billing_token(&session, user_id).await?; + result = self + .timed_billing(request(self.billing.clone(), token)) + .await; + } + match result { + Ok(value) => Ok(value), + Err(BillingError::Unauthorized) => { + self.billing_tokens.lock().await.remove(user_id); + Err(BillingError::Unauthorized.to_string()) + } + Err(BillingError::Other(error)) => Err(error), + } + } + + async fn timed_billing( + &self, + request: impl Future>, + ) -> Result { + tokio::time::timeout(std::time::Duration::from_secs(20), request) + .await + .unwrap_or_else(|_| { + Err(crate::billing::BillingError::Other( + "billing request timed out".to_string(), + )) + }) + } + + async fn mint_billing_token( + &self, + session: &Arc, + user_id: &str, + ) -> Result { + let token = session + .third_party_token(self.billing.base_url().to_string()) + .await?; + self.billing_tokens + .lock() + .await + .insert(user_id.to_string(), token.clone()); + 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 Documents/Maple instead. + let request = match request { + Some(AgentStartRequest { + project_root: None, + model, + }) => { + let config = handle.load_config().await?; + Some(AgentStartRequest { + project_root: gui_start_root(&config), + model, + }) + } + 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 + } + + pub(crate) async fn shutdown_host(&self) { + self.service.begin_draining(); + if let Err(error) = self.service.shutdown().await { + log::warn!("Host shutdown: {error}"); + } + } + + #[cfg(feature = "acp")] + pub(crate) async fn serve_acp(&self, read: R, write: W) -> Result<(), String> + where + R: tokio::io::AsyncRead + Unpin + Send + 'static, + W: tokio::io::AsyncWrite + Unpin + Send + 'static, + { + let account = self.account_gate.write().await; + let user_id = match self.validate_persisted_auth().await { + RestoreOutcome::Valid(user_id) => user_id, + _ => { + return Err( + "No saved Maple sign-in. Open the desktop app and sign in first.".into(), + ); + } + }; + let handle = self.service.handle_for_user(&user_id).await?; + let session = self.session_for(&user_id).await?; + let starting = handle.clone(); + let start = maple_agent::acp::shared_runtime_start(async move { + starting.start(session, None).await.map(|_| ()) + }); + let config = maple_agent::acp::load_acp_config(&local_data_root(), &user_id)?; + drop(account); + maple_agent::acp::serve_connection(handle, config, start, read, write).await + } + + /// Roots recently used by this account, most recent first. + pub async fn recent_project_roots( + &self, + user_id: &str, + ) -> Result, String> { + let handle = self.service.handle_for_user(user_id).await?; + let config = handle.load_config().await?; + let roots = handle.list_recent_project_roots().await?; + Ok(offered_project_roots(&config, roots)) + } + + /// 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, the + /// process launch directory, and the default Maple workspace 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 { + let handle = self.service.handle_for_user(user_id).await?; + // The listed Maple workspace is created the first time it is chosen. + if maple_agent::agent::is_default_maple_workspace(std::path::Path::new(path.trim())) { + maple_agent::agent::ensure_default_maple_workspace()?; + } + let mut registration = handle.save_recent_project_root(path).await?; + // The screen shows these roots as they are: offer the workspace in + // them the same way the project list does. + registration.roots = offered_project_roots( + ®istration.config, + std::mem::take(&mut registration.roots), + ); + Ok(registration) + } + + pub async fn list_sessions( + &self, + user_id: &str, + project_root: Option, + ) -> Result, String> { + let sessions = self + .service + .handle_for_user(user_id) + .await? + .list_sessions(project_root) + .await?; + 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 sessions = handle.list_sessions(None).await?; + let recent_roots = + offered_project_roots(&config, handle.list_recent_project_roots().await?) + .into_iter() + .map(|root| root.path) + .collect(); + // Same choice the screen's auto-select makes: the newest unarchived + // task under the root that the runtime will start in. An empty task + // is a draft an older build persisted; the screen's own new-task + // draft stands in for it. + let latest_id = sessions + .iter() + .find(|session| { + session.state != AgentTaskState::Archived + && session.message_count > 0 + && 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, + questions: self.service.pending_questions().await, + }) + } + + 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 + } + + /// Move a task between active, settled, and archived. The runtime + /// refuses to settle or archive a task while it runs. + pub async fn set_session_state( + &self, + user_id: &str, + session_id: &str, + state: AgentTaskState, + ) -> Result { + self.service + .handle_for_user(user_id) + .await? + .set_session_state(session_id.to_string(), state) + .await + } + + /// Delete a task and everything stored for it. The runtime refuses + /// while the task runs or an external surface holds it. + pub async fn delete_session(&self, user_id: &str, session_id: &str) -> Result<(), String> { + self.service + .handle_for_user(user_id) + .await? + .delete_session(session_id.to_string()) + .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 + } + + /// What the catalog says about a model: its vision and context window. + /// None when the catalog cannot be read. + pub async fn model_catalog_entry( + &self, + user_id: &str, + model: &str, + ) -> Result, String> { + self.service + .handle_for_user(user_id) + .await? + .model_catalog_entry(model) + .await + } + + /// Which voice endpoints the account offers. + pub async fn audio_capabilities( + &self, + user_id: &str, + ) -> Result { + self.service + .handle_for_user(user_id) + .await? + .audio_capabilities() + .await + } + + /// WAV audio for `text` in the given voice. + pub async fn synthesize_speech( + &self, + user_id: &str, + text: String, + voice: String, + speed: f32, + ) -> Result, String> { + self.service + .handle_for_user(user_id) + .await? + .synthesize_speech(&text, &voice, speed) + .await + } + + /// Transcript text for a WAV recording. + pub async fn transcribe_audio(&self, user_id: &str, wav: Vec) -> Result { + self.service + .handle_for_user(user_id) + .await? + .transcribe_audio(wav) + .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( + &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 + } + + /// 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 + } + + /// 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 or prompt template's prompt; + /// `None` when the command matches neither. + pub async fn resolve_slash_command( + &self, + user_id: &str, + working_dir: Option, + command: String, + args: String, + ) -> Result, String> { + let service = self.service.clone(); + let user_id = user_id.to_string(); + tokio::task::spawn_blocking(move || { + service.resolve_slash_command(Some(&user_id), 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()) + }) + } + + /// A session's context usage: (context tokens, context limit). The + /// tokens are the runtime's count of the task's context; 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> { + if self.account_scope(user_id).is_none() { + return Ok(None); + } + let handle = self.service.handle_for_user(user_id).await?; + 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) => handle + .context_limit_for_model(model) + .await? + .unwrap_or(200_000), + None => 200_000, + }, + }; + let tokens = handle.session_context_tokens(session_id).await?; + Ok(tokens.map(|tokens| (i64::try_from(tokens).unwrap_or(i64::MAX), 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 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 thinking_state( + &self, + user_id: &str, + session_id: Option<&str>, + model: &str, + ) -> Result { + self.service + .handle_for_user(user_id) + .await? + .thinking_state(session_id, model) + .await + } + + pub async fn set_thinking_level( + &self, + user_id: &str, + session_id: Option<&str>, + model: &str, + level: maple_agent::agent::ThinkingLevel, + ) -> Result { + self.service + .handle_for_user(user_id) + .await? + .set_thinking_level(session_id, model, level) + .await + } + + /// 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 + } + + /// 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 = + "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"; +const MACOS_SCREEN_RECORDING_SETTINGS_URL: &str = + "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture"; + +/// The settings pane that grants the permission the integration still needs. +/// +/// Which permission that is comes from `AgentIntegrationPermissions`, so the +/// pane Maple opens and the notice telling the user what to do there cannot +/// disagree about the order. +fn next_integration_setup_settings_url( + permissions: &AgentIntegrationPermissions, +) -> Option<&'static str> { + match permissions.first_missing()? { + AgentIntegrationPermissionKind::Accessibility => Some(MACOS_ACCESSIBILITY_SETTINGS_URL), + AgentIntegrationPermissionKind::ScreenRecording => { + Some(MACOS_SCREEN_RECORDING_SETTINGS_URL) + } + // The user installs a compositor helper themselves; there is no + // settings pane that grants it. + AgentIntegrationPermissionKind::DesktopHelper => None, + } +} + +async fn open_integration_setup_settings( + permissions: &AgentIntegrationPermissions, +) -> Result<(), String> { + let Some(url) = next_integration_setup_settings_url(permissions) else { + return Ok(()); + }; + #[cfg(target_os = "macos")] + { + let status = + tokio::task::spawn_blocking(move || Command::new("/usr/bin/open").arg(url).status()) + .await + .map_err(|error| format!("Failed to start macOS System Settings: {error}"))? + .map_err(|error| format!("Failed to open macOS System Settings: {error}"))?; + if !status.success() { + return Err(format!( + "Failed to open macOS System Settings (exit status {:?})", + status.code() + )); + } + } + #[cfg(not(target_os = "macos"))] + let _ = url; + Ok(()) +} + +/// Extract `code` and `state` query parameters from an OAuth redirect URL. +/// Values are form-decoded: Google codes carry `%2F`, and a browser may +/// encode a space in `state` as `+`. +fn parse_oauth_callback(url: &str) -> Option<(String, String)> { + let query = url.split_once('?')?.1; + let mut code = None; + let mut state = None; + for pair in query.split(['&', '#']) { + if let Some((key, value)) = pair.split_once('=') { + match key { + "code" => code = Some(decode_query_value(value)), + "state" => state = Some(decode_query_value(value)), + _ => {} + } + } + } + Some((code?, state?)) +} + +/// `application/x-www-form-urlencoded` decoding of one query value. +fn decode_query_value(value: &str) -> String { + let spaced = value.replace('+', " "); + percent_encoding::percent_decode_str(&spaced) + .decode_utf8_lossy() + .into_owned() +} + +/// A user-facing message for a failed billing action; the server detail +/// goes to the log, not the screen. +fn billing_failure(error: String, fallback: &str) -> String { + log::warn!("{fallback}: {error}"); + format!("{fallback}. Try again, or use the pricing page on the web.") +} + +/// Hand a billing URL to the system browser. Only `https` URLs are opened: +/// the billing API is trusted, but a bad deploy must not launch anything +/// else through the browser handler. +pub fn open_in_browser(url: &str, what: &str) -> Result<(), String> { + if !url.starts_with("https://") { + log::warn!("refusing to open a non-https {what} URL"); + return Err(format!("Could not open {what}: unexpected link")); + } + webbrowser::open(url).map_err(|error| { + log::warn!("failed to open {what} in the browser: {error}"); + format!("Could not open {what} in your browser") + }) +} + +/// The server's API key name rule: 1 to 50 characters after trimming. +pub const MAX_API_KEY_NAME_LENGTH: usize = 50; + +pub fn validate_api_key_name(name: &str) -> Result { + let name = name.trim(); + if name.is_empty() { + return Err("Give the key a name".to_string()); + } + if name.chars().count() > MAX_API_KEY_NAME_LENGTH { + return Err(format!( + "Use at most {MAX_API_KEY_NAME_LENGTH} characters for the key name" + )); + } + Ok(name.to_string()) +} + +/// API key errors by status, matching the web app's wording. +fn api_key_error_message(error: MapleAccountError, fallback: &str) -> String { + match error { + MapleAccountError::Unauthorized => "API keys need a Pro, Max, or Team plan".to_string(), + MapleAccountError::Status(409) => "A key with that name already exists".to_string(), + MapleAccountError::Status(400) => "That key name is not allowed".to_string(), + MapleAccountError::Status(404) => "That key no longer exists".to_string(), + MapleAccountError::Status(429) => "You have reached the API key limit".to_string(), + other => account_error_message(other, fallback), + } +} + +/// Minimum password length, the same rule as Maple's web forms. +pub const MIN_PASSWORD_LENGTH: usize = 8; + +/// The web app's password rule: at least eight characters. +pub fn validate_new_password(password: &str) -> Result<(), String> { + if password.chars().count() < MIN_PASSWORD_LENGTH { + return Err(format!( + "Use at least {MIN_PASSWORD_LENGTH} characters for the new password" + )); + } + Ok(()) +} + +/// A user-facing message for an account call that failed. Backend detail +/// stays in the log; the status alone picks the wording. +fn account_error_message(error: MapleAccountError, fallback: &str) -> String { + match error { + MapleAccountError::Unauthorized => { + maple_agent::maple_api::AUTH_REJECTED_MESSAGE.to_string() + } + MapleAccountError::Status(status) => { + log::debug!("account request failed with status {status}"); + format!("{fallback}. Try again.") + } + MapleAccountError::Other(message) => { + log::debug!("account request failed: {message}"); + format!("{fallback}. Check your connection and try again.") + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn oauth_test_client() -> OpenSecretClient { + // Construct the SDK without making any network request. + OpenSecretClient::new("http://127.0.0.1:1").unwrap() + } + + #[tokio::test] + async fn oauth_completion_keeps_the_originating_client_and_consumes_only_its_callback() { + let store = PendingOAuthStore::default(); + let started = store.begin(OAuthProvider::Github, oauth_test_client()); + store + .set_state(&started, "fixture-state".to_string()) + .unwrap(); + + assert!( + store + .complete(OAuthProvider::Google, "fixture-state") + .is_err() + ); + assert!( + store + .complete(OAuthProvider::Github, "other-state") + .is_err() + ); + let completed = store + .complete(OAuthProvider::Github, "fixture-state") + .unwrap(); + assert!(Arc::ptr_eq(&started.client, &completed.client)); + assert!( + store + .complete(OAuthProvider::Github, "fixture-state") + .is_err() + ); + store.clear(&completed); + assert!( + store + .complete(OAuthProvider::Github, "fixture-state") + .is_err() + ); + } + + #[tokio::test] + async fn hosted_deadline_cancels_its_attempt_but_not_a_replacement() { + let store = Arc::new(PendingOAuthStore::default()); + let expired = store.begin(OAuthProvider::Google, oauth_test_client()); + store.expire_handoff_after(&expired, std::time::Duration::from_millis(10)); + let result = tokio::time::timeout( + std::time::Duration::from_secs(1), + expired.while_active(std::future::pending::>()), + ) + .await + .unwrap(); + assert_eq!(result, Err(OAUTH_CANCELLED_MESSAGE.to_string())); + assert!(store.0.lock().unwrap().is_none()); + + let old = store.begin(OAuthProvider::Google, oauth_test_client()); + store.expire_handoff_after(&old, std::time::Duration::from_millis(10)); + let replacement = store.begin(OAuthProvider::Apple, oauth_test_client()); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + assert!(!*replacement.cancelled.borrow()); + assert!(Arc::ptr_eq( + &store.0.lock().unwrap().as_ref().unwrap().attempt, + &replacement + )); + // Waiting on the hosted path cannot claim or consume a legacy attempt. + assert!(store.take_handoff(OAuthProvider::Apple).is_err()); + store + .set_state(&replacement, "fixture-state".to_string()) + .unwrap(); + assert!( + store + .complete(OAuthProvider::Apple, "fixture-state") + .is_ok() + ); + } + + #[tokio::test] + async fn oauth_replacement_rejects_late_start_and_preserves_new_attempt_during_old_cleanup() { + let store = PendingOAuthStore::default(); + let old = store.begin(OAuthProvider::Github, oauth_test_client()); + let old_guard = OAuthAttemptGuard { + store: &store, + attempt: &old, + retain: false, + }; + let current = store.begin(OAuthProvider::Google, oauth_test_client()); + assert!(store.set_state(&old, "old-state".to_string()).is_err()); + drop(old_guard); + store + .set_state(¤t, "current-state".to_string()) + .unwrap(); + let completed = store + .complete(OAuthProvider::Google, "current-state") + .unwrap(); + assert!(Arc::ptr_eq(&completed.client, ¤t.client)); + + assert!( + old.while_active::<()>(async { panic!("cancelled work must not run") }) + .await + .is_err() + ); + } + + #[tokio::test] + async fn oauth_cancellation_discards_an_inflight_completion_before_publication() { + let store = Arc::new(PendingOAuthStore::default()); + let attempt = store.begin(OAuthProvider::Apple, oauth_test_client()); + store + .set_state(&attempt, "fixture-state".to_string()) + .unwrap(); + let (entered_tx, entered_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = tokio::sync::oneshot::channel(); + let published = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let task_store = Arc::clone(&store); + let task_published = Arc::clone(&published); + let task = tokio::spawn(async move { + let attempt = task_store + .complete(OAuthProvider::Apple, "fixture-state") + .unwrap(); + let _guard = OAuthAttemptGuard { + store: &task_store, + attempt: &attempt, + retain: false, + }; + attempt + .while_active(async { + entered_tx.send(()).unwrap(); + release_rx.await.unwrap(); + task_published.store(true, std::sync::atomic::Ordering::SeqCst); + Ok(()) + }) + .await + }); + entered_rx.await.unwrap(); + store.cancel(); + let _ = release_tx.send(()); + let result = tokio::time::timeout(std::time::Duration::from_secs(2), task) + .await + .unwrap() + .unwrap(); + assert_eq!(result, Err(OAUTH_CANCELLED_MESSAGE.to_string())); + assert!(!published.load(std::sync::atomic::Ordering::SeqCst)); + assert!( + store + .complete(OAuthProvider::Apple, "fixture-state") + .is_err() + ); + } + + fn auth_snapshot( + user_id: &str, + session_id: &str, + revision: u64, + access_token: &str, + ) -> MapleApiAuthSnapshot { + MapleApiAuthSnapshot { + user_id: user_id.to_string(), + access_token: access_token.to_string(), + refresh_token: Some(format!("refresh-{access_token}")), + native_instance_id: "native-test".to_string(), + session_id: session_id.to_string(), + revision, + } + } + + #[test] + fn persisted_auth_compare_clear_preserves_a_new_session_and_rejects_late_rotation() { + let root = std::env::temp_dir().join(format!( + "maple-persisted-auth-race-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&root).unwrap(); + let path = root.join("auth.json"); + let store = PersistedAuthStore::new(path.clone(), "https://api.example".to_string()); + let old = auth_snapshot("same-user", "session-old", 2, "old-token"); + let new = auth_snapshot("same-user", "session-new", 1, "new-token"); + + store.claim_and_persist(&old); + let mut newer_old = old.clone(); + newer_old.revision = 3; + newer_old.access_token = "newer-old-token".to_string(); + store.persist_rotation(&newer_old); + store.claim_and_persist(&old); + assert_eq!( + store.read_record().unwrap().access_token, + "newer-old-token", + "a late same-session claim must not roll back a newer rotation" + ); + + store.claim_and_persist(&new); + store.clear_if_owned(&old); + assert_eq!( + store.read_record().unwrap().access_token, + "new-token", + "a delayed old logout must not erase a new same-account session" + ); + + let mut late_old = old.clone(); + late_old.revision = 3; + late_old.access_token = "late-old-token".to_string(); + store.persist_rotation(&late_old); + assert_eq!(store.read_record().unwrap().access_token, "new-token"); + + store.clear_if_owned(&new); + assert!(!path.exists()); + let mut late_new = new; + late_new.revision = 2; + late_new.access_token = "late-new-token".to_string(); + store.persist_rotation(&late_new); + assert!( + !path.exists(), + "a rotation published after sign-out must not recreate auth.json" + ); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn persistence_reports_write_failure_and_successful_restore() { + let root = std::env::temp_dir().join(format!("maple-auth-persistence-{}", Uuid::new_v4())); + std::fs::create_dir_all(&root).unwrap(); + let path = root.join("auth.json"); + let store = PersistedAuthStore::new(path.clone(), "https://api.example".to_string()); + let snapshot = auth_snapshot("fixture-user", "fixture-session", 1, "fixture-token"); + std::fs::create_dir(&path).unwrap(); + assert!( + !store.claim_and_persist(&snapshot), + "a directory cannot be replaced by credentials" + ); + assert!(store.load().is_none()); + std::fs::remove_dir(&path).unwrap(); + assert!(store.claim_and_persist(&snapshot)); + let restored = PersistedAuthStore::new(path, "https://api.example".to_string()) + .load() + .unwrap(); + assert_eq!(restored.user_id, snapshot.user_id); + assert_eq!(restored.access_token, snapshot.access_token); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn rejected_restore_clears_only_the_record_that_was_loaded() { + let root = std::env::temp_dir().join(format!( + "maple-persisted-auth-restore-race-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&root).unwrap(); + let path = root.join("auth.json"); + let store = PersistedAuthStore::new(path.clone(), "https://api.example".to_string()); + let old = auth_snapshot("old-user", "session-old", 1, "old-token"); + let new = auth_snapshot("new-user", "session-new", 1, "new-token"); + + store.claim_and_persist(&old); + let loaded = store.load().unwrap(); + store.claim_and_persist(&new); + store.clear_record_if_unchanged(&loaded); + assert_eq!(store.read_record().unwrap().user_id, "new-user"); + + store.clear_if_owned(&new); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn oauth_callback_values_are_form_decoded() { + let url = "http://localhost/cb?state=ab%20cd+ef&code=4%2F0AX4XfWh%3Dz&x=1#frag"; + assert_eq!( + parse_oauth_callback(url), + Some(("4/0AX4XfWh=z".to_string(), "ab cd ef".to_string())) + ); + assert_eq!( + parse_oauth_callback("http://localhost/cb?code=plain&state=s"), + Some(("plain".to_string(), "s".to_string())) + ); + assert_eq!(parse_oauth_callback("http://localhost/cb?code=only"), None); + assert_eq!(parse_oauth_callback("http://localhost/cb"), None); + } + + #[test] + fn malformed_client_id_falls_back_to_default() { + let default: Uuid = DEFAULT_CLIENT_ID.parse().unwrap(); + assert_eq!(client_id_from(None), default); + assert_eq!(client_id_from(Some("not-a-uuid")), default); + let custom = "123e4567-e89b-12d3-a456-426614174000"; + assert_eq!( + client_id_from(Some(custom)), + custom.parse::().unwrap() + ); + } + + fn macos_permissions( + accessibility: bool, + screen_recording: bool, + ) -> AgentIntegrationPermissions { + AgentIntegrationPermissions::default() + .with(AgentIntegrationPermissionKind::Accessibility, accessibility) + .with( + AgentIntegrationPermissionKind::ScreenRecording, + screen_recording, + ) + } + + #[test] + fn integration_setup_opens_one_missing_permission_at_a_time() { + assert_eq!( + next_integration_setup_settings_url(&macos_permissions(false, false)), + Some(MACOS_ACCESSIBILITY_SETTINGS_URL) + ); + assert_eq!( + next_integration_setup_settings_url(&macos_permissions(true, false)), + Some(MACOS_SCREEN_RECORDING_SETTINGS_URL) + ); + assert_eq!( + next_integration_setup_settings_url(&macos_permissions(true, true)), + None + ); + } +} + +#[cfg(test)] +mod app_dir_tests { + use super::{APP_DIR_NAME, LEGACY_APP_DIR_NAME, adopt_legacy_app_dir}; + + #[test] + fn legacy_app_dir_is_adopted_only_when_the_current_one_is_absent() { + let base = std::env::temp_dir().join(format!( + "maple-agent-adopt-{}-{}", + std::process::id(), + std::time::SystemTime::UNIX_EPOCH + .elapsed() + .map(|d| d.as_nanos()) + .unwrap_or_default() + )); + let legacy = base.join(LEGACY_APP_DIR_NAME); + let root = base.join(APP_DIR_NAME); + std::fs::create_dir_all(legacy.join("logs")).unwrap(); + std::fs::write(legacy.join("auth.json"), "saved").unwrap(); + + let note = adopt_legacy_app_dir(&root, true).expect("first start adopts the legacy dir"); + assert!(note.starts_with("adopted "), "{note}"); + assert_eq!( + std::fs::read_to_string(root.join("auth.json")).unwrap(), + "saved" + ); + assert!(root.join("logs").is_dir()); + assert!(!legacy.exists()); + + // Nothing to do once the current directory exists. + assert_eq!(adopt_legacy_app_dir(&root, true), None); + + // A legacy directory that reappears next to a current one is left alone. + std::fs::create_dir_all(&legacy).unwrap(); + std::fs::write(legacy.join("auth.json"), "stale").unwrap(); + assert_eq!(adopt_legacy_app_dir(&root, true), None); + assert_eq!( + std::fs::read_to_string(root.join("auth.json")).unwrap(), + "saved" + ); + assert!(legacy.join("auth.json").is_file()); + + // No legacy directory: nothing happens and nothing is created. + let fresh = base.join("fresh").join(APP_DIR_NAME); + std::fs::create_dir_all(fresh.parent().unwrap()).unwrap(); + assert_eq!(adopt_legacy_app_dir(&fresh, true), None); + assert!(!fresh.exists()); + + std::fs::remove_dir_all(&base).unwrap(); + } + + #[test] + fn packaged_profiles_never_adopt_legacy_local_or_research_state() { + let base = std::env::temp_dir().join(format!( + "maple-agent-profile-isolation-{}-{}", + std::process::id(), + std::time::SystemTime::UNIX_EPOCH + .elapsed() + .unwrap() + .as_nanos() + )); + for namespace in [ + "maple-gpui", + "maple-agent", + "cloud.opensecret.maple", + "cloud.opensecret.maple.dev", + ] { + let path = base.join(namespace); + std::fs::create_dir_all(&path).unwrap(); + std::fs::write(path.join("auth.json"), namespace).unwrap(); + } + for namespace in ["maple-agent-dev", "maple-agent-prod"] { + let root = base.join(namespace); + assert!(adopt_legacy_app_dir(&root, false).is_none()); + assert!(!root.exists()); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join("auth.json"), namespace).unwrap(); + } + for namespace in [ + "maple-gpui", + "maple-agent", + "cloud.opensecret.maple", + "cloud.opensecret.maple.dev", + "maple-agent-dev", + "maple-agent-prod", + ] { + assert_eq!( + std::fs::read_to_string(base.join(namespace).join("auth.json")).unwrap(), + namespace + ); + } + assert_eq!(APP_DIR_NAME, crate::profile::DATA_NAMESPACE); + std::fs::remove_dir_all(base).unwrap(); + } +} diff --git a/apps/maple-agent/app/src/billing.rs b/apps/maple-agent/app/src/billing.rs index 895fd2476..4a1656c7d 100644 --- a/apps/maple-agent/app/src/billing.rs +++ b/apps/maple-agent/app/src/billing.rs @@ -17,7 +17,9 @@ pub const PRICING_URL: &str = crate::profile::PRICING_URL; /// Plan tier derived from the product name, the way the web app gates /// features (`hasProAccess`, `hasApiAccess`, tier ordering). -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize, +)] pub enum PlanTier { Free, Starter, @@ -142,7 +144,7 @@ pub fn configured_billing_api_url() -> String { /// Plan quota shown in the sidebar footer: plan name, percent of the /// quota used in the current period, and the date the period resets. -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub struct PlanUsage { pub plan_label: String, pub percent_used: u8, diff --git a/apps/maple-agent/app/src/desktop.rs b/apps/maple-agent/app/src/desktop.rs index 458f5cf8f..a888de65d 100644 --- a/apps/maple-agent/app/src/desktop.rs +++ b/apps/maple-agent/app/src/desktop.rs @@ -55,6 +55,16 @@ impl MapleApp { events: Vec, cx: &mut Context, ) { + for event in &events { + if let maple_agent::agent::AgentServiceEvent::AccountChanged(user_id) = event + && &self.user_id != user_id + { + match user_id { + Some(user_id) => self.open_chat(user_id.clone(), cx), + None => self.show_login(cx), + } + } + } let chat = match &self.screen { Screen::Chat(chat) => Some(chat.clone()), _ => self.parked_chat.clone(), diff --git a/apps/maple-agent/app/src/host.rs b/apps/maple-agent/app/src/host.rs new file mode 100644 index 000000000..0d07958e1 --- /dev/null +++ b/apps/maple-agent/app/src/host.rs @@ -0,0 +1,642 @@ +//! One on-demand process owns a profile's backend. Frontends attach over +//! OS-private IPC; closing a frontend never tears down another client's runtime. + +mod ipc; +pub mod protocol; +pub(crate) mod receipts; + +use crate::backend::{self, LocalBackend}; +use maple_agent::agent::AgentServiceEvent; +use protocol::{ConnectionMode, Hello, Message, Request, Welcome}; +use serde::{Serialize, de::DeserializeOwned}; +use std::{ + collections::HashMap, + sync::{ + Arc, Mutex, RwLock, + atomic::{AtomicU64, Ordering}, + }, + time::Duration, +}; +use tokio::sync::{Semaphore, broadcast, mpsc, oneshot}; + +const START_TIMEOUT: Duration = Duration::from_secs(15); +const IDLE_GRACE: Duration = Duration::from_secs(2); +const MAX_CLIENTS: usize = 32; +const MAX_CALLS: usize = 16; +type Pending = HashMap>>; + +struct ClientState { + pending: Mutex>, + user_id: RwLock>, +} + +impl ClientState { + fn disconnect(&self) { + if let Some(pending) = self.pending.lock().unwrap().take() { + for (_, sender) in pending { + let _ = sender.send(Err( + "The Maple host disconnected. Reload the task before retrying a message." + .into(), + )); + } + } + } +} + +struct PendingCall<'a> { + state: &'a ClientState, + id: u64, +} +impl Drop for PendingCall<'_> { + fn drop(&mut self) { + if let Some(pending) = self.state.pending.lock().unwrap().as_mut() { + pending.remove(&self.id); + } + } +} + +pub struct RemoteClient { + endpoint: ipc::Endpoint, + requests: mpsc::Sender, + state: Arc, + next_id: AtomicU64, + #[cfg_attr(not(feature = "desktop"), allow(dead_code))] + events: tokio::sync::Mutex>>, + permits: Semaphore, +} + +impl RemoteClient { + pub async fn connect(api_url: &str) -> Result { + let endpoint = ipc::Endpoint::new(api_url)?; + let (connection, welcome) = connect(&endpoint, ConnectionMode::Client, true).await?; + let state = Arc::new(ClientState { + pending: Mutex::new(Some(HashMap::new())), + user_id: RwLock::new(welcome.user_id), + }); + let (requests, mut request_rx) = mpsc::channel::(MAX_CALLS); + let (event_tx, event_rx) = mpsc::channel(256); + let closed = tokio_util::sync::CancellationToken::new(); + let reader_closed = closed.clone(); + let (mut read, mut write) = tokio::io::split(connection); + let reader_state = state.clone(); + tokio::spawn(async move { + loop { + let message = tokio::select! { + _ = reader_closed.cancelled() => break, + message = protocol::read::(&mut read) => match message { Ok(message) => message, Err(_) => break }, + }; + match message { + Message::Response { id, result } => { + if let Some(sender) = reader_state + .pending + .lock() + .unwrap() + .as_mut() + .and_then(|pending| pending.remove(&id)) + { + let _ = sender.send(result); + } + } + Message::Event(event) => { + if let AgentServiceEvent::AccountChanged(user_id) = &*event { + *reader_state.user_id.write().unwrap() = user_id.clone(); + } + if event_tx.try_send(*event).is_err() { + break; + } + } + } + } + reader_closed.cancel(); + reader_state.disconnect(); + // Delivery waits only after closing the socket; a stalled UI never + // backpressures the host. Reconnect requires a fresh snapshot. + let _ = event_tx.send(AgentServiceEvent::HostDisconnected).await; + }); + let writer_state = state.clone(); + tokio::spawn(async move { + loop { + let request = tokio::select! { + _ = closed.cancelled() => break, + request = request_rx.recv() => match request { Some(request) => request, None => break }, + }; + let result = tokio::select! { + _ = closed.cancelled() => break, + result = protocol::write(&mut write, &request) => result, + }; + if result.is_err() { + break; + } + } + closed.cancel(); + writer_state.disconnect(); + }); + Ok(Self { + endpoint, + requests, + state, + next_id: AtomicU64::new(1), + events: tokio::sync::Mutex::new(Some(event_rx)), + permits: Semaphore::new(MAX_CALLS), + }) + } + + pub fn user_id(&self) -> Option { + self.state.user_id.read().unwrap().clone() + } + + #[cfg_attr(not(feature = "desktop"), allow(dead_code))] + pub async fn take_events(&self) -> Option> { + self.events.lock().await.take() + } + + pub async fn call( + &self, + method: &str, + params: impl Serialize, + ) -> Result { + let params = serde_json::to_value(params) + .map_err(|_| "Could not encode host request".to_string())?; + let _permit = self + .permits + .acquire() + .await + .map_err(|_| "Host disconnected".to_string())?; + let id = self.next_id.fetch_add(1, Ordering::Relaxed); + let (sender, reply) = oneshot::channel(); + self.state + .pending + .lock() + .unwrap() + .as_mut() + .ok_or("The Maple host disconnected")? + .insert(id, sender); + let _pending = PendingCall { + state: &self.state, + id, + }; + let send_id = (method == "send_message").then(|| uuid::Uuid::new_v4().to_string()); + let sent = self + .requests + .send(Request { + id, + method: method.into(), + params, + send_id: send_id.clone(), + }) + .await; + let result = if sent.is_ok() { + reply + .await + .unwrap_or_else(|_| Err("The Maple host disconnected".into())) + } else { + Err("The Maple host connection closed".into()) + }; + let disconnected = self.state.pending.lock().unwrap().is_none(); + let result = match (result, send_id) { + (Err(_), Some(id)) if disconnected => recover_send(&self.endpoint, &id).await?, + (result, _) => result?, + }; + serde_json::from_value(result) + .map_err(|_| "Incompatible Maple host response; restart Maple".into()) + } +} + +async fn handshake( + mut stream: ipc::Connection, + endpoint: &ipc::Endpoint, + mode: ConnectionMode, +) -> Result<(ipc::Connection, Welcome), String> { + protocol::write( + &mut stream, + &Hello { + version: protocol::VERSION, + profile: endpoint.profile.clone(), + build: protocol::BUILD.into(), + mode, + }, + ) + .await?; + let welcome: Result = protocol::read(&mut stream).await?; + let welcome = welcome?; + if welcome.version != protocol::VERSION || welcome.build != protocol::BUILD { + return Err( + "A different Maple host version is running. Quit that host before opening this build." + .into(), + ); + } + Ok((stream, welcome)) +} + +async fn connect( + endpoint: &ipc::Endpoint, + mode: ConnectionMode, + start: bool, +) -> Result<(ipc::Connection, Welcome), String> { + if let Ok(stream) = endpoint.connect().await { + return tokio::time::timeout(START_TIMEOUT, handshake(stream, endpoint, mode)) + .await + .map_err(|_| "Maple host did not answer".to_string())?; + } + if !start { + return Err("No Maple host is running for this profile".into()); + } + let startup = endpoint.lock("startup.lock")?; + let deadline = tokio::time::Instant::now() + START_TIMEOUT; + loop { + match startup.try_lock() { + Ok(()) => break, + Err(std::fs::TryLockError::WouldBlock) if tokio::time::Instant::now() < deadline => { + tokio::time::sleep(Duration::from_millis(50)).await + } + Err(error) => return Err(format!("Cannot acquire Maple host startup lock: {error}")), + } + } + if let Ok(stream) = endpoint.connect().await { + return tokio::time::timeout_at(deadline, handshake(stream, endpoint, mode)) + .await + .map_err(|_| "Maple host did not answer".to_string())?; + } + spawn_host()?; + let deadline = tokio::time::Instant::now() + START_TIMEOUT; + loop { + if let Ok(stream) = endpoint.connect().await { + let result = tokio::time::timeout_at(deadline, handshake(stream, endpoint, mode)) + .await + .map_err(|_| "Maple host did not answer".to_string())?; + drop(startup); + return result; + } + if tokio::time::Instant::now() >= deadline { + return Err("Maple host did not start. See the profile's maple-agent.log.".into()); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +fn spawn_host() -> Result<(), String> { + let mut command = + std::process::Command::new(std::env::current_exe().map_err(|e| e.to_string())?); + command + .args(["host", "serve"]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + // Host startup must not choose the first editor's project as everyone's + // default working directory. Each task carries its own explicit cwd. + if let Some(home) = dirs::home_dir() { + command.current_dir(home); + } + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + // Only async-signal-safe setsid between fork and exec. + unsafe { + command.pre_exec(|| { + if libc::setsid() < 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok(()) + } + }); + } + } + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + command.creation_flags(0x00000008 | 0x00000200 | 0x01000000); // detached, new group, break away from client job + } + let mut child = command + .spawn() + .map_err(|e| format!("Cannot start Maple host: {e}"))?; + // Reap on Unix while the parent remains alive; the host is not kill-on-drop. + std::thread::spawn(move || { + let _ = child.wait(); + }); + Ok(()) +} + +pub fn run(api_url: String) -> Result<(), String> { + let endpoint = ipc::Endpoint::new(&api_url)?; + let ownership = endpoint.lock("host.lock")?; + ownership + .try_lock() + .map_err(|_| "A Maple host already owns this profile".to_string())?; + let runtime = tokio::runtime::Runtime::new().map_err(|e| e.to_string())?; + runtime.block_on(async { + let backend = Arc::new(LocalBackend::new( + api_url, + crate::settings::load_settings().effective_harness_instructions(), + )?); + serve(endpoint, backend).await + }) +} + +async fn serve(endpoint: ipc::Endpoint, backend: Arc) -> Result<(), String> { + let mut listener = endpoint.bind()?; + let receipts = Arc::new(receipts::Receipts::open( + &backend::local::host_data_root().join("send-receipts.db"), + )?); + let (events, _) = broadcast::channel::>(256); + let (stop_tx, mut stop_rx) = mpsc::channel::<()>(1); + let mut source = backend + .take_events() + .await + .ok_or("Host event source already taken")?; + let sink = events.clone(); + let pump = tokio::spawn(async move { + while let Some(event) = source.recv().await { + let _ = sink.send(Arc::new(event)); + } + }); + let mut clients = tokio::task::JoinSet::new(); + let mut idle = tokio::time::Instant::now() + START_TIMEOUT; + let shutdown = shutdown_signal(); + tokio::pin!(shutdown); + loop { + tokio::select! { + _ = stop_rx.recv() => break, + _ = &mut shutdown => break, + _ = tokio::time::sleep_until(idle), if clients.is_empty() => break, + result = clients.join_next(), if !clients.is_empty() => { + if let Some(Err(error)) = result { log::warn!("Host client ended unexpectedly: {error}"); } + if clients.is_empty() { idle = tokio::time::Instant::now() + IDLE_GRACE; } + } + incoming = listener.accept() => { + let connection = match incoming { Ok(connection) => connection, Err(error) => { log::warn!("Host rejected a connection: {error}"); continue; } }; + if clients.len() >= MAX_CLIENTS { drop(connection); continue; } + let backend = backend.clone(); let endpoint = endpoint.clone(); let events = events.clone(); let stop = stop_tx.clone(); let receipts = receipts.clone(); + clients.spawn(async move { + if let Err(error) = serve_client(connection, endpoint, backend, events, stop, receipts).await { + log::debug!("Host connection ended: {error}"); + } + }); + } + } + } + // Stop accepting first, then stop/drain the runtime while clients can still + // receive its final updates. Only this owner shuts down shared children. + drop(listener); + backend.shutdown_host().await; + clients.abort_all(); + while clients.join_next().await.is_some() {} + pump.abort(); + Ok(()) +} + +async fn serve_client( + mut connection: ipc::Connection, + endpoint: ipc::Endpoint, + backend: Arc, + events: broadcast::Sender>, + stop: mpsc::Sender<()>, + receipts: Arc, +) -> Result<(), String> { + let hello = tokio::time::timeout(START_TIMEOUT, protocol::read::(&mut connection)) + .await + .map_err(|_| "Host handshake timed out".to_string())??; + let mut subscription = events.subscribe(); + if hello.version != protocol::VERSION + || hello.profile != endpoint.profile + || hello.build != protocol::BUILD + { + protocol::write( + &mut connection, + &Err::( + "Incompatible Maple host or profile; explicitly quit the existing host first", + ), + ) + .await?; + return Ok(()); + } + protocol::write( + &mut connection, + &Ok::<_, String>(Welcome { + version: protocol::VERSION, + pid: std::process::id(), + build: protocol::BUILD.into(), + user_id: backend.saved_user_id(), + }), + ) + .await?; + match hello.mode { + ConnectionMode::Stop => { + let _ = stop.send(()).await; + return std::future::pending().await; + } + ConnectionMode::Acp => { + #[cfg(feature = "acp")] + { + let (read, write) = tokio::io::split(connection); + return backend.serve_acp(read, write).await; + } + #[cfg(not(feature = "acp"))] + return Err("This host was built without ACP".into()); + } + ConnectionMode::Client => {} + } + let _questions = backend.question_responder(); + let (mut input, mut output) = tokio::io::split(connection); + let (requests, mut request_rx) = mpsc::channel(1); + // Framed reads cannot be cancelled between the header and body. + let reader = tokio::spawn(async move { + while let Ok(request) = protocol::read::(&mut input).await { + if requests.send(request).await.is_err() { + break; + } + } + }); + let _reader = AbortOnDrop(reader.abort_handle()); + let (responses, mut response_rx) = mpsc::channel(MAX_CALLS); + let mut calls = tokio::task::JoinSet::new(); + let result = async { + loop { + tokio::select! { + request = request_rx.recv(), if calls.len() < MAX_CALLS => { + let Some(request) = request else { break; }; + let backend = backend.clone(); let responses = responses.clone(); let events = events.clone(); let receipts = receipts.clone(); + calls.spawn(async move { + let id = request.id; + let before = backend.saved_user_id(); + let result = dispatch_with_receipt(&backend, request, receipts).await; + let after = backend.saved_user_id(); + if before != after { let _ = events.send(Arc::new(AgentServiceEvent::AccountChanged(after))); } + let _ = responses.send(Message::Response { id, result }).await; + }); + } + _ = calls.join_next(), if !calls.is_empty() => {}, + response = response_rx.recv() => { + if let Some(response) = response { write_message(&mut output, &response).await?; } + } + event = subscription.recv() => { + // A lagging frontend reloads persisted state; it cannot stall + // the host or silently miss an arbitrary portion of the stream. + let event = event.map_err(|_| "Host event stream fell behind; reconnect and reload".to_string())?; + write_message(&mut output, &Message::Event(Box::new((*event).clone()))).await?; + } + } + } + Ok::<(), String>(()) + }.await; + // Accepted mutations finish even if their response cannot be delivered. + drop(response_rx); + while calls.join_next().await.is_some() {} + result +} + +pub fn stop(api_url: &str) -> Result<(), String> { + let runtime = tokio::runtime::Runtime::new().map_err(|e| e.to_string())?; + runtime.block_on(async { + let endpoint = ipc::Endpoint::new(api_url)?; + let (mut connection, _) = connect(&endpoint, ConnectionMode::Stop, false).await?; + use tokio::io::AsyncReadExt; + let mut byte = [0]; + tokio::time::timeout(Duration::from_secs(45), connection.read(&mut byte)) + .await + .map_err(|_| "Maple host is still shutting down".to_string())? + .map(|_| ()) + .map_err(|e| e.to_string()) + }) +} + +#[cfg(feature = "acp")] +pub fn acp(api_url: &str) -> Result<(), String> { + let runtime = tokio::runtime::Runtime::new().map_err(|e| e.to_string())?; + let result = runtime.block_on(async { + let endpoint = ipc::Endpoint::new(api_url)?; + let (stream, _) = connect(&endpoint, ConnectionMode::Acp, true).await?; + let (mut read, mut write) = tokio::io::split(stream); + let mut input = tokio::io::stdin(); let mut output = tokio::io::stdout(); + tokio::select! { + result = tokio::io::copy(&mut input, &mut write) => result.map(|_| ()).map_err(|e| e.to_string()), + result = tokio::io::copy(&mut read, &mut output) => result.map(|_| ()).map_err(|e| e.to_string()), + } + }); + // Tokio's stdin uses an uncancellable blocking read. If the host closes + // first (for example on sign-out), runtime Drop would wait for the editor + // to write to stdin before this adapter could exit. + runtime.shutdown_background(); + result +} + +struct AbortOnDrop(tokio::task::AbortHandle); +impl Drop for AbortOnDrop { + fn drop(&mut self) { + self.0.abort(); + } +} + +async fn write_message( + output: &mut (impl tokio::io::AsyncWrite + Unpin), + message: &Message, +) -> Result<(), String> { + tokio::time::timeout(START_TIMEOUT, protocol::write(output, message)) + .await + .map_err(|_| "Host client stopped receiving updates".to_string())? +} + +async fn shutdown_signal() { + #[cfg(unix)] + { + if let Ok(mut terminate) = + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + { + tokio::select! { _ = tokio::signal::ctrl_c() => {}, _ = terminate.recv() => {} } + return; + } + } + let _ = tokio::signal::ctrl_c().await; +} + +async fn dispatch_with_receipt( + backend: &LocalBackend, + request: Request, + receipts: Arc, +) -> Result { + if request.method == "send_outcome" { + let id: String = + serde_json::from_value(request.params).map_err(|_| "Invalid send identity")?; + return tokio::task::spawn_blocking(move || { + receipts + .outcome(&id) + .and_then(|result| serde_json::to_value(result).map_err(|e| e.to_string())) + }) + .await + .map_err(|e| e.to_string())?; + } + if request.method != "send_message" { + return backend::dispatch(backend, request).await; + } + let id = request + .send_id + .clone() + .ok_or("A send requires a stable identity")?; + let record_id = id.clone(); + let params = request.params.clone(); + let store = receipts.clone(); + match tokio::task::spawn_blocking(move || store.reserve(&record_id, ¶ms)) + .await + .map_err(|e| e.to_string())?? + { + Some(receipts::Outcome::Finished(result)) => return result, + Some(_) => { + return Err(format!( + "Send outcome unknown ({id}). Reload this task before sending again." + )); + } + None => {} + } + let result = backend::dispatch(backend, request).await; + let saved = result.clone(); + tokio::task::spawn_blocking(move || receipts.finish(&id, &saved)) + .await + .map_err(|e| e.to_string())??; + result +} + +async fn recover_send(endpoint: &ipc::Endpoint, id: &str) -> Result { + // Query only. Starting another host or replaying inference is never recovery. + let recovery = async { + let (mut connection, _) = connect(endpoint, ConnectionMode::Client, false).await?; + for _ in 0..20 { + protocol::write( + &mut connection, + &Request { + id: 1, + send_id: None, + method: "send_outcome".into(), + params: serde_json::json!(id), + }, + ) + .await?; + loop { + match protocol::read::(&mut connection).await? { + Message::Response { result, .. } => { + let outcome: receipts::Outcome = + serde_json::from_value(result?).map_err(|_| "Invalid send receipt")?; + match outcome { + receipts::Outcome::Finished(result) => return result, + receipts::Outcome::Missing => return Err( + "The host did not admit this message. It is safe to send again." + .into(), + ), + receipts::Outcome::Pending => break, + } + } + Message::Event(_) => {} + } + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + Err(format!( + "Send outcome unknown ({id}). Reload this task before sending again." + )) + }; + match tokio::time::timeout(Duration::from_secs(5), recovery).await { + Ok(Ok(result)) => Ok(result), + Ok(Err(error)) if error.starts_with("The host did not admit") => Err(error), + _ => Err(format!( + "Send outcome unknown ({id}). Reload this task before sending again; it may already be running." + )), + } +} diff --git a/apps/maple-agent/app/src/host/ipc.rs b/apps/maple-agent/app/src/host/ipc.rs new file mode 100644 index 000000000..1ea2907bb --- /dev/null +++ b/apps/maple-agent/app/src/host/ipc.rs @@ -0,0 +1,370 @@ +//! OS-local endpoints and startup arbitration. No TCP listener or credentials. + +use sha2::{Digest, Sha256}; +use std::{ + fs::{File, OpenOptions}, + path::PathBuf, +}; +use tokio::io::{AsyncRead, AsyncWrite}; + +pub trait Stream: AsyncRead + AsyncWrite + Unpin + Send {} +impl Stream for T {} +pub type Connection = Box; + +#[derive(Clone)] +pub struct Endpoint { + pub profile: String, + directory: PathBuf, + address: PathBuf, +} + +impl Endpoint { + pub fn new(api_url: &str) -> Result { + let config = crate::backend::app_config_root(); + let data = crate::backend::local::host_data_root(); + std::fs::create_dir_all(&config).map_err(|e| e.to_string())?; + std::fs::create_dir_all(&data).map_err(|e| e.to_string())?; + // The storage owner is independent of backend settings. A client + // pointing the same files at another backend must fail the handshake, + // not create a second writer to those files. + let storage = serde_json::to_vec(&( + std::fs::canonicalize(config).map_err(|e| e.to_string())?, + std::fs::canonicalize(data).map_err(|e| e.to_string())?, + crate::profile::DATA_NAMESPACE, + os_user_identity()?, + )) + .map_err(|e| e.to_string())?; + let namespace = format!("{:x}", Sha256::digest(&storage)); + let identity = serde_json::to_vec(&( + namespace.as_str(), + ( + cfg!(feature = "desktop"), + cfg!(feature = "acp"), + cfg!(feature = "proxy"), + ), + api_url, + crate::billing::configured_billing_api_url(), + crate::backend::local::configured_client_id(), + format!( + "{:?}", + maple_agent::open_secret_config::configured_pcr0_environment()? + ), + )) + .map_err(|e| e.to_string())?; + let profile = format!("{:x}", Sha256::digest(identity)); + #[cfg(unix)] + let directory = { + // Short even when XDG roots are long (macOS sun_path is 104 bytes). + let uid = unsafe { libc::geteuid() }; + let parent = PathBuf::from(format!("/tmp/maple-agent-{uid}")); + private_directory(&parent)?; + parent.join(&namespace[..24]) + }; + #[cfg(windows)] + let directory = crate::backend::local::host_data_root().join("host"); + private_directory(&directory)?; + #[cfg(unix)] + let address = directory.join("ipc.sock"); + #[cfg(windows)] + let address = PathBuf::from(format!(r"\\.\pipe\maple-agent-{namespace}")); + Ok(Self { + profile, + directory, + address, + }) + } + + pub fn lock(&self, name: &str) -> Result { + let mut options = OpenOptions::new(); + options.create(true).truncate(false).read(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC); + } + options + .open(self.directory.join(name)) + .map_err(|e| format!("Cannot open host lock: {e}")) + } + + fn address(&self) -> &std::path::Path { + &self.address + } + + pub async fn connect(&self) -> Result { + #[cfg(unix)] + { + tokio::net::UnixStream::connect(self.address()) + .await + .map(|s| Box::new(s) as Connection) + .map_err(|e| e.to_string()) + } + #[cfg(windows)] + { + use std::os::windows::io::AsRawHandle; + let stream = tokio::net::windows::named_pipe::ClientOptions::new() + .open(self.address()) + .map_err(|e| e.to_string())?; + verify_pipe_owner(stream.as_raw_handle())?; + Ok(Box::new(stream)) + } + } + + pub fn bind(&self) -> Result { + #[cfg(unix)] + { + use std::os::unix::fs::{FileTypeExt, PermissionsExt}; + let path = self.address().to_path_buf(); + if let Ok(metadata) = std::fs::symlink_metadata(&path) { + if !metadata.file_type().is_socket() { + return Err("Host endpoint is not a socket".into()); + } + std::fs::remove_file(&path).map_err(|e| e.to_string())?; + } + let listener = tokio::net::UnixListener::bind(&path).map_err(|e| e.to_string())?; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) + .map_err(|e| e.to_string())?; + Ok(Listener { + inner: listener, + path, + }) + } + #[cfg(windows)] + { + Ok(Listener { + inner: make_pipe(self.address(), true)?, + path: self.address().to_path_buf(), + }) + } + } +} + +fn private_directory(path: &std::path::Path) -> Result<(), String> { + let mut builder = std::fs::DirBuilder::new(); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + match builder.create(path) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(e) => return Err(format!("Cannot create host directory: {e}")), + } + let metadata = std::fs::symlink_metadata(path).map_err(|e| e.to_string())?; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Err("Unsafe host directory".into()); + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if metadata.uid() != unsafe { libc::geteuid() } || metadata.mode() & 0o077 != 0 { + return Err("Host directory must be private to this OS user".into()); + } + } + Ok(()) +} + +pub struct Listener { + #[cfg(unix)] + inner: tokio::net::UnixListener, + #[cfg(windows)] + inner: tokio::net::windows::named_pipe::NamedPipeServer, + path: PathBuf, +} + +impl Listener { + pub async fn accept(&mut self) -> Result { + #[cfg(unix)] + { + let (stream, _) = self.inner.accept().await.map_err(|e| e.to_string())?; + let credentials = stream.peer_cred().map_err(|e| e.to_string())?; + if credentials.uid() != unsafe { libc::geteuid() } { + return Err("Host peer belongs to another OS user".into()); + } + Ok(Box::new(stream)) + } + #[cfg(windows)] + { + self.inner.connect().await.map_err(|e| e.to_string())?; + let next = make_pipe(&self.path, false)?; + Ok(Box::new(std::mem::replace(&mut self.inner, next))) + } + } +} + +impl Drop for Listener { + fn drop(&mut self) { + #[cfg(unix)] + { + let _ = std::fs::remove_file(&self.path); + } + } +} + +#[cfg(windows)] +fn make_pipe( + path: &std::path::Path, + first: bool, +) -> Result { + use windows_sys::Win32::{ + Foundation::LocalFree, + Security::{ + Authorization::ConvertStringSecurityDescriptorToSecurityDescriptorW, + SECURITY_ATTRIBUTES, + }, + }; + // Owner rights and SYSTEM only. Named-pipe defaults also grant Everyone + // read access, so the default DACL is not sufficient for a control endpoint. + let descriptor: Vec = "D:P(A;;GA;;;OW)(A;;GA;;;SY)\0".encode_utf16().collect(); + let mut security = std::ptr::null_mut(); + let ok = unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + descriptor.as_ptr(), + 1, + &mut security, + std::ptr::null_mut(), + ) + }; + if ok == 0 { + return Err(std::io::Error::last_os_error().to_string()); + } + let attributes = SECURITY_ATTRIBUTES { + nLength: std::mem::size_of::() as u32, + lpSecurityDescriptor: security, + bInheritHandle: 0, + }; + let result = unsafe { + tokio::net::windows::named_pipe::ServerOptions::new() + .first_pipe_instance(first) + .reject_remote_clients(true) + .create_with_security_attributes_raw(path, &attributes as *const _ as *mut _) + }; + unsafe { + LocalFree(security); + } + result.map_err(|e| e.to_string()) +} + +#[cfg(windows)] +fn verify_pipe_owner(pipe: windows_sys::Win32::Foundation::HANDLE) -> Result<(), String> { + use std::{ + os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}, + ptr::null_mut, + }; + use windows_sys::Win32::{ + Foundation::LocalFree, + Security::{ + Authorization::{GetSecurityInfo, SE_KERNEL_OBJECT}, + EqualSid, GetTokenInformation, OWNER_SECURITY_INFORMATION, TOKEN_QUERY, TOKEN_USER, + TokenUser, + }, + System::Threading::{GetCurrentProcess, OpenProcessToken}, + }; + // Verify the server as well as restricting clients: an unrelated OS user + // must not pre-create our predictable pipe name and receive sign-in data. + unsafe { + let mut token = null_mut(); + if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) == 0 { + return Err(std::io::Error::last_os_error().to_string()); + } + let token = OwnedHandle::from_raw_handle(token); + let mut size = 0; + GetTokenInformation(token.as_raw_handle(), TokenUser, null_mut(), 0, &mut size); + if size == 0 { + return Err("Cannot read local user identity".into()); + } + let mut storage = vec![0usize; (size as usize).div_ceil(std::mem::size_of::())]; + if GetTokenInformation( + token.as_raw_handle(), + TokenUser, + storage.as_mut_ptr().cast(), + size, + &mut size, + ) == 0 + { + return Err(std::io::Error::last_os_error().to_string()); + } + let user = &*storage.as_ptr().cast::(); + let mut owner = null_mut(); + let mut descriptor = null_mut(); + let status = GetSecurityInfo( + pipe, + SE_KERNEL_OBJECT, + OWNER_SECURITY_INFORMATION, + &mut owner, + null_mut(), + null_mut(), + null_mut(), + &mut descriptor, + ); + if status != 0 { + return Err(format!("Cannot verify Maple host ownership ({status})")); + } + let matches = !owner.is_null() && EqualSid(owner, user.User.Sid) != 0; + LocalFree(descriptor); + if matches { + Ok(()) + } else { + Err("Maple host belongs to another OS user".into()) + } + } +} + +#[cfg(unix)] +fn os_user_identity() -> Result { + Ok(unsafe { libc::geteuid() }.to_string()) +} + +#[cfg(windows)] +fn os_user_identity() -> Result { + use std::{ + os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}, + ptr::null_mut, + }; + use windows_sys::Win32::{ + Foundation::LocalFree, + Security::{ + Authorization::ConvertSidToStringSidW, GetTokenInformation, TOKEN_QUERY, TOKEN_USER, + TokenUser, + }, + System::Threading::{GetCurrentProcess, OpenProcessToken}, + }; + unsafe { + let mut token = null_mut(); + if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) == 0 { + return Err(std::io::Error::last_os_error().to_string()); + } + let token = OwnedHandle::from_raw_handle(token); + let mut size = 0; + GetTokenInformation(token.as_raw_handle(), TokenUser, null_mut(), 0, &mut size); + if size == 0 { + return Err("Cannot read local user identity".into()); + } + let mut storage = vec![0usize; (size as usize).div_ceil(std::mem::size_of::())]; + if GetTokenInformation( + token.as_raw_handle(), + TokenUser, + storage.as_mut_ptr().cast(), + size, + &mut size, + ) == 0 + { + return Err(std::io::Error::last_os_error().to_string()); + } + let user = &*storage.as_ptr().cast::(); + let mut text = null_mut(); + if ConvertSidToStringSidW(user.User.Sid, &mut text) == 0 { + return Err(std::io::Error::last_os_error().to_string()); + } + let mut len = 0; + while *text.add(len) != 0 { + len += 1; + } + let sid = String::from_utf16_lossy(std::slice::from_raw_parts(text, len)); + LocalFree(text.cast()); + Ok(sid) + } +} diff --git a/apps/maple-agent/app/src/host/protocol.rs b/apps/maple-agent/app/src/host/protocol.rs new file mode 100644 index 000000000..10113655a --- /dev/null +++ b/apps/maple-agent/app/src/host/protocol.rs @@ -0,0 +1,131 @@ +//! Private, versioned local host messages. Payloads are never logged. + +use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; + +pub const VERSION: u32 = 1; +pub const BUILD: &str = concat!(env!("CARGO_PKG_VERSION"), ":", env!("MAPLE_GIT_SOURCE_SHA")); +// Ten 10 MiB image attachments fit after base64/JSON encoding. Reads check +// length before allocating. A connection admits only a bounded number of calls. +pub const MAX_FRAME: usize = 160 * 1024 * 1024; + +#[derive(Serialize, Deserialize)] +pub struct Hello { + pub version: u32, + pub profile: String, + pub build: String, + pub mode: ConnectionMode, +} + +#[derive(Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum ConnectionMode { + Client, + Acp, + Stop, +} + +#[derive(Serialize, Deserialize)] +pub struct Welcome { + pub version: u32, + pub pid: u32, + pub build: String, + pub user_id: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Request { + pub id: u64, + pub method: String, + #[serde(default)] + pub send_id: Option, + pub params: serde_json::Value, +} + +#[derive(Serialize, Deserialize)] +pub enum Message { + Response { + id: u64, + result: Result, + }, + Event(Box), +} + +pub async fn read(input: &mut (impl AsyncRead + Unpin)) -> Result { + let length = input + .read_u32() + .await + .map_err(|_| "Host connection closed".to_string())? as usize; + if length == 0 || length > MAX_FRAME { + return Err("Host message exceeds the size limit".into()); + } + let mut bytes = vec![0; length]; + input + .read_exact(&mut bytes) + .await + .map_err(|_| "Host connection closed".to_string())?; + serde_json::from_slice(&bytes).map_err(|_| "Invalid host message".into()) +} + +pub async fn write( + output: &mut (impl AsyncWrite + Unpin), + value: &T, +) -> Result<(), String> { + let bytes = + serde_json::to_vec(value).map_err(|_| "Could not encode host message".to_string())?; + if bytes.len() > MAX_FRAME { + return Err("Host message exceeds the size limit".into()); + } + output + .write_u32(bytes.len() as u32) + .await + .map_err(|_| "Host connection closed".to_string())?; + output + .write_all(&bytes) + .await + .map_err(|_| "Host connection closed".to_string())?; + output + .flush() + .await + .map_err(|_| "Host connection closed".into()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn fragmented_frames_preserve_request_boundaries() { + let (mut sender, mut receiver) = tokio::io::duplex(7); + let writer = tokio::spawn(async move { + for id in 0..20 { + write( + &mut sender, + &Request { + id, + send_id: None, + method: "test".into(), + params: serde_json::json!(["a payload larger than the pipe"]), + }, + ) + .await + .unwrap(); + } + }); + for id in 0..20 { + assert_eq!(read::(&mut receiver).await.unwrap().id, id); + } + writer.await.unwrap(); + } + + #[tokio::test] + async fn oversized_header_is_rejected_before_reading_or_allocating_a_body() { + let (mut sender, mut receiver) = tokio::io::duplex(8); + sender.write_u32(MAX_FRAME as u32 + 1).await.unwrap(); + assert!( + read::(&mut receiver) + .await + .unwrap_err() + .contains("size limit") + ); + } +} diff --git a/apps/maple-agent/app/src/host/receipts.rs b/apps/maple-agent/app/src/host/receipts.rs new file mode 100644 index 000000000..a08855458 --- /dev/null +++ b/apps/maple-agent/app/src/host/receipts.rs @@ -0,0 +1,153 @@ +//! A send gets an identity before it crosses IPC. Persist admission before +//! invoking the runtime, then its result. A lost reply can be queried; a host +//! crash between admission and completion is explicitly uncertain, never replayed. +//! This contains no prompts, images, credentials, or token events. + +use rusqlite::{Connection, OptionalExtension, params}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{path::Path, sync::Mutex}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum Outcome { + Missing, + Pending, + Finished(Result), +} + +pub struct Receipts(Mutex); + +impl Receipts { + pub fn open(path: &Path) -> Result { + let connection = Connection::open(path).map_err(|e| e.to_string())?; + maple_agent::private_file::set_owner_only_file(path).map_err(|e| e.to_string())?; + connection + .execute_batch( + "PRAGMA journal_mode=WAL; + CREATE TABLE IF NOT EXISTS sends ( + id TEXT PRIMARY KEY, fingerprint TEXT NOT NULL, + task TEXT NOT NULL, result TEXT + );", + ) + .map_err(|e| e.to_string())?; + Ok(Self(Mutex::new(connection))) + } + + /// Returns None only to the first caller. Reusing an ID with different + /// content is a caller error, not another prompt. + pub fn reserve(&self, id: &str, params: &serde_json::Value) -> Result, String> { + uuid::Uuid::parse_str(id).map_err(|_| "Invalid send identity".to_string())?; + let bytes = serde_json::to_vec(params).map_err(|e| e.to_string())?; + let fingerprint = format!("{:x}", Sha256::digest(bytes)); + let task = params + .pointer("/1/sessionId") + .and_then(serde_json::Value::as_str) + .ok_or("Invalid send parameters")?; + let connection = self.0.lock().unwrap(); + let existing: Option<(String, Option)> = connection + .query_row( + "SELECT fingerprint, result FROM sends WHERE id=?1", + [id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional() + .map_err(|e| e.to_string())?; + if let Some((saved, result)) = existing { + if saved != fingerprint { + return Err("Send identity was reused for different content".into()); + } + return result + .map(|result| { + serde_json::from_str(&result) + .map(Outcome::Finished) + .map(Some) + .map_err(|e| e.to_string()) + }) + .unwrap_or(Ok(Some(Outcome::Pending))); + } + connection + .execute( + "INSERT INTO sends(id,fingerprint,task) VALUES (?1,?2,?3)", + params![id, fingerprint, task], + ) + .map_err(|e| e.to_string())?; + Ok(None) + } + + pub fn finish( + &self, + id: &str, + result: &Result, + ) -> Result<(), String> { + let result = serde_json::to_string(result).map_err(|e| e.to_string())?; + self.0 + .lock() + .unwrap() + .execute( + "UPDATE sends SET result=?2 WHERE id=?1", + params![id, result], + ) + .map_err(|e| e.to_string())?; + Ok(()) + } + + pub fn outcome(&self, id: &str) -> Result { + let result: Option> = self + .0 + .lock() + .unwrap() + .query_row("SELECT result FROM sends WHERE id=?1", [id], |row| { + row.get(0) + }) + .optional() + .map_err(|e| e.to_string())?; + match result { + None => Ok(Outcome::Missing), + Some(None) => Ok(Outcome::Pending), + Some(Some(result)) => serde_json::from_str(&result) + .map(Outcome::Finished) + .map_err(|e| e.to_string()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lost_reply_and_incomplete_admission_survive_reopening_without_resending() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("receipts.db"); + let id = uuid::Uuid::new_v4().to_string(); + let params = serde_json::json!(["user", {"sessionId":"task", "text":"hello"}]); + let receipts = Receipts::open(&path).unwrap(); + assert_eq!(receipts.reserve(&id, ¶ms).unwrap(), None); + drop(receipts); + let receipts = Receipts::open(&path).unwrap(); + assert_eq!( + receipts.reserve(&id, ¶ms).unwrap(), + Some(Outcome::Pending) + ); + let result = Ok(serde_json::json!("run-1")); + receipts.finish(&id, &result).unwrap(); + drop(receipts); + let receipts = Receipts::open(&path).unwrap(); + assert_eq!( + receipts.outcome(&id).unwrap(), + Outcome::Finished(result.clone()) + ); + assert_eq!( + receipts.reserve(&id, ¶ms).unwrap(), + Some(Outcome::Finished(result)) + ); + assert!( + receipts + .reserve( + &id, + &serde_json::json!(["user", {"sessionId":"task", "text":"different"}]) + ) + .is_err() + ); + } +} diff --git a/apps/maple-agent/app/src/main.rs b/apps/maple-agent/app/src/main.rs index ae3b476ed..b0db23e3c 100644 --- a/apps/maple-agent/app/src/main.rs +++ b/apps/maple-agent/app/src/main.rs @@ -11,6 +11,7 @@ mod billing; #[cfg(feature = "desktop")] mod desktop; mod env; +mod host; mod hosted_oauth; #[cfg(feature = "desktop")] mod keymap; @@ -64,6 +65,11 @@ struct Cli { #[derive(Debug, Subcommand, PartialEq, Eq)] enum Mode { + /// Manage the local shared host. `serve` is normally started automatically. + Host { + #[command(subcommand)] + command: HostCommand, + }, /// Serve the Agent Client Protocol on stdio. /// /// Trailing arguments belong to the ACP client and are ignored. @@ -78,6 +84,14 @@ enum Mode { Login(LoginArgs), } +#[derive(Debug, Subcommand, PartialEq, Eq)] +enum HostCommand { + #[command(hide = true)] + Serve, + /// Stop this profile's host and all its running tasks. + Stop, +} + /// Settings for `maple-agent login`. The password is always prompted for /// so it never lands in shell history or a process listing. #[derive(Debug, Clone, Default, PartialEq, Eq, Args)] @@ -162,6 +176,17 @@ fn main() { #[cfg(feature = "desktop")] let _ = PROCESS_START.set(std::time::Instant::now()); match cli.mode { + Some(Mode::Host { command }) => { + init_logging(LogOutput::FileOnly); + let result = match command { + HostCommand::Serve => host::run(configured_api_url()), + HostCommand::Stop => host::stop(&configured_api_url()), + }; + if let Err(error) = result { + eprintln!("{error}"); + std::process::exit(1); + } + } Some(Mode::Acp { .. }) => { // stdout is the ACP channel. Logs go to the log file only, so // the client's stderr capture stays quiet. An inherited @@ -298,12 +323,7 @@ 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 user_id = backend.restore_now().ok_or_else(|| { - "No saved Maple sign-in. Open the desktop app and sign in first.".to_string() - })?; - backend.run_acp_stdio(&user_id) + host::acp(&configured_api_url()) } /// `maple-agent login`: sign in from a terminal. The saved session is the diff --git a/apps/maple-agent/app/src/ui/chat/commands.rs b/apps/maple-agent/app/src/ui/chat/commands.rs index d2dcc2766..d39398aca 100644 --- a/apps/maple-agent/app/src/ui/chat/commands.rs +++ b/apps/maple-agent/app/src/ui/chat/commands.rs @@ -200,7 +200,8 @@ 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"), + AgentBackend::for_test("http://127.0.0.1:9".to_string(), String::new()) + .expect("backend"), ); cx.new(|cx| ChatScreen::new_inner(backend, "user".to_string(), cx)) } diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index fb974280f..ac9f5e44e 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -411,6 +411,8 @@ pub struct ChatScreen { /// Task being opened whose snapshot has not landed yet, so the pane /// can say so instead of showing the previous task. loading_session: Option, + loading_timeline: HashMap, + snapshot_revision: u64, /// Virtualized transcript state; bottom-aligned like a chat log. list_state: gpui::ListState, /// Set when the transcript should jump to its newest content on the @@ -1041,6 +1043,8 @@ impl ChatScreen { notice: None, booting: true, loading_session: None, + loading_timeline: HashMap::new(), + snapshot_revision: 0, list_state: transcript_list_state(), tool_details: settings.tool_details, project_root: None, @@ -1213,6 +1217,7 @@ impl ChatScreen { summaries: HashMap, cx: &mut Context, ) { + self.handle_service_events(boot.questions, cx); self.project_root = boot.project_root; self.project_root_changed(cx); self.check_project_trust(cx); @@ -2205,6 +2210,9 @@ impl ChatScreen { }; let revision = *self.timeline_revisions.get(session_id).unwrap_or(&0); if matches!(mode, LoadMode::Select) && !self.is_selected(session_id) { + if self.loading_session.as_deref() != Some(session_id) { + self.loading_timeline.clear(); + } self.loading_session = Some(session_id.to_string()); cx.notify(); } @@ -2246,7 +2254,7 @@ impl ChatScreen { if matches!(mode, LoadMode::Reload) && !this.is_selected(&session_id) { return; } - let (detail, summaries) = match result { + let (mut detail, summaries) = match result { Ok(detail) => detail, Err(message) => { this.finish_loading(&session_id); @@ -2263,7 +2271,27 @@ impl ChatScreen { this.load_session(&session_id, mode, retries - 1, cx); return; } + // The response's cutoff includes live partials. Keep newer + // rows already delivered while the snapshot crossed IPC. + let mut newer: Vec<_> = if this.is_selected(&session_id) { + this.timeline.clone() + } else { + this.loading_timeline.values().cloned().collect() + }; + newer.sort_by_key(|item| item.revision); + for item in newer + .into_iter() + .filter(|item| item.revision > detail.timeline_revision) + { + if let Some(row) = detail.timeline.iter_mut().find(|row| row.id == item.id) { + *row = item; + } else { + detail.timeline.push(item); + } + } this.finish_loading(&session_id); + this.snapshot_revision = detail.timeline_revision; + this.loading_timeline.clear(); // Stored summaries stand in for the model calls the // timeline would otherwise request again. let summaries: HashMap = summaries @@ -4028,6 +4056,7 @@ impl ChatScreen { // task must not land in it. this.push_local_error("Send failed", &message, cx); if from_composer + && !message.starts_with("Send outcome unknown") && this.selected_session.as_deref() == Some(session_id.as_str()) { if let Some(composer) = this.composer.clone() { @@ -4047,6 +4076,7 @@ impl ChatScreen { fn push_local_error(&mut self, title: &str, message: &str, cx: &mut Context) { let item = AgentTimelineItem { + revision: 0, id: format!( "local-error-{}", std::time::SystemTime::now() @@ -4455,8 +4485,18 @@ impl ChatScreen { /// message/thinking text on the item with the same id; otherwise merge /// fields, keeping the previous value when the incoming field is absent. /// - /// Returns the item's index in the timeline. - fn apply_timeline_item(&mut self, session_id: &str, item: AgentTimelineItem) -> usize { + /// Returns the changed item's index, or None for an older snapshot event. + fn apply_timeline_item(&mut self, session_id: &str, item: AgentTimelineItem) -> Option { + if item.revision > 0 { + if let Some((index, _)) = self.timeline_index.get(&item.id) + && self.timeline[*index].revision >= item.revision + { + return None; + } + if item.revision <= self.snapshot_revision { + return None; + } + } // Streaming delivers one of these per chunk; move the payload // rather than copy it. let position = match self.timeline_index.get_mut(&item.id) { @@ -4469,6 +4509,7 @@ impl ChatScreen { let index = match position { Some(index) => { let AgentTimelineItem { + revision: incoming_revision, created_ms: incoming_created, item_type: incoming_type, role: incoming_role, @@ -4481,6 +4522,7 @@ impl ChatScreen { .. } = item; let existing = &mut self.timeline[index]; + existing.revision = incoming_revision; // The virtualized list caches item heights; tell it this // one changed. A remeasure keeps the previous height as a // hint and leaves the scroll position alone, so a stream @@ -4524,7 +4566,7 @@ impl ChatScreen { .timeline_revisions .entry(session_id.to_string()) .or_insert(0) += 1; - index + Some(index) } /// Insert or replace a session row; returns whether anything changed. @@ -4573,10 +4615,13 @@ impl ChatScreen { let completed = item.status.as_deref() == Some("completed"); let is_tool = matches!(item.item_type.as_str(), "tool" | "toolCall"); self.load_attachment_images_for(&item, cx); - if let Some(plan) = plan_entries(&item) { + let plan = plan_entries(&item); + let Some(index) = self.apply_timeline_item(session_id, item) else { + return; + }; + if let Some(plan) = plan { self.set_plan(plan); } - let index = self.apply_timeline_item(session_id, item); self.follow_application_stream(); // A newer item after a thinking block finalizes the block; that is // when its header summary is requested (it has no status of its @@ -4623,7 +4668,46 @@ impl ChatScreen { /// Apply one event; returns false when nothing visible changed. fn apply_service_event(&mut self, event: AgentServiceEvent, cx: &mut Context) -> bool { + let row = match &event { + AgentServiceEvent::TimelineItem { + session_id, item, .. + } => Some((session_id, item)), + AgentServiceEvent::Run { + session_id, + event: + maple_agent::agent::AgentRunEvent::TimelineItem(item) + | maple_agent::agent::AgentRunEvent::Error(item) + | maple_agent::agent::AgentRunEvent::QueuePromoted { item, .. }, + .. + } => Some((session_id, item)), + _ => None, + }; + if let Some((session_id, item)) = row + && self.loading_session.as_ref() == Some(session_id) + { + self.loading_timeline.insert(item.id.clone(), item.clone()); + } match event { + AgentServiceEvent::AccountChanged(_) => return false, + AgentServiceEvent::QuestionResolved { request_id } => { + let showing = self + .current_question() + .is_some_and(|question| question.request_id == request_id); + self.pending_questions + .retain(|question| question.request_id != request_id); + if showing { + self.question_selected.clear(); + } + } + AgentServiceEvent::ThinkingChanged { session_id, state } => { + if self.selected_session.as_deref() == Some(&session_id) { + self.thinking_generation += 1; + self.thinking = Some(state); + } + } + AgentServiceEvent::HostDisconnected => { + self.runtime_error = Some("The Maple host disconnected. Reopen Maple to reload your tasks before sending again.".into()); + } AgentServiceEvent::RuntimeStatus(mut status) => { // A runtime that just started points Goose at the account's // skills, which the first scan ran before; ask again. diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index 39a496dde..0d683b8c3 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -46,6 +46,7 @@ mod state_tests { fn item(id: &str, item_type: &str, text: Option<&str>) -> AgentTimelineItem { AgentTimelineItem { + revision: 0, id: id.to_string(), item_type: item_type.to_string(), role: None, @@ -75,7 +76,7 @@ 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()) + crate::backend::AgentBackend::for_test("http://127.0.0.1:9".to_string(), String::new()) .expect("backend"), ); // This gpui's test scheduler flags activity on other threads unless @@ -105,8 +106,11 @@ mod state_tests { let backend = { let _guard = SETTINGS_LOCK.lock(); std::sync::Arc::new( - crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string(), String::new()) - .expect("backend"), + crate::backend::AgentBackend::for_test( + "http://127.0.0.1:9".to_string(), + String::new(), + ) + .expect("backend"), ) }; chat_window_in(cx, backend, setup) @@ -436,7 +440,7 @@ mod state_tests { let screen = cx.new(|cx| { ChatScreen::new_inner( std::sync::Arc::new( - crate::backend::AgentBackend::new( + crate::backend::AgentBackend::for_test( "http://127.0.0.1:9".to_string(), String::new(), ) @@ -714,7 +718,7 @@ mod state_tests { cx, ); let backend = Arc::new( - AgentBackend::new("http://127.0.0.1:9".into(), String::new()).unwrap(), + AgentBackend::for_test("http://127.0.0.1:9".into(), String::new()).unwrap(), ); cx.open_window( gpui::WindowOptions { @@ -1068,8 +1072,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()) - .expect("backend"), + crate::backend::AgentBackend::for_test( + "http://127.0.0.1:9".to_string(), + String::new(), + ) + .expect("backend"), ); crate::desktop::register_key_bindings(cx); let mut chat = ChatScreen::new_without_start(backend, "user".to_string(), cx); @@ -1297,11 +1304,15 @@ mod state_tests { screen.update(cx, |this, cx| { this.summaries_enabled = true; // Running tools are not summarized. - let index = this.apply_timeline_item("s1", long_tool("tool-1", "running")); + let index = this + .apply_timeline_item("s1", long_tool("tool-1", "running")) + .unwrap(); this.maybe_summarize_tool(index, cx); assert!(this.summary_requests.is_empty()); // Completed tools with long output are queued once. - let index = this.apply_timeline_item("s1", long_tool("tool-1", "completed")); + let index = this + .apply_timeline_item("s1", long_tool("tool-1", "completed")) + .unwrap(); this.maybe_summarize_tool(index, cx); assert!(this.summary_requests.contains("tool-1")); assert_eq!(this.pending_summaries, 1); @@ -1312,14 +1323,14 @@ mod state_tests { short.status = Some("completed".to_string()); short.input = Some(serde_json::json!({"q": 1})); short.output = Some(serde_json::json!({"stdout": "ok"})); - let index = this.apply_timeline_item("s1", short); + let index = this.apply_timeline_item("s1", short).unwrap(); this.maybe_summarize_tool(index, cx); assert!(this.summary_requests.contains("tool-2")); let mut empty = item("tool-3", "tool", None); empty.status = Some("completed".to_string()); empty.input = Some(serde_json::json!({"q": 1})); empty.output = Some(serde_json::json!({"stdout": " "})); - let index = this.apply_timeline_item("s1", empty); + let index = this.apply_timeline_item("s1", empty).unwrap(); this.maybe_summarize_tool(index, cx); assert!(!this.summary_requests.contains("tool-3")); }); @@ -1334,8 +1345,9 @@ mod state_tests { this.active_runs .insert("s1".to_string(), "run-1".to_string()); let long_thought = "x".repeat(600); - let index = - this.apply_timeline_item("s1", item("th-1", "thinking", Some(&long_thought))); + let index = this + .apply_timeline_item("s1", item("th-1", "thinking", Some(&long_thought))) + .unwrap(); // The newest item of an active run is still streaming. this.maybe_summarize_thinking(index, cx); assert!(this.summary_requests.is_empty()); @@ -1356,8 +1368,9 @@ mod state_tests { screen.update(cx, |this, cx| { this.summaries_enabled = true; for n in 0..5 { - let index = - this.apply_timeline_item("s1", long_tool(&format!("t{n}"), "completed")); + let index = this + .apply_timeline_item("s1", long_tool(&format!("t{n}"), "completed")) + .unwrap(); this.maybe_summarize_tool(index, cx); } // Three ride at once; the rest wait instead of being dropped. @@ -2442,8 +2455,10 @@ mod state_tests { std::env::set_var("XDG_CONFIG_HOME", dir.join("config")); std::env::set_var("XDG_DATA_HOME", dir.join("data")); } - let backend = - crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string(), String::new()); + let backend = crate::backend::AgentBackend::for_test( + "http://127.0.0.1:9".to_string(), + String::new(), + ); unsafe { match previous_config { Some(value) => std::env::set_var("XDG_CONFIG_HOME", value), @@ -2815,10 +2830,12 @@ mod state_tests { this.apply_bootstrap( crate::backend::LocalBootstrap { + questions: Vec::new(), project_root: Some("/work/alpha".to_string()), sessions: vec![old.clone()], recent_roots: Vec::new(), latest: Some(maple_agent::agent::AgentSessionDetail { + timeline_revision: 0, session: old.clone(), timeline: vec![user_item("u1", "earlier")], mcp_errors: Vec::new(), @@ -3076,15 +3093,15 @@ mod state_tests { screen.update(cx, |this, _cx| { assert_eq!( this.apply_timeline_item("s1", item("a", "message", Some("1"))), - 0 + Some(0) ); assert_eq!( this.apply_timeline_item("s1", item("b", "message", Some("2"))), - 1 + Some(1) ); assert_eq!( this.apply_timeline_item("s1", item("a", "message", Some("3"))), - 0 + Some(0) ); assert_eq!(this.timeline_index.get("a"), Some(&(0, 1))); assert_eq!(this.timeline_index.get("b"), Some(&(1, 0))); @@ -4833,8 +4850,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()) - .expect("backend"), + crate::backend::AgentBackend::for_test( + "http://127.0.0.1:9".to_string(), + String::new(), + ) + .expect("backend"), ); let mut this = ChatScreen::new_inner(backend, "user".to_string(), cx); this.selected_session = Some("s1".to_string()); @@ -4942,8 +4962,11 @@ 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()) - .expect("backend"), + crate::backend::AgentBackend::for_test( + "http://127.0.0.1:9".to_string(), + String::new(), + ) + .expect("backend"), ); let mut this = ChatScreen::new_inner(backend, "user".to_string(), cx); this.selected_session = Some("s1".to_string()); @@ -5087,8 +5110,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()) - .expect("backend"), + crate::backend::AgentBackend::for_test( + "http://127.0.0.1:9".to_string(), + String::new(), + ) + .expect("backend"), ); ChatScreen::new(backend, "user".to_string(), cx) }); @@ -5176,8 +5202,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()) - .expect("backend"), + crate::backend::AgentBackend::for_test( + "http://127.0.0.1:9".to_string(), + String::new(), + ) + .expect("backend"), ); crate::desktop::register_key_bindings(cx); let mut chat = ChatScreen::new_inner(backend, "user".to_string(), cx); @@ -5277,8 +5306,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()) - .expect("backend"), + crate::backend::AgentBackend::for_test( + "http://127.0.0.1:9".to_string(), + String::new(), + ) + .expect("backend"), ); crate::desktop::register_key_bindings(cx); let mut chat = ChatScreen::new_without_start(backend, "user".to_string(), cx); @@ -5370,8 +5402,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()) - .expect("backend"), + crate::backend::AgentBackend::for_test( + "http://127.0.0.1:9".to_string(), + String::new(), + ) + .expect("backend"), ); crate::desktop::register_key_bindings(cx); let mut chat = ChatScreen::new_without_start(backend, "user".to_string(), cx); @@ -5563,8 +5598,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()) - .expect("backend"), + crate::backend::AgentBackend::for_test( + "http://127.0.0.1:9".to_string(), + String::new(), + ) + .expect("backend"), ); crate::desktop::register_key_bindings(cx); let mut chat = ChatScreen::new_without_start(backend, "user".to_string(), cx); @@ -5656,8 +5694,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()) - .expect("backend"), + crate::backend::AgentBackend::for_test( + "http://127.0.0.1:9".to_string(), + String::new(), + ) + .expect("backend"), ); crate::desktop::register_key_bindings(cx); let mut chat = ChatScreen::new_without_start(backend, "user".to_string(), cx); @@ -5721,8 +5762,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()) - .expect("backend"), + crate::backend::AgentBackend::for_test( + "http://127.0.0.1:9".to_string(), + String::new(), + ) + .expect("backend"), ); crate::desktop::register_key_bindings(cx); let mut chat = ChatScreen::new_without_start(backend, "user".to_string(), cx); diff --git a/apps/maple-agent/app/src/ui/login.rs b/apps/maple-agent/app/src/ui/login.rs index 311d51a0b..58f391dcb 100644 --- a/apps/maple-agent/app/src/ui/login.rs +++ b/apps/maple-agent/app/src/ui/login.rs @@ -495,8 +495,9 @@ 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::for_test("http://127.0.0.1:9".to_string(), String::new()).unwrap(), + ); let screen = cx.new(|cx| { crate::desktop::register_key_bindings(cx); LoginScreen::new(backend, cx) @@ -582,8 +583,9 @@ mod tests { fn assert_committed_success_after_back(flow: OAuthFlow, 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::for_test("http://127.0.0.1:9".to_string(), String::new()).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); @@ -628,8 +630,9 @@ mod tests { #[gpui::test] fn hosted_wait_has_no_paste_step_and_back_remains_usable(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::for_test("http://127.0.0.1:9".to_string(), String::new()).unwrap(), + ); let screen = cx.new(|cx| LoginScreen::new(backend, cx)); screen.update(cx, |this, cx| { this.oauth = OAuthFlow::Hosted { @@ -664,8 +667,9 @@ mod tests { #[gpui::test] async fn waiting_bridge_drops_view_and_aborts_backend_work(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::for_test("http://127.0.0.1:9".to_string(), String::new()).unwrap(), + ); let screen = cx.new(|cx| LoginScreen::new(backend.clone(), cx)); let weak = screen.downgrade(); let (owned, released) = tokio::sync::oneshot::channel::<()>(); diff --git a/apps/maple-agent/app/src/ui/settings/navigation.rs b/apps/maple-agent/app/src/ui/settings/navigation.rs index 2336765e9..25505dd7b 100644 --- a/apps/maple-agent/app/src/ui/settings/navigation.rs +++ b/apps/maple-agent/app/src/ui/settings/navigation.rs @@ -659,7 +659,7 @@ 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()) + crate::backend::AgentBackend::for_test("http://127.0.0.1:9".to_string(), String::new()) .expect("backend"), ); let settings = cx.new(|cx| { @@ -743,7 +743,7 @@ 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()) + crate::backend::AgentBackend::for_test("http://127.0.0.1:9".to_string(), String::new()) .expect("backend"), ); let settings = cx.new(|cx| { @@ -817,7 +817,7 @@ mod tests { } let backend = std::sync::Arc::new( - crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string(), String::new()) + crate::backend::AgentBackend::for_test("http://127.0.0.1:9".to_string(), String::new()) .expect("backend"), ); let settings = cx.new(|cx| { @@ -888,7 +888,7 @@ mod tests { } let backend = std::sync::Arc::new( - crate::backend::AgentBackend::new("http://127.0.0.1:9".to_string(), String::new()) + crate::backend::AgentBackend::for_test("http://127.0.0.1:9".to_string(), String::new()) .expect("backend"), ); let settings = cx.new(|cx| { @@ -970,7 +970,7 @@ 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()) + crate::backend::AgentBackend::for_test("http://127.0.0.1:9".to_string(), String::new()) .expect("backend"), ); cx.new(|cx| { diff --git a/apps/maple-agent/crates/maple-agent/src/acp/convert.rs b/apps/maple-agent/crates/maple-agent/src/acp/convert.rs index 6b97415a7..d6b26165b 100644 --- a/apps/maple-agent/crates/maple-agent/src/acp/convert.rs +++ b/apps/maple-agent/crates/maple-agent/src/acp/convert.rs @@ -26,6 +26,8 @@ pub(super) const MAX_ACP_TOOL_TEXT_CHARS: usize = 16_000; #[derive(Default)] pub(super) struct AcpProjection { tools: HashSet, + revisions: HashMap, + pub(super) snapshot_revision: u64, streamed: HashMap, } @@ -58,6 +60,28 @@ pub(super) fn acp_session_config_options( } } +pub(super) fn add_thinking_option( + options: &mut Vec, + state: &crate::agent::AgentThinkingState, +) { + if state.levels.len() < 2 { + return; + } + options.push( + SessionConfigOption::select( + "thought_level", + "Thinking", + state.current.as_str(), + state + .levels + .iter() + .map(|level| SessionConfigSelectOption::new(level.as_str(), level.as_str())) + .collect::>(), + ) + .category(SessionConfigOptionCategory::ThoughtLevel), + ); +} + /// Parse a whole message shaped like a slash command. Mirrors the desktop /// composer: one leading `/`, a name without further slashes, optional args. pub(super) fn parse_slash_command(text: &str) -> Option<(String, String)> { @@ -262,13 +286,20 @@ pub(super) fn timeline_update( projection: &mut AcpProjection, replay: bool, ) -> Option { + if item.revision > 0 { + let previous = projection.revisions.entry(item.id.clone()).or_default(); + if item.revision <= *previous { + return None; + } + *previous = item.revision; + } match item.item_type.as_str() { "message" if item.role.as_deref() == Some("user") => { let text = item.text.as_ref().filter(|_| replay)?; - Some(SessionUpdate::UserMessageChunk(text_chunk( - text.clone(), - &item.id, - ))) + let _ = text; + projection + .unsent_text(item) + .map(|text| SessionUpdate::UserMessageChunk(text_chunk(text, &item.id))) } "message" if item.role.as_deref() == Some("assistant") => projection .unsent_text(item) @@ -304,10 +335,15 @@ impl AcpProjection { // The settled text: what streamed is its start, and the rest goes // now. A caller cannot take back text, so a message that settled // differently from its stream is left as it streamed. - let streamed = self.streamed.remove(&item.id).unwrap_or_default(); - text.strip_prefix(streamed.as_str()) + let streamed = self.streamed.entry(item.id.clone()).or_default(); + let suffix = text + .strip_prefix(streamed.as_str()) .filter(|rest| !rest.is_empty()) - .map(str::to_string) + .map(str::to_string); + if suffix.is_some() { + *streamed = text.to_string(); + } + suffix } } diff --git a/apps/maple-agent/crates/maple-agent/src/acp/handler.rs b/apps/maple-agent/crates/maple-agent/src/acp/handler.rs index 58864e317..22d240fd4 100644 --- a/apps/maple-agent/crates/maple-agent/src/acp/handler.rs +++ b/apps/maple-agent/crates/maple-agent/src/acp/handler.rs @@ -10,9 +10,8 @@ use agent_client_protocol::schema::v1::{ InitializeResponse, ListSessionsRequest, ListSessionsResponse, LoadSessionRequest, LoadSessionResponse, McpCapabilities, NewSessionRequest, NewSessionResponse, PromptCapabilities, PromptRequest, PromptResponse, SessionCapabilities, - SessionCloseCapabilities, SessionDeleteCapabilities, SessionListCapabilities, - SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, SetSessionModeRequest, - SetSessionModeResponse, + SessionDeleteCapabilities, SessionListCapabilities, SetSessionConfigOptionRequest, + SetSessionConfigOptionResponse, SetSessionModeRequest, SetSessionModeResponse, }; use agent_client_protocol::util::MatchDispatchFrom; use agent_client_protocol::{ @@ -68,11 +67,13 @@ impl HandleDispatchFrom for MapleAcpHandler { MatchDispatchFrom::new(message, &cx) .if_request({ let context = Arc::clone(&context); + let observer_connection = cx.clone(); move |request: InitializeRequest, responder: Responder| async move { context.client_supports_form_elicitation.store( client_supports_form_elicitation(&request), Ordering::SeqCst, ); + context.start_observing(observer_connection).await; let capabilities = AgentCapabilities::new() .load_session(true) .prompt_capabilities( @@ -85,11 +86,11 @@ impl HandleDispatchFrom for MapleAcpHandler { .session_capabilities( SessionCapabilities::new() .list(SessionListCapabilities::new()) - .close(SessionCloseCapabilities::new()) .delete(SessionDeleteCapabilities::new()), ); responder.respond( InitializeResponse::new(ProtocolVersion::LATEST) + .meta(serde_json::from_value::>(serde_json::json!({"steering": {"supported": true}})).expect("literal metadata")) .agent_info(Implementation::new( "maple", env!("CARGO_PKG_VERSION"), @@ -135,7 +136,8 @@ impl HandleDispatchFrom for MapleAcpHandler { |request: CloseSessionRequest, responder: Responder| async move { let task_context = Arc::clone(&context); respond_in_background(&context, responder, async move { - task_context.close_session(request).await + let _ = (task_context, request); + Err(agent_client_protocol::Error::method_not_found()) }) .await } @@ -217,8 +219,15 @@ impl HandleDispatchFrom for MapleAcpHandler { .await .otherwise({ let cx = cx.clone(); + let context = Arc::clone(&context); |message: Dispatch| async move { match message { + Dispatch::Request(request, responder) if request.method == "_session/steering" => { + let task_context = Arc::clone(&context); + respond_in_background(&context, responder, async move { + task_context.steer(&cx, request.params).await + }).await?; + } Dispatch::Request(_, responder) => { responder.respond_with_error( agent_client_protocol::Error::method_not_found(), @@ -229,7 +238,6 @@ impl HandleDispatchFrom for MapleAcpHandler { } Dispatch::Notification(_) => {} } - let _ = cx; Ok(()) } }) diff --git a/apps/maple-agent/crates/maple-agent/src/acp/mod.rs b/apps/maple-agent/crates/maple-agent/src/acp/mod.rs index d942b271e..bc483e700 100644 --- a/apps/maple-agent/crates/maple-agent/src/acp/mod.rs +++ b/apps/maple-agent/crates/maple-agent/src/acp/mod.rs @@ -12,15 +12,15 @@ mod config; mod convert; mod handler; mod session; +mod shared; mod transport; pub use config::{AgentAcpConfig, load_acp_config}; use config::{AgentAcpStats, normalize_config}; use convert::{ AcpProjection, COMPACTING_NOTICE, COMPACTION_COMPLETED_NOTICE, MAX_ACP_ERROR_CHARS, - NOTHING_TO_COMPACT_NOTICE, acp_available_commands, acp_config_options, - acp_session_config_options, acp_usage, bounded_chars, event_error_text, internal_acp_error, - outbound_error, parse_slash_command, project_trust_elicitation_request, + NOTHING_TO_COMPACT_NOTICE, acp_available_commands, acp_usage, bounded_chars, event_error_text, + internal_acp_error, outbound_error, parse_slash_command, project_trust_elicitation_request, project_trust_permission_decision, project_trust_permission_options, prompt_images, prompt_result_from_terminal, prompt_text, text_chunk, timeline_update, }; @@ -28,8 +28,7 @@ use handler::MapleAcpHandler; use session::{ AcpConnectionContext, AcpProjectTrustResolution, AcpPromptState, AcpSession, AcpSessionOperation, UnpublishedAcpSession, canonical_session_id, canonical_session_id_text, - close_registration_may_be_released, ensure_allowed_project_root, find_acp_session, - prepare_session_mcp, + ensure_allowed_project_root, find_acp_session, prepare_session_mcp, }; use transport::{ AcpOutboundReservation, AcpOutboundSendError, AcpOutboundTracker, BoundedLineReader, @@ -42,18 +41,18 @@ use crate::agent::{ AgentTimelineItem, CatalogEntry, NOTHING_TO_COMPACT_ERROR, }; use agent_client_protocol::schema::v1::{ - CancelNotification, CloseSessionRequest, CloseSessionResponse, ConfigOptionUpdate, - ContentBlock, DeleteSessionRequest, DeleteSessionResponse, ElicitationAction, - ElicitationContentValue, ListSessionsRequest, ListSessionsResponse, LoadSessionRequest, - LoadSessionResponse, NewSessionRequest, NewSessionResponse, PromptRequest, PromptResponse, - RequestPermissionRequest, SessionId, SessionInfo, SessionInfoUpdate, SessionNotification, - SessionUpdate, SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, - SetSessionModeRequest, SetSessionModeResponse, StopReason, TextContent, ToolCall, - ToolCallContent, ToolCallStatus, ToolKind, UsageUpdate, + CancelNotification, ConfigOptionUpdate, ContentBlock, DeleteSessionRequest, + DeleteSessionResponse, ElicitationAction, ElicitationContentValue, ListSessionsRequest, + ListSessionsResponse, LoadSessionRequest, LoadSessionResponse, NewSessionRequest, + NewSessionResponse, PromptRequest, PromptResponse, RequestPermissionRequest, SessionId, + SessionInfo, SessionInfoUpdate, SessionNotification, SessionUpdate, + SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, SetSessionModeRequest, + SetSessionModeResponse, StopReason, TextContent, ToolCall, ToolCallContent, ToolCallStatus, + ToolKind, UsageUpdate, }; use agent_client_protocol::{Agent as AcpAgent, Client, ConnectionTo, Lines}; use futures_util::StreamExt as _; -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; @@ -62,8 +61,6 @@ use tokio_util::codec::{FramedRead, LinesCodec}; use tokio_util::sync::CancellationToken; const ACP_CONNECTION_CLEANUP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); -const ACP_SYNTHETIC_STOP_DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); -const ACP_SESSION_CLOSE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); /// The runtime start an ACP connection shares between its sessions, so the /// handshake can answer before the runtime boots: the first session request @@ -96,6 +93,29 @@ pub async fn serve_stdio( serve(context, tokio::io::stdin(), tokio::io::stdout()).await } +/// Serve an ACP attachment to a shared host. The connection owns only its +/// subscriptions and temporary client resources, never the host runtime. +pub async fn serve_connection( + agent: AgentRuntimeHandle, + config: AgentAcpConfig, + runtime_start: SharedRuntimeStart, + input: R, + output: W, +) -> Result<(), String> +where + R: tokio::io::AsyncRead + Unpin + Send + 'static, + W: tokio::io::AsyncWrite + Unpin + Send + 'static, +{ + let config = Arc::new(RwLock::new(normalize_config(config)?)); + let context = AcpConnectionContext::new( + agent, + config, + Arc::new(AgentAcpStats::default()), + runtime_start, + ); + serve(context, input, output).await +} + /// Serve one connection on `input` and `output` until the peer closes it, /// then clean up every session it opened. async fn serve(context: Arc, input: R, output: W) -> Result<(), String> @@ -120,6 +140,7 @@ where let result = tokio::select! { result = serving => result.map_err(|error| bounded_chars(&error.to_string(), MAX_ACP_ERROR_CHARS)), _ = peer_eof.cancelled() => Ok(()), + _ = context.lifetime.cancelled() => Ok(()), }; context.cleanup().await; result @@ -157,8 +178,11 @@ impl AcpConnectionContext { stats, sessions: Mutex::new(HashMap::new()), session_operations: Mutex::new(HashMap::new()), - closing_sessions: Mutex::new(HashSet::new()), prompt_states: Mutex::new(HashMap::new()), + owned_runs: Mutex::new(HashMap::new()), + observing: AtomicBool::new(false), + eliciting: Mutex::new(std::collections::HashSet::new()), + question_responder: Mutex::new(None), background_tasks: Mutex::new(tokio::task::JoinSet::new()), finalization: Mutex::new(()), lifetime: CancellationToken::new(), @@ -240,6 +264,8 @@ impl AcpConnectionContext { model: model.clone(), available_models: available_models.clone(), catalog: None, + thinking: None, + projection: AcpProjection::default(), advertised_title: Some(created.detail.session.title.clone()), message_count: created.detail.session.message_count, created_here: true, @@ -255,8 +281,8 @@ impl AcpConnectionContext { drop(finalization); self.send_available_commands(cx, &session_id, &project_root) .await; - Ok(NewSessionResponse::new(session_id) - .config_options(acp_config_options(&model, &available_models))) + let options = self.refresh_config_options(&session_id).await?; + Ok(NewSessionResponse::new(session_id).config_options(options)) } async fn retire_session(&self, session_id: &str) { @@ -363,7 +389,7 @@ impl AcpConnectionContext { } async fn load_session( - &self, + self: &Arc, cx: &ConnectionTo, request: LoadSessionRequest, ) -> Result { @@ -415,10 +441,6 @@ impl AcpConnectionContext { return Err(agent_client_protocol::Error::internal_error() .data("The Maple ACP connection is closing")); } - if self.closing_sessions.lock().await.contains(&session_id) { - return Err(agent_client_protocol::Error::invalid_request() - .data("This ACP session is closing")); - } if self.sessions.lock().await.contains_key(&session_id) { return Err(agent_client_protocol::Error::invalid_request() .data("This ACP connection already owns the requested session")); @@ -455,12 +477,11 @@ impl AcpConnectionContext { .await; return Err(internal_acp_error("Maple returned no models".to_string())); }; + let snapshot_revision = attached.detail.timeline_revision; let timeline = attached.detail.timeline; let message_count = attached.detail.session.message_count; let finalization = self.finalization.lock().await; - if self.closed.load(Ordering::SeqCst) - || self.closing_sessions.lock().await.contains(&session_id) - { + if self.closed.load(Ordering::SeqCst) { drop(finalization); lease.release().await; self.remove_session_operation_if_same(&session_id, &operation) @@ -485,6 +506,8 @@ impl AcpConnectionContext { model: model.clone(), available_models: available_models.clone(), catalog: None, + thinking: None, + projection: AcpProjection::default(), advertised_title: Some(persisted.title.clone()), message_count, created_here: false, @@ -513,16 +536,18 @@ impl AcpConnectionContext { return Err(outbound_error(error)); } } + projection.snapshot_revision = snapshot_revision; + if let Some(session) = self.sessions.lock().await.get_mut(&session_id) { + session.projection = projection; + } drop(operation_guard); + for question in self.agent.pending_questions().await { + self.observe(cx, question).await.map_err(outbound_error)?; + } self.send_available_commands(cx, &session_id, &project_root) .await; - Ok( - LoadSessionResponse::new().config_options(acp_session_config_options( - &model, - &available_models, - message_count, - )), - ) + Ok(LoadSessionResponse::new() + .config_options(self.refresh_config_options(&session_id).await?)) } async fn list_sessions( @@ -599,76 +624,44 @@ impl AcpConnectionContext { Ok(response) } - async fn close_session( + async fn refresh_config_options( &self, - request: CloseSessionRequest, - ) -> Result { - let session_id = canonical_session_id(&request.session_id)?; - let operation = self - .session_operations - .lock() + session_id: &str, + ) -> Result< + Vec, + agent_client_protocol::Error, + > { + let summary = self + .agent + .load_session(session_id.to_string()) .await - .get(&session_id) - .cloned() - .ok_or_else(|| { - agent_client_protocol::Error::resource_not_found(Some(session_id.clone())) - .data("ACP session is not owned by this connection") + .map_err(internal_acp_error)? + .session; + let model = { + let mut sessions = self.sessions.lock().await; + let session = sessions.get_mut(session_id).ok_or_else(|| { + agent_client_protocol::Error::resource_not_found(Some(session_id.into())) })?; - self.closing_sessions - .lock() - .await - .insert(session_id.clone()); - // Stops the session's prompt, from its admission on, and its run. - operation.cancellation.cancel(); - // One absolute deadline for every potentially blocking close phase. - // Paseo awaits this response before terminating the ACP child, so a - // fresh timeout per phase could still hang it for multiples of the - // advertised close bound. - let close_deadline = tokio::time::Instant::now() + ACP_SESSION_CLOSE_TIMEOUT; - // A prompt holds this gate until its run ended. A broken provider - // must not make close hang forever, though: after the bound, revoke - // the lease at once and let its release finish in the background - // while Paseo can terminate the child process. - let operation_guard = - tokio::time::timeout_at(close_deadline, Arc::clone(&operation.gate).lock_owned()) - .await - .ok(); - let operation_drained = operation_guard.is_some(); - let Some(mut session) = self.sessions.lock().await.remove(&session_id) else { - if close_registration_may_be_released(true, operation_drained, true) { - self.remove_session_operation_if_same(&session_id, &operation) - .await; - self.closing_sessions.lock().await.remove(&session_id); - } - return Ok(CloseSessionResponse::new()); - }; - self.stats.active_sessions.fetch_sub(1, Ordering::SeqCst); - let discard = operation_drained && session.created_here && !session.prompted; - let cleanup_completed = if let Some(lease) = session.lease.take() { - if operation_drained { - tokio::time::timeout_at(close_deadline, async move { - if discard { - lease.discard_created_if_untouched().await; - } else { - lease.release().await; - } - }) - .await - .is_ok() - } else { - lease.revoke(); - drop(lease); - false + session.message_count = summary.message_count; + if let Some(model) = summary.model + && session.model != model + { + session.model = model; + session.catalog = None; } - } else { - operation_drained + session.model.clone() }; - if close_registration_may_be_released(true, operation_drained, cleanup_completed) { - self.remove_session_operation_if_same(&session_id, &operation) - .await; - self.closing_sessions.lock().await.remove(&session_id); - } - Ok(CloseSessionResponse::new()) + let thinking = self + .agent + .thinking_state(Some(session_id), &model) + .await + .map_err(internal_acp_error)?; + let mut sessions = self.sessions.lock().await; + let session = sessions.get_mut(session_id).ok_or_else(|| { + agent_client_protocol::Error::resource_not_found(Some(session_id.into())) + })?; + session.thinking = Some(thinking); + Ok(session.config_options()) } async fn set_config_option( @@ -676,20 +669,7 @@ impl AcpConnectionContext { request: SetSessionConfigOptionRequest, ) -> Result { let session_id = canonical_session_id(&request.session_id)?; - let operation = self - .session_operations - .lock() - .await - .get(&session_id) - .cloned() - .ok_or_else(|| { - agent_client_protocol::Error::resource_not_found(Some(session_id.clone())) - .data("ACP session is not owned by this connection") - })?; - let _operation_guard = Arc::clone(&operation.gate).lock_owned().await; - if self.closed.load(Ordering::SeqCst) - || self.closing_sessions.lock().await.contains(&session_id) - { + if self.closed.load(Ordering::SeqCst) { return Err( agent_client_protocol::Error::resource_not_found(Some(session_id.clone())) .data("ACP session is closing"), @@ -720,17 +700,44 @@ impl AcpConnectionContext { return Err(agent_client_protocol::Error::invalid_params() .data("Maple tasks are model-locked after their first message")); } - session.model = model.to_string(); - session.catalog = None; + let model = model.to_string(); + drop(sessions); + self.agent + .set_task_model(&session_id, &model) + .await + .map_err(internal_acp_error)?; + return Ok(SetSessionConfigOptionResponse::new( + self.refresh_config_options(&session_id).await?, + )); } - _ => { - return Err(agent_client_protocol::Error::invalid_params() - .data("Unknown Maple ACP configuration option")); + "thought_level" => { + let level = crate::agent::ThinkingLevel::parse(selected_value.0.as_ref()) + .ok_or_else(|| { + agent_client_protocol::Error::invalid_params() + .data("Unknown thinking level") + })?; + let model = session.model.clone(); + drop(sessions); + let state = self + .agent + .thinking_state(Some(&session_id), &model) + .await + .map_err(internal_acp_error)?; + if !state.levels.contains(&level) { + return Err(agent_client_protocol::Error::invalid_params() + .data("This model does not support that thinking level")); + } + self.agent + .set_thinking_level(Some(&session_id), &model, level) + .await + .map_err(internal_acp_error)?; + return Ok(SetSessionConfigOptionResponse::new( + self.refresh_config_options(&session_id).await?, + )); } + _ => Err(agent_client_protocol::Error::invalid_params() + .data("Unknown Maple ACP configuration option")), } - Ok(SetSessionConfigOptionResponse::new( - session.config_options(), - )) } /// Maple advertises no session modes, so no mode id can be selected. @@ -753,7 +760,6 @@ impl AcpConnectionContext { })?; let _operation_guard = Arc::clone(&operation.gate).lock_owned().await; if self.closed.load(Ordering::SeqCst) - || self.closing_sessions.lock().await.contains(&session_id) || !self.sessions.lock().await.contains_key(&session_id) { return Err( @@ -790,9 +796,7 @@ impl AcpConnectionContext { agent_client_protocol::Error::resource_not_found(Some(session_id.clone())) .data("ACP session is not owned by this connection") })?; - if self.closing_sessions.lock().await.contains(&session_id) - || !self.sessions.lock().await.contains_key(&session_id) - { + if !self.sessions.lock().await.contains_key(&session_id) { return Err( agent_client_protocol::Error::resource_not_found(Some(session_id.clone())) .data("ACP session is not available on this connection"), @@ -1154,9 +1158,7 @@ impl AcpConnectionContext { cancellation: &CancellationToken, ) -> Result { let protocol_session_id = SessionId::new(session_id.to_string()); - if self.closed.load(Ordering::SeqCst) - || self.closing_sessions.lock().await.contains(session_id) - { + if self.closed.load(Ordering::SeqCst) { return Ok(PromptResponse::new(StopReason::Cancelled)); } match self @@ -1266,6 +1268,10 @@ impl AcpConnectionContext { } Err(error) => return Err(internal_acp_error(error)), }; + self.owned_runs + .lock() + .await + .insert(session_id.to_string(), run.run_id.clone()); let locked_config_options = { let mut sessions = self.sessions.lock().await; sessions.get_mut(session_id).and_then(|session| { @@ -1291,16 +1297,9 @@ impl AcpConnectionContext { Ok((response, stopped)) => (Ok(response), stopped), Err(error) => (Err(error), true), }; - if stopped_early { - // A turn that stopped early stops its run, and settles once the - // run has, within a bound; the next prompt waits for the rest. - cancellation.cancel(); - let _ = tokio::time::timeout( - ACP_SYNTHETIC_STOP_DRAIN_TIMEOUT, - wait_for_terminal(&mut terminal), - ) - .await; - } + // A projection failure or disconnected observer never cancels shared + // work. session/cancel is the explicit task-wide stop operation. + let _ = (stopped_early, &mut terminal); let turn_usage = usage.borrow().as_ref().copied().unwrap_or_default(); // ACP defines PromptResponse.usage as usage for this prompt turn. // Paseo stores it as currentTurnUsage, so cumulative session totals @@ -1376,7 +1375,10 @@ impl AcpConnectionContext { if overflowed.load(Ordering::Acquire) { break StreamStop::Overflowed; } - let event = events.recv().await; + let event = tokio::select! { + _ = self.lifetime.cancelled() => return Ok((PromptResponse::new(StopReason::Cancelled), true)), + event = events.recv() => event, + }; if overflowed.load(Ordering::Acquire) { break StreamStop::Overflowed; } @@ -1461,10 +1463,10 @@ impl AcpConnectionContext { }; let message = match stop { StreamStop::Overflowed => { - "Maple stopped this turn because its bounded ACP event stream overflowed." + "The ACP event stream fell behind. Reload this session to see the shared task." } StreamStop::UpdateTooLarge => { - "Maple stopped this turn because one ACP update exceeded the 4 MiB transport limit." + "An update exceeded the ACP transport limit. The shared task continues." } }; cancellation.cancel(); @@ -1542,9 +1544,13 @@ impl AcpConnectionContext { if let Some(state) = self.prompt_states.lock().await.get(session_id) { state.cancellation.cancel(); } + if self.sessions.lock().await.contains_key(session_id) { + let _ = self.agent.cancel_session(session_id).await; + } } async fn cleanup(&self) { + self.question_responder.lock().await.take(); let deadline = tokio::time::Instant::now() + ACP_CONNECTION_CLEANUP_TIMEOUT; { // Linearize closure with the last new-session commit. A task that @@ -1554,7 +1560,7 @@ impl AcpConnectionContext { self.closed.store(true, Ordering::SeqCst); self.lifetime.cancel(); } - // Every prompt and run stops with the connection's lifetime. + // Stop projections and pending admissions, not already admitted runs. for state in self.prompt_states.lock().await.values() { state.cancellation.cancel(); } @@ -1637,7 +1643,6 @@ impl AcpConnectionContext { } } self.session_operations.lock().await.clear(); - self.closing_sessions.lock().await.clear(); } } diff --git a/apps/maple-agent/crates/maple-agent/src/acp/session.rs b/apps/maple-agent/crates/maple-agent/src/acp/session.rs index 70baaa811..a0a52e7e5 100644 --- a/apps/maple-agent/crates/maple-agent/src/acp/session.rs +++ b/apps/maple-agent/crates/maple-agent/src/acp/session.rs @@ -34,8 +34,11 @@ pub(super) struct AcpConnectionContext { pub(super) stats: Arc, pub(super) sessions: Mutex>, pub(super) session_operations: Mutex>>, - pub(super) closing_sessions: Mutex>, pub(super) prompt_states: Mutex>, + pub(super) owned_runs: Mutex>, + pub(super) observing: AtomicBool, + pub(super) eliciting: Mutex>, + pub(super) question_responder: Mutex>, pub(super) background_tasks: Mutex>, pub(super) finalization: Mutex<()>, pub(super) lifetime: CancellationToken, @@ -52,6 +55,8 @@ pub(super) struct AcpSession { /// for each turn and for `usage_update`. `None` until the catalog is /// read, and again after a model switch. pub(super) catalog: Option, + pub(super) thinking: Option, + pub(super) projection: super::convert::AcpProjection, /// The session title last advertised to the caller; semantic-title /// updates are only sent when the title moves past this. pub(super) advertised_title: Option, @@ -107,19 +112,6 @@ pub(super) struct AcpSessionOperation { pub(super) cancellation: CancellationToken, } -pub(super) fn close_registration_may_be_released( - cancellation_completed: bool, - operation_drained: bool, - cleanup_completed: bool, -) -> bool { - // A timed-out load may already have passed its final core cancellation - // check. Keep both its exact operation registration and the closing - // tombstone until connection teardown so it can never publish a lease - // after session/close has returned. The same fence stays in place while - // run cancellation or exact-match lease cleanup is still settling. - cancellation_completed && operation_drained && cleanup_completed -} - impl AcpSessionOperation { pub(super) fn new(connection_lifetime: &CancellationToken) -> Arc { Arc::new(Self { @@ -131,7 +123,12 @@ impl AcpSessionOperation { impl AcpSession { pub(super) fn config_options(&self) -> Vec { - acp_session_config_options(&self.model, &self.available_models, self.message_count) + let mut options = + acp_session_config_options(&self.model, &self.available_models, self.message_count); + if let Some(state) = &self.thinking { + super::convert::add_thinking_option(&mut options, state); + } + options } } diff --git a/apps/maple-agent/crates/maple-agent/src/acp/shared.rs b/apps/maple-agent/crates/maple-agent/src/acp/shared.rs new file mode 100644 index 000000000..a4b88c571 --- /dev/null +++ b/apps/maple-agent/crates/maple-agent/src/acp/shared.rs @@ -0,0 +1,345 @@ +//! Projection of shared host tasks and the steering extension used by the +//! Codex and Claude ACP adapters. ACP v1 prompt responses still end with a turn. + +use super::*; +use serde_json::{Value, json}; + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct SteeringRequest { + session_id: SessionId, + prompt: Vec, + #[serde(rename = "_meta", default)] + meta: Value, +} + +impl AcpConnectionContext { + pub(super) async fn steer( + self: &Arc, + cx: &ConnectionTo, + params: Value, + ) -> Result { + let request: SteeringRequest = serde_json::from_value(params) + .map_err(|_| agent_client_protocol::Error::invalid_params())?; + let idle = request + .meta + .pointer("/steering/idleBehavior") + .and_then(Value::as_str); + if idle.is_some_and(|behavior| behavior != "promptRequired") { + return Err(agent_client_protocol::Error::invalid_params() + .data("Unsupported steering idleBehavior")); + } + let session_id = canonical_session_id(&request.session_id)?; + let (access, model) = { + let sessions = self.sessions.lock().await; + let session = sessions.get(&session_id).ok_or_else(|| { + agent_client_protocol::Error::resource_not_found(Some(session_id.clone())) + })?; + ( + session + .lease + .as_ref() + .ok_or_else(agent_client_protocol::Error::invalid_request)? + .access(), + session.model.clone(), + ) + }; + let catalog = self + .agent + .model_catalog_entry(&model) + .await + .map_err(internal_acp_error)?; + let injected = self + .agent + .steer_surface_message( + &access, + AgentSendMessageRequest { + session_id: session_id.clone(), + text: prompt_text(&request.prompt)?, + attachments: prompt_images(&request.prompt), + model: Some(model), + context_limit: None, + vision_capable: catalog.and_then(|entry| entry.vision) == Some(true), + steer: true, + queue_id: None, + }, + ) + .await + .map_err(internal_acp_error)?; + if injected { + return Ok(json!({"outcome": "injected"})); + } + if idle == Some("promptRequired") { + return Ok(json!({"outcome": "promptRequired", "reason": "noRunningTurn"})); + } + // Legacy adapters start a new turn when steering races with completion. + // Clients opting into promptRequired retain ownership of that fallback. + let prompt = PromptRequest::new(request.session_id, request.prompt); + let admission = self.begin_prompt(&prompt).await?; + let context = Arc::clone(self); + let cx = cx.clone(); + self.background_tasks.lock().await.spawn(async move { + if let Err(error) = context.prompt(&cx, admission).await { + log::debug!("ACP steering fallback ended: {error}"); + } + }); + Ok(json!({"outcome": "startedNewTurn"})) + } +} + +impl AcpConnectionContext { + pub(super) async fn start_observing(self: &Arc, cx: ConnectionTo) { + if self.observing.swap(true, Ordering::SeqCst) { + return; + } + if self.client_supports_form_elicitation.load(Ordering::SeqCst) { + *self.question_responder.lock().await = Some(self.agent.question_responder()); + } + let context = self.clone(); + let mut events = self.agent.subscribe(); + self.background_tasks.lock().await.spawn(async move { + loop { + let event = tokio::select! { + _ = context.lifetime.cancelled() => break, + event = events.recv() => match event { + Ok(event) => event, + Err(_) => { context.lifetime.cancel(); break; } + }, + }; + if context.observe(&cx, event).await.is_err() { + context.lifetime.cancel(); + break; + } + } + }); + } + + pub(super) async fn observe( + self: &Arc, + cx: &ConnectionTo, + event: crate::agent::AgentServiceEvent, + ) -> Result<(), AcpOutboundSendError> { + use crate::agent::AgentServiceEvent as Event; + let summary = match &event { + Event::SessionUpdated { session, .. } + | Event::Run { + event: AgentRunEvent::SessionUpdated(session), + .. + } => Some(session), + _ => None, + }; + if let Some(summary) = summary { + let changed = { + let mut sessions = self.sessions.lock().await; + if let Some(session) = sessions.get_mut(&summary.id) { + let changed = session.message_count != summary.message_count + || summary + .model + .as_ref() + .is_some_and(|model| model != &session.model); + session.message_count = summary.message_count; + if let Some(model) = &summary.model + && model != &session.model + { + session.model = model.clone(); + session.catalog = None; + session.thinking = None; + } + changed.then(|| session.config_options()) + } else { + None + } + }; + if let Some(options) = changed { + self.send_session_update( + cx, + SessionNotification::new( + summary.id.clone(), + SessionUpdate::ConfigOptionUpdate(ConfigOptionUpdate::new(options)), + ), + &self.lifetime, + ) + .await?; + } + } + let (session_id, item, direct) = match event { + Event::AccountChanged(_) => { + self.lifetime.cancel(); + return Ok(()); + } + Event::Question { + session_id, + request_id, + questions, + } => { + if self.client_supports_form_elicitation.load(Ordering::SeqCst) + && self.sessions.lock().await.contains_key(&session_id) + && self.eliciting.lock().await.insert(request_id.clone()) + { + let context = self.clone(); + let cx = cx.clone(); + self.background_tasks.lock().await.spawn(async move { + context + .elicit_question(&cx, session_id, request_id.clone(), questions) + .await; + context.eliciting.lock().await.remove(&request_id); + }); + } + return Ok(()); + } + Event::ThinkingChanged { session_id, state } => { + let mut sessions = self.sessions.lock().await; + let Some(session) = sessions.get_mut(&session_id) else { + return Ok(()); + }; + session.thinking = Some(state); + let update = SessionUpdate::ConfigOptionUpdate(ConfigOptionUpdate::new( + session.config_options(), + )); + drop(sessions); + // Reasoning may change during a prompt; never queue it behind + // the prompt's whole-turn history gate. + self.send_session_update( + cx, + SessionNotification::new(session_id, update), + &self.lifetime, + ) + .await?; + return Ok(()); + } + Event::Run { + session_id, + run_id, + event, + } => { + // The originating prompt has its own terminal-aware projection. + if self.owned_runs.lock().await.get(&session_id) == Some(&run_id) + || self.prompt_states.lock().await.contains_key(&session_id) + { + return Ok(()); + } + match event { + AgentRunEvent::TimelineItem(item) | AgentRunEvent::Error(item) => { + (session_id, Some(item), None) + } + AgentRunEvent::SessionUpdated(summary) => { + let update = self.title_update(&session_id, summary.title).await; + (session_id, None, update) + } + _ => return Ok(()), + } + } + Event::TimelineItem { + session_id, item, .. + } => (session_id, Some(item), None), + Event::SessionUpdated { + session_id, + session, + .. + } => { + let update = self.title_update(&session_id, session.title).await; + (session_id, None, update) + } + _ => return Ok(()), + }; + let operation = self + .session_operations + .lock() + .await + .get(&session_id) + .cloned(); + let Some(operation) = operation else { + return Ok(()); + }; + let _gate = tokio::select! { + _ = self.lifetime.cancelled() => return Ok(()), + gate = operation.gate.lock() => gate, + }; + let update = { + let mut sessions = self.sessions.lock().await; + let Some(session) = sessions.get_mut(&session_id) else { + return Ok(()); + }; + direct.or_else(|| { + item.as_ref() + .filter(|item| { + item.revision == 0 || item.revision > session.projection.snapshot_revision + }) + .and_then(|item| timeline_update(item, &mut session.projection, true)) + }) + }; + if let Some(update) = update { + self.send_session_update( + cx, + SessionNotification::new(session_id, update), + &self.lifetime, + ) + .await?; + } + Ok(()) + } + + async fn elicit_question( + &self, + cx: &ConnectionTo, + session_id: String, + request_id: String, + questions: Vec, + ) { + use agent_client_protocol::schema::v1::{ + CreateElicitationRequest, ElicitationFormMode, ElicitationSchema, + ElicitationSessionScope, StringPropertySchema, + }; + let mut resolved = self.agent.subscribe(); + if !self.agent.pending_questions().await.iter().any(|event| matches!(event, crate::agent::AgentServiceEvent::Question { request_id: id, .. } if id == &request_id)) { return; } + let message = questions + .iter() + .map(|question| { + let options = question + .options + .iter() + .map(|option| format!("- {}: {}", option.label, option.description)) + .collect::>() + .join("\n"); + format!("{}\n{}", question.question, options) + }) + .collect::>() + .join("\n\n"); + let schema = ElicitationSchema::new().property( + "answer", + StringPropertySchema::new().title("Your answer"), + true, + ); + let request = CreateElicitationRequest::new( + ElicitationFormMode::new(ElicitationSessionScope::new(session_id), schema), + message, + ); + let sent = cx.send_request(request); + let response = sent.block_task(); + tokio::pin!(response); + loop { + tokio::select! { + _ = self.lifetime.cancelled() => return, + event = resolved.recv() => match event { + Ok(crate::agent::AgentServiceEvent::QuestionResolved { request_id: id }) if id == request_id => return, + Err(_) => return, + _ => {}, + }, + result = &mut response => { + if let Ok(result) = result { + match result.action { + ElicitationAction::Accept(action) => { + if let Some(ElicitationContentValue::String(answer)) = action.content.as_ref().and_then(|content| content.get("answer")) { + let _ = self.agent.answer_question_via_handle(&request_id, answer.clone()).await; + } + } + ElicitationAction::Decline => { let _ = self.agent.answer_question_via_handle(&request_id, "The user declined to answer.".into()).await; } + _ => {}, // Closing a form is not a denial on other interfaces. + } + } + return; + } + } + } + } +} diff --git a/apps/maple-agent/crates/maple-agent/src/acp/tests.rs b/apps/maple-agent/crates/maple-agent/src/acp/tests.rs index c8f714e46..18c07c19e 100644 --- a/apps/maple-agent/crates/maple-agent/src/acp/tests.rs +++ b/apps/maple-agent/crates/maple-agent/src/acp/tests.rs @@ -2,7 +2,10 @@ //! and its conversions and checks one by one. use super::config::{MAX_ACP_CONNECTIONS, config_path, load_config}; -use super::convert::{acp_tool_update, client_supports_form_elicitation, timeline_tool_text}; +use super::convert::{ + acp_session_config_options, acp_tool_update, client_supports_form_elicitation, + timeline_tool_text, +}; use super::session::completed_runtime_start; use super::transport::is_session_update_line; use super::*; @@ -682,14 +685,6 @@ async fn session_operation_cancellation_keeps_close_behind_the_active_fence() { drop(closing); } -#[test] -fn timed_out_close_keeps_its_resurrection_fence() { - assert!(close_registration_may_be_released(true, true, true)); - assert!(!close_registration_may_be_released(false, true, true)); - assert!(!close_registration_may_be_released(true, false, true)); - assert!(!close_registration_may_be_released(true, true, false)); -} - fn row( id: &str, item_type: &str, @@ -698,6 +693,7 @@ fn row( merge: &str, ) -> AgentTimelineItem { AgentTimelineItem { + revision: 0, id: id.to_string(), item_type: item_type.to_string(), role: role.map(str::to_string), @@ -714,6 +710,7 @@ fn row( #[test] fn completed_replayed_tool_prefers_result_over_request_summary() { let item = AgentTimelineItem { + revision: 0, id: "tool-1".to_string(), item_type: "tool".to_string(), role: Some("assistant".to_string()), @@ -769,6 +766,7 @@ fn available_commands_lead_with_the_built_ins_plus_skills() { fn failed_replayed_tool_preserves_result_and_failure_badge_message() { let mut projection = AcpProjection::default(); let pending = AgentTimelineItem { + revision: 0, id: "tool-1".to_string(), item_type: "tool".to_string(), role: Some("assistant".to_string()), @@ -1220,3 +1218,171 @@ fn retained_terminal_results_preserve_all_stop_states() { "end_turn" ); } + +#[tokio::test] +async fn eof_detaches_from_an_active_shared_run_and_another_client_can_cancel_it() { + let harness = Harness::new().await; + harness.faux.push_hang(); + let (mut first, first_serving) = connect(&harness.handle, no_answers()); + let initialize = first.initialize(json!({})).await; + assert!(initialize["agentCapabilities"]["sessionCapabilities"]["close"].is_null()); + let task = first.new_session(harness.project.path()).await; + first + .request( + "session/prompt", + json!({"sessionId": task, "prompt": [{"type": "text", "text": "Wait"}]}), + ) + .await; + crate::agent::tests::eventually(|| !harness.faux.requests().is_empty()).await; + drop(first); + finish(first_serving).await; + assert!( + harness + .handle + .status() + .await + .unwrap() + .active_runs + .contains_key(&task) + ); + + let (mut second, second_serving) = connect(&harness.handle, no_answers()); + second.initialize(json!({})).await; + second + .call( + "session/load", + json!({"sessionId": task, "cwd": harness.project.path(), "mcpServers": []}), + ) + .await; + second + .send_line( + json!({"jsonrpc": "2.0", "method": "session/cancel", "params": {"sessionId": task}}), + ) + .await; + // A subsequent request is ordered after the cancellation notification. + second.call("session/list", json!({})).await; + assert!( + !harness + .handle + .status() + .await + .unwrap() + .active_runs + .contains_key(&task) + ); + drop(second); + finish(second_serving).await; +} + +#[tokio::test] +async fn steering_uses_the_shared_adapter_extension_and_preserves_idle_fallback() { + let harness = Harness::new().await; + let (mut client, serving) = connect(&harness.handle, no_answers()); + let initialize = client.initialize(json!({})).await; + assert_eq!(initialize["_meta"]["steering"]["supported"], true); + let task = client.new_session(harness.project.path()).await; + let idle = client.call("_session/steering", json!({"sessionId": task, "prompt": [{"type":"text", "text":"not consumed"}], "_meta":{"steering":{"idleBehavior":"promptRequired"}}})).await; + assert_eq!(idle["outcome"], "promptRequired"); + assert!(harness.faux.requests().is_empty()); + + harness.faux.push_message(vec![faux_tool_call( + "bash", + json!({"command": "sleep 0.5"}), + )]); + harness + .faux + .push_text("I followed the updated instruction."); + let prompt_id = client + .request( + "session/prompt", + json!({"sessionId": task, "prompt": [{"type": "text", "text": "Start"}]}), + ) + .await; + crate::agent::tests::eventually(|| !harness.faux.requests().is_empty()).await; + let steered = client.call("_session/steering", json!({"sessionId": task, "prompt": [{"type": "text", "text": "Now use the updated instruction"}]})).await; + assert_eq!(steered["outcome"], "injected"); + assert_eq!( + client.response(prompt_id).await["result"]["stopReason"], + "end_turn" + ); + let detail = harness.handle.load_session(task).await.unwrap(); + assert!(detail.timeline.iter().any(|item| { + item.role.as_deref() == Some("user") + && item + .text + .as_deref() + .is_some_and(|text| text.contains("updated instruction")) + })); + drop(client); + finish(serving).await; +} + +#[tokio::test] +async fn joining_a_stream_replays_partial_once_then_follows_live_text() { + let harness = Harness::with_faux( + pi_ai::faux::FauxProvider::new().with_chunk_delay(Duration::from_millis(25)), + ) + .await; + let reply = "A running desktop task can be joined from an editor while its response is streaming, without losing the prefix or duplicating any later chunks."; + harness.faux.push_text(reply); + let task = harness.create_task().await; + let _run = harness.send(&task, "Explain shared tasks").await; + let snapshot = loop { + let detail = harness.handle.load_session(task.clone()).await.unwrap(); + if detail.timeline.iter().any(|row| { + row.role.as_deref() == Some("assistant") + && row.text.as_ref().is_some_and(|text| !text.is_empty()) + }) { + break detail; + } + tokio::time::sleep(Duration::from_millis(5)).await; + }; + let partial = snapshot + .timeline + .iter() + .find(|row| row.role.as_deref() == Some("assistant")) + .unwrap(); + assert!(partial.revision > 0 && snapshot.timeline_revision >= partial.revision); + assert!(reply.starts_with(partial.text.as_deref().unwrap())); + assert!(partial.text.as_ref().unwrap().len() < reply.len()); + + let (mut client, serving) = connect(&harness.handle, no_answers()); + client.initialize(json!({})).await; + client + .call( + "session/load", + json!({"sessionId": task, "cwd": harness.project.path(), "mcpServers": []}), + ) + .await; + tokio::time::timeout(Duration::from_secs(10), async { + loop { + client.call("session/list", json!({})).await; + if !harness + .handle + .status() + .await + .unwrap() + .active_runs + .contains_key(&task) + { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .unwrap(); + // Flush observer notifications issued before the final request. + tokio::time::sleep(Duration::from_millis(50)).await; + client.call("session/list", json!({})).await; + let texts = client.texts("agent_message_chunk"); + assert_eq!( + texts + .iter() + .map(|(_, text)| text.as_str()) + .collect::(), + reply + ); + client.shutdown().await; + finish(serving).await; +} diff --git a/apps/maple-agent/crates/maple-agent/src/agent.rs b/apps/maple-agent/crates/maple-agent/src/agent.rs index 0f4584697..4ad82a042 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent.rs @@ -49,6 +49,7 @@ pub use config::{ }; pub use integrations::begin_integration_setup; pub use provider::CatalogEntry; +pub use questions::QuestionResponder; pub use runs::AgentRunHandle; use runtime::AgentRuntime; use store::TaskStore; @@ -112,15 +113,20 @@ pub trait AgentEventSink: Send + Sync + 'static { #[derive(Clone)] pub(crate) struct AgentEventDispatcher { sink: Arc, + subscribers: tokio::sync::broadcast::Sender, } impl AgentEventDispatcher { pub(crate) fn new(sink: Arc) -> Self { - Self { sink } + Self { + sink, + subscribers: tokio::sync::broadcast::channel(256).0, + } } } pub(crate) fn emit_agent_event(events: &AgentEventDispatcher, event: AgentServiceEvent) { + let _ = events.subscribers.send(event.clone()); events.sink.emit(&event); } @@ -211,6 +217,16 @@ impl MapleAgentService { } } + pub fn manage_question_availability(&self) { + self.state.questions.manage_availability(); + } + pub fn question_responder(&self) -> QuestionResponder { + self.state.questions.responder() + } + pub async fn pending_questions(&self) -> Vec { + self.state.questions.snapshot().await + } + /// Bind operations to one Maple account and its current data generation. pub async fn handle_for_user(&self, user_id: &str) -> Result { let account_scope = account_scope(user_id)?; @@ -297,6 +313,31 @@ impl MapleAgentService { .store(AGENT_SERVICE_DRAINING, Ordering::Release); } + /// Drain the profile's runtime during host shutdown, regardless of which + /// frontend or account originally started it. + pub async fn shutdown(&self) -> Result<(), String> { + self.begin_draining(); + let _lifecycle = self.state.lifecycle.lock().await; + let runtime = self.state.runtime.lock().await.take(); + if let Some(runtime) = runtime { + runtime.shutdown().await; + } + Ok(()) + } + + /// Stop and invalidate account handles before changing profile identity. + pub async fn sign_out(&self, user_id: &str) -> Result<(), String> { + let scope = account_scope(user_id)?; + let _lifecycle = self.state.lifecycle.lock().await; + self.advance_generation(&scope); + emit_agent_event(&self.host.events, AgentServiceEvent::AccountChanged(None)); + let runtime = self.state.runtime.lock().await.take(); + if let Some(runtime) = runtime { + runtime.shutdown().await; + } + Ok(()) + } + /// Admit work again after a shutdown that did not happen. pub fn reopen_after_failed_shutdown(&self) { self.state @@ -344,7 +385,6 @@ impl MapleAgentService { self.state.questions.clone() } - #[cfg(test)] fn advance_generation(&self, account_scope: &str) { let mut generations = self .state @@ -356,6 +396,19 @@ impl MapleAgentService { } impl AgentRuntimeHandle { + /// Subscribe before reading a task snapshot; projections reconcile updates + /// by their existing timeline item identities. + pub fn subscribe(&self) -> tokio::sync::broadcast::Receiver { + self.service.host.events.subscribers.subscribe() + } + + pub fn question_responder(&self) -> QuestionResponder { + self.service.question_responder() + } + pub async fn pending_questions(&self) -> Vec { + self.service.pending_questions().await + } + pub async fn verify_generation(&self) -> Result<(), String> { if self.service.generation(&self.account_scope) == self.generation { Ok(()) @@ -725,6 +778,7 @@ impl AgentRuntimeHandle { request_id: &str, answer: String, ) -> Result { + self.verify_generation().await?; Ok(self.service.answer_question(request_id, answer).await) } 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 b233e7e3f..c8a567e17 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/cua/task.rs b/apps/maple-agent/crates/maple-agent/src/agent/cua/task.rs index 9a3927e72..e0c954ba2 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/cua/task.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/cua/task.rs @@ -18,7 +18,9 @@ use super::{INSTRUCTIONS, INSTRUCTIONS_SECTION, missing_permission_message}; use crate::agent::integrations::{ CUA_CARD_DESCRIPTION, CUA_CARD_NAME, CUA_INTEGRATION_ID, cua_default, is_cua_identity, }; -use crate::agent::store::{TaskKind, TaskRow}; +#[cfg(test)] +use crate::agent::store::TaskKind; +use crate::agent::store::TaskRow; use crate::agent::{AgentRuntimeHandle, AgentSessionIntegrationKind, AgentSessionMcpServer}; /// The task setting that records the task's choice. @@ -63,9 +65,6 @@ pub(in crate::agent) fn session_row( device: Option, ready: bool, ) -> Option { - if row.kind != TaskKind::Desktop { - return None; - } let choice = task_choice(row); let set_up = choice.is_some() || device.is_some(); Some(AgentSessionMcpServer { @@ -238,12 +237,6 @@ impl AgentRuntimeHandle { let row = store .get(session_id)? .ok_or_else(|| format!("Failed to find Agent task {session_id}"))?; - if row.kind != TaskKind::Desktop { - return Err( - "Built-in CUA is available only to tasks running in the Maple desktop app" - .to_string(), - ); - } let not_found = || format!("Failed to find Agent task {session_id}"); let loaded = runtime.loaded_cua(session_id).await; if !enabled { @@ -346,6 +339,10 @@ mod tests { assert!(on.enabled && !on.available); assert_eq!(on.transport, "embedded"); - assert!(session_row(&row(TaskKind::Acp, Some(true)), Some(true), true).is_none()); + assert!( + session_row(&row(TaskKind::Acp, Some(true)), Some(true), true) + .unwrap() + .enabled + ); } } 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 8f6d616e9..72b264f8e 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 @@ -2126,6 +2126,7 @@ fn activity_row_item( _ => "completed", }; AgentTimelineItem { + revision: 0, id: row_id.to_string(), item_type: "tool".to_string(), role: Some("assistant".to_string()), diff --git a/apps/maple-agent/crates/maple-agent/src/agent/external_agents/task.rs b/apps/maple-agent/crates/maple-agent/src/agent/external_agents/task.rs index 3efc7b0e8..563a98ff1 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/external_agents/task.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/external_agents/task.rs @@ -1,11 +1,13 @@ //! Which external agents a task may use: those switched on in Settings that -//! the user also switched on for the task, in a desktop task. Each task +//! the user also switched on for the task. Each task //! starts with none; a choice applies from the task's next run. use serde_json::Value; use super::PROVIDERS; -use crate::agent::store::{TaskKind, TaskRow}; +#[cfg(test)] +use crate::agent::store::TaskKind; +use crate::agent::store::TaskRow; use crate::agent::{ AgentIntegration, AgentIntegrationAvailability, AgentRuntimeHandle, AgentSessionIntegrationKind, AgentSessionMcpServer, @@ -35,11 +37,8 @@ pub(super) fn set_chosen_providers(row: &mut TaskRow, providers: Vec) { } /// The providers a task's tools may use: switched on in Settings and for -/// the task, in a desktop task. +/// the task. pub(in crate::agent) fn task_providers(settings_on: &[String], row: &TaskRow) -> Vec { - if row.kind != TaskKind::Desktop { - return Vec::new(); - } let chosen = chosen_providers(row); PROVIDERS .iter() @@ -52,14 +51,11 @@ pub(in crate::agent) fn task_providers(settings_on: &[String], row: &TaskRow) -> } /// A task's rows in the MCP menu for the external agents switched on in -/// Settings, given their cards; none outside a desktop task. +/// Settings, given their cards. pub(in crate::agent) fn session_rows( cards: &[AgentIntegration], row: &TaskRow, ) -> Vec { - if row.kind != TaskKind::Desktop { - return Vec::new(); - } let chosen = chosen_providers(row); cards .iter() @@ -94,12 +90,9 @@ impl AgentRuntimeHandle { return Err("Unknown external agent integration".to_string()); } let store = self.store()?; - let row = store + store .get(session_id)? .ok_or_else(|| format!("Failed to find Agent task {session_id}"))?; - if row.kind != TaskKind::Desktop { - return Err("External agents are available only in desktop tasks".to_string()); - } let cards = self.external_agent_cards().await?; if enabled { let card = cards @@ -167,7 +160,10 @@ mod tests { task_providers(&on, &row(TaskKind::Desktop, &["claude", "codex", "other"])), ["codex", "claude"] ); - assert!(task_providers(&on, &row(TaskKind::Acp, &["codex"])).is_empty()); + assert_eq!( + task_providers(&on, &row(TaskKind::Acp, &["codex"])), + ["codex"] + ); assert!(task_providers(&on, &row(TaskKind::Desktop, &[])).is_empty()); } @@ -198,7 +194,10 @@ mod tests { assert!(rows[1].enabled && rows[1].available); assert_eq!(rows[1].kind, AgentSessionIntegrationKind::ExternalAgent); assert_eq!(rows[1].transport, "external_agent"); - assert!(session_rows(&cards, &row(TaskKind::Acp, &["claude"])).is_empty()); + assert_eq!( + session_rows(&cards, &row(TaskKind::Acp, &["claude"])).len(), + 2 + ); let off = vec![card( "codex", false, diff --git a/apps/maple-agent/crates/maple-agent/src/agent/integrations/tests.rs b/apps/maple-agent/crates/maple-agent/src/agent/integrations/tests.rs index d58f7bf85..58cbe1292 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/integrations/tests.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/integrations/tests.rs @@ -445,3 +445,44 @@ mod probes { } } } + +#[tokio::test] +async fn acp_created_tasks_inherit_the_hosts_computer_use_default() { + let harness = crate::agent::tests::Harness::new().await; + save_stored_integrations( + harness.handle.paths(), + &harness.handle.user_id, + &StoredIntegrations { + integrations: vec![StoredIntegration { + id: CUA_INTEGRATION_ID.into(), + enabled: true, + backend: StoredBackend::Embedded, + }], + ..Default::default() + }, + ) + .unwrap(); + let session = harness + .handle + .create_surface_session( + crate::agent::AgentCreateSessionRequest { + project_root: Some(harness.project.path().to_string_lossy().into_owned()), + title: None, + model: None, + context_limit: None, + mcp_server_names: None, + }, + vec![], + ) + .await + .unwrap(); + let row = harness + .handle + .store() + .unwrap() + .get(&session.detail.session.id) + .unwrap() + .unwrap(); + assert_eq!(crate::agent::cua::task_choice(&row), Some(true)); + session.lease.discard_created_if_untouched().await; +} diff --git a/apps/maple-agent/crates/maple-agent/src/agent/mcp/task.rs b/apps/maple-agent/crates/maple-agent/src/agent/mcp/task.rs index 2b728d7b7..35627ad17 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/mcp/task.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/mcp/task.rs @@ -33,7 +33,7 @@ use super::super::config::{account_config_dir_path, load_agent_config_file}; use super::super::cua; use super::super::external_agents; use super::super::integrations::{cua_default, is_cua_identity}; -use super::super::store::{TaskKind, TaskRow}; +use super::super::store::TaskRow; use super::super::timeline::bounded_timeline_text; use super::super::{ AgentIntegration, AgentMcpServer, AgentMcpTransport, AgentPathLayout, AgentRuntimeHandle, @@ -857,20 +857,14 @@ impl AgentRuntimeHandle { row }; let row = row.ok_or_else(|| format!("Failed to find Agent task {session_id}"))?; - let cards = match row.kind { - TaskKind::Desktop => self.external_agent_cards().await?, - TaskKind::Acp => Vec::new(), - }; + let cards = self.external_agent_cards().await?; Ok(self.menu_rows_with(&saved, &cards, &row)) } /// A task's whole MCP menu, read now. async fn menu_rows(&self, row: &TaskRow) -> Result, String> { let saved = self.list_mcp_servers().await?; - let cards = match row.kind { - TaskKind::Desktop => self.external_agent_cards().await?, - TaskKind::Acp => Vec::new(), - }; + let cards = self.external_agent_cards().await?; Ok(self.menu_rows_with(&saved, &cards, row)) } @@ -884,11 +878,9 @@ impl AgentRuntimeHandle { ) -> Vec { let mut rows = session_rows(saved, row); rows.extend(external_agents::session_rows(agent_cards, row)); - if row.kind == TaskKind::Desktop { - let device = cua_default(self.paths(), &self.user_id); - let ready = (device.is_some() || cua::task_choice(row).is_some()) && cua::ready(); - rows.extend(cua::session_row(row, device, ready)); - } + let device = cua_default(self.paths(), &self.user_id); + let ready = (device.is_some() || cua::task_choice(row).is_some()) && cua::ready(); + rows.extend(cua::session_row(row, device, ready)); rows } } diff --git a/apps/maple-agent/crates/maple-agent/src/agent/provider.rs b/apps/maple-agent/crates/maple-agent/src/agent/provider.rs index 34bb4c6fe..36f8b2479 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/provider.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/provider.rs @@ -176,7 +176,7 @@ impl ApiKeySource for NoKeys { } /// What the catalog says about one model, after resolving an alias. -#[derive(Clone, Debug, Default, PartialEq)] +#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)] pub struct CatalogEntry { /// The context window in tokens; `None` when the catalog does not say. pub context_window: Option, diff --git a/apps/maple-agent/crates/maple-agent/src/agent/questions.rs b/apps/maple-agent/crates/maple-agent/src/agent/questions.rs index 2685b38ce..9cff93edb 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/questions.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/questions.rs @@ -9,13 +9,27 @@ use tokio::sync::{Mutex, oneshot}; use crate::agent::{AgentEventDispatcher, AgentServiceEvent, emit_agent_event}; -type PendingQuestions = Arc>>>; +struct PendingQuestion { + sender: oneshot::Sender, + event: AgentServiceEvent, +} +type PendingQuestions = Arc>>; + +/// Keeps question handling available while a capable interface is attached. +pub struct QuestionResponder(Arc); +impl Drop for QuestionResponder { + fn drop(&mut self) { + self.0.fetch_sub(1, std::sync::atomic::Ordering::SeqCst); + } +} #[derive(Clone)] pub(crate) struct QuestionBroker { pending: PendingQuestions, dispatcher: AgentEventDispatcher, next_id: Arc, + responders: Arc, + managed: Arc, } /// Process-wide broker: each service initializes it; tool clients fetch it @@ -47,6 +61,7 @@ struct PendingGuard { pending: PendingQuestions, request_id: String, armed: bool, + dispatcher: AgentEventDispatcher, } impl Drop for PendingGuard { @@ -54,6 +69,12 @@ impl Drop for PendingGuard { if !self.armed { return; } + emit_agent_event( + &self.dispatcher, + AgentServiceEvent::QuestionResolved { + request_id: self.request_id.clone(), + }, + ); if let Ok(mut pending) = self.pending.try_lock() { pending.remove(&self.request_id); return; @@ -74,9 +95,32 @@ impl QuestionBroker { pending: Arc::new(Mutex::new(HashMap::new())), dispatcher, next_id: Arc::new(std::sync::atomic::AtomicU64::new(1)), + responders: Arc::new(std::sync::atomic::AtomicUsize::new(0)), + managed: Arc::new(std::sync::atomic::AtomicBool::new(false)), } } + pub(crate) fn manage_availability(&self) { + self.managed + .store(true, std::sync::atomic::Ordering::SeqCst); + } + + pub(crate) fn responder(&self) -> QuestionResponder { + self.responders + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + QuestionResponder(self.responders.clone()) + } + + pub(crate) async fn snapshot(&self) -> Vec { + self.pending + .lock() + .await + .values() + .filter(|question| !question.sender.is_closed()) + .map(|question| question.event.clone()) + .collect() + } + /// Ask the user one or more related questions and wait for the /// answers. Emits a service event the UI renders as a question card /// covering the whole batch. @@ -85,6 +129,11 @@ impl QuestionBroker { session_id: &str, questions: Vec, ) -> String { + if self.managed.load(std::sync::atomic::Ordering::SeqCst) + && self.responders.load(std::sync::atomic::Ordering::SeqCst) == 0 + { + return "No connected interface can show questions. Ask in plain text, or have the user open Maple desktop or an ACP client with form elicitation.".into(); + } let request_id = format!( "question_{}_{}", std::time::SystemTime::now() @@ -95,20 +144,25 @@ impl QuestionBroker { .fetch_add(1, std::sync::atomic::Ordering::Relaxed) ); let (tx, rx) = oneshot::channel(); - self.pending.lock().await.insert(request_id.clone(), tx); + let event = AgentServiceEvent::Question { + session_id: session_id.into(), + request_id: request_id.clone(), + questions, + }; + self.pending.lock().await.insert( + request_id.clone(), + PendingQuestion { + sender: tx, + event: event.clone(), + }, + ); let mut guard = PendingGuard { pending: Arc::clone(&self.pending), request_id: request_id.clone(), armed: true, + dispatcher: self.dispatcher.clone(), }; - emit_agent_event( - &self.dispatcher, - AgentServiceEvent::Question { - session_id: session_id.to_string(), - request_id: request_id.clone(), - questions, - }, - ); + emit_agent_event(&self.dispatcher, event); let answer = rx.await.unwrap_or_default(); self.pending.lock().await.remove(&request_id); guard.armed = false; @@ -132,11 +186,20 @@ impl QuestionBroker { /// Entries whose asker has gone away are dropped on the way. pub(crate) async fn answer(&self, request_id: &str, answer: String) -> bool { let mut pending = self.pending.lock().await; - pending.retain(|_, tx| !tx.is_closed()); + pending.retain(|_, question| !question.sender.is_closed()); let sender = pending.remove(request_id); drop(pending); match sender { - Some(tx) => tx.send(answer).is_ok(), + Some(tx) => { + let answered = tx.sender.send(answer).is_ok(); + emit_agent_event( + &self.dispatcher, + AgentServiceEvent::QuestionResolved { + request_id: request_id.into(), + }, + ); + answered + } None => false, } } @@ -177,7 +240,15 @@ mod tests { let broker = broker(); let (tx, rx) = oneshot::channel(); drop(rx); - broker.pending.lock().await.insert("stale".to_string(), tx); + broker.pending.lock().await.insert( + "stale".to_string(), + PendingQuestion { + sender: tx, + event: AgentServiceEvent::QuestionResolved { + request_id: "stale".into(), + }, + }, + ); assert!(!broker.answer("missing", String::new()).await); assert_eq!(broker.pending_count().await, 0); } diff --git a/apps/maple-agent/crates/maple-agent/src/agent/runs.rs b/apps/maple-agent/crates/maple-agent/src/agent/runs.rs index 3d98eda21..1834f4104 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/runs.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/runs.rs @@ -27,16 +27,15 @@ use super::attachments::{AgentImageAttachment, image_prompt, split_image_prompt} use super::config::{account_attachment_store, unix_ms}; use super::runtime::AgentRuntime; use super::store::SessionFacts; -use super::surface::{AGENT_SURFACE_INACTIVE_ERROR, AgentSurfaceAccess, SURFACE_CONTROLLED_ERROR}; +use super::surface::{AGENT_SURFACE_INACTIVE_ERROR, AgentSurfaceAccess}; use super::timeline::{ LiveTimeline, MAPLE_NOTICE_ENTRY, STOPPED_NOTICE_TEXT, error_item, notice_entry_data, notice_item, }; use super::{ AgentDesktopQueueSnapshot, AgentEventDispatcher, AgentQueueControlRequest, AgentQueuedMessage, - AgentRunEvent, AgentRunSurface, AgentRunTerminal, AgentRunUsage, AgentRuntimeHandle, - AgentSendMessageRequest, AgentServiceEvent, AgentTaskState, AgentTimelineItem, - emit_agent_event, + AgentRunEvent, AgentRunTerminal, AgentRunUsage, AgentRuntimeHandle, AgentSendMessageRequest, + AgentServiceEvent, AgentTaskState, AgentTimelineItem, emit_agent_event, }; const AGENT_RUN_EVENT_CAPACITY: usize = 256; @@ -50,6 +49,8 @@ const STOP_REPEAT_INTERVAL: Duration = Duration::from_millis(100); /// How long Stop and shutdown wait for a run to end before aborting it. pub(super) const RUN_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(10); +static NEXT_TIMELINE_REVISION: AtomicU64 = AtomicU64::new(1); + static NEXT_RUN_ID: AtomicU64 = AtomicU64::new(1); static NEXT_QUEUE_ID: AtomicU64 = AtomicU64::new(1); @@ -188,15 +189,6 @@ impl AgentRunHandle { } } -/// Whether a run's events also go to the host's event sink. A calling -/// surface that follows its runs through [`AgentRunHandle::events`] keeps -/// them from the desktop. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum HostEvents { - Publish, - Suppress, -} - /// Publishes one run's events, in order, to the host and to the run's own /// bounded channel. A slow reader of the channel never holds the run back: /// events that do not fit are dropped and the overflow is flagged. @@ -206,9 +198,14 @@ pub(super) struct RunEvents { session_id: Arc, run_id: Arc, sender: mpsc::Sender, - host: HostEvents, overflowed: Arc, - order: Arc>, + order: Arc>, +} + +#[derive(Default)] +struct LiveSnapshot { + epoch: u64, + items: Vec, } impl RunEvents { @@ -216,7 +213,6 @@ impl RunEvents { dispatcher: AgentEventDispatcher, session_id: &str, run_id: &str, - host: HostEvents, ) -> (Self, mpsc::Receiver) { let (sender, receiver) = mpsc::channel(AGENT_RUN_EVENT_CAPACITY); ( @@ -225,23 +221,43 @@ impl RunEvents { session_id: Arc::from(session_id), run_id: Arc::from(run_id), sender, - host, overflowed: Arc::new(AtomicBool::new(false)), - order: Arc::new(Mutex::new(())), + order: Arc::new(Mutex::new(LiveSnapshot::default())), }, receiver, ) } pub(super) fn publish(&self, event: AgentRunEvent) { - let _order = lock(&self.order); - if self.host == HostEvents::Publish { + let mut live = lock(&self.order); + let mut shared = event.clone(); + match &mut shared { + AgentRunEvent::TimelineItem(item) + | AgentRunEvent::Error(item) + | AgentRunEvent::QueuePromoted { item, .. } => { + super::timeline::merge_into(&mut live.items, item.clone()); + let full = live + .items + .iter_mut() + .find(|full| full.id == item.id) + .expect("merged row"); + full.merge = "replace".into(); + full.revision = NEXT_TIMELINE_REVISION.fetch_add(1, Ordering::SeqCst); + *item = full.clone(); + } + AgentRunEvent::HistoryReplaced => { + live.items.clear(); + live.epoch += 1; + } + _ => {} + } + { emit_agent_event( &self.dispatcher, AgentServiceEvent::Run { session_id: self.session_id.to_string(), run_id: self.run_id.to_string(), - event: event.clone(), + event: shared, }, ); } @@ -254,7 +270,6 @@ impl RunEvents { /// A run that has started and not yet finished. struct ActiveRun { session_id: String, - surface: AgentRunSurface, /// Cancelled by Stop. stopped: CancellationToken, /// Whether staged messages may still join the run. False once the run @@ -270,7 +285,7 @@ struct ActiveRun { impl ActiveRun { fn stageable(&self) -> bool { - self.surface == AgentRunSurface::Desktop && self.accepting && !self.stopped.is_cancelled() + self.accepting && !self.stopped.is_cancelled() } fn steer(&mut self, message: Outgoing) { @@ -448,6 +463,44 @@ impl RunsState { } impl Runs { + pub(super) fn while_idle( + &self, + session_id: &str, + action: impl FnOnce() -> Result, + ) -> Result { + let state = self.state(); + if state.run_of(session_id).is_some() { + return Err("The task is running; its model cannot change".into()); + } + action() + } + + pub(super) fn snapshot_marker(&self, session_id: &str) -> Option<(String, u64)> { + self.state() + .run_of(session_id) + .map(|(id, run)| (id.clone(), lock(&run.events.order).epoch)) + } + + pub(super) fn live_snapshot( + &self, + session_id: &str, + ) -> (Option<(String, u64)>, u64, Vec) { + let state = self.state(); + if let Some((id, run)) = state.run_of(session_id) { + let live = lock(&run.events.order); + return ( + Some((id.clone(), live.epoch)), + NEXT_TIMELINE_REVISION.load(Ordering::SeqCst) - 1, + live.items.clone(), + ); + } + ( + None, + NEXT_TIMELINE_REVISION.load(Ordering::SeqCst) - 1, + Vec::new(), + ) + } + fn state(&self) -> MutexGuard<'_, RunsState> { lock(&self.state) } @@ -457,7 +510,6 @@ impl Runs { self.state() .runs .iter() - .filter(|(_, run)| run.surface == AgentRunSurface::Desktop) .map(|(run_id, run)| (run.session_id.clone(), run_id.clone())) .collect() } @@ -627,7 +679,45 @@ impl AgentRuntimeHandle { let Some(runtime) = self.current_runtime().await? else { return Ok(()); }; - runtime.stop_run(&run_id, AgentRunSurface::Desktop) + runtime.stop_run(&run_id) + } + + /// Stop the shared task regardless of the interface that admitted it. + pub async fn cancel_session(&self, session_id: &str) -> Result<(), String> { + let runtime = self.runtime().await?; + runtime + .stop_task_run(session_id, RUN_SHUTDOWN_TIMEOUT) + .await; + Ok(()) + } + + /// Inject at Pi's next steering boundary without starting a new turn. + /// False means the run settled before admission and consumed no message. + pub async fn steer_surface_message( + &self, + access: &AgentSurfaceAccess, + request: AgentSendMessageRequest, + ) -> Result { + let runtime = self.runtime().await?; + if request.session_id != access.session_id() || !runtime.surface_holds(access) { + return Err(AGENT_SURFACE_INACTIVE_ERROR.into()); + } + let message = runtime.outgoing(&request).await?; + if message.is_empty() { + return Err(EMPTY_PROMPT_ERROR.into()); + } + let mut state = runtime.runs.state(); + let Some(run) = state + .runs + .values_mut() + .find(|run| run.session_id == request.session_id && run.stageable()) + else { + return Ok(false); + }; + run.steers + .send(message) + .map_err(|_| "The task stopped before accepting steering".to_string())?; + Ok(true) } /// Send a calling surface's prompt to a task it holds: a run of its own, @@ -679,52 +769,19 @@ impl AgentRuntime { if message.is_empty() { return Err(EMPTY_PROMPT_ERROR.to_string()); } - // A run the surface stopped may still be ending. - let run = loop { - let finished = self.runs.finished.notified(); - tokio::pin!(finished); - finished.as_mut().enable(); - { - let mut state = self.runs.state(); - if state.run_of(&session_id).is_none() { - break self.register_run( - &mut state, - &session_id, - vec![message.clone()], - Vec::new(), - AgentRunSurface::CallingSurface, - ); - } + let run = { + let mut state = self.runs.state(); + if cancellation.is_cancelled() { + return Err("The prompt was cancelled before admission".into()); } - tokio::select! { - _ = &mut finished => {} - _ = cancellation.cancelled() => { - return Err("The Agent run was cancelled before it started".to_string()); - } - _ = self.lifetime.cancelled() => { - return Err(super::RUNTIME_NOT_RUNNING_ERROR.to_string()); - } + if state.run_of(&session_id).is_some() { + return Err("This task already has an active run".into()); } + self.register_run(&mut state, &session_id, vec![message], Vec::new()) }; - // The caller's cancellation stops the run, from its setup on, until - // the run ends. - let stopped = run.stopped.clone(); - let (follow_tx, follow_rx) = tokio::sync::oneshot::channel(); - tokio::spawn(async move { - let follow = async move { - let Ok(mut terminal) = follow_rx.await else { - return; - }; - wait_for_terminal(&mut terminal).await; - }; - tokio::select! { - _ = cancellation.cancelled() => stopped.cancel(), - _ = follow => {} - } - }); + // Once admitted, work belongs to the host. Transport cancellation or + // EOF affects only the projection; explicit Stop uses cancel_session. let handle = self.start_run(&request, run).await?; - let _ = follow_tx.send(handle.terminal.clone()); - // Prompted, the task is the surface's for good. self.settle_provisional(&session_id); Ok(handle) } @@ -758,14 +815,11 @@ impl AgentRuntime { } } - pub(super) fn stop_run(&self, run_id: &str, surface: AgentRunSurface) -> Result<(), String> { + pub(super) fn stop_run(&self, run_id: &str) -> Result<(), String> { let mut state = self.runs.state(); let Some(run) = state.runs.get_mut(run_id) else { return Ok(()); }; - if run.surface != surface { - return Err("Agent run is controlled by another Agent surface".to_string()); - } run.accepting = false; run.stopped.cancel(); if let Some(session) = &run.session { @@ -781,9 +835,6 @@ impl AgentRuntime { if request.queue_id.is_some() && !request.attachments.is_empty() { return Err(QUEUED_MESSAGE_ATTACHMENTS_ERROR.to_string()); } - if self.is_surfaced(&request.session_id) { - return Err(SURFACE_CONTROLLED_ERROR.to_string()); - } let message = self.outgoing(&request).await?; loop { let finished = self.runs.finished.notified(); @@ -852,9 +903,6 @@ impl AgentRuntime { let mut state = self.runs.state(); let joinable = match state.run_of(session_id) { Some((run_id, run)) if run.stageable() => Some(run_id.clone()), - Some((_, run)) if run.surface != AgentRunSurface::Desktop => { - return Err("This Agent task is controlled by another Agent surface".to_string()); - } Some(_) => return Ok(Plan::Wait), None => None, }; @@ -961,13 +1009,9 @@ impl AgentRuntime { (prompts, consumed) } }; - Ok(Plan::Start(self.register_run( - &mut state, - session_id, - prompts, - consumed, - AgentRunSurface::Desktop, - ))) + Ok(Plan::Start( + self.register_run(&mut state, session_id, prompts, consumed), + )) } /// Register a run of `surface` for `session_id`, to start with @@ -978,15 +1022,9 @@ impl AgentRuntime { session_id: &str, prompts: Vec, consumed: Vec, - surface: AgentRunSurface, ) -> NewRun { let run_id = next_run_id(); - let host = match surface { - AgentRunSurface::Desktop => HostEvents::Publish, - AgentRunSurface::CallingSurface => HostEvents::Suppress, - }; - let (events, receiver) = - RunEvents::new(self.host.events.clone(), session_id, &run_id, host); + let (events, receiver) = RunEvents::new(self.host.events.clone(), session_id, &run_id); // Stopping the runtime stops the run, also before it has a task. let stopped = self.lifetime.child_token(); let (steers, steering) = mpsc::unbounded_channel(); @@ -994,7 +1032,6 @@ impl AgentRuntime { run_id.clone(), ActiveRun { session_id: session_id.to_string(), - surface, stopped: stopped.clone(), accepting: true, events: events.clone(), @@ -1047,9 +1084,6 @@ impl AgentRuntime { custom_type: &'static str, text: String, ) -> Result<(), String> { - if self.is_surfaced(session_id) { - return Err(SURFACE_CONTROLLED_ERROR.to_string()); - } let message = Outgoing::hidden(custom_type, text); loop { let finished = self.runs.finished.notified(); @@ -1067,11 +1101,6 @@ impl AgentRuntime { let queue = state.snapshot(session_id); Plan::Joined(AgentRunHandle::joined(run_id, None, queue)) } - Some((_, run)) if run.surface != AgentRunSurface::Desktop => { - return Err( - "This Agent task is controlled by another Agent surface".to_string() - ); - } Some(_) => Plan::Wait, // Chips left from an earlier run wait for the user's // next send; the message goes alone. @@ -1080,7 +1109,6 @@ impl AgentRuntime { session_id, vec![message.clone()], Vec::new(), - AgentRunSurface::Desktop, )), } }; @@ -1500,15 +1528,6 @@ impl AgentRuntime { } } -/// Wait until `terminal` says how the run ended, or its run is gone. -async fn wait_for_terminal(terminal: &mut watch::Receiver>) { - while terminal.borrow_and_update().is_none() { - if terminal.changed().await.is_err() { - return; - } - } -} - /// Poll the prompt and the run's steering inbox together. Only this future /// drains the inbox, so async input handlers cannot reorder accepted steers. async fn with_steering( @@ -1736,9 +1755,7 @@ mod tests { )); } if stop { - runtime - .stop_run(&run.run_id, AgentRunSurface::Desktop) - .unwrap(); + runtime.stop_run(&run.run_id).unwrap(); } else { harness.faux.push_text("Done."); } diff --git a/apps/maple-agent/crates/maple-agent/src/agent/runtime.rs b/apps/maple-agent/crates/maple-agent/src/agent/runtime.rs index b0d675ed9..4fe3746eb 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/runtime.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/runtime.rs @@ -28,12 +28,10 @@ use super::provider::{CatalogEntry, catalog_entry, maple_model, maple_model_regi use super::questions::QuestionBroker; use super::runs::{Failures, Runs}; use super::store::{TaskKind, TaskRow, TaskStore}; -use super::surface::{ - AgentSurfaceAccess, SURFACE_CONTROLLED_ERROR, SurfaceTask, with_surface_servers, -}; +use super::surface::{AgentSurfaceAccess, SurfaceTask, with_surface_servers}; use super::{ - AgentMcpServer, AgentRuntimeStatus, AgentServiceEvent, MapleAgentHostResources, - emit_agent_event, login_path, tools, + AgentRuntimeStatus, AgentServiceEvent, MapleAgentHostResources, emit_agent_event, login_path, + tools, }; use crate::maple_api::MapleApiSession; @@ -106,7 +104,7 @@ pub(super) struct AgentRuntime { /// The tasks a calling surface holds, by task id. Changed, and read for /// a session's build, under the sessions' lock, so a session is built /// with what the task has. - surfaces: Mutex>, + surfaces: Mutex>>, pub(super) runs: Runs, pub(super) failures: Failures, /// The external agents of the account's tasks. @@ -260,17 +258,30 @@ impl AgentRuntime { .await .map_err(|error| error.to_string()) .and_then(|saved| saved); - // A calling surface's own servers run beside the task's, in place of - // a saved one of the same name. - let surface_servers = self.surface_mcp_servers(session_id); - match saved { - Ok(saved) => mcp.sync(with_surface_servers( - task_servers(&saved, &row), - surface_servers, - )), - Err(error) => { - log::warn!("Failed to read the account's MCP servers: {error}"); - if !surface_servers.is_empty() { + // Synchronize under the attachment lock. Detach revokes under this + // same lock before disabling its servers, so a stale snapshot cannot + // resurrect client credentials after disconnection. + { + let surfaces = lock(&self.surfaces); + let surface_servers = surfaces + .get(session_id) + .into_iter() + .flat_map(|tasks| tasks.values()) + .filter(|task| !task.revoked.is_cancelled()) + .flat_map(|task| task.mcp_servers.clone()) + .collect(); + match saved { + Ok(saved) => { + match with_surface_servers(task_servers(&saved, &row), surface_servers) { + Ok(servers) => mcp.sync(servers), + Err(error) => { + mcp.sync(Vec::new()); + return Some(error); + } + } + } + Err(error) => { + log::warn!("Failed to read the account's MCP servers: {error}"); mcp.sync(surface_servers); } } @@ -290,7 +301,7 @@ impl AgentRuntime { /// binding failed, if it did. async fn start_task_cua(&self, row: &TaskRow, vision: bool) -> Option { let task_cua = self.loaded_cua(&row.id).await?; - if self.driven_kind(row) == TaskKind::Desktop && cua::task_choice(row) == Some(true) { + if cua::task_choice(row) == Some(true) { task_cua.bind(vision).await.err() } else { if task_cua.is_bound() { @@ -300,101 +311,75 @@ impl AgentRuntime { } } - /// Whether a calling surface holds the task. - pub(super) fn is_surfaced(&self, session_id: &str) -> bool { - lock(&self.surfaces).contains_key(session_id) - } - - /// How a task is driven: as a calling surface's while one holds it, - /// whichever surface created it. - pub(super) fn driven_kind(&self, row: &TaskRow) -> TaskKind { - if self.is_surfaced(&row.id) { - TaskKind::Acp - } else { - row.kind - } - } - - /// The MCP servers the surface that holds the task gave it. - pub(super) fn surface_mcp_servers(&self, session_id: &str) -> Vec { - lock(&self.surfaces) - .get(session_id) - .map(|task| task.mcp_servers.clone()) - .unwrap_or_default() - } - - /// Whether `access` still holds its task. pub(super) fn surface_holds(&self, access: &AgentSurfaceAccess) -> bool { lock(&self.surfaces) .get(access.session_id()) - .is_some_and(|task| { - task.installation == access.installation() && !task.revoked.is_cancelled() - }) + .and_then(|tasks| tasks.get(&access.installation())) + .is_some_and(|task| !task.revoked.is_cancelled()) } - /// Give a calling surface its hold on a task. The task's loaded session, - /// built for whoever drove it before, is unloaded, so its next run - /// builds it for the surface. Refused while the task runs or another - /// surface holds it. pub(super) async fn install_surface( &self, session_id: &str, task: SurfaceTask, ) -> Result<(), String> { - let unloaded = { - let mut sessions = self.sessions.lock().await; - if self.lifetime.is_cancelled() { - return Err(super::RUNTIME_NOT_RUNNING_ERROR.to_string()); - } - if self.runs.is_running(session_id) { - return Err("Stop the running agent before opening this task here".to_string()); - } - let mut surfaces = lock(&self.surfaces); - if surfaces.contains_key(session_id) { - return Err(SURFACE_CONTROLLED_ERROR.to_string()); + if self.lifetime.is_cancelled() { + return Err(super::RUNTIME_NOT_RUNNING_ERROR.into()); + } + // Host and client servers must never silently replace each other. + let saved = read_saved_servers(&self.host.paths, &self.user_id)?; + let mut surfaces = lock(&self.surfaces); + let attached = surfaces.entry(session_id.to_string()).or_default(); + for server in &task.mcp_servers { + let key = super::mcp::name_to_key(&server.name); + if saved + .iter() + .any(|other| super::mcp::name_to_key(&other.name) == key) + || attached + .values() + .flat_map(|other| &other.mcp_servers) + .any(|other| super::mcp::name_to_key(&other.name) == key) + { + return Err(format!( + "MCP server {} is already configured on this task", + server.name + )); } - surfaces.insert(session_id.to_string(), task); - drop(surfaces); - sessions.remove(session_id) - }; - if let Some(task) = unloaded { - task.session.shutdown().await; } + attached.insert(task.installation, task); Ok(()) } - /// End the hold `installation` has on a task, if it still has it: the - /// task's run stops, and its session is unloaded, so whoever drives it - /// next builds it afresh. + /// Detach only this interface. Shared runs and the host-owned task survive. + /// Client MCP bindings disappear from the next server synchronization. pub(super) async fn release_surface(&self, session_id: &str, installation: u64) { - let held = |surfaces: &HashMap| { - surfaces + let names: Vec = { + let surfaces = lock(&self.surfaces); + match surfaces .get(session_id) - .is_some_and(|task| task.installation == installation) + .and_then(|tasks| tasks.get(&installation)) + { + Some(task) => { + task.revoked.cancel(); + task.mcp_servers + .iter() + .map(|server| super::mcp::name_to_key(&server.name)) + .collect() + } + None => Vec::new(), + } }; - if !held(&lock(&self.surfaces)) { - return; - } - self.stop_task_run(session_id, super::runs::RUN_SHUTDOWN_TIMEOUT) - .await; - let unloaded = { - let mut sessions = self.sessions.lock().await; - let mut surfaces = lock(&self.surfaces); - if !held(&surfaces) { - return; + if let Some(mcp) = self.loaded_mcp(session_id).await { + for name in names { + mcp.disable(&name).await; } - surfaces.remove(session_id); - drop(surfaces); - // A run that would not stop keeps its session, with the - // surface's hold already ended. - if self.runs.is_running(session_id) { - None - } else { - sessions.remove(session_id) + } + let mut surfaces = lock(&self.surfaces); + if let Some(tasks) = surfaces.get_mut(session_id) { + tasks.remove(&installation); + if tasks.is_empty() { + surfaces.remove(session_id); } - }; - if let Some(task) = unloaded { - task.session.shutdown().await; } } @@ -547,14 +532,8 @@ impl AgentRuntime { if self.lifetime.is_cancelled() { return Err(super::RUNTIME_NOT_RUNNING_ERROR.to_string()); } - // A task a calling surface holds runs without the desktop's own - // tools. - let kind = self.driven_kind(row); - let providers = if kind == TaskKind::Desktop { - task_providers(&agents_on, row) - } else { - Vec::new() - }; + // Every interface drives the same host-owned tools. + let providers = task_providers(&agents_on, row); if let Some(LoadedTask { session, agents, .. }) = sessions.get(&row.id) @@ -591,10 +570,8 @@ impl AgentRuntime { let attachments = Arc::new(account_attachment_store(&self.host.paths, &self.user_id)?); let tools = tools::task_tools(tools::TaskToolsFor { session_id: row.id.clone(), - kind, - // A calling surface's commands keep the PATH its client started - // Maple with. - login_path: search_path.clone().filter(|_| kind == TaskKind::Desktop), + kind: TaskKind::Desktop, + login_path: search_path.clone(), questions: self.questions.clone(), web: self.api.clone(), web_enabled: row.web_enabled, @@ -610,19 +587,16 @@ impl AgentRuntime { options.builtin_tools = Some(tools.builtin); options.tools = tools.maple; options.extensions.push(tools.extension); - // External agents work for tasks in the desktop app. let agents = TaskProviders::default(); - if kind == TaskKind::Desktop { - options - .tools - .extend(external_agent_tools(ExternalAgentToolsFor { - registry: Arc::clone(&self.external_agents), - session_id: row.id.clone(), - working_dir: PathBuf::from(&row.project_root), - login_path: search_path.clone(), - providers: agents.clone(), - })); - } + options + .tools + .extend(external_agent_tools(ExternalAgentToolsFor { + registry: Arc::clone(&self.external_agents), + session_id: row.id.clone(), + working_dir: PathBuf::from(&row.project_root), + login_path: search_path.clone(), + providers: agents.clone(), + })); let mcp = TaskMcp::new(Path::new(&row.project_root), search_path); options.extensions.push(mcp.extension()); let task_cua = TaskCua::new(&self.account_scope, &row.id, self.models.clone()); diff --git a/apps/maple-agent/crates/maple-agent/src/agent/surface.rs b/apps/maple-agent/crates/maple-agent/src/agent/surface.rs index e670a591d..a6c8005ea 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/surface.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/surface.rs @@ -1,16 +1,6 @@ -//! Tasks a calling surface drives, such as an ACP client's sessions. -//! -//! A surface holds each task it drives with a lease. While the lease lasts, -//! the task's runs are the surface's alone, and the desktop's own tools stay -//! out of it: the plan and questions, external agents and computer use. The -//! surface's own MCP servers run beside the task's. Releasing the lease ends -//! its hold at once and unloads the task's session, so whoever drives the -//! task next builds it afresh. -//! -//! A task a surface creates is provisional until its first prompt: no list -//! shows it, and releasing the lease of an untouched one discards it. A crash -//! can strand one; the next runtime start removes those older than ten -//! minutes. +//! ACP attachments to host-owned tasks. A lease identifies temporary client +//! bindings; it never grants exclusive execution ownership. Dropping it detaches +//! that client without cancelling shared work. Unprompted drafts stay unlisted. use std::path::Path; use std::sync::Arc; @@ -34,9 +24,6 @@ use super::{ /// Refused when a surface's lease no longer holds its task: the lease was /// released, or the task is gone. pub const AGENT_SURFACE_INACTIVE_ERROR: &str = "Agent surface access is no longer active"; -/// Refused to anyone else while a surface holds the task. -pub(super) const SURFACE_CONTROLLED_ERROR: &str = - "This Agent task is controlled by another Agent surface"; /// The task setting that marks a surface's task before its first prompt. const PROVISIONAL: &str = "surfaceProvisional"; /// How old a provisional task is before a runtime start removes it. @@ -98,8 +85,7 @@ impl AgentSurfaceLease { self.revoked.cancel(); } - /// Release the task: end the hold, stop the task's run and unload its - /// session. + /// Detach this client without stopping the shared task. pub async fn release(mut self) { self.settled = true; self.revoke(); @@ -142,23 +128,25 @@ impl Drop for AgentSurfaceLease { } } -/// The servers a task runs: its own, and the surface's in place of one of -/// its own with the same name. +/// Merge distinct host and client bindings. Conflicts are unavailable until +/// explicitly reconfigured; neither interface may replace the other's tool. pub(super) fn with_surface_servers( - task: Vec, + mut task: Vec, surface: Vec, -) -> Vec { - let mut servers: Vec = task - .into_iter() - .filter(|server| { - let key = name_to_key(&server.name); - !surface - .iter() - .any(|surface| name_to_key(&surface.name) == key) - }) - .collect(); - servers.extend(surface); - servers +) -> Result, String> { + for server in surface { + if task + .iter() + .any(|saved| name_to_key(&saved.name) == name_to_key(&server.name)) + { + return Err(format!( + "MCP server '{}' conflicts with another binding", + server.name + )); + } + task.push(server); + } + Ok(task) } fn is_provisional(row: &TaskRow) -> bool { @@ -301,6 +289,7 @@ impl AgentRuntimeHandle { }; Ok(AgentSurfaceSession { detail: AgentSessionDetail { + timeline_revision: 0, session: row.summary(), timeline: Vec::new(), mcp_errors: Vec::new(), diff --git a/apps/maple-agent/crates/maple-agent/src/agent/surface/tests.rs b/apps/maple-agent/crates/maple-agent/src/agent/surface/tests.rs index 124dad5d3..d6f96f804 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/surface/tests.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/surface/tests.rs @@ -90,7 +90,7 @@ async fn a_surface_task_is_unlisted_until_prompted_and_discarded_untouched() { } #[tokio::test] -async fn a_surface_task_runs_without_desktop_tools() { +async fn a_surface_task_shares_host_tools_and_events() { let harness = Harness::new().await; let created = harness .handle @@ -116,21 +116,17 @@ async fn a_surface_task_runs_without_desktop_tools() { .map(|tool| tool.name) .collect(); assert!(declared.contains(&"bash".to_string()), "{declared:?}"); - let desktop = ["todo_write", "request_user_input"] - .into_iter() - .chain(crate::agent::external_agents::EXTERNAL_AGENT_TOOLS); - for name in desktop { - assert!(!declared.contains(&name.to_string()), "{declared:?}"); + for name in ["todo_write", "request_user_input"] { + assert!(declared.contains(&name.to_string()), "{declared:?}"); } - // A surface's run is the surface's alone: the host hears none of it. - assert!(!harness.recorder.events().iter().any( + assert!(harness.recorder.events().iter().any( |event| matches!(event, AgentServiceEvent::Run { run_id, .. } if *run_id == run.run_id) )); created.lease.release().await; } #[tokio::test] -async fn the_desktop_waits_for_a_surface_to_release_its_task() { +async fn desktop_and_two_surfaces_share_one_task() { let harness = Harness::new().await; let task = harness.create_task().await; let attached = harness @@ -140,21 +136,19 @@ async fn the_desktop_waits_for_a_surface_to_release_its_task() { .unwrap(); assert_eq!(attached.detail.session.id, task); let access = attached.lease.access(); - let refused = harness + let second = harness .handle - .send_message(prompt(&task, "From the desktop")) + .attach_surface_session(&task, Vec::new()) .await - .err() .unwrap(); - assert_eq!(refused, SURFACE_CONTROLLED_ERROR); - // A second surface cannot hold it either. - let second = harness + harness.faux.push_text("From the shared host."); + let mut desktop = harness .handle - .attach_surface_session(&task, Vec::new()) + .send_message(prompt(&task, "From the desktop")) .await - .err() .unwrap(); - assert_eq!(second, SURFACE_CONTROLLED_ERROR); + assert_eq!(finished(&mut desktop).await, AgentRunTerminal::Completed); + second.lease.release().await; let runtime = harness.runtime().await; harness.faux.push_text("Loaded."); @@ -170,7 +164,7 @@ async fn the_desktop_waits_for_a_surface_to_release_its_task() { // is the desktop's again. attached.lease.release().await; assert!(!runtime.surface_holds(&access)); - assert!(runtime.loaded_session(&task).await.is_none()); + assert!(runtime.loaded_session(&task).await.is_some()); let gone = harness .handle .send_surface_message(&access, prompt(&task, "Again"), CancellationToken::new()) @@ -194,7 +188,7 @@ async fn the_desktop_waits_for_a_surface_to_release_its_task() { } #[tokio::test] -async fn cancelling_a_surface_send_stops_its_run() { +async fn detaching_does_not_stop_a_run_but_explicit_cancel_does() { let harness = Harness::with_faux(FauxProvider::new().with_chunk_delay(Duration::from_millis(10))).await; let created = harness @@ -216,6 +210,8 @@ async fn cancelling_a_surface_send_stops_its_run() { .unwrap(); crate::agent::tests::eventually(|| !harness.faux.requests().is_empty()).await; cancellation.cancel(); + assert!(run.terminal.borrow().is_none()); + harness.handle.cancel_session(&task).await.unwrap(); assert_eq!(finished(&mut run).await, AgentRunTerminal::Cancelled); // The next prompt runs once the stopped one has ended. harness.faux.push_text("Ready."); @@ -284,7 +280,7 @@ async fn a_surface_brings_its_own_mcp_servers() { } #[test] -fn a_surface_server_takes_the_place_of_a_saved_one_of_its_name() { +fn a_surface_server_cannot_replace_a_saved_binding() { let server = |name: &str, url: &str| AgentMcpServer { name: name.to_string(), description: String::new(), @@ -303,20 +299,13 @@ fn a_surface_server_takes_the_place_of_a_saved_one_of_its_name() { ], vec![server("github", "https://client.example/github")], ); - let urls: Vec = servers - .iter() - .map(|server| match &server.transport { - AgentMcpTransport::StreamableHttp { url, .. } => url.clone(), - AgentMcpTransport::Stdio { .. } => unreachable!(), - }) - .collect(); - assert_eq!( - urls, - [ - "https://saved.example/docs", - "https://client.example/github" - ] - ); + assert!(servers.unwrap_err().contains("conflicts")); + let distinct = with_surface_servers( + vec![server("Docs", "https://saved.example/docs")], + vec![server("Client", "https://client.example/tool")], + ) + .unwrap(); + assert_eq!(distinct.len(), 2); } #[tokio::test] diff --git a/apps/maple-agent/crates/maple-agent/src/agent/tasks.rs b/apps/maple-agent/crates/maple-agent/src/agent/tasks.rs index e93999f01..152f31d8d 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/tasks.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/tasks.rs @@ -116,6 +116,7 @@ impl AgentRuntimeHandle { AgentServiceEvent::SessionCreated(summary.clone()), ); Ok(AgentSessionDetail { + timeline_revision: 0, session: summary, timeline: Vec::new(), mcp_errors: Vec::new(), @@ -164,14 +165,12 @@ impl AgentRuntimeHandle { pi_ai::now_ms(), ); set_chosen_servers(&mut row, mcp_servers); - // Built-in CUA set up on this device is on for a desktop task when + // Built-in CUA set up on this device is on for any new task when // the composer asks for it, or by the default for new tasks. - if kind == TaskKind::Desktop - && let Some(cua_on) = super::cua::choice_for_new_task( - super::integrations::cua_default(self.paths(), &self.user_id), - request.mcp_server_names.as_deref(), - ) - { + if let Some(cua_on) = super::cua::choice_for_new_task( + super::integrations::cua_default(self.paths(), &self.user_id), + request.mcp_server_names.as_deref(), + ) { super::cua::set_task_choice(&mut row, cua_on); } Ok(row) @@ -198,24 +197,71 @@ impl AgentRuntimeHandle { } /// A task's summary, transcript and queue. + /// Change a draft task's model at the same boundary that admits its first run. + pub async fn set_task_model( + &self, + session_id: &str, + model: &str, + ) -> Result { + self.verify_generation().await?; + let runtime = self.runtime().await?; + let summary = runtime.runs.while_idle(session_id, || { + let row = runtime.store.get(session_id)?.ok_or("Task was not found")?; + if row.message_count > 0 && row.model.as_deref() != Some(model) { + return Err("Maple tasks are model-locked after their first message".into()); + } + runtime + .store + .update(session_id, |row| row.model = Some(model.into()))? + .map(|row| row.summary()) + .ok_or_else(|| "Task was not found".into()) + })?; + emit_agent_event( + &runtime.host.events, + AgentServiceEvent::SessionUpdated { + session_id: session_id.into(), + run_id: None, + session: summary.clone(), + }, + ); + Ok(summary) + } + pub async fn load_session(&self, session_id: String) -> Result { let runtime = self.current_runtime().await?; let store = self.store()?; let row = store .get(&session_id)? .ok_or_else(|| format!("Failed to load Agent task: {session_id} was not found"))?; - let loaded = match &runtime { - Some(runtime) => runtime.loaded_session(&session_id).await, - None => None, - }; - let mut timeline = match loaded { - Some(session) => session.with_session(session_timeline), - None => { - let path = store.session_path(&session_id); - tokio::task::spawn_blocking(move || stored_timeline(&path)) - .await - .map_err(|error| format!("Failed to load Agent task: {error}"))?? + let (mut timeline, timeline_revision) = loop { + let before = runtime + .as_ref() + .and_then(|runtime| runtime.runs.snapshot_marker(&session_id)); + let loaded = match &runtime { + Some(runtime) => runtime.loaded_session(&session_id).await, + None => None, + }; + let mut timeline = match loaded { + Some(session) => session.with_session(session_timeline), + None => { + let path = store.session_path(&session_id); + tokio::task::spawn_blocking(move || stored_timeline(&path)) + .await + .map_err(|error| format!("Failed to load Agent task: {error}"))?? + } + }; + + let (after, revision, live) = runtime + .as_ref() + .map(|runtime| runtime.runs.live_snapshot(&session_id)) + .unwrap_or_default(); + if before != after { + continue; + } + for item in live { + merge_into(&mut timeline, item); } + break (timeline, revision); }; let queue = match &runtime { Some(runtime) => { @@ -235,6 +281,7 @@ impl AgentRuntimeHandle { }, }; Ok(AgentSessionDetail { + timeline_revision, session: row.summary(), timeline, mcp_errors: Vec::new(), diff --git a/apps/maple-agent/crates/maple-agent/src/agent/tests.rs b/apps/maple-agent/crates/maple-agent/src/agent/tests.rs index 79ead16d1..16cadb5d6 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/tests.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/tests.rs @@ -186,7 +186,7 @@ impl Harness { } } - pub(super) async fn send(&self, session_id: &str, text: &str) -> AgentRunHandle { + pub(crate) async fn send(&self, session_id: &str, text: &str) -> AgentRunHandle { self.handle .send_message(self.request(session_id, text)) .await diff --git a/apps/maple-agent/crates/maple-agent/src/agent/thinking.rs b/apps/maple-agent/crates/maple-agent/src/agent/thinking.rs index 26587667a..2d9e735c0 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/thinking.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/thinking.rs @@ -93,6 +93,15 @@ impl AgentRuntimeHandle { config.default_thinking_level = Some(state.current); save_agent_config_inner(self.paths(), &self.user_id, &config) .map_err(|error| error.to_string())?; + if let Some(session_id) = session_id { + super::emit_agent_event( + &self.service.host.events, + super::AgentServiceEvent::ThinkingChanged { + session_id: session_id.into(), + state: state.clone(), + }, + ); + } Ok(state) } } diff --git a/apps/maple-agent/crates/maple-agent/src/agent/timeline.rs b/apps/maple-agent/crates/maple-agent/src/agent/timeline.rs index e568aa4c8..8c6ade23c 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/timeline.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/timeline.rs @@ -219,6 +219,7 @@ pub(crate) fn merge_into(items: &mut Vec, incoming: AgentTime previous.output = incoming.output; } previous.created_ms = incoming.created_ms; + previous.revision = incoming.revision; previous.merge = incoming.merge; } @@ -243,6 +244,7 @@ fn user_item(timestamp: Timestamp, content: &[Content], merge: &str) -> AgentTim None => (text, None), }; AgentTimelineItem { + revision: 0, id: user_row_id(timestamp), item_type: "message".to_string(), role: Some("user".to_string()), @@ -297,6 +299,7 @@ fn reply_items(reply: &AssistantMessage) -> Vec { fn thinking_item(timestamp: Timestamp, text: String, merge: &str) -> AgentTimelineItem { AgentTimelineItem { + revision: 0, id: reply_row_id(timestamp, "thinking"), item_type: "thinking".to_string(), role: Some("thought".to_string()), @@ -312,6 +315,7 @@ fn thinking_item(timestamp: Timestamp, text: String, merge: &str) -> AgentTimeli fn text_item(timestamp: Timestamp, text: String, merge: &str) -> AgentTimelineItem { AgentTimelineItem { + revision: 0, id: reply_row_id(timestamp, "text"), item_type: "message".to_string(), role: Some("assistant".to_string()), @@ -327,6 +331,7 @@ fn text_item(timestamp: Timestamp, text: String, merge: &str) -> AgentTimelineIt fn tool_call_item(reply_timestamp: Timestamp, call: &ToolCall, status: &str) -> AgentTimelineItem { AgentTimelineItem { + revision: 0, id: tool_row_id(reply_timestamp, &call.id), item_type: "tool".to_string(), role: Some("assistant".to_string()), @@ -351,6 +356,7 @@ fn tool_result_item( timestamp: Timestamp, ) -> AgentTimelineItem { AgentTimelineItem { + revision: 0, id, item_type: "tool".to_string(), role: Some("assistant".to_string()), @@ -375,6 +381,7 @@ fn reply_error_item(reply: &AssistantMessage) -> AgentTimelineItem { .clone() .unwrap_or_else(|| "The model's reply failed".to_string()); AgentTimelineItem { + revision: 0, id: reply_row_id(reply.timestamp, "error"), item_type: "error".to_string(), role: Some("system".to_string()), @@ -411,6 +418,7 @@ pub(crate) fn notice_item( timestamp: Timestamp, ) -> AgentTimelineItem { AgentTimelineItem { + revision: 0, id, item_type: "system".to_string(), role: Some("system".to_string()), @@ -428,6 +436,7 @@ pub(crate) fn notice_item( pub(crate) fn error_item(message: String) -> AgentTimelineItem { let now = pi_ai::now_ms(); AgentTimelineItem { + revision: 0, id: format!("error-{now}"), item_type: "error".to_string(), role: Some("system".to_string()), @@ -524,6 +533,7 @@ impl LiveTimeline { }; let arguments = args.as_object().cloned().unwrap_or_default(); vec![AgentTimelineItem { + revision: 0, id: id.clone(), item_type: "tool".to_string(), role: Some("assistant".to_string()), @@ -591,6 +601,7 @@ impl LiveTimeline { let row = tool_row_id(reply.timestamp, id); self.tool_rows.insert(id.clone(), row.clone()); vec![AgentTimelineItem { + revision: 0, id: row, item_type: "tool".to_string(), role: Some("assistant".to_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 4fab03e2b..b6f29ceec 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/types.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/types.rs @@ -76,7 +76,7 @@ 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, @@ -85,7 +85,7 @@ pub enum AgentProjectTrustFeature { SystemPrompt, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentProjectTrustStatus { pub path: String, @@ -136,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, @@ -176,7 +176,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, @@ -214,7 +214,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, @@ -227,7 +227,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, @@ -261,7 +261,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, @@ -269,14 +269,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, @@ -287,7 +287,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, @@ -296,7 +296,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, @@ -306,7 +306,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 multi_select: bool, @@ -316,7 +316,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, @@ -327,7 +327,7 @@ 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)] pub struct AudioCapabilities { pub transcription: bool, pub speech: bool, @@ -448,7 +448,7 @@ pub enum AgentSessionIntegrationKind { ExternalAgent, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentSessionMcpServer { pub name: String, @@ -460,7 +460,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, @@ -477,7 +477,7 @@ pub struct AgentStartRequest { pub model: Option, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentRuntimeStatus { pub running: bool, @@ -494,7 +494,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, @@ -502,7 +502,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, @@ -513,7 +513,7 @@ pub struct AgentCreateSessionRequest { pub mcp_server_names: Option>, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct AgentSendMessageRequest { pub session_id: String, @@ -531,7 +531,7 @@ 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, @@ -558,20 +558,20 @@ pub(crate) enum AgentPermissionDecision { /// Which surface owns a run: Maple Desktop, or a calling surface such as /// ACP that keeps its own run handle, cancellation scope and live timeline. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum AgentRunSurface { Desktop, CallingSurface, } -#[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, @@ -582,28 +582,28 @@ pub struct AgentQueuedMessage { pub created_ms: u128, } -#[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)] pub enum AgentRunTerminal { Completed, Cancelled, Failed, } -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct AgentRunUsage { pub(crate) input_tokens: u64, pub(crate) output_tokens: u64, @@ -612,7 +612,7 @@ pub struct AgentRunUsage { pub(crate) cached_write_tokens: u64, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub enum AgentRunEvent { SessionUpdated(AgentSessionSummary), Started, @@ -656,8 +656,12 @@ pub enum AgentRunEvent { }, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub enum AgentServiceEvent { + /// Profile sign-in changed in the authoritative host. + AccountChanged(Option), + /// The local frontend must reconnect and reload before sending again. + HostDisconnected, RuntimeStatus(AgentRuntimeStatus), /// The agent asked the user one or more related questions (ask_user /// tool); every question in the batch is answered in one card. @@ -666,6 +670,13 @@ pub enum AgentServiceEvent { request_id: String, questions: Vec, }, + QuestionResolved { + request_id: String, + }, + ThinkingChanged { + session_id: String, + state: AgentThinkingState, + }, SessionCreated(AgentSessionSummary), SessionUpdated { session_id: String, @@ -693,7 +704,7 @@ pub enum AgentServiceEvent { /// One external agent 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 { /// Row ID of the agent, shared by every event about it. @@ -711,7 +722,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, @@ -778,13 +789,13 @@ pub struct ActivityTodo { /// 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)] pub struct SideQuestionTurn { pub question: String, pub answer: String, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub enum SideQuestionEvent { Chunk(String), Finished, @@ -817,7 +828,7 @@ impl AgentTaskState { } } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentSessionSummary { pub id: String, @@ -835,18 +846,24 @@ pub struct AgentSessionSummary { pub acp: bool, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentSessionDetail { + /// All live timeline updates through this revision are in the snapshot. + #[serde(default)] + pub timeline_revision: u64, pub session: AgentSessionSummary, pub timeline: Vec, pub mcp_errors: Vec, pub queue: AgentDesktopQueueSnapshot, } -#[derive(Debug, Clone, PartialEq, Serialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentTimelineItem { + /// Host-local update order; zero for persisted or locally constructed rows. + #[serde(default)] + pub revision: u64, pub id: String, pub item_type: String, #[serde(skip_serializing_if = "Option::is_none")] @@ -912,7 +929,7 @@ mod speech_body_tests { } /// The selected model's supported levels and the task's actual Pi setting. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AgentThinkingState { pub levels: Vec, pub current: ThinkingLevel, diff --git a/apps/maple-agent/crates/maple-agent/src/maple_api.rs b/apps/maple-agent/crates/maple-agent/src/maple_api.rs index cd827007c..cd67d5920 100644 --- a/apps/maple-agent/crates/maple-agent/src/maple_api.rs +++ b/apps/maple-agent/crates/maple-agent/src/maple_api.rs @@ -23,7 +23,7 @@ pub struct MapleApiAuthRequest { } /// Account profile as reported by the backend (`GET /protected/user`). -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct MapleAccount { pub user_id: String, pub email: Option, @@ -34,7 +34,7 @@ pub struct MapleAccount { pub created_at: String, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum MapleLoginMethod { Email, Github, @@ -86,7 +86,7 @@ impl From for MapleAccount { } /// One API key as listed by the backend; the key value is never listed. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct MapleApiKey { pub name: String, /// RFC 3339 creation time. @@ -94,7 +94,7 @@ pub struct MapleApiKey { } /// A freshly created API key. `key` is shown once and never again. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct MapleApiKeyCreated { pub name: String, pub key: String, diff --git a/apps/maple-agent/crates/maple-agent/src/private_file.rs b/apps/maple-agent/crates/maple-agent/src/private_file.rs index 35a1a0ebe..014df8760 100644 --- a/apps/maple-agent/crates/maple-agent/src/private_file.rs +++ b/apps/maple-agent/crates/maple-agent/src/private_file.rs @@ -15,7 +15,7 @@ use std::path::Path; /// /// A no-op off unix, where the containing profile already carries the /// access control. -pub(crate) fn set_owner_only_file(path: &Path) -> io::Result<()> { +pub fn set_owner_only_file(path: &Path) -> io::Result<()> { set_owner_only_mode(path, 0o600) } diff --git a/apps/maple-agent/crates/maple-billing/src/lib.rs b/apps/maple-agent/crates/maple-billing/src/lib.rs index cf4518dfe..a6097b383 100644 --- a/apps/maple-agent/crates/maple-billing/src/lib.rs +++ b/apps/maple-agent/crates/maple-billing/src/lib.rs @@ -11,7 +11,7 @@ pub const DEFAULT_BILLING_API_URL: &str = "https://billing.opensecret.cloud"; /// `GET /v1/maple/subscription/status`. Every field is optional on the /// wire except the booleans, which default to false when absent. -#[derive(Debug, Clone, Default, Deserialize, PartialEq)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] pub struct BillingStatus { #[serde(default)] pub is_subscribed: bool, @@ -45,7 +45,7 @@ pub struct BillingStatus { } /// One purchasable plan from `GET /v1/maple/products` (public). -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct Product { pub id: String, pub name: String, @@ -60,7 +60,7 @@ pub struct Product { pub is_available: Option, } -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct Price { pub id: String, pub currency: String, @@ -70,7 +70,7 @@ pub struct Price { pub recurring: Option, } -#[derive(Debug, Clone, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct Recurring { pub interval: String, #[serde(default)] diff --git a/apps/maple-agent/docs/local-host.md b/apps/maple-agent/docs/local-host.md new file mode 100644 index 000000000..15e3018f4 --- /dev/null +++ b/apps/maple-agent/docs/local-host.md @@ -0,0 +1,103 @@ +# Local host and ACP + +Desktop and `maple-agent acp` attach to one on-demand process, started by the +same installed executable as `maple-agent host serve`. The host owns credentials, +account configuration, task files, the task index, Pi runs, tools and child +processes. Desktop owns presentation and native permission prompts. ACP projects +host operations and events onto the client protocol. Pi itself is unchanged. + +## Lifetime and isolation + +Opening an ACP editor can start the host without opening a window. Desktop joins +it later. Closing a client detaches; a run continues while another client remains. +After the last client leaves, a two-second grace period allows a replacement to +attach, then the host cancels and drains its work and exits. `maple-agent host stop` +explicitly stops this profile and all its tasks, even with clients attached. +There is no login item, permanent service, remote listener or remote keepalive. + +One OS user, build channel and canonical config/data roots identify the storage +owner. Startup and lifetime file locks prevent two owners. Backend URL, billing +URL, client identity, attestation environment, executable source version and +compiled features must match before a client joins. A mismatched client reports +an error; it does not terminate or replace the owner. Quit the old build before +switching versions. Rebuilds of uncommitted source share a source version, so stop +the development host before rebuilding. + +Unix uses an owner-only socket inside a private directory and checks peer UID. +Windows uses a local named pipe restricted to its owner and SYSTEM and verifies +the pipe owner. This is an OS-user trust boundary, not a sandbox against other +programs running as that user. No API token is placed in endpoint names or logs. +Signing out revokes existing runtime handles and ACP connections before another +account can sign in. Task commands validate their account at the host boundary. + +## Shared tasks and recovery + +A new draft remains absent from task lists until the first accepted message. +Desktop and ACP can load the same task and observe its history and live stream. +Each task has one running loop. Desktop retains its queue/steer controls; ACP uses +the steering extension below. Cancel from either interface stops the shared run. +Filesystem paths and tools always refer to the host machine. + +Snapshots include live partial rows with an event cutoff. Clients discard older +updates and keep later ones, rather than appending the same partial text twice. +The event stream is bounded. A client that falls behind disconnects and must +reopen/reload; this version does not automatically reconnect the desktop. A host +crash does not restart inference. Stored history survives; active tools are not +promised to survive a host crash. + +Desktop sends carry unique IDs. A small private `send-receipts.db` records admission +and its result, so a lost reply can be queried without resending the prompt. A +crash during admission produces an explicit unknown outcome: inspect the task +before retrying. Receipts contain hashes and task/run IDs, not prompt text or +images. They are not a token event log. IPC frames are bounded at 160 MiB to fit +the existing attachment allowance; oversized requests fail explicitly. + +## ACP compatibility + +The adapter uses stable ACP protocol version 1. It supports initialization, +new/load/list/delete, prompts, cancellation, model and `thought_level` config +options, command discovery, images, tool/thought/text updates, usage, and negotiated +form elicitation. It does not advertise session modes or `session/close`: ACP close +means cancellation, while this host's pipe EOF means detach. A client may still +explicitly cancel before closing; Maple honors that cancellation. + +Stable ACP has no steer or queue method. Maple advertises +`_meta.steering.supported=true` and implements `_session/steering`, shared with the +Codex and Claude adapters. It accepts the same `sessionId` and `prompt` content as +`session/prompt` and injects at Pi's next steering opportunity. The result is +`{"outcome":"injected"}`. With `_meta.steering.idleBehavior="promptRequired"`, an +idle task returns `{"outcome":"promptRequired","reason":"noRunningTurn"}` without +consuming the message. Legacy callers omitting that option start a new turn and +receive `{"outcome":"startedNewTurn"}`. Ordinary overlapping `session/prompt` +requests return busy; clients should use the advertised extension. + +Questions appear on capable attached interfaces; the first answer resolves the +host request. Without a capable responder, the tool asks for a normal message +instead of waiting indefinitely. Client filesystem and terminal callbacks are +not used. Host-configured MCP servers stay host-owned. ACP-supplied bindings are +transient, disabled on detach and require explicit reattachment. Conflicting +server names are rejected rather than silently replacing another client's tools. + +### Paseo + +Configure a provider extending `acp` with command `["/path/to/maple-agent", "acp"]`. +It must inherit the same profile environment as desktop. In a managed development +workspace use its launcher (or source its generated Agent environment before +executing the built binary). A different profile is intentionally a different host. + +The source baseline inspected for this change is Paseo `b2ca7dcf` (2026-10-15). +Its generic ACP provider supports the normal create/resume, prompt, tool streaming, +model/thought-level and cancellation path. It does not call `_session/steering` +and rejects an active second turn itself. It also does not render every idle +update from other interfaces. These are client limits; opening the task again +loads the host history. ACP cannot retract already rendered history on compaction; +reload is the authoritative view after history replacement. No Paseo-specific +wire shims or separate task database are added. + +## Maintenance boundary + +Keep host commands private and versioned; ACP remains a standards-based projection. +Test ownership, admission, snapshot/stream reconciliation, account revocation and +resource cleanup where they cross boundaries. Native macOS permissions, Windows +named pipes and Linux sockets each require platform execution evidence. Remote +pairing and transport belong to a later phase, not this local IPC implementation. diff --git a/apps/maple-agent/docs/scenario-checklist.md b/apps/maple-agent/docs/scenario-checklist.md index af1f19a0a..025d7ddeb 100644 --- a/apps/maple-agent/docs/scenario-checklist.md +++ b/apps/maple-agent/docs/scenario-checklist.md @@ -73,6 +73,17 @@ stdio MCP server configured in Settings > Integrations. | A1 | ACP agent mode | A stdio ACP client: `session/new`, a tool prompt, `session/list`, `session/load`, cancel, `/compact` | No modes advertised, zero `session/request_permission` for tools, the trust chooser on an untrusted project, no task left by a session closed before its first prompt, and every turn on the model's catalog vision and context window, which `usage_update` reports (**automated**: `acp::tests`) | | A2 | Proxy, `login`, `--version` | Run each mode | Each works as the README says | +## Shared local host + +| # | Scenario | How | Pass when | +|---|---|---|---| +| H1 | Concurrent startup | Start desktop and ACP for the same profile together | One host owns the profile; both attach | +| H2 | Join a running task | Start a tool turn in one interface, load it in the other during streaming | The partial reply appears once; later text and tools follow | +| H3 | Detach and stop | Close the originating ACP pipe with desktop open; cancel from desktop; then close all clients | Detach preserves the run, cancel ends it, last-client exit shuts down the host | +| H4 | Profile/version mismatch | Connect another backend or build using the same state roots | A clear rejection; the active host is untouched | +| H5 | ACP steering | Call advertised `_session/steering` during a tool turn | The next model step receives the new instruction; idle `promptRequired` consumes nothing | +| H6 | Shared questions | Attach desktop and form-capable ACP, answer one question | First answer wins and the other interface dismisses it | + ## Side models and the rest | # | Scenario | How | Pass when | From 144b01f217daa092e5ed0f4935dce5db5e237b11 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:57:09 +0000 Subject: [PATCH 2/8] Keep ACP observers draining during streamed replies --- .../crates/maple-agent/src/acp/shared.rs | 25 +++-- .../crates/maple-agent/src/acp/tests.rs | 97 +++++++++++++++++++ apps/maple-agent/docs/local-host.md | 9 +- 3 files changed, 121 insertions(+), 10 deletions(-) diff --git a/apps/maple-agent/crates/maple-agent/src/acp/shared.rs b/apps/maple-agent/crates/maple-agent/src/acp/shared.rs index a4b88c571..9ee06033a 100644 --- a/apps/maple-agent/crates/maple-agent/src/acp/shared.rs +++ b/apps/maple-agent/crates/maple-agent/src/acp/shared.rs @@ -242,6 +242,21 @@ impl AcpConnectionContext { } _ => return Ok(()), }; + // Titles do not touch history projection. Waiting on its gate while + // our own prompt holds it would stop this observer from draining the + // broadcast stream for the entire turn, even for a fast client. + if let Some(update) = direct { + return self + .send_session_update( + cx, + SessionNotification::new(session_id, update), + &self.lifetime, + ) + .await; + } + let Some(item) = item else { + return Ok(()); + }; let operation = self .session_operations .lock() @@ -260,13 +275,9 @@ impl AcpConnectionContext { let Some(session) = sessions.get_mut(&session_id) else { return Ok(()); }; - direct.or_else(|| { - item.as_ref() - .filter(|item| { - item.revision == 0 || item.revision > session.projection.snapshot_revision - }) - .and_then(|item| timeline_update(item, &mut session.projection, true)) - }) + (item.revision == 0 || item.revision > session.projection.snapshot_revision) + .then(|| timeline_update(&item, &mut session.projection, true)) + .flatten() }; if let Some(update) = update { self.send_session_update( diff --git a/apps/maple-agent/crates/maple-agent/src/acp/tests.rs b/apps/maple-agent/crates/maple-agent/src/acp/tests.rs index 18c07c19e..e4cd01418 100644 --- a/apps/maple-agent/crates/maple-agent/src/acp/tests.rs +++ b/apps/maple-agent/crates/maple-agent/src/acp/tests.rs @@ -1386,3 +1386,100 @@ async fn joining_a_stream_replays_partial_once_then_follows_live_text() { client.shutdown().await; finish(serving).await; } + +#[tokio::test] +async fn title_updates_during_long_prompts_preserve_shared_session_delivery() { + let harness = Harness::with_faux( + pi_ai::faux::FauxProvider::new().with_chunk_delay(Duration::from_millis(2)), + ) + .await; + // More chunks than the runtime's broadcast capacity, while the client + // keeps reading. A metadata update must not stall the observer behind + // the originating prompt's whole-turn history gate. + let reply = "word".repeat(400); + harness.faux.push_text(&reply); + let (mut client, serving) = connect(&harness.handle, no_answers()); + client.initialize(json!({})).await; + let task = client.new_session(harness.project.path()).await; + let first = client + .request( + "session/prompt", + json!({"sessionId": task, "prompt": [{"type": "text", "text": "First turn"}]}), + ) + .await; + eventually(|| !harness.faux.requests().is_empty()).await; + let title = "A title arriving while the reply streams"; + // Repeating an unchanged title must also leave the observer draining. + for _ in 0..2 { + harness + .handle + .rename_session( + crate::maple_api::test_maple_api_session("slice@example.com"), + crate::agent::AgentRenameSessionRequest { + session_id: task.clone(), + title: title.into(), + }, + ) + .await + .unwrap(); + } + assert_eq!( + client.response(first).await["result"]["stopReason"], + "end_turn" + ); + let title_position = client + .updates + .iter() + .position(|update| update["update"]["title"] == title) + .expect("the new title is delivered during the reply"); + let last_text = client + .updates + .iter() + .rposition(|update| update["update"]["sessionUpdate"] == "agent_message_chunk") + .unwrap(); + assert!(title_position < last_text); + assert_eq!(client.texts("agent_message_chunk")[0].1, reply); + + // Continue through the desktop's runtime handle, then return to the + // original ACP connection. Both the external exchange and next prompt + // must be delivered without reopening or replaying the session. + harness.faux.push_text("Desktop reply"); + let mut desktop = harness.send(&task, "Desktop message").await; + crate::agent::tests::finished(&mut desktop).await; + tokio::time::timeout(Duration::from_secs(5), async { + loop { + client.call("session/list", json!({})).await; + let received: String = client + .updates + .iter() + .filter(|update| update["update"]["sessionUpdate"] == "agent_message_chunk") + .filter_map(|update| update["update"]["content"]["text"].as_str()) + .collect(); + if received == "Desktop reply" { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("the desktop reply reaches the idle ACP client"); + assert!( + client + .texts("user_message_chunk") + .iter() + .any(|(_, text)| text == "Desktop message") + ); + harness.faux.push_text("ACP follow-up reply"); + assert_eq!( + client.prompt(&task, "ACP follow-up").await["stopReason"], + "end_turn" + ); + assert!( + client + .texts("agent_message_chunk") + .iter() + .any(|(_, text)| text == "ACP follow-up reply") + ); + client.shutdown().await; + finish(serving).await; +} diff --git a/apps/maple-agent/docs/local-host.md b/apps/maple-agent/docs/local-host.md index 15e3018f4..62e89da80 100644 --- a/apps/maple-agent/docs/local-host.md +++ b/apps/maple-agent/docs/local-host.md @@ -88,9 +88,12 @@ executing the built binary). A different profile is intentionally a different ho The source baseline inspected for this change is Paseo `b2ca7dcf` (2026-10-15). Its generic ACP provider supports the normal create/resume, prompt, tool streaming, model/thought-level and cancellation path. It does not call `_session/steering` -and rejects an active second turn itself. It also does not render every idle -update from other interfaces. These are client limits; opening the task again -loads the host history. ACP cannot retract already rendered history on compaction; +and rejects an active second turn itself. Live validation with Paseo 0.11.1 also +confirmed desktop-originated user and assistant messages reach an already attached, +idle Paseo client, which can then continue the task without reopening it. Stable +ACP v1 does not supply a separate running/idle lifecycle for turns started by +another client, so transcript delivery does not establish full desktop status parity. +ACP cannot retract already rendered history on compaction; reload is the authoritative view after history replacement. No Paseo-specific wire shims or separate task database are added. From 108e6ec048d5a30b1b9a0bfed5434befd21f3ba6 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:06:22 +0000 Subject: [PATCH 3/8] Fix host Windows lint and allow macOS verifier shutdown grace --- apps/maple-agent/app/src/host/ipc.rs | 11 ++++++----- apps/maple-agent/scripts/verify-release.sh | 16 +++++++++++++++- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/apps/maple-agent/app/src/host/ipc.rs b/apps/maple-agent/app/src/host/ipc.rs index 1ea2907bb..b938c54d3 100644 --- a/apps/maple-agent/app/src/host/ipc.rs +++ b/apps/maple-agent/app/src/host/ipc.rs @@ -142,13 +142,14 @@ impl Endpoint { } fn private_directory(path: &std::path::Path) -> Result<(), String> { - let mut builder = std::fs::DirBuilder::new(); #[cfg(unix)] - { + let result = { use std::os::unix::fs::DirBuilderExt; - builder.mode(0o700); - } - match builder.create(path) { + std::fs::DirBuilder::new().mode(0o700).create(path) + }; + #[cfg(not(unix))] + let result = std::fs::create_dir(path); + match result { Ok(()) => {} Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} Err(e) => return Err(format!("Cannot create host directory: {e}")), diff --git a/apps/maple-agent/scripts/verify-release.sh b/apps/maple-agent/scripts/verify-release.sh index 9e43e2ff7..e6945adbf 100755 --- a/apps/maple-agent/scripts/verify-release.sh +++ b/apps/maple-agent/scripts/verify-release.sh @@ -31,7 +31,21 @@ mount="" cleanup() { local status=$? trap - EXIT INT TERM HUP - [[ -z "$mount" ]] || /usr/bin/hdiutil detach "$mount" -quiet >/dev/null 2>&1 || true + if [[ -n "$mount" ]]; then + # The shared host exits shortly after the GUI probe's last client. + # Give it time to release the mounted executable before removing files. + for attempt in {1..10}; do + if /usr/bin/hdiutil detach "$mount" -quiet >/dev/null 2>&1; then + mount="" + break + fi + sleep 1 + done + if [[ -n "$mount" ]]; then + echo "Could not detach verification image; leaving $temporary intact" >&2 + exit 1 + fi + fi rm -rf -- "$temporary" exit "$status" } From e580912cfcee6e44ee7fdc4615017186204dfc68 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Fri, 16 Oct 2026 03:43:04 +0000 Subject: [PATCH 4/8] Make ACP steering test wait for an explicit tool boundary --- .../crates/maple-agent/src/acp/tests.rs | 37 +++++++++++++++++-- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/apps/maple-agent/crates/maple-agent/src/acp/tests.rs b/apps/maple-agent/crates/maple-agent/src/acp/tests.rs index e4cd01418..a52425dee 100644 --- a/apps/maple-agent/crates/maple-agent/src/acp/tests.rs +++ b/apps/maple-agent/crates/maple-agent/src/acp/tests.rs @@ -1285,9 +1285,13 @@ async fn steering_uses_the_shared_adapter_extension_and_preserves_idle_fallback( assert_eq!(idle["outcome"], "promptRequired"); assert!(harness.faux.requests().is_empty()); + // Hold the turn at a question until steering is acknowledged. A shell sleep + // can finish before a busy runner sends the request (or be unavailable). + let _responder = harness.handle.question_responder(); + let mut events = harness.handle.subscribe(); harness.faux.push_message(vec![faux_tool_call( - "bash", - json!({"command": "sleep 0.5"}), + "request_user_input", + json!({"question": "Continue?", "options": [{"label": "Yes"}]}), )]); harness .faux @@ -1298,13 +1302,40 @@ async fn steering_uses_the_shared_adapter_extension_and_preserves_idle_fallback( json!({"sessionId": task, "prompt": [{"type": "text", "text": "Start"}]}), ) .await; - crate::agent::tests::eventually(|| !harness.faux.requests().is_empty()).await; + let question_id = tokio::time::timeout(Duration::from_secs(10), async { + loop { + if let crate::agent::AgentServiceEvent::Question { + session_id, + request_id, + .. + } = events.recv().await.unwrap() + && session_id == task + { + break request_id; + } + } + }) + .await + .expect("the turn waits for an answer before steering"); let steered = client.call("_session/steering", json!({"sessionId": task, "prompt": [{"type": "text", "text": "Now use the updated instruction"}]})).await; assert_eq!(steered["outcome"], "injected"); + assert!( + harness + .handle + .answer_question_via_handle(&question_id, "Yes".into()) + .await + .unwrap() + ); assert_eq!( client.response(prompt_id).await["result"]["stopReason"], "end_turn" ); + let requests = harness.faux.requests(); + assert_eq!(requests.len(), 2); + assert_eq!( + crate::agent::tests::last_user_text(&requests[1]), + "Now use the updated instruction" + ); let detail = harness.handle.load_session(task).await.unwrap(); assert!(detail.timeline.iter().any(|item| { item.role.as_deref() == Some("user") From 4fc61bf1ff29c4e47c8bf016a5ac39ca7674a17e Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Fri, 16 Oct 2026 07:08:02 +0000 Subject: [PATCH 5/8] fix(agent): keep ACP updates flowing and recover host startup failures --- apps/maple-agent/app/src/desktop.rs | 79 +++++- apps/maple-agent/app/src/host.rs | 241 +++++++++++++++--- apps/maple-agent/app/src/host/protocol.rs | 2 +- .../crates/maple-agent/src/acp/mod.rs | 27 +- .../crates/maple-agent/src/acp/session.rs | 3 + .../crates/maple-agent/src/acp/shared.rs | 2 +- .../crates/maple-agent/src/acp/tests.rs | 49 +++- .../crates/maple-agent/src/agent/tests.rs | 11 + apps/maple-agent/docs/local-host.md | 13 +- 9 files changed, 372 insertions(+), 55 deletions(-) diff --git a/apps/maple-agent/app/src/desktop.rs b/apps/maple-agent/app/src/desktop.rs index a888de65d..e2398b0a8 100644 --- a/apps/maple-agent/app/src/desktop.rs +++ b/apps/maple-agent/app/src/desktop.rs @@ -395,6 +395,67 @@ fn main_window_options(window_bounds: WindowBounds) -> WindowOptions { } } +struct StartupError(String); + +impl Render for StartupError { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div() + .size_full() + .flex() + .flex_col() + .gap_4() + .p_6() + .bg(gpui::rgb(ui::theme::bg_app())) + .text_color(gpui::rgb(ui::theme::text_primary())) + .child(div().text_lg().child("Maple could not start")) + .child(self.0.clone()) + .child( + div() + .id("close-startup-error") + .cursor_pointer() + .px_4() + .py_2() + .rounded_md() + .bg(gpui::rgb(ui::theme::bg_elevated())) + .child("Close") + .on_click(|_, _, cx| cx.quit()), + ) + } +} + +fn show_startup_error(error: String) { + gpui_platform::application().run(move |cx| { + cx.set_app_identity(crate::profile::BUNDLE_ID, crate::profile::DISPLAY_NAME); + cx.on_action(|_: &QuitApp, cx| cx.quit()); + ui::menus::install(cx); + let bounds = Bounds::centered(None, size(px(560.), px(260.)), cx); + let result = cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + app_id: Some(crate::profile::BUNDLE_ID.into()), + titlebar: Some(gpui::TitlebarOptions { + title: Some(crate::profile::DISPLAY_NAME.into()), + ..Default::default() + }), + ..Default::default() + }, + move |window, cx| { + window.on_window_should_close(cx, |_, cx| { + cx.quit(); + true + }); + cx.new(|_| StartupError(error)) + }, + ); + if let Err(error) = result { + log::error!("Could not show the startup error: {error}"); + cx.quit(); + } else { + cx.activate(true); + } + }); +} + pub fn run() { crate::init_logging(crate::LogOutput::FileAndStderr); log::debug!("startup: logging ready at {} ms", crate::startup_elapsed()); @@ -412,13 +473,17 @@ pub fn run() { .display(), crate::startup_elapsed() ); - let backend = Arc::new( - AgentBackend::new( - crate::configured_api_url(), - startup_settings.effective_harness_instructions(), - ) - .expect("failed to initialize agent backend"), - ); + let backend = match AgentBackend::new( + crate::configured_api_url(), + startup_settings.effective_harness_instructions(), + ) { + Ok(backend) => Arc::new(backend), + Err(error) => { + log::error!("Could not start Maple: {error}"); + show_startup_error(error); + return; + } + }; log::debug!("startup: backend ready at {} ms", crate::startup_elapsed()); gpui_platform::application() diff --git a/apps/maple-agent/app/src/host.rs b/apps/maple-agent/app/src/host.rs index 0d07958e1..5ed02cf58 100644 --- a/apps/maple-agent/app/src/host.rs +++ b/apps/maple-agent/app/src/host.rs @@ -204,14 +204,14 @@ impl RemoteClient { async fn handshake( mut stream: ipc::Connection, - endpoint: &ipc::Endpoint, + profile: &str, mode: ConnectionMode, ) -> Result<(ipc::Connection, Welcome), String> { protocol::write( &mut stream, &Hello { version: protocol::VERSION, - profile: endpoint.profile.clone(), + profile: profile.into(), build: protocol::BUILD.into(), mode, }, @@ -219,7 +219,9 @@ async fn handshake( .await?; let welcome: Result = protocol::read(&mut stream).await?; let welcome = welcome?; - if welcome.version != protocol::VERSION || welcome.build != protocol::BUILD { + if mode != ConnectionMode::Stop + && (welcome.version != protocol::VERSION || welcome.build != protocol::BUILD) + { return Err( "A different Maple host version is running. Quit that host before opening this build." .into(), @@ -234,7 +236,7 @@ async fn connect( start: bool, ) -> Result<(ipc::Connection, Welcome), String> { if let Ok(stream) = endpoint.connect().await { - return tokio::time::timeout(START_TIMEOUT, handshake(stream, endpoint, mode)) + return tokio::time::timeout(START_TIMEOUT, handshake(stream, &endpoint.profile, mode)) .await .map_err(|_| "Maple host did not answer".to_string())?; } @@ -253,7 +255,7 @@ async fn connect( } } if let Ok(stream) = endpoint.connect().await { - return tokio::time::timeout_at(deadline, handshake(stream, endpoint, mode)) + return tokio::time::timeout_at(deadline, handshake(stream, &endpoint.profile, mode)) .await .map_err(|_| "Maple host did not answer".to_string())?; } @@ -261,9 +263,10 @@ async fn connect( let deadline = tokio::time::Instant::now() + START_TIMEOUT; loop { if let Ok(stream) = endpoint.connect().await { - let result = tokio::time::timeout_at(deadline, handshake(stream, endpoint, mode)) - .await - .map_err(|_| "Maple host did not answer".to_string())?; + let result = + tokio::time::timeout_at(deadline, handshake(stream, &endpoint.profile, mode)) + .await + .map_err(|_| "Maple host did not answer".to_string())?; drop(startup); return result; } @@ -319,11 +322,12 @@ fn spawn_host() -> Result<(), String> { pub fn run(api_url: String) -> Result<(), String> { let endpoint = ipc::Endpoint::new(&api_url)?; let ownership = endpoint.lock("host.lock")?; - ownership - .try_lock() - .map_err(|_| "A Maple host already owns this profile".to_string())?; let runtime = tokio::runtime::Runtime::new().map_err(|e| e.to_string())?; runtime.block_on(async { + // The previous owner removes its listener before draining runs and + // children. A replacement waits for that drain instead of exiting on + // the first locked attempt. Never open shared state before ownership. + wait_for_ownership(&ownership, START_TIMEOUT).await?; let backend = Arc::new(LocalBackend::new( api_url, crate::settings::load_settings().effective_harness_instructions(), @@ -332,6 +336,22 @@ pub fn run(api_url: String) -> Result<(), String> { }) } +async fn wait_for_ownership(file: &std::fs::File, timeout: Duration) -> Result<(), String> { + let deadline = tokio::time::Instant::now() + timeout; + loop { + match file.try_lock() { + Ok(()) => return Ok(()), + Err(std::fs::TryLockError::WouldBlock) => { + if tokio::time::Instant::now() >= deadline { + return Err("The previous Maple host is still running or shutting down. Try again after it exits.".into()); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + Err(error) => return Err(format!("Cannot acquire Maple host ownership: {error}")), + } + } +} + async fn serve(endpoint: ipc::Endpoint, backend: Arc) -> Result<(), String> { let mut listener = endpoint.bind()?; let receipts = Arc::new(receipts::Receipts::open( @@ -392,34 +412,10 @@ async fn serve_client( stop: mpsc::Sender<()>, receipts: Arc, ) -> Result<(), String> { - let hello = tokio::time::timeout(START_TIMEOUT, protocol::read::(&mut connection)) - .await - .map_err(|_| "Host handshake timed out".to_string())??; let mut subscription = events.subscribe(); - if hello.version != protocol::VERSION - || hello.profile != endpoint.profile - || hello.build != protocol::BUILD - { - protocol::write( - &mut connection, - &Err::( - "Incompatible Maple host or profile; explicitly quit the existing host first", - ), - ) - .await?; - return Ok(()); - } - protocol::write( - &mut connection, - &Ok::<_, String>(Welcome { - version: protocol::VERSION, - pid: std::process::id(), - build: protocol::BUILD.into(), - user_id: backend.saved_user_id(), - }), - ) - .await?; - match hello.mode { + let mode = + accept_handshake(&mut connection, &endpoint.profile, backend.saved_user_id()).await?; + match mode { ConnectionMode::Stop => { let _ = stop.send(()).await; return std::future::pending().await; @@ -484,6 +480,49 @@ async fn serve_client( result } +async fn accept_handshake( + connection: &mut ipc::Connection, + profile: &str, + user_id: Option, +) -> Result { + let hello = tokio::time::timeout(START_TIMEOUT, protocol::read::(connection)) + .await + .map_err(|_| "Host handshake timed out".to_string())??; + // Stop is an explicit, minimal control operation on the OS-private storage + // endpoint. It must remain usable when the new build cannot join the old + // runtime. It grants no access to account data or ordinary host commands. + let error = if hello.mode != ConnectionMode::Stop + && (hello.version != protocol::VERSION + || hello.profile != profile + || hello.build != protocol::BUILD) + { + Some( + "Incompatible Maple host or profile. Close the existing host or run `maple-agent host stop` for this profile, then reopen Maple.", + ) + } else if hello.mode == ConnectionMode::Acp && user_id.is_none() { + Some( + "Maple ACP requires sign-in. Sign in through Maple Desktop or `maple-agent login` first.", + ) + } else { + None + }; + if let Some(error) = error { + protocol::write(connection, &Err::(error)).await?; + return Err(error.into()); + } + protocol::write( + connection, + &Ok::<_, String>(Welcome { + version: protocol::VERSION, + pid: std::process::id(), + build: protocol::BUILD.into(), + user_id, + }), + ) + .await?; + Ok(hello.mode) +} + pub fn stop(api_url: &str) -> Result<(), String> { let runtime = tokio::runtime::Runtime::new().map_err(|e| e.to_string())?; runtime.block_on(async { @@ -640,3 +679,129 @@ async fn recover_send(endpoint: &ipc::Endpoint, id: &str) -> Result hello.profile = "different-backend".into(), + "build" => hello.build = "old-build".into(), + _ => hello.version += 1, + } + let (reply, accepted) = tokio::join!( + async { + protocol::write(&mut client, &hello).await.unwrap(); + protocol::read::>(&mut client) + .await + .unwrap() + }, + accept_handshake( + &mut server, + "same-storage-profile", + Some("test-user".into()) + ) + ); + assert_eq!(reply.is_ok(), mode == ConnectionMode::Stop, "{mismatch}"); + assert_eq!( + accepted.ok(), + (mode == ConnectionMode::Stop).then_some(mode) + ); + } + } + } + + #[tokio::test] + async fn stop_client_accepts_an_older_hosts_welcome() { + for mode in [ConnectionMode::Client, ConnectionMode::Stop] { + let (client, mut server) = tokio::io::duplex(4096); + let (result, ()) = tokio::join!(handshake(Box::new(client), "profile", mode), async { + let _: Hello = protocol::read(&mut server).await.unwrap(); + protocol::write( + &mut server, + &Ok::<_, String>(Welcome { + version: protocol::VERSION + 1, + pid: 1, + build: "old-build".into(), + user_id: None, + }), + ) + .await + .unwrap(); + }); + assert_eq!(result.is_ok(), mode == ConnectionMode::Stop); + } + } + + #[tokio::test] + async fn signed_out_acp_gets_an_error_while_desktop_can_open_login() { + for mode in [ConnectionMode::Acp, ConnectionMode::Client] { + let (client, server) = tokio::io::duplex(4096); + let mut server: ipc::Connection = Box::new(server); + let (result, _) = tokio::join!( + handshake(Box::new(client), "profile", mode), + accept_handshake(&mut server, "profile", None) + ); + match (mode, result) { + (ConnectionMode::Acp, Err(error)) => assert!(error.contains("requires sign-in")), + (ConnectionMode::Client, Ok((_, welcome))) => assert!(welcome.user_id.is_none()), + _ => panic!("unexpected signed-out handshake result"), + } + } + } + + #[tokio::test] + async fn replacement_waits_for_shutdown_to_release_ownership() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("host.lock"); + let old = std::fs::File::create(&path).unwrap(); + old.lock().unwrap(); + let replacement = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(&path) + .unwrap(); + let waiting = wait_for_ownership(&replacement, Duration::from_secs(5)); + tokio::pin!(waiting); + assert!( + tokio::time::timeout(Duration::from_millis(100), &mut waiting) + .await + .is_err() + ); + drop(old); + tokio::time::timeout(Duration::from_secs(5), waiting) + .await + .unwrap() + .unwrap(); + let contender = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(&path) + .unwrap(); + assert!(matches!( + contender.try_lock(), + Err(std::fs::TryLockError::WouldBlock) + )); + assert!( + wait_for_ownership(&contender, Duration::from_millis(50)) + .await + .is_err() + ); + } +} diff --git a/apps/maple-agent/app/src/host/protocol.rs b/apps/maple-agent/app/src/host/protocol.rs index 10113655a..fffa5101c 100644 --- a/apps/maple-agent/app/src/host/protocol.rs +++ b/apps/maple-agent/app/src/host/protocol.rs @@ -17,7 +17,7 @@ pub struct Hello { pub mode: ConnectionMode, } -#[derive(Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] pub enum ConnectionMode { Client, Acp, diff --git a/apps/maple-agent/crates/maple-agent/src/acp/mod.rs b/apps/maple-agent/crates/maple-agent/src/acp/mod.rs index bc483e700..f87e1b7a3 100644 --- a/apps/maple-agent/crates/maple-agent/src/acp/mod.rs +++ b/apps/maple-agent/crates/maple-agent/src/acp/mod.rs @@ -432,6 +432,7 @@ impl AcpConnectionContext { let operation = AcpSessionOperation::new(&self.lifetime); // Held until the history is replayed, so a prompt's updates follow it. let operation_guard = Arc::clone(&operation.gate).lock_owned().await; + let replay_guard = operation.replay_gate.lock().await; { // Register the operation before the task is held. Close can now // mark it closing and wait, while disconnect linearizes through @@ -540,6 +541,7 @@ impl AcpConnectionContext { if let Some(session) = self.sessions.lock().await.get_mut(&session_id) { session.projection = projection; } + drop(replay_guard); drop(operation_guard); for question in self.agent.pending_questions().await { self.observe(cx, question).await.map_err(outbound_error)?; @@ -1368,7 +1370,13 @@ impl AcpConnectionContext { Err(AcpOutboundSendError::Transport(error)) => return Err(error), } } - let mut projection = AcpProjection::default(); + let operation = self + .session_operations + .lock() + .await + .get(&session_id) + .cloned() + .ok_or_else(agent_client_protocol::Error::invalid_request)?; // A failure is told once the run ends failed: Pi may still retry it. let mut failure: Option = None; let stop = loop { @@ -1382,14 +1390,25 @@ impl AcpConnectionContext { if overflowed.load(Ordering::Acquire) { break StreamStop::Overflowed; } + // A tool row may be updated both by this run and by its external + // agent. Share projection state and serialize each outgoing update + // with the observer, without holding the gate while waiting for + // the next model event. + let _projection = tokio::select! { + _ = self.lifetime.cancelled() => return Ok((PromptResponse::new(StopReason::Cancelled), true)), + guard = operation.replay_gate.lock() => guard, + }; let update = match event { Some(AgentRunEvent::TimelineItem(item)) if item.item_type == "error" => { failure = Some(item); None } - Some(AgentRunEvent::TimelineItem(item)) => { - timeline_update(&item, &mut projection, false) - } + Some(AgentRunEvent::TimelineItem(item)) => self + .sessions + .lock() + .await + .get_mut(&session_id) + .and_then(|session| timeline_update(&item, &mut session.projection, false)), Some(AgentRunEvent::Error(item)) => { failure = Some(item); None diff --git a/apps/maple-agent/crates/maple-agent/src/acp/session.rs b/apps/maple-agent/crates/maple-agent/src/acp/session.rs index a0a52e7e5..920c9a1df 100644 --- a/apps/maple-agent/crates/maple-agent/src/acp/session.rs +++ b/apps/maple-agent/crates/maple-agent/src/acp/session.rs @@ -109,6 +109,8 @@ impl Drop for UnpublishedAcpSession { pub(super) struct AcpSessionOperation { pub(super) gate: Arc>, + /// Live observers wait for initial history replay, never for a whole turn. + pub(super) replay_gate: Mutex<()>, pub(super) cancellation: CancellationToken, } @@ -116,6 +118,7 @@ impl AcpSessionOperation { pub(super) fn new(connection_lifetime: &CancellationToken) -> Arc { Arc::new(Self { gate: Arc::new(Mutex::new(())), + replay_gate: Mutex::new(()), cancellation: connection_lifetime.child_token(), }) } diff --git a/apps/maple-agent/crates/maple-agent/src/acp/shared.rs b/apps/maple-agent/crates/maple-agent/src/acp/shared.rs index 9ee06033a..11e95a27d 100644 --- a/apps/maple-agent/crates/maple-agent/src/acp/shared.rs +++ b/apps/maple-agent/crates/maple-agent/src/acp/shared.rs @@ -268,7 +268,7 @@ impl AcpConnectionContext { }; let _gate = tokio::select! { _ = self.lifetime.cancelled() => return Ok(()), - gate = operation.gate.lock() => gate, + gate = operation.replay_gate.lock() => gate, }; let update = { let mut sessions = self.sessions.lock().await; diff --git a/apps/maple-agent/crates/maple-agent/src/acp/tests.rs b/apps/maple-agent/crates/maple-agent/src/acp/tests.rs index a52425dee..da9c09cb2 100644 --- a/apps/maple-agent/crates/maple-agent/src/acp/tests.rs +++ b/apps/maple-agent/crates/maple-agent/src/acp/tests.rs @@ -1419,7 +1419,7 @@ async fn joining_a_stream_replays_partial_once_then_follows_live_text() { } #[tokio::test] -async fn title_updates_during_long_prompts_preserve_shared_session_delivery() { +async fn shared_updates_during_long_prompts_preserve_shared_session_delivery() { let harness = Harness::with_faux( pi_ai::faux::FauxProvider::new().with_chunk_delay(Duration::from_millis(2)), ) @@ -1428,6 +1428,10 @@ async fn title_updates_during_long_prompts_preserve_shared_session_delivery() { // keeps reading. A metadata update must not stall the observer behind // the originating prompt's whole-turn history gate. let reply = "word".repeat(400); + std::fs::write(harness.project.path().join("notes.txt"), "notes").unwrap(); + harness + .faux + .push_message(vec![faux_tool_call("read", json!({"path": "notes.txt"}))]); harness.faux.push_text(&reply); let (mut client, serving) = connect(&harness.handle, no_answers()); client.initialize(json!({})).await; @@ -1438,7 +1442,29 @@ async fn title_updates_during_long_prompts_preserve_shared_session_delivery() { json!({"sessionId": task, "prompt": [{"type": "text", "text": "First turn"}]}), ) .await; - eventually(|| !harness.faux.requests().is_empty()).await; + let call_id = tokio::time::timeout(Duration::from_secs(5), async { + loop { + client.call("session/list", json!({})).await; + if let Some(update) = client + .updates + .iter() + .find(|update| update["update"]["sessionUpdate"] == "tool_call") + { + break update["update"]["toolCallId"].as_str().unwrap().to_string(); + } + } + }) + .await + .unwrap(); + // External-agent activity is published outside the originating run's + // stream. It must reach this client without waiting for its prompt to end. + let activity: AgentTimelineItem = serde_json::from_value(json!({ + "id": call_id, "itemType": "tool", "role": "assistant", + "status": "running", "output": {"text": "External agent is working"}, + "createdMs": 0, "merge": "replace" + })) + .unwrap(); + harness.emit_timeline_item(&task, activity); let title = "A title arriving while the reply streams"; // Repeating an unchanged title must also leave the observer draining. for _ in 0..2 { @@ -1469,6 +1495,25 @@ async fn title_updates_during_long_prompts_preserve_shared_session_delivery() { .rposition(|update| update["update"]["sessionUpdate"] == "agent_message_chunk") .unwrap(); assert!(title_position < last_text); + let activity_position = client + .updates + .iter() + .position(|update| { + update["update"]["toolCallId"] == call_id + && update["update"]["content"][0]["content"]["text"] == "External agent is working" + }) + .expect("external-agent activity reaches the originating ACP client"); + assert!(activity_position < last_text); + assert_eq!( + client + .updates + .iter() + .filter(|update| update["update"]["toolCallId"] == call_id + && update["update"]["sessionUpdate"] == "tool_call") + .count(), + 1, + "run and external-agent updates share one tool announcement" + ); assert_eq!(client.texts("agent_message_chunk")[0].1, reply); // Continue through the desktop's runtime handle, then return to the diff --git a/apps/maple-agent/crates/maple-agent/src/agent/tests.rs b/apps/maple-agent/crates/maple-agent/src/agent/tests.rs index 16cadb5d6..8002eea0f 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/tests.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/tests.rs @@ -107,6 +107,17 @@ fn service(data: &Path, recorder: Arc) -> MapleAgentService { } impl Harness { + pub(crate) fn emit_timeline_item(&self, session_id: &str, item: AgentTimelineItem) { + emit_agent_event( + &self.service.host.events, + AgentServiceEvent::TimelineItem { + session_id: session_id.into(), + run_id: None, + item, + }, + ); + } + pub(crate) async fn new() -> Self { Self::with_faux(FauxProvider::new()).await } diff --git a/apps/maple-agent/docs/local-host.md b/apps/maple-agent/docs/local-host.md index 62e89da80..78c4d4c7e 100644 --- a/apps/maple-agent/docs/local-host.md +++ b/apps/maple-agent/docs/local-host.md @@ -19,8 +19,14 @@ One OS user, build channel and canonical config/data roots identify the storage owner. Startup and lifetime file locks prevent two owners. Backend URL, billing URL, client identity, attestation environment, executable source version and compiled features must match before a client joins. A mismatched client reports -an error; it does not terminate or replace the owner. Quit the old build before -switching versions. Rebuilds of uncommitted source share a source version, so stop +an error; it does not terminate or replace the owner. Desktop shows a startup +error window instead of panicking. Explicit `maple-agent host stop` can stop the +owner of the same storage namespace even when its build or backend settings +differ; this exception does not allow other commands. Hosts predating that stop +exception must be stopped using their matching build/configuration. Quit the old +build before switching versions. A replacement waits for a shutting-down host to +release its ownership lock before opening shared state. Rebuilds of uncommitted +source share a source version, so stop the development host before rebuilding. Unix uses an owner-only socket inside a private directory and checks peer UID. @@ -54,6 +60,9 @@ the existing attachment allowance; oversized requests fail explicitly. ## ACP compatibility +ACP requires an existing sign-in. With no saved account, the command prints a +sign-in instruction to stderr and exits unsuccessfully; stdout remains protocol-only. + The adapter uses stable ACP protocol version 1. It supports initialization, new/load/list/delete, prompts, cancellation, model and `thought_level` config options, command discovery, images, tool/thought/text updates, usage, and negotiated From f26b9db7f5efeb27f37ac6a3d8facf5046dfa5f2 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Fri, 16 Oct 2026 07:13:35 +0000 Subject: [PATCH 6/8] fix(agent): let local operations proceed during credential validation --- apps/maple-agent/app/src/backend.rs | 8 +- apps/maple-agent/app/src/backend/local.rs | 92 ++++++++++++++++++++++- 2 files changed, 96 insertions(+), 4 deletions(-) diff --git a/apps/maple-agent/app/src/backend.rs b/apps/maple-agent/app/src/backend.rs index 8a759c420..6670320e6 100644 --- a/apps/maple-agent/app/src/backend.rs +++ b/apps/maple-agent/app/src/backend.rs @@ -349,8 +349,11 @@ pub(crate) async fn dispatch( backend: &LocalBackend, request: Request, ) -> Result { - // Authentication changes cannot interleave with an operation admitted for - // the previous account. Cancelling an OAuth wait must remain independent. + // Account switches cannot interleave with an operation admitted for the + // previous account. Restoring that same account is a reader: its network + // validation must not prevent local history, configuration, or stop calls. + // The restore mutex still serializes credential validation/publication. + // Cancelling an OAuth wait must remain independent. if request.method == "cancel_oauth" { return dispatch_inner(backend, request).await; } @@ -360,7 +363,6 @@ pub(crate) async fn dispatch( | "logout_and_clear" | "oauth_wait_for_handoff" | "oauth_complete" - | "restore_auth" | "confirm_account_deletion" ) { let _account = backend.account_gate.write().await; diff --git a/apps/maple-agent/app/src/backend/local.rs b/apps/maple-agent/app/src/backend/local.rs index dccb7ee91..2d05efb12 100644 --- a/apps/maple-agent/app/src/backend/local.rs +++ b/apps/maple-agent/app/src/backend/local.rs @@ -1527,7 +1527,9 @@ impl LocalBackend { R: tokio::io::AsyncRead + Unpin + Send + 'static, W: tokio::io::AsyncWrite + Unpin + Send + 'static, { - let account = self.account_gate.write().await; + // Joining the existing account is not an account switch. Keep local + // desktop operations available while saved credentials are validated. + let account = self.account_gate.read().await; let user_id = match self.validate_persisted_auth().await { RestoreOutcome::Valid(user_id) => user_id, _ => { @@ -2440,6 +2442,94 @@ fn account_error_message(error: MapleAccountError, fallback: &str) -> String { mod tests { use super::*; + #[tokio::test] + async fn credential_validation_and_acp_attachment_do_not_block_local_operations() { + use std::{future::Future, task::Poll}; + + let root = tempfile::tempdir().unwrap(); + let api_url = "http://127.0.0.1:1".to_string(); + let mut backend = LocalBackend::new(api_url.clone(), String::new()).unwrap(); + let (events, _) = mpsc::unbounded_channel(); + backend.service = MapleAgentService::new(MapleAgentHostResources::new( + maple_agent::agent::AgentPathLayout::from_app_roots( + root.path().join("config"), + root.path().join("data"), + ), + Arc::new(ChannelEventSink(events)), + String::new(), + )); + backend.persisted_auth = Arc::new(PersistedAuthStore::new( + root.path().join("auth.json"), + api_url, + )); + backend.persisted_auth.claim_and_persist(&auth_snapshot( + "fixture-user", + "fixture-session", + 1, + "fixture-token", + )); + let request = |method: &str, params| crate::host::protocol::Request { + id: 1, + method: method.into(), + params, + send_id: None, + }; + + // Hold the credential-validation boundary rather than relying on a + // network timeout. Both entry points have acquired their account guard + // by the time this poll returns Pending. + let _validation = backend.restore_lock.lock().await; + let restore = + super::super::dispatch(&backend, request("restore_auth", serde_json::Value::Null)); + tokio::pin!(restore); + std::future::poll_fn(|cx| { + assert!(restore.as_mut().poll(cx).is_pending()); + Poll::Ready(()) + }) + .await; + #[cfg(feature = "acp")] + let mut attach = Box::pin(backend.serve_acp(tokio::io::empty(), tokio::io::sink())); + #[cfg(feature = "acp")] + std::future::poll_fn(|cx| { + assert!(attach.as_mut().poll(cx).is_pending()); + Poll::Ready(()) + }) + .await; + + for method in ["local_bootstrap", "stop_runtime"] { + tokio::time::timeout( + std::time::Duration::from_secs(2), + super::super::dispatch( + &backend, + request(method, serde_json::json!(["fixture-user"])), + ), + ) + .await + .expect("local work must not wait for credential validation") + .unwrap(); + } + assert!( + super::super::dispatch( + &backend, + request("local_bootstrap", serde_json::json!(["different-user"])), + ) + .await + .unwrap_err() + .contains("account changed") + ); + + // Real account changes must still wait; releasing the credential + // serialization alone cannot let a switch overtake admitted work. + let switch = + super::super::dispatch(&backend, request("login", serde_json::json!(["", ""]))); + tokio::pin!(switch); + std::future::poll_fn(|cx| { + assert!(switch.as_mut().poll(cx).is_pending()); + Poll::Ready(()) + }) + .await; + } + fn oauth_test_client() -> OpenSecretClient { // Construct the SDK without making any network request. OpenSecretClient::new("http://127.0.0.1:1").unwrap() From 5bcc3514d57664890dcb1f51e0bb1203ad899d07 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Fri, 16 Oct 2026 07:35:41 +0000 Subject: [PATCH 7/8] fix(agent): recover desktop connections without replaying work --- apps/maple-agent/app/src/backend.rs | 52 ++- apps/maple-agent/app/src/backend/local.rs | 63 +++- apps/maple-agent/app/src/desktop.rs | 37 +- apps/maple-agent/app/src/host.rs | 301 ++++++++++----- apps/maple-agent/app/src/host/ipc.rs | 12 + .../app/src/host/recovery_tests.rs | 352 ++++++++++++++++++ apps/maple-agent/app/src/ui/chat/mod.rs | 160 +++++++- apps/maple-agent/app/src/ui/chat/tests.rs | 129 +++++++ .../crates/maple-agent/src/agent/types.rs | 6 +- apps/maple-agent/docs/local-host.md | 27 +- .../scripts/smoke-macos-release.py | 18 +- .../scripts/test_smoke_macos_release.py | 18 + 12 files changed, 1038 insertions(+), 137 deletions(-) create mode 100644 apps/maple-agent/app/src/host/recovery_tests.rs diff --git a/apps/maple-agent/app/src/backend.rs b/apps/maple-agent/app/src/backend.rs index 6670320e6..36fa41c78 100644 --- a/apps/maple-agent/app/src/backend.rs +++ b/apps/maple-agent/app/src/backend.rs @@ -15,11 +15,23 @@ use std::{collections::HashMap, future::Future, sync::Arc}; use tokio::{runtime::Runtime, sync::mpsc}; pub struct AgentBackend { - runtime: Runtime, + runtime: tokio::runtime::Handle, + owned_runtime: Option, client: BackendClient, api_url: String, } +impl Drop for AgentBackend { + fn drop(&mut self) { + // A completing UI/IPC future may release the last Arc on a Tokio + // worker. Runtime::drop would panic there by trying to block. The + // frontend owns no agent runs; host shutdown remains explicit. + if let Some(runtime) = self.owned_runtime.take() { + runtime.shutdown_background(); + } + } +} + enum BackendClient { Host(RemoteClient), #[cfg(test)] @@ -101,6 +113,7 @@ operations! { select_project_root(user_id: (&str), path: (String)) -> AgentProjectRootRegistration; list_sessions(user_id: (&str), project_root: (Option)) -> Vec; local_bootstrap(user_id: (&str)) -> LocalBootstrap; + recovery_snapshot(user_id: (&str), session_id: (Option)) -> RecoverySnapshot; create_session(user_id: (&str), request: (Option)) -> AgentSessionDetail; load_session(user_id: (&str), session_id: (&str)) -> AgentSessionDetail; set_session_state(user_id: (&str), session_id: (&str), state: (AgentTaskState)) -> AgentSessionSummary; @@ -154,7 +167,8 @@ impl AgentBackend { backend.ui_fixture = true; drop(guard); Ok(Self { - runtime, + runtime: runtime.handle().clone(), + owned_runtime: Some(runtime), client: BackendClient::InProcess(Box::new(backend)), api_url, }) @@ -165,7 +179,8 @@ impl AgentBackend { let runtime = Runtime::new().map_err(|e| format!("Could not start host client: {e}"))?; let client = runtime.block_on(RemoteClient::connect(&api_url))?; Ok(Self { - runtime, + runtime: runtime.handle().clone(), + owned_runtime: Some(runtime), client: BackendClient::Host(client), api_url, }) @@ -195,7 +210,7 @@ impl AgentBackend { } } pub fn runtime_handle(&self) -> tokio::runtime::Handle { - self.runtime.handle().clone() + self.runtime.clone() } pub fn spawn(&self, future: F) -> tokio::task::JoinHandle where @@ -233,6 +248,16 @@ impl AgentBackend { BackendClient::InProcess(_) => None, } } + pub fn detach(self: &Arc) -> tokio::task::JoinHandle<()> { + let backend = self.clone(); + self.spawn(async move { + match &backend.client { + BackendClient::Host(client) => client.detach().await, + #[cfg(test)] + BackendClient::InProcess(_) => {} + } + }) + } pub fn uses_hosted_oauth(&self) -> bool { crate::profile::AUTH_ORIGIN.is_some() } @@ -372,3 +397,22 @@ pub(crate) async fn dispatch( dispatch_inner(backend, request).await } } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn final_frontend_reference_can_be_dropped_by_an_async_callback() { + let backend = tokio::task::spawn_blocking(|| { + AgentBackend::for_test("http://127.0.0.1:1".into(), String::new()).unwrap() + }) + .await + .unwrap(); + tokio::spawn(async move { + drop(backend); + }) + .await + .expect("frontend shutdown must not panic on a runtime worker"); + } +} diff --git a/apps/maple-agent/app/src/backend/local.rs b/apps/maple-agent/app/src/backend/local.rs index 2d05efb12..b96f6c3ed 100644 --- a/apps/maple-agent/app/src/backend/local.rs +++ b/apps/maple-agent/app/src/backend/local.rs @@ -77,6 +77,13 @@ pub struct LocalBootstrap { pub questions: Vec, } +#[derive(serde::Serialize, serde::Deserialize)] +pub struct RecoverySnapshot { + pub bootstrap: LocalBootstrap, + pub status: AgentRuntimeStatus, + pub selected: Option, +} + pub struct LocalBackend { #[cfg(test)] pub(super) ui_fixture: bool, @@ -383,6 +390,25 @@ impl Drop for OAuthAttemptGuard<'_> { } impl LocalBackend { + #[cfg(test)] + pub(crate) fn isolated_for_test(root: &std::path::Path) -> Self { + let api_url = "http://127.0.0.1:1".to_string(); + let mut backend = Self::new(api_url.clone(), String::new()).unwrap(); + let (events, receiver) = mpsc::unbounded_channel(); + backend.service = MapleAgentService::new(MapleAgentHostResources::new( + maple_agent::agent::AgentPathLayout::from_app_roots( + root.join("config"), + root.join("data"), + ), + Arc::new(ChannelEventSink(events)), + String::new(), + )); + backend.service.manage_question_availability(); + backend.event_rx = tokio::sync::Mutex::new(Some(receiver)); + backend.persisted_auth = Arc::new(PersistedAuthStore::new(root.join("auth.json"), api_url)); + backend + } + /// 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() @@ -1644,6 +1670,27 @@ impl LocalBackend { }) } + /// Recover presentation without starting the runtime or resubmitting work. + pub async fn recovery_snapshot( + &self, + user_id: &str, + session_id: Option, + ) -> Result { + let mut bootstrap = self.local_bootstrap(user_id).await?; + bootstrap.latest = None; + let handle = self.service.handle_for_user(user_id).await?; + let selected = match session_id.filter(|id| bootstrap.sessions.iter().any(|s| &s.id == id)) + { + Some(id) => Some(handle.load_session(id).await?), + None => None, + }; + Ok(RecoverySnapshot { + bootstrap, + status: handle.status().await?, + selected, + }) + } + pub async fn create_session( &self, user_id: &str, @@ -2447,21 +2494,7 @@ mod tests { use std::{future::Future, task::Poll}; let root = tempfile::tempdir().unwrap(); - let api_url = "http://127.0.0.1:1".to_string(); - let mut backend = LocalBackend::new(api_url.clone(), String::new()).unwrap(); - let (events, _) = mpsc::unbounded_channel(); - backend.service = MapleAgentService::new(MapleAgentHostResources::new( - maple_agent::agent::AgentPathLayout::from_app_roots( - root.path().join("config"), - root.path().join("data"), - ), - Arc::new(ChannelEventSink(events)), - String::new(), - )); - backend.persisted_auth = Arc::new(PersistedAuthStore::new( - root.path().join("auth.json"), - api_url, - )); + let backend = LocalBackend::isolated_for_test(root.path()); backend.persisted_auth.claim_and_persist(&auth_snapshot( "fixture-user", "fixture-session", diff --git a/apps/maple-agent/app/src/desktop.rs b/apps/maple-agent/app/src/desktop.rs index e2398b0a8..59968d426 100644 --- a/apps/maple-agent/app/src/desktop.rs +++ b/apps/maple-agent/app/src/desktop.rs @@ -55,16 +55,30 @@ impl MapleApp { events: Vec, cx: &mut Context, ) { - for event in &events { - if let maple_agent::agent::AgentServiceEvent::AccountChanged(user_id) = event - && &self.user_id != user_id - { - match user_id { - Some(user_id) => self.open_chat(user_id.clone(), cx), - None => self.show_login(cx), + let mut batch = Vec::new(); + for event in events { + if let maple_agent::agent::AgentServiceEvent::AccountChanged(user_id) = &event { + // Deliver the old account's queued frames only to its screen. + // A reconnect may discover a sign-out/account change while away. + self.forward_service_events(std::mem::take(&mut batch), cx); + if &self.user_id != user_id { + match user_id { + Some(user_id) => self.open_chat(user_id.clone(), cx), + None => self.show_login(cx), + } } + } else { + batch.push(event); } } + self.forward_service_events(batch, cx); + } + + fn forward_service_events( + &mut self, + events: Vec, + cx: &mut Context, + ) { let chat = match &self.screen { Screen::Chat(chat) => Some(chat.clone()), _ => self.parked_chat.clone(), @@ -549,8 +563,13 @@ pub fn run() { } else { WindowBounds::Windowed(bounds) }; - cx.on_app_quit(|_cx| async { - persist_window_state(); + let quit_backend = backend.clone(); + cx.on_app_quit(move |_cx| { + let backend = quit_backend.clone(); + async move { + persist_window_state(); + let _ = backend.detach().await; + } }) .detach(); let root_backend = backend.clone(); diff --git a/apps/maple-agent/app/src/host.rs b/apps/maple-agent/app/src/host.rs index 5ed02cf58..e9ce67e47 100644 --- a/apps/maple-agent/app/src/host.rs +++ b/apps/maple-agent/app/src/host.rs @@ -4,6 +4,8 @@ mod ipc; pub mod protocol; pub(crate) mod receipts; +#[cfg(test)] +mod recovery_tests; use crate::backend::{self, LocalBackend}; use maple_agent::agent::AgentServiceEvent; @@ -21,6 +23,8 @@ use tokio::sync::{Semaphore, broadcast, mpsc, oneshot}; const START_TIMEOUT: Duration = Duration::from_secs(15); const IDLE_GRACE: Duration = Duration::from_secs(2); +const RECONNECT_TIMEOUT: Duration = Duration::from_secs(15); +const DISCONNECT_GRACE: Duration = Duration::from_secs(30); const MAX_CLIENTS: usize = 32; const MAX_CALLS: usize = 16; type Pending = HashMap>>; @@ -28,18 +32,16 @@ type Pending = HashMap>>; struct ClientState { pending: Mutex>, user_id: RwLock>, + generation: AtomicU64, } impl ClientState { fn disconnect(&self) { - if let Some(pending) = self.pending.lock().unwrap().take() { - for (_, sender) in pending { - let _ = sender.send(Err( - "The Maple host disconnected. Reload the task before retrying a message." - .into(), - )); - } - } + // Closing these senders distinguishes transport loss from a host's + // application error, even if reconnection wins the response race. + let mut pending = self.pending.lock().unwrap(); + pending.take(); + self.generation.fetch_add(1, Ordering::Relaxed); } } @@ -69,76 +71,38 @@ impl RemoteClient { pub async fn connect(api_url: &str) -> Result { let endpoint = ipc::Endpoint::new(api_url)?; let (connection, welcome) = connect(&endpoint, ConnectionMode::Client, true).await?; + Ok(Self::attach(endpoint, connection, welcome)) + } + + fn attach(endpoint: ipc::Endpoint, connection: ipc::Connection, welcome: Welcome) -> Self { let state = Arc::new(ClientState { pending: Mutex::new(Some(HashMap::new())), user_id: RwLock::new(welcome.user_id), + generation: AtomicU64::new(0), }); - let (requests, mut request_rx) = mpsc::channel::(MAX_CALLS); + let (requests, request_rx) = mpsc::channel::(MAX_CALLS); let (event_tx, event_rx) = mpsc::channel(256); - let closed = tokio_util::sync::CancellationToken::new(); - let reader_closed = closed.clone(); - let (mut read, mut write) = tokio::io::split(connection); - let reader_state = state.clone(); - tokio::spawn(async move { - loop { - let message = tokio::select! { - _ = reader_closed.cancelled() => break, - message = protocol::read::(&mut read) => match message { Ok(message) => message, Err(_) => break }, - }; - match message { - Message::Response { id, result } => { - if let Some(sender) = reader_state - .pending - .lock() - .unwrap() - .as_mut() - .and_then(|pending| pending.remove(&id)) - { - let _ = sender.send(result); - } - } - Message::Event(event) => { - if let AgentServiceEvent::AccountChanged(user_id) = &*event { - *reader_state.user_id.write().unwrap() = user_id.clone(); - } - if event_tx.try_send(*event).is_err() { - break; - } - } - } - } - reader_closed.cancel(); - reader_state.disconnect(); - // Delivery waits only after closing the socket; a stalled UI never - // backpressures the host. Reconnect requires a fresh snapshot. - let _ = event_tx.send(AgentServiceEvent::HostDisconnected).await; - }); - let writer_state = state.clone(); - tokio::spawn(async move { - loop { - let request = tokio::select! { - _ = closed.cancelled() => break, - request = request_rx.recv() => match request { Some(request) => request, None => break }, - }; - let result = tokio::select! { - _ = closed.cancelled() => break, - result = protocol::write(&mut write, &request) => result, - }; - if result.is_err() { - break; - } - } - closed.cancel(); - writer_state.disconnect(); - }); - Ok(Self { + tokio::spawn(maintain_connection( + endpoint.clone(), + connection, + state.clone(), + request_rx, + event_tx, + )); + Self { endpoint, requests, state, next_id: AtomicU64::new(1), events: tokio::sync::Mutex::new(Some(event_rx)), permits: Semaphore::new(MAX_CALLS), - }) + } + } + + pub async fn detach(&self) { + // Normal desktop Quit is explicit, unlike a socket failure. Bounded + // so an unresponsive host cannot prevent the window from closing. + let _ = tokio::time::timeout(Duration::from_secs(2), self.call::<()>("detach", ())).await; } pub fn user_id(&self) -> Option { @@ -157,6 +121,7 @@ impl RemoteClient { ) -> Result { let params = serde_json::to_value(params) .map_err(|_| "Could not encode host request".to_string())?; + let generation = self.state.generation.load(Ordering::Relaxed); let _permit = self .permits .acquire() @@ -164,13 +129,16 @@ impl RemoteClient { .map_err(|_| "Host disconnected".to_string())?; let id = self.next_id.fetch_add(1, Ordering::Relaxed); let (sender, reply) = oneshot::channel(); - self.state - .pending - .lock() - .unwrap() - .as_mut() - .ok_or("The Maple host disconnected")? - .insert(id, sender); + { + let mut pending = self.state.pending.lock().unwrap(); + if generation != self.state.generation.load(Ordering::Relaxed) { + return Err("The Maple host disconnected before this operation was sent.".into()); + } + pending + .as_mut() + .ok_or("The Maple host disconnected")? + .insert(id, sender); + } let _pending = PendingCall { state: &self.state, id, @@ -185,23 +153,145 @@ impl RemoteClient { send_id: send_id.clone(), }) .await; - let result = if sent.is_ok() { - reply - .await - .unwrap_or_else(|_| Err("The Maple host disconnected".into())) - } else { - Err("The Maple host connection closed".into()) - }; - let disconnected = self.state.pending.lock().unwrap().is_none(); - let result = match (result, send_id) { - (Err(_), Some(id)) if disconnected => recover_send(&self.endpoint, &id).await?, - (result, _) => result?, + if sent.is_err() { + self.state.disconnect(); + } + let result = match (sent, reply.await) { + (Ok(()), Ok(result)) => result?, + (_, _) => match send_id { + Some(id) => recover_send(&self.endpoint, &id).await?, + None => { + return Err("The Maple host disconnected. Reconnect before retrying.".into()); + } + }, }; serde_json::from_value(result) .map_err(|_| "Incompatible Maple host response; restart Maple".into()) } } +/// Own the socket independently of the UI. Reconnection never starts a host or +/// replays a request: pending callers fail (sends query their admission receipt). +async fn maintain_connection( + endpoint: ipc::Endpoint, + mut connection: ipc::Connection, + state: Arc, + mut requests: mpsc::Receiver, + events: mpsc::Sender, +) { + loop { + let graceful = client_connection(connection, &state, &mut requests, &events).await; + state.disconnect(); + if graceful || requests.is_closed() || events.is_closed() { + return; + } + let recovery = async { + events + .send(AgentServiceEvent::HostReconnecting) + .await + .map_err(|_| ())?; + loop { + // No startup arbitration or spawn here: explicit host stop and + // crashes must never be undone by a frontend reconnect loop. + if let Ok((next, welcome)) = connect(&endpoint, ConnectionMode::Client, false).await + { + *state.user_id.write().unwrap() = welcome.user_id.clone(); + events + .send(AgentServiceEvent::AccountChanged(welcome.user_id)) + .await + .map_err(|_| ())?; + // Publish readiness before the marker can trigger snapshot + // calls. The socket already subscribes to host events. + *state.pending.lock().unwrap() = Some(HashMap::new()); + events + .send(AgentServiceEvent::HostReconnected) + .await + .map_err(|_| ())?; + return Ok::<_, ()>(next); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + }; + match tokio::time::timeout(RECONNECT_TIMEOUT, recovery).await { + Ok(Ok(next)) => connection = next, + _ => { + state.disconnect(); + let _ = events.send(AgentServiceEvent::HostDisconnected).await; + return; + } + } + } +} + +/// Reads are never cancelled midway through a frame. All queues are bounded; +/// an overflow drops the socket, then the supervisor requests a fresh snapshot. +async fn client_connection( + connection: ipc::Connection, + state: &ClientState, + requests: &mut mpsc::Receiver, + events: &mpsc::Sender, +) -> bool { + let (mut read, mut write) = tokio::io::split(connection); + let (messages, mut incoming) = mpsc::channel(256); + let reader = tokio::spawn(async move { + while let Ok(message) = protocol::read::(&mut read).await { + if messages.send(message).await.is_err() { + break; + } + } + }); + let _reader = AbortOnDrop(reader.abort_handle()); + loop { + tokio::select! { + request = requests.recv() => { + let Some(request) = request else { + let _ = tokio::time::timeout(Duration::from_secs(2), protocol::write(&mut write, &Request { + id: 0, send_id: None, method: "detach".into(), params: serde_json::Value::Null, + })).await; + return true; + }; + // A request queued before a disconnect lost its caller. It + // must not become a mutation on the replacement connection. + if !state.pending.lock().unwrap().as_ref().is_some_and(|pending| pending.contains_key(&request.id)) { + continue; + } + if !matches!(tokio::time::timeout(START_TIMEOUT, protocol::write(&mut write, &request)).await, Ok(Ok(()))) { + return false; + } + if request.method == "detach" { + // No reconnection after an intentional detach, even if + // the acknowledgement is lost or the host is unresponsive. + let _ = tokio::time::timeout(Duration::from_secs(2), async { + while let Some(message) = incoming.recv().await { + if let Message::Response { id, result } = message { + if let Some(sender) = state.pending.lock().unwrap().as_mut().and_then(|pending| pending.remove(&id)) { + let _ = sender.send(result); + } + if id == request.id { break; } + } + } + }).await; + return true; + } + } + message = incoming.recv() => match message { + Some(Message::Response { id, result }) => { + if let Some(sender) = state.pending.lock().unwrap().as_mut().and_then(|pending| pending.remove(&id)) { + let _ = sender.send(result); + } + } + Some(Message::Event(event)) => { + if let AgentServiceEvent::AccountChanged(user_id) = &*event { + *state.user_id.write().unwrap() = user_id.clone(); + } + if events.try_send(*event).is_err() { return false; } + } + None => return false, + }, + } + } +} + async fn handshake( mut stream: ipc::Connection, profile: &str, @@ -353,10 +443,18 @@ async fn wait_for_ownership(file: &std::fs::File, timeout: Duration) -> Result<( } async fn serve(endpoint: ipc::Endpoint, backend: Arc) -> Result<(), String> { - let mut listener = endpoint.bind()?; let receipts = Arc::new(receipts::Receipts::open( &backend::local::host_data_root().join("send-receipts.db"), )?); + serve_with_receipts(endpoint, backend, receipts).await +} + +async fn serve_with_receipts( + endpoint: ipc::Endpoint, + backend: Arc, + receipts: Arc, +) -> Result<(), String> { + let mut listener = endpoint.bind()?; let (events, _) = broadcast::channel::>(256); let (stop_tx, mut stop_rx) = mpsc::channel::<()>(1); let mut source = backend @@ -371,6 +469,7 @@ async fn serve(endpoint: ipc::Endpoint, backend: Arc) -> Result<() }); let mut clients = tokio::task::JoinSet::new(); let mut idle = tokio::time::Instant::now() + START_TIMEOUT; + let mut recovery_until = tokio::time::Instant::now(); let shutdown = shutdown_signal(); tokio::pin!(shutdown); loop { @@ -379,17 +478,19 @@ async fn serve(endpoint: ipc::Endpoint, backend: Arc) -> Result<() _ = &mut shutdown => break, _ = tokio::time::sleep_until(idle), if clients.is_empty() => break, result = clients.join_next(), if !clients.is_empty() => { - if let Some(Err(error)) = result { log::warn!("Host client ended unexpectedly: {error}"); } - if clients.is_empty() { idle = tokio::time::Instant::now() + IDLE_GRACE; } + if matches!(result, Some(Ok(Ok(false))) | Some(Err(_))) { + recovery_until = tokio::time::Instant::now() + DISCONNECT_GRACE; + } + if clients.is_empty() { + idle = (tokio::time::Instant::now() + IDLE_GRACE).max(recovery_until); + } } incoming = listener.accept() => { let connection = match incoming { Ok(connection) => connection, Err(error) => { log::warn!("Host rejected a connection: {error}"); continue; } }; if clients.len() >= MAX_CLIENTS { drop(connection); continue; } let backend = backend.clone(); let endpoint = endpoint.clone(); let events = events.clone(); let stop = stop_tx.clone(); let receipts = receipts.clone(); clients.spawn(async move { - if let Err(error) = serve_client(connection, endpoint, backend, events, stop, receipts).await { - log::debug!("Host connection ended: {error}"); - } + serve_client(connection, endpoint, backend, events, stop, receipts).await }); } } @@ -411,7 +512,7 @@ async fn serve_client( events: broadcast::Sender>, stop: mpsc::Sender<()>, receipts: Arc, -) -> Result<(), String> { +) -> Result { let mut subscription = events.subscribe(); let mode = accept_handshake(&mut connection, &endpoint.profile, backend.saved_user_id()).await?; @@ -424,7 +525,7 @@ async fn serve_client( #[cfg(feature = "acp")] { let (read, write) = tokio::io::split(connection); - return backend.serve_acp(read, write).await; + return backend.serve_acp(read, write).await.map(|_| true); } #[cfg(not(feature = "acp"))] return Err("This host was built without ACP".into()); @@ -445,11 +546,15 @@ async fn serve_client( let _reader = AbortOnDrop(reader.abort_handle()); let (responses, mut response_rx) = mpsc::channel(MAX_CALLS); let mut calls = tokio::task::JoinSet::new(); - let result = async { + let result: Result = async { loop { tokio::select! { request = request_rx.recv(), if calls.len() < MAX_CALLS => { - let Some(request) = request else { break; }; + let Some(request) = request else { return Ok(false); }; + if request.method == "detach" { + write_message(&mut output, &Message::Response { id: request.id, result: Ok(serde_json::Value::Null) }).await?; + return Ok(true); + } let backend = backend.clone(); let responses = responses.clone(); let events = events.clone(); let receipts = receipts.clone(); calls.spawn(async move { let id = request.id; @@ -472,12 +577,14 @@ async fn serve_client( } } } - Ok::<(), String>(()) }.await; // Accepted mutations finish even if their response cannot be delivered. drop(response_rx); while calls.join_next().await.is_some() {} - result + if let Err(error) = &result { + log::debug!("Host connection ended: {error}"); + } + Ok(result.unwrap_or(false)) } async fn accept_handshake( diff --git a/apps/maple-agent/app/src/host/ipc.rs b/apps/maple-agent/app/src/host/ipc.rs index b938c54d3..bacd57c14 100644 --- a/apps/maple-agent/app/src/host/ipc.rs +++ b/apps/maple-agent/app/src/host/ipc.rs @@ -19,6 +19,18 @@ pub struct Endpoint { } impl Endpoint { + #[cfg(test)] + pub(super) fn for_test(directory: PathBuf) -> Self { + #[cfg(unix)] + let address = directory.join("ipc.sock"); + #[cfg(windows)] + let address = PathBuf::from(format!(r"\\.\pipe\maple-test-{}", uuid::Uuid::new_v4())); + Self { + profile: "test-profile".into(), + directory, + address, + } + } pub fn new(api_url: &str) -> Result { let config = crate::backend::app_config_root(); let data = crate::backend::local::host_data_root(); diff --git a/apps/maple-agent/app/src/host/recovery_tests.rs b/apps/maple-agent/app/src/host/recovery_tests.rs new file mode 100644 index 000000000..f313117bb --- /dev/null +++ b/apps/maple-agent/app/src/host/recovery_tests.rs @@ -0,0 +1,352 @@ +use super::*; + +fn endpoint() -> (tempfile::TempDir, ipc::Endpoint) { + #[cfg(unix)] + let root = tempfile::Builder::new() + .prefix("maple-recovery-") + .tempdir_in("/tmp") + .unwrap(); + #[cfg(windows)] + let root = tempfile::tempdir().unwrap(); + let endpoint = ipc::Endpoint::for_test(root.path().to_path_buf()); + (root, endpoint) +} + +async fn accept( + listener: &mut ipc::Listener, + endpoint: &ipc::Endpoint, + user: &str, +) -> ipc::Connection { + let mut connection = listener.accept().await.unwrap(); + assert_eq!( + accept_handshake(&mut connection, &endpoint.profile, Some(user.into())) + .await + .unwrap(), + ConnectionMode::Client + ); + connection +} + +async fn client( + endpoint: &ipc::Endpoint, + listener: &mut ipc::Listener, +) -> (RemoteClient, ipc::Connection) { + let (connection, server) = tokio::join!( + connect(endpoint, ConnectionMode::Client, false), + accept(listener, endpoint, "first-user") + ); + let (connection, welcome) = connection.unwrap(); + ( + RemoteClient::attach(endpoint.clone(), connection, welcome), + server, + ) +} + +async fn reconnected(events: &mut mpsc::Receiver) { + tokio::time::timeout(Duration::from_secs(5), async { + while let Some(event) = events.recv().await { + if matches!(event, AgentServiceEvent::HostReconnected) { + return; + } + assert!(!matches!(event, AgentServiceEvent::HostDisconnected)); + } + panic!("event stream ended before reconnection"); + }) + .await + .unwrap(); +} + +async fn response(connection: &mut ipc::Connection, expected: &str, value: serde_json::Value) { + let request: Request = protocol::read(connection).await.unwrap(); + assert_eq!( + request.method, expected, + "an old command must never be replayed" + ); + protocol::write( + connection, + &Message::Response { + id: request.id, + result: Ok(value), + }, + ) + .await + .unwrap(); +} + +#[tokio::test] +async fn interrupted_calls_fail_then_reconnect_to_the_current_account_without_replay() { + let (_root, endpoint) = endpoint(); + let mut listener = endpoint.bind().unwrap(); + let (client, mut server) = client(&endpoint, &mut listener).await; + let mut events = client.take_events().await.unwrap(); + let call = client.call::<()>("test-mutation", ()); + tokio::pin!(call); + tokio::select! { + _ = &mut call => panic!("mutation must await its response"), + request = protocol::read::(&mut server) => assert_eq!(request.unwrap().method, "test-mutation"), + } + drop(server); + assert!( + tokio::time::timeout(Duration::from_secs(5), call) + .await + .unwrap() + .is_err() + ); + let mut server = accept(&mut listener, &endpoint, "second-user").await; + reconnected(&mut events).await; + assert_eq!(client.user_id().as_deref(), Some("second-user")); + let (result, ()) = tokio::join!( + client.call::("inspect", ()), + response(&mut server, "inspect", serde_json::json!("fresh")) + ); + assert_eq!(result.unwrap(), "fresh"); + let ((), ()) = tokio::join!( + client.detach(), + response(&mut server, "detach", serde_json::Value::Null) + ); + assert!( + tokio::time::timeout(Duration::from_secs(2), events.recv()) + .await + .unwrap() + .is_none() + ); +} + +#[tokio::test] +async fn calls_waiting_for_capacity_do_not_cross_a_connection_generation() { + use std::{future::Future, task::Poll}; + let (_root, endpoint) = endpoint(); + let mut listener = endpoint.bind().unwrap(); + let (client, server) = client(&endpoint, &mut listener).await; + let mut events = client.take_events().await.unwrap(); + let capacity = client.permits.acquire_many(MAX_CALLS as u32).await.unwrap(); + let queued = client.call::<()>("queued-mutation", ()); + tokio::pin!(queued); + std::future::poll_fn(|cx| { + assert!(queued.as_mut().poll(cx).is_pending()); + Poll::Ready(()) + }) + .await; + drop(server); + let (mut server, ()) = tokio::join!( + accept(&mut listener, &endpoint, "first-user"), + reconnected(&mut events) + ); + drop(capacity); + assert!( + queued + .await + .unwrap_err() + .contains("before this operation was sent") + ); + let (result, ()) = tokio::join!( + client.call::<()>("new-command", ()), + response(&mut server, "new-command", serde_json::Value::Null) + ); + result.unwrap(); + let ((), ()) = tokio::join!( + client.detach(), + response(&mut server, "detach", serde_json::Value::Null) + ); +} + +#[tokio::test] +async fn slow_frontend_recovers_after_a_bounded_event_queue_overflow() { + let (_root, endpoint) = endpoint(); + let mut listener = endpoint.bind().unwrap(); + let (client, mut server) = client(&endpoint, &mut listener).await; + let mut events = client.take_events().await.unwrap(); + // Don't consume UI events until the reader has dropped its old socket. + tokio::time::timeout(Duration::from_secs(5), async { + for _ in 0..2000 { + if protocol::write( + &mut server, + &Message::Event(Box::new(AgentServiceEvent::QuestionResolved { + request_id: "fixture".into(), + })), + ) + .await + .is_err() + { + return; + } + } + let _: Result = protocol::read(&mut server).await; + }) + .await + .unwrap(); + assert!(events.len() <= 256); + drop(server); + let (mut server, ()) = tokio::join!( + accept(&mut listener, &endpoint, "first-user"), + reconnected(&mut events) + ); + let (result, ()) = tokio::join!( + client.call::("inspect", ()), + response(&mut server, "inspect", serde_json::json!(true)) + ); + assert!(result.unwrap()); + let ((), ()) = tokio::join!( + client.detach(), + response(&mut server, "detach", serde_json::Value::Null) + ); +} + +#[tokio::test] +async fn a_lost_send_response_queries_the_receipt_without_resending() { + let (_root, endpoint) = endpoint(); + let mut listener = endpoint.bind().unwrap(); + let (client, mut first) = client(&endpoint, &mut listener).await; + let mut events = client.take_events().await.unwrap(); + let server = tokio::spawn(async move { + let request: Request = protocol::read(&mut first).await.unwrap(); + assert_eq!(request.method, "send_message"); + let identity = request.send_id.unwrap(); + drop(first); + // One connection restores the stream, the other queries admission. + let mut connections = tokio::task::JoinSet::new(); + for _ in 0..2 { + let mut connection = accept(&mut listener, &endpoint, "first-user").await; + let identity = identity.clone(); + connections.spawn(async move { + let request: Request = protocol::read(&mut connection).await.unwrap(); + match request.method.as_str() { + "send_outcome" => { + assert_eq!(request.params, serde_json::json!(identity)); + protocol::write( + &mut connection, + &Message::Response { + id: request.id, + result: Ok(serde_json::to_value(receipts::Outcome::Finished(Ok( + serde_json::json!("accepted-run"), + ))) + .unwrap()), + }, + ) + .await + .unwrap(); + } + "detach" => protocol::write( + &mut connection, + &Message::Response { + id: request.id, + result: Ok(serde_json::Value::Null), + }, + ) + .await + .unwrap(), + other => panic!("unexpected replay: {other}"), + } + }); + } + while let Some(result) = connections.join_next().await { + result.unwrap(); + } + }); + let result = tokio::time::timeout( + Duration::from_secs(5), + client.call::("send_message", ()), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(result, "accepted-run"); + reconnected(&mut events).await; + client.detach().await; + server.await.unwrap(); +} + +#[tokio::test] +async fn unexpected_disconnect_keeps_the_host_alive_but_explicit_stop_still_wins() { + let (root, endpoint) = endpoint(); + let backend = Arc::new(LocalBackend::isolated_for_test(root.path())); + let receipts = Arc::new(receipts::Receipts::open(&root.path().join("receipts.db")).unwrap()); + let server_endpoint = endpoint.clone(); + let server = + tokio::spawn(async move { serve_with_receipts(server_endpoint, backend, receipts).await }); + let (connection, _) = tokio::time::timeout(Duration::from_secs(5), async { + loop { + if let Ok(connection) = connect(&endpoint, ConnectionMode::Client, false).await { + break connection; + } + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + drop(connection); + tokio::time::sleep(IDLE_GRACE + Duration::from_millis(100)).await; + assert!( + !server.is_finished(), + "an unexpected loss must not use the normal quit grace" + ); + let (_stop, _) = connect(&endpoint, ConnectionMode::Stop, false) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(5), server) + .await + .unwrap() + .unwrap() + .unwrap(); +} + +#[tokio::test] +async fn host_loss_exhausts_recovery_without_starting_a_replacement() { + let (_root, endpoint) = endpoint(); + let mut listener = endpoint.bind().unwrap(); + let (client, server) = client(&endpoint, &mut listener).await; + let mut events = client.take_events().await.unwrap(); + drop(listener); + drop(server); + assert!(matches!( + events.recv().await, + Some(AgentServiceEvent::HostReconnecting) + )); + assert!(matches!( + tokio::time::timeout(RECONNECT_TIMEOUT + Duration::from_secs(2), events.recv()) + .await + .unwrap(), + Some(AgentServiceEvent::HostDisconnected) + )); + assert!(endpoint.connect().await.is_err()); + assert!(client.call::<()>("must-not-run", ()).await.is_err()); +} + +#[tokio::test] +async fn graceful_detach_keeps_other_clients_running_and_last_quit_shuts_down() { + let (root, endpoint) = endpoint(); + let backend = Arc::new(LocalBackend::isolated_for_test(root.path())); + let receipts = Arc::new(receipts::Receipts::open(&root.path().join("receipts.db")).unwrap()); + let server_endpoint = endpoint.clone(); + let server = + tokio::spawn(async move { serve_with_receipts(server_endpoint, backend, receipts).await }); + let (connection, welcome) = tokio::time::timeout(Duration::from_secs(5), async { + loop { + if let Ok(connection) = connect(&endpoint, ConnectionMode::Client, false).await { + break connection; + } + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + let first = RemoteClient::attach(endpoint.clone(), connection, welcome); + let (connection, welcome) = connect(&endpoint, ConnectionMode::Client, false) + .await + .unwrap(); + let second = RemoteClient::attach(endpoint.clone(), connection, welcome); + first.detach().await; + tokio::time::sleep(IDLE_GRACE + Duration::from_millis(100)).await; + assert!(!server.is_finished()); + second + .call::<()>("set_harness_instructions", ("fixture",)) + .await + .unwrap(); + second.detach().await; + tokio::time::timeout(IDLE_GRACE + Duration::from_secs(3), server) + .await + .unwrap() + .unwrap() + .unwrap(); + assert!(endpoint.connect().await.is_err()); +} diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index ac9f5e44e..f99301a17 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -406,6 +406,7 @@ pub struct ChatScreen { window_active: bool, sidebar_plan: Option, runtime_error: Option, + host_recovery: HostRecovery, notice: Option, booting: bool, /// Task being opened whose snapshot has not landed yet, so the pane @@ -622,6 +623,14 @@ enum LoadMode { Reload, } +#[derive(Default)] +struct HostRecovery { + epoch: u64, + unavailable: bool, + events: Option>, + overflow: bool, +} + /// How often a stale snapshot is fetched again before it is applied. const LOAD_RETRIES: u8 = 2; @@ -1040,6 +1049,7 @@ impl ChatScreen { window_active: true, sidebar_plan: None, runtime_error: None, + host_recovery: HostRecovery::default(), notice: None, booting: true, loading_session: None, @@ -2171,6 +2181,12 @@ impl ChatScreen { /// Load a task and make it current when its snapshot lands; its /// persisted root then becomes the visible project context. pub(crate) fn select_session(&mut self, session_id: &str, cx: &mut Context) { + if self.host_recovery.unavailable { + self.notice = + Some("Task history is reconnecting. Try again when it has recovered.".into()); + cx.notify(); + return; + } // The side thread belongs to the task it forked. if self.btw.is_some() && self.selected_session.as_deref() != Some(session_id) { self.close_side_thread(cx); @@ -3656,6 +3672,13 @@ impl ChatScreen { } fn send_text_with(&mut self, text: String, steer: bool, cx: &mut Context) { + if self.host_recovery.unavailable { + self.notice = Some( + "Wait for the host connection and task history to recover before sending.".into(), + ); + cx.notify(); + return; + } if self.thinking_saving { self.notice = Some("The thinking level is still being saved".into()); cx.notify(); @@ -4666,8 +4689,139 @@ impl ChatScreen { } } + /// Rebuild presentation from the owner, preserving the composer and + /// selection. Buffer a bounded stream while loading, then apply it after + /// the snapshot. Overflow asks for another snapshot, never a bigger queue. + fn recover_host(&mut self, cx: &mut Context) { + self.host_recovery.epoch += 1; + let epoch = self.host_recovery.epoch; + self.host_recovery.unavailable = true; + self.host_recovery.events = Some(Vec::new()); + self.host_recovery.overflow = false; + self.begin_navigation(); + let selection = self.selection_generation; + let backend = self.backend.clone(); + let user = self.user_id.clone(); + let selected = self.selected_session.clone(); + self.runtime_error = Some("Connected. Refreshing task history…".into()); + self.call( + async move { backend.recovery_snapshot(&user, selected).await }, + cx, + move |this, result, cx| { + if this.host_recovery.epoch != epoch { + return; + } + if this.host_recovery.overflow { + this.recover_host(cx); + return; + } + match result { + Ok(snapshot) => { + let events = this.host_recovery.events.take().unwrap_or_default(); + let running = events + .iter() + .rev() + .find_map(|event| match event { + AgentServiceEvent::RuntimeStatus(status) => Some(status.running), + _ => None, + }) + .unwrap_or(snapshot.status.running); + this.apply_recovery_snapshot(snapshot, selection, cx); + this.handle_service_events(events, cx); + this.host_recovery.unavailable = false; + this.runtime_error = None; + this.refresh_models(cx); + this.refresh_roots(cx); + this.refresh_slash_commands(cx); + if !running { + // A host another client restarted may not have a + // runtime yet. Restore catalog/tool availability; + // this does not resume or submit any prompt. + this.booting = true; + this.start_runtime(cx); + } + } + Err(error) => { + this.host_recovery.events = None; + this.runtime_error = Some( + format!( + "Could not reload host state: {error}. Reopen Maple before sending." + ) + .into(), + ); + } + } + cx.notify(); + }, + ); + } + + fn apply_recovery_snapshot( + &mut self, + snapshot: crate::backend::RecoverySnapshot, + selection: u64, + cx: &mut Context, + ) { + // Revisions are scoped to a host incarnation. Even a restarted host + // with lower revision numbers must supersede the old partial rows. + self.snapshot_revision = 0; + for item in &mut self.timeline { + item.revision = 0; + } + self.loading_timeline.clear(); + self.finished_runs.clear(); + self.active_runs = snapshot.status.active_runs; + self.pending_questions.clear(); + self.question_selected.clear(); + self.sessions = snapshot.bootstrap.sessions; + self.recent_roots = snapshot.bootstrap.recent_roots; + self.handle_service_events(snapshot.bootstrap.questions, cx); + if self.selection_generation == selection { + if let Some(detail) = snapshot.selected { + self.snapshot_revision = detail.timeline_revision; + self.set_active_session(detail.session, detail.timeline, HashMap::new(), cx); + self.set_queue(detail.queue.items); + } else if self.selected_session.is_some() { + // The selected task was deleted while this client was away. + self.new_session(cx); + } + } + self.sync_sidebar(cx); + } + /// Apply one event; returns false when nothing visible changed. fn apply_service_event(&mut self, event: AgentServiceEvent, cx: &mut Context) -> bool { + match event { + AgentServiceEvent::HostReconnecting => { + self.host_recovery.epoch += 1; + self.host_recovery.unavailable = true; + self.host_recovery.events = None; + self.begin_navigation(); + self.runtime_error = + Some("Connection lost. Reconnecting to the Maple host…".into()); + return true; + } + AgentServiceEvent::HostReconnected => { + self.recover_host(cx); + return true; + } + AgentServiceEvent::HostDisconnected => { + self.host_recovery.epoch += 1; + self.host_recovery.unavailable = true; + self.host_recovery.events = None; + self.runtime_error = Some("The Maple host is unavailable. Reopen Maple to reload your tasks. No message was resent.".into()); + return true; + } + _ => {} + } + if let Some(events) = &mut self.host_recovery.events { + if events.len() < 256 { + events.push(event); + } else { + self.host_recovery.overflow = true; + } + return false; + } let row = match &event { AgentServiceEvent::TimelineItem { session_id, item, .. @@ -4705,9 +4859,9 @@ impl ChatScreen { self.thinking = Some(state); } } - AgentServiceEvent::HostDisconnected => { - self.runtime_error = Some("The Maple host disconnected. Reopen Maple to reload your tasks before sending again.".into()); - } + AgentServiceEvent::HostDisconnected + | AgentServiceEvent::HostReconnecting + | AgentServiceEvent::HostReconnected => return false, AgentServiceEvent::RuntimeStatus(mut status) => { // A runtime that just started points Goose at the account's // skills, which the first scan ran before; ask again. diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index 0d683b8c3..b13a4284f 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -581,6 +581,135 @@ mod state_tests { } } + #[gpui::test] + fn recovery_snapshot_replaces_old_host_revisions_and_reconciles_live_events( + cx: &mut TestAppContext, + ) { + let screen = screen(cx); + screen.update(cx, |this, cx| { + let mut old = item("reply", "message", Some("old partial")); + old.revision = 900; + this.replace_timeline(vec![old]); + this.snapshot_revision = 900; + this.active_runs.insert("s1".into(), "old-run".into()); + this.handle_service_event(one_question("old", "old question"), cx); + this.handle_service_event(AgentServiceEvent::HostReconnecting, cx); + assert!(this.host_recovery.unavailable); + // Model a subscribed replacement host and updates arriving while + // its snapshot is in flight. The new host starts revisions at 1. + this.host_recovery.events = Some(Vec::new()); + let mut latest = item("reply", "message", Some("new complete reply")); + latest.revision = 3; + this.handle_service_event( + AgentServiceEvent::TimelineItem { + session_id: "s1".into(), + run_id: None, + item: latest.clone(), + }, + cx, + ); + this.handle_service_event( + AgentServiceEvent::QuestionResolved { + request_id: "req-current".into(), + }, + cx, + ); + let events = this.host_recovery.events.take().unwrap(); + let mut snapshot_row = latest.clone(); + snapshot_row.revision = 2; + snapshot_row.text = Some("new partial".into()); + let selected = summary("s1", "Recovered title"); + this.apply_recovery_snapshot( + crate::backend::RecoverySnapshot { + bootstrap: crate::backend::LocalBootstrap { + project_root: None, + recent_roots: Vec::new(), + latest: None, + sessions: vec![selected.clone()], + questions: vec![one_question("current", "new question")], + }, + status: maple_agent::agent::AgentRuntimeStatus { + running: true, + project_root: None, + model: None, + active_runs: HashMap::new(), + }, + selected: Some(maple_agent::agent::AgentSessionDetail { + timeline_revision: 2, + session: selected, + timeline: vec![snapshot_row], + mcp_errors: Vec::new(), + queue: maple_agent::agent::AgentDesktopQueueSnapshot { + revision: 1, + items: vec![maple_agent::agent::AgentQueuedMessage { + queue_id: "queued".into(), + message_id: "queued-message".into(), + session_id: "s1".into(), + text: "pending message".into(), + attachments: Vec::new(), + created_ms: 0, + }], + }, + }), + }, + this.selection_generation, + cx, + ); + this.handle_service_events(events, cx); + assert_eq!(this.timeline.len(), 1); + assert_eq!(this.timeline[0].text.as_deref(), Some("new complete reply")); + assert_eq!(this.timeline[0].revision, 3); + assert!(this.active_runs.is_empty()); + assert!( + this.pending_questions.is_empty(), + "resolved questions must not reappear" + ); + assert_eq!(this.queue[0].text, "pending message"); + assert_eq!(this.sessions[0].title, "Recovered title"); + this.handle_service_event( + AgentServiceEvent::TimelineItem { + session_id: "s1".into(), + run_id: None, + item: latest, + }, + cx, + ); + assert_eq!( + this.timeline.len(), + 1, + "snapshot/event overlap must not duplicate rows" + ); + }); + } + + #[gpui::test] + fn reconnecting_keeps_composer_text_and_bounds_snapshot_event_buffer(cx: &mut TestAppContext) { + let (screen, cx) = chat_window(cx, |this, _| { + this.booting = false; + }); + screen.update(cx, |this, cx| { + this.fill_empty_composer("do not automatically send this", cx); + this.handle_service_event(AgentServiceEvent::HostReconnecting, cx); + this.send_inner(cx); + assert_eq!( + this.composer_text(cx).as_deref(), + Some("do not automatically send this") + ); + assert!(!this.session_setup_pending); + this.host_recovery.events = Some(Vec::new()); + for _ in 0..300 { + this.handle_service_event( + AgentServiceEvent::QuestionResolved { + request_id: "fixture".into(), + }, + cx, + ); + } + assert_eq!(this.host_recovery.events.as_ref().unwrap().len(), 256); + assert!(this.host_recovery.overflow); + }); + } + #[gpui::test] fn test_question_event_sets_pending_card(cx: &mut TestAppContext) { cx.executor().allow_parking(); 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 b6f29ceec..6af4289c0 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/types.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/types.rs @@ -660,7 +660,11 @@ pub enum AgentRunEvent { pub enum AgentServiceEvent { /// Profile sign-in changed in the authoritative host. AccountChanged(Option), - /// The local frontend must reconnect and reload before sending again. + /// Temporary loss of the frontend connection; the host may still be running. + HostReconnecting, + /// A new connection is subscribed. Reload authoritative snapshots before sending. + HostReconnected, + /// Automatic recovery ended; reopening is required. No commands were replayed. HostDisconnected, RuntimeStatus(AgentRuntimeStatus), /// The agent asked the user one or more related questions (ask_user diff --git a/apps/maple-agent/docs/local-host.md b/apps/maple-agent/docs/local-host.md index 78c4d4c7e..2922a2543 100644 --- a/apps/maple-agent/docs/local-host.md +++ b/apps/maple-agent/docs/local-host.md @@ -10,8 +10,11 @@ host operations and events onto the client protocol. Pi itself is unchanged. Opening an ACP editor can start the host without opening a window. Desktop joins it later. Closing a client detaches; a run continues while another client remains. -After the last client leaves, a two-second grace period allows a replacement to -attach, then the host cancels and drains its work and exits. `maple-agent host stop` +After the last intentional desktop Quit or ACP detach, a two-second grace period +allows a replacement to attach, then the host cancels and drains its work and exits. +An unexpected desktop connection loss instead reserves thirty seconds for recovery, +including when another client briefly connects and leaves during that interval. +Attached clients keep the host alive. `maple-agent host stop` explicitly stops this profile and all its tasks, even with clients attached. There is no login item, permanent service, remote listener or remote keepalive. @@ -35,6 +38,9 @@ the pipe owner. This is an OS-user trust boundary, not a sandbox against other programs running as that user. No API token is placed in endpoint names or logs. Signing out revokes existing runtime handles and ACP connections before another account can sign in. Task commands validate their account at the host boundary. +Checking saved credentials and attaching ACP share a read lock with local operations; +slow credential validation does not block history, settings, or Stop. Actual account +switches remain exclusive so a request cannot cross accounts while it is admitted. ## Shared tasks and recovery @@ -46,10 +52,19 @@ Filesystem paths and tools always refer to the host machine. Snapshots include live partial rows with an event cutoff. Clients discard older updates and keep later ones, rather than appending the same partial text twice. -The event stream is bounded. A client that falls behind disconnects and must -reopen/reload; this version does not automatically reconnect the desktop. A host -crash does not restart inference. Stored history survives; active tools are not -promised to survive a host crash. +The event stream is bounded. A desktop that falls behind or loses its socket +reconnects for up to fifteen seconds, without starting a replacement host. It shows +the connection state and blocks new sends until task history, active runs, questions, +and queues have been refreshed. Unsent composer text stays in place. A snapshot +also resets host-local revisions, so a restarted host cannot leave old partials +on screen. An account change discovered during recovery switches the screen before +new events are shown. Failed recovery asks the user to reopen Maple. + +Commands are never replayed on reconnection. A host crash does not restart inference. +Stored history survives; active tools are not promised to survive a host crash. +An unrecovered last-client disconnect eventually drains work after the recovery +grace. This bounded local policy is not remote-mode keepalive; a future enabled +remote host must have a lifetime independent of its currently attached clients. Desktop sends carry unique IDs. A small private `send-receipts.db` records admission and its result, so a lost reply can be queried without resending the prompt. A diff --git a/apps/maple-agent/scripts/smoke-macos-release.py b/apps/maple-agent/scripts/smoke-macos-release.py index 1629d0bd0..b83752a35 100644 --- a/apps/maple-agent/scripts/smoke-macos-release.py +++ b/apps/maple-agent/scripts/smoke-macos-release.py @@ -82,7 +82,7 @@ def stop_process(process): process.wait(timeout=5) -def observe_gui(command, *, timeout=30.0, settle=1.0, parent_environment=None): +def observe_gui(command, *, timeout=30.0, settle=1.0, parent_environment=None, host_command=None): """Observe a bounded real process; fixtures use this independently of macOS.""" parent = os.environ if parent_environment is None else parent_environment with tempfile.TemporaryDirectory(prefix="maple-agent-gui-smoke-") as temporary: @@ -131,6 +131,20 @@ def observe_gui(command, *, timeout=30.0, settle=1.0, parent_environment=None): stop_process(process) finally: process.stderr.close() + if host_command is not None: + # SIGTERM simulates an unexpected client loss, for which + # the host now reserves a longer recovery window. Stop only + # this probe's isolated profile before unmounting its DMG. + try: + stopped = subprocess.run( + [*host_command, "host", "stop"], cwd=scratch, env=environment, + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, timeout=45, check=False, + ) + except subprocess.TimeoutExpired as error: + raise SmokeError("The probe's isolated host did not stop") from error + if stopped.returncode and b"No Maple host is running for this profile" not in stopped.stderr: + raise SmokeError("Could not stop the probe's isolated host") def main(argv=None): @@ -141,7 +155,7 @@ def main(argv=None): if platform.system() != "Darwin": raise SmokeError("Agent macOS GUI verification requires a native macOS GUI session") executable = bundle_executable(arguments.bundle) - observe_gui([str(executable)]) + observe_gui([str(executable)], host_command=[str(executable)]) except (SmokeError, OSError) as error: # Application stderr may contain sensitive data. Report only our # bounded diagnostics, never the subprocess output or its environment. diff --git a/apps/maple-agent/scripts/test_smoke_macos_release.py b/apps/maple-agent/scripts/test_smoke_macos_release.py index 06c5ba893..c905e995a 100644 --- a/apps/maple-agent/scripts/test_smoke_macos_release.py +++ b/apps/maple-agent/scripts/test_smoke_macos_release.py @@ -82,6 +82,24 @@ def test_missing_render_times_out_and_cleans_only_the_launched_process(self): self.assert_cleaned() self.assertIsNone(unrelated.poll()) + def test_cleanup_stops_only_the_probe_profile_before_removing_its_state(self): + command = self.fixture(''' +print("startup: window open at 20 ms", file=sys.stderr, flush=True) +print("startup: first render at 30 ms", file=sys.stderr, flush=True) +time.sleep(30) +''') + with mock.patch.object(smoke.subprocess, "run") as run: + def stop(args, **options): + state = json.loads(self.record.read_text()) + self.assertEqual(args, ["/fixture/maple-agent", "host", "stop"]) + self.assertEqual(options["env"], state["environment"]) + self.assertTrue(Path(options["cwd"]).exists()) + return subprocess.CompletedProcess(args, 0, stderr=b"") + run.side_effect = stop + smoke.observe_gui(command, timeout=2, settle=0.1, host_command=["/fixture/maple-agent"]) + run.assert_called_once() + self.assert_cleaned() + def test_child_exit_after_markers_fails_instead_of_claiming_live_gui(self): command = self.fixture(''' print("startup: window open at 20 ms", file=sys.stderr, flush=True) From fa2b1097bc9114b7b9613201cb1988c4cc2d2028 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Fri, 16 Oct 2026 08:12:22 +0000 Subject: [PATCH 8/8] fix(agent): wait for busy Windows host pipe instances --- apps/maple-agent/app/src/host/ipc.rs | 21 ++++++++++-- .../app/src/host/recovery_tests.rs | 32 +++++++++++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/apps/maple-agent/app/src/host/ipc.rs b/apps/maple-agent/app/src/host/ipc.rs index bacd57c14..a896c717b 100644 --- a/apps/maple-agent/app/src/host/ipc.rs +++ b/apps/maple-agent/app/src/host/ipc.rs @@ -116,9 +116,24 @@ impl Endpoint { #[cfg(windows)] { use std::os::windows::io::AsRawHandle; - let stream = tokio::net::windows::named_pipe::ClientOptions::new() - .open(self.address()) - .map_err(|e| e.to_string())?; + use windows_sys::Win32::Foundation::ERROR_PIPE_BUSY; + // A concurrent client can take the available pipe instance before + // accept creates the next one. Busy is not a missing host: wait + // briefly without blocking the executor or starting another host. + let stream = tokio::time::timeout(super::START_TIMEOUT, async { + loop { + match tokio::net::windows::named_pipe::ClientOptions::new().open(self.address()) + { + Ok(stream) => return Ok(stream), + Err(error) if error.raw_os_error() == Some(ERROR_PIPE_BUSY as i32) => { + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + Err(error) => return Err(error.to_string()), + } + } + }) + .await + .map_err(|_| "Maple host pipe remained busy".to_string())??; verify_pipe_owner(stream.as_raw_handle())?; Ok(Box::new(stream)) } diff --git a/apps/maple-agent/app/src/host/recovery_tests.rs b/apps/maple-agent/app/src/host/recovery_tests.rs index f313117bb..0594f2dbb 100644 --- a/apps/maple-agent/app/src/host/recovery_tests.rs +++ b/apps/maple-agent/app/src/host/recovery_tests.rs @@ -256,6 +256,38 @@ async fn a_lost_send_response_queries_the_receipt_without_resending() { server.await.unwrap(); } +#[cfg(windows)] +#[tokio::test] +async fn concurrent_clients_wait_for_the_next_pipe_instance() { + use std::{future::Future, task::Poll}; + let (_root, endpoint) = endpoint(); + let mut listener = endpoint.bind().unwrap(); + let _first_client = endpoint.connect().await.unwrap(); + // Keep the first connection unaccepted so the only pipe instance is busy. + // The next open must wait, rather than reporting that the host is absent. + let waiting = endpoint.connect(); + tokio::pin!(waiting); + std::future::poll_fn(|cx| { + assert!(waiting.as_mut().poll(cx).is_pending()); + Poll::Ready(()) + }) + .await; + let _first_server = listener.accept().await.unwrap(); + let (second_client, second_server) = tokio::time::timeout(Duration::from_secs(5), async { + tokio::join!(waiting, listener.accept()) + }) + .await + .unwrap(); + let mut second_client = second_client.unwrap(); + let mut second_server = second_server.unwrap(); + let (sent, received) = tokio::join!( + protocol::write(&mut second_client, &"second-client"), + protocol::read::(&mut second_server) + ); + sent.unwrap(); + assert_eq!(received.unwrap(), "second-client"); +} + #[tokio::test] async fn unexpected_disconnect_keeps_the_host_alive_but_explicit_stop_still_wins() { let (root, endpoint) = endpoint();