Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions desktop/src-tauri/src/commands/agent_config_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ fn persona_with_model(model: &str) -> AgentDefinition {
display_name: "Persona".to_string(),
avatar_url: None,
system_prompt: "You are a persona.".to_string(),
acp_command: None,
runtime: None,
model: Some(model.to_string()),
provider: None,
Expand Down
2 changes: 1 addition & 1 deletion desktop/src-tauri/src/commands/agent_models_tests.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
use super::*;

#[test]
fn access_policy_change_requires_runtime_refresh_for_effective_gate_changes() {
use crate::managed_agents::RespondTo;
Expand Down Expand Up @@ -433,6 +432,7 @@ fn model_discovery_ignores_stale_record_for_linked_agent() {
display_name: "Persona".to_string(),
avatar_url: None,
system_prompt: "You are a persona.".to_string(),
acp_command: None,
runtime: Some("goose".to_string()),
model: Some("persona-model".to_string()),
provider: Some("anthropic".to_string()),
Expand Down
20 changes: 19 additions & 1 deletion desktop/src-tauri/src/commands/agent_providers.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,22 @@
use crate::managed_agents::{discover_provider_candidates, invoke_provider, BackendProviderInfo};
use crate::managed_agents::{
discover_acp_command_candidates, discover_provider_candidates, invoke_provider,
AcpCommandCandidate, BackendProviderInfo,
};

#[tauri::command]
pub async fn discover_acp_commands() -> Result<Vec<AcpCommandCandidate>, String> {
tokio::task::spawn_blocking(|| {
discover_acp_command_candidates()
.into_iter()
.map(|(command, path)| AcpCommandCandidate {
command,
binary_path: path.display().to_string(),
})
.collect()
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))
}

#[tauri::command]
pub async fn discover_backend_providers() -> Result<Vec<BackendProviderInfo>, String> {
Expand Down
10 changes: 5 additions & 5 deletions desktop/src-tauri/src/commands/agents.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
use super::managed_agent_definition::validate_create_definition;
use nostr::{Keys, ToBech32};
use tauri::{AppHandle, State};

use super::managed_agent_definition::validate_create_definition;

use crate::{
app_state::AppState,
managed_agents::{
Expand Down Expand Up @@ -647,9 +646,10 @@ pub async fn create_managed_agent(
auth_tag: auth_tag.clone(),
relay_url: resolved_relay_url.clone(),
avatar_url: resolved_avatar_url.clone(),
acp_command: input
.acp_command
.as_deref()
acp_command: linked_persona
.as_ref()
.and_then(|persona| persona.acp_command.as_deref())
.or(input.acp_command.as_deref())
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(DEFAULT_ACP_COMMAND)
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/commands/agents_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ fn persona_record(id: &str, model: Option<&str>, provider: Option<&str>) -> Agen
display_name: "Test Persona".to_string(),
avatar_url: None,
system_prompt: "".to_string(),
acp_command: None,
runtime: None,
model: model.map(str::to_string),
provider: provider.map(str::to_string),
Expand Down
2 changes: 2 additions & 0 deletions desktop/src-tauri/src/commands/personas/create.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ pub async fn create_persona(
let system_prompt = input.system_prompt.clone();
validate_agent_definition_text(&display_name, &system_prompt)?;
let avatar_url = trim_optional(input.avatar_url);
let acp_command = trim_optional(input.acp_command);
let runtime = trim_optional(input.runtime);
let model = trim_optional(input.model);
let provider = trim_optional(input.provider);
Expand Down Expand Up @@ -59,6 +60,7 @@ pub async fn create_persona(
display_name,
avatar_url,
system_prompt,
acp_command,
runtime,
model,
provider,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ fn member(id: &str, display_name: &str) -> AgentDefinition {
display_name: display_name.to_string(),
avatar_url: None,
system_prompt: "Do the work.".to_string(),
acp_command: None,
runtime: None,
model: None,
provider: None,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ fn local_in_app() -> AgentDefinition {
display_name: "Local".to_string(),
avatar_url: None,
system_prompt: "local prompt".to_string(),
acp_command: None,
runtime: Some("goose".to_string()),
model: Some("opus".to_string()),
provider: Some("anthropic".to_string()),
Expand Down Expand Up @@ -42,6 +43,7 @@ fn inbound_for(d_tag: &str, display_name: &str) -> AgentDefinition {
display_name: display_name.to_string(),
avatar_url: Some("https://example.com/a.png".to_string()),
system_prompt: "remote prompt".to_string(),
acp_command: None,
runtime: Some("acp".to_string()),
model: Some("sonnet".to_string()),
provider: Some("openai".to_string()),
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/commands/personas/pending.rs
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,7 @@ mod tests {
display_name: "Catalog Reviewer".to_string(),
avatar_url: None,
system_prompt: "Review the catalog.".to_string(),
acp_command: None,
runtime: None,
model: None,
provider: None,
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/commands/personas/sharing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ mod tests {
display_name: "Catalog Reviewer".to_string(),
avatar_url: None,
system_prompt: "Review the catalog.".to_string(),
acp_command: None,
runtime: None,
model: None,
provider: None,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,7 @@ pub async fn confirm_agent_snapshot_import(
.system_prompt
.clone()
.unwrap_or_default(),
acp_command: None,
runtime: snapshot.definition.runtime.clone(),
model: snapshot.definition.model.clone(),
provider: snapshot.definition.provider.clone(),
Expand Down
2 changes: 2 additions & 0 deletions desktop/src-tauri/src/commands/personas/update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ pub(super) async fn update_persona_with<R: Send + 'static>(
let system_prompt = input.system_prompt.clone();
validate_agent_definition_text(&display_name, &system_prompt)?;
let avatar_url = trim_optional(input.avatar_url);
let acp_command = trim_optional(input.acp_command);
let runtime = trim_optional(input.runtime);
let model = trim_optional(input.model);
let provider = trim_optional(input.provider);
Expand All @@ -120,6 +121,7 @@ pub(super) async fn update_persona_with<R: Send + 'static>(
persona.display_name = display_name;
persona.avatar_url = avatar_url;
persona.system_prompt = system_prompt;
persona.acp_command = acp_command;
persona.runtime = runtime;
persona.model = model;
persona.provider = provider;
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/commands/team_snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ fn definition_from_snapshot(
display_name: member.profile.display_name.trim().to_string(),
avatar_url: effective_avatar(member),
system_prompt: member.definition.system_prompt.clone().unwrap_or_default(),
acp_command: None,
runtime: member.definition.runtime.clone(),
model: member.definition.model.clone(),
provider: member.definition.provider.clone(),
Expand Down
3 changes: 3 additions & 0 deletions desktop/src-tauri/src/commands/team_snapshot/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ fn team_export_round_trip_preserves_team_and_excludes_member_memory() {
display_name: "Alice".to_string(),
avatar_url: None,
system_prompt: "Alice prompt".to_string(),
acp_command: None,
runtime: Some("goose".to_string()),
model: None,
provider: None,
Expand All @@ -82,6 +83,7 @@ fn team_export_round_trip_preserves_team_and_excludes_member_memory() {
display_name: "Bob".to_string(),
avatar_url: None,
system_prompt: "Bob prompt".to_string(),
acp_command: None,
runtime: Some("goose".to_string()),
model: None,
provider: None,
Expand Down Expand Up @@ -148,6 +150,7 @@ fn team_export_with_instance_and_memory_level_uses_supplied_entries() {
display_name: "Alice".to_string(),
avatar_url: None,
system_prompt: "Alice prompt".to_string(),
acp_command: None,
runtime: Some("goose".to_string()),
model: None,
provider: None,
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/commands/teams/adopt/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,7 @@ fn member_copy(
display_name: member.display_name.clone(),
avatar_url: member.avatar_url.clone(),
system_prompt: member.system_prompt.clone().unwrap_or_default(),
acp_command: None,
runtime: member.runtime.clone(),
model: member.model.clone(),
provider: member.provider.clone(),
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/commands/teams/adopt/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ fn persona(id: &str, prompt: &str) -> AgentDefinition {
display_name: id.to_string(),
avatar_url: None,
system_prompt: prompt.to_string(),
acp_command: None,
runtime: None,
model: None,
provider: None,
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/commands/teams/pending/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ fn member(id: &str, display_name: &str) -> AgentDefinition {
display_name: display_name.to_string(),
avatar_url: None,
system_prompt: "Do the work.".to_string(),
acp_command: None,
runtime: None,
model: None,
provider: None,
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/commands/teams/sharing/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ fn member(id: &str) -> AgentDefinition {
display_name: "One".to_string(),
avatar_url: None,
system_prompt: "Do the work.".to_string(),
acp_command: None,
runtime: None,
model: None,
provider: None,
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/event_sync_team_catalog_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ fn member(id: &str, prompt: &str) -> AgentDefinition {
display_name: id.to_string(),
avatar_url: None,
system_prompt: prompt.to_string(),
acp_command: None,
runtime: None,
model: None,
provider: None,
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -717,6 +717,7 @@ pub fn run() {
mesh_installed_models,
mesh_model_catalog,
update_managed_agent,
discover_acp_commands,
discover_backend_providers,
probe_backend_provider,
persona_catalog::fetch_persona_catalog,
Expand Down
106 changes: 91 additions & 15 deletions desktop/src-tauri/src/managed_agents/backend.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use super::discovery::{command_search::command_discovery_dirs, resolve_command};
use sha2::{Digest, Sha256};
use std::io::{BufReader, Read, Write};
use std::path::{Path, PathBuf};
Expand Down Expand Up @@ -583,23 +584,12 @@ fn provider_id_from_filename(name: &str) -> Option<&str> {
(!id.is_empty()).then_some(id)
}

/// Enumerate PATH for buzz-backend-* executables. Returns (id, path) pairs.
/// Only includes files that are executable. Does NOT execute any binaries.
///
/// On macOS, GUI apps inherit a minimal PATH from launchd (`/usr/bin:/bin:/usr/sbin:/sbin`)
/// which excludes both the app bundle's `Contents/MacOS/` dir and `~/.local/bin`.
/// We augment the search with those directories so bundled and user-installed providers
/// are always discovered regardless of how the desktop was launched.
pub fn discover_provider_candidates() -> Vec<(String, PathBuf)> {
let prefix = "buzz-backend-";
let mut seen = std::collections::HashSet::new();
let mut results = Vec::new();

fn executable_search_dirs() -> Vec<PathBuf> {
let path_var = std::env::var_os("PATH").unwrap_or_default();
let mut dirs: Vec<PathBuf> = std::env::split_paths(&path_var).collect();

// Prepend the exe parent dir (Contents/MacOS/ in a .app bundle) so bundled
// providers are found even when the process PATH is minimal.
// extensions are found even when the process PATH is minimal.
if let Ok(exe) = std::env::current_exe() {
if let Some(parent) = exe.parent() {
let parent_buf = parent.to_path_buf();
Expand All @@ -610,15 +600,50 @@ pub fn discover_provider_candidates() -> Vec<(String, PathBuf)> {
}

// Also include ~/.local/bin — the conventional location for user-installed
// provider binaries (symlinks created by install scripts).
// Buzz extension binaries (symlinks created by install scripts).
if let Some(home) = dirs::home_dir() {
let local_bin = home.join(".local").join("bin");
if !dirs.contains(&local_bin) {
dirs.push(local_bin);
}
}

for dir in dirs {
dirs
}

fn strip_windows_command_extension(name: &str) -> &str {
[".exe", ".bat", ".cmd"]
.into_iter()
.find_map(|extension| {
name.get(name.len().saturating_sub(extension.len())..)
.filter(|suffix| suffix.eq_ignore_ascii_case(extension))
.map(|_| &name[..name.len() - extension.len()])
})
.unwrap_or(name)
}

fn acp_command_from_filename(name: &str, require_windows_extension: bool) -> Option<&str> {
let command = strip_windows_command_extension(name);
if require_windows_extension && command == name {
return None;
}
let middle = command.strip_prefix("buzz-")?.strip_suffix("-acp")?;
(!middle.is_empty()).then_some(command)
}

/// Enumerate PATH for buzz-backend-* executables. Returns (id, path) pairs.
/// Only includes files that are executable. Does NOT execute any binaries.
///
/// On macOS, GUI apps inherit a minimal PATH from launchd (`/usr/bin:/bin:/usr/sbin:/sbin`)
/// which excludes both the app bundle's `Contents/MacOS/` dir and `~/.local/bin`.
/// We augment the search with those directories so bundled and user-installed providers
/// are always discovered regardless of how the desktop was launched.
pub fn discover_provider_candidates() -> Vec<(String, PathBuf)> {
let prefix = "buzz-backend-";
let mut seen = std::collections::HashSet::new();
let mut results = Vec::new();

for dir in executable_search_dirs() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
Expand All @@ -637,6 +662,49 @@ pub fn discover_provider_candidates() -> Vec<(String, PathBuf)> {
results
}

/// Enumerate executable `buzz-*-acp` drop-in wrappers without running them.
/// The stock `buzz-acp` command is deliberately excluded because it has no
/// namespaced middle segment and is always the editor's built-in default.
///
/// Candidate names come from the normal executable search directories, but
/// each result is resolved through the same command resolver used at spawn.
/// This guarantees the path shown for a command is the path that command will
/// execute, even when managed shims or workspace builds take precedence.
pub fn discover_acp_command_candidates() -> Vec<(String, PathBuf)> {
discover_acp_command_candidates_in(command_discovery_dirs(), resolve_command)
}

fn discover_acp_command_candidates_in(
dirs: impl IntoIterator<Item = PathBuf>,
mut resolve: impl FnMut(&str) -> Option<PathBuf>,
) -> Vec<(String, PathBuf)> {
let mut seen = std::collections::HashSet::new();
let mut results = Vec::new();

for dir in dirs {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let filename = entry.file_name().to_string_lossy().to_string();
let Some(command) = acp_command_from_filename(&filename, cfg!(windows)) else {
continue;
};
if !entry.path().is_file() || !is_executable(&entry.path()) {
continue;
}
if seen.insert(command.to_string()) {
if let Some(path) = resolve(command) {
results.push((command.to_string(), path));
}
}
}
}

results.sort_by(|left, right| left.0.cmp(&right.0));
results
}

/// Resolve a provider ID to a discovered, executable binary path.
///
/// This is the ONLY way to resolve provider binaries for execution. It:
Expand Down Expand Up @@ -699,6 +767,14 @@ pub struct BackendProviderInfo {
pub binary_path: String,
}

/// A PATH-discovered drop-in wrapper for the stock `buzz-acp` harness.
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AcpCommandCandidate {
pub command: String,
pub binary_path: String,
}

#[cfg(test)]
#[path = "backend_tests.rs"]
mod tests;
Loading
Loading