From 3c440a1328717f508b42aa3c36e0d4f0aca2fe58 Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 13 Mar 2026 13:13:42 -0700 Subject: [PATCH] Add desktop managed agents view (#58) --- desktop/src-tauri/Cargo.lock | 1 + desktop/src-tauri/Cargo.toml | 1 + desktop/src-tauri/src/lib.rs | 1265 ++++++++++++- desktop/src/app/AppShell.tsx | 24 +- desktop/src/features/agents/hooks.ts | 156 ++ desktop/src/features/agents/ui/AgentsView.tsx | 1669 +++++++++++++++++ desktop/src/features/chat/ui/ChatHeader.tsx | 10 +- .../features/messages/ui/MessageComposer.tsx | 34 +- .../src/features/sidebar/ui/AppSidebar.tsx | 16 +- desktop/src/shared/api/tauri.ts | 255 +++ desktop/src/shared/api/types.ts | 87 + desktop/src/testing/e2eBridge.ts | 387 ++++ 12 files changed, 3891 insertions(+), 14 deletions(-) create mode 100644 desktop/src/features/agents/hooks.ts create mode 100644 desktop/src/features/agents/ui/AgentsView.tsx diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index e2c54e8310..c02d717df6 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -4018,6 +4018,7 @@ name = "sprout" version = "0.1.0" dependencies = [ "base64 0.22.1", + "chrono", "nostr 0.37.0", "reqwest 0.12.28", "serde", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index fe9bd458a3..c6ee939a75 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -29,3 +29,4 @@ reqwest = { version = "0.12", features = ["json"] } sprout-core = { path = "../../crates/sprout-core" } base64 = "0.22" sha2 = "0.10" +chrono = { version = "0.4", features = ["serde"] } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index e52bcc59fb..c02d794902 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -1,11 +1,20 @@ -use std::{collections::HashMap, sync::Mutex}; +use std::{ + collections::HashMap, + fs::{self, File, OpenOptions}, + io::{BufRead, BufReader, Write}, + path::{Path, PathBuf}, + process::{Child, Command, Output, Stdio}, + sync::Mutex, +}; use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; +use chrono::Utc; use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, ToBech32}; use reqwest::Method; use serde::{de::DeserializeOwned, Deserialize, Deserializer, Serialize}; use sha2::{Digest, Sha256}; use sprout_core::PresenceStatus; +use tauri::{AppHandle, Manager}; use tauri_plugin_window_state::StateFlags; pub struct AppState { @@ -13,6 +22,8 @@ pub struct AppState { pub http_client: reqwest::Client, pub configured_api_token: Option, pub session_token: Mutex>, + pub managed_agents_store_lock: Mutex<()>, + pub managed_agent_processes: Mutex>, } #[derive(Serialize)] @@ -296,6 +307,192 @@ pub struct SendChannelMessageResponse { pub created_at: i64, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RelayAgentInfo { + pub pubkey: String, + pub name: String, + pub agent_type: String, + pub channels: Vec, + pub capabilities: Vec, + pub status: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ManagedAgentRecord { + pub pubkey: String, + pub name: String, + pub private_key_nsec: String, + pub api_token: Option, + pub relay_url: String, + pub acp_command: String, + pub agent_command: String, + pub agent_args: Vec, + pub mcp_command: String, + pub turn_timeout_seconds: u64, + pub created_at: String, + pub updated_at: String, + pub last_started_at: Option, + pub last_stopped_at: Option, + pub last_exit_code: Option, + pub last_error: Option, +} + +#[derive(Debug)] +pub struct ManagedAgentProcess { + pub child: Child, + pub log_path: PathBuf, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ManagedAgentSummary { + pub pubkey: String, + pub name: String, + pub relay_url: String, + pub acp_command: String, + pub agent_command: String, + pub agent_args: Vec, + pub mcp_command: String, + pub turn_timeout_seconds: u64, + pub has_api_token: bool, + pub status: String, + pub pid: Option, + pub created_at: String, + pub updated_at: String, + pub last_started_at: Option, + pub last_stopped_at: Option, + pub last_exit_code: Option, + pub last_error: Option, + pub log_path: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateManagedAgentRequest { + pub name: String, + pub relay_url: Option, + pub acp_command: Option, + pub agent_command: Option, + #[serde(default)] + pub agent_args: Vec, + pub mcp_command: Option, + pub turn_timeout_seconds: Option, + #[serde(default)] + pub mint_token: bool, + #[serde(default)] + pub token_scopes: Vec, + pub token_name: Option, + #[serde(default)] + pub spawn_after_create: bool, +} + +#[derive(Debug, Serialize)] +pub struct CreateManagedAgentResponse { + pub agent: ManagedAgentSummary, + pub private_key_nsec: String, + pub api_token: Option, + pub spawn_error: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MintManagedAgentTokenRequest { + pub pubkey: String, + pub token_name: Option, + #[serde(default)] + pub scopes: Vec, +} + +#[derive(Debug, Serialize)] +pub struct MintManagedAgentTokenResponse { + pub agent: ManagedAgentSummary, + pub token: String, +} + +#[derive(Debug, Serialize)] +pub struct ManagedAgentLogResponse { + pub content: String, + pub log_path: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct AcpProviderInfo { + pub id: String, + pub label: String, + pub command: String, + pub binary_path: String, + pub default_args: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct CommandAvailabilityInfo { + pub command: String, + pub resolved_path: Option, + pub available: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DiscoverManagedAgentPrereqsRequest { + pub acp_command: Option, + pub mcp_command: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ManagedAgentPrereqsInfo { + pub admin: CommandAvailabilityInfo, + pub acp: CommandAvailabilityInfo, + pub mcp: CommandAvailabilityInfo, +} + +#[derive(Debug, Deserialize)] +struct SproutAdminMintTokenJsonOutput { + token_id: String, + name: String, + scopes: Vec, + pubkey: String, + private_key_nsec: Option, + api_token: String, +} + +struct KnownAcpProvider { + id: &'static str, + label: &'static str, + command: &'static str, + default_args: &'static [&'static str], +} + +const DEFAULT_ACP_COMMAND: &str = "sprout-acp"; +const DEFAULT_AGENT_COMMAND: &str = "goose"; +const DEFAULT_MCP_COMMAND: &str = "sprout-mcp-server"; +const DEFAULT_AGENT_ARG: &str = "acp"; +const DEFAULT_AGENT_TURN_TIMEOUT_SECONDS: u64 = 300; +const COMMON_BINARY_PATHS: &[&str] = &[ + "/opt/homebrew/bin", + "/usr/local/bin", + "/usr/bin", + "/home/linuxbrew/.linuxbrew/bin", +]; +const KNOWN_ACP_PROVIDERS: &[KnownAcpProvider] = &[ + KnownAcpProvider { + id: "goose", + label: "Goose", + command: "goose", + default_args: &[DEFAULT_AGENT_ARG], + }, + KnownAcpProvider { + id: "claude", + label: "Claude Code", + command: "claude-agent-acp", + default_args: &[], + }, + KnownAcpProvider { + id: "codex", + label: "Codex", + command: "codex-acp", + default_args: &[], + }, +]; + fn deserialize_null_string_as_empty<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, @@ -346,6 +543,638 @@ fn relay_api_base_url() -> String { .replace("ws://", "http://") } +fn now_iso() -> String { + Utc::now().to_rfc3339() +} + +fn managed_agents_base_dir(app: &AppHandle) -> Result { + let dir = app + .path() + .app_data_dir() + .map_err(|e| format!("failed to resolve app data dir: {e}"))? + .join("agents"); + fs::create_dir_all(&dir).map_err(|e| format!("failed to create agents dir: {e}"))?; + Ok(dir) +} + +fn managed_agents_store_path(app: &AppHandle) -> Result { + Ok(managed_agents_base_dir(app)?.join("managed-agents.json")) +} + +fn managed_agents_logs_dir(app: &AppHandle) -> Result { + let dir = managed_agents_base_dir(app)?.join("logs"); + fs::create_dir_all(&dir).map_err(|e| format!("failed to create logs dir: {e}"))?; + Ok(dir) +} + +fn managed_agent_log_path(app: &AppHandle, pubkey: &str) -> Result { + Ok(managed_agents_logs_dir(app)?.join(format!("{pubkey}.log"))) +} + +fn load_managed_agents(app: &AppHandle) -> Result, String> { + let path = managed_agents_store_path(app)?; + if !path.exists() { + return Ok(Vec::new()); + } + + let content = + fs::read_to_string(&path).map_err(|e| format!("failed to read agent store: {e}"))?; + serde_json::from_str(&content).map_err(|e| format!("failed to parse agent store: {e}")) +} + +fn save_managed_agents(app: &AppHandle, records: &[ManagedAgentRecord]) -> Result<(), String> { + let mut sorted = records.to_vec(); + sorted.sort_by(|left, right| { + left.name + .to_lowercase() + .cmp(&right.name.to_lowercase()) + .then_with(|| left.pubkey.cmp(&right.pubkey)) + }); + + let path = managed_agents_store_path(app)?; + let payload = serde_json::to_vec_pretty(&sorted) + .map_err(|e| format!("failed to serialize agent store: {e}"))?; + fs::write(&path, payload).map_err(|e| format!("failed to write agent store: {e}")) +} + +fn workspace_root_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +fn command_looks_like_path(command: &str) -> bool { + let path = Path::new(command); + path.is_absolute() || path.components().count() > 1 +} + +fn executable_basename(command: &str) -> String { + let suffix = std::env::consts::EXE_SUFFIX; + if suffix.is_empty() || command.ends_with(suffix) { + command.to_string() + } else { + format!("{command}{suffix}") + } +} + +fn command_search_dirs(app: Option<&AppHandle>) -> Vec { + let mut dirs = vec![ + workspace_root_dir().join("target/release"), + workspace_root_dir().join("target/debug"), + ]; + + if let Ok(current_dir) = std::env::current_dir() { + dirs.push(current_dir.join("target/release")); + dirs.push(current_dir.join("target/debug")); + } + + if app.is_some() { + if let Ok(exe_path) = std::env::current_exe() { + if let Some(parent) = exe_path.parent() { + dirs.push(parent.to_path_buf()); + } + } + } + + let mut unique = Vec::new(); + for dir in dirs { + if unique.iter().any(|candidate: &PathBuf| candidate == &dir) { + continue; + } + unique.push(dir); + } + + unique +} + +fn resolve_workspace_command(command: &str, app: Option<&AppHandle>) -> Option { + if command_looks_like_path(command) { + let path = PathBuf::from(command); + return path.exists().then_some(path); + } + + let file_name = executable_basename(command); + command_search_dirs(app) + .into_iter() + .map(|dir| dir.join(&file_name)) + .find(|candidate| candidate.exists()) +} + +fn resolve_command(command: &str, app: Option<&AppHandle>) -> Option { + if let Some(path) = resolve_workspace_command(command, app) { + return Some(path); + } + + if command_looks_like_path(command) { + let path = PathBuf::from(command); + return path.exists().then_some(path); + } + + for candidate in path_candidates_from_env(command) { + if candidate.exists() { + return Some(candidate); + } + } + + if let Some(path) = find_via_login_shell(command) { + return Some(path); + } + + for dir in COMMON_BINARY_PATHS { + let candidate = PathBuf::from(dir).join(executable_basename(command)); + if candidate.exists() { + return Some(candidate); + } + } + + None +} + +fn path_candidates_from_env(command: &str) -> Vec { + std::env::var_os("PATH") + .map(|paths| { + std::env::split_paths(&paths) + .map(|dir| dir.join(executable_basename(command))) + .collect::>() + }) + .unwrap_or_default() +} + +fn find_via_login_shell(command: &str) -> Option { + let which_cmd = format!("command -v {command}"); + + for shell in ["/bin/zsh", "/bin/bash"] { + let Ok(output) = Command::new(shell) + .args(["-l", "-c", &which_cmd]) + .output() + else { + continue; + }; + + if !output.status.success() { + continue; + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let Some(resolved) = stdout.lines().rfind(|line| !line.trim().is_empty()) else { + continue; + }; + let path = PathBuf::from(resolved.trim()); + if path.is_absolute() && path.exists() { + return Some(path); + } + } + + None +} + +fn find_command(command: &str) -> Option { + resolve_command(command, None) +} + +fn command_availability(command: &str, app: Option<&AppHandle>) -> CommandAvailabilityInfo { + let resolved_path = resolve_command(command, app).map(|path| path.display().to_string()); + CommandAvailabilityInfo { + command: command.to_string(), + available: resolved_path.is_some(), + resolved_path, + } +} + +fn missing_command_message(command: &str, role: &str) -> String { + if command_looks_like_path(command) { + return format!("{role} `{command}` does not exist."); + } + + format!( + "{role} `{command}` was not found. Build the workspace binaries (`cargo build --release --workspace`) or add `target/release` to PATH as described in TESTING.md." + ) +} + +fn discover_local_acp_providers() -> Vec { + KNOWN_ACP_PROVIDERS + .iter() + .filter_map(|provider| { + find_command(provider.command).map(|binary_path| AcpProviderInfo { + id: provider.id.to_string(), + label: provider.label.to_string(), + command: provider.command.to_string(), + binary_path: binary_path.display().to_string(), + default_args: provider + .default_args + .iter() + .map(|arg| (*arg).to_string()) + .collect(), + }) + }) + .collect() +} + +fn admin_command() -> String { + std::env::var("SPROUT_ADMIN_COMMAND").unwrap_or_else(|_| "sprout-admin".to_string()) +} + +fn default_token_scopes() -> Vec { + vec![ + "messages:read".to_string(), + "messages:write".to_string(), + "channels:read".to_string(), + ] +} + +fn open_log_file(path: &Path) -> Result { + OpenOptions::new() + .create(true) + .append(true) + .open(path) + .map_err(|e| format!("failed to open log file {}: {e}", path.display())) +} + +fn append_log_marker(path: &Path, message: &str) -> Result<(), String> { + let mut file = open_log_file(path)?; + writeln!(file, "{message}").map_err(|e| format!("failed to write log marker: {e}")) +} + +fn read_log_tail(path: &Path, max_lines: usize) -> Result { + if !path.exists() { + return Ok(String::new()); + } + + let file = File::open(path) + .map_err(|e| format!("failed to read log file {}: {e}", path.display()))?; + let reader = BufReader::new(file); + let lines = reader + .lines() + .collect::, _>>() + .map_err(|e| format!("failed to read log lines: {e}"))?; + let start = lines.len().saturating_sub(max_lines); + Ok(lines[start..].join("\n")) +} + +fn run_sprout_admin_command( + program: &Path, + pubkey: &str, + owner_pubkey: &str, + name: &str, + scope_arg: &str, + json: bool, +) -> Result { + let mut command = Command::new(program); + command.arg("mint-token"); + if json { + command.arg("--json"); + } + command.args([ + "--name", + name, + "--scopes", + scope_arg, + "--pubkey", + pubkey, + "--owner-pubkey", + owner_pubkey, + ]); + + command.output().map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + format!( + "failed to run `{}`: command not found. Build the workspace binaries from TESTING.md or set SPROUT_ADMIN_COMMAND explicitly.", + program.display() + ) + } else { + format!("failed to run `{}`: {e}", program.display()) + } + }) +} + +fn output_error_detail(program: &Path, output: &Output) -> String { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let detail = if !stderr.is_empty() { stderr } else { stdout }; + format!("`{}` exited with {}: {detail}", program.display(), output.status) +} + +fn boxed_line_content(line: &str) -> String { + line.trim_matches(|ch: char| ch == '║' || ch.is_whitespace()) + .trim() + .to_string() +} + +fn parse_legacy_sprout_admin_mint_output( + stdout: &str, + pubkey: &str, + name: &str, + scopes: &[String], +) -> Result { + let contents: Vec = stdout + .lines() + .map(boxed_line_content) + .filter(|line| !line.is_empty()) + .collect(); + + let token_id = contents + .iter() + .find_map(|line| line.strip_prefix("Token ID:").map(|value| value.trim().to_string())) + .ok_or_else(|| "failed to parse legacy sprout-admin output: missing token id".to_string())?; + + let api_token_index = contents + .iter() + .position(|line| line == "API Token:") + .ok_or_else(|| "failed to parse legacy sprout-admin output: missing API token label".to_string())?; + let api_token = contents + .iter() + .skip(api_token_index + 1) + .find(|line| !line.ends_with(':')) + .cloned() + .ok_or_else(|| "failed to parse legacy sprout-admin output: missing API token value".to_string())?; + + Ok(SproutAdminMintTokenJsonOutput { + token_id, + name: name.to_string(), + scopes: scopes.to_vec(), + pubkey: pubkey.to_string(), + private_key_nsec: None, + api_token, + }) +} + +fn run_sprout_admin_mint_token( + app: &AppHandle, + pubkey: &str, + owner_pubkey: &str, + name: &str, + scopes: &[String], +) -> Result { + let configured_program = admin_command(); + let program = resolve_command(&configured_program, Some(app)) + .ok_or_else(|| missing_command_message(&configured_program, "sprout-admin command"))?; + let scope_arg = scopes.join(","); + let output = run_sprout_admin_command(&program, pubkey, owner_pubkey, name, &scope_arg, true)?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.contains("unexpected argument '--json'") { + let legacy_output = + run_sprout_admin_command(&program, pubkey, owner_pubkey, name, &scope_arg, false)?; + if !legacy_output.status.success() { + return Err(output_error_detail(&program, &legacy_output)); + } + + return parse_legacy_sprout_admin_mint_output( + &String::from_utf8_lossy(&legacy_output.stdout), + pubkey, + name, + scopes, + ); + } + + return Err(output_error_detail(&program, &output)); + } + + let parsed: SproutAdminMintTokenJsonOutput = serde_json::from_slice(&output.stdout) + .map_err(|e| format!("failed to parse sprout-admin JSON output: {e}"))?; + + if parsed.pubkey.to_lowercase() != pubkey.to_lowercase() { + return Err("sprout-admin returned a token for the wrong pubkey".to_string()); + } + + if parsed.token_id.trim().is_empty() { + return Err("sprout-admin returned an empty token id".to_string()); + } + + if parsed.name.trim() != name.trim() { + return Err("sprout-admin returned a token with the wrong name".to_string()); + } + + if parsed.scopes != scopes { + return Err("sprout-admin returned a token with the wrong scopes".to_string()); + } + + if parsed.private_key_nsec.is_some() { + return Err("sprout-admin unexpectedly returned a private key for an existing pubkey".to_string()); + } + + Ok(parsed) +} + +fn sync_managed_agent_processes( + records: &mut [ManagedAgentRecord], + runtimes: &mut HashMap, +) -> bool { + let mut changed = false; + let mut exited = Vec::new(); + + for (pubkey, runtime) in runtimes.iter_mut() { + let status = match runtime.child.try_wait() { + Ok(status) => status, + Err(error) => { + if let Some(record) = records.iter_mut().find(|record| record.pubkey == *pubkey) { + record.updated_at = now_iso(); + record.last_error = Some(format!("failed to inspect process state: {error}")); + } + changed = true; + exited.push(pubkey.clone()); + continue; + } + }; + + let Some(status) = status else { + continue; + }; + + if let Some(record) = records.iter_mut().find(|record| record.pubkey == *pubkey) { + record.updated_at = now_iso(); + record.last_stopped_at = Some(now_iso()); + record.last_exit_code = status.code(); + record.last_error = if status.success() { + None + } else { + Some(format!("harness exited with status {status}")) + }; + } + + changed = true; + exited.push(pubkey.clone()); + } + + for pubkey in exited { + runtimes.remove(&pubkey); + } + + changed +} + +fn build_managed_agent_summary( + app: &AppHandle, + record: &ManagedAgentRecord, + runtimes: &HashMap, +) -> Result { + let (status, pid, log_path) = if let Some(runtime) = runtimes.get(&record.pubkey) { + ( + "running".to_string(), + Some(runtime.child.id()), + runtime.log_path.display().to_string(), + ) + } else { + ( + "stopped".to_string(), + None, + managed_agent_log_path(app, &record.pubkey)? + .display() + .to_string(), + ) + }; + + Ok(ManagedAgentSummary { + pubkey: record.pubkey.clone(), + name: record.name.clone(), + relay_url: record.relay_url.clone(), + acp_command: record.acp_command.clone(), + agent_command: record.agent_command.clone(), + agent_args: record.agent_args.clone(), + mcp_command: record.mcp_command.clone(), + turn_timeout_seconds: record.turn_timeout_seconds, + has_api_token: record.api_token.is_some(), + status, + pid, + created_at: record.created_at.clone(), + updated_at: record.updated_at.clone(), + last_started_at: record.last_started_at.clone(), + last_stopped_at: record.last_stopped_at.clone(), + last_exit_code: record.last_exit_code, + last_error: record.last_error.clone(), + log_path, + }) +} + +fn find_managed_agent_mut<'a>( + records: &'a mut [ManagedAgentRecord], + pubkey: &str, +) -> Result<&'a mut ManagedAgentRecord, String> { + records + .iter_mut() + .find(|record| record.pubkey == pubkey) + .ok_or_else(|| format!("agent {pubkey} not found")) +} + +fn start_managed_agent_process( + app: &AppHandle, + record: &mut ManagedAgentRecord, + runtimes: &mut HashMap, +) -> Result<(), String> { + if let Some(runtime) = runtimes.get_mut(&record.pubkey) { + if runtime + .child + .try_wait() + .map_err(|e| format!("failed to inspect running process: {e}"))? + .is_none() + { + return Ok(()); + } + + runtimes.remove(&record.pubkey); + } + + let log_path = managed_agent_log_path(app, &record.pubkey)?; + append_log_marker( + &log_path, + &format!( + "\n=== starting {} ({}) at {} ===", + record.name, + record.pubkey, + now_iso() + ), + )?; + + let stdout = open_log_file(&log_path)?; + let stderr = stdout + .try_clone() + .map_err(|e| format!("failed to clone log handle: {e}"))?; + let agent_args = if record.agent_args.is_empty() { + vec![DEFAULT_AGENT_ARG.to_string()] + } else { + record.agent_args.clone() + }; + let resolved_acp_command = resolve_command(&record.acp_command, Some(app)) + .ok_or_else(|| missing_command_message(&record.acp_command, "ACP harness command"))?; + let resolved_mcp_command = resolve_command(&record.mcp_command, Some(app)) + .ok_or_else(|| missing_command_message(&record.mcp_command, "MCP server command"))?; + + let mut command = Command::new(&resolved_acp_command); + command.stdin(Stdio::null()); + command.stdout(Stdio::from(stdout)); + command.stderr(Stdio::from(stderr)); + command.env("SPROUT_PRIVATE_KEY", &record.private_key_nsec); + command.env("SPROUT_RELAY_URL", &record.relay_url); + command.env("SPROUT_ACP_AGENT_COMMAND", &record.agent_command); + command.env("SPROUT_ACP_AGENT_ARGS", agent_args.join(",")); + command.env("SPROUT_ACP_MCP_COMMAND", &resolved_mcp_command); + command.env( + "SPROUT_ACP_TURN_TIMEOUT", + record.turn_timeout_seconds.to_string(), + ); + command.env( + "GOOSE_MODE", + std::env::var("GOOSE_MODE").unwrap_or_else(|_| "auto".to_string()), + ); + command.env_remove("SPROUT_ACP_PRIVATE_KEY"); + command.env_remove("SPROUT_ACP_API_TOKEN"); + + if let Some(token) = &record.api_token { + command.env("SPROUT_API_TOKEN", token); + } else { + command.env_remove("SPROUT_API_TOKEN"); + } + + let child = command.spawn().map_err(|e| { + format!( + "failed to spawn `{}` for agent {}: {e}", + resolved_acp_command.display(), + record.name + ) + })?; + + let now = now_iso(); + record.updated_at = now.clone(); + record.last_started_at = Some(now); + record.last_stopped_at = None; + record.last_exit_code = None; + record.last_error = None; + + runtimes.insert(record.pubkey.clone(), ManagedAgentProcess { child, log_path }); + Ok(()) +} + +fn stop_managed_agent_process( + record: &mut ManagedAgentRecord, + runtimes: &mut HashMap, +) -> Result<(), String> { + let Some(mut runtime) = runtimes.remove(&record.pubkey) else { + return Ok(()); + }; + + let _ = runtime.child.kill(); + let status = runtime + .child + .wait() + .map_err(|e| format!("failed to wait for agent shutdown: {e}"))?; + let now = now_iso(); + record.updated_at = now.clone(); + record.last_stopped_at = Some(now); + record.last_exit_code = status.code(); + record.last_error = None; + + append_log_marker( + &runtime.log_path, + &format!( + "=== stopped {} ({}) at {} ===", + record.name, + record.pubkey, + now_iso() + ), + )?; + + Ok(()) +} + fn build_authed_request( client: &reqwest::Client, method: Method, @@ -368,6 +1197,26 @@ fn auth_pubkey_header(state: &AppState) -> Result { Ok(keys.public_key().to_hex()) } +async fn managed_agent_owner_pubkey(state: &AppState) -> Result { + if state.configured_api_token.is_none() { + return auth_pubkey_header(state); + } + + let request = build_authed_request( + &state.http_client, + Method::GET, + "/api/users/me/profile", + state, + )?; + let profile: ProfileInfo = send_json_request(request).await.map_err(|e| { + format!( + "failed to resolve the authenticated token owner: {e}. Managed-agent minting with SPROUT_API_TOKEN requires a token for the desktop identity with `users:read`." + ) + })?; + + Ok(profile.pubkey) +} + fn session_api_token(state: &AppState) -> Result, String> { let token = state.session_token.lock().map_err(|e| e.to_string())?; Ok(token.clone()) @@ -492,6 +1341,37 @@ fn get_relay_ws_url() -> String { relay_ws_url() } +#[tauri::command] +fn discover_acp_providers() -> Vec { + discover_local_acp_providers() +} + +#[tauri::command] +fn discover_managed_agent_prereqs( + input: DiscoverManagedAgentPrereqsRequest, + app: AppHandle, +) -> ManagedAgentPrereqsInfo { + let acp_command = input + .acp_command + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(DEFAULT_ACP_COMMAND); + let mcp_command = input + .mcp_command + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(DEFAULT_MCP_COMMAND); + let admin_command = admin_command(); + + ManagedAgentPrereqsInfo { + admin: command_availability(&admin_command, Some(&app)), + acp: command_availability(acp_command, Some(&app)), + mcp: command_availability(mcp_command, Some(&app)), + } +} + #[tauri::command] async fn get_profile(state: tauri::State<'_, AppState>) -> Result { let request = build_authed_request( @@ -982,6 +1862,377 @@ async fn revoke_all_tokens( send_json_request(request).await } +#[tauri::command] +async fn list_relay_agents(state: tauri::State<'_, AppState>) -> Result, String> { + let request = build_authed_request(&state.http_client, Method::GET, "/api/agents", &state)?; + send_json_request(request).await +} + +#[tauri::command] +fn list_managed_agents( + app: AppHandle, + state: tauri::State<'_, AppState>, +) -> Result, String> { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let mut records = load_managed_agents(&app)?; + let mut runtimes = state + .managed_agent_processes + .lock() + .map_err(|e| e.to_string())?; + + if sync_managed_agent_processes(&mut records, &mut runtimes) { + save_managed_agents(&app, &records)?; + } + + records + .iter() + .map(|record| build_managed_agent_summary(&app, record, &runtimes)) + .collect() +} + +#[tauri::command] +async fn create_managed_agent( + input: CreateManagedAgentRequest, + app: AppHandle, + state: tauri::State<'_, AppState>, +) -> Result { + let name = input.name.trim(); + if name.is_empty() { + return Err("agent name is required".to_string()); + } + + let owner_pubkey = if input.mint_token { + Some(managed_agent_owner_pubkey(&state).await?) + } else { + None + }; + + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let mut records = load_managed_agents(&app)?; + let mut runtimes = state + .managed_agent_processes + .lock() + .map_err(|e| e.to_string())?; + + if sync_managed_agent_processes(&mut records, &mut runtimes) { + save_managed_agents(&app, &records)?; + } + + let keys = Keys::generate(); + let pubkey = keys.public_key().to_hex(); + if records.iter().any(|record| record.pubkey == pubkey) { + return Err(format!("agent {pubkey} already exists")); + } + + let private_key_nsec = keys + .secret_key() + .to_bech32() + .map_err(|e| format!("failed to encode private key: {e}"))?; + let token_scopes = if input.mint_token { + let requested = input + .token_scopes + .into_iter() + .map(|scope| scope.trim().to_string()) + .filter(|scope| !scope.is_empty()) + .collect::>(); + if requested.is_empty() { + default_token_scopes() + } else { + requested + } + } else { + Vec::new() + }; + let minted = if input.mint_token { + let token_name = input + .token_name + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(name); + Some(run_sprout_admin_mint_token( + &app, + &pubkey, + owner_pubkey + .as_deref() + .ok_or_else(|| "managed agent owner pubkey was not resolved".to_string())?, + token_name, + &token_scopes, + )?) + } else { + None + }; + let resolved_relay_url = input + .relay_url + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(relay_ws_url); + + let mut record = ManagedAgentRecord { + pubkey: pubkey.clone(), + name: name.to_string(), + private_key_nsec: private_key_nsec.clone(), + api_token: minted.as_ref().map(|output| output.api_token.clone()), + relay_url: resolved_relay_url, + acp_command: input + .acp_command + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(DEFAULT_ACP_COMMAND) + .to_string(), + agent_command: input + .agent_command + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(DEFAULT_AGENT_COMMAND) + .to_string(), + agent_args: input + .agent_args + .into_iter() + .map(|arg| arg.trim().to_string()) + .filter(|arg| !arg.is_empty()) + .collect::>(), + mcp_command: input + .mcp_command + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(DEFAULT_MCP_COMMAND) + .to_string(), + turn_timeout_seconds: input + .turn_timeout_seconds + .filter(|seconds| *seconds > 0) + .unwrap_or(DEFAULT_AGENT_TURN_TIMEOUT_SECONDS), + created_at: now_iso(), + updated_at: now_iso(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + }; + + if record.agent_args.is_empty() { + record.agent_args.push(DEFAULT_AGENT_ARG.to_string()); + } + + records.push(record); + + let mut spawn_error = None; + if input.spawn_after_create { + let record = find_managed_agent_mut(&mut records, &pubkey)?; + if let Err(error) = start_managed_agent_process(&app, record, &mut runtimes) { + record.updated_at = now_iso(); + record.last_error = Some(error.clone()); + spawn_error = Some(error); + } + } + + save_managed_agents(&app, &records)?; + + let record = records + .iter() + .find(|record| record.pubkey == pubkey) + .ok_or_else(|| "created agent disappeared unexpectedly".to_string())?; + let agent = build_managed_agent_summary(&app, record, &runtimes)?; + + Ok(CreateManagedAgentResponse { + agent, + private_key_nsec, + api_token: minted.map(|output| output.api_token), + spawn_error, + }) +} + +#[tauri::command] +fn start_managed_agent( + pubkey: String, + app: AppHandle, + state: tauri::State<'_, AppState>, +) -> Result { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let mut records = load_managed_agents(&app)?; + let mut runtimes = state + .managed_agent_processes + .lock() + .map_err(|e| e.to_string())?; + + if sync_managed_agent_processes(&mut records, &mut runtimes) { + save_managed_agents(&app, &records)?; + } + + let record = find_managed_agent_mut(&mut records, &pubkey)?; + start_managed_agent_process(&app, record, &mut runtimes)?; + let _ = record; + save_managed_agents(&app, &records)?; + let record = records + .iter() + .find(|record| record.pubkey == pubkey) + .ok_or_else(|| format!("agent {pubkey} not found"))?; + build_managed_agent_summary(&app, record, &runtimes) +} + +#[tauri::command] +fn stop_managed_agent( + pubkey: String, + app: AppHandle, + state: tauri::State<'_, AppState>, +) -> Result { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let mut records = load_managed_agents(&app)?; + let mut runtimes = state + .managed_agent_processes + .lock() + .map_err(|e| e.to_string())?; + + if sync_managed_agent_processes(&mut records, &mut runtimes) { + save_managed_agents(&app, &records)?; + } + + let record = find_managed_agent_mut(&mut records, &pubkey)?; + stop_managed_agent_process(record, &mut runtimes)?; + let _ = record; + save_managed_agents(&app, &records)?; + let record = records + .iter() + .find(|record| record.pubkey == pubkey) + .ok_or_else(|| format!("agent {pubkey} not found"))?; + build_managed_agent_summary(&app, record, &runtimes) +} + +#[tauri::command] +fn delete_managed_agent( + pubkey: String, + app: AppHandle, + state: tauri::State<'_, AppState>, +) -> Result<(), String> { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let mut records = load_managed_agents(&app)?; + let mut runtimes = state + .managed_agent_processes + .lock() + .map_err(|e| e.to_string())?; + + if sync_managed_agent_processes(&mut records, &mut runtimes) { + save_managed_agents(&app, &records)?; + } + + if let Some(record) = records.iter_mut().find(|record| record.pubkey == pubkey) { + stop_managed_agent_process(record, &mut runtimes)?; + } + + let initial_len = records.len(); + records.retain(|record| record.pubkey != pubkey); + if records.len() == initial_len { + return Err(format!("agent {pubkey} not found")); + } + + save_managed_agents(&app, &records) +} + +#[tauri::command] +async fn mint_managed_agent_token( + input: MintManagedAgentTokenRequest, + app: AppHandle, + state: tauri::State<'_, AppState>, +) -> Result { + let owner_pubkey = managed_agent_owner_pubkey(&state).await?; + + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let mut records = load_managed_agents(&app)?; + let mut runtimes = state + .managed_agent_processes + .lock() + .map_err(|e| e.to_string())?; + + if sync_managed_agent_processes(&mut records, &mut runtimes) { + save_managed_agents(&app, &records)?; + } + + let record = find_managed_agent_mut(&mut records, &input.pubkey)?; + let scopes = input + .scopes + .into_iter() + .map(|scope| scope.trim().to_string()) + .filter(|scope| !scope.is_empty()) + .collect::>(); + let scopes = if scopes.is_empty() { + default_token_scopes() + } else { + scopes + }; + let token_name = input + .token_name + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("{}-token", record.name)); + let minted = + run_sprout_admin_mint_token(&app, &record.pubkey, &owner_pubkey, &token_name, &scopes)?; + + record.api_token = Some(minted.api_token.clone()); + record.updated_at = now_iso(); + record.last_error = None; + let pubkey = record.pubkey.clone(); + + save_managed_agents(&app, &records)?; + let record = records + .iter() + .find(|record| record.pubkey == pubkey) + .ok_or_else(|| format!("agent {pubkey} not found"))?; + let agent = build_managed_agent_summary(&app, record, &runtimes)?; + + Ok(MintManagedAgentTokenResponse { + agent, + token: minted.api_token, + }) +} + +#[tauri::command] +fn get_managed_agent_log( + pubkey: String, + line_count: Option, + app: AppHandle, + state: tauri::State<'_, AppState>, +) -> Result { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let records = load_managed_agents(&app)?; + if !records.iter().any(|record| record.pubkey == pubkey) { + return Err(format!("agent {pubkey} not found")); + } + + let log_path = managed_agent_log_path(&app, &pubkey)?; + Ok(ManagedAgentLogResponse { + content: read_log_tail(&log_path, line_count.unwrap_or(120) as usize)?, + log_path: log_path.display().to_string(), + }) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { // GUI app: warn on bad key but don't crash — fall back to ephemeral. @@ -1020,6 +2271,8 @@ pub fn run() { http_client: reqwest::Client::new(), configured_api_token: api_token, session_token: Mutex::new(None), + managed_agents_store_lock: Mutex::new(()), + managed_agent_processes: Mutex::new(HashMap::new()), }; tauri::Builder::default() @@ -1042,6 +2295,8 @@ pub fn run() { get_presence, set_presence, get_relay_ws_url, + discover_acp_providers, + discover_managed_agent_prereqs, sign_event, create_auth_event, get_channels, @@ -1068,6 +2323,14 @@ pub fn run() { mint_token, revoke_token, revoke_all_tokens, + list_relay_agents, + list_managed_agents, + create_managed_agent, + start_managed_agent, + stop_managed_agent, + delete_managed_agent, + mint_managed_agent_token, + get_managed_agent_log, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index dd9460626b..095ac501be 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import { useQueryClient } from "@tanstack/react-query"; import { Settings2 } from "lucide-react"; +import { AgentsView } from "@/features/agents/ui/AgentsView"; import { ChatHeader } from "@/features/chat/ui/ChatHeader"; import { channelsQueryKey, @@ -50,7 +51,7 @@ import { SidebarTrigger, } from "@/shared/ui/sidebar"; -type AppView = "home" | "channel" | "settings"; +type AppView = "home" | "channel" | "settings" | "agents"; function createSearchAnchorEvent(hit: SearchHit): RelayEvent { return { @@ -200,9 +201,11 @@ export function AppShell() { const contentPaneKey = selectedView === "home" ? "home" - : selectedView === "settings" - ? "settings" - : `channel:${activeChannel?.id ?? "none"}`; + : selectedView === "agents" + ? "agents" + : selectedView === "settings" + ? "settings" + : `channel:${activeChannel?.id ?? "none"}`; const isTimelineLoading = messagesQuery.isLoading && resolvedMessages.length === 0; @@ -434,6 +437,11 @@ export function AppShell() { setIsSearchOpen(true); void refetchChannels(); }} + onSelectAgents={() => { + React.startTransition(() => { + setSelectedView("agents"); + }); + }} onSelectHome={() => { React.startTransition(() => { setSelectedView("home"); @@ -459,6 +467,12 @@ export function AppShell() { mode="home" title="Home" /> + ) : selectedView === "agents" ? ( + ) : selectedView === "settings" ? ( + ) : selectedView === "agents" ? ( + ) : selectedView === "settings" ? ( + discoverManagedAgentPrereqs({ + acpCommand: normalizedAcpCommand || undefined, + mcpCommand: normalizedMcpCommand || undefined, + }), + staleTime: 15_000, + }); +} + +export function useRelayAgentsQuery() { + return useQuery({ + queryKey: relayAgentsQueryKey, + queryFn: listRelayAgents, + staleTime: 15_000, + }); +} + +export function useManagedAgentsQuery() { + return useQuery({ + queryKey: managedAgentsQueryKey, + queryFn: listManagedAgents, + staleTime: 1_000, + refetchInterval: (query) => { + const agents = query.state.data as ManagedAgent[] | undefined; + return agents?.some((agent) => agent.status === "running") + ? 2_000 + : 10_000; + }, + }); +} + +export function useCreateManagedAgentMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (input: CreateManagedAgentInput) => createManagedAgent(input), + onSettled: async () => { + await queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }); + await queryClient.invalidateQueries({ queryKey: relayAgentsQueryKey }); + }, + }); +} + +export function useStartManagedAgentMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (pubkey: string) => startManagedAgent(pubkey), + onSettled: async () => { + await queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }); + await queryClient.invalidateQueries({ queryKey: relayAgentsQueryKey }); + }, + }); +} + +export function useStopManagedAgentMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (pubkey: string) => stopManagedAgent(pubkey), + onSettled: async () => { + await queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }); + await queryClient.invalidateQueries({ queryKey: relayAgentsQueryKey }); + }, + }); +} + +export function useDeleteManagedAgentMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (pubkey: string) => deleteManagedAgent(pubkey), + onSettled: async () => { + await queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }); + await queryClient.invalidateQueries({ queryKey: relayAgentsQueryKey }); + }, + }); +} + +export function useMintManagedAgentTokenMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (input: MintManagedAgentTokenInput) => + mintManagedAgentToken(input), + onSettled: async () => { + await queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }); + await queryClient.invalidateQueries({ queryKey: relayAgentsQueryKey }); + }, + }); +} + +export function useManagedAgentLogQuery( + pubkey: string | null, + lineCount = 120, +) { + return useQuery({ + queryKey: ["managed-agent-log", pubkey, lineCount], + queryFn: () => { + if (!pubkey) { + throw new Error("No agent selected."); + } + + return getManagedAgentLog(pubkey, lineCount); + }, + enabled: pubkey !== null, + staleTime: 1_000, + refetchInterval: pubkey ? 2_000 : false, + }); +} diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx new file mode 100644 index 0000000000..78a6902b8f --- /dev/null +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -0,0 +1,1669 @@ +import { + Check, + CircleAlert, + Copy, + KeyRound, + Play, + Plus, + RefreshCcw, + Square, + TerminalSquare, + Trash2, + UserPlus, +} from "lucide-react"; +import * as React from "react"; + +import { + useAcpProvidersQuery, + useCreateManagedAgentMutation, + useDeleteManagedAgentMutation, + useManagedAgentLogQuery, + useManagedAgentPrereqsQuery, + useManagedAgentsQuery, + useMintManagedAgentTokenMutation, + useRelayAgentsQuery, + useStartManagedAgentMutation, + useStopManagedAgentMutation, +} from "@/features/agents/hooks"; +import { + useChannelsQuery, + useAddChannelMembersMutation, +} from "@/features/channels/hooks"; +import { PresenceBadge } from "@/features/presence/ui/PresenceBadge"; +import type { + Channel, + ChannelRole, + CreateManagedAgentInput, + CreateManagedAgentResponse, + ManagedAgent, + RelayAgent, + TokenScope, +} from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; +import { Input } from "@/shared/ui/input"; +import { Skeleton } from "@/shared/ui/skeleton"; + +const AGENT_SCOPE_OPTIONS: Array<{ value: TokenScope; label: string }> = [ + { value: "messages:read", label: "Messages read" }, + { value: "messages:write", label: "Messages write" }, + { value: "channels:read", label: "Channels read" }, + { value: "channels:write", label: "Channels write" }, + { value: "users:read", label: "Users read" }, + { value: "files:read", label: "Files read" }, + { value: "files:write", label: "Files write" }, +]; + +function formatTimestamp(value: string | null) { + if (!value) { + return "Never"; + } + + return new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }).format(new Date(value)); +} + +function truncatePubkey(pubkey: string) { + return `${pubkey.slice(0, 8)}…${pubkey.slice(-6)}`; +} + +function commandLooksLikePath(command: string) { + const trimmed = command.trim(); + return ( + trimmed.startsWith(".") || + trimmed.startsWith("~") || + trimmed.includes("/") || + trimmed.includes("\\") + ); +} + +function describeResolvedCommand(command: string, resolvedPath: string) { + const normalized = resolvedPath.replace(/\\/g, "/"); + + if (normalized.includes("/target/release/")) { + return "workspace release build"; + } + if (normalized.includes("/target/debug/")) { + return "workspace debug build"; + } + + if (commandLooksLikePath(command)) { + return "custom command"; + } + + return "installed on PATH"; +} + +function describeLogFile(path: string) { + const normalized = path.replace(/\\/g, "/"); + const basename = normalized.split("/").pop() ?? path; + + if (!basename.endsWith(".log")) { + return "local harness log"; + } + + const stem = basename.slice(0, -4); + if (stem.length <= 18) { + return basename; + } + + return `${stem.slice(0, 8)}…${stem.slice(-6)}.log`; +} + +function CopyButton({ value, label }: { value: string; label?: string }) { + const [copied, setCopied] = React.useState(false); + + return ( + + ); +} + +function SecretRevealDialog({ + created, + onOpenChange, +}: { + created: CreateManagedAgentResponse | null; + onOpenChange: (open: boolean) => void; +}) { + return ( + + +
+ + Agent created + + Save the private key and token now. The app can keep running the + harness locally, but these secrets are only revealed here. + + + +
+ {created ? ( + <> +
+
+
+

+ Private key (nsec) +

+

+ This is the agent identity used by `sprout-acp`. +

+
+ +
+ + {created.privateKeyNsec} + +
+ + {created.apiToken ? ( +
+
+
+

+ API token +

+

+ Optional for local dev, required when the relay + enforces bearer auth. +

+
+ +
+ + {created.apiToken} + +
+ ) : null} + + {created.spawnError ? ( +

+ {created.spawnError} +

+ ) : ( +

+ {created.agent.name} is ready + {created.agent.status === "running" ? " and running." : "."} +

+ )} + + ) : null} +
+ +
+ +
+
+
+
+ ); +} + +function TokenRevealDialog({ + name, + token, + onOpenChange, +}: { + name: string | null; + token: string | null; + onOpenChange: (open: boolean) => void; +}) { + return ( + + +
+ + Agent token minted + + Save this token now. Restart the harness if you want the running + agent to pick it up immediately. + + + +
+
+
+
+

{name}

+

+ Token shown once only. +

+
+ {token ? : null} +
+ {token ? ( + + {token} + + ) : null} +
+
+ +
+ +
+
+
+
+ ); +} + +function CreateAgentDialog({ + open, + onCreated, + onOpenChange, +}: { + open: boolean; + onCreated: (result: CreateManagedAgentResponse) => void; + onOpenChange: (open: boolean) => void; +}) { + const createMutation = useCreateManagedAgentMutation(); + const providersQuery = useAcpProvidersQuery(); + const [acpCommand, setAcpCommand] = React.useState("sprout-acp"); + const [agentCommand, setAgentCommand] = React.useState("goose"); + const [agentArgs, setAgentArgs] = React.useState("acp"); + const [mcpCommand, setMcpCommand] = React.useState("sprout-mcp-server"); + const prereqsQuery = useManagedAgentPrereqsQuery(acpCommand, mcpCommand); + const [name, setName] = React.useState(""); + const [relayUrl, setRelayUrl] = React.useState(""); + const [mintToken, setMintToken] = React.useState(true); + const [spawnAfterCreate, setSpawnAfterCreate] = React.useState(true); + const [tokenName, setTokenName] = React.useState(""); + const [selectedScopes, setSelectedScopes] = React.useState>( + () => + new Set(["messages:read", "messages:write", "channels:read"]), + ); + const [turnTimeoutSeconds, setTurnTimeoutSeconds] = React.useState("300"); + const [selectedProviderId, setSelectedProviderId] = + React.useState("custom"); + const [hasSyncedProviderSelection, setHasSyncedProviderSelection] = + React.useState(false); + const providers = providersQuery.data ?? []; + const prereqs = prereqsQuery.data ?? null; + const selectedProvider = React.useMemo( + () => + providers.find((provider) => provider.id === selectedProviderId) ?? null, + [providers, selectedProviderId], + ); + const isMintSupported = prereqs?.admin.available ?? false; + const isSpawnSupported = + prereqs?.acp.available === true && prereqs?.mcp.available === true; + const mintToggleDisabled = + prereqsQuery.isLoading || (prereqs !== null && !isMintSupported); + const spawnToggleDisabled = + prereqsQuery.isLoading || (prereqs !== null && !isSpawnSupported); + const isDiscoveryPending = providersQuery.isLoading || prereqsQuery.isLoading; + const prerequisiteCards = [ + { + id: "admin", + label: "Token minting", + info: prereqs?.admin ?? null, + command: prereqs?.admin.command ?? "sprout-admin", + }, + { + id: "acp", + label: "ACP harness", + info: prereqs?.acp ?? null, + command: prereqs?.acp.command ?? (acpCommand.trim() || "sprout-acp"), + }, + { + id: "mcp", + label: "MCP server", + info: prereqs?.mcp ?? null, + command: + prereqs?.mcp.command ?? (mcpCommand.trim() || "sprout-mcp-server"), + }, + ]; + + React.useEffect(() => { + if (hasSyncedProviderSelection || providersQuery.isLoading) { + return; + } + + const matchingProvider = + providers.find((provider) => provider.command === agentCommand) ?? null; + if (matchingProvider) { + setSelectedProviderId(matchingProvider.id); + } + setHasSyncedProviderSelection(true); + }, [ + agentCommand, + hasSyncedProviderSelection, + providers, + providersQuery.isLoading, + ]); + + React.useEffect(() => { + if (!prereqs || prereqs.admin.available || !mintToken) { + return; + } + + setMintToken(false); + }, [mintToken, prereqs]); + + React.useEffect(() => { + if ( + !prereqs || + (prereqs.acp.available && prereqs.mcp.available) || + !spawnAfterCreate + ) { + return; + } + + setSpawnAfterCreate(false); + }, [prereqs, spawnAfterCreate]); + + function reset() { + setName(""); + setRelayUrl(""); + setMintToken(true); + setSpawnAfterCreate(true); + setTokenName(""); + setSelectedScopes( + new Set(["messages:read", "messages:write", "channels:read"]), + ); + setAcpCommand("sprout-acp"); + setAgentCommand("goose"); + setAgentArgs("acp"); + setMcpCommand("sprout-mcp-server"); + setTurnTimeoutSeconds("300"); + setSelectedProviderId("custom"); + setHasSyncedProviderSelection(false); + createMutation.reset(); + } + + function handleOpenChange(next: boolean) { + if (!next) { + reset(); + } + + onOpenChange(next); + } + + function toggleScope(scope: TokenScope) { + setSelectedScopes((previous) => { + const next = new Set(previous); + if (next.has(scope)) { + next.delete(scope); + } else { + next.add(scope); + } + return next; + }); + } + + function handleProviderChange(nextProviderId: string) { + setSelectedProviderId(nextProviderId); + + if (nextProviderId === "custom") { + return; + } + + const provider = providers.find( + (candidate) => candidate.id === nextProviderId, + ); + if (!provider) { + return; + } + + setAgentCommand(provider.command); + setAgentArgs(provider.defaultArgs.join(",")); + } + + const canSubmit = + name.trim().length > 0 && + (!mintToken || selectedScopes.size > 0) && + !isDiscoveryPending && + !(mintToken && prereqs !== null && !isMintSupported) && + !(spawnAfterCreate && prereqs !== null && !isSpawnSupported) && + !createMutation.isPending; + + async function handleSubmit() { + try { + const input: CreateManagedAgentInput = { + name: name.trim(), + relayUrl: relayUrl.trim() || undefined, + acpCommand: acpCommand.trim() || undefined, + agentCommand: agentCommand.trim() || undefined, + agentArgs: agentArgs + .split(",") + .map((value) => value.trim()) + .filter((value) => value.length > 0), + mcpCommand: mcpCommand.trim() || undefined, + turnTimeoutSeconds: + Number.parseInt(turnTimeoutSeconds, 10) > 0 + ? Number.parseInt(turnTimeoutSeconds, 10) + : undefined, + mintToken, + tokenName: tokenName.trim() || undefined, + tokenScopes: [...selectedScopes], + spawnAfterCreate, + }; + const created = await createMutation.mutateAsync(input); + handleOpenChange(false); + onCreated(created); + } catch { + // React Query stores the error; keep the dialog open and render it inline. + } + } + + return ( + + +
+ + Create agent + + This creates a local agent identity, optionally mints a relay + token, and can spawn `sprout-acp` immediately. + + + +
+
+
+ + setName(event.target.value)} + placeholder="alice" + value={name} + /> +
+ +
+ + setRelayUrl(event.target.value)} + placeholder="Leave blank to use the desktop relay" + value={relayUrl} + /> +
+
+ +
+
+ + setAcpCommand(event.target.value)} + value={acpCommand} + /> +
+ +
+ + + {selectedProvider ? ( +

+ Detected via{" "} + + {describeResolvedCommand( + selectedProvider.command, + selectedProvider.binaryPath, + )} + +

+ ) : providersQuery.isLoading ? ( +

+ Looking for installed ACP runtimes... +

+ ) : ( +

+ No known ACP runtime was detected. You can still enter a + custom command below. +

+ )} +
+
+ + {selectedProviderId === "custom" ? ( +
+ + setAgentCommand(event.target.value)} + value={agentCommand} + /> +
+ ) : null} + +
+
+ + setAgentArgs(event.target.value)} + placeholder="Comma-separated" + value={agentArgs} + /> +

+ `sprout-acp` splits args on commas, matching the testing + guide. +

+
+ +
+ + setMcpCommand(event.target.value)} + value={mcpCommand} + /> +
+ +
+ + + setTurnTimeoutSeconds(event.target.value) + } + value={turnTimeoutSeconds} + /> +
+
+ +
+
+
+

+ Local Sprout binaries +

+

+ The desktop app uses these commands to mint tokens and spawn + harnesses. +

+
+ {prereqsQuery.isLoading ? ( + + Checking... + + ) : null} +
+ +
+ {prerequisiteCards.map((card) => ( +
+

+ {card.label} +

+

{card.command}

+

+ {card.info?.resolvedPath + ? `Available via ${describeResolvedCommand(card.command, card.info.resolvedPath)}` + : prereqsQuery.isLoading + ? "Looking for a matching binary..." + : "Not currently available."} +

+
+ ))} +
+ + {prereqs && + (!prereqs.admin.available || + !prereqs.acp.available || + !prereqs.mcp.available) ? ( +

+ Build the workspace binaries with `cargo build --release + --workspace` or point the command fields at installed binaries + before enabling token minting or spawn. +

+ ) : null} +
+ + {providersQuery.error instanceof Error ? ( +

+ {providersQuery.error.message} +

+ ) : null} + + {prereqsQuery.error instanceof Error ? ( +

+ {prereqsQuery.error.message} +

+ ) : null} + +
+ + + +
+ + {mintToken ? ( +
+
+ + setTokenName(event.target.value)} + placeholder="Leave blank to reuse the agent name" + value={tokenName} + /> +
+ +
+

Token scopes

+
+ {AGENT_SCOPE_OPTIONS.map((scope) => { + const selected = selectedScopes.has(scope.value); + return ( + + ); + })} +
+
+
+ ) : null} + + {createMutation.error instanceof Error ? ( +

+ {createMutation.error.message} +

+ ) : null} +
+ +
+ + +
+
+
+
+ ); +} + +function AddAgentToChannelDialog({ + agent, + open, + onAdded, + onOpenChange, +}: { + agent: ManagedAgent | null; + open: boolean; + onAdded: (channel: Channel, agent: ManagedAgent) => void; + onOpenChange: (open: boolean) => void; +}) { + const channelsQuery = useChannelsQuery(); + const [channelId, setChannelId] = React.useState(""); + const [role, setRole] = React.useState>("bot"); + const addMembersMutation = useAddChannelMembersMutation(channelId || null); + const channels = React.useMemo( + () => + (channelsQuery.data ?? []).filter( + (channel) => channel.channelType !== "dm" && !channel.archivedAt, + ), + [channelsQuery.data], + ); + + function reset() { + setChannelId(""); + setRole("bot"); + addMembersMutation.reset(); + } + + function handleOpenChange(next: boolean) { + if (!next) { + reset(); + } + + onOpenChange(next); + } + + React.useEffect(() => { + if (!open) { + return; + } + + if (!channelId && channels.length > 0) { + setChannelId(channels[0].id); + } + }, [channelId, channels, open]); + + const selectedChannel = + channels.find((channel) => channel.id === channelId) ?? null; + + async function handleSubmit() { + if (!agent || !selectedChannel) { + return; + } + + try { + const result = await addMembersMutation.mutateAsync({ + pubkeys: [agent.pubkey], + role, + }); + const membershipError = result.errors.find( + (error) => error.pubkey === agent.pubkey, + ); + + if (membershipError) { + throw new Error(membershipError.error); + } + + onAdded(selectedChannel, agent); + handleOpenChange(false); + } catch { + // React Query stores the error; keep the dialog open and render it inline. + } + } + + return ( + + +
+ + Add agent to channel + + Add {agent?.name ?? "this agent"} to a channel so desktop chat can + `@mention` it. If the harness is already running, restart it after + adding membership so it subscribes to the new channel. + + + +
+
+ + +

+ Only channels accessible to the current desktop user are shown + here. +

+
+ +
+ + +
+ +
+

+ Agent pubkey +

+
+ + {agent?.pubkey ?? "No agent selected"} + + {agent ? ( + + ) : null} +
+
+ + {channelsQuery.error instanceof Error ? ( +

+ {channelsQuery.error.message} +

+ ) : null} + + {addMembersMutation.error instanceof Error ? ( +

+ {addMembersMutation.error.message} +

+ ) : null} +
+ +
+ + +
+
+
+
+ ); +} + +function ManagedAgentCard({ + agent, + onAddToChannel, + isSelected, + onDelete, + onMintToken, + onSelect, + onStart, + onStop, +}: { + agent: ManagedAgent; + onAddToChannel: (agent: ManagedAgent) => void; + isSelected: boolean; + onDelete: (pubkey: string) => void; + onMintToken: (pubkey: string, name: string) => void; + onSelect: (pubkey: string) => void; + onStart: (pubkey: string) => void; + onStop: (pubkey: string) => void; +}) { + const statusBadgeClass = + agent.status === "running" + ? "bg-primary text-primary-foreground" + : "bg-muted text-muted-foreground"; + + return ( +
+ + +
+
+

+ Relay +

+

{agent.relayUrl}

+
+
+

+ Auth +

+

+ {agent.hasApiToken ? "Bearer token saved" : "Key-only dev mode"} +

+
+
+ +
+ + + {agent.status === "running" ? ( + + ) : ( + + )} + + + + + + +
+ +
+

Started {formatTimestamp(agent.lastStartedAt)}

+

Stopped {formatTimestamp(agent.lastStoppedAt)}

+
+ + {agent.lastError ? ( +

+ {agent.lastError} +

+ ) : null} +
+ ); +} + +function RelayAgentCard({ + agent, + isManagedLocally, +}: { + agent: RelayAgent; + isManagedLocally: boolean; +}) { + const visibleCapabilities = agent.capabilities.slice(0, 4); + const hiddenCapabilityCount = + agent.capabilities.length - visibleCapabilities.length; + + return ( +
+
+
+
+

+ {agent.name} +

+ {isManagedLocally ? ( + + Local + + ) : null} +
+

+ {truncatePubkey(agent.pubkey)} + {agent.agentType ? ` · ${agent.agentType}` : ""} +

+
+ +
+ +
+ {visibleCapabilities.map((capability) => ( + + {capability} + + ))} + {hiddenCapabilityCount > 0 ? ( + + +{hiddenCapabilityCount} + + ) : null} +
+ +

+ {agent.channels.length > 0 + ? `Visible in ${agent.channels.join(", ")}` + : "No visible channel memberships yet."} +

+
+ ); +} + +export function AgentsView() { + const relayAgentsQuery = useRelayAgentsQuery(); + const managedAgentsQuery = useManagedAgentsQuery(); + const startMutation = useStartManagedAgentMutation(); + const stopMutation = useStopManagedAgentMutation(); + const deleteMutation = useDeleteManagedAgentMutation(); + const mintTokenMutation = useMintManagedAgentTokenMutation(); + const [isCreateOpen, setIsCreateOpen] = React.useState(false); + const [agentToAddToChannel, setAgentToAddToChannel] = + React.useState(null); + const [createdAgent, setCreatedAgent] = + React.useState(null); + const [revealedToken, setRevealedToken] = React.useState<{ + name: string; + token: string; + } | null>(null); + const [actionNoticeMessage, setActionNoticeMessage] = React.useState< + string | null + >(null); + const [actionErrorMessage, setActionErrorMessage] = React.useState< + string | null + >(null); + const managedAgents = React.useMemo( + () => + [...(managedAgentsQuery.data ?? [])].sort((left, right) => { + if (left.status !== right.status) { + return left.status === "running" ? -1 : 1; + } + + return left.name.localeCompare(right.name); + }), + [managedAgentsQuery.data], + ); + const [selectedAgentPubkey, setSelectedAgentPubkey] = React.useState< + string | null + >(null); + const selectedAgent = + managedAgents.find((agent) => agent.pubkey === selectedAgentPubkey) ?? + managedAgents[0] ?? + null; + const managedAgentLogQuery = useManagedAgentLogQuery( + selectedAgent?.pubkey ?? null, + ); + const managedPubkeys = React.useMemo( + () => new Set(managedAgents.map((agent) => agent.pubkey)), + [managedAgents], + ); + + React.useEffect(() => { + if ( + selectedAgentPubkey && + managedAgents.some((agent) => agent.pubkey === selectedAgentPubkey) + ) { + return; + } + + setSelectedAgentPubkey(managedAgents[0]?.pubkey ?? null); + }, [managedAgents, selectedAgentPubkey]); + + async function handleStart(pubkey: string) { + setActionNoticeMessage(null); + setActionErrorMessage(null); + + try { + await startMutation.mutateAsync(pubkey); + } catch (error) { + setActionErrorMessage( + error instanceof Error ? error.message : "Failed to start agent.", + ); + } + } + + async function handleStop(pubkey: string) { + setActionNoticeMessage(null); + setActionErrorMessage(null); + + try { + await stopMutation.mutateAsync(pubkey); + } catch (error) { + setActionErrorMessage( + error instanceof Error ? error.message : "Failed to stop agent.", + ); + } + } + + async function handleDelete(pubkey: string) { + setActionNoticeMessage(null); + setActionErrorMessage(null); + + try { + await deleteMutation.mutateAsync(pubkey); + if (selectedAgentPubkey === pubkey) { + setSelectedAgentPubkey(null); + } + } catch (error) { + setActionErrorMessage( + error instanceof Error ? error.message : "Failed to delete agent.", + ); + } + } + + async function handleMintToken(pubkey: string, name: string) { + setActionNoticeMessage(null); + setActionErrorMessage(null); + + try { + const result = await mintTokenMutation.mutateAsync({ + pubkey, + tokenName: `${name}-token`, + }); + setRevealedToken({ + name, + token: result.token, + }); + } catch (error) { + setActionErrorMessage( + error instanceof Error ? error.message : "Failed to mint token.", + ); + } + } + + function handleAddedToChannel(channel: Channel, agent: ManagedAgent) { + setActionErrorMessage(null); + setActionNoticeMessage( + agent.status === "running" + ? `Added ${agent.name} to ${channel.name}. Restart the agent to subscribe to the new channel.` + : `Added ${agent.name} to ${channel.name}.`, + ); + void managedAgentsQuery.refetch(); + void relayAgentsQuery.refetch(); + } + + const isActionPending = + startMutation.isPending || + stopMutation.isPending || + deleteMutation.isPending || + mintTokenMutation.isPending; + + return ( + <> +
+
+
+
+
+
+

+ Managed locally +

+

+ Saved agent profiles and local ACP process state. +

+
+
+ + +
+
+ + {managedAgentsQuery.isLoading ? ( +
+ {["first", "second"].map((key) => ( +
+ + + +
+ ))} +
+ ) : null} + + {!managedAgentsQuery.isLoading && managedAgents.length === 0 ? ( +
+

+ No local agents yet +

+

+ Create one to generate a keypair, mint a token, and launch + the ACP harness from the desktop app. +

+
+ ) : null} + + {managedAgents.map((agent) => ( + { + if (!isActionPending) { + setActionNoticeMessage(null); + setActionErrorMessage(null); + setAgentToAddToChannel(managedAgent); + } + }} + onDelete={(pubkey) => { + if (!isActionPending) { + void handleDelete(pubkey); + } + }} + onMintToken={(pubkey, name) => { + if (!isActionPending) { + void handleMintToken(pubkey, name); + } + }} + onSelect={setSelectedAgentPubkey} + onStart={(pubkey) => { + if (!isActionPending) { + void handleStart(pubkey); + } + }} + onStop={(pubkey) => { + if (!isActionPending) { + void handleStop(pubkey); + } + }} + /> + ))} + + {managedAgentsQuery.error instanceof Error ? ( +

+ {managedAgentsQuery.error.message} +

+ ) : null} + + {actionNoticeMessage ? ( +

+ {actionNoticeMessage} +

+ ) : null} + + {actionErrorMessage ? ( +

+ {actionErrorMessage} +

+ ) : null} +
+ +
+
+

+ Relay directory +

+

+ Bot and agent identities visible to the current desktop user. +

+
+ + {relayAgentsQuery.isLoading ? ( +
+ {["directory-1", "directory-2"].map((key) => ( +
+ + + +
+ ))} +
+ ) : null} + + {!relayAgentsQuery.isLoading && + (relayAgentsQuery.data?.length ?? 0) === 0 ? ( +
+

+ No relay-visible agents yet +

+

+ Start one of your local harnesses or join an existing bot to + a channel and it will appear here. +

+
+ ) : null} + + {relayAgentsQuery.data?.map((agent) => ( + + ))} + + {relayAgentsQuery.error instanceof Error ? ( +

+ {relayAgentsQuery.error.message} +

+ ) : null} +
+
+ +
+
+
+

+ Harness log +

+

+ {selectedAgent + ? `${selectedAgent.name} · ${describeLogFile(selectedAgent.logPath)}` + : "Select a local agent to inspect recent output."} +

+
+ {selectedAgent ? ( + + ) : null} +
+ + {!selectedAgent ? ( +
+

+ No local agent selected +

+

+ Pick a managed agent to view the latest ACP log output. +

+
+ ) : managedAgentLogQuery.isLoading ? ( +
+ + + + +
+ ) : ( +
+
+ {selectedAgent.name} + {selectedAgent.status} +
+
+                  {managedAgentLogQuery.data?.content?.trim()
+                    ? managedAgentLogQuery.data.content
+                    : "No log output yet."}
+                
+
+ )} + + {managedAgentLogQuery.error instanceof Error ? ( +

+ + {managedAgentLogQuery.error.message} +

+ ) : null} +
+
+
+ + { + setCreatedAgent(result); + }} + onOpenChange={setIsCreateOpen} + open={isCreateOpen} + /> + { + if (!open) { + setAgentToAddToChannel(null); + } + }} + open={agentToAddToChannel !== null} + /> + { + if (!open) { + setCreatedAgent(null); + } + }} + /> + { + if (!open) { + setRevealedToken(null); + } + }} + token={revealedToken?.token ?? null} + /> + + ); +} diff --git a/desktop/src/features/chat/ui/ChatHeader.tsx b/desktop/src/features/chat/ui/ChatHeader.tsx index 1ce1706721..b3a5222afe 100644 --- a/desktop/src/features/chat/ui/ChatHeader.tsx +++ b/desktop/src/features/chat/ui/ChatHeader.tsx @@ -1,5 +1,5 @@ import { getCurrentWindow } from "@tauri-apps/api/window"; -import { CircleDot, FileText, Hash, Home, Settings2 } from "lucide-react"; +import { Bot, CircleDot, FileText, Hash, Home, Settings2 } from "lucide-react"; import type * as React from "react"; import type { ChannelType } from "@/shared/api/types"; @@ -9,7 +9,7 @@ type ChatHeaderProps = { title: string; description: string; channelType?: ChannelType; - mode?: "home" | "channel" | "settings"; + mode?: "home" | "channel" | "settings" | "agents"; statusBadge?: React.ReactNode; }; @@ -18,12 +18,16 @@ function ChannelIcon({ mode = "channel", }: { channelType?: ChannelType; - mode?: "home" | "channel" | "settings"; + mode?: "home" | "channel" | "settings" | "agents"; }) { if (mode === "home") { return ; } + if (mode === "agents") { + return ; + } + if (mode === "settings") { return ; } diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index f3a80476d7..d1ef1ac16c 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -1,6 +1,7 @@ import { Paperclip, SendHorizontal, SmilePlus } from "lucide-react"; import * as React from "react"; +import { useManagedAgentsQuery } from "@/features/agents/hooks"; import { useChannelMembersQuery } from "@/features/channels/hooks"; import { Button } from "@/shared/ui/button"; import { Textarea } from "@/shared/ui/textarea"; @@ -68,6 +69,17 @@ export function MessageComposer({ const membersQuery = useChannelMembersQuery(channelId); const members = membersQuery.data ?? []; + const managedAgentsQuery = useManagedAgentsQuery(); + const managedAgentNamesByPubkey = React.useMemo( + () => + new Map( + (managedAgentsQuery.data ?? []).map((agent) => [ + agent.pubkey.toLowerCase(), + agent.name, + ]), + ), + [managedAgentsQuery.data], + ); const suggestions = React.useMemo(() => { if (mentionQuery === null) { @@ -76,16 +88,28 @@ export function MessageComposer({ const lowerQuery = mentionQuery.toLowerCase(); return members - .filter((member) => - member.displayName?.toLowerCase().includes(lowerQuery), + .map((member) => { + const fallbackName = + managedAgentNamesByPubkey.get(member.pubkey.toLowerCase()) ?? + member.pubkey.slice(0, 8); + + return { + member, + label: member.displayName ?? fallbackName, + }; + }) + .filter( + ({ label, member }) => + label.toLowerCase().includes(lowerQuery) || + member.pubkey.toLowerCase().includes(lowerQuery), ) .slice(0, 8) - .map((member) => ({ + .map(({ member, label }) => ({ pubkey: member.pubkey, - displayName: member.displayName ?? member.pubkey.slice(0, 8), + displayName: label, role: member.role === "admin" ? "admin" : null, })); - }, [members, mentionQuery]); + }, [managedAgentNamesByPubkey, members, mentionQuery]); const isMentionOpen = mentionQuery !== null && suggestions.length > 0; diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index cc79cf6a59..d316a96456 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -1,5 +1,6 @@ import { getCurrentWindow } from "@tauri-apps/api/window"; import { + Bot, CircleDot, Compass, FileText, @@ -44,7 +45,7 @@ type AppSidebarProps = { selfPresenceStatus: PresenceStatus; errorMessage?: string; selectedChannelId: string | null; - selectedView: "home" | "channel" | "settings"; + selectedView: "home" | "channel" | "settings" | "agents"; unreadChannelIds: Set; onCreateChannel: (input: { name: string; @@ -52,6 +53,7 @@ type AppSidebarProps = { }) => Promise; onOpenBrowseChannels: () => void; onOpenSearch: () => void; + onSelectAgents: () => void; onSelectHome: () => void; onSelectChannel: (channelId: string) => void; onSelectSettings: () => void; @@ -299,6 +301,7 @@ export function AppSidebar({ onCreateChannel, onOpenBrowseChannels, onOpenSearch, + onSelectAgents, onSelectHome, onSelectChannel, onSelectSettings, @@ -435,6 +438,17 @@ export function AppSidebar({ Home + + + + Agents + + diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 2b26cd39bc..1766b48ff7 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -13,6 +13,11 @@ import type { Identity, MintTokenInput, MintTokenResponse, + MintManagedAgentTokenInput, + MintManagedAgentTokenResponse, + ManagedAgent, + ManagedAgentLog, + RelayAgent, PresenceLookup, PresenceStatus, Profile, @@ -29,6 +34,11 @@ import type { UpdateChannelInput, UserProfileSummary, UsersBatchResponse, + CreateManagedAgentInput, + CreateManagedAgentResponse, + AcpProvider, + CommandAvailability, + ManagedAgentPrereqs, } from "@/shared/api/types"; type RawIdentity = { @@ -186,6 +196,73 @@ type RawMintTokenResponse = { expires_at: string | null; }; +type RawRelayAgent = { + pubkey: string; + name: string; + agent_type: string; + channels: string[]; + capabilities: string[]; + status: RelayAgent["status"]; +}; + +type RawManagedAgent = { + pubkey: string; + name: string; + relay_url: string; + acp_command: string; + agent_command: string; + agent_args: string[]; + mcp_command: string; + turn_timeout_seconds: number; + has_api_token: boolean; + status: ManagedAgent["status"]; + pid: number | null; + created_at: string; + updated_at: string; + last_started_at: string | null; + last_stopped_at: string | null; + last_exit_code: number | null; + last_error: string | null; + log_path: string; +}; + +type RawCreateManagedAgentResponse = { + agent: RawManagedAgent; + private_key_nsec: string; + api_token: string | null; + spawn_error: string | null; +}; + +type RawMintManagedAgentTokenResponse = { + agent: RawManagedAgent; + token: string; +}; + +type RawManagedAgentLog = { + content: string; + log_path: string; +}; + +type RawAcpProvider = { + id: string; + label: string; + command: string; + binary_path: string; + default_args: string[]; +}; + +type RawCommandAvailability = { + command: string; + resolved_path: string | null; + available: boolean; +}; + +type RawManagedAgentPrereqs = { + admin: RawCommandAvailability; + acp: RawCommandAvailability; + mcp: RawCommandAvailability; +}; + function toTauriError(error: unknown): Error { if (error instanceof Error) { return error; @@ -577,6 +654,60 @@ function fromRawToken(token: RawToken): Token { }; } +function fromRawRelayAgent(agent: RawRelayAgent): RelayAgent { + return { + pubkey: agent.pubkey, + name: agent.name, + agentType: agent.agent_type, + channels: agent.channels, + capabilities: agent.capabilities, + status: agent.status, + }; +} + +function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent { + return { + pubkey: agent.pubkey, + name: agent.name, + relayUrl: agent.relay_url, + acpCommand: agent.acp_command, + agentCommand: agent.agent_command, + agentArgs: agent.agent_args, + mcpCommand: agent.mcp_command, + turnTimeoutSeconds: agent.turn_timeout_seconds, + hasApiToken: agent.has_api_token, + status: agent.status, + pid: agent.pid, + createdAt: agent.created_at, + updatedAt: agent.updated_at, + lastStartedAt: agent.last_started_at, + lastStoppedAt: agent.last_stopped_at, + lastExitCode: agent.last_exit_code, + lastError: agent.last_error, + logPath: agent.log_path, + }; +} + +function fromRawAcpProvider(provider: RawAcpProvider): AcpProvider { + return { + id: provider.id, + label: provider.label, + command: provider.command, + binaryPath: provider.binary_path, + defaultArgs: provider.default_args, + }; +} + +function fromRawCommandAvailability( + command: RawCommandAvailability, +): CommandAvailability { + return { + command: command.command, + resolvedPath: command.resolved_path, + available: command.available, + }; +} + export async function listTokens(): Promise { const response = await invokeTauri("list_tokens"); return response.tokens.map(fromRawToken); @@ -612,3 +743,127 @@ export async function revokeAllTokens(): Promise<{ revokedCount: number }> { ); return { revokedCount: response.revoked_count }; } + +export async function listRelayAgents(): Promise { + const response = await invokeTauri("list_relay_agents"); + return response.map(fromRawRelayAgent); +} + +export async function listManagedAgents(): Promise { + const response = await invokeTauri("list_managed_agents"); + return response.map(fromRawManagedAgent); +} + +export async function createManagedAgent( + input: CreateManagedAgentInput, +): Promise { + const response = await invokeTauri( + "create_managed_agent", + { + input: { + name: input.name, + relayUrl: input.relayUrl, + acpCommand: input.acpCommand, + agentCommand: input.agentCommand, + agentArgs: input.agentArgs, + mcpCommand: input.mcpCommand, + turnTimeoutSeconds: input.turnTimeoutSeconds, + mintToken: input.mintToken, + tokenScopes: input.tokenScopes, + tokenName: input.tokenName, + spawnAfterCreate: input.spawnAfterCreate, + }, + }, + ); + + return { + agent: fromRawManagedAgent(response.agent), + privateKeyNsec: response.private_key_nsec, + apiToken: response.api_token, + spawnError: response.spawn_error, + }; +} + +export async function startManagedAgent(pubkey: string): Promise { + const response = await invokeTauri("start_managed_agent", { + pubkey, + }); + return fromRawManagedAgent(response); +} + +export async function stopManagedAgent(pubkey: string): Promise { + const response = await invokeTauri("stop_managed_agent", { + pubkey, + }); + return fromRawManagedAgent(response); +} + +export async function deleteManagedAgent(pubkey: string): Promise { + await invokeTauri("delete_managed_agent", { pubkey }); +} + +export async function mintManagedAgentToken( + input: MintManagedAgentTokenInput, +): Promise { + const response = await invokeTauri( + "mint_managed_agent_token", + { + input: { + pubkey: input.pubkey, + tokenName: input.tokenName, + scopes: input.scopes, + }, + }, + ); + + return { + agent: fromRawManagedAgent(response.agent), + token: response.token, + }; +} + +export async function getManagedAgentLog( + pubkey: string, + lineCount?: number, +): Promise { + const response = await invokeTauri( + "get_managed_agent_log", + { + pubkey, + lineCount, + }, + ); + + return { + content: response.content, + logPath: response.log_path, + }; +} + +export async function discoverAcpProviders(): Promise { + const response = await invokeTauri( + "discover_acp_providers", + ); + return response.map(fromRawAcpProvider); +} + +export async function discoverManagedAgentPrereqs(input: { + acpCommand?: string; + mcpCommand?: string; +}): Promise { + const response = await invokeTauri( + "discover_managed_agent_prereqs", + { + input: { + acpCommand: input.acpCommand, + mcpCommand: input.mcpCommand, + }, + }, + ); + + return { + admin: fromRawCommandAvailability(response.admin), + acp: fromRawCommandAvailability(response.acp), + mcp: fromRawCommandAvailability(response.mcp), + }; +} diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 6c958ae814..388549b853 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -233,3 +233,90 @@ export type MintTokenResponse = { createdAt: string; expiresAt: string | null; }; + +export type RelayAgent = { + pubkey: string; + name: string; + agentType: string; + channels: string[]; + capabilities: string[]; + status: "online" | "away" | "offline"; +}; + +export type ManagedAgent = { + pubkey: string; + name: string; + relayUrl: string; + acpCommand: string; + agentCommand: string; + agentArgs: string[]; + mcpCommand: string; + turnTimeoutSeconds: number; + hasApiToken: boolean; + status: "running" | "stopped"; + pid: number | null; + createdAt: string; + updatedAt: string; + lastStartedAt: string | null; + lastStoppedAt: string | null; + lastExitCode: number | null; + lastError: string | null; + logPath: string; +}; + +export type CreateManagedAgentInput = { + name: string; + relayUrl?: string; + acpCommand?: string; + agentCommand?: string; + agentArgs?: string[]; + mcpCommand?: string; + turnTimeoutSeconds?: number; + mintToken?: boolean; + tokenScopes?: TokenScope[]; + tokenName?: string; + spawnAfterCreate?: boolean; +}; + +export type CreateManagedAgentResponse = { + agent: ManagedAgent; + privateKeyNsec: string; + apiToken: string | null; + spawnError: string | null; +}; + +export type MintManagedAgentTokenInput = { + pubkey: string; + tokenName?: string; + scopes?: TokenScope[]; +}; + +export type MintManagedAgentTokenResponse = { + agent: ManagedAgent; + token: string; +}; + +export type ManagedAgentLog = { + content: string; + logPath: string; +}; + +export type AcpProvider = { + id: string; + label: string; + command: string; + binaryPath: string; + defaultArgs: string[]; +}; + +export type CommandAvailability = { + command: string; + resolvedPath: string | null; + available: boolean; +}; + +export type ManagedAgentPrereqs = { + admin: CommandAvailability; + acp: CommandAvailability; + mcp: CommandAvailability; +}; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index bed8e9dc74..c965ccf31c 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -190,10 +190,83 @@ type RawRevokeAllTokensResponse = { revoked_count: number; }; +type RawRelayAgent = { + pubkey: string; + name: string; + agent_type: string; + channels: string[]; + capabilities: string[]; + status: PresenceStatus; +}; + +type RawManagedAgent = { + pubkey: string; + name: string; + relay_url: string; + acp_command: string; + agent_command: string; + agent_args: string[]; + mcp_command: string; + turn_timeout_seconds: number; + has_api_token: boolean; + status: "running" | "stopped"; + pid: number | null; + created_at: string; + updated_at: string; + last_started_at: string | null; + last_stopped_at: string | null; + last_exit_code: number | null; + last_error: string | null; + log_path: string; +}; + +type RawCreateManagedAgentResponse = { + agent: RawManagedAgent; + private_key_nsec: string; + api_token: string | null; + spawn_error: string | null; +}; + +type RawMintManagedAgentTokenResponse = { + agent: RawManagedAgent; + token: string; +}; + +type RawManagedAgentLog = { + content: string; + log_path: string; +}; + +type RawAcpProvider = { + id: string; + label: string; + command: string; + binary_path: string; + default_args: string[]; +}; + +type RawCommandAvailability = { + command: string; + resolved_path: string | null; + available: boolean; +}; + +type RawManagedAgentPrereqs = { + admin: RawCommandAvailability; + acp: RawCommandAvailability; + mcp: RawCommandAvailability; +}; + type MockToken = RawToken & { token: string; }; +type MockManagedAgent = RawManagedAgent & { + private_key_nsec: string; + api_token: string | null; + log_lines: string[]; +}; + type WsHandler = (message: unknown) => void; type MockSocket = { @@ -377,6 +450,37 @@ function cloneMintedToken(token: MockToken): RawMintTokenResponse { }; } +function cloneRelayAgent(agent: RawRelayAgent): RawRelayAgent { + return { + ...agent, + channels: [...agent.channels], + capabilities: [...agent.capabilities], + }; +} + +function cloneManagedAgent(agent: MockManagedAgent): RawManagedAgent { + return { + pubkey: agent.pubkey, + name: agent.name, + relay_url: agent.relay_url, + acp_command: agent.acp_command, + agent_command: agent.agent_command, + agent_args: [...agent.agent_args], + mcp_command: agent.mcp_command, + turn_timeout_seconds: agent.turn_timeout_seconds, + has_api_token: agent.has_api_token, + status: agent.status, + pid: agent.pid, + created_at: agent.created_at, + updated_at: agent.updated_at, + last_started_at: agent.last_started_at, + last_stopped_at: agent.last_stopped_at, + last_exit_code: agent.last_exit_code, + last_error: agent.last_error, + log_path: agent.log_path, + }; +} + function toMockToken(seed: RawMockTokenSeed): MockToken { return { ...cloneToken(seed), @@ -391,6 +495,11 @@ function resetMockTokens(config: E2eConfig | undefined) { mockMintTokenError = config?.mock?.mintTokenError ?? null; } +function resetMockManagedAgents() { + mockManagedAgents = []; + syncMockRelayAgentsFromManagedAgents(); +} + function getMockProfileByPubkey(pubkey: string): RawProfile | null { const normalizedPubkey = pubkey.toLowerCase(); const existing = mockProfiles.get(normalizedPubkey); @@ -709,6 +818,25 @@ const mockSockets = new Map(); const realSockets = new Map(); let mockTokens: MockToken[] = []; let mockMintTokenError: string | null = null; +let mockManagedAgents: MockManagedAgent[] = []; +let mockRelayAgents: RawRelayAgent[] = [ + { + pubkey: ALICE_PUBKEY, + name: "alice", + agent_type: "goose", + channels: ["general", "agents"], + capabilities: ["search", "summaries", "workflows"], + status: "online", + }, + { + pubkey: CHARLIE_PUBKEY, + name: "charlie", + agent_type: "codex", + channels: ["general"], + capabilities: ["code", "reviews"], + status: "away", + }, +]; const mockProfiles = new Map([ [ MOCK_IDENTITY_PUBKEY, @@ -733,6 +861,25 @@ const mockPresence = new Map([ let installed = false; let nextSocketId = 1; +function syncMockRelayAgentsFromManagedAgents() { + const baseAgents = mockRelayAgents.filter( + (agent) => + !mockManagedAgents.some((managed) => managed.pubkey === agent.pubkey), + ); + const managedAgentsAsRelay: RawRelayAgent[] = mockManagedAgents.map( + (agent) => ({ + pubkey: agent.pubkey, + name: agent.name, + agent_type: agent.agent_command, + channels: ["agents"], + capabilities: ["messages", "channels", "mcp"], + status: agent.status === "running" ? "online" : "offline", + }), + ); + + mockRelayAgents = [...baseAgents, ...managedAgentsAsRelay]; +} + function getConfig(): E2eConfig | undefined { return window.__SPROUT_E2E__; } @@ -1817,6 +1964,211 @@ async function handleRevokeAllTokens( }); } +async function handleListRelayAgents(): Promise { + syncMockRelayAgentsFromManagedAgents(); + return mockRelayAgents.map(cloneRelayAgent); +} + +async function handleDiscoverAcpProviders(): Promise { + return [ + { + id: "goose", + label: "Goose", + command: "goose", + binary_path: "/usr/local/bin/goose", + default_args: ["acp"], + }, + { + id: "codex", + label: "Codex", + command: "codex-acp", + binary_path: "/usr/local/bin/codex-acp", + default_args: [], + }, + ]; +} + +async function handleDiscoverManagedAgentPrereqs(args: { + input?: { + acpCommand?: string; + mcpCommand?: string; + }; +}): Promise { + return { + admin: { + command: "sprout-admin", + resolved_path: "/Users/wesb/dev/sprout/target/debug/sprout-admin", + available: true, + }, + acp: { + command: args.input?.acpCommand ?? "sprout-acp", + resolved_path: "/Users/wesb/dev/sprout/target/debug/sprout-acp", + available: true, + }, + mcp: { + command: args.input?.mcpCommand ?? "sprout-mcp-server", + resolved_path: "/Users/wesb/dev/sprout/target/debug/sprout-mcp-server", + available: true, + }, + }; +} + +async function handleListManagedAgents(): Promise { + return mockManagedAgents.map(cloneManagedAgent); +} + +async function handleCreateManagedAgent(args: { + input: { + name: string; + relayUrl?: string; + acpCommand?: string; + agentCommand?: string; + agentArgs?: string[]; + mcpCommand?: string; + turnTimeoutSeconds?: number; + mintToken?: boolean; + tokenScopes?: string[]; + tokenName?: string; + spawnAfterCreate?: boolean; + }; +}): Promise { + const name = args.input.name.trim(); + const now = new Date().toISOString(); + const pubkey = crypto + .randomUUID() + .replace(/-/g, "") + .padEnd(64, "0") + .slice(0, 64); + const token = + args.input.mintToken === false + ? null + : `spr_tok_mock_${crypto.randomUUID().replace(/-/g, "")}`; + const managedAgent: MockManagedAgent = { + pubkey, + name, + relay_url: args.input.relayUrl ?? DEFAULT_RELAY_WS_URL, + acp_command: args.input.acpCommand ?? "sprout-acp", + agent_command: args.input.agentCommand ?? "goose", + agent_args: + args.input.agentArgs && args.input.agentArgs.length > 0 + ? [...args.input.agentArgs] + : ["acp"], + mcp_command: args.input.mcpCommand ?? "sprout-mcp-server", + turn_timeout_seconds: args.input.turnTimeoutSeconds ?? 300, + has_api_token: token !== null, + status: args.input.spawnAfterCreate ? "running" : "stopped", + pid: args.input.spawnAfterCreate ? 42000 + mockManagedAgents.length : null, + created_at: now, + updated_at: now, + last_started_at: args.input.spawnAfterCreate ? now : null, + last_stopped_at: null, + last_exit_code: null, + last_error: null, + log_path: `/tmp/mock-agent-${pubkey}.log`, + private_key_nsec: `nsec1mock${pubkey.slice(0, 20)}`, + api_token: token, + log_lines: [ + `sprout-acp starting: relay=${args.input.relayUrl ?? DEFAULT_RELAY_WS_URL} agent_pubkey=${pubkey}`, + args.input.spawnAfterCreate + ? "connected to relay at ws://localhost:3000" + : "profile created; harness not started", + ], + }; + + mockManagedAgents.unshift(managedAgent); + syncMockRelayAgentsFromManagedAgents(); + + return { + agent: cloneManagedAgent(managedAgent), + private_key_nsec: managedAgent.private_key_nsec, + api_token: managedAgent.api_token, + spawn_error: null, + }; +} + +function getMockManagedAgent(pubkey: string): MockManagedAgent { + const agent = mockManagedAgents.find( + (candidate) => candidate.pubkey === pubkey, + ); + if (!agent) { + throw new Error(`Managed agent ${pubkey} not found.`); + } + + return agent; +} + +async function handleStartManagedAgent(args: { + pubkey: string; +}): Promise { + const agent = getMockManagedAgent(args.pubkey); + const now = new Date().toISOString(); + agent.status = "running"; + agent.pid = agent.pid ?? 42000 + mockManagedAgents.indexOf(agent); + agent.updated_at = now; + agent.last_started_at = now; + agent.last_error = null; + agent.log_lines.push(`started mock harness at ${now}`); + syncMockRelayAgentsFromManagedAgents(); + return cloneManagedAgent(agent); +} + +async function handleStopManagedAgent(args: { + pubkey: string; +}): Promise { + const agent = getMockManagedAgent(args.pubkey); + const now = new Date().toISOString(); + agent.status = "stopped"; + agent.pid = null; + agent.updated_at = now; + agent.last_stopped_at = now; + agent.log_lines.push(`stopped mock harness at ${now}`); + syncMockRelayAgentsFromManagedAgents(); + return cloneManagedAgent(agent); +} + +async function handleDeleteManagedAgent(args: { + pubkey: string; +}): Promise { + mockManagedAgents = mockManagedAgents.filter( + (candidate) => candidate.pubkey !== args.pubkey, + ); + syncMockRelayAgentsFromManagedAgents(); +} + +async function handleMintManagedAgentToken(args: { + input: { + pubkey: string; + tokenName?: string; + scopes?: string[]; + }; +}): Promise { + const agent = getMockManagedAgent(args.input.pubkey); + const now = new Date().toISOString(); + agent.api_token = `spr_tok_mock_${crypto.randomUUID().replace(/-/g, "")}`; + agent.has_api_token = true; + agent.updated_at = now; + agent.log_lines.push( + `minted token ${args.input.tokenName ?? `${agent.name}-token`} at ${now}`, + ); + + return { + agent: cloneManagedAgent(agent), + token: agent.api_token ?? "", + }; +} + +async function handleGetManagedAgentLog(args: { + pubkey: string; + lineCount?: number; +}): Promise { + const agent = getMockManagedAgent(args.pubkey); + const count = args.lineCount ?? 120; + return { + content: agent.log_lines.slice(-count).join("\n"), + log_path: agent.log_path, + }; +} + async function handleSearchMessages( args: { q: string; @@ -2242,6 +2594,7 @@ export function maybeInstallE2eTauriMocks() { } resetMockTokens(config); + resetMockManagedAgents(); mockWindows("main"); window.__SPROUT_E2E_COMMANDS__ = []; window.__SPROUT_E2E_EMIT_MOCK_MESSAGE__ = ({ channelName, content }) => { @@ -2300,6 +2653,12 @@ export function maybeInstallE2eTauriMocks() { ); case "get_relay_ws_url": return getRelayWsUrl(activeConfig); + case "discover_acp_providers": + return handleDiscoverAcpProviders(); + case "discover_managed_agent_prereqs": + return handleDiscoverManagedAgentPrereqs( + payload as Parameters[0], + ); case "get_channels": return handleGetChannels(activeConfig); case "get_feed": @@ -2321,6 +2680,34 @@ export function maybeInstallE2eTauriMocks() { ); case "revoke_all_tokens": return handleRevokeAllTokens(activeConfig); + case "list_relay_agents": + return handleListRelayAgents(); + case "list_managed_agents": + return handleListManagedAgents(); + case "create_managed_agent": + return handleCreateManagedAgent( + payload as Parameters[0], + ); + case "start_managed_agent": + return handleStartManagedAgent( + payload as Parameters[0], + ); + case "stop_managed_agent": + return handleStopManagedAgent( + payload as Parameters[0], + ); + case "delete_managed_agent": + return handleDeleteManagedAgent( + payload as Parameters[0], + ); + case "mint_managed_agent_token": + return handleMintManagedAgentToken( + payload as Parameters[0], + ); + case "get_managed_agent_log": + return handleGetManagedAgentLog( + payload as Parameters[0], + ); case "create_channel": return handleCreateChannel( payload as Parameters[0],