diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs
index 66ef7ef17b..9d2d6e90b2 100644
--- a/desktop/src-tauri/src/commands/mod.rs
+++ b/desktop/src-tauri/src/commands/mod.rs
@@ -52,6 +52,8 @@ mod project_terminal;
mod qr_download;
mod relay_members;
mod relay_reconnect;
+mod remote_agent_connect;
+mod remote_agent_discovery;
mod social;
mod team_snapshot;
mod teams;
@@ -103,6 +105,8 @@ pub use project_terminal::*;
pub use qr_download::*;
pub use relay_members::*;
pub use relay_reconnect::*;
+pub use remote_agent_connect::*;
+pub use remote_agent_discovery::*;
pub use social::*;
pub use team_snapshot::*;
pub use teams::*;
diff --git a/desktop/src-tauri/src/commands/remote_agent_connect.rs b/desktop/src-tauri/src/commands/remote_agent_connect.rs
new file mode 100644
index 0000000000..4798941427
--- /dev/null
+++ b/desktop/src-tauri/src/commands/remote_agent_connect.rs
@@ -0,0 +1,305 @@
+//! Connecting a self-hosted agent — one that already runs on a machine the
+//! user owns, supervises itself, and holds its own key.
+//!
+//! This is *connect*, not create. Every other agent path in Buzz mints an
+//! identity, writes a key, and takes responsibility for a process. Here Buzz
+//! learns about an identity that already exists and records where it lives.
+//! The result is a [`ConnectedAgentRecord`] in its own store — a type with no
+//! key, no command, and no pid, which is what keeps it out of every spawn,
+//! deploy, auto-start, profile-republish, and tombstone path. Those paths take
+//! `ManagedAgentRecord`, so they cannot receive one of these by construction
+//! rather than by filtering.
+//!
+//! Three things this deliberately does not do:
+//!
+//! - **No key transport.** The agent's nsec never crosses the network, is
+//! never requested, and is never stored. Buzz holds the public half only.
+//! - **No published claim.** Connecting does not emit an owner-signed
+//! kind:30177 "I manage this agent" event. That event is what
+//! `delete_managed_agent` tombstones, so publishing it would let Buzz
+//! assert — and later revoke — the directory entry for an agent it cannot
+//! restart. A self-hosted agent's directory presence is its own replaceable
+//! kind:10100, signed with the key Buzz has never held.
+//! - **No lifecycle.** There is no start, stop, restart, or deploy here, and
+//! `disconnect` removes Buzz's local pointer without touching the remote
+//! process. Disconnecting an agent that is happily running is expected to
+//! leave it running.
+
+use tauri::{AppHandle, Manager};
+
+use nostr::nips::nip19::FromBech32;
+
+use crate::app_state::AppState;
+use crate::managed_agents::ssh_config::parse_ssh_config;
+use crate::managed_agents::storage::{load_agent_definitions, load_managed_agents};
+use crate::managed_agents::{
+ load_connected_agents, save_connected_agents, ConnectedAgentRecord, ConnectedAgentSummary,
+};
+use crate::util::now_iso;
+
+/// Longest accepted local label. Matches nothing on the wire — this name is
+/// Buzz-local, so the limit only needs to keep the list readable.
+const MAX_CONNECTED_NAME_LEN: usize = 64;
+
+/// Normalize a user-supplied agent pubkey to 64-char lowercase hex.
+///
+/// Both `npub1…` and bare hex are accepted because both are things a user
+/// legitimately has on hand: `npub` is what an agent's own tooling prints,
+/// hex is what appears in event tags and relay queries. Normalizing at this
+/// one boundary means the stored record and every comparison downstream sees
+/// a single form — a mixed-case hex duplicate of an already-connected agent
+/// would otherwise slip past the collision check below.
+pub(crate) fn normalize_agent_pubkey(input: &str) -> Result {
+ let trimmed = input.trim();
+ if trimmed.is_empty() {
+ return Err("agent pubkey is required".to_string());
+ }
+ if let Some(stripped) = trimmed.strip_prefix("nsec") {
+ // Refuse loudly and specifically. A user who pastes a secret key here
+ // has made a serious mistake, and "invalid pubkey" would not tell them
+ // what it was. The value itself is never echoed back.
+ let _ = stripped;
+ return Err(
+ "that is a secret key (nsec), not a pubkey — a self-hosted agent's secret must \
+ never leave its own machine. Paste the agent's npub instead."
+ .to_string(),
+ );
+ }
+ let parsed = if trimmed.starts_with("npub") {
+ nostr::PublicKey::from_bech32(trimmed)
+ .map_err(|_| "invalid npub — check for a truncated or mistyped value".to_string())?
+ } else {
+ nostr::PublicKey::from_hex(trimmed).map_err(|_| {
+ "invalid agent pubkey — expected an npub or 64 hex characters".to_string()
+ })?
+ };
+ Ok(parsed.to_hex())
+}
+
+/// Validate the Buzz-local label for a connected agent.
+pub(crate) fn validate_connected_name(input: &str) -> Result {
+ let trimmed = input.trim();
+ if trimmed.is_empty() {
+ return Err("agent name is required".to_string());
+ }
+ if trimmed.chars().count() > MAX_CONNECTED_NAME_LEN {
+ return Err(format!(
+ "agent name must be at most {MAX_CONNECTED_NAME_LEN} characters"
+ ));
+ }
+ if trimmed.chars().any(|c| c.is_control()) {
+ return Err("agent name must not contain control characters".to_string());
+ }
+ Ok(trimmed.to_string())
+}
+
+/// Resolve a host alias against the user's own `~/.ssh/config`.
+///
+/// Requiring a real alias is not gratuitous strictness. The host is a probe
+/// target: `probe_agent_host` re-resolves it through this same parsed config
+/// and refuses anything it cannot find, so a free-form host string would
+/// produce a connected agent whose reachability could never be reported — a
+/// row that silently never works. Failing at connect time, with the fix named,
+/// is the honest alternative.
+fn resolve_connect_host(host: &str) -> Result {
+ let trimmed = host.trim();
+ if trimmed.is_empty() {
+ return Err("host is required".to_string());
+ }
+ let known = parse_ssh_config();
+ known
+ .iter()
+ .find(|candidate| candidate.host == trimmed)
+ .map(|candidate| candidate.host.clone())
+ .ok_or_else(|| {
+ format!(
+ "'{trimmed}' is not a Host in ~/.ssh/config. Add a stanza for it (Buzz reaches \
+ self-hosted agents through your own ssh config) and try again."
+ )
+ })
+}
+
+/// List the self-hosted agents this machine is connected to.
+#[tauri::command]
+pub async fn list_connected_agents(app: AppHandle) -> Result, String> {
+ tokio::task::spawn_blocking(move || {
+ let state = app.state::();
+ let _store_guard = state
+ .managed_agents_store_lock
+ .lock()
+ .map_err(|error| error.to_string())?;
+ let records = load_connected_agents(&app)?;
+ Ok(records.iter().map(ConnectedAgentSummary::from).collect())
+ })
+ .await
+ .map_err(|error| format!("spawn_blocking failed: {error}"))?
+}
+
+/// Record a self-hosted agent that already runs on `host`.
+///
+/// `harness` is the id observed by the host probe (e.g. `"claude"`). It is
+/// stored as an observation for display; nothing in Buzz executes it.
+#[tauri::command]
+pub async fn connect_remote_agent(
+ host: String,
+ pubkey: String,
+ name: String,
+ harness: Option,
+ app: AppHandle,
+) -> Result {
+ tokio::task::spawn_blocking(move || {
+ // Validate everything before taking the store lock: none of these
+ // checks need the store, and a bad input should not serialize behind
+ // an unrelated agent save.
+ let host = resolve_connect_host(&host)?;
+ let pubkey = normalize_agent_pubkey(&pubkey)?;
+ let name = validate_connected_name(&name)?;
+ let harness = harness.and_then(|value| {
+ let trimmed = value.trim().to_string();
+ (!trimmed.is_empty()).then_some(trimmed)
+ });
+
+ let state = app.state::();
+
+ // Connecting your own identity would make you an agent that replies to
+ // your own messages. The relay- and desktop-side loop guards key off
+ // author identity, so this is the one collision they cannot help with.
+ // An unavailable identity is not a reason to block the connect.
+ if let Ok(keys) = state.signing_keys() {
+ if keys.public_key().to_hex() == pubkey {
+ return Err(
+ "that is your own pubkey. Connect the agent's identity, not yours — \
+ an agent sharing your key would answer your own messages."
+ .to_string(),
+ );
+ }
+ }
+
+ let _store_guard = state
+ .managed_agents_store_lock
+ .lock()
+ .map_err(|error| error.to_string())?;
+
+ // Collision checks span BOTH stores. Separating the stores is what makes
+ // the lifecycle exclusion structural, but uniqueness is the one property
+ // that does not partition: one identity with a record in each store
+ // would be two answers to "who is this pubkey", and two agents sharing a
+ // name would be ambiguous at every mention site.
+ let connected = load_connected_agents(&app)?;
+ if let Some(clash) = connected.iter().find(|record| record.pubkey == pubkey) {
+ return Err(format!(
+ "that agent is already connected as '{}' on {}",
+ clash.name, clash.host
+ ));
+ }
+
+ // Both halves of `managed-agents.json`: keyed instances and the key-less
+ // definitions folded into the same file. A definition's name is just as
+ // mentionable, so checking only instances would let a connect shadow one.
+ let managed = load_managed_agents(&app)?;
+ let definitions = load_agent_definitions(&app)?;
+ if let Some(clash) = managed
+ .iter()
+ .chain(definitions.iter())
+ .find(|record| record.pubkey == pubkey)
+ {
+ return Err(format!(
+ "'{}' is an agent Buzz already manages on this machine — it holds that agent's \
+ key, so it cannot also be connected as self-hosted",
+ clash.name
+ ));
+ }
+
+ let name_taken = connected
+ .iter()
+ .any(|record| record.name.eq_ignore_ascii_case(&name))
+ || managed
+ .iter()
+ .chain(definitions.iter())
+ .any(|record| record.name.eq_ignore_ascii_case(&name));
+ if name_taken {
+ return Err(format!(
+ "an agent named '{name}' already exists — names are how agents are mentioned, \
+ so pick a different one"
+ ));
+ }
+
+ let now = now_iso();
+ // Stamp the record from Buzz's active workspace rather than accepting a
+ // caller-supplied community assertion.
+ let community = crate::managed_agents::normalize_community_url(
+ &crate::relay::relay_ws_url_with_override(&state),
+ );
+ let record = connected_record(&host, &pubkey, &name, harness, Some(community), &now);
+ let summary = ConnectedAgentSummary::from(&record);
+
+ let mut connected = connected;
+ connected.push(record);
+ save_connected_agents(&app, &connected)?;
+
+ Ok(summary)
+ })
+ .await
+ .map_err(|error| format!("spawn_blocking failed: {error}"))?
+}
+
+/// Build the stored record for a connected agent.
+///
+/// A pure function so the invariants that matter are directly testable without
+/// a Tauri app handle. With a dedicated record type most of them are no longer
+/// assertions at all: there is no key field to leave empty, no `agent_command`
+/// to leave blank, and no `start_on_app_launch` to set false. The type states
+/// them, so this function only has to be correct about the six facts Buzz knows.
+pub(crate) fn connected_record(
+ host: &str,
+ pubkey: &str,
+ name: &str,
+ harness: Option,
+ community: Option,
+ now: &str,
+) -> ConnectedAgentRecord {
+ ConnectedAgentRecord {
+ pubkey: pubkey.to_string(),
+ name: name.to_string(),
+ host: host.to_string(),
+ harness,
+ community,
+ created_at: now.to_string(),
+ updated_at: now.to_string(),
+ }
+}
+
+/// Forget a connected agent.
+///
+/// Local-only by construction: this removes Buzz's pointer and nothing else.
+/// It deliberately does not take the paths `delete_managed_agent` takes —
+/// no process stop (Buzz owns no process), no keyring delete (Buzz holds no
+/// key), and above all no kind:30177 tombstone or NIP-IA archive. Those
+/// publish the owner's assertion that an agent is gone; running them for an
+/// agent that is still alive on its own machine would remove a working agent
+/// from every member picker and autocomplete on the relay.
+#[tauri::command]
+pub async fn disconnect_remote_agent(pubkey: String, app: AppHandle) -> Result<(), String> {
+ tokio::task::spawn_blocking(move || {
+ let pubkey = normalize_agent_pubkey(&pubkey)?;
+ let state = app.state::();
+ let _store_guard = state
+ .managed_agents_store_lock
+ .lock()
+ .map_err(|error| error.to_string())?;
+
+ let mut connected = load_connected_agents(&app)?;
+ let before = connected.len();
+ connected.retain(|record| record.pubkey != pubkey);
+ if connected.len() == before {
+ return Err(format!("connected agent {pubkey} not found"));
+ }
+ save_connected_agents(&app, &connected)
+ })
+ .await
+ .map_err(|error| format!("spawn_blocking failed: {error}"))?
+}
+
+#[cfg(test)]
+#[path = "remote_agent_connect_tests.rs"]
+mod tests;
diff --git a/desktop/src-tauri/src/commands/remote_agent_connect_tests.rs b/desktop/src-tauri/src/commands/remote_agent_connect_tests.rs
new file mode 100644
index 0000000000..59c320e18a
--- /dev/null
+++ b/desktop/src-tauri/src/commands/remote_agent_connect_tests.rs
@@ -0,0 +1,214 @@
+use super::*;
+
+const AGENT_HEX: &str = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
+const AGENT_NPUB: &str = "npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6";
+
+fn sample_record() -> ConnectedAgentRecord {
+ connected_record(
+ "workstation",
+ AGENT_HEX,
+ "Scout",
+ Some("claude".to_string()),
+ Some("wss://community.example".to_string()),
+ "2026-07-28T00:00:00Z",
+ )
+}
+
+#[test]
+fn npub_and_hex_normalize_to_the_same_stored_form() {
+ // Both are forms a user legitimately has on hand. If they normalized
+ // differently, connecting the same agent twice — once from each form —
+ // would pass the pubkey collision check and produce two records for one
+ // identity.
+ assert_eq!(normalize_agent_pubkey(AGENT_NPUB).unwrap(), AGENT_HEX);
+ assert_eq!(normalize_agent_pubkey(AGENT_HEX).unwrap(), AGENT_HEX);
+}
+
+#[test]
+fn uppercase_hex_is_normalized_rather_than_stored_verbatim() {
+ let shouty = AGENT_HEX.to_uppercase();
+ assert_eq!(normalize_agent_pubkey(&shouty).unwrap(), AGENT_HEX);
+}
+
+#[test]
+fn surrounding_whitespace_is_tolerated() {
+ // Pasted from a terminal, an npub routinely arrives with a trailing
+ // newline.
+ assert_eq!(
+ normalize_agent_pubkey(&format!(" {AGENT_NPUB}\n")).unwrap(),
+ AGENT_HEX
+ );
+}
+
+#[test]
+fn a_pasted_secret_key_is_refused_with_a_specific_message() {
+ // The whole point of this feature is that the agent's secret stays on its
+ // own machine. A user who pastes an nsec has made a serious mistake, and
+ // "invalid pubkey" would not tell them what it was.
+ let error =
+ normalize_agent_pubkey("nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5")
+ .expect_err("an nsec must never be accepted as an agent pubkey");
+ assert!(
+ error.contains("secret key"),
+ "message must name the mistake: {error}"
+ );
+ assert!(
+ !error.contains("nsec1vl029"),
+ "the secret must not be echoed back into an error string: {error}"
+ );
+}
+
+#[test]
+fn malformed_pubkeys_are_refused() {
+ for bad in [
+ "",
+ " ",
+ "not-a-key",
+ "npub1truncated",
+ // 63 hex chars — one short.
+ "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459",
+ ] {
+ assert!(
+ normalize_agent_pubkey(bad).is_err(),
+ "expected {bad:?} to be refused"
+ );
+ }
+}
+
+#[test]
+fn names_are_trimmed_and_bounded() {
+ assert_eq!(validate_connected_name(" Scout ").unwrap(), "Scout");
+ assert!(validate_connected_name("").is_err());
+ assert!(validate_connected_name(" ").is_err());
+ assert!(validate_connected_name(&"n".repeat(65)).is_err());
+ assert!(validate_connected_name(&"n".repeat(64)).is_ok());
+ // A newline in a name would break every single-line list rendering it.
+ assert!(validate_connected_name("Sco\nut").is_err());
+}
+
+#[test]
+fn a_connected_record_stores_the_identity_and_the_host_and_nothing_else() {
+ // The exhaustive field set, asserted against the serialized form rather
+ // than field-by-field. Under the previous design each absent capability
+ // needed its own assertion (`private_key_nsec` empty, `agent_command`
+ // blank, `start_on_app_launch` false, `runtime_pid` none) because the
+ // fields existed and merely held harmless values. They no longer exist, so
+ // the honest test is that the shape itself cannot express them: anyone
+ // widening this type to carry a key or a command breaks this test.
+ let record = sample_record();
+ let json = serde_json::to_value(&record).unwrap();
+ let mut keys: Vec<&str> = json
+ .as_object()
+ .unwrap()
+ .keys()
+ .map(String::as_str)
+ .collect();
+ keys.sort_unstable();
+
+ assert_eq!(
+ keys,
+ [
+ "community",
+ "created_at",
+ "harness",
+ "host",
+ "name",
+ "pubkey",
+ "updated_at"
+ ]
+ );
+ assert_eq!(record.pubkey, AGENT_HEX);
+ assert_eq!(record.host, "workstation");
+ assert_eq!(record.harness.as_deref(), Some("claude"));
+}
+
+#[test]
+fn a_probeless_connect_stores_no_harness_key_at_all() {
+ // `harness` is an observation, so "not observed" must be representable.
+ // `skip_serializing_if` keeps it out of the file rather than writing null,
+ // which keeps the stored shape honest about what was actually seen.
+ let record = connected_record(
+ "workstation",
+ AGENT_HEX,
+ "Scout",
+ None,
+ None,
+ "2026-07-28T00:00:00Z",
+ );
+ let json = serde_json::to_value(&record).unwrap();
+ assert!(!json.as_object().unwrap().contains_key("harness"));
+}
+
+#[test]
+fn a_connected_record_round_trips_through_the_store_format() {
+ let record = sample_record();
+ let json = serde_json::to_string(&record).unwrap();
+ let restored: ConnectedAgentRecord = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, record);
+ assert_eq!(
+ restored.host, "workstation",
+ "the host must survive a store write/read cycle — it is the probe target"
+ );
+}
+
+#[test]
+fn the_summary_projection_omits_lifecycle_and_secrets() {
+ // `ConnectedAgentSummary` is intentionally narrower than
+ // `ManagedAgentSummary`. Serialize it and assert the absent fields stay
+ // absent: a later widening that reintroduces `status` or `pid` would give
+ // the UI something to render a start button from.
+ let record = sample_record();
+ let summary = ConnectedAgentSummary::from(&record);
+ let json = serde_json::to_value(&summary).unwrap();
+ let object = json.as_object().unwrap();
+
+ assert_eq!(object.get("host").unwrap(), "workstation");
+ assert_eq!(object.get("harness").unwrap(), "claude");
+ assert_eq!(object.get("pubkey").unwrap(), AGENT_HEX);
+ for absent in [
+ "status",
+ "pid",
+ "logPath",
+ "log_path",
+ "needsRestart",
+ "startOnAppLaunch",
+ "privateKeyNsec",
+ "private_key_nsec",
+ "relayUrl",
+ ] {
+ assert!(
+ !object.contains_key(absent),
+ "{absent} must not reach the connected-agent surface"
+ );
+ }
+}
+
+#[test]
+fn the_summary_projection_is_total() {
+ // The custody-field version of this projection read the host out of an
+ // `Option` and fell back to an empty string for a record that arrived under
+ // local custody — a case that could only happen if a caller's filtering was
+ // wrong. With a dedicated type there is no such case and no fallback, so an
+ // empty host in the UI can now only mean an empty host on disk.
+ let record = sample_record();
+ assert_eq!(ConnectedAgentSummary::from(&record).host, record.host);
+}
+
+#[test]
+fn an_unknown_ssh_host_is_refused_with_the_fix_named() {
+ // The host is a probe target. Accepting a free-form string would create a
+ // row whose reachability can never be reported, which reads as a broken
+ // feature rather than a missing config entry.
+ let error = resolve_connect_host("definitely-not-in-any-ssh-config-xyzzy")
+ .expect_err("an unknown alias must be refused");
+ assert!(
+ error.contains("~/.ssh/config"),
+ "the message must name the fix: {error}"
+ );
+}
+
+#[test]
+fn a_blank_host_is_refused() {
+ assert!(resolve_connect_host("").is_err());
+ assert!(resolve_connect_host(" ").is_err());
+}
diff --git a/desktop/src-tauri/src/commands/remote_agent_discovery.rs b/desktop/src-tauri/src/commands/remote_agent_discovery.rs
new file mode 100644
index 0000000000..85e9f47a03
--- /dev/null
+++ b/desktop/src-tauri/src/commands/remote_agent_discovery.rs
@@ -0,0 +1,57 @@
+//! Tauri commands for host-aware harness discovery.
+//!
+//! These answer "which machines can I reach, and what agent harnesses are on
+//! them?" so an agent that already runs on another host can be found instead of
+//! described by hand.
+//!
+//! All three commands are read-only. Nothing here installs software, writes to
+//! a remote host, or collects a credential — the probe runs `command -v` and
+//! `--version` and nothing else.
+
+use crate::managed_agents::remote_probe::{probe_localhost, probe_ssh_host, HostProbeResult};
+use crate::managed_agents::ssh_config::{parse_ssh_config, SshHost};
+
+/// Enumerate the user's `~/.ssh/config` host aliases.
+///
+/// No connection is attempted. An absent config yields an empty list, which
+/// means "no remote hosts configured", not a failure.
+#[tauri::command]
+pub async fn list_ssh_hosts() -> Result, String> {
+ tokio::task::spawn_blocking(parse_ssh_config)
+ .await
+ .map_err(|e| format!("spawn_blocking failed: {e}"))
+}
+
+/// Probe one host for agent harnesses and the `buzz` CLI.
+///
+/// `host` must name an alias present in `~/.ssh/config`. Resolving it through
+/// the parsed config rather than trusting the argument is what keeps an
+/// arbitrary string — including anything shaped like an ssh option — from
+/// reaching the `ssh` argv.
+///
+/// A host-side problem (unreachable, password-only, unknown host key) comes back
+/// as `Ok` with `ok: false` and a classified `errorKind`: the UI shows one row
+/// per host and needs a renderable status, not an exception.
+#[tauri::command]
+pub async fn probe_agent_host(host: String) -> Result {
+ tokio::task::spawn_blocking(move || {
+ let hosts = parse_ssh_config();
+ let Some(entry) = hosts.into_iter().find(|candidate| candidate.host == host) else {
+ return Err(format!(
+ "'{host}' is not a Host alias in ~/.ssh/config; only configured hosts can be probed"
+ ));
+ };
+ Ok(probe_ssh_host(&entry))
+ })
+ .await
+ .map_err(|e| format!("spawn_blocking failed: {e}"))?
+}
+
+/// Probe the machine Buzz is running on, using the identical probe script so
+/// the result is shape-compatible with [`probe_agent_host`].
+#[tauri::command]
+pub async fn probe_local_agent_host() -> Result {
+ tokio::task::spawn_blocking(probe_localhost)
+ .await
+ .map_err(|e| format!("spawn_blocking failed: {e}"))
+}
diff --git a/desktop/src-tauri/src/initial_window.rs b/desktop/src-tauri/src/initial_window.rs
new file mode 100644
index 0000000000..05dee47674
--- /dev/null
+++ b/desktop/src-tauri/src/initial_window.rs
@@ -0,0 +1,70 @@
+//! Initial window reveal helpers.
+//!
+//! Kept outside the app entrypoint so platform-specific first-frame handling
+//! does not make command registration and shutdown wiring harder to navigate.
+
+pub(crate) fn reveal_initial_window(window: &tauri::Window) {
+ if let Err(error) = window.show() {
+ eprintln!("buzz-desktop: failed to reveal main window: {error}");
+ return;
+ }
+ if let Err(error) = window.set_focus() {
+ eprintln!("buzz-desktop: failed to focus main window: {error}");
+ }
+}
+
+#[cfg(target_os = "macos")]
+pub(crate) fn set_initial_window_backing(window: &tauri::Window) {
+ // The window remains transparent at runtime for vibrancy. Use an opaque
+ // native backing only across the first visible frames so the previous app
+ // cannot show through before WebKit has submitted its first surface.
+ if let Err(error) = window.set_background_color(Some(tauri::window::Color(17, 21, 24, 255))) {
+ eprintln!("buzz-desktop: failed to set initial window backing: {error}");
+ }
+}
+
+#[cfg(target_os = "macos")]
+pub(crate) async fn clear_initial_window_backing(window: &tauri::Window) {
+ tokio::time::sleep(std::time::Duration::from_millis(250)).await;
+ if let Err(error) = window.set_background_color(None) {
+ eprintln!("buzz-desktop: failed to clear initial window backing: {error}");
+ }
+}
+
+#[cfg(target_os = "macos")]
+pub(crate) async fn wait_for_stable_initial_window_geometry(
+ window: &tauri::Window,
+) {
+ const MAX_POLLS: usize = 120;
+ const REQUIRED_STABLE_POLLS: usize = 4;
+
+ let mut previous_bounds = None;
+ let mut stable_polls = 0;
+
+ for _ in 0..MAX_POLLS {
+ // Accept whatever geometry the window-state plugin restores — maximized
+ // or a normal saved size. macOS applies the restore asynchronously, so
+ // we only need consecutive identical outer bounds to know it settled.
+ // Gating on `is_maximized()` here would leave `bounds` permanently
+ // `None` for restored non-maximized windows and stall the reveal until
+ // the poll timeout.
+ let bounds = match (window.outer_position(), window.outer_size()) {
+ (Ok(position), Ok(size)) => Some((position.x, position.y, size.width, size.height)),
+ _ => None,
+ };
+
+ if bounds.is_some() && bounds == previous_bounds {
+ stable_polls += 1;
+ if stable_polls >= REQUIRED_STABLE_POLLS {
+ return;
+ }
+ } else {
+ stable_polls = 0;
+ }
+ previous_bounds = bounds;
+
+ tokio::time::sleep(std::time::Duration::from_millis(16)).await;
+ }
+
+ eprintln!("buzz-desktop: initial window geometry did not settle before reveal timeout");
+}
diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs
index 6814008f0d..3a4cf1ba09 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -9,6 +9,7 @@ mod event_sync;
mod events;
mod huddle;
mod identity_storage;
+mod initial_window;
mod key_backup;
mod linux_media;
mod managed_agents;
@@ -54,6 +55,12 @@ use huddle::{
join_huddle, leave_huddle, push_audio_pcm, set_huddle_transcription_enabled, set_tts_enabled,
set_voice_input_mode, speak_agent_message, start_huddle, start_stt_pipeline,
};
+use initial_window::reveal_initial_window;
+#[cfg(target_os = "macos")]
+use initial_window::{
+ clear_initial_window_backing, set_initial_window_backing,
+ wait_for_stable_initial_window_geometry,
+};
use managed_agents::{
backfill_persona_snapshots, ensure_nest, list_managed_agent_runtimes,
put_managed_agent_runtime_lifecycle, reconcile_managed_agent_runtimes,
@@ -79,70 +86,6 @@ use tray_menu::show_main_window;
#[cfg(target_os = "macos")]
const INITIAL_RENDER_READY_EVENT: &str = "initial-render-ready";
-fn reveal_initial_window(window: &tauri::Window) {
- if let Err(error) = window.show() {
- eprintln!("buzz-desktop: failed to reveal main window: {error}");
- return;
- }
- if let Err(error) = window.set_focus() {
- eprintln!("buzz-desktop: failed to focus main window: {error}");
- }
-}
-
-#[cfg(target_os = "macos")]
-fn set_initial_window_backing(window: &tauri::Window) {
- // The window remains transparent at runtime for vibrancy. Use an opaque
- // native backing only across the first visible frames so the previous app
- // cannot show through before WebKit has submitted its first surface.
- if let Err(error) = window.set_background_color(Some(tauri::window::Color(17, 21, 24, 255))) {
- eprintln!("buzz-desktop: failed to set initial window backing: {error}");
- }
-}
-
-#[cfg(target_os = "macos")]
-async fn clear_initial_window_backing(window: &tauri::Window) {
- tokio::time::sleep(std::time::Duration::from_millis(250)).await;
- if let Err(error) = window.set_background_color(None) {
- eprintln!("buzz-desktop: failed to clear initial window backing: {error}");
- }
-}
-
-#[cfg(target_os = "macos")]
-async fn wait_for_stable_initial_window_geometry(window: &tauri::Window) {
- const MAX_POLLS: usize = 120;
- const REQUIRED_STABLE_POLLS: usize = 4;
-
- let mut previous_bounds = None;
- let mut stable_polls = 0;
-
- for _ in 0..MAX_POLLS {
- // Accept whatever geometry the window-state plugin restores — maximized
- // or a normal saved size. macOS applies the restore asynchronously, so
- // we only need consecutive identical outer bounds to know it settled.
- // Gating on `is_maximized()` here would leave `bounds` permanently
- // `None` for restored non-maximized windows and stall the reveal until
- // the poll timeout.
- let bounds = match (window.outer_position(), window.outer_size()) {
- (Ok(position), Ok(size)) => Some((position.x, position.y, size.width, size.height)),
- _ => None,
- };
-
- if bounds.is_some() && bounds == previous_bounds {
- stable_polls += 1;
- if stable_polls >= REQUIRED_STABLE_POLLS {
- return;
- }
- } else {
- stable_polls = 0;
- }
- previous_bounds = bounds;
-
- tokio::time::sleep(std::time::Duration::from_millis(16)).await;
- }
-
- eprintln!("buzz-desktop: initial window geometry did not settle before reveal timeout");
-}
-
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// mesh-llm's async chains (model download, node start/join) overflow
@@ -800,6 +743,12 @@ pub fn run() {
get_relay_self,
resolve_oa_owner,
list_relay_agents,
+ list_ssh_hosts,
+ probe_agent_host,
+ probe_local_agent_host,
+ list_connected_agents,
+ connect_remote_agent,
+ disconnect_remote_agent,
list_managed_agents,
list_managed_agent_runtimes,
start_managed_agent_runtime,
diff --git a/desktop/src-tauri/src/managed_agents/connected_agents.rs b/desktop/src-tauri/src/managed_agents/connected_agents.rs
new file mode 100644
index 0000000000..e8813e83b6
--- /dev/null
+++ b/desktop/src-tauri/src/managed_agents/connected_agents.rs
@@ -0,0 +1,212 @@
+//! Connected self-hosted agents: a distinct record type in a distinct store.
+//!
+//! A connected agent is one that already runs on a machine the user owns,
+//! supervises itself, and holds its own key. Buzz knows its pubkey and where it
+//! lives, and nothing else.
+//!
+//! # Why a separate type and file
+//!
+//! The first cut of this feature added a `key_custody` field to
+//! [`ManagedAgentRecord`] and filtered on it inside `load_managed_agents`. That
+//! works, but it makes "Buzz must not act on this agent" a *value* that every
+//! reader has to interpret correctly, and it puts a connected agent one missed
+//! filter away from the spawn, deploy, tombstone, and key-persisting paths. It
+//! also obliged every one of the ~20 `ManagedAgentRecord { .. }` literals in the
+//! tree to name a field about custody they have no opinion on.
+//!
+//! Making it a separate type removes the question instead of answering it:
+//!
+//! - [`ConnectedAgentRecord`] has no `private_key_nsec`, no `agent_command`, no
+//! `start_on_app_launch`, no `runtime_pid`. A lifecycle path cannot act on one
+//! because there is nothing to act *with* — and it cannot receive one anyway,
+//! because it takes [`ManagedAgentRecord`].
+//! - The records live in `connected-agents.json`, so
+//! [`super::load_managed_agents`] cannot return one no matter what it filters,
+//! and an instance-side save cannot erase one no matter what it re-reads.
+//! - [`super::storage`] is byte-identical to upstream. Key custody is expressed
+//! by which store a record is in, which is not something a future contributor
+//! can forget to check.
+//!
+//! The invariants that used to need guards are now properties of the types, and
+//! the cross-store tests below assert the ones a type cannot state by itself.
+
+use std::fs;
+use std::path::{Path, PathBuf};
+
+use serde::{Deserialize, Serialize};
+use tauri::AppHandle;
+
+use super::storage::{atomic_write_json, backup_invalid_store, managed_agents_base_dir};
+
+/// A self-hosted agent Buzz talks to but does not own — the persisted shape.
+///
+/// Every field is either an identity Buzz only holds the public half of, or a
+/// local label. There is deliberately no key, no command, no timeout, no
+/// auto-start flag, and no pid: this type cannot describe a process, so no
+/// amount of downstream code can use it to start one.
+///
+/// There is no operational `relay_url`. Every agent relay lookup resolves the
+/// active workspace relay at read time (see
+/// [`crate::relay::effective_agent_relay_url`]). The optional `community` below
+/// is only a display-scope marker; it is never used to route agent traffic.
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct ConnectedAgentRecord {
+ /// The agent's own pubkey, 64-char lowercase hex. Normalized at the connect
+ /// boundary so every comparison downstream sees one form.
+ pub pubkey: String,
+ /// Buzz-local label. Not published anywhere — the agent's own kind:10100
+ /// profile is the authority on how it presents itself on the relay.
+ pub name: String,
+ /// `~/.ssh/config` alias of the machine the agent and its key live on.
+ ///
+ /// A plain `String`, not an `Option`: a connected agent without a host would
+ /// be a record whose reachability can never be probed, so the connect
+ /// boundary rejects it and the type refuses to represent it.
+ pub host: String,
+ /// Harness id observed on the host at connect time, e.g. `"claude"`. A
+ /// record of what was seen, not a spawn instruction — nothing in Buzz
+ /// executes it. `None` when the user connected without a completed probe.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub harness: Option,
+ /// Normalized relay URL identifying the community where this connection
+ /// was created. Records written before this field was introduced remain
+ /// readable and visible until they are reconnected.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub community: Option,
+ pub created_at: String,
+ pub updated_at: String,
+}
+
+/// The connected-agent view handed to the frontend.
+///
+/// Distinct from [`ConnectedAgentRecord`] only in casing: the record is the
+/// on-disk shape (snake_case, matching `managed-agents.json`) and this is the
+/// wire shape (camelCase, matching every other Tauri command). Keeping them
+/// separate means a future storage field is not automatically exposed to the UI.
+///
+/// Deliberately not a `ManagedAgentSummary`. That type carries `status`, `pid`,
+/// `log_path`, `needs_restart`, `start_on_app_launch`, and
+/// `auto_restart_on_config_change` — every one a claim about a process Buzz
+/// supervises. Projecting a connected agent onto it would force this surface to
+/// invent a lifecycle it has no access to (a self-supervised agent with no local
+/// pid is not "stopped"), and the UI would then render controls that cannot
+/// work. The narrow shape is what makes "no start/stop button" a property of the
+/// type rather than a rule someone has to remember.
+#[derive(Debug, Clone, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ConnectedAgentSummary {
+ pub pubkey: String,
+ pub name: String,
+ pub host: String,
+ pub harness: Option,
+ /// Community where this connection was created, or `None` for a legacy
+ /// record that predates community scoping.
+ pub community: Option,
+ pub created_at: String,
+ pub updated_at: String,
+}
+
+impl From<&ConnectedAgentRecord> for ConnectedAgentSummary {
+ /// Total and infallible. The custody-field version of this projection had to
+ /// fall back to an empty host for a record that reached it under local
+ /// custody; with a dedicated type that case does not exist.
+ fn from(record: &ConnectedAgentRecord) -> Self {
+ Self {
+ pubkey: record.pubkey.clone(),
+ name: record.name.clone(),
+ host: record.host.clone(),
+ harness: record.harness.clone(),
+ community: record.community.clone(),
+ created_at: record.created_at.clone(),
+ updated_at: record.updated_at.clone(),
+ }
+ }
+}
+
+/// Path of the connected-agent store, beside `managed-agents.json`.
+pub(crate) fn connected_agents_store_path(app: &AppHandle) -> Result {
+ Ok(managed_agents_base_dir(app)?.join("connected-agents.json"))
+}
+
+/// Load the connected self-hosted agents.
+///
+/// No key hydration, because there is no key to hydrate: a keyring lookup here
+/// would query for a secret that by definition does not exist locally, and a
+/// miss would be indistinguishable from an outage.
+///
+/// Parse failure is fail-loud with the evidence preserved, matching
+/// [`super::storage::load_managed_agents`]: a later in-app save rewrites this
+/// file wholesale, which would otherwise silently destroy a malformed hand edit.
+pub(crate) fn load_connected_agents(app: &AppHandle) -> Result, String> {
+ load_connected_agents_at(&connected_agents_store_path(app)?)
+}
+
+/// Path-based seam, so the store's behavior is testable over a tempdir without
+/// a Tauri app handle. Mirrors the `hydrate_keys` / `hydrate_keys_with` split in
+/// [`super::storage`].
+pub(crate) fn load_connected_agents_at(path: &Path) -> Result, String> {
+ if !path.exists() {
+ return Ok(Vec::new());
+ }
+ let content = fs::read_to_string(path)
+ .map_err(|error| format!("failed to read connected agent store: {error}"))?;
+ serde_json::from_str(&content).map_err(|error| {
+ backup_invalid_store(path);
+ format!("failed to parse connected agent store (preserved as .invalid): {error}")
+ })
+}
+
+/// Save the connected self-hosted agents.
+///
+/// A wholesale rewrite of this file only. It cannot disturb `managed-agents.json`
+/// — which is the point of the separate store — so unlike the custody-field
+/// design there is no other half to re-read and no way for an unrelated save to
+/// erase these rows.
+///
+/// Uses the ordinary [`atomic_write_json`], not the `0o600` restricted variant:
+/// that exists for files carrying plaintext agent nsecs, and this type cannot
+/// hold one.
+pub(crate) fn save_connected_agents(
+ app: &AppHandle,
+ connected: &[ConnectedAgentRecord],
+) -> Result<(), String> {
+ save_connected_agents_at(&connected_agents_store_path(app)?, connected)
+}
+
+/// Path-based seam. See [`load_connected_agents_at`].
+pub(crate) fn save_connected_agents_at(
+ path: &Path,
+ connected: &[ConnectedAgentRecord],
+) -> Result<(), String> {
+ let mut sorted = connected.to_vec();
+ sort_for_stable_diffs(&mut sorted);
+ let payload = serde_json::to_vec_pretty(&sorted)
+ .map_err(|error| format!("failed to serialize connected agents: {error}"))?;
+ // `atomic_write_json` canonicalizes to preserve a symlink at `path`, which
+ // requires the target to exist. A first save has nothing to canonicalize.
+ if !path.exists() {
+ fs::write(path, b"[]")
+ .map_err(|error| format!("failed to create connected agent store: {error}"))?;
+ }
+ atomic_write_json(path, &payload)
+}
+
+/// Order by name then pubkey, matching how instances are sorted, so the file
+/// produces stable diffs.
+fn sort_for_stable_diffs(records: &mut [ConnectedAgentRecord]) {
+ records.sort_by(|left, right| {
+ left.name
+ .to_lowercase()
+ .cmp(&right.name.to_lowercase())
+ .then_with(|| left.pubkey.cmp(&right.pubkey))
+ });
+}
+
+/// Normalize a relay URL so equivalent community spellings compare equally.
+pub fn normalize_community_url(url: &str) -> String {
+ url.trim().trim_end_matches('/').to_ascii_lowercase()
+}
+
+#[cfg(test)]
+#[path = "connected_agents_tests.rs"]
+mod tests;
diff --git a/desktop/src-tauri/src/managed_agents/connected_agents_tests.rs b/desktop/src-tauri/src/managed_agents/connected_agents_tests.rs
new file mode 100644
index 0000000000..a6973ae640
--- /dev/null
+++ b/desktop/src-tauri/src/managed_agents/connected_agents_tests.rs
@@ -0,0 +1,250 @@
+//! Cross-store tests for connected self-hosted agents.
+//!
+//! The lifecycle-exclusion invariants used to be enforced by a `key_custody`
+//! filter inside `load_managed_agents`, and were tested by asserting that the
+//! filter returned the right subset. With a separate type in a separate file
+//! there is no filter to test — so what these cover instead is that the
+//! separation is real: that the two stores cannot see each other's rows, that
+//! neither type can be read out of the other's file, and that a write to one
+//! cannot disturb the other.
+//!
+//! Together with the type itself (no key, no command, no pid — see
+//! [`super::ConnectedAgentRecord`]) that is the whole of the old invariant set,
+//! reproved at the boundary rather than at each consumer.
+
+use std::fs;
+
+use super::{
+ load_connected_agents_at, normalize_community_url, save_connected_agents_at,
+ ConnectedAgentRecord, ConnectedAgentSummary,
+};
+use crate::managed_agents::ManagedAgentRecord;
+
+const CONNECTED_HEX: &str = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
+const OWNED_HEX: &str = "1bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa4591";
+
+fn connected(pubkey: &str, name: &str, host: &str) -> ConnectedAgentRecord {
+ ConnectedAgentRecord {
+ pubkey: pubkey.to_string(),
+ name: name.to_string(),
+ host: host.to_string(),
+ harness: Some("claude".to_string()),
+ community: None,
+ created_at: "2026-07-28T00:00:00Z".to_string(),
+ updated_at: "2026-07-28T00:00:00Z".to_string(),
+ }
+}
+
+/// A `managed-agents.json` payload as earlier builds wrote it.
+fn managed_store_json() -> String {
+ serde_json::json!([{
+ "pubkey": OWNED_HEX,
+ "name": "Owned",
+ "private_key_nsec": "",
+ "relay_url": "wss://localhost:3000",
+ "acp_command": "buzz-acp",
+ "agent_command": "goose",
+ "agent_args": [],
+ "mcp_command": "",
+ "turn_timeout_seconds": 320,
+ "created_at": "2026-01-01T00:00:00Z",
+ "updated_at": "2026-01-01T00:00:00Z"
+ }])
+ .to_string()
+}
+
+#[test]
+fn an_absent_store_is_an_empty_list_not_an_error() {
+ // First run, and every run before the user connects anything. This must not
+ // surface as a load failure in the agents view.
+ let dir = tempfile::tempdir().expect("temp dir");
+ let path = dir.path().join("connected-agents.json");
+ assert_eq!(load_connected_agents_at(&path).unwrap(), Vec::new());
+}
+
+#[test]
+fn records_round_trip_through_the_store() {
+ let dir = tempfile::tempdir().expect("temp dir");
+ let path = dir.path().join("connected-agents.json");
+ let records = vec![connected(CONNECTED_HEX, "Scout", "workstation")];
+
+ save_connected_agents_at(&path, &records).expect("save");
+ assert_eq!(load_connected_agents_at(&path).unwrap(), records);
+}
+
+#[test]
+fn a_first_save_creates_the_store_and_a_second_overwrites_it_atomically() {
+ // `atomic_write_json` canonicalizes its target to preserve a symlink, which
+ // needs the file to exist — so the create-then-write path is load-bearing on
+ // the very first connect, and a regression there would make connecting fail
+ // only on a machine that had never connected anything.
+ let dir = tempfile::tempdir().expect("temp dir");
+ let path = dir.path().join("connected-agents.json");
+
+ save_connected_agents_at(&path, &[connected(CONNECTED_HEX, "Scout", "workstation")])
+ .expect("first");
+ save_connected_agents_at(&path, &[connected(CONNECTED_HEX, "Scout", "buildbox")])
+ .expect("second");
+
+ let loaded = load_connected_agents_at(&path).unwrap();
+ assert_eq!(loaded.len(), 1);
+ assert_eq!(loaded[0].host, "buildbox");
+ assert!(
+ !path.with_extension("json.tmp").exists(),
+ "the atomic write must not leave its temp file behind"
+ );
+}
+
+#[test]
+fn records_are_sorted_for_stable_diffs() {
+ let dir = tempfile::tempdir().expect("temp dir");
+ let path = dir.path().join("connected-agents.json");
+ let records = vec![
+ connected(CONNECTED_HEX, "zeta", "workstation"),
+ connected(OWNED_HEX, "Alpha", "buildbox"),
+ ];
+
+ save_connected_agents_at(&path, &records).expect("save");
+
+ let names: Vec = load_connected_agents_at(&path)
+ .unwrap()
+ .into_iter()
+ .map(|record| record.name)
+ .collect();
+ assert_eq!(names, ["Alpha", "zeta"], "case-insensitive name order");
+}
+
+#[test]
+fn a_malformed_store_fails_loudly_and_preserves_the_evidence() {
+ // Matches `load_managed_agents`: a later in-app save rewrites this file
+ // wholesale, so swallowing a parse error into an empty list would silently
+ // destroy a hand edit.
+ let dir = tempfile::tempdir().expect("temp dir");
+ let path = dir.path().join("connected-agents.json");
+ fs::write(&path, b"{ not an array").expect("seed");
+
+ let error = load_connected_agents_at(&path).expect_err("a malformed store must not load as []");
+ assert!(error.contains(".invalid"), "message must name the backup");
+ assert!(
+ path.with_extension("json.invalid").exists(),
+ "the malformed content must survive for the user to recover"
+ );
+}
+
+#[test]
+fn a_connected_record_cannot_be_deserialized_as_a_managed_record() {
+ // The type boundary, stated as data. Even a future reader that pointed at
+ // the wrong file could not produce a `ManagedAgentRecord` from a connected
+ // row: the fields every lifecycle path needs are not merely empty, they are
+ // absent, so serde refuses. This is what replaces the custody filter — the
+ // old design's connected rows WERE `ManagedAgentRecord`s and deserialized
+ // happily, which is exactly why a missed filter was dangerous.
+ let record = connected(CONNECTED_HEX, "Scout", "workstation");
+ let json = serde_json::to_value(&record).unwrap();
+
+ let parsed = serde_json::from_value::(json);
+ assert!(
+ parsed.is_err(),
+ "a connected row must not satisfy ManagedAgentRecord"
+ );
+}
+
+#[test]
+fn a_managed_record_cannot_be_deserialized_as_a_connected_record() {
+ // The converse, and the reason `host` is a plain `String`: an owned agent's
+ // row has no host, so it cannot become a connected agent by being read out
+ // of the wrong file. If `host` were `Option` this would silently
+ // succeed and produce a connected agent that can never be probed.
+ let managed: serde_json::Value = serde_json::from_str(&managed_store_json()).unwrap();
+ let first = managed.as_array().unwrap()[0].clone();
+
+ let parsed = serde_json::from_value::(first);
+ assert!(
+ parsed.is_err(),
+ "an owned agent's row must not satisfy ConnectedAgentRecord"
+ );
+}
+
+#[test]
+fn the_two_stores_are_separate_files_and_a_connected_save_leaves_the_other_untouched() {
+ // The invariant that most needed a guard before. Under the shared-file
+ // design, `load_managed_agents` filtered connected rows out, so every one of
+ // the dozens of existing `load … mutate … save_managed_agents` call sites
+ // would have erased them without a deliberate re-read of the connected
+ // third. Separate files remove the failure mode rather than compensating for
+ // it: there is no shared payload to drop a half of.
+ let dir = tempfile::tempdir().expect("temp dir");
+ let managed_path = dir.path().join("managed-agents.json");
+ let connected_path = dir.path().join("connected-agents.json");
+ fs::write(&managed_path, managed_store_json()).expect("seed managed store");
+ let before = fs::read(&managed_path).expect("read managed store");
+
+ save_connected_agents_at(
+ &connected_path,
+ &[connected(CONNECTED_HEX, "Scout", "workstation")],
+ )
+ .expect("save connected");
+
+ assert_eq!(
+ fs::read(&managed_path).expect("re-read managed store"),
+ before,
+ "a connected save must not rewrite managed-agents.json at all"
+ );
+
+ // And the managed store still parses to exactly the agent it started with —
+ // no connected row leaked into the reader that feeds spawn and deploy.
+ let managed: Vec =
+ serde_json::from_slice(&fs::read(&managed_path).unwrap()).unwrap();
+ assert_eq!(managed.len(), 1);
+ assert_eq!(managed[0].pubkey, OWNED_HEX);
+ assert!(
+ managed.iter().all(|record| record.pubkey != CONNECTED_HEX),
+ "the connected agent must be invisible to the managed-agent reader"
+ );
+}
+
+#[test]
+fn a_connected_store_write_carries_no_secret_and_needs_no_restricted_mode() {
+ // `managed-agents.json` is written `0o600` because it can carry plaintext
+ // agent nsecs during a keyring outage. This store uses the ordinary write,
+ // which is only correct because the type cannot hold a secret — so assert
+ // the serialized bytes contain nothing key-shaped.
+ let dir = tempfile::tempdir().expect("temp dir");
+ let path = dir.path().join("connected-agents.json");
+ save_connected_agents_at(&path, &[connected(CONNECTED_HEX, "Scout", "workstation")])
+ .expect("save");
+
+ let raw = fs::read_to_string(&path).expect("read back");
+ for forbidden in ["nsec", "private_key", "auth_tag"] {
+ assert!(
+ !raw.contains(forbidden),
+ "{forbidden} must never appear in the connected store: {raw}"
+ );
+ }
+}
+
+#[test]
+fn the_summary_is_a_lossless_projection_of_the_record() {
+ // Both types exist so a future storage field is not automatically exposed to
+ // the UI. Today they carry the same six facts, and this pins that: if the
+ // record gains a field the summary should not have, this test still passes,
+ // but if the projection starts dropping or renaming one it fails.
+ let record = connected(CONNECTED_HEX, "Scout", "workstation");
+ let summary = ConnectedAgentSummary::from(&record);
+
+ assert_eq!(summary.pubkey, record.pubkey);
+ assert_eq!(summary.name, record.name);
+ assert_eq!(summary.host, record.host);
+ assert_eq!(summary.harness, record.harness);
+ assert_eq!(summary.community, record.community);
+ assert_eq!(summary.created_at, record.created_at);
+ assert_eq!(summary.updated_at, record.updated_at);
+}
+
+#[test]
+fn community_comparison_ignores_trailing_slashes_and_case() {
+ assert_eq!(
+ normalize_community_url(" wss://Relay.Example.com/ "),
+ "wss://relay.example.com"
+ );
+}
diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs
index 8d1b8a5013..4cafed1c9a 100644
--- a/desktop/src-tauri/src/managed_agents/discovery.rs
+++ b/desktop/src-tauri/src/managed_agents/discovery.rs
@@ -10,18 +10,25 @@ use crate::managed_agents::{
HarnessSource,
};
+mod known_runtimes;
mod presets;
+mod probe_targets;
mod runtime_metadata;
+pub(crate) use known_runtimes::KNOWN_ACP_RUNTIMES;
use presets::{preset_catalog_entry, PRESET_HARNESSES};
pub(crate) use presets::{preset_harness_definitions, preset_harness_ids};
+pub use probe_targets::{harness_probe_targets, HarnessProbeTarget};
+// The avatar URLs are only named directly by `tests.rs`, which asserts each
+// runtime resolves to its own icon; production code reaches them through
+// `KNOWN_ACP_RUNTIMES`. Re-exported here so the move out of this file stays
+// invisible to the test module.
+#[cfg(test)]
+pub(crate) use known_runtimes::{
+ BUZZ_AGENT_AVATAR_URL, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL,
+};
pub(crate) use runtime_metadata::KnownAcpRuntime;
-const GOOSE_AVATAR_URL: &str = "https://goose-docs.ai/img/logo_dark.png";
-const CLAUDE_CODE_AVATAR_URL: &str = "https://anthropic.gallerycdn.vsassets.io/extensions/anthropic/claude-code/2.1.77/1773707456892/Microsoft.VisualStudio.Services.Icons.Default";
-const CODEX_AVATAR_URL: &str = "https://openai.gallerycdn.vsassets.io/extensions/openai/chatgpt/26.5313.41514/1773706730621/Microsoft.VisualStudio.Services.Icons.Default";
-const BUZZ_AGENT_AVATAR_URL: &str =
- "https://raw.githubusercontent.com/block/buzz/refs/heads/main/crates/buzz-agent/buzz-agent.png";
fn common_binary_paths() -> &'static [PathBuf] {
static PATHS: OnceLock> = OnceLock::new();
PATHS.get_or_init(|| {
@@ -72,140 +79,6 @@ fn common_binary_paths() -> &'static [PathBuf] {
})
}
-const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[
- KnownAcpRuntime {
- id: "goose",
- label: "Goose",
- commands: &["goose"],
- aliases: &[],
- avatar_url: GOOSE_AVATAR_URL,
- mcp_command: None,
- mcp_hooks: false,
- underlying_cli: Some("goose"),
- cli_install_commands: &["curl -fsSL https://github.com/aaif-goose/goose/releases/download/stable/download_cli.sh | CONFIGURE=false bash"],
- // Goose's stable release currently publishes only the Unix installer;
- // its official Windows instructions intentionally point at this main-branch script.
- cli_install_commands_windows: &["powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"$env:CONFIGURE='false'; irm https://raw.githubusercontent.com/aaif-goose/goose/main/download_cli.ps1 | iex\""],
- adapter_install_commands: &[],
- cli_install_instructions_url: "https://goose-docs.ai/docs/getting-started/installation/",
- adapter_install_instructions_url: "",
- cli_install_hint: "Buzz talks to Goose through the Goose CLI.",
- adapter_install_hint: "",
- skill_dir: Some(".goose/skills"),
- supports_acp_model_switching: false,
- model_env_var: Some("GOOSE_MODEL"),
- provider_env_var: Some("GOOSE_PROVIDER"),
- provider_locked: false,
- default_env: &[("GOOSE_MODE", "auto")],
- config_file_path: Some("~/.config/goose/config.yaml"),
- config_file_format: Some("yaml"),
- supports_acp_native_config: true,
- thinking_env_var: Some("GOOSE_THINKING_EFFORT"),
- max_tokens_env_var: Some("GOOSE_MAX_TOKENS"),
- context_limit_env_var: Some("GOOSE_CONTEXT_LIMIT"),
- required_normalized_fields: &["model", "provider"],
- login_hint: None,
- auth_probe_args: None,
- },
- KnownAcpRuntime {
- id: "claude",
- label: "Claude Code",
- commands: &["claude-agent-acp", "claude-code-acp"],
- aliases: &["claude-code", "claudecode"],
- avatar_url: CLAUDE_CODE_AVATAR_URL,
- mcp_command: None,
- mcp_hooks: false,
- underlying_cli: Some("claude"),
- cli_install_commands: &["curl -fsSL https://claude.ai/install.sh | bash"],
- cli_install_commands_windows: &["powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"irm https://claude.ai/install.ps1 | iex\""],
- adapter_install_commands: &["npm install -g @agentclientprotocol/claude-agent-acp"],
- cli_install_instructions_url: "https://code.claude.com/docs/en/getting-started",
- adapter_install_instructions_url: "https://github.com/agentclientprotocol/claude-agent-acp",
- cli_install_hint: "Buzz talks to Claude Code through the Claude Code CLI.",
- adapter_install_hint: "Buzz talks to the Claude Code CLI through an ACP adapter. Install it with: npm install -g @agentclientprotocol/claude-agent-acp.",
- skill_dir: Some(".claude/skills"),
- supports_acp_model_switching: false,
- model_env_var: None,
- provider_env_var: None,
- provider_locked: true,
- default_env: &[],
- config_file_path: Some("~/.claude/settings.json"),
- config_file_format: Some("json"),
- supports_acp_native_config: false,
- thinking_env_var: None,
- max_tokens_env_var: None,
- context_limit_env_var: None,
- required_normalized_fields: &[],
- login_hint: Some("Run the Claude CLI to complete authentication."),
- auth_probe_args: Some(&["claude", "auth", "status"]),
- },
- KnownAcpRuntime {
- id: "codex",
- label: "Codex",
- commands: &["codex-acp"],
- aliases: &[],
- avatar_url: CODEX_AVATAR_URL,
- mcp_command: Some("buzz-dev-mcp"),
- mcp_hooks: false,
- underlying_cli: Some("codex"),
- cli_install_commands: &["curl -fsSL https://chatgpt.com/codex/install.sh | sh"],
- cli_install_commands_windows: &["powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"irm https://chatgpt.com/codex/install.ps1 | iex\""],
- adapter_install_commands: &["npm install -g @agentclientprotocol/codex-acp"],
- cli_install_instructions_url: "https://developers.openai.com/codex/cli/",
- adapter_install_instructions_url: "https://github.com/agentclientprotocol/codex-acp",
- cli_install_hint: "Buzz talks to Codex through the Codex CLI.",
- adapter_install_hint: "Buzz talks to the Codex CLI through an ACP adapter. Install it with: npm install -g @agentclientprotocol/codex-acp.",
- skill_dir: Some(".codex/skills"),
- supports_acp_model_switching: false,
- model_env_var: None,
- provider_env_var: None,
- provider_locked: false,
- default_env: &[],
- config_file_path: Some("~/.codex/config.toml"),
- config_file_format: Some("toml"),
- supports_acp_native_config: false,
- thinking_env_var: None,
- max_tokens_env_var: None,
- context_limit_env_var: None,
- required_normalized_fields: &[],
- login_hint: Some("Run `codex login` to authenticate."),
- // Verified: `codex login status` exits 0 when logged in, non-zero otherwise.
- auth_probe_args: Some(&["codex", "login", "status"]),
- },
- KnownAcpRuntime {
- id: "buzz-agent",
- label: "Buzz Agent",
- commands: &["buzz-agent"],
- aliases: &[],
- avatar_url: BUZZ_AGENT_AVATAR_URL,
- mcp_command: Some("buzz-dev-mcp"),
- mcp_hooks: true,
- underlying_cli: None,
- cli_install_commands: &[],
- cli_install_commands_windows: &[],
- adapter_install_commands: &[],
- cli_install_instructions_url: "https://github.com/block/buzz",
- adapter_install_instructions_url: "https://github.com/block/buzz",
- cli_install_hint: "Ships with the Buzz desktop app.",
- adapter_install_hint: "",
- skill_dir: None,
- supports_acp_model_switching: true,
- model_env_var: Some("BUZZ_AGENT_MODEL"),
- provider_env_var: Some("BUZZ_AGENT_PROVIDER"),
- provider_locked: false,
- default_env: &[],
- config_file_path: None,
- config_file_format: None,
- supports_acp_native_config: false,
- thinking_env_var: Some("BUZZ_AGENT_THINKING_EFFORT"),
- max_tokens_env_var: Some("BUZZ_AGENT_MAX_OUTPUT_TOKENS"),
- context_limit_env_var: Some("BUZZ_AGENT_MAX_CONTEXT_TOKENS"),
- required_normalized_fields: &["model", "provider"],
- login_hint: None,
- auth_probe_args: None,
- },
-];
-
/// Skill discovery directories declared by known runtimes.
pub(crate) fn known_skill_dirs() -> impl Iterator {
KNOWN_ACP_RUNTIMES.iter().filter_map(|p| p.skill_dir)
diff --git a/desktop/src-tauri/src/managed_agents/discovery/known_runtimes.rs b/desktop/src-tauri/src/managed_agents/discovery/known_runtimes.rs
new file mode 100644
index 0000000000..be294a7b33
--- /dev/null
+++ b/desktop/src-tauri/src/managed_agents/discovery/known_runtimes.rs
@@ -0,0 +1,149 @@
+//! Compiled-in metadata for the ACP runtimes Buzz knows how to discover.
+//!
+//! Split out of `discovery.rs` so the remote-probe work can grow the module
+//! without pushing the parent past the desktop file-size ratchet. This is a
+//! verbatim move: `KNOWN_ACP_RUNTIMES` and the avatar URLs that populate it
+//! are the single source of truth for both local discovery and the remote
+//! probe target list in `probe_targets.rs`.
+
+use super::KnownAcpRuntime;
+
+pub(crate) const GOOSE_AVATAR_URL: &str = "https://goose-docs.ai/img/logo_dark.png";
+pub(crate) const CLAUDE_CODE_AVATAR_URL: &str = "https://anthropic.gallerycdn.vsassets.io/extensions/anthropic/claude-code/2.1.77/1773707456892/Microsoft.VisualStudio.Services.Icons.Default";
+pub(crate) const CODEX_AVATAR_URL: &str = "https://openai.gallerycdn.vsassets.io/extensions/openai/chatgpt/26.5313.41514/1773706730621/Microsoft.VisualStudio.Services.Icons.Default";
+pub(crate) const BUZZ_AGENT_AVATAR_URL: &str =
+ "https://raw.githubusercontent.com/block/buzz/refs/heads/main/crates/buzz-agent/buzz-agent.png";
+
+pub(crate) const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[
+ KnownAcpRuntime {
+ id: "goose",
+ label: "Goose",
+ commands: &["goose"],
+ aliases: &[],
+ avatar_url: GOOSE_AVATAR_URL,
+ mcp_command: None,
+ mcp_hooks: false,
+ underlying_cli: Some("goose"),
+ cli_install_commands: &["curl -fsSL https://github.com/aaif-goose/goose/releases/download/stable/download_cli.sh | CONFIGURE=false bash"],
+ // Goose's stable release currently publishes only the Unix installer;
+ // its official Windows instructions intentionally point at this main-branch script.
+ cli_install_commands_windows: &["powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"$env:CONFIGURE='false'; irm https://raw.githubusercontent.com/aaif-goose/goose/main/download_cli.ps1 | iex\""],
+ adapter_install_commands: &[],
+ cli_install_instructions_url: "https://goose-docs.ai/docs/getting-started/installation/",
+ adapter_install_instructions_url: "",
+ cli_install_hint: "Buzz talks to Goose through the Goose CLI.",
+ adapter_install_hint: "",
+ skill_dir: Some(".goose/skills"),
+ supports_acp_model_switching: false,
+ model_env_var: Some("GOOSE_MODEL"),
+ provider_env_var: Some("GOOSE_PROVIDER"),
+ provider_locked: false,
+ default_env: &[("GOOSE_MODE", "auto")],
+ config_file_path: Some("~/.config/goose/config.yaml"),
+ config_file_format: Some("yaml"),
+ supports_acp_native_config: true,
+ thinking_env_var: Some("GOOSE_THINKING_EFFORT"),
+ max_tokens_env_var: Some("GOOSE_MAX_TOKENS"),
+ context_limit_env_var: Some("GOOSE_CONTEXT_LIMIT"),
+ required_normalized_fields: &["model", "provider"],
+ login_hint: None,
+ auth_probe_args: None,
+ },
+ KnownAcpRuntime {
+ id: "claude",
+ label: "Claude Code",
+ commands: &["claude-agent-acp", "claude-code-acp"],
+ aliases: &["claude-code", "claudecode"],
+ avatar_url: CLAUDE_CODE_AVATAR_URL,
+ mcp_command: None,
+ mcp_hooks: false,
+ underlying_cli: Some("claude"),
+ cli_install_commands: &["curl -fsSL https://claude.ai/install.sh | bash"],
+ cli_install_commands_windows: &["powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"irm https://claude.ai/install.ps1 | iex\""],
+ adapter_install_commands: &["npm install -g @agentclientprotocol/claude-agent-acp"],
+ cli_install_instructions_url: "https://code.claude.com/docs/en/getting-started",
+ adapter_install_instructions_url: "https://github.com/agentclientprotocol/claude-agent-acp",
+ cli_install_hint: "Buzz talks to Claude Code through the Claude Code CLI.",
+ adapter_install_hint: "Buzz talks to the Claude Code CLI through an ACP adapter. Install it with: npm install -g @agentclientprotocol/claude-agent-acp.",
+ skill_dir: Some(".claude/skills"),
+ supports_acp_model_switching: false,
+ model_env_var: None,
+ provider_env_var: None,
+ provider_locked: true,
+ default_env: &[],
+ config_file_path: Some("~/.claude/settings.json"),
+ config_file_format: Some("json"),
+ supports_acp_native_config: false,
+ thinking_env_var: None,
+ max_tokens_env_var: None,
+ context_limit_env_var: None,
+ required_normalized_fields: &[],
+ login_hint: Some("Run the Claude CLI to complete authentication."),
+ auth_probe_args: Some(&["claude", "auth", "status"]),
+ },
+ KnownAcpRuntime {
+ id: "codex",
+ label: "Codex",
+ commands: &["codex-acp"],
+ aliases: &[],
+ avatar_url: CODEX_AVATAR_URL,
+ mcp_command: Some("buzz-dev-mcp"),
+ mcp_hooks: false,
+ underlying_cli: Some("codex"),
+ cli_install_commands: &["curl -fsSL https://chatgpt.com/codex/install.sh | sh"],
+ cli_install_commands_windows: &["powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"irm https://chatgpt.com/codex/install.ps1 | iex\""],
+ adapter_install_commands: &["npm install -g @agentclientprotocol/codex-acp"],
+ cli_install_instructions_url: "https://developers.openai.com/codex/cli/",
+ adapter_install_instructions_url: "https://github.com/agentclientprotocol/codex-acp",
+ cli_install_hint: "Buzz talks to Codex through the Codex CLI.",
+ adapter_install_hint: "Buzz talks to the Codex CLI through an ACP adapter. Install it with: npm install -g @agentclientprotocol/codex-acp.",
+ skill_dir: Some(".codex/skills"),
+ supports_acp_model_switching: false,
+ model_env_var: None,
+ provider_env_var: None,
+ provider_locked: false,
+ default_env: &[],
+ config_file_path: Some("~/.codex/config.toml"),
+ config_file_format: Some("toml"),
+ supports_acp_native_config: false,
+ thinking_env_var: None,
+ max_tokens_env_var: None,
+ context_limit_env_var: None,
+ required_normalized_fields: &[],
+ login_hint: Some("Run `codex login` to authenticate."),
+ // Verified: `codex login status` exits 0 when logged in, non-zero otherwise.
+ auth_probe_args: Some(&["codex", "login", "status"]),
+ },
+ KnownAcpRuntime {
+ id: "buzz-agent",
+ label: "Buzz Agent",
+ commands: &["buzz-agent"],
+ aliases: &[],
+ avatar_url: BUZZ_AGENT_AVATAR_URL,
+ mcp_command: Some("buzz-dev-mcp"),
+ mcp_hooks: true,
+ underlying_cli: None,
+ cli_install_commands: &[],
+ cli_install_commands_windows: &[],
+ adapter_install_commands: &[],
+ cli_install_instructions_url: "https://github.com/block/buzz",
+ adapter_install_instructions_url: "https://github.com/block/buzz",
+ cli_install_hint: "Ships with the Buzz desktop app.",
+ adapter_install_hint: "",
+ skill_dir: None,
+ supports_acp_model_switching: true,
+ model_env_var: Some("BUZZ_AGENT_MODEL"),
+ provider_env_var: Some("BUZZ_AGENT_PROVIDER"),
+ provider_locked: false,
+ default_env: &[],
+ config_file_path: None,
+ config_file_format: None,
+ supports_acp_native_config: false,
+ thinking_env_var: Some("BUZZ_AGENT_THINKING_EFFORT"),
+ max_tokens_env_var: Some("BUZZ_AGENT_MAX_OUTPUT_TOKENS"),
+ context_limit_env_var: Some("BUZZ_AGENT_MAX_CONTEXT_TOKENS"),
+ required_normalized_fields: &["model", "provider"],
+ login_hint: None,
+ auth_probe_args: None,
+ },
+];
diff --git a/desktop/src-tauri/src/managed_agents/discovery/presets.rs b/desktop/src-tauri/src/managed_agents/discovery/presets.rs
index 72c4657dc7..e1de9396be 100644
--- a/desktop/src-tauri/src/managed_agents/discovery/presets.rs
+++ b/desktop/src-tauri/src/managed_agents/discovery/presets.rs
@@ -10,17 +10,17 @@ use super::normalize_agent_args;
/// Static data for a well-known tier-2 ACP harness.
pub(super) struct PresetHarness {
pub(super) id: &'static str,
- label: &'static str,
- command: &'static str,
- args: &'static [&'static str],
- install_instructions_url: &'static str,
- install_hint: &'static str,
+ pub(super) label: &'static str,
+ pub(super) command: &'static str,
+ pub(super) args: &'static [&'static str],
+ pub(super) install_instructions_url: &'static str,
+ pub(super) install_hint: &'static str,
/// Vendor CLI the ACP command wraps, when the preset is an adapter.
///
/// Consulted only when the adapter is absent, so `AdapterMissing` replaces
/// `NotInstalled` when the CLI is present but the adapter is not. `None`
/// when the command is itself the vendor CLI.
- underlying_cli: Option<&'static str>,
+ pub(super) underlying_cli: Option<&'static str>,
}
/// Build one preset catalog entry through an injectable command resolver.
diff --git a/desktop/src-tauri/src/managed_agents/discovery/probe_targets.rs b/desktop/src-tauri/src/managed_agents/discovery/probe_targets.rs
new file mode 100644
index 0000000000..c6a58bc9ba
--- /dev/null
+++ b/desktop/src-tauri/src/managed_agents/discovery/probe_targets.rs
@@ -0,0 +1,77 @@
+//! What to look for when probing another machine for agent harnesses.
+//!
+//! A child module of `discovery` rather than new lines inside `discovery.rs`:
+//! that file is already over the desktop 1000-line limit and carries a
+//! documented "queued to be split" override, so new surface goes beside it. As
+//! a child it still sees `discovery`'s private tables directly, so nothing had
+//! to be made more visible to accommodate the move.
+//!
+//! The projection direction matters. These targets are derived from the same
+//! compiled-in tables local discovery uses (`KNOWN_ACP_RUNTIMES` and
+//! `PRESET_HARNESSES`), never from a parallel list. A hand-maintained set of
+//! "harnesses we can find remotely" would drift the moment a preset is added —
+//! the exact failure `preset_harness_ids()` already exists to prevent.
+
+use super::{KNOWN_ACP_RUNTIMES, PRESET_HARNESSES};
+use crate::managed_agents::types::HarnessSource;
+
+/// One harness's probe target set, projected from the compiled-in tables.
+///
+/// Remote discovery needs to know *what to look for* on another machine. That
+/// set must come from the same tables local discovery uses — a second,
+/// hand-maintained list of harnesses would drift the moment a preset is added,
+/// which is the failure mode `preset_harness_ids()` already exists to prevent.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct HarnessProbeTarget {
+ pub id: &'static str,
+ pub label: &'static str,
+ /// ACP command basenames to look for, in preference order. The first one
+ /// found on the remote host wins.
+ pub acp_commands: &'static [&'static str],
+ /// Vendor CLI the ACP command wraps, when the harness is an adapter.
+ /// `None` when the ACP command *is* the vendor CLI.
+ pub underlying_cli: Option<&'static str>,
+ pub install_hint: &'static str,
+ pub install_instructions_url: &'static str,
+ pub source: HarnessSource,
+}
+
+/// Every harness a remote host can be probed for: the four builtins plus every
+/// bundled preset.
+///
+/// Custom (tier-3) harnesses are deliberately excluded. Their definitions live
+/// in the *local* user's `custom_harnesses/` directory and describe commands on
+/// the local machine; projecting them onto a remote host would assert a layout
+/// nothing has verified. A user who wants a custom harness discovered remotely
+/// is better served by it becoming a preset.
+pub fn harness_probe_targets() -> Vec {
+ let mut targets: Vec = KNOWN_ACP_RUNTIMES
+ .iter()
+ .map(|runtime| HarnessProbeTarget {
+ id: runtime.id,
+ label: runtime.label,
+ acp_commands: runtime.commands,
+ underlying_cli: runtime.underlying_cli,
+ // Builtins carry separate CLI and adapter hints. The CLI hint is the
+ // useful one for a remote host: an absent adapter is only reachable
+ // once the vendor CLI it wraps is present.
+ install_hint: runtime.cli_install_hint,
+ install_instructions_url: runtime.cli_install_instructions_url,
+ source: HarnessSource::Builtin,
+ })
+ .collect();
+
+ targets.extend(PRESET_HARNESSES.iter().map(|preset| HarnessProbeTarget {
+ id: preset.id,
+ label: preset.label,
+ // A preset's `command` is the binary; its `args` are how it is invoked.
+ // Only the binary is probeable, matching the local PATH probe.
+ acp_commands: std::slice::from_ref(&preset.command),
+ underlying_cli: preset.underlying_cli,
+ install_hint: preset.install_hint,
+ install_instructions_url: preset.install_instructions_url,
+ source: HarnessSource::Preset,
+ }));
+
+ targets
+}
diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs
index be9b07cf11..2c3acb7840 100644
--- a/desktop/src-tauri/src/managed_agents/mod.rs
+++ b/desktop/src-tauri/src/managed_agents/mod.rs
@@ -7,6 +7,7 @@ pub(crate) use agent_env::{
};
mod backend;
pub(crate) mod config_bridge;
+mod connected_agents;
pub(crate) mod custom_harnesses;
mod discovery;
pub(crate) mod effective_config;
@@ -23,6 +24,7 @@ mod process_lifecycle;
pub(crate) mod readiness;
pub(crate) mod reconcile;
mod relay_mesh;
+pub mod remote_probe;
mod repos;
mod restore;
pub mod retention;
@@ -31,6 +33,7 @@ mod runtime_commands;
mod runtime_types;
pub(crate) mod snapshot_avatar;
pub(crate) mod spawn_hash;
+pub mod ssh_config;
pub(crate) mod storage;
pub(crate) mod team_events;
mod team_repair;
@@ -47,6 +50,10 @@ pub(crate) fn lock_path_mutex() -> std::sync::MutexGuard<'static, ()> {
}
pub use backend::*;
+pub(crate) use connected_agents::{
+ load_connected_agents, normalize_community_url, save_connected_agents, ConnectedAgentRecord,
+ ConnectedAgentSummary,
+};
pub use discovery::*;
pub use env_vars::*;
#[cfg(windows)]
diff --git a/desktop/src-tauri/src/managed_agents/remote_probe.rs b/desktop/src-tauri/src/managed_agents/remote_probe.rs
new file mode 100644
index 0000000000..e2c582a72c
--- /dev/null
+++ b/desktop/src-tauri/src/managed_agents/remote_probe.rs
@@ -0,0 +1,649 @@
+//! Host-aware harness discovery.
+//!
+//! Local discovery (`discover_acp_runtimes_from`) answers "which harnesses are
+//! on *this* machine?" This module answers it for any host in the user's
+//! `~/.ssh/config`, so an agent that already runs on another machine can be
+//! found rather than described by hand.
+//!
+//! # Design constraints, learned the hard way
+//!
+//! * **The probe script is a constant.** No user input is interpolated into it,
+//! so single-quoting it into the `ssh` argv is safe by construction rather
+//! than by careful escaping. Host and port reach `ssh` as separate argv
+//! entries, never through the shell.
+//! * **It runs under `exec $SHELL -lc` — login, but NOT interactive.** Harness
+//! binaries live in npm-global, homebrew, pyenv, and venv prefixes that a
+//! *login* shell puts on `PATH`, so `-l` is required. `-i` is not, and is
+//! actively harmful: an interactive shell sources `.zshrc`/`.bashrc`, which is
+//! where prompt frameworks, completion init, and autosuggestion plugins live.
+//! Several of those block forever without a TTY. Verified against a real macOS
+//! `/bin/zsh` host: `-lic` hung indefinitely and had to be killed, while
+//! `-lc` returned the complete binary set including a Python venv prefix.
+//! A probe that hangs is worse than one that misses a path, because it turns a
+//! healthy host into a timeout.
+//! * **The `for` list is a flat set of binary names.** Harness identity is
+//! reattached afterwards, in Rust, by matching resolved binaries back to the
+//! probe targets. Encoding `harness=binary` pairs in the shell loop instead
+//! would put a delimiter inside a `for … in` list, and the obvious choice
+//! (`|`) is a parse error in both bash and zsh that kills the loop before it
+//! runs. Keeping the shell dumb avoids the question entirely.
+//! * **`BatchMode=yes`, and a password wall is a status, not a prompt.** Buzz
+//! never collects or stores an SSH password. A host that offers only
+//! interactive auth is reported as such, with the fix (install a key) in the
+//! message.
+//! * **Local and remote return the same shape.** `probe_localhost` runs the
+//! identical script, so nothing downstream needs a special case for "this
+//! machine".
+
+use std::collections::{BTreeMap, BTreeSet};
+use std::process::Command;
+use std::time::{Duration, Instant};
+
+use serde::Serialize;
+
+use crate::managed_agents::discovery::{harness_probe_targets, HarnessProbeTarget};
+use crate::managed_agents::ssh_config::{resolve_ssh_binary, SshHost};
+use crate::managed_agents::HarnessSource;
+
+/// Sentinel that brackets the probe's own output.
+///
+/// A login shell may print motd banners, shell-init chatter, or warnings before
+/// and after our commands. Without a delimiter those lines get parsed as
+/// results; with one, everything outside the markers is discarded.
+const PROBE_START: &str = "---BUZZ-PROBE-START---";
+const PROBE_END: &str = "---BUZZ-PROBE-END---";
+
+/// Wall-clock ceiling for a single host probe. A wedged host must not be able
+/// to hold the caller open — the UI renders one row per host and a single
+/// unresponsive machine would otherwise stall the whole list.
+const PROBE_TIMEOUT: Duration = Duration::from_secs(20);
+
+/// `ssh` connect timeout, kept well under [`PROBE_TIMEOUT`] so an unreachable
+/// host fails through ssh's own error path (which yields a useful message)
+/// rather than our blunt kill path.
+const SSH_CONNECT_TIMEOUT_SECS: u32 = 6;
+
+/// Per-binary ceiling for a `--version` call on the probed host.
+///
+/// A version string is informational; a hung `--version` is not. Observed on a
+/// real host: `claude --version` never returned, which truncated the probe and
+/// silently hid every harness later in the loop. Bounding each call means a
+/// broken or first-run binary costs one `unknown` version instead of the whole
+/// result.
+///
+/// Kept small because it multiplies: worst case is roughly this value times the
+/// number of harnesses that both exist and hang, and it must stay well inside
+/// [`PROBE_TIMEOUT`].
+const VERSION_TIMEOUT_SECS: u32 = 3;
+
+/// Why a probe failed, when the cause is actionable.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum HostProbeErrorKind {
+ /// The host offered only password / keyboard-interactive auth, which a
+ /// `BatchMode` probe cannot satisfy and Buzz will not collect.
+ PasswordRequired,
+ /// The host key is unknown or changed — a trust decision the user must make
+ /// outside Buzz.
+ HostKeyProblem,
+ /// Name resolution or the TCP connection failed.
+ Unreachable,
+ /// The probe exceeded [`PROBE_TIMEOUT`].
+ TimedOut,
+ /// The probe started but its output stopped before the closing marker, so
+ /// the facts gathered are an unknown fraction of the real ones.
+ Truncated,
+}
+
+/// One harness found on a probed host.
+///
+/// Deliberately narrower than the local `AcpRuntimeCatalogEntry`. That type
+/// carries `can_auto_install`, `node_required`, and `auth_status`, all of which
+/// describe actions Buzz performs on the local machine. Buzz does not install
+/// software on, or authenticate CLIs on, someone else's host — reusing the local
+/// shape would mean fabricating those fields, and the UI would then offer
+/// buttons that cannot work.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct RemoteHarness {
+ pub id: String,
+ pub label: String,
+ pub source: HarnessSource,
+ /// Resolved absolute path of the ACP command on the remote host.
+ pub acp_command_path: Option,
+ /// The ACP command basename that resolved, for building a run command.
+ pub acp_command: Option,
+ /// Version string the ACP command reported, when it reported one.
+ pub version: Option,
+ /// Resolved path of the vendor CLI this harness wraps, when it wraps one.
+ pub underlying_cli_path: Option,
+ /// True when the harness is usable on this host: its ACP command resolved,
+ /// and any vendor CLI it wraps also resolved.
+ pub ready: bool,
+ pub install_hint: String,
+ pub install_instructions_url: String,
+}
+
+/// Result of probing one host.
+#[derive(Debug, Clone, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct HostProbeResult {
+ /// The `ssh` alias probed, or [`LOCALHOST_ID`] for this machine.
+ pub host: String,
+ pub ok: bool,
+ pub duration_ms: u64,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub error: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub error_kind: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub user: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub hostname: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub os: Option,
+ /// Path of the `buzz` CLI on the host. A connected agent needs it to reach
+ /// the relay, so its absence is the single most useful thing to surface.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub buzz_cli_path: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub buzz_cli_version: Option,
+ pub harnesses: Vec,
+}
+
+/// Host id used for the local machine, so it can sit in the same list as ssh
+/// aliases without colliding with one (`localhost` is a legal alias, this is
+/// not).
+pub const LOCALHOST_ID: &str = "__localhost__";
+
+/// Build the probe script for a target set.
+///
+/// Returns a script containing only literals derived from the compiled-in
+/// harness tables — never user input. Callers must not append anything to it.
+fn build_probe_script(targets: &[HarnessProbeTarget]) -> String {
+ // Every ACP command basename across all harnesses, plus every vendor CLI,
+ // plus `buzz`. Sorted and deduped so the emitted script is deterministic
+ // (which makes it cacheable and makes test assertions stable).
+ let mut binaries: BTreeSet<&str> = BTreeSet::new();
+ for target in targets {
+ for command in target.acp_commands {
+ binaries.insert(command);
+ }
+ if let Some(cli) = target.underlying_cli {
+ binaries.insert(cli);
+ }
+ }
+ binaries.insert("buzz");
+
+ let binary_list = binaries.into_iter().collect::>().join(" ");
+
+ // `command -v` rather than `which`: it is a POSIX shell builtin, present
+ // even on minimal images, and does not depend on an external binary that
+ // may itself be missing.
+ //
+ // Each `--version` call is individually time-bounded. This is not
+ // defensive padding — a real harness binary was observed hanging forever on
+ // `--version` on a real host (a `claude` install on macOS), which truncated
+ // the whole probe: every harness after it in the loop went unreported and
+ // the trailing sentinel never printed, so the result looked like a
+ // half-provisioned machine rather than a stuck command.
+ //
+ // The bound is hand-rolled because `timeout(1)` is not portable — it is
+ // absent from a stock macOS, which is precisely where the hang was found.
+ // Shape: run the version command in the background, run a killer in the
+ // background, then `wait` for the version command. The killer's stdout is
+ // closed, which matters — otherwise it holds the command substitution's
+ // pipe open for the full sleep and every binary would cost
+ // `VERSION_TIMEOUT` even when it answered instantly.
+ //
+ // `/dev/null)
+ if [ -n "$bin" ]; then
+ ver=$( {{ "$tool" --version /dev/null & vp=$!; {{ sleep {version_timeout}; kill -9 $vp; }} >/dev/null 2>&1 & kp=$!; wait $vp; kill -9 $kp; }} 2>/dev/null | head -1 | tr -d "\"\047" | tr -d "\r" )
+ echo "BIN:$tool:$bin:${{ver:-unknown}}"
+ fi
+done
+echo "USER:$USER"
+echo "HOST:$(hostname -s 2>/dev/null)"
+echo "OS:$(uname -s 2>/dev/null)"
+echo "{PROBE_END}"
+'"#,
+ version_timeout = VERSION_TIMEOUT_SECS
+ )
+}
+
+/// Facts a single probe run recovered from the host.
+#[derive(Debug, Default)]
+struct ProbeFacts {
+ /// binary basename → (resolved path, version)
+ binaries: BTreeMap)>,
+ user: Option,
+ hostname: Option,
+ os: Option,
+}
+
+/// Parse probe stdout, ignoring everything outside the sentinels.
+fn parse_probe_output(raw: &str) -> ProbeFacts {
+ let mut facts = ProbeFacts::default();
+ let mut inside = false;
+
+ for line in raw.lines() {
+ if line.contains(PROBE_START) {
+ inside = true;
+ continue;
+ }
+ if line.contains(PROBE_END) {
+ inside = false;
+ continue;
+ }
+ if !inside {
+ continue;
+ }
+
+ if let Some(rest) = line.strip_prefix("BIN:") {
+ // `BIN:::` — the version may itself contain
+ // colons, so split into at most 3 pieces and keep the remainder
+ // whole. The path may not contain a colon, which holds for every
+ // real install prefix.
+ let mut parts = rest.splitn(3, ':');
+ let (Some(tool), Some(path)) = (parts.next(), parts.next()) else {
+ continue;
+ };
+ let version = parts
+ .next()
+ .map(str::trim)
+ .filter(|v| !v.is_empty() && *v != "unknown")
+ .map(str::to_string);
+ let tool = tool.trim();
+ let path = path.trim();
+ if tool.is_empty() || path.is_empty() {
+ continue;
+ }
+ facts
+ .binaries
+ .insert(tool.to_string(), (path.to_string(), version));
+ } else if let Some(rest) = line.strip_prefix("USER:") {
+ facts.user = non_empty(rest);
+ } else if let Some(rest) = line.strip_prefix("HOST:") {
+ facts.hostname = non_empty(rest);
+ } else if let Some(rest) = line.strip_prefix("OS:") {
+ facts.os = non_empty(rest);
+ }
+ }
+
+ facts
+}
+
+fn non_empty(value: &str) -> Option {
+ let trimmed = value.trim();
+ (!trimmed.is_empty()).then(|| trimmed.to_string())
+}
+
+/// Assemble harness entries from probe facts.
+///
+/// Shared by the ssh and localhost paths so both produce identical shapes.
+fn assemble_harnesses(facts: &ProbeFacts, targets: &[HarnessProbeTarget]) -> Vec {
+ targets
+ .iter()
+ .map(|target| {
+ // First listed ACP command that resolved wins, matching the local
+ // catalog's preference-order semantics.
+ let found = target
+ .acp_commands
+ .iter()
+ .find_map(|cmd| facts.binaries.get(*cmd).map(|hit| (*cmd, hit)));
+
+ let underlying_cli_path = target
+ .underlying_cli
+ .and_then(|cli| facts.binaries.get(cli))
+ .map(|(path, _)| path.clone());
+
+ // A harness is ready only if its ACP command exists AND, when it is
+ // an adapter, the vendor CLI it wraps exists too. An adapter without
+ // its CLI starts and then fails at first use, so reporting it as
+ // ready would be worse than reporting it missing.
+ let ready = found.is_some()
+ && (target.underlying_cli.is_none() || underlying_cli_path.is_some());
+
+ RemoteHarness {
+ id: target.id.to_string(),
+ label: target.label.to_string(),
+ source: target.source.clone(),
+ acp_command: found.map(|(cmd, _)| cmd.to_string()),
+ acp_command_path: found.map(|(_, (path, _))| path.clone()),
+ version: found.and_then(|(_, (_, version))| version.clone()),
+ underlying_cli_path,
+ ready,
+ install_hint: target.install_hint.to_string(),
+ install_instructions_url: target.install_instructions_url.to_string(),
+ }
+ })
+ .collect()
+}
+
+/// Classify ssh's stderr into an actionable cause.
+///
+/// Raw ssh stderr is accurate but unhelpful in a UI; these are the cases where
+/// naming the cause tells the user what to actually do.
+pub fn classify_ssh_failure(stderr: &str) -> Option {
+ let lower = stderr.to_ascii_lowercase();
+
+ // A denial listing password or keyboard-interactive means the host wants
+ // interactive auth. A bare `(publickey)` denial is NOT this case — that is a
+ // missing or rejected key, where the raw message is the more honest report.
+ if let Some(start) = lower.find("permission denied") {
+ let tail = &lower[start..];
+ if let (Some(open), Some(close)) = (tail.find('('), tail.find(')')) {
+ if open < close {
+ let methods = &tail[open + 1..close];
+ if methods.contains("password") || methods.contains("keyboard-interactive") {
+ return Some(HostProbeErrorKind::PasswordRequired);
+ }
+ }
+ }
+ }
+
+ if lower.contains("host key verification failed")
+ || lower.contains("remote host identification has changed")
+ // Emitted by `StrictHostKeyChecking=yes` for a first-seen host. Matched
+ // in its own right because it is the line that names the actual cause;
+ // relying only on the generic "verification failed" that follows it
+ // would leave an unknown key indistinguishable from a changed one.
+ || lower.contains("you have requested strict checking")
+ {
+ return Some(HostProbeErrorKind::HostKeyProblem);
+ }
+
+ if lower.contains("could not resolve hostname")
+ || lower.contains("name or service not known")
+ || lower.contains("connection refused")
+ || lower.contains("connection timed out")
+ || lower.contains("no route to host")
+ || lower.contains("network is unreachable")
+ || lower.contains("operation timed out")
+ {
+ return Some(HostProbeErrorKind::Unreachable);
+ }
+
+ None
+}
+
+/// Human-facing message for a classified failure, including the remedy.
+fn failure_message(kind: &HostProbeErrorKind, host: &str, stderr: &str) -> String {
+ match kind {
+ HostProbeErrorKind::PasswordRequired => format!(
+ "'{host}' accepts only password login. Buzz never stores SSH passwords — \
+ set up key-based access instead (for example `ssh-copy-id {host}`), or add \
+ an IdentityFile for this host in ~/.ssh/config."
+ ),
+ // A changed key and a first-seen key are both refused, but they are not
+ // the same news: one is routine setup, the other is the warning ssh
+ // exists to give. Reporting them identically would train the user to
+ // dismiss the serious one.
+ HostProbeErrorKind::HostKeyProblem
+ if stderr
+ .to_ascii_lowercase()
+ .contains("remote host identification has changed") =>
+ {
+ format!(
+ "The host key for '{host}' has CHANGED since it was last trusted. This can mean \
+ the host was rebuilt — or that the connection is being intercepted. Buzz will \
+ not probe it. Verify the new key out of band before touching known_hosts."
+ )
+ }
+ HostProbeErrorKind::HostKeyProblem => format!(
+ "The host key for '{host}' is not yet trusted on this machine. Buzz does not accept \
+ host keys on your behalf — connect once with `ssh {host}`, check the fingerprint, \
+ then probe again."
+ ),
+ HostProbeErrorKind::Unreachable => {
+ format!("'{host}' is not reachable: {}", first_line(stderr))
+ }
+ HostProbeErrorKind::TimedOut => format!(
+ "Probing '{host}' exceeded {}s and was cancelled.",
+ PROBE_TIMEOUT.as_secs()
+ ),
+ HostProbeErrorKind::Truncated => format!(
+ "The probe of '{host}' was cut off before it finished. What it found is incomplete, \
+ so it is not being reported. Check the connection to '{host}' and probe again."
+ ),
+ }
+}
+
+fn first_line(text: &str) -> String {
+ text.lines()
+ .map(str::trim)
+ .find(|line| !line.is_empty())
+ .unwrap_or("no error output")
+ .to_string()
+}
+
+/// Probe one ssh host for harnesses and the `buzz` CLI.
+///
+/// Never returns `Err` for a *host-side* problem: an unreachable or
+/// unauthenticated host is a normal, reportable outcome, and the caller renders
+/// one row per host regardless. `Err` is reserved for a failure to run `ssh` at
+/// all.
+pub fn probe_ssh_host(host: &SshHost) -> HostProbeResult {
+ let started = Instant::now();
+ let targets = harness_probe_targets();
+ let script = build_probe_script(&targets);
+
+ let mut command = Command::new(resolve_ssh_binary());
+ command.args(ssh_probe_args(host)).arg(&script);
+
+ run_probe(command, &host.host, &targets, started)
+}
+
+/// The `ssh` arguments preceding the probe script, ending with the host alias.
+///
+/// Split out so the trust-affecting options are assertable: nothing else in this
+/// module consults `known_hosts`, so whether Buzz can alter the user's trust
+/// state is decided entirely by this list.
+fn ssh_probe_args(host: &SshHost) -> Vec {
+ let mut args = vec![
+ "-o".to_string(),
+ format!("ConnectTimeout={SSH_CONNECT_TIMEOUT_SECS}"),
+ // Never prompt. A probe that blocks on a password prompt would hang the
+ // UI with no way for the user to see or answer it.
+ "-o".to_string(),
+ "BatchMode=yes".to_string(),
+ // Reject an unknown key as well as a changed one. `accept-new` would
+ // write a first-seen key into the user's `known_hosts` as a side effect
+ // of opening a dialog and clicking Probe — Buzz would be making a trust
+ // decision, and persisting it, on their behalf. Both cases are a
+ // reportable status here; the user grants trust with `ssh `, where
+ // they see the fingerprint and answer for themselves.
+ "-o".to_string(),
+ "StrictHostKeyChecking=yes".to_string(),
+ // Suppress banners so parsing has less to discard.
+ "-o".to_string(),
+ "LogLevel=ERROR".to_string(),
+ ];
+ if let Some(port) = &host.port {
+ args.push("-p".to_string());
+ args.push(port.clone());
+ }
+ // The alias, not `user@hostname`: the alias is what carries the user's own
+ // ssh config (User, IdentityFile, ProxyJump, and anything else we do not
+ // model). Rebuilding a user@host string would discard all of it.
+ args.push(host.host.clone());
+ args
+}
+
+/// Probe the machine Buzz is running on, using the identical script.
+pub fn probe_localhost() -> HostProbeResult {
+ let started = Instant::now();
+ let targets = harness_probe_targets();
+ let script = build_probe_script(&targets);
+
+ let mut command = Command::new("/bin/sh");
+ command.arg("-c").arg(&script);
+
+ run_probe(command, LOCALHOST_ID, &targets, started)
+}
+
+/// Execute a prepared probe command and shape its outcome.
+fn run_probe(
+ mut command: Command,
+ host: &str,
+ targets: &[HarnessProbeTarget],
+ started: Instant,
+) -> HostProbeResult {
+ command
+ .stdin(std::process::Stdio::null())
+ .stdout(std::process::Stdio::piped())
+ .stderr(std::process::Stdio::piped());
+
+ let base = |ok: bool| HostProbeResult {
+ host: host.to_string(),
+ ok,
+ duration_ms: started.elapsed().as_millis() as u64,
+ error: None,
+ error_kind: None,
+ user: None,
+ hostname: None,
+ os: None,
+ buzz_cli_path: None,
+ buzz_cli_version: None,
+ harnesses: Vec::new(),
+ };
+
+ let output = match wait_with_timeout(command, PROBE_TIMEOUT) {
+ Ok(Some(output)) => output,
+ Ok(None) => {
+ let kind = HostProbeErrorKind::TimedOut;
+ return HostProbeResult {
+ error: Some(failure_message(&kind, host, "")),
+ error_kind: Some(kind),
+ ..base(false)
+ };
+ }
+ Err(err) => {
+ return HostProbeResult {
+ error: Some(format!("could not run probe for '{host}': {err}")),
+ error_kind: None,
+ ..base(false)
+ };
+ }
+ };
+
+ let stderr = String::from_utf8_lossy(&output.stderr).to_string();
+ let stdout = String::from_utf8_lossy(&output.stdout).to_string();
+
+ // Success is "the probe produced its own output", not "exit code 0". A login
+ // shell can exit non-zero because of an unrelated rc-file quirk while still
+ // having run every command we asked for; discarding that would report a
+ // healthy host as broken.
+ if !stdout.contains(PROBE_START) {
+ let kind = classify_ssh_failure(&stderr);
+ let message = match &kind {
+ Some(kind) => failure_message(kind, host, &stderr),
+ None => {
+ let detail = first_line(&stderr);
+ format!("probe of '{host}' produced no output: {detail}")
+ }
+ };
+ return HostProbeResult {
+ error: Some(message),
+ error_kind: kind,
+ ..base(false)
+ };
+ }
+
+ // Both markers, not just the opening one. The script emits PROBE_END as its
+ // last statement, so its absence means the session died partway through the
+ // harness loop — and `parse_probe_output` cannot tell that from a host that
+ // genuinely has no `openclaw` installed. Reporting `ok: true` there would
+ // present "this harness is missing" and "we never got to look" as the same
+ // answer, and the connect dialog would offer a harness list that is missing
+ // entries for no visible reason.
+ if !stdout.contains(PROBE_END) {
+ let kind = HostProbeErrorKind::Truncated;
+ return HostProbeResult {
+ error: Some(failure_message(&kind, host, &stderr)),
+ error_kind: Some(kind),
+ ..base(false)
+ };
+ }
+
+ let facts = parse_probe_output(&stdout);
+ let harnesses = assemble_harnesses(&facts, targets);
+ let buzz = facts.binaries.get("buzz");
+
+ HostProbeResult {
+ user: facts.user.clone(),
+ hostname: facts.hostname.clone(),
+ os: facts.os.clone(),
+ buzz_cli_path: buzz.map(|(path, _)| path.clone()),
+ buzz_cli_version: buzz.and_then(|(_, version)| version.clone()),
+ harnesses,
+ ..base(true)
+ }
+}
+
+/// Wait for a child with a wall-clock ceiling.
+///
+/// Returns `Ok(None)` on timeout, having killed the child. `Command::output()`
+/// has no timeout, and an ssh that connects but then stalls (a wedged login
+/// shell, a hung NFS mount in a profile script) would otherwise block forever.
+fn wait_with_timeout(
+ mut command: Command,
+ timeout: Duration,
+) -> std::io::Result
+
+
+ {probe.errorKind === "password_required"
+ ? "This machine asked for a password. Buzz only uses key-based ssh — add a key to connect it later. You can still record the agent now."
+ : (probe.error ??
+ "Could not reach this machine. You can still record the agent now.")}
+
+
+ );
+}
+
+/**
+ * Reachability of the machine, not liveness of the agent.
+ *
+ * The distinction is deliberate and the wording keeps it: a reachable host does
+ * not mean the agent process is up, and Buzz has no way to ask. Presence on the
+ * relay — which the agent publishes itself — is the answer to "is it running",
+ * and it belongs to the agent, not to this panel.
+ */
+function Reachability({
+ probe,
+}: {
+ probe: HostProbeResult | "pending" | undefined;
+}) {
+ if (probe === undefined) return null;
+ if (probe === "pending") {
+ return checking…;
+ }
+ if (probe.ok) {
+ return (
+
+ machine reachable
+ {probe.buzzCliPath ? "" : " · no buzz CLI"}
+
+ );
+ }
+ return (
+ {reachabilityLabel(probe)}
+ );
+}
diff --git a/desktop/src/features/agents/ui/connectAgentIntent.test.mjs b/desktop/src/features/agents/ui/connectAgentIntent.test.mjs
new file mode 100644
index 0000000000..a0c61226a1
--- /dev/null
+++ b/desktop/src/features/agents/ui/connectAgentIntent.test.mjs
@@ -0,0 +1,214 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ canSubmitConnectAgent,
+ connectAgentPayload,
+ emptyConnectAgentDraft,
+ harnessOptions,
+ missingBuzzCli,
+ nameInputMessage,
+ pubkeyInputMessage,
+ reachabilityLabel,
+ verifyPubkeyInput,
+} from "./connectAgentIntent.ts";
+
+const HEX = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
+const NPUB = "npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6";
+
+function draft(overrides = {}) {
+ return {
+ ...emptyConnectAgentDraft,
+ host: "workstation",
+ pubkey: HEX,
+ name: "Scout",
+ ...overrides,
+ };
+}
+
+function probe(overrides = {}) {
+ return {
+ host: "workstation",
+ ok: true,
+ durationMs: 900,
+ harnesses: [],
+ buzzCliPath: "/Users/alice/.local/bin/buzz",
+ ...overrides,
+ };
+}
+
+test("both pubkey forms a user actually has on hand are accepted", () => {
+ assert.equal(verifyPubkeyInput(HEX).kind, "ok");
+ assert.equal(verifyPubkeyInput(NPUB).kind, "ok");
+ assert.equal(verifyPubkeyInput(HEX.toUpperCase()).kind, "ok");
+ assert.equal(verifyPubkeyInput(` ${NPUB} `).kind, "ok");
+});
+
+test("a pasted secret key is called out as such, not as invalid input", () => {
+ // A user who pastes an nsec has made a serious mistake. "Invalid pubkey"
+ // would not tell them what it was, and they would try again with the same
+ // secret.
+ assert.equal(verifyPubkeyInput("nsec1abcdef").kind, "secret");
+ const message = pubkeyInputMessage("nsec1abcdef");
+ assert.match(message, /secret key/i);
+ assert.match(message, /never leave/i);
+});
+
+test("malformed pubkeys are rejected", () => {
+ for (const bad of [
+ "not-a-key",
+ "npub1short",
+ HEX.slice(0, 63),
+ `${HEX}f`,
+ // Bech32 excludes 1, b, i, and o — a lookalike must not pass.
+ `npub1${"b".repeat(58)}`,
+ ]) {
+ assert.equal(verifyPubkeyInput(bad).kind, "invalid", bad);
+ }
+});
+
+test("an empty pubkey is silent, not an error", () => {
+ // Nothing typed yet is not a mistake; showing red text on an untouched field
+ // trains users to ignore it.
+ assert.equal(verifyPubkeyInput("").kind, "empty");
+ assert.equal(pubkeyInputMessage(""), null);
+ assert.equal(pubkeyInputMessage(" "), null);
+});
+
+test("names are bounded and the bound is stated", () => {
+ assert.equal(nameInputMessage("Scout"), null);
+ assert.equal(nameInputMessage(""), null);
+ assert.equal(nameInputMessage("n".repeat(64)), null);
+ assert.match(nameInputMessage("n".repeat(65)), /64 characters/);
+});
+
+test("submit requires a host, a well-formed pubkey, and a name", () => {
+ assert.equal(canSubmitConnectAgent(draft()), true);
+ assert.equal(canSubmitConnectAgent(draft({ host: "" })), false);
+ assert.equal(canSubmitConnectAgent(draft({ host: " " })), false);
+ assert.equal(canSubmitConnectAgent(draft({ pubkey: "nope" })), false);
+ assert.equal(canSubmitConnectAgent(draft({ name: "" })), false);
+ assert.equal(canSubmitConnectAgent(draft({ name: "n".repeat(65) })), false);
+});
+
+test("submit does not require a reachable host", () => {
+ // A machine that is asleep, off the VPN, or mid-reboot is still an agent host
+ // the user wants recorded. Gating on reachability would break the feature
+ // exactly during setup.
+ assert.equal(canSubmitConnectAgent(draft({ probe: null })), true);
+ assert.equal(
+ canSubmitConnectAgent(
+ draft({ probe: probe({ ok: false, errorKind: "unreachable" }) }),
+ ),
+ true,
+ );
+});
+
+test("submit is blocked while a probe is in flight", () => {
+ // The probe fills the harness options; submitting mid-probe would record a
+ // null harness the user was about to pick.
+ assert.equal(canSubmitConnectAgent(draft({ isProbing: true })), false);
+});
+
+test("the payload trims and omits an unset harness", () => {
+ assert.deepEqual(
+ connectAgentPayload(draft({ host: " workstation ", name: " Scout " })),
+ { host: "workstation", pubkey: HEX, name: "Scout", harness: null },
+ );
+ assert.deepEqual(
+ connectAgentPayload(draft({ harness: "claude" })).harness,
+ "claude",
+ );
+ assert.equal(connectAgentPayload(draft({ harness: " " })).harness, null);
+});
+
+test("an unsubmittable draft yields no payload", () => {
+ assert.equal(connectAgentPayload(draft({ pubkey: "" })), null);
+});
+
+test("only ready harnesses are offered", () => {
+ // An ACP adapter whose vendor CLI is missing starts and then fails at first
+ // use. Offering it would record something known-broken as the agent's
+ // harness.
+ const options = harnessOptions(
+ probe({
+ harnesses: [
+ { id: "claude", label: "Claude Code", ready: true },
+ { id: "codex", label: "Codex", ready: false },
+ ],
+ }),
+ );
+ assert.deepEqual(
+ options.map((harness) => harness.id),
+ ["claude"],
+ );
+});
+
+test("a failed or absent probe offers no harnesses", () => {
+ assert.deepEqual(harnessOptions(null), []);
+ assert.deepEqual(
+ harnessOptions(
+ probe({
+ ok: false,
+ errorKind: "password_required",
+ harnesses: [{ id: "claude", label: "Claude Code", ready: true }],
+ }),
+ ),
+ [],
+ );
+});
+
+test("a missing buzz CLI is flagged only once the probe succeeded", () => {
+ // Without the CLI the agent cannot reach the relay at all, so it is the one
+ // warning worth surfacing — but an unreachable host has not told us anything
+ // about its CLI, and claiming it is missing would be a fabrication.
+ assert.equal(missingBuzzCli(probe({ buzzCliPath: null })), true);
+ assert.equal(missingBuzzCli(probe()), false);
+ assert.equal(missingBuzzCli(null), false);
+ assert.equal(missingBuzzCli(probe({ ok: false, buzzCliPath: null })), false);
+});
+
+test("a failed probe is labelled by cause, not as unreachable", () => {
+ // "machine unreachable" is wrong for every classified kind except one — the
+ // host answered in all the others. The host-key case matters most: Buzz probes
+ // with strict checking and never writes known_hosts, so this label is the only
+ // prompt telling the user to go review a fingerprint.
+ const label = (errorKind) =>
+ reachabilityLabel({
+ host: "workstation",
+ ok: false,
+ durationMs: 1,
+ errorKind,
+ harnesses: [],
+ });
+
+ assert.equal(label("host_key_problem"), "host key not trusted");
+ assert.equal(label("truncated"), "probe incomplete \u00b7 retry");
+ assert.equal(label("password_required"), "needs an ssh key");
+ assert.equal(label("timed_out"), "probe timed out");
+ assert.equal(label("unreachable"), "machine unreachable");
+
+ // Only `unreachable` may claim the machine could not be reached.
+ for (const kind of [
+ "host_key_problem",
+ "truncated",
+ "password_required",
+ "timed_out",
+ ]) {
+ assert.ok(
+ !label(kind).includes("unreachable"),
+ `${kind} must not be reported as unreachable`,
+ );
+ }
+});
+
+test("an unclassified probe failure does not invent a cause", () => {
+ const label = reachabilityLabel({
+ host: "workstation",
+ ok: false,
+ durationMs: 1,
+ errorKind: null,
+ harnesses: [],
+ });
+ assert.equal(label, "probe failed");
+});
diff --git a/desktop/src/features/agents/ui/connectAgentIntent.ts b/desktop/src/features/agents/ui/connectAgentIntent.ts
new file mode 100644
index 0000000000..43d8fe5c2d
--- /dev/null
+++ b/desktop/src/features/agents/ui/connectAgentIntent.ts
@@ -0,0 +1,170 @@
+import type {
+ HostProbeResult,
+ RemoteHarness,
+} from "@/shared/api/remoteAgentTypes";
+
+/**
+ * Draft state for the Connect-an-agent dialog.
+ *
+ * `probe` is the RC3 host probe result, kept in the draft rather than derived
+ * on submit because the harness options and the "is this host even reachable"
+ * answer both come from it.
+ */
+export type ConnectAgentDraft = {
+ host: string;
+ pubkey: string;
+ name: string;
+ harness: string;
+ probe: HostProbeResult | null;
+ isProbing: boolean;
+};
+
+export const emptyConnectAgentDraft: ConnectAgentDraft = {
+ host: "",
+ pubkey: "",
+ name: "",
+ harness: "",
+ probe: null,
+ isProbing: false,
+};
+
+/**
+ * Client-side pubkey shape check.
+ *
+ * The backend is the authority — it normalizes and stores — but repeating the
+ * shape check here lets the dialog disable submit and explain why instead of
+ * round-tripping to produce an error. `nsec` gets its own answer because
+ * "invalid" would not tell a user who just pasted their agent's secret what
+ * they actually did.
+ */
+export type PubkeyVerdict =
+ | { kind: "empty" }
+ | { kind: "secret" }
+ | { kind: "invalid" }
+ | { kind: "ok" };
+
+const HEX64 = /^[0-9a-fA-F]{64}$/;
+// npub1 + 58 bech32 data characters. Length is checked rather than the checksum:
+// the backend verifies the checksum, and a client-side bech32 implementation
+// here would be a second decoder to keep correct.
+const NPUB = /^npub1[023456789acdefghjklmnpqrstuvwxyz]{58}$/;
+
+export function verifyPubkeyInput(input: string): PubkeyVerdict {
+ const trimmed = input.trim();
+ if (!trimmed) return { kind: "empty" };
+ if (trimmed.startsWith("nsec")) return { kind: "secret" };
+ if (HEX64.test(trimmed) || NPUB.test(trimmed)) return { kind: "ok" };
+ return { kind: "invalid" };
+}
+
+/** Human-readable reason a pubkey input is not usable yet, or `null`. */
+export function pubkeyInputMessage(input: string): string | null {
+ switch (verifyPubkeyInput(input).kind) {
+ case "empty":
+ return null;
+ case "secret":
+ return "That is a secret key. A self-hosted agent's nsec must never leave its own machine — paste its npub instead.";
+ case "invalid":
+ return "Expected an npub or 64 hex characters.";
+ case "ok":
+ return null;
+ }
+}
+
+export const MAX_CONNECTED_NAME_LENGTH = 64;
+
+/** Human-readable reason a name is not usable yet, or `null`. */
+export function nameInputMessage(input: string): string | null {
+ const trimmed = input.trim();
+ if (!trimmed) return null;
+ if (trimmed.length > MAX_CONNECTED_NAME_LENGTH) {
+ return `Names are limited to ${MAX_CONNECTED_NAME_LENGTH} characters.`;
+ }
+ return null;
+}
+
+/**
+ * Harnesses worth offering for a connected agent.
+ *
+ * Only `ready` ones: an ACP adapter whose vendor CLI is missing starts and then
+ * fails at first use, so listing it as the agent's harness would record
+ * something known-broken. An empty list is a legitimate answer — the host may
+ * run an agent Buzz has no recipe for — which is why the harness field is
+ * optional.
+ */
+export function harnessOptions(probe: HostProbeResult | null): RemoteHarness[] {
+ if (!probe?.ok) return [];
+ return probe.harnesses.filter((harness) => harness.ready);
+}
+
+/**
+ * True when the host probe came back but found no `buzz` CLI.
+ *
+ * Not a blocker: the CLI can be installed after connecting, and a user may be
+ * recording an agent they are still setting up. It is the single most useful
+ * warning to show, because without it the agent cannot reach the relay at all.
+ */
+export function missingBuzzCli(probe: HostProbeResult | null): boolean {
+ return Boolean(probe?.ok) && !probe?.buzzCliPath;
+}
+
+/**
+ * Submit gate.
+ *
+ * Deliberately does NOT require a successful probe. A machine that is asleep,
+ * off the VPN, or mid-reboot is still an agent host the user wants recorded —
+ * blocking on reachability would make the feature unusable exactly when the
+ * user is setting things up. What is required is a host, a well-formed pubkey,
+ * and a name; the backend re-validates all three and additionally rejects a
+ * host that is not in `~/.ssh/config`.
+ */
+export function canSubmitConnectAgent(draft: ConnectAgentDraft): boolean {
+ if (draft.isProbing) return false;
+ if (!draft.host.trim()) return false;
+ if (verifyPubkeyInput(draft.pubkey).kind !== "ok") return false;
+ const name = draft.name.trim();
+ if (!name || name.length > MAX_CONNECTED_NAME_LENGTH) return false;
+ return true;
+}
+
+/** The payload `connectRemoteAgent` expects, or `null` when not submittable. */
+export function connectAgentPayload(draft: ConnectAgentDraft) {
+ if (!canSubmitConnectAgent(draft)) return null;
+ const harness = draft.harness.trim();
+ return {
+ host: draft.host.trim(),
+ pubkey: draft.pubkey.trim(),
+ name: draft.name.trim(),
+ harness: harness ? harness : null,
+ };
+}
+
+/**
+ * Compact label for a failed probe, for the Connected Agents list.
+ *
+ * Every classified kind gets its own wording because they call for different
+ * actions, and "machine unreachable" is actively wrong for all but one of them:
+ * the host answered in every case except `unreachable`. Labelling an untrusted
+ * host key as unreachable would send someone to check the network when the fix
+ * is to review a fingerprint — and since Buzz probes with strict host-key
+ * checking and never writes `known_hosts`, this label is the only prompt the
+ * user gets.
+ */
+export function reachabilityLabel(probe: HostProbeResult): string {
+ switch (probe.errorKind) {
+ case "password_required":
+ return "needs an ssh key";
+ case "host_key_problem":
+ return "host key not trusted";
+ case "truncated":
+ return "probe incomplete · retry";
+ case "timed_out":
+ return "probe timed out";
+ case "unreachable":
+ return "machine unreachable";
+ default:
+ // Unclassified: the backend could not attribute the failure, so naming a
+ // specific cause here would be a guess.
+ return "probe failed";
+ }
+}
diff --git a/desktop/src/features/agents/ui/connectedAgentChannelIntent.test.mjs b/desktop/src/features/agents/ui/connectedAgentChannelIntent.test.mjs
new file mode 100644
index 0000000000..e04a54a615
--- /dev/null
+++ b/desktop/src/features/agents/ui/connectedAgentChannelIntent.test.mjs
@@ -0,0 +1,48 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { connectedAgentMembershipAdded } from "./connectedAgentChannelIntent.ts";
+
+const AGENT =
+ "4687f50de3a9e235e28eb58d68b0746062d7be6401bbf78a766bbd6f96ffe3c9";
+
+test("reports the connected agent's successful membership write", () => {
+ assert.equal(
+ connectedAgentMembershipAdded(AGENT, {
+ added: [AGENT.toUpperCase()],
+ errors: [],
+ }),
+ true,
+ );
+});
+
+test("does not treat another batch entry as this agent's success", () => {
+ assert.equal(
+ connectedAgentMembershipAdded(AGENT, {
+ added: ["f".repeat(64)],
+ errors: [],
+ }),
+ false,
+ );
+});
+
+test("surfaces the matching relay membership error", () => {
+ assert.throws(
+ () =>
+ connectedAgentMembershipAdded(AGENT, {
+ added: [],
+ errors: [{ pubkey: AGENT, error: "channel is archived" }],
+ }),
+ /channel is archived/,
+ );
+});
+
+test("ignores an error for a different batch entry", () => {
+ assert.equal(
+ connectedAgentMembershipAdded(AGENT, {
+ added: [AGENT],
+ errors: [{ pubkey: "f".repeat(64), error: "not this agent" }],
+ }),
+ true,
+ );
+});
diff --git a/desktop/src/features/agents/ui/connectedAgentChannelIntent.ts b/desktop/src/features/agents/ui/connectedAgentChannelIntent.ts
new file mode 100644
index 0000000000..9a4417d720
--- /dev/null
+++ b/desktop/src/features/agents/ui/connectedAgentChannelIntent.ts
@@ -0,0 +1,35 @@
+export type ConnectedAgentMembershipResult = {
+ added: string[];
+ errors: Array<{
+ pubkey: string;
+ error: string;
+ }>;
+};
+
+function normalizePubkey(pubkey: string): string {
+ return pubkey.trim().toLowerCase();
+}
+
+/**
+ * Interpret the relay's batch membership result for one connected agent.
+ *
+ * `addChannelMembers` is batch-shaped even when this UI writes one pubkey. Keep
+ * the exact matching and error precedence in a pure seam so the connected path
+ * cannot mistake another batch entry for this agent's outcome.
+ */
+export function connectedAgentMembershipAdded(
+ agentPubkey: string,
+ result: ConnectedAgentMembershipResult,
+): boolean {
+ const normalizedAgent = normalizePubkey(agentPubkey);
+ const membershipError = result.errors.find(
+ (error) => normalizePubkey(error.pubkey) === normalizedAgent,
+ );
+ if (membershipError) {
+ throw new Error(membershipError.error);
+ }
+
+ return result.added.some(
+ (pubkey) => normalizePubkey(pubkey) === normalizedAgent,
+ );
+}
diff --git a/desktop/src/features/agents/ui/connectedAgentScope.test.mjs b/desktop/src/features/agents/ui/connectedAgentScope.test.mjs
new file mode 100644
index 0000000000..686b61dea3
--- /dev/null
+++ b/desktop/src/features/agents/ui/connectedAgentScope.test.mjs
@@ -0,0 +1,65 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ connectedAgentsForCommunity,
+ normalizeCommunityUrl,
+} from "./connectedAgentScope.ts";
+
+const PRIMARY = "wss://community.example";
+const SECONDARY = "wss://other.example";
+
+function agent(overrides = {}) {
+ return {
+ pubkey: "a".repeat(64),
+ name: "Scout",
+ host: "workstation",
+ harness: "claude",
+ community: PRIMARY,
+ createdAt: "2026-07-29T00:00:00Z",
+ updatedAt: "2026-07-29T00:00:00Z",
+ ...overrides,
+ };
+}
+
+test("an agent appears only in its recorded community", () => {
+ assert.equal(connectedAgentsForCommunity([agent()], PRIMARY).length, 1);
+ assert.deepEqual(connectedAgentsForCommunity([agent()], SECONDARY), []);
+});
+
+test("legacy records remain visible until reconnected", () => {
+ const legacy = agent({ community: undefined });
+ assert.equal(connectedAgentsForCommunity([legacy], PRIMARY).length, 1);
+ assert.equal(connectedAgentsForCommunity([legacy], SECONDARY).length, 1);
+});
+
+test("comparison ignores trailing slashes and case", () => {
+ assert.equal(
+ connectedAgentsForCommunity(
+ [agent({ community: "WSS://COMMUNITY.EXAMPLE/" })],
+ PRIMARY,
+ ).length,
+ 1,
+ );
+ assert.equal(
+ normalizeCommunityUrl(" wss://Relay.Example.com// "),
+ "wss://relay.example.com",
+ );
+});
+
+test("an unknown active community does not hide records", () => {
+ assert.equal(connectedAgentsForCommunity([agent()], null).length, 1);
+ assert.equal(connectedAgentsForCommunity([agent()], "").length, 1);
+});
+
+test("mixed communities are separated while legacy records stay visible", () => {
+ const agents = [
+ agent({ pubkey: "a".repeat(64), community: PRIMARY }),
+ agent({ pubkey: "b".repeat(64), community: SECONDARY }),
+ agent({ pubkey: "c".repeat(64), community: undefined }),
+ ];
+ assert.deepEqual(
+ connectedAgentsForCommunity(agents, PRIMARY).map((item) => item.pubkey),
+ ["a".repeat(64), "c".repeat(64)],
+ );
+});
diff --git a/desktop/src/features/agents/ui/connectedAgentScope.ts b/desktop/src/features/agents/ui/connectedAgentScope.ts
new file mode 100644
index 0000000000..37aaf1dd82
--- /dev/null
+++ b/desktop/src/features/agents/ui/connectedAgentScope.ts
@@ -0,0 +1,19 @@
+import type { ConnectedAgent } from "@/shared/api/remoteAgentTypes";
+
+/** Normalize a relay URL so equivalent community spellings compare equally. */
+export function normalizeCommunityUrl(url: string): string {
+ return url.trim().replace(/\/+$/, "").toLowerCase();
+}
+
+/** Return the connected agents relevant to the currently active community. */
+export function connectedAgentsForCommunity(
+ agents: ConnectedAgent[],
+ activeRelayUrl: string | null | undefined,
+): ConnectedAgent[] {
+ if (!activeRelayUrl) return agents;
+ const active = normalizeCommunityUrl(activeRelayUrl);
+ return agents.filter(
+ (agent) =>
+ !agent.community || normalizeCommunityUrl(agent.community) === active,
+ );
+}
diff --git a/desktop/src/features/agents/ui/useConnectedAgents.ts b/desktop/src/features/agents/ui/useConnectedAgents.ts
new file mode 100644
index 0000000000..6c56f0269b
--- /dev/null
+++ b/desktop/src/features/agents/ui/useConnectedAgents.ts
@@ -0,0 +1,177 @@
+import * as React from "react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+
+import {
+ disconnectRemoteAgent,
+ listConnectedAgents,
+} from "@/shared/api/remoteAgentApi";
+import type { ConnectedAgent } from "@/shared/api/remoteAgentTypes";
+import { addChannelMembers } from "@/shared/api/tauri";
+import type { Channel, ChannelRole } from "@/shared/api/types";
+import { channelsQueryKey } from "@/features/channels/hooks";
+import { relayAgentsQueryKey } from "@/features/agents/hooks";
+import {
+ loadActiveCommunityId,
+ loadCommunities,
+} from "@/features/communities/communityStorage";
+import { normalizePubkey } from "@/shared/lib/pubkey";
+import { connectedAgentMembershipAdded } from "./connectedAgentChannelIntent";
+import { connectedAgentsForCommunity } from "./connectedAgentScope";
+
+export const connectedAgentsQueryKey = ["connected-agents"] as const;
+
+export type AttachConnectedAgentToChannelInput = {
+ agent: ConnectedAgent;
+ channelId: string;
+ role?: Exclude;
+};
+
+export type AttachConnectedAgentToChannelResult = {
+ agent: ConnectedAgent;
+ membershipAdded: boolean;
+};
+
+/**
+ * Add a self-hosted agent to a relay channel without crossing the custody
+ * boundary. This writes owner-signed membership only: it never starts,
+ * deploys, restarts, or otherwise acts on the remote process.
+ */
+export function useAttachConnectedAgentToChannelMutation() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({
+ agent,
+ channelId,
+ role = "bot",
+ }: AttachConnectedAgentToChannelInput): Promise => {
+ const normalizedPubkey = normalizePubkey(agent.pubkey);
+ const result = await addChannelMembers({
+ channelId,
+ pubkeys: [normalizedPubkey],
+ role,
+ });
+
+ return {
+ agent,
+ membershipAdded: connectedAgentMembershipAdded(
+ normalizedPubkey,
+ result,
+ ),
+ };
+ },
+ onSettled: async (_data, _error, variables) => {
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: channelsQueryKey }),
+ queryClient.invalidateQueries({ queryKey: relayAgentsQueryKey }),
+ ...(variables
+ ? [
+ queryClient.invalidateQueries({
+ queryKey: ["channels", variables.channelId, "members"],
+ }),
+ ]
+ : []),
+ ]);
+ },
+ });
+}
+
+/**
+ * Connected self-hosted agents.
+ *
+ * No `refetchInterval`. The managed-agents query polls because a local process
+ * can die with no relay event to signal it; a connected agent's record is a
+ * local pointer that changes only when the user connects or disconnects one, so
+ * polling it would be pure noise. Liveness of the agent itself comes from relay
+ * presence, which the agent publishes and Buzz already subscribes to.
+ */
+export function useConnectedAgentsQuery() {
+ return useQuery({
+ queryKey: connectedAgentsQueryKey,
+ queryFn: listConnectedAgents,
+ staleTime: 30_000,
+ });
+}
+
+function activeCommunityRelayUrl(): string | null {
+ const activeId = loadActiveCommunityId();
+ if (!activeId) return null;
+ return (
+ loadCommunities().find((community) => community.id === activeId)
+ ?.relayUrl ?? null
+ );
+}
+
+/**
+ * State and actions for the Connected-agents section.
+ *
+ * There is deliberately no start/stop/restart action here to match: the
+ * surface offers only what Buzz can actually do to an agent it does not own.
+ */
+export function useConnectedAgents() {
+ const queryClient = useQueryClient();
+ const query = useConnectedAgentsQuery();
+ // The community-scoped app subtree remounts when the active community changes.
+ const activeRelayUrl = React.useMemo(activeCommunityRelayUrl, []);
+ const [isDialogOpen, setIsDialogOpen] = React.useState(false);
+ const [agentToAddToChannel, setAgentToAddToChannel] =
+ React.useState(null);
+ const [noticeMessage, setNoticeMessage] = React.useState(null);
+
+ const disconnectMutation = useMutation({
+ mutationFn: (pubkey: string) => disconnectRemoteAgent(pubkey),
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: connectedAgentsQueryKey });
+ },
+ });
+
+ const handleDisconnect = React.useCallback(
+ async (agent: ConnectedAgent) => {
+ await disconnectMutation.mutateAsync(agent.pubkey);
+ // Say what did NOT happen. A user who just clicked "Disconnect" has good
+ // reason to wonder whether they killed their agent; they did not, and
+ // silence would leave them guessing.
+ setNoticeMessage(
+ `${agent.name} is no longer listed here. It is still running on ${agent.host} — Buzz never controlled it.`,
+ );
+ },
+ [disconnectMutation],
+ );
+
+ const handleConnected = React.useCallback(
+ (agent: ConnectedAgent) => {
+ void queryClient.invalidateQueries({ queryKey: connectedAgentsQueryKey });
+ setNoticeMessage(`Connected ${agent.name} on ${agent.host}.`);
+ },
+ [queryClient],
+ );
+
+ const handleAddedToChannel = React.useCallback(
+ (channel: Channel, result: AttachConnectedAgentToChannelResult) => {
+ setAgentToAddToChannel(null);
+ setNoticeMessage(
+ result.membershipAdded
+ ? `Added ${result.agent.name} to ${channel.name} as a bot. The agent remains self-supervised on ${result.agent.host}.`
+ : `${result.agent.name} is already available in ${channel.name}.`,
+ );
+ },
+ [],
+ );
+
+ return {
+ agents: connectedAgentsForCommunity(query.data ?? [], activeRelayUrl),
+ agentToAddToChannel,
+ error: query.error instanceof Error ? query.error : null,
+ isLoading: query.isLoading,
+ isPending: disconnectMutation.isPending,
+ isDialogOpen,
+ noticeMessage,
+ openConnectDialog: () => setIsDialogOpen(true),
+ setAgentToAddToChannel,
+ setIsDialogOpen,
+ setNoticeMessage,
+ handleAddedToChannel,
+ handleConnected,
+ handleDisconnect,
+ };
+}
diff --git a/desktop/src/shared/api/remoteAgentApi.ts b/desktop/src/shared/api/remoteAgentApi.ts
new file mode 100644
index 0000000000..9e26e7ad17
--- /dev/null
+++ b/desktop/src/shared/api/remoteAgentApi.ts
@@ -0,0 +1,72 @@
+import { invokeTauri } from "@/shared/api/tauri";
+import type {
+ ConnectedAgent,
+ HostProbeResult,
+ SshHost,
+} from "@/shared/api/remoteAgentTypes";
+
+/**
+ * Enumerate the user's `~/.ssh/config` host aliases. No connection is attempted;
+ * an absent config yields an empty list.
+ */
+export async function listSshHosts(): Promise {
+ return await invokeTauri("list_ssh_hosts");
+}
+
+/**
+ * Probe one configured host for agent harnesses and the `buzz` CLI.
+ *
+ * `host` must be an alias present in `~/.ssh/config`. A host-side failure
+ * (unreachable, password-only, unknown key) resolves with `ok: false` and a
+ * classified `errorKind`; only a failure to run `ssh` at all rejects.
+ */
+export async function probeAgentHost(host: string): Promise {
+ return await invokeTauri("probe_agent_host", { host });
+}
+
+/**
+ * Probe the machine Buzz is running on, using the identical probe script so the
+ * result is shape-compatible with `probeAgentHost`.
+ */
+export async function probeLocalAgentHost(): Promise {
+ return await invokeTauri("probe_local_agent_host");
+}
+
+/** The self-hosted agents this machine is connected to. */
+export async function listConnectedAgents(): Promise {
+ return await invokeTauri("list_connected_agents");
+}
+
+/**
+ * Record a self-hosted agent that already runs on `host`.
+ *
+ * `pubkey` accepts an npub or 64 hex characters and is normalized to hex by the
+ * backend. An nsec is refused with a specific message — a self-hosted agent's
+ * secret must never leave its own machine, and this call never transports one.
+ * `host` must be an alias present in `~/.ssh/config`, because it is also the
+ * reachability probe target.
+ */
+export async function connectRemoteAgent(input: {
+ host: string;
+ pubkey: string;
+ name: string;
+ harness?: string | null;
+}): Promise {
+ return await invokeTauri("connect_remote_agent", {
+ host: input.host,
+ pubkey: input.pubkey,
+ name: input.name,
+ harness: input.harness ?? null,
+ });
+}
+
+/**
+ * Forget a connected agent.
+ *
+ * Local-only: this removes Buzz's pointer and nothing else. The remote process
+ * keeps running, and no tombstone or archive event is published — Buzz never
+ * claimed to own this agent, so it has nothing to revoke.
+ */
+export async function disconnectRemoteAgent(pubkey: string): Promise {
+ await invokeTauri("disconnect_remote_agent", { pubkey });
+}
diff --git a/desktop/src/shared/api/remoteAgentTypes.ts b/desktop/src/shared/api/remoteAgentTypes.ts
new file mode 100644
index 0000000000..31089882fb
--- /dev/null
+++ b/desktop/src/shared/api/remoteAgentTypes.ts
@@ -0,0 +1,123 @@
+/**
+ * Types for the remote-agent surface: enumerating the user's own SSH hosts and
+ * probing them for agent harnesses.
+ *
+ * A separate module rather than more lines in `types.ts`, which is already over
+ * the desktop 1000-line limit and carries a documented "queued to be split"
+ * override. Import these from here directly — `types.ts` deliberately does not
+ * re-export them, because a re-export block would put it back over the limit
+ * and defeat the point of the split.
+ */
+
+/** One `Host` stanza from the user's `~/.ssh/config`. */
+export type SshHost = {
+ /** The `Host` alias as written — this is what gets passed to `ssh`. */
+ host: string;
+ hostname?: string | null;
+ user?: string | null;
+ port?: string | null;
+ identityFile?: string | null;
+};
+
+/**
+ * Why a host probe failed, when the cause is actionable.
+ *
+ * `password_required` means the host offered only interactive auth. Buzz never
+ * collects or stores an SSH password, so this is a status to render with a
+ * remedy, not a prompt to raise.
+ *
+ * `host_key_problem` covers both an untrusted first-seen key and a changed one.
+ * Buzz probes with strict host-key checking and never writes to `known_hosts`,
+ * so granting trust is always something the user does outside the app.
+ *
+ * `truncated` means the probe started but its output stopped early, so the facts
+ * are an unknown fraction of the real ones and are withheld rather than shown as
+ * a complete answer.
+ */
+export type HostProbeErrorKind =
+ | "password_required"
+ | "host_key_problem"
+ | "unreachable"
+ | "timed_out"
+ | "truncated";
+
+/**
+ * One agent harness found on a probed host.
+ *
+ * Deliberately narrower than `AcpRuntime`: that type carries install and auth
+ * affordances that only apply to the local machine. Buzz does not install
+ * software on, or authenticate CLIs on, another host.
+ */
+export type RemoteHarness = {
+ id: string;
+ label: string;
+ source: "builtin" | "preset" | "custom";
+ acpCommand?: string | null;
+ acpCommandPath?: string | null;
+ version?: string | null;
+ underlyingCliPath?: string | null;
+ /**
+ * True when the harness is usable on this host: its ACP command resolved and,
+ * if it is an adapter, the vendor CLI it wraps resolved too. An adapter
+ * without its CLI starts and then fails at first use.
+ */
+ ready: boolean;
+ installHint: string;
+ installInstructionsUrl: string;
+};
+
+/**
+ * Result of probing one host for agent harnesses.
+ *
+ * A host-side problem comes back with `ok: false` and a classified
+ * `errorKind` rather than as a thrown error — the UI shows one row per host and
+ * needs a renderable status.
+ */
+export type HostProbeResult = {
+ /** The ssh alias probed, or `__localhost__` for this machine. */
+ host: string;
+ ok: boolean;
+ durationMs: number;
+ error?: string | null;
+ errorKind?: HostProbeErrorKind | null;
+ user?: string | null;
+ hostname?: string | null;
+ os?: string | null;
+ /** Path of the `buzz` CLI on the host; a connected agent needs it. */
+ buzzCliPath?: string | null;
+ buzzCliVersion?: string | null;
+ harnesses: RemoteHarness[];
+};
+
+/** Host id the backend uses for the local machine. */
+export const LOCALHOST_HOST_ID = "__localhost__";
+
+/**
+ * A self-hosted agent Buzz talks to but does not own: it runs on a machine the
+ * user owns, supervises itself, and holds its own signing key.
+ *
+ * Deliberately **not** a `ManagedAgent`. That type carries `status`, `pid`,
+ * `logPath`, `needsRestart`, and `startOnAppLaunch` — each one a claim about a
+ * process Buzz supervises. A connected agent has none of those, and the narrow
+ * shape is what makes "no start/stop button" a property of the type rather
+ * than a rule a component has to remember. Connected agents are not part of
+ * `listManagedAgents()` at all: they are a separate record type in a separate
+ * store, so they cannot reach a surface that renders lifecycle controls.
+ */
+export type ConnectedAgent = {
+ /** The agent's own pubkey, lowercase hex. Buzz holds only the public half. */
+ pubkey: string;
+ /** Buzz-local label. The agent's own kind:10100 profile is what the relay sees. */
+ name: string;
+ /** `~/.ssh/config` alias of the machine the agent and its key live on. */
+ host: string;
+ /**
+ * Harness id observed on the host at connect time (e.g. `"claude"`). A
+ * record of what was there — nothing in Buzz executes it.
+ */
+ harness: string | null;
+ /** Community where this connection was created, or `null` for legacy records. */
+ community: string | null;
+ createdAt: string;
+ updatedAt: string;
+};