From a37c438229ffbce0f1d3798cb9c9d55aa37ca8ae Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 27 Oct 2026 12:11:25 -0400 Subject: [PATCH 1/3] refactor(desktop): tie page-owned native work to its own webview Host processes, MCP servers and held agent removals belonged to "the page", which meant the main webview: a load there stopped all of it. Each now records the page in the webview that started it, and a page load stops only that webview's work. With main the only webview that starts any, behavior is unchanged; a hidden agents webview can then outlive a reload of the UI page. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- src-tauri/src/app_agents.rs | 62 ++++++++++++--------- src-tauri/src/host_mcp.rs | 42 ++++++++++----- src-tauri/src/host_mcp/tests.rs | 32 +++++++---- src-tauri/src/host_process.rs | 58 ++++++++++++-------- src-tauri/src/host_process/tests.rs | 83 ++++++++++++++++++++++++----- src-tauri/src/lib.rs | 11 ++-- src-tauri/src/page.rs | 62 +++++++++++++++++++++ 7 files changed, 263 insertions(+), 87 deletions(-) create mode 100644 src-tauri/src/page.rs diff --git a/src-tauri/src/app_agents.rs b/src-tauri/src/app_agents.rs index 08954df231..16a7a4a9c4 100644 --- a/src-tauri/src/app_agents.rs +++ b/src-tauri/src/app_agents.rs @@ -2,6 +2,7 @@ //! and owner attestation never enter the WebView; it asks for one bounded //! event, read, upload or memory write at a time, and native signs and sends it //! to the agent's community. +use crate::page::{Page, PageLoads}; use buzz_agent_controller::{ inline_picture, AppAgent, AppAgents, Carry, Credentials, OldStores, PlatformCredentials, PortSource, Portables, Published, Removal, Secret, CREDENTIALS_BUSY, @@ -88,11 +89,13 @@ impl AppAgentHost { .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) } - /// Lets go of the removals the page held, as a new page starts none. - pub(crate) fn page_load(&self) { + /// Lets go of the removals `webview`'s page held, as a new page starts none. + pub(crate) fn page_load(&self, webview: &str) { let mut removals = self.removals(); - removals.page += 1; - removals.held.clear(); + removals.pages.load(webview); + removals + .held + .retain(|_, (page, _)| page.webview() != webview); } fn keys(&self) -> std::sync::MutexGuard<'_, HashMap>> { self.keys @@ -140,8 +143,8 @@ impl AppAgentHost { /// will ever have. The row stays, marked deleted, until the WebView has /// cleaned up the community and calls `app_agent_forget`. This copy of the /// app stops running the agent once it is in no community. - async fn delete(&self, pubkey: String, relay: String) -> Result<(), String> { - let page = self.removals().page; + async fn delete(&self, webview: &str, pubkey: String, relay: String) -> Result<(), String> { + let page = self.removals().pages.current(webview); let turn = self.profile_turn(&pubkey); let _turn = turn.lock().await; let host = self.clone(); @@ -150,13 +153,13 @@ impl AppAgentHost { let mut keys = host.keys(); let agents = host.agents.clone()?; let removals = host.removals(); - if removals.page != page { + if !removals.pages.is_current(&page) { return Err("The app reloaded while it was being removed".into()); } - let removal = removals + let (_, removal) = removals .held .get(&pubkey) - .filter(|removal| removal.is(&pubkey, &relay)) + .filter(|(holder, removal)| *holder == page && removal.is(&pubkey, &relay)) .ok_or("Its removal from that community isn't held by this app")?; keys.remove(&pubkey); agents.remove(removal, host.credentials.as_ref())?; @@ -268,9 +271,10 @@ fn open_lock(path: &Path) -> Result { /// `AppAgents::hold_removal`. #[derive(Default)] struct Removals { - /// Counts page loads, so a hold taken for an earlier page is let go. - page: u64, - held: HashMap, + /// So a hold taken for an earlier page is let go. + pages: PageLoads, + /// Each hold, with the page that took it. + held: HashMap, } /// Runs blocking file and credential work off the async runtime. @@ -530,12 +534,13 @@ pub(crate) async fn app_agent_join( } #[tauri::command] -pub(crate) async fn app_agent_delete( +pub(crate) async fn app_agent_delete( + webview: tauri::Webview, state: tauri::State<'_, AppAgentHost>, pubkey: String, relay: String, ) -> Result<(), String> { - state.delete(pubkey, relay).await + state.delete(webview.label(), pubkey, relay).await } #[tauri::command] @@ -561,21 +566,22 @@ pub(crate) async fn app_agent_begin_leave( /// Holds its removal from the community at `relay` for this copy of the app, /// until `app_agent_release_removal`; see `AppAgents::hold_removal`. #[tauri::command] -pub(crate) async fn app_agent_hold_removal( +pub(crate) async fn app_agent_hold_removal( + webview: tauri::Webview, state: tauri::State<'_, AppAgentHost>, pubkey: String, relay: String, ) -> Result<(), String> { let host = state.inner().clone(); - let page = host.removals().page; + let page = host.removals().pages.current(webview.label()); blocking(move || { let removal = host.agents.clone()?.hold_removal(&pubkey, &relay)?; let mut removals = host.removals(); // A page that loaded meanwhile holds none, so it is let go. - if removals.page != page { + if !removals.pages.is_current(&page) { return Err("The app reloaded while it was being removed".into()); } - removals.held.insert(pubkey, removal); + removals.held.insert(pubkey, (page, removal)); Ok(()) }) .await @@ -590,7 +596,7 @@ pub(crate) fn app_agent_release_removal( let held = &mut state.removals().held; if held .get(&pubkey) - .is_some_and(|removal| removal.is(&pubkey, &relay)) + .is_some_and(|(_, removal)| removal.is(&pubkey, &relay)) { held.remove(&pubkey); } @@ -1204,22 +1210,30 @@ mod tests { )); let agents = host.agents().unwrap(); host.keys().insert(agent.pubkey.clone(), Arc::new(key)); + let page = host.removals().pages.current("main"); host.removals().held.insert( agent.pubkey.clone(), - agents.hold_removal(&agent.pubkey, &agent.relay).unwrap(), + ( + page, + agents.hold_removal(&agent.pubkey, &agent.relay).unwrap(), + ), ); let turn = host.profile_turn(&agent.pubkey); let publishing = turn.lock().await; - let mut deleting = Box::pin(host.delete(agent.pubkey.clone(), agent.relay.clone())); + let mut deleting = Box::pin(host.delete("main", agent.pubkey.clone(), agent.relay.clone())); // Poll through the generation capture into the held publication lock. assert!(matches!( futures_util::poll!(&mut deleting), std::task::Poll::Pending )); - host.page_load(); + host.page_load("main"); + let page = host.removals().pages.current("main"); host.removals().held.insert( agent.pubkey.clone(), - agents.hold_removal(&agent.pubkey, &agent.relay).unwrap(), + ( + page, + agents.hold_removal(&agent.pubkey, &agent.relay).unwrap(), + ), ); drop(publishing); assert_eq!( @@ -1231,7 +1245,7 @@ mod tests { assert!(host.keys().contains_key(&agent.pubkey)); assert!(host.removals().held.contains_key(&agent.pubkey)); // Only the current page may finish cleanup using its own hold. - host.delete(agent.pubkey.clone(), agent.relay.clone()) + host.delete("main", agent.pubkey.clone(), agent.relay.clone()) .await .unwrap(); assert!(agents.list().unwrap()[0].deleted); diff --git a/src-tauri/src/host_mcp.rs b/src-tauri/src/host_mcp.rs index b75019f333..1f9a033ba4 100644 --- a/src-tauri/src/host_mcp.rs +++ b/src-tauri/src/host_mcp.rs @@ -24,7 +24,9 @@ //! - Each connection answers one request and closes. A client that closes //! before its answer drops the request; the page's late reply is refused. //! - A server belongs to the plugin and the page that started it. It stops -//! when the plugin closes it, when the page reloads, or when the app exits. +//! when the plugin closes it, when that webview loads another page, or when +//! the app exits. +use crate::page::{Page, PageLoads}; use crate::{with_manager, PluginManager}; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; use http_body_util::{BodyExt, Full, Limited}; @@ -105,6 +107,7 @@ impl Shared { struct Entry { plugin: String, + page: Page, shared: Arc, } @@ -115,9 +118,9 @@ pub(crate) struct HostMcpServers { #[derive(Default)] struct Registry { next: u64, - /// Counts page loads, so a server the page asked for before it reloaded - /// is refused rather than left running with nobody answering. - page: u64, + /// So a server the page asked for before it reloaded is refused rather + /// than left running with nobody answering. + pages: PageLoads, entries: HashMap, } impl HostMcpServers { @@ -130,20 +133,20 @@ impl HostMcpServers { _ => Err("No such MCP server".into()), } } - /// The current page load, for `serve`. - pub(crate) fn page(&self) -> u64 { - self.registry().page + /// The page `webview` shows now, for `serve`. + pub(crate) fn page(&self, webview: &str) -> Page { + self.registry().pages.current(webview) } /// Starts a server owned by `plugin` whose messages go to `events`, unless /// the page that asked during `page` has since gone. fn serve( &self, - page: u64, + page: Page, plugin: String, events: Channel, ) -> Result { let mut registry = self.registry(); - if registry.page != page { + if !registry.pages.is_current(&page) { return Err("The page reloaded".into()); } if registry.entries.len() >= MAX_SERVERS { @@ -175,6 +178,7 @@ impl HostMcpServers { handle, Entry { plugin, + page, shared: shared.clone(), }, ); @@ -215,10 +219,21 @@ impl HostMcpServers { self.owned(plugin, handle)?.stop.cancel(); Ok(()) } - /// Stops every server: the page that answered them is gone. + /// Stops every server `webview`'s page started: the page that answered + /// them is gone. + pub(crate) fn page_load(&self, webview: &str) { + let mut registry = self.registry(); + registry.pages.load(webview); + for entry in registry.entries.values() { + if entry.page.webview() == webview { + entry.shared.stop.cancel(); + } + } + } + /// Stops every server: the app is exiting. pub(crate) fn stop_all(&self) { let mut registry = self.registry(); - registry.page += 1; + registry.pages.exit(); for entry in registry.entries.values() { entry.shared.stop.cancel(); } @@ -478,14 +493,15 @@ fn same(a: &[u8], b: &[u8]) -> bool { /// Starts a loopback MCP server for a plugin that declares `host.mcp`. Each /// message arrives on `on_event` and is answered with `plugin_host_mcp_reply`. #[tauri::command] -pub(crate) async fn plugin_host_mcp_serve( +pub(crate) async fn plugin_host_mcp_serve( + webview: tauri::Webview, manager: tauri::State<'_, PluginManager>, servers: tauri::State<'_, HostMcpServers>, id: String, revision: String, on_event: Channel, ) -> Result { - let page = servers.page(); + let page = servers.page(webview.label()); let plugin = id.clone(); let granted = with_manager(manager, move |manager| { Ok(manager.host_grants(&id, &revision)?.mcp) diff --git a/src-tauri/src/host_mcp/tests.rs b/src-tauri/src/host_mcp/tests.rs index 643c05012b..7c738b4252 100644 --- a/src-tauri/src/host_mcp/tests.rs +++ b/src-tauri/src/host_mcp/tests.rs @@ -59,7 +59,7 @@ async fn answers_a_request_with_the_page_reply() { let servers = HostMcpServers::default(); let (channel, mut events) = channel(); let info = servers - .serve(servers.page(), "plugin".into(), channel) + .serve(servers.page("main"), "plugin".into(), channel) .unwrap(); assert!(info.url.starts_with("http://127.0.0.1:")); let named = initialize(&servers, &info, &mut events).await; @@ -110,7 +110,7 @@ async fn accepts_a_notification_without_a_body() { let servers = HostMcpServers::default(); let (channel, mut events) = channel(); let info = servers - .serve(servers.page(), "plugin".into(), channel) + .serve(servers.page("main"), "plugin".into(), channel) .unwrap(); let named = initialize(&servers, &info, &mut events).await; let request = post( @@ -189,7 +189,7 @@ async fn admits_only_the_session_that_initialized() { let servers = HostMcpServers::default(); let (channel, mut events) = channel(); let info = servers - .serve(servers.page(), "plugin".into(), channel) + .serve(servers.page("main"), "plugin".into(), channel) .unwrap(); // Before `initialize`, discovery needs no session and joins none; a tool // call is refused. @@ -226,7 +226,7 @@ async fn stops_when_a_second_client_initializes() { let servers = HostMcpServers::default(); let (channel, mut events) = channel(); let info = servers - .serve(servers.page(), "plugin".into(), channel) + .serve(servers.page("main"), "plugin".into(), channel) .unwrap(); initialize(&servers, &info, &mut events).await; // Whoever claimed the session, two clients hold the token. @@ -240,7 +240,7 @@ async fn releases_a_claim_that_did_not_succeed() { let servers = HostMcpServers::default(); let (channel, mut events) = channel(); let info = servers - .serve(servers.page(), "plugin".into(), channel) + .serve(servers.page("main"), "plugin".into(), channel) .unwrap(); let claim = || claim(&servers, &info); // The page answered with an error. @@ -284,7 +284,7 @@ async fn keeps_the_session_across_requests() { let servers = HostMcpServers::default(); let (channel, mut events) = channel(); let info = servers - .serve(servers.page(), "plugin".into(), channel) + .serve(servers.page("main"), "plugin".into(), channel) .unwrap(); let named = initialize(&servers, &info, &mut events).await; let call = r#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#; @@ -305,7 +305,7 @@ async fn holds_the_claim_while_initialize_waits() { let servers = HostMcpServers::default(); let (channel, mut events) = channel(); let info = servers - .serve(servers.page(), "plugin".into(), channel) + .serve(servers.page("main"), "plugin".into(), channel) .unwrap(); let request = post(&info, &info.token, "", INITIALIZE); let client = tokio::spawn({ @@ -340,7 +340,7 @@ async fn refuses_without_starting_oauth() { let servers = HostMcpServers::default(); let (channel, mut events) = channel(); let info = servers - .serve(servers.page(), "plugin".into(), channel) + .serve(servers.page("main"), "plugin".into(), channel) .unwrap(); let body = r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#; // A wrong token and a web page are both told there is nothing here. @@ -363,9 +363,13 @@ async fn refuses_without_starting_oauth() { async fn stops_with_the_page() { let servers = HostMcpServers::default(); let (events_channel, mut events) = channel(); - let page = servers.page(); + let page = servers.page("main"); let info = servers - .serve(page, "plugin".into(), events_channel) + .serve(page.clone(), "plugin".into(), events_channel) + .unwrap(); + let (other_channel, _other_events) = channel(); + let other = servers + .serve(servers.page("agents"), "plugin".into(), other_channel) .unwrap(); let request = post( &info, @@ -378,11 +382,17 @@ async fn stops_with_the_page() { async move { send(&info, request).await } }); assert_eq!(next(&mut events).await["type"], "message"); - servers.stop_all(); + servers.page_load("main"); assert_eq!(next(&mut events).await["type"], "closed"); // The request waiting on the page is dropped, not left hanging. assert!(!client.await.unwrap().contains("200 OK")); assert!(servers.reply("plugin", info.handle, 0, None).is_err()); let (late, _events) = channel(); assert!(servers.serve(page, "plugin".into(), late).is_err()); + // Another webview's server stays up. + assert!(!servers + .owned("plugin", other.handle) + .unwrap() + .stop + .is_cancelled()); } diff --git a/src-tauri/src/host_process.rs b/src-tauri/src/host_process.rs index 3d62548031..6d12187911 100644 --- a/src-tauri/src/host_process.rs +++ b/src-tauri/src/host_process.rs @@ -1,10 +1,11 @@ //! Long-lived processes a plugin starts from its manifest's `host.processes`: //! stdin stays open for the plugin, and stdout and stderr stream back as they //! arrive. A process belongs to the plugin that started it and to the page that -//! asked; it is killed when the plugin asks, when the page reloads, or when the -//! app exits. A page that reloaded hears nothing of it after. It runs with the +//! asked; it is killed when the plugin asks, when that webview loads another +//! page, or when the app exits. A page that reloaded hears nothing of it after. It runs with the //! user's own access: there is no sandbox. use crate::host_command::{effective_path, resolve_program}; +use crate::page::{Page, PageLoads}; use crate::{with_manager, PluginManager}; use buzzodz_plugins::HostProcess; use serde::{Deserialize, Serialize}; @@ -60,6 +61,7 @@ pub(crate) enum ProcessEvent { struct Entry { plugin: String, + page: Page, /// Its process group, which is its own pid. #[cfg_attr(not(unix), allow(dead_code))] group: Option, @@ -74,9 +76,9 @@ pub(crate) struct HostProcesses { #[derive(Default)] struct Registry { next: u64, - /// Counts page loads, so a spawn the page asked for before it reloaded is - /// refused rather than left running with nobody listening. - page: u64, + /// So a spawn the page asked for before it reloaded is refused rather + /// than left running with nobody listening. + pages: PageLoads, entries: HashMap, } impl HostProcesses { @@ -97,15 +99,15 @@ impl HostProcesses { _ => Err("No such process".into()), } } - /// The current page load, for `start`. - pub(crate) fn page(&self) -> u64 { - self.registry().page + /// The page `webview` shows now, for `start`. + pub(crate) fn page(&self, webview: &str) -> Page { + self.registry().pages.current(webview) } /// Starts `command`, owned by `plugin`, and streams its output to `on_event`, /// unless the page that asked during `page` has since gone. fn start( &self, - page: u64, + page: Page, plugin: String, program: &str, mut command: Command, @@ -113,7 +115,7 @@ impl HostProcesses { ) -> Result { let state = self.clone(); let mut registry = self.registry(); - if registry.page != page { + if !registry.pages.is_current(&page) { return Err("The page reloaded".into()); } if registry.entries.len() >= MAX_PROCESSES { @@ -138,6 +140,7 @@ impl HostProcesses { handle, Entry { plugin, + page: page.clone(), group: child.id(), stdin: child .stdin @@ -205,7 +208,7 @@ impl HostProcesses { registry.entries.remove(&handle); // A page that reloaded is told nothing: it stopped the process by // going, and may run until it unloads, taking the exit for a failure. - let reloaded = registry.page != page; + let reloaded = !registry.pages.is_current(&page); drop(registry); if !reloaded { let _ = on_event.send(ProcessEvent::Exit { @@ -251,22 +254,21 @@ impl HostProcesses { } Ok(()) } - /// Stops every process, without telling the page: it is gone. - pub(crate) fn stop_all(&self) { + /// Stops every process `webview`'s page started, without telling the + /// page: it is gone. + pub(crate) fn page_load(&self, webview: &str) { let mut registry = self.registry(); - registry.page += 1; - for entry in registry.entries.values_mut() { - entry.stdin = None; - if let Some(stop) = entry.stop.take() { - let _ = stop.send(()); - } - } + registry.pages.load(webview); + registry.stop(|page| page.webview() == webview); } /// Kills every process group at once: the app is exiting, and its async /// tasks will not run again to stop them. On Windows each process's job /// kills it when the app's handle to the job closes. pub(crate) fn shutdown(&self) { - self.stop_all(); + let mut registry = self.registry(); + registry.pages.exit(); + registry.stop(|_| true); + drop(registry); #[cfg(unix)] for group in self .registry() @@ -278,6 +280,17 @@ impl HostProcesses { } } } +impl Registry { + /// Stops the processes of each page `gone` picks. + fn stop(&mut self, gone: impl Fn(&Page) -> bool) { + for entry in self.entries.values_mut().filter(|entry| gone(&entry.page)) { + entry.stdin = None; + if let Some(stop) = entry.stop.take() { + let _ = stop.send(()); + } + } + } +} #[derive(Clone, Copy)] enum Stream { @@ -292,6 +305,7 @@ enum Stream { #[allow(clippy::too_many_arguments)] pub(crate) async fn plugin_host_process_spawn( app: tauri::AppHandle, + webview: tauri::Webview, manager: tauri::State<'_, PluginManager>, processes: tauri::State<'_, HostProcesses>, id: String, @@ -303,7 +317,7 @@ pub(crate) async fn plugin_host_process_spawn( on_event: Channel, mcp: Option, ) -> Result { - let page = processes.page(); + let page = processes.page(webview.label()); let plugin = id.clone(); let declared = with_manager(manager, move |manager| { manager diff --git a/src-tauri/src/host_process/tests.rs b/src-tauri/src/host_process/tests.rs index 827e417624..46e9eb25f1 100644 --- a/src-tauri/src/host_process/tests.rs +++ b/src-tauri/src/host_process/tests.rs @@ -58,7 +58,13 @@ async fn streams_stdin_to_stdout_until_input_closes() { let processes = HostProcesses::default(); let (channel, mut events) = channel(); let handle = processes - .start(0, "a.plugin".into(), "cat", command("cat", &[]), channel) + .start( + processes.page("main"), + "a.plugin".into(), + "cat", + command("cat", &[]), + channel, + ) .unwrap(); processes .write("a.plugin", handle, "one\n".into(), false) @@ -87,7 +93,7 @@ async fn reports_stderr_and_exit_code() { let (channel, mut events) = channel(); processes .start( - 0, + processes.page("main"), "a.plugin".into(), "sh", command("sh", &["-c", "echo oops >&2; exit 3"]), @@ -105,7 +111,13 @@ async fn only_the_owning_plugin_can_write_or_kill() { let processes = HostProcesses::default(); let (channel, mut events) = channel(); let handle = processes - .start(0, "a.plugin".into(), "cat", command("cat", &[]), channel) + .start( + processes.page("main"), + "a.plugin".into(), + "cat", + command("cat", &[]), + channel, + ) .unwrap(); assert!(processes .write("b.plugin", handle, "x".into(), false) @@ -125,7 +137,7 @@ async fn kill_ends_the_process_and_its_descendants() { // The shell ignores the polite signal; its child would outlive it. let handle = processes .start( - 0, + processes.page("main"), "a.plugin".into(), "sh", command("sh", &["-c", "trap '' TERM; sleep 30 & echo $!; wait"]), @@ -146,17 +158,29 @@ async fn kill_ends_the_process_and_its_descendants() { #[cfg(unix)] #[tokio::test] -async fn stop_all_ends_every_process() { +async fn a_page_load_ends_every_process_that_page_started() { let processes = HostProcesses::default(); let (first, mut first_events) = channel(); let (second, mut second_events) = channel(); processes - .start(0, "a.plugin".into(), "cat", command("cat", &[]), first) + .start( + processes.page("main"), + "a.plugin".into(), + "cat", + command("cat", &[]), + first, + ) .unwrap(); processes - .start(0, "b.plugin".into(), "cat", command("cat", &[]), second) + .start( + processes.page("main"), + "b.plugin".into(), + "cat", + command("cat", &[]), + second, + ) .unwrap(); - processes.stop_all(); + processes.page_load("main"); // The page that reloaded hears no exit: each channel closes without one. for events in [&mut first_events, &mut second_events] { while let Some(event) = tokio::time::timeout(Duration::from_secs(10), events.recv()) @@ -169,6 +193,39 @@ async fn stop_all_ends_every_process() { assert!(processes.registry().entries.is_empty()); } +#[cfg(unix)] +#[tokio::test] +async fn another_webviews_page_load_leaves_the_process_running() { + let processes = HostProcesses::default(); + let (channel, mut events) = channel(); + let handle = processes + .start( + processes.page("agents"), + "a.plugin".into(), + "cat", + command("cat", &[]), + channel, + ) + .unwrap(); + processes.page_load("main"); + processes + .write("a.plugin", handle, "still here\n".into(), false) + .await + .unwrap(); + let echoed = tokio::time::timeout(Duration::from_secs(10), events.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(echoed["data"], "still here\n"); + processes.page_load("agents"); + while let Some(event) = tokio::time::timeout(Duration::from_secs(10), events.recv()) + .await + .expect("the channel stays open after the process stops") + { + assert_ne!(event["type"], "exit"); + } +} + #[cfg(unix)] #[tokio::test] async fn a_page_that_stops_listening_stops_the_process() { @@ -176,7 +233,7 @@ async fn a_page_that_stops_listening_stops_the_process() { let channel = Channel::new(|_| Err(tauri::Error::FailedToReceiveMessage)); let handle = processes .start( - 0, + processes.page("main"), "a.plugin".into(), "sh", command("sh", &["-c", "echo hi; sleep 30"]), @@ -196,8 +253,8 @@ async fn a_page_that_stops_listening_stops_the_process() { #[tokio::test] async fn a_spawn_from_before_a_reload_is_refused() { let processes = HostProcesses::default(); - let page = processes.page(); - processes.stop_all(); + let page = processes.page("main"); + processes.page_load("main"); let (channel, _events) = channel(); let refused = processes.start(page, "a.plugin".into(), "cat", command("cat", &[]), channel); assert_eq!(refused.err().as_deref(), Some("The page reloaded")); @@ -211,7 +268,7 @@ async fn a_process_may_close_its_output_and_carry_on() { let (channel, mut events) = channel(); processes .start( - 0, + processes.page("main"), "a.plugin".into(), "sh", command("sh", &["-c", "exec >&- 2>&-; sleep 1; exit 4"]), @@ -229,7 +286,7 @@ async fn shutdown_kills_every_group_without_waiting() { let (channel, mut events) = channel(); processes .start( - 0, + processes.page("main"), "a.plugin".into(), "sh", command("sh", &["-c", "trap '' TERM; sleep 30 & echo $!; wait"]), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fd51fc4006..c71d29b24f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -54,6 +54,7 @@ mod microphone; mod notifications; mod os_idle; +mod page; mod repositories_folder; mod window_controls; mod window_state; @@ -875,15 +876,17 @@ pub fn run() { { eprintln!("OAuth callback cleanup failed: {error}"); } + if matches!(payload.event(), tauri::webview::PageLoadEvent::Started) { + // Processes belong to the page that started them, in its own webview. + webview.state::().page_load(webview.label()); + webview.state::().page_load(webview.label()); + webview.state::().page_load(webview.label()); + } if webview.label() == "main" && matches!(payload.event(), tauri::webview::PageLoadEvent::Started) { webview.state::().cancel_all(); webview.state::().document_load(); webview.state::().cancel_all(&webview.state::()); - // Processes belong to the page that started them. webview.state::().stop_all(); - webview.state::().stop_all(); - webview.state::().stop_all(); - webview.state::().page_load(); } browser::page_load(webview, payload); }) diff --git a/src-tauri/src/page.rs b/src-tauri/src/page.rs new file mode 100644 index 0000000000..9d3c4896f6 --- /dev/null +++ b/src-tauri/src/page.rs @@ -0,0 +1,62 @@ +//! Each webview's page loads. Work a page starts belongs to that load, in that +//! webview: it stops when the same webview loads a page again, and a request +//! the page made before it reloaded is refused. Another webview's load leaves +//! it alone. +use std::collections::HashMap; + +/// One page load in one webview. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Page { + webview: String, + load: u64, +} +impl Page { + pub(crate) fn webview(&self) -> &str { + &self.webview + } +} + +/// Counts each webview's page loads. +#[derive(Default)] +pub(crate) struct PageLoads { + loads: HashMap, + exiting: bool, +} +impl PageLoads { + /// The page `webview` shows now. + pub(crate) fn current(&self, webview: &str) -> Page { + Page { + webview: webview.into(), + load: self.loads.get(webview).copied().unwrap_or_default(), + } + } + pub(crate) fn is_current(&self, page: &Page) -> bool { + !self.exiting && *page == self.current(&page.webview) + } + /// `webview` started loading another page. + pub(crate) fn load(&mut self, webview: &str) { + *self.loads.entry(webview.into()).or_default() += 1; + } + /// The app is exiting: no page is current any more. + pub(crate) fn exit(&mut self) { + self.exiting = true; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_load_ends_only_its_own_webviews_page() { + let mut pages = PageLoads::default(); + let main = pages.current("main"); + let agents = pages.current("agents"); + pages.load("main"); + assert!(!pages.is_current(&main)); + assert!(pages.is_current(&agents)); + assert!(pages.is_current(&pages.current("main"))); + pages.exit(); + assert!(!pages.is_current(&agents)); + } +} From 29c872be9c448e67a74d96f6bebdb90aaf343ae3 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 27 Oct 2026 12:51:31 -0400 Subject: [PATCH 2/3] refactor(desktop): stop only a reloading page's own uploads A page load in the main webview cancelled every relay upload, and a page load in another webview cancelled none. Each upload now records the webview whose page began it, and a page load or a closed window stops only that webview's uploads. Quit still stops them all. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- src-tauri/src/lib.rs | 4 +- src-tauri/src/relay.rs | 38 ++++++++++++-- src-tauri/src/relay/tests.rs | 12 +++-- src-tauri/src/relay/upload_spool.rs | 78 ++++++++++++++++++++--------- 4 files changed, 99 insertions(+), 33 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c71d29b24f..beea38bbcb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -881,18 +881,18 @@ pub fn run() { webview.state::().page_load(webview.label()); webview.state::().page_load(webview.label()); webview.state::().page_load(webview.label()); + webview.state::().cancel_page(&webview.state::(), webview.label()); } if webview.label() == "main" && matches!(payload.event(), tauri::webview::PageLoadEvent::Started) { webview.state::().cancel_all(); webview.state::().document_load(); - webview.state::().cancel_all(&webview.state::()); webview.state::().stop_all(); } browser::page_load(webview, payload); }) .on_window_event(|window, event| { if window.label() == "main" && matches!(event, tauri::WindowEvent::Destroyed | tauri::WindowEvent::CloseRequested { .. }) { window.state::().cancel_all(); } - if window.label() == "main" && matches!(event, tauri::WindowEvent::Destroyed) { window.state::().cancel_all(&window.state::()); } + if matches!(event, tauri::WindowEvent::Destroyed) { window.state::().cancel_page(&window.state::(), window.label()); } #[cfg(target_os = "macos")] if window.label() == "main" { if let tauri::WindowEvent::CloseRequested { api, .. } = event { diff --git a/src-tauri/src/relay.rs b/src-tauri/src/relay.rs index 6394118ad4..b93ba4154d 100644 --- a/src-tauri/src/relay.rs +++ b/src-tauri/src/relay.rs @@ -922,6 +922,8 @@ const PENDING_CANCEL_LIFETIME: Duration = Duration::from_secs(60); struct UploadState { active: HashMap>, pending: HashMap, + /// The webview whose page started each upload that has one. + owners: HashMap, } #[derive(Default)] pub(crate) struct Uploads(std::sync::Mutex); @@ -950,8 +952,14 @@ impl Uploads { Ok(Some(receiver)) } + /// Records that `webview`'s page started `id`, so its next load stops it. + fn own(&self, id: &str, webview: &str) { + self.lock().owners.insert(id.into(), webview.into()); + } + fn cancel(&self, id: &str) { let mut uploads = self.lock(); + uploads.owners.remove(id); if let Some(sender) = uploads.active.remove(id) { let _ = sender.send(()); return; @@ -976,13 +984,35 @@ impl Uploads { } pub(crate) fn cancel_all(&self) { - for (_, sender) in self.lock().active.drain() { + let mut uploads = self.lock(); + uploads.owners.clear(); + for (_, sender) in uploads.active.drain() { let _ = sender.send(()); } } + /// Stops the uploads `webview`'s page started; returns their IDs. + fn cancel_page(&self, webview: &str) -> Vec { + let mut uploads = self.lock(); + let ids: Vec = uploads + .owners + .iter() + .filter(|(_, owner)| *owner == webview) + .map(|(id, _)| id.clone()) + .collect(); + for id in &ids { + uploads.owners.remove(id); + if let Some(sender) = uploads.active.remove(id) { + let _ = sender.send(()); + } + } + ids + } + fn finish(&self, id: &str) { - self.lock().active.remove(id); + let mut uploads = self.lock(); + uploads.owners.remove(id); + uploads.active.remove(id); } } @@ -999,13 +1029,15 @@ fn upload_id(value: Option<&str>) -> Result<&str> { #[tauri::command] pub(crate) async fn relay_upload_begin( app: tauri::AppHandle, + webview: tauri::Webview, id: String, size: usize, ) -> Result<()> { use tauri::Manager as _; + let page = webview.label().to_owned(); tauri::async_runtime::spawn_blocking(move || { app.state::() - .begin(&app.state::(), &id, size) + .begin(&app.state::(), &page, &id, size) }) .await .map_err(|_| "Upload receiving could not complete")? diff --git a/src-tauri/src/relay/tests.rs b/src-tauri/src/relay/tests.rs index e9ca9171df..dff703133d 100644 --- a/src-tauri/src/relay/tests.rs +++ b/src-tauri/src/relay/tests.rs @@ -3365,7 +3365,7 @@ async fn spool_processing_cleans_after_upstream_and_conversion_failures() { let parent = tempfile::tempdir().unwrap(); let spools = Spools::new(Ok(parent.path().to_owned())); let uploads = Uploads::default(); - spools.begin(&uploads, "failure", 3).unwrap(); + spools.begin(&uploads, "main", "failure", 3).unwrap(); spools.append("failure", 0, b"bad").unwrap(); let (spool, mut cancel) = spools.take("failure").unwrap(); let path = spool.directory.path().to_owned(); @@ -3389,7 +3389,7 @@ async fn spool_processing_cleans_after_upstream_and_conversion_failures() { assert!(result.is_err()); } assert!(!path.exists()); - assert!(spools.begin(&uploads, "next", 3).is_ok()); + assert!(spools.begin(&uploads, "main", "next", 3).is_ok()); } } @@ -3398,7 +3398,7 @@ async fn cancelled_spool_processing_never_starts_upstream_and_cleans() { let parent = tempfile::tempdir().unwrap(); let spools = Spools::new(Ok(parent.path().to_owned())); let uploads = Uploads::default(); - spools.begin(&uploads, "cancel", 3).unwrap(); + spools.begin(&uploads, "main", "cancel", 3).unwrap(); spools.append("cancel", 0, b"abc").unwrap(); let (spool, mut cancel) = spools.take("cancel").unwrap(); let path = spool.directory.path().to_owned(); @@ -3449,7 +3449,9 @@ async fn converted_spool_streams_signed_output_then_removes_both_files() { let bytes = std::fs::read(&input).unwrap(); let spools = Spools::new(Ok(parent.path().join("spools"))); let uploads = Uploads::default(); - spools.begin(&uploads, "converted", bytes.len()).unwrap(); + spools + .begin(&uploads, "main", "converted", bytes.len()) + .unwrap(); for (index, chunk) in bytes.chunks(UPLOAD_CHUNK).enumerate() { spools .append("converted", index * UPLOAD_CHUNK, chunk) @@ -3515,7 +3517,7 @@ async fn running_upstream_cancellation_closes_request_and_cleans_spool() { let parent = tempfile::tempdir().unwrap(); let spools = Spools::new(Ok(parent.path().to_owned())); let uploads = Uploads::default(); - spools.begin(&uploads, "running", 3).unwrap(); + spools.begin(&uploads, "main", "running", 3).unwrap(); spools.append("running", 0, b"abc").unwrap(); let (spool, mut cancel) = spools.take("running").unwrap(); let path = spool.directory.path().to_owned(); diff --git a/src-tauri/src/relay/upload_spool.rs b/src-tauri/src/relay/upload_spool.rs index 781c036b91..f41dc2aff2 100644 --- a/src-tauri/src/relay/upload_spool.rs +++ b/src-tauri/src/relay/upload_spool.rs @@ -146,7 +146,14 @@ impl Spools { }; drop(removed); } - pub(super) fn begin(&self, uploads: &Uploads, id: &str, size: usize) -> Result<()> { + /// Starts receiving upload `id` for the page in `webview`. + pub(super) fn begin( + &self, + uploads: &Uploads, + webview: &str, + id: &str, + size: usize, + ) -> Result<()> { upload_id(Some(id))?; validate_upload_size(size)?; let slot = self @@ -173,6 +180,7 @@ impl Spools { return Err("Upload is already in progress".into()); } let cancelled = uploads.start(id)?.ok_or("Upload cancelled")?; + uploads.own(id, webview); entries.insert(id.into(), entry.clone()); cancelled }; @@ -292,6 +300,19 @@ impl Spools { }; drop(removed); } + /// Stops what the page in `webview` was uploading, as it loads again or + /// closes. Other pages' uploads go on. + pub(crate) fn cancel_page(&self, uploads: &Uploads, webview: &str) { + let removed: Vec<_> = { + let mut entries = self.lock(); + uploads + .cancel_page(webview) + .into_iter() + .filter_map(|id| entries.remove(&id)) + .collect() + }; + drop(removed); + } pub(crate) fn cancel_all(&self, uploads: &Uploads) { let removed = { let mut entries = self.lock(); @@ -383,10 +404,10 @@ mod tests { fn simultaneous_files_have_exact_hashes_and_cleanup_holds_admission() { let (_parent, spools, uploads) = fixture(); for id in ["a", "b", "c", "d"] { - spools.begin(&uploads, id, 3).unwrap(); + spools.begin(&uploads, "main", id, 3).unwrap(); } - assert!(spools.begin(&uploads, "extra", 3).is_err()); - assert!(spools.begin(&uploads, "a", 3).is_err()); + assert!(spools.begin(&uploads, "main", "extra", 3).is_err()); + assert!(spools.begin(&uploads, "main", "a", 3).is_err()); spools.append("a", 0, b"abc").unwrap(); spools.append("b", 0, b"xyz").unwrap(); let (a, _cancel) = spools.take("a").unwrap(); @@ -395,11 +416,11 @@ mod tests { assert_eq!(std::fs::read(a.source()).unwrap(), b"abc"); assert_eq!(a.hash, format!("{:x}", Sha256::digest(b"abc"))); assert_eq!(std::fs::read(b.source()).unwrap(), b"xyz"); - assert!(spools.begin(&uploads, "extra", 3).is_err()); + assert!(spools.begin(&uploads, "main", "extra", 3).is_err()); drop(a); assert!(!path.exists()); uploads.finish("a"); - spools.begin(&uploads, "extra", 3).unwrap(); + spools.begin(&uploads, "main", "extra", 3).unwrap(); } #[test] fn cancellation_partial_and_invalid_chunks_remove_files() { @@ -409,30 +430,30 @@ mod tests { ("empty", 0, b""), ("overflow", 0, b"four"), ] { - spools.begin(&uploads, id, 3).unwrap(); + spools.begin(&uploads, "main", id, 3).unwrap(); let path = spool_path(&spools, id); assert!(spools.append(id, offset, bytes).is_err()); assert!(!path.exists()); uploads.finish(id); } - spools.begin(&uploads, "partial", 3).unwrap(); + spools.begin(&uploads, "main", "partial", 3).unwrap(); let path = spool_path(&spools, "partial"); spools.append("partial", 0, b"a").unwrap(); assert!(spools.take("partial").is_err()); assert!(!path.exists()); uploads.finish("partial"); - spools.begin(&uploads, "cancel", 3).unwrap(); + spools.begin(&uploads, "main", "cancel", 3).unwrap(); let path = spool_path(&spools, "cancel"); spools.cancel(&uploads, "cancel"); assert!(!path.exists()); assert!(spools.append("cancel", 0, b"abc").is_err()); uploads.cancel("before"); - assert!(spools.begin(&uploads, "before", 3).is_err()); + assert!(spools.begin(&uploads, "main", "before", 3).is_err()); } #[test] fn idle_and_page_reload_release_receiving_and_running_work() { let (_parent, spools, uploads) = fixture(); - spools.begin(&uploads, "idle", 3).unwrap(); + spools.begin(&uploads, "main", "idle", 3).unwrap(); let path = spool_path(&spools, "idle"); receiving(&spools, "idle") .lock() @@ -442,12 +463,23 @@ mod tests { .touched = Instant::now() - IDLE; spools.reap(&uploads); assert!(!path.exists()); - spools.begin(&uploads, "running", 3).unwrap(); + spools.begin(&uploads, "main", "running", 3).unwrap(); spools.append("running", 0, b"abc").unwrap(); let (running, mut cancel) = spools.take("running").unwrap(); let path = running.directory.path().to_owned(); - spools.cancel_all(&uploads); + // Another webview's page goes on uploading while this one reloads. + spools.begin(&uploads, "agents", "other", 3).unwrap(); + let other = spool_path(&spools, "other"); + spools.begin(&uploads, "agents", "sending", 3).unwrap(); + spools.append("sending", 0, b"abc").unwrap(); + let (sending, mut kept) = spools.take("sending").unwrap(); + spools.cancel_page(&uploads, "main"); assert!(cancel.try_recv().is_ok()); + assert!(other.exists()); + spools.append("other", 0, b"abc").unwrap(); + assert!(kept.try_recv().is_err()); + drop(sending); + uploads.finish("sending"); assert!( path.exists(), "running operation owns cleanup until stopped" @@ -458,8 +490,8 @@ mod tests { #[test] fn busy_upload_does_not_hold_map_or_other_uploads_and_cancel_wins() { let (_parent, spools, uploads) = fixture(); - spools.begin(&uploads, "busy", 3).unwrap(); - spools.begin(&uploads, "other", 3).unwrap(); + spools.begin(&uploads, "main", "busy", 3).unwrap(); + spools.begin(&uploads, "main", "other", 3).unwrap(); let busy = receiving(&spools, "busy"); let path = spool_path(&spools, "busy"); // Hold the exact mutex used across write_all. No timing/sleep required. @@ -470,7 +502,7 @@ mod tests { spools.append("other", 0, b"abc").unwrap(); let (other, _) = spools.take("other").unwrap(); assert_eq!(std::fs::read(other.source()).unwrap(), b"abc"); - spools.begin(&uploads, "new", 3).unwrap(); + spools.begin(&uploads, "main", "new", 3).unwrap(); spools.reap(&uploads); spools.cancel(&uploads, "busy"); assert!(spools.entry("busy").is_err()); @@ -536,7 +568,7 @@ mod tests { let removal = std::fs::remove_dir_all(stale.path()); let spools = Spools::new(Ok(parent.path().to_owned())); let uploads = Uploads::default(); - let admission = spools.begin(&uploads, "new", 3); + let admission = spools.begin(&uploads, "main", "new", 3); // Release the fixture's restriction before assertions, even on failure. #[cfg(unix)] std::fs::set_permissions(&blocked, original).unwrap(); @@ -559,7 +591,7 @@ mod tests { let spools = Spools::new(Ok(file)); let uploads = Uploads::default(); assert!(spools - .begin(&uploads, "failure", 3) + .begin(&uploads, "main", "failure", 3) .unwrap_err() .contains("temporary storage")); assert_eq!(spools.slots.available_permits(), SLOTS); @@ -572,13 +604,13 @@ mod tests { std::fs::write(&storage, b"unavailable").unwrap(); let spools = Spools::new(Ok(storage.clone())); let uploads = Uploads::default(); - assert!(spools.begin(&uploads, "failed", 3).is_err()); + assert!(spools.begin(&uploads, "main", "failed", 3).is_err()); assert_eq!(spools.slots.available_permits(), SLOTS); assert!(uploads.lock().active.is_empty()); std::fs::remove_file(&storage).unwrap(); let live = root(&storage).unwrap(); let live_path = live.directory.path().to_owned(); - spools.begin(&uploads, "recovered", 3).unwrap(); + spools.begin(&uploads, "main", "recovered", 3).unwrap(); assert!(live_path.exists()); spools.append("recovered", 0, b"abc").unwrap(); let (spool, _) = spools.take("recovered").unwrap(); @@ -600,7 +632,7 @@ mod tests { let uploads_ref = &uploads; let begin = scope.spawn(move || { started.send(()).unwrap(); - spools_ref.begin(uploads_ref, "pending", 3) + spools_ref.begin(uploads_ref, "main", "pending", 3) }); entering.recv().unwrap(); // Hold root initialization until the cancellation has been recorded. @@ -615,7 +647,7 @@ mod tests { } fn assert_write_failure_cleans_partial_file(file: impl FnOnce(&Path) -> File) { let (_parent, spools, uploads) = fixture(); - spools.begin(&uploads, "failure", 3).unwrap(); + spools.begin(&uploads, "main", "failure", 3).unwrap(); spools.append("failure", 0, b"a").unwrap(); let path = spool_path(&spools, "failure"); receiving(&spools, "failure") @@ -631,7 +663,7 @@ mod tests { assert!(!path.exists()); assert_eq!(spools.slots.available_permits(), SLOTS); uploads.cancel("failure"); - spools.begin(&uploads, "retry", 3).unwrap(); + spools.begin(&uploads, "main", "retry", 3).unwrap(); spools.append("retry", 0, b"abc").unwrap(); assert_eq!( std::fs::read(spools.take("retry").unwrap().0.source()).unwrap(), From b5131210bdee2b4c5e83609c99509538ccec4409 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 27 Oct 2026 13:27:55 -0400 Subject: [PATCH 3/3] feat(agents): run agents in a hidden page of their own on macOS On macOS, Buzz now opens a hidden, never-throttled "agents" webview at launch. It runs the agent service, the agent entries of plugins, local agents and their activity, and connects to the joined communities with sessions that keep nothing on the device. The main window runs only the UI parts of plugins and reaches the service over the agents port, so a hidden or busy main window no longer holds agents up. The two pages talk over a native command pair, agents_port_listen and agents_port_send. It routes each frame from one page's webview to the other's, so neither page needs app event permissions. The agents page has its own macOS-only capability with the relay, agent, plugin host and classifier commands, and none of the screen, window, account setup, terminal, browser or notification commands. In the main window, ctx.agents is a facade that forwards plugin agent types and tools to the service under the plugin's identity, and refuses the members only an agent entry can use. Other platforms keep the in-page service. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- agents.html | 2 + src-tauri/build.rs | 2 + src-tauri/capabilities/agents.json | 102 ++++++++++++++ src-tauri/capabilities/default.json | 2 + src-tauri/src/agents_port.rs | 119 ++++++++++++++++ src-tauri/src/browser_permissions_tests.rs | 39 ++++++ src-tauri/src/lib.rs | 23 +++ src-tauri/tauri.native-mcp.conf.json | 1 + src/agents.ts | 12 ++ src/app/agents-host.ts | 86 ++++++++++++ src/app/services.ts | 69 ++++++--- src/features/agents/apart.ts | 16 +++ src/features/agents/event-port.ts | 32 ++++- src/features/agents/port.test.ts | 28 ++++ src/features/agents/port.ts | 24 +++- src/features/agents/screens.test.ts | 89 ++++++++++++ src/features/agents/screens.ts | 154 +++++++++++++++++++++ src/main.tsx | 6 +- src/plugins/manager.ts | 7 +- src/plugins/runtime.test.mjs | 42 +++++- src/plugins/runtime.ts | 18 ++- vite.config.ts | 1 + 22 files changed, 839 insertions(+), 35 deletions(-) create mode 100644 agents.html create mode 100644 src-tauri/capabilities/agents.json create mode 100644 src-tauri/src/agents_port.rs create mode 100644 src/agents.ts create mode 100644 src/app/agents-host.ts create mode 100644 src/features/agents/apart.ts create mode 100644 src/features/agents/screens.test.ts create mode 100644 src/features/agents/screens.ts diff --git a/agents.html b/agents.html new file mode 100644 index 0000000000..68336fe7ca --- /dev/null +++ b/agents.html @@ -0,0 +1,2 @@ + +Agents — Buzz diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 2d602d2237..072baa65ed 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -111,6 +111,8 @@ fn main() { "media_copy_image", "media_stream_base", "get_os_idle_seconds", + "agents_port_listen", + "agents_port_send", "workspace_pick_folder", "repositories_folder_validate", "plugin_import_folder", diff --git a/src-tauri/capabilities/agents.json b/src-tauri/capabilities/agents.json new file mode 100644 index 0000000000..70a10d6df3 --- /dev/null +++ b/src-tauri/capabilities/agents.json @@ -0,0 +1,102 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "agents-page", + "description": "The hidden page that runs agents: relay, agent, plugin host and classifier commands, without screens, windows, account setup or OS shell commands.", + "webviews": ["agents"], + "platforms": ["macOS"], + "permissions": [ + "allow-identity-restore", + "allow-relay-sign", + "allow-identity-prepare-remote-agent-authorization", + "allow-identity-sign-builderlab-binding", + "allow-relay-decode-read-state", + "allow-relay-sign-read-state", + "allow-relay-publish-read-state", + "allow-relay-http", + "allow-relay-workflow-runs", + "allow-relay-admin-discover", + "allow-relay-admin-request", + "allow-relay-admin-attachment", + "allow-relay-admin-save-attachment", + "allow-relay-project-git", + "allow-relay-project-git-cancel", + "allow-relay-channel-sign", + "allow-relay-channel-publish", + "allow-relay-kit-sign", + "allow-relay-kit-prepare", + "allow-relay-kit-decode", + "allow-relay-direct-message", + "allow-relay-decode-sidebar", + "allow-relay-sign-sidebar", + "allow-relay-decode-reminders", + "allow-relay-sign-reminder", + "allow-relay-agent-resolve", + "allow-relay-git-authorization", + "allow-relay-agent-log-proof", + "allow-relay-agent-observer", + "allow-relay-archive", + "allow-relay-agent-memories-read", + "allow-relay-agent-library", + "allow-relay-upload-begin", + "allow-relay-upload-chunk", + "allow-relay-upload", + "allow-relay-upload-cancel", + "allow-media-download", + "allow-media-stream-base", + "allow-get-os-idle-seconds", + "allow-repositories-folder-validate", + "allow-plugin-catalog", + "allow-plugin-change", + "allow-plugin-reload", + "allow-plugin-module", + "allow-plugin-recover", + "allow-plugin-host-run-command", + "allow-plugin-host-request", + "allow-plugin-host-upload", + "allow-plugin-host-process-spawn", + "allow-plugin-host-process-write", + "allow-plugin-host-process-kill", + "allow-plugin-host-mcp-serve", + "allow-plugin-host-mcp-reply", + "allow-plugin-host-mcp-close", + "allow-plugin-host-read-file", + "allow-plugin-host-resolve-directory", + "allow-oauth-callback-begin", + "allow-oauth-callback-wait", + "allow-oauth-callback-cancel", + "allow-app-agent-list", + "allow-app-agent-create", + "allow-app-agent-portable", + "allow-app-agent-port", + "allow-app-agent-rename", + "allow-app-agent-set-picture", + "allow-app-agent-set-type", + "allow-app-agent-delete", + "allow-app-agent-begin-delete", + "allow-app-agent-begin-leave", + "allow-app-agent-hold-removal", + "allow-app-agent-release-removal", + "allow-app-agent-join", + "allow-app-agent-forget", + "allow-app-agent-publish", + "allow-app-agent-publish-profile", + "allow-app-agent-query", + "allow-app-agent-search-profiles", + "allow-app-agent-upload", + "allow-app-agent-media", + "allow-app-agent-git-auth", + "allow-app-agent-remember", + "allow-app-agent-claim", + "allow-app-agent-release", + "allow-jev-classifier-status", + "allow-jev-classifier-set-key", + "allow-jev-classifier-clear-key", + "allow-app-agent-state-load", + "allow-app-agent-state-set", + "allow-decide", + "core:app:allow-version", + "core:resources:allow-close", + "allow-agents-port-listen", + "allow-agents-port-send" + ] +} diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index c38b62903c..92eeac8964 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -161,6 +161,8 @@ "core:window:allow-close", "core:window:allow-start-dragging", "core:window:allow-internal-toggle-maximize", + "allow-agents-port-listen", + "allow-agents-port-send", { "identifier": "opener:allow-open-url", "allow": [ diff --git a/src-tauri/src/agents_port.rs b/src-tauri/src/agents_port.rs new file mode 100644 index 0000000000..a0375d6507 --- /dev/null +++ b/src-tauri/src/agents_port.rs @@ -0,0 +1,119 @@ +//! The port between the screens' page and the page that runs agents. Each +//! page opens its end with a channel, and a frame one page sends goes to the +//! other's channel. Only those two webviews take part, and the sender is the +//! webview the frame came from, so neither page listens to app events and no +//! other page can speak for either. + +use std::{collections::HashMap, sync::Mutex}; + +use serde_json::Value; +use tauri::{ipc::Channel, Runtime, State, Webview}; + +const PAGES: [&str; 2] = ["main", "agents"]; + +/// The other page of the pair, for `label`. +fn peer(label: &str) -> Option<&'static str> { + match label { + "main" => Some("agents"), + "agents" => Some("main"), + _ => None, + } +} + +/// Each page's open end, by its webview's label. A page that loads again +/// opens a new one, which replaces the last. +#[derive(Default)] +pub(crate) struct AgentsPort(Mutex>>); + +impl AgentsPort { + fn open(&self, label: &str, frames: Channel) -> Result<(), String> { + let page = PAGES + .into_iter() + .find(|page| *page == label) + .ok_or("Only Buzz's own pages use the agents port")?; + self.0 + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .insert(page, frames); + Ok(()) + } + /// Sends `frame` from `label`'s page to its peer's. A peer that is not + /// open yet misses it; it says when it is, and the sender sends again. + fn send(&self, label: &str, frame: Value) -> Result<(), String> { + let to = peer(label).ok_or("Only Buzz's own pages use the agents port")?; + let channel = self + .0 + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .get(to) + .cloned(); + match channel { + Some(channel) => channel.send(frame).map_err(|error| error.to_string()), + None => Ok(()), + } + } +} + +#[tauri::command] +pub(crate) fn agents_port_listen( + webview: Webview, + port: State<'_, AgentsPort>, + frames: Channel, +) -> Result<(), String> { + port.open(webview.label(), frames) +} + +#[tauri::command] +pub(crate) fn agents_port_send( + webview: Webview, + port: State<'_, AgentsPort>, + frame: Value, +) -> Result<(), String> { + port.send(webview.label(), frame) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Arc, Mutex as StdMutex}; + use tauri::ipc::InvokeResponseBody; + + fn end() -> (Channel, Arc>>) { + let heard = Arc::new(StdMutex::new(Vec::new())); + let into = heard.clone(); + let channel = Channel::new(move |body| { + if let InvokeResponseBody::Json(json) = body { + into.lock() + .unwrap() + .push(serde_json::from_str(&json).unwrap()); + } + Ok(()) + }); + (channel, heard) + } + + #[test] + fn frames_go_only_to_the_other_page_of_the_pair() { + let port = AgentsPort::default(); + let (main, to_main) = end(); + let (agents, to_agents) = end(); + // Before the other page is open, a frame for it is missed, not held. + port.send("main", Value::from("early")).unwrap(); + port.open("main", main).unwrap(); + port.open("agents", agents).unwrap(); + port.send("main", Value::from(1)).unwrap(); + port.send("agents", Value::from(2)).unwrap(); + assert_eq!(*to_agents.lock().unwrap(), vec![Value::from(1)]); + assert_eq!(*to_main.lock().unwrap(), vec![Value::from(2)]); + // A reloaded page's new end replaces its old one. + let (again, to_again) = end(); + port.open("agents", again).unwrap(); + port.send("main", Value::from(3)).unwrap(); + assert_eq!(*to_again.lock().unwrap(), vec![Value::from(3)]); + assert_eq!(to_agents.lock().unwrap().len(), 1); + // No other page opens an end or sends. + let (huddle, _) = end(); + assert!(port.open("huddle", huddle).is_err()); + assert!(port.send("huddle", Value::from(4)).is_err()); + } +} diff --git a/src-tauri/src/browser_permissions_tests.rs b/src-tauri/src/browser_permissions_tests.rs index 112cf50ef3..697b3571b3 100644 --- a/src-tauri/src/browser_permissions_tests.rs +++ b/src-tauri/src/browser_permissions_tests.rs @@ -70,6 +70,10 @@ fn native_command_permissions_isolate_webviews() { let huddle = tauri::WebviewWindowBuilder::new(&app, "huddle", WebviewUrl::default()) .build() .unwrap(); + // The hidden page that runs agents. + let agents = tauri::WebviewWindowBuilder::new(&app, "agents", WebviewUrl::default()) + .build() + .unwrap(); let application_commands = [ "identity_restore", @@ -122,6 +126,8 @@ fn native_command_permissions_isolate_webviews() { "media_copy_image", "media_stream_base", "get_os_idle_seconds", + "agents_port_listen", + "agents_port_send", "plugin_import_folder", "plugin_update_check", "plugin_update_preview", @@ -215,6 +221,7 @@ fn native_command_permissions_isolate_webviews() { assert!(invoke(&main, "relay_agent_history_decode", local_origin).is_err()); for command in application_commands { assert!(invoke(&main, command, local_origin).is_ok(), "{command}"); + assert!(invoke(agents.as_ref(), command, "https://example.org").is_err()); assert_eq!( invoke(huddle.as_ref(), command, local_origin).is_ok(), command == "media_download", @@ -233,6 +240,38 @@ fn native_command_permissions_isolate_webviews() { ); } assert!(invoke(&main, "plugin:window|close", local_origin).is_ok()); + // The hidden page that runs agents and their plugin hosts gets none of + // the screens, windows, account setup or OS shell commands. + #[cfg(target_os = "macos")] + for command in [ + "relay_sign", + "app_agent_list", + "plugin_host_run_command", + "decide", + "get_os_idle_seconds", + "agents_port_listen", + "agents_port_send", + ] { + assert!( + invoke(agents.as_ref(), command, local_origin).is_ok(), + "{command}" + ); + } + for command in [ + "identity_create", + "nsec_restore_begin", + "notification_show", + "terminal_spawn", + "browser_attach", + "plugin_import_folder", + "plugin:window|close", + "plugin:event|listen", + ] { + assert!( + invoke(agents.as_ref(), command, local_origin).is_err(), + "{command}" + ); + } #[cfg(all(feature = "native-mcp", target_os = "macos"))] { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index beea38bbcb..d52836522f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,3 +1,4 @@ +mod agents_port; mod archive; use archive::relay_archive; mod browser; @@ -547,6 +548,8 @@ fn commands() -> impl Fn(tauri::ipc::Invoke) -> bool + Sen media_copy_image, media_stream_base, get_os_idle_seconds, + agents_port::agents_port_listen, + agents_port::agents_port_send, workspace_pick_folder, repositories_folder_validate, plugin_import_folder, @@ -793,6 +796,11 @@ pub fn run() { // `just desktop` builds step aside for a packaged app. !tauri::is_dev(), )); + // After the agent state it uses: its page may call it at once. + #[cfg(target_os = "macos")] + if let Err(error) = agents_window(app.handle()) { + eprintln!("Could not open the agents page: {error}"); + } Ok(()) }); #[cfg(not(target_os = "macos"))] @@ -834,6 +842,7 @@ pub fn run() { .manage(decisions::DecisionsHost::new()) .manage(pairing::Pairing::default()) .manage(Huddles::default()) + .manage(agents_port::AgentsPort::default()) .manage(HuddleWindow::default()) .manage(relay::Uploads::default()) .register_asynchronous_uri_scheme_protocol("buzz-media", relay::media_protocol) @@ -919,6 +928,20 @@ pub fn run() { }); } +/// The hidden page that runs agents apart from the screens, so a main window +/// that is hidden or busy cannot hold them up. It never shows and is never +/// throttled, and lives until Quit. The screens' page expects it on macOS +/// (`agentsRunApart` in src/features/agents/apart.ts). +#[cfg(target_os = "macos")] +fn agents_window(app: &tauri::AppHandle) -> tauri::Result<()> { + tauri::WebviewWindowBuilder::new(app, "agents", tauri::WebviewUrl::App("agents.html".into())) + .title("Buzz agents") + .visible(false) + .background_throttling(tauri::utils::config::BackgroundThrottlingPolicy::Disabled) + .build() + .map(|_| ()) +} + /// Best-effort native teardown when Buzz exits, from Quit or a fenced sign-out. pub(crate) fn shut_down(app: &tauri::AppHandle) { app.state::().close(); diff --git a/src-tauri/tauri.native-mcp.conf.json b/src-tauri/tauri.native-mcp.conf.json index e55d90c0c2..e1909f58a6 100644 --- a/src-tauri/tauri.native-mcp.conf.json +++ b/src-tauri/tauri.native-mcp.conf.json @@ -6,6 +6,7 @@ "main-window", "desktop-window", "huddle-window", + "agents-page", { "identifier": "native-mcp-callbacks", "description": "Callbacks for the opt-in native MCP test bridge.", diff --git a/src/agents.ts b/src/agents.ts new file mode 100644 index 0000000000..88296a11a5 --- /dev/null +++ b/src/agents.ts @@ -0,0 +1,12 @@ +// FOUNDATION: Startup of the page that runs agents. It has no screens. +import { createAgentsHost } from "./app/agents-host"; +import { loadAgentStorage } from "./features/agents/state"; + +// Agent storage is read before the service, since its reads are synchronous. +void loadAgentStorage().finally(() => { + const host = createAgentsHost(); + if (import.meta.hot) + import.meta.hot.dispose(() => { + void host.dispose().catch(console.error); + }); +}); diff --git a/src/app/agents-host.ts b/src/app/agents-host.ts new file mode 100644 index 0000000000..dd7c3b4c7b --- /dev/null +++ b/src/app/agents-host.ts @@ -0,0 +1,86 @@ +// FOUNDATION: Compose the page that runs agents apart from the screens: the +// plugins' agent entries and the agents service, served to the screens' page +// over the agents port. It shows nothing, and keeps nothing on the device +// for the screens: their communities, outbox and history stay theirs. +import { Context } from "@deepseek-ai/cordis"; +import { bundledPlugins } from "../bundled"; +import { AGENTS_WEBVIEW } from "../features/agents/apart"; +import { eventPort } from "../features/agents/event-port"; +import { createLocalAgents } from "../features/agents/local"; +import { createLocalAgentActivity } from "../features/agents/local-activity"; +import { type AgentsPort, serveAgents } from "../features/agents/port"; +import { AgentsService } from "../features/agents/service"; +import type { ToolOwner } from "../features/agents/tool-port"; +import { pageCommunities } from "../features/communities/page"; +import { DecisionsService } from "../features/decisions/service"; +import { HostService } from "../features/host/service"; +import { IdentityNamesService } from "../features/identity-names/service"; +import { connectNativeTransport } from "../features/relay/native"; +import { createPluginManager } from "../plugins/manager"; +import { withTimeout } from "../plugins/timeout"; + +export function createAgentsHost( + port: AgentsPort = eventPort(AGENTS_WEBVIEW, "main"), +) { + const ctx = new Context(); + new HostService(ctx); + const plugins = createPluginManager(ctx, { + bundled: bundledPlugins, + parts: "agent", + }); + const localAgents = createLocalAgents(); + const localActivity = createLocalAgentActivity(); + const decisions = new DecisionsService(ctx); + const identityNames = new IdentityNamesService(ctx, localAgents.agents); + const communities = pageCommunities(ctx, connectNativeTransport, { + identityNames, + agentChoices: localAgents.agents, + localAgents: localActivity.reader, + }); + const agents = new AgentsService( + ctx, + communities, + undefined, + undefined, + undefined, + localActivity, + undefined, + decisions, + ); + ctx.effect(() => + localAgents.bind({ + snapshot: agents.local, + subscribe: agents.subscribe, + refresh: () => agents.refresh(), + }), + ); + /** Runs `add` as `owner`'s, as the screens' page's plugin runtime names + * it, until the returned stop. */ + const as = (owner: ToolOwner, add: (plugin: Context) => void) => { + const fiber = ctx + .extend({ pluginOwner: Object.freeze({ ...owner }) }) + .plugin({ inject: ["agents"], apply: add }); + return () => void fiber.dispose(); + }; + const stop = serveAgents(agents, port, { + addTool: (tool, owner) => + as(owner, (plugin) => plugin.agents.addTool(tool)), + register: (type, owner) => + as(owner, (plugin) => plugin.agents.register(type)), + local: localAgents.agents, + activity: localActivity.reader, + follow: communities.follow, + }); + let disposal: Promise | undefined; + return { + agents, + dispose() { + stop(); + disposal ??= withTimeout( + Promise.all([plugins.dispose(), ctx.fiber.dispose()]), + "Agents cleanup timed out; restart the app", + ).then(() => {}); + return disposal; + }, + }; +} diff --git a/src/app/services.ts b/src/app/services.ts index 9fc686611a..c24e1d48f9 100644 --- a/src/app/services.ts +++ b/src/app/services.ts @@ -23,7 +23,11 @@ import { import { AccountActionsService } from "../features/account-actions/service"; import { ShortcutsService } from "../features/shortcuts/service"; import { DecisionsService } from "../features/decisions/service"; -import { AgentsService } from "../features/agents/service"; +import { AgentsService, type AgentsClient } from "../features/agents/service"; +import { AGENTS_WEBVIEW, agentsRunApart } from "../features/agents/apart"; +import { eventPort } from "../features/agents/event-port"; +import { connectAgents } from "../features/agents/port"; +import { ScreensAgents } from "../features/agents/screens"; import { createShortcutBindings } from "../features/shortcuts/preferences"; import { ConversationService } from "../features/conversation/service"; import { createAppearance } from "../shared/theme/service"; @@ -46,12 +50,18 @@ export function createServices() { const updates = createUpdates(); const ctx = new Context(); new HostService(ctx); + // Agents run in their own page where the app opens one; this page then + // runs only the plugins' UI entries, and reaches agents over a port. + const apart = agentsRunApart() + ? connectAgents(eventPort("main", AGENTS_WEBVIEW)) + : undefined; const plugins = createPluginManager(ctx, { bundled: bundledPlugins, builtIn: conversationFeatures, + parts: apart ? "ui" : "both", }); const localAgents = createLocalAgents(); - const localActivity = createLocalAgentActivity(); + const localActivity = apart ? undefined : createLocalAgentActivity(); const navigationHost = provideNavigation(ctx); const navigation = navigationHost.navigation; const browser = new BrowserService(ctx); @@ -72,7 +82,7 @@ export function createServices() { localAgents.agents, identity?.ready, identity ? connectNativeTransport : undefined, - localActivity.reader, + apart?.activity ?? localActivity?.reader, ); ctx.provide("communityReader", { snapshot: communities.snapshot, @@ -88,23 +98,42 @@ export function createServices() { const adminAccess = createAdminAccess(relay, communities, nativeRelayStaff()); ctx.provide("adminAccess", adminAccess); ctx.effect(() => () => adminAccess.dispose()); - const agents = new AgentsService( - ctx, - communities, - undefined, - undefined, - undefined, - localActivity, - undefined, - decisions, - ); - ctx.effect(() => - localAgents.bind({ - snapshot: agents.local, - subscribe: agents.subscribe, - refresh: () => agents.refresh(), - }), - ); + let agents: AgentsClient; + if (apart) { + new ScreensAgents(ctx, apart, communities.open); + agents = apart; + ctx.effect(() => localAgents.bind(apart.local)); + const follow = () => { + const { viewer, memberships, selected } = communities.snapshot(); + apart.follow({ + ...(viewer !== undefined && { viewer }), + memberships: memberships.map((membership) => membership.id), + selected, + }); + }; + follow(); + ctx.effect(() => communities.subscribe(follow)); + ctx.effect(() => () => apart.close()); + } else { + const service = new AgentsService( + ctx, + communities, + undefined, + undefined, + undefined, + localActivity, + undefined, + decisions, + ); + agents = service; + ctx.effect(() => + localAgents.bind({ + snapshot: service.local, + subscribe: service.subscribe, + refresh: () => service.refresh(), + }), + ); + } const notifications = new NotificationsService( ctx, navigation, diff --git a/src/features/agents/apart.ts b/src/features/agents/apart.ts new file mode 100644 index 0000000000..66c19ad8c5 --- /dev/null +++ b/src/features/agents/apart.ts @@ -0,0 +1,16 @@ +import { isTauri } from "@tauri-apps/api/core"; + +/** The webview of the page that runs agents, apart from the screens. */ +export const AGENTS_WEBVIEW = "agents"; + +/** Whether agents run in their own hidden page, so a screen that is hidden + * or busy cannot hold them up. The desktop app on macOS opens that page at + * startup (`agents_window` in src-tauri/src/lib.rs); elsewhere they run in + * the page that shows them. */ +export function agentsRunApart() { + return ( + isTauri() && + typeof navigator !== "undefined" && + /^Mac/.test(navigator.platform) + ); +} diff --git a/src/features/agents/event-port.ts b/src/features/agents/event-port.ts index ffbe3732dd..02b558abfd 100644 --- a/src/features/agents/event-port.ts +++ b/src/features/agents/event-port.ts @@ -1,17 +1,20 @@ -// A port between pages in two webviews of this app, over Tauri events. The -// agents protocol needs each message once and in the order sent: a call's -// answer must not overtake the state push before it. Separate emits can -// arrive out of order, so each frame is numbered and held until its turn. +// A port between pages in two webviews of this app, over the native agents +// port (src-tauri/src/agents_port.rs), which passes each frame from one +// page's webview to the other's. The agents protocol needs each message once +// and in the order sent: a call's answer must not overtake the state push +// before it. Separate sends can arrive out of order, so each frame is +// numbered and held until its turn. // // Either page may load first, and either may reload. Each page load is a // session. A page announces its session once it listens, and answers a new // peer's announcement with its own; until it knows the peer's session it // holds what it sends. Frames name the session they are for, so a frame // meant for a page that has since reloaded is dropped. -import { emitTo, listen, type UnlistenFn } from "@tauri-apps/api/event"; +import { Channel, invoke } from "@tauri-apps/api/core"; import type { AgentsPort } from "./port"; const EVENT = "buzz://agents-port"; +type UnlistenFn = () => void; export type Frame = Readonly< { @@ -34,6 +37,23 @@ export type Events = Readonly<{ ): Promise; }>; +/** The native agents port: it knows which webview a frame comes from, and + * sends it to the other one. */ +export const agentsPortEvents: Events = { + emitTo: (_webview, _event, frame) => invoke("agents_port_send", { frame }), + async listen(_event, handler) { + const frames = new Channel(); + let open = true; + frames.onmessage = (payload) => { + if (open) handler({ payload }); + }; + await invoke("agents_port_listen", { frames }); + return () => { + open = false; + }; + }, +}; + /** A port from this page, in webview `from`, to the page in webview `to`. * Messages are JSON: a value JSON cannot carry does not arrive as sent. * What this page sends before the peer's page is up waits for it; what it @@ -41,7 +61,7 @@ export type Events = Readonly<{ export function eventPort( from: string, to: string, - events: Events = { emitTo, listen }, + events: Events = agentsPortEvents, ): AgentsPort { const session = crypto.randomUUID(); let closed = false; diff --git a/src/features/agents/port.test.ts b/src/features/agents/port.test.ts index 19cc2bf202..0a573ca9ae 100644 --- a/src/features/agents/port.test.ts +++ b/src/features/agents/port.test.ts @@ -684,3 +684,31 @@ it("does not show a type whose handles cannot cross", async () => { expect(error).toHaveBeenCalled(); error.mockRestore(); }); + +it("tells the service the screens' page's communities, and tells a reloaded service again", async () => { + const events = jsonEvents(); + const agents = fakeAgents() as unknown as Agents; + const heard: unknown[] = []; + const follow = (communities: unknown) => heard.push(communities); + const client = connectAgents(eventPort("main", "agents", events)); + const communities = { + viewer: "v", + memberships: ["wss://alpha.example"], + selected: "wss://alpha.example", + }; + client.follow(communities); + const first = serveAgents(agents, eventPort("agents", "main", events), { + follow, + }); + await vi.waitFor(() => expect(heard).toEqual([communities])); + // The agents page reloads: the new one hears them without a change. + first(); + const second = serveAgents(agents, eventPort("agents", "main", events), { + follow, + }); + stops.push(() => { + client.close(); + second(); + }); + await vi.waitFor(() => expect(heard).toEqual([communities, communities])); +}); diff --git a/src/features/agents/port.ts b/src/features/agents/port.ts index 6920b96c27..52bc3fa55c 100644 --- a/src/features/agents/port.ts +++ b/src/features/agents/port.ts @@ -15,6 +15,7 @@ import { type SessionState, } from "./service"; import type { AddedTool } from "../../buzz-mcp/session-tools"; +import type { FollowedCommunities } from "../communities/page"; import type { LocalAgents, LocalAgentsSnapshot } from "./local"; import type { LocalAgentSource, LocalWork } from "./local-activity"; import type { Reply } from "../../buzz-mcp/tools"; @@ -136,7 +137,8 @@ type ToService = }> | Readonly<{ kind: "refresh-local"; id: number }> | Readonly<{ kind: "dismiss-work"; work: WorkKey }> - | Readonly<{ kind: "interrupt-work"; id: number; work: WorkKey }>; + | Readonly<{ kind: "interrupt-work"; id: number; work: WorkKey }> + | Readonly<{ kind: "communities"; communities: FollowedCommunities }>; /** A `MessagePort`, or a link to a page in another webview. It delivers each * message once, in the order sent. */ @@ -161,6 +163,9 @@ export type AgentsPortClient = AgentsClient & local: LocalAgents; /** The service's local agents' work, for status rows and Stop. */ activity: LocalAgentSource; + /** Tells the service the communities this page has now: the viewer, + * the joined ones and the selected one. */ + follow(communities: FollowedCommunities): void; }>; const placeKey = (key: string, { channelId, root }: SessionPlace) => @@ -222,7 +227,9 @@ function stateOf( * tool or hosted type the screens' page lists is added, as its plugin's, * with `options.addTool` or `options.register` until it withdraws it; * without them, it is not added. `local` and `activity` are the service's - * local agents and their work, for the screens' page's names and status. */ + * local agents and their work, for the screens' page's names and status. + * `follow` hears the communities the screens' page has, each time they + * change. */ export function serveAgents( agents: Agents, port: AgentsPort, @@ -231,6 +238,7 @@ export function serveAgents( register?: (type: AgentType, owner: ToolOwner) => () => void; local?: LocalAgents; activity?: LocalAgentSource; + follow?: (communities: FollowedCommunities) => void; }> = {}, ) { const { local, activity } = options; @@ -337,6 +345,10 @@ export function serveAgents( answer(data.id, () => local?.refresh()); return; } + if (data.kind === "communities") { + options.follow?.(data.communities); + return; + } if (data.kind === "dismiss-work") { activity?.dismissError?.(data.work); return; @@ -477,6 +489,7 @@ export function connectAgents(port: AgentsPort) { let closed = false; const listeners = new Set<() => void>(); const asked = new Set(); + let followed: FollowedCommunities | undefined; const waiting = new Map< number, Readonly<{ resolve(value: unknown): void; reject(error: Error): void }> @@ -490,6 +503,7 @@ export function connectAgents(port: AgentsPort) { asked.clear(); tools.relist(); hosted.relist(); + if (followed) post({ kind: "communities", communities: followed }); } if (state !== prior) for (const listener of listeners) listener(); return; @@ -612,6 +626,12 @@ export function connectAgents(port: AgentsPort) { present: types.present, addTool: tools.add, register: hosted.register, + follow: (communities: FollowedCommunities) => { + // The screens' page's communities change for much the service ignores. + if (JSON.stringify(communities) === JSON.stringify(followed)) return; + followed = communities; + if (!closed) post({ kind: "communities", communities }); + }, local: Object.freeze({ snapshot: () => state.local, subscribe, diff --git a/src/features/agents/screens.test.ts b/src/features/agents/screens.test.ts new file mode 100644 index 0000000000..1f936cbed6 --- /dev/null +++ b/src/features/agents/screens.test.ts @@ -0,0 +1,89 @@ +import { Context } from "@deepseek-ai/cordis"; +import { afterEach, expect, it, vi } from "vitest"; +import type { AddedTool } from "../../buzz-mcp/session-tools"; +import { communityDestination } from "../communities/destination"; +import type { RelayData } from "../relay/service"; +import { connectAgents, serveAgents } from "./port"; +import { ScreensAgents } from "./screens"; +import type { Agents, AgentsSnapshot } from "./service"; +import type { ToolOwner } from "./tool-port"; + +const stops: (() => unknown)[] = []; +afterEach(async () => { + for (const stop of stops.splice(0).reverse()) await stop(); +}); +const key = "a".repeat(64); +const snapshot: AgentsSnapshot = { + status: "ready", + classifier: "available", + classifierKey: "saved", + agents: [], + running: [{ key, relay: "wss://alpha.example" }] as never, + interrupted: [], + teams: [], +}; +function setup() { + const agents = { + snapshot: () => snapshot, + subscribe: () => () => {}, + types: () => [], + memberships: () => [], + joinable: () => [], + interruptible: () => false, + creationRequests: () => [], + updateRequests: () => [], + } as unknown as Agents; + const added = new Map(); + const { port1, port2 } = new MessageChannel(); + const stop = serveAgents(agents, port1, { + addTool: (tool, owner) => { + added.set(tool.name, owner); + return () => added.delete(tool.name); + }, + }); + const client = connectAgents(port2); + const ctx = new Context(); + const community = {} as RelayData; + const open = vi.fn(() => community); + new ScreensAgents(ctx, client, open); + stops.push( + stop, + () => client.close(), + () => ctx.fiber.dispose(), + ); + return { ctx, client, added, open, community }; +} +const tool: AddedTool = { + name: "files_copy", + description: "Copy a file.", + inputSchema: { type: "object" }, + call: async () => "done", +}; + +it("lists a UI entry's tool under its plugin until the plugin stops", async () => { + const { ctx, added } = setup(); + const owner = Object.freeze({ id: "example", revision: "1" }); + const plugin = ctx.extend({ pluginOwner: owner }).plugin({ + inject: ["agents"], + apply: (scope: Context) => scope.agents.addTool(tool), + }); + await vi.waitFor(() => expect(added.get("files_copy")).toEqual(owner)); + await plugin.dispose(); + await vi.waitFor(() => expect(added.size).toBe(0)); +}); + +it("connects a running agent's community from this page, and refuses what only running agents use", async () => { + const { ctx, client, open, community } = setup(); + await vi.waitFor(() => expect(client.snapshot().running).toHaveLength(1)); + expect(ctx.agents.relay(key)).toBe(community); + expect(open).toHaveBeenCalledWith( + communityDestination("wss://alpha.example").url, + ); + expect(ctx.agents.relay("b".repeat(64))).toBeUndefined(); + expect(() => + ctx.agents.wake(key, { channelId: "c", text: "t" } as never), + ).toThrow(/agent entry/); + expect(() => ctx.agents.storage).toThrow(/agent entry/); + // Outside a plugin, nothing names whose tool it would be. + expect(() => ctx.agents.addTool(tool)).toThrow(/Only a plugin/); +}); diff --git a/src/features/agents/screens.ts b/src/features/agents/screens.ts new file mode 100644 index 0000000000..abdeffdfa6 --- /dev/null +++ b/src/features/agents/screens.ts @@ -0,0 +1,154 @@ +// The agents service as the screens' page has it when agents run in another +// page. What screens read and call goes over the agents port. What a +// plugin's UI entry adds, its tools, hosted types and what its types show, +// is listed with the service under the plugin's identity until the plugin +// stops. What only running agents use belongs in the plugin's agent entry, +// which runs where the agents do. +import { type Context, Service } from "@deepseek-ai/cordis"; +import type { AddedTool } from "../../buzz-mcp/session-tools"; +import type { RelayData } from "../relay/service"; +import { communityDestination } from "../communities/destination"; +import type { connectAgents } from "./port"; +import type { Remote } from "./handles"; +import type { AgentKey } from "./membership"; +import type { + AgentPresentation, + Agents, + AgentsClient, + AgentType, + RegisteredAgentType, +} from "./service"; + +type Client = ReturnType; + +const apart = (what: string) => + new Error( + `${what} is for running agents, which run in another page: use it from the plugin's agent entry.`, + ); + +export class ScreensAgents extends Service implements Agents { + // What screens read and call, as the client has it. + declare readonly snapshot: AgentsClient["snapshot"]; + declare readonly subscribe: AgentsClient["subscribe"]; + declare readonly resumeInterrupted: AgentsClient["resumeInterrupted"]; + declare readonly dismissInterrupted: AgentsClient["dismissInterrupted"]; + declare readonly find: AgentsClient["find"]; + declare readonly create: AgentsClient["create"]; + declare readonly creationRequests: AgentsClient["creationRequests"]; + declare readonly dismissCreation: AgentsClient["dismissCreation"]; + declare readonly approveCreation: AgentsClient["approveCreation"]; + declare readonly updateRequests: AgentsClient["updateRequests"]; + declare readonly dismissUpdate: AgentsClient["dismissUpdate"]; + declare readonly approveUpdate: AgentsClient["approveUpdate"]; + declare readonly finish: AgentsClient["finish"]; + declare readonly portable: AgentsClient["portable"]; + declare readonly port: AgentsClient["port"]; + declare readonly portTeam: AgentsClient["portTeam"]; + declare readonly save: AgentsClient["save"]; + declare readonly previewRetype: AgentsClient["previewRetype"]; + declare readonly retype: AgentsClient["retype"]; + declare readonly remove: AgentsClient["remove"]; + declare readonly memberships: AgentsClient["memberships"]; + declare readonly forget: AgentsClient["forget"]; + declare readonly joinable: AgentsClient["joinable"]; + declare readonly join: AgentsClient["join"]; + declare readonly leave: AgentsClient["leave"]; + declare readonly interruptible: AgentsClient["interruptible"]; + declare readonly sessionState: AgentsClient["sessionState"]; + declare readonly session: AgentsClient["session"]; + declare readonly interrupt: AgentsClient["interrupt"]; + declare readonly unarchive: AgentsClient["unarchive"]; + declare readonly classifier: AgentsClient["classifier"]; + declare readonly setClassifierKey: AgentsClient["setClassifierKey"]; + declare readonly clearClassifierKey: AgentsClient["clearClassifierKey"]; + declare readonly saveTeam: AgentsClient["saveTeam"]; + declare readonly removeTeam: AgentsClient["removeTeam"]; + private readonly client: Client; + private readonly open: (community: string) => RelayData | undefined; + constructor( + ctx: Context, + client: Client, + open: (community: string) => RelayData | undefined, + ) { + super(ctx, "agents"); + const { + present: _present, + addTool: _addTool, + register: _register, + local: _local, + activity: _activity, + follow: _follow, + close: _close, + types: _types, + ...screens + } = client; + Object.assign(this, screens); + this.client = client; + this.open = open; + } + private owner(what: string) { + const owner = this.ctx.pluginOwner; + if (!owner) throw new Error(`Only a plugin ${what}`); + return owner; + } + types = () => + this.client.types() as unknown as readonly RegisteredAgentType[]; + register(type: AgentType) { + const withdraw = this.client.register( + type as unknown as AgentType, + this.owner("registers agent types"), + ); + this.ctx.effect(() => withdraw); + } + present( + typeId: string, + present: (exposed: Remote) => AgentPresentation, + ) { + const { id } = this.owner("presents agent types"); + const stop = this.client.present( + `${id}/${typeId}`, + present as unknown as Parameters[1], + ); + this.ctx.effect(() => stop); + } + addTool(tool: AddedTool) { + const withdraw = this.client.addTool(tool, this.owner("adds agent tools")); + this.ctx.effect(() => withdraw); + } + /** This page's own connection to the running agent's community. */ + relay(key: AgentKey) { + const agent = this.client + .snapshot() + .running.find((running) => running.key === key); + if (!agent) return undefined; + try { + return this.open(communityDestination(agent.relay).url); + } catch { + return undefined; + } + } + addAttentionSource(): never { + throw apart("addAttentionSource"); + } + attentionProviders(): never { + throw apart("attentionProviders"); + } + tools(): never { + throw apart("tools"); + } + wake(): never { + throw apart("wake"); + } + handle(): never { + throw apart("handle"); + } + core(): never { + throw apart("core"); + } + note(): never { + throw apart("note"); + } + get storage(): never { + throw apart("storage"); + } +} diff --git a/src/main.tsx b/src/main.tsx index c190cc5971..fee0d0019c 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -5,6 +5,7 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { createServices } from "./app/services"; import { loadAgentStorage } from "./features/agents/state"; +import { agentsRunApart } from "./features/agents/apart"; import { App } from "./app/App"; import "@fontsource-variable/inter/wght.css"; import "@fontsource-variable/inter/wght-italic.css"; @@ -20,8 +21,9 @@ if (!container) { } // Agent storage is read before any service, since its reads are synchronous. -// It settles, with localStorage at worst, so the window always starts. -void loadAgentStorage().finally(() => { +// It settles, with localStorage at worst, so the window always starts. Where +// agents run in their own page, that page reads it, and this one never does. +void (agentsRunApart() ? Promise.resolve() : loadAgentStorage()).finally(() => { const services = createServices(); const root = createRoot(container); function HostApp() { diff --git a/src/plugins/manager.ts b/src/plugins/manager.ts index 3aabda94f5..2452710220 100644 --- a/src/plugins/manager.ts +++ b/src/plugins/manager.ts @@ -1,6 +1,6 @@ import { createPluginStorage, type PluginStorage } from "./storage"; import * as React from "react"; -import { PluginRuntime } from "./runtime"; +import { type PluginParts, PluginRuntime } from "./runtime"; import type { Context } from "@deepseek-ai/cordis"; import type { PluginManifest, PluginModule } from "./api"; import { createModuleLoader } from "./modules"; @@ -38,6 +38,7 @@ export function createPluginManager( { bundled, builtIn = [], + parts = "both", storage = createPluginStorage(() => bundled.map(({ manifest, enabledByDefault = true }) => ({ manifest, @@ -54,6 +55,8 @@ export function createPluginManager( /** Host-owned contributions, independent of optional-plugin storage. */ builtIn?: readonly BundledPlugin[]; storage?: PluginStorage | undefined; + /** Which part of each plugin this page runs. */ + parts?: PluginParts; }, ) { ctx.provide("react", React); @@ -78,6 +81,8 @@ export function createPluginManager( ), storage.readModule, ), + undefined, + parts, ); let configuration: ConfigurationState = { status: "loading" }; let busy = false; diff --git a/src/plugins/runtime.test.mjs b/src/plugins/runtime.test.mjs index 1a86b7e432..60b24bf6fc 100644 --- a/src/plugins/runtime.test.mjs +++ b/src/plugins/runtime.test.mjs @@ -29,7 +29,7 @@ function deferred() { }); return { promise, resolve }; } -function harness(load, timeoutMs) { +function harness(load, timeoutMs, parts) { let states = {}; const waiters = new Set(); const root = new Context(); @@ -37,6 +37,7 @@ function harness(load, timeoutMs) { root, async (plugin) => ({ inject: ["pages"], ...(await load(plugin)) }), timeoutMs, + parts, ); runtime.subscribe(() => { states = runtime.snapshot(); @@ -135,6 +136,45 @@ test("an agent entry has its own dependencies and the plugin waits for both part } }); +test("a page runs only the part of each plugin it is for", async () => { + const started = []; + const load = async (info) => ({ + apply() { + started.push(`${info.manifest.id} ui`); + }, + ...(info.manifest.id === "example.agent" && { + agent: { + apply(ctx) { + assert.equal(ctx.pluginOwner.id, "example.agent"); + started.push(`${info.manifest.id} agent`); + }, + }, + }), + }); + const plugins = [plugin("example.agent"), plugin("example.page")]; + const active = (s) => + plugins.every(({ manifest }) => s[manifest.id]?.status === "active"); + const agents = harness(load, undefined, "agent"); + const screens = harness(load, undefined, "ui"); + try { + agents.runtime.reconcile(plugins); + screens.runtime.reconcile(plugins); + await agents.wait(active); + await screens.wait(active); + assert.deepEqual(started.sort(), [ + "example.agent agent", + "example.agent ui", + "example.page ui", + ]); + // Nothing of example.page runs in the agents page, yet it is active + // there, so what the screens' page adds as it is admitted. + assert.equal(agents.runtime.isActive("example.page", "one"), true); + } finally { + await agents.dispose(); + await screens.dispose(); + } +}); + test("a failed agent entry fails its plugin and stops its other part", async () => { const h = harness(async () => ({ apply(ctx) { diff --git a/src/plugins/runtime.ts b/src/plugins/runtime.ts index 304bb5a301..970f01dcf3 100644 --- a/src/plugins/runtime.ts +++ b/src/plugins/runtime.ts @@ -21,6 +21,11 @@ type Entry = { predecessor: Promise; }; +/** Which parts of each plugin this page runs: both, its UI entry, or its + * agent entry. A plugin with nothing to run here is active once loaded, so + * what another page adds under its identity is admitted here. */ +export type PluginParts = "both" | "ui" | "agent"; + // Cordis 4.0.2 erases its FiberState const enum from the published JavaScript. // Keep the version-specific values here, at the lifecycle adapter boundary. const cordisState = { active: 2, failed: 3, disposed: 4 } as const; @@ -35,14 +40,17 @@ export class PluginRuntime { private readonly load: (plugin: PluginInfo) => Promise; private readonly listeners = new Set<() => void>(); private readonly timeoutMs: number; + private readonly parts: PluginParts; constructor( root: Context, load: (plugin: PluginInfo) => Promise, timeoutMs = 10_000, + parts: PluginParts = "both", ) { this.root = root; this.load = load; + this.parts = parts; root.provide("pluginStatus", { isActive: this.isActive, subscribe: this.subscribe, @@ -153,10 +161,14 @@ export class PluginRuntime { }), }); const id = entry.plugin.manifest.id; - // Both parts run in this page for now; the plugin is active once both are. + // The plugin is active once each part this page runs is. const parts = [ - { name: id, inject: module.inject, apply: module.apply }, - ...(module.agent ? [{ name: `${id}/agent`, ...module.agent }] : []), + ...(this.parts !== "agent" + ? [{ name: id, inject: module.inject, apply: module.apply }] + : []), + ...(module.agent && this.parts !== "ui" + ? [{ name: `${id}/agent`, ...module.agent }] + : []), ]; entry.fibers = parts.map(({ name, inject, apply }) => scope.plugin({ diff --git a/vite.config.ts b/vite.config.ts index a0b36b7e01..792b14afe8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -97,6 +97,7 @@ export default defineConfig(async ({ command, mode }) => { input: { main: fileURLToPath(new URL("index.html", import.meta.url)), huddle: fileURLToPath(new URL("huddle.html", import.meta.url)), + agents: fileURLToPath(new URL("agents.html", import.meta.url)), }, }, },