Skip to content
Merged
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
21 changes: 12 additions & 9 deletions desktop/src-tauri/src/commands/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,18 @@ use crate::{
managed_agents::{
admin_command, build_managed_agent_summary, command_availability, default_token_scopes,
discover_local_acp_providers, find_managed_agent_mut, load_managed_agents,
managed_agent_log_path, read_log_tail, run_sprout_admin_mint_token, save_managed_agents,
start_managed_agent_process, stop_managed_agent_process, sync_managed_agent_processes,
AcpProviderInfo, CreateManagedAgentRequest, CreateManagedAgentResponse,
DiscoverManagedAgentPrereqsRequest, ManagedAgentLogResponse, ManagedAgentPrereqsInfo,
ManagedAgentSummary, MintManagedAgentTokenRequest, MintManagedAgentTokenResponse,
RelayAgentInfo, DEFAULT_ACP_COMMAND, DEFAULT_AGENT_ARG, DEFAULT_AGENT_COMMAND,
DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, DEFAULT_MCP_COMMAND,
managed_agent_avatar_url, managed_agent_log_path, read_log_tail,
run_sprout_admin_mint_token, save_managed_agents, start_managed_agent_process,
stop_managed_agent_process, sync_managed_agent_processes, AcpProviderInfo,
CreateManagedAgentRequest, CreateManagedAgentResponse, DiscoverManagedAgentPrereqsRequest,
ManagedAgentLogResponse, ManagedAgentPrereqsInfo, ManagedAgentSummary,
MintManagedAgentTokenRequest, MintManagedAgentTokenResponse, RelayAgentInfo,
DEFAULT_ACP_COMMAND, DEFAULT_AGENT_ARG, DEFAULT_AGENT_COMMAND, DEFAULT_AGENT_PARALLELISM,
DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, DEFAULT_MCP_COMMAND,
},
relay::{
build_authed_request, managed_agent_owner_pubkey, relay_ws_url, send_json_request,
sync_managed_agent_profile_display_name,
sync_managed_agent_profile,
},
util::now_iso,
};
Expand Down Expand Up @@ -269,13 +270,15 @@ pub async fn create_managed_agent(
))
}?;

let profile_sync_error = match sync_managed_agent_profile_display_name(
let avatar_url = managed_agent_avatar_url(agent.agent_command.as_str());
let profile_sync_error = match sync_managed_agent_profile(
&state,
&resolved_relay_url,
&pubkey,
api_token.as_deref(),
&token_scopes,
name,
avatar_url.as_deref(),
)
.await
{
Expand Down
89 changes: 89 additions & 0 deletions desktop/src-tauri/src/managed_agents/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,15 @@ struct KnownAcpProvider {
id: &'static str,
label: &'static str,
command: &'static str,
aliases: &'static [&'static str],
default_args: &'static [&'static str],
avatar_url: &'static str,
}

const GOOSE_AVATAR_URL: &str = "https://block.github.io/goose/img/logo_dark.png";
const CLAUDE_CODE_AVATAR_URL: &str = "https://anthropic.gallerycdn.vsassets.io/extensions/anthropic/claude-code/2.1.77/1773707456892/Microsoft.VisualStudio.Services.Icons.Default";
const CODEX_AVATAR_URL: &str = "https://openai.gallerycdn.vsassets.io/extensions/openai/chatgpt/26.5313.41514/1773706730621/Microsoft.VisualStudio.Services.Icons.Default";

const COMMON_BINARY_PATHS: &[&str] = &[
"/opt/homebrew/bin",
"/usr/local/bin",
Expand All @@ -28,19 +34,25 @@ const KNOWN_ACP_PROVIDERS: &[KnownAcpProvider] = &[
id: "goose",
label: "Goose",
command: "goose",
aliases: &[],
default_args: &[DEFAULT_AGENT_ARG],
avatar_url: GOOSE_AVATAR_URL,
},
KnownAcpProvider {
id: "claude",
label: "Claude Code",
command: "claude-agent-acp",
aliases: &["claude-code", "claudecode"],
default_args: &[],
avatar_url: CLAUDE_CODE_AVATAR_URL,
},
KnownAcpProvider {
id: "codex",
label: "Codex",
command: "codex-acp",
aliases: &[],
default_args: &[],
avatar_url: CODEX_AVATAR_URL,
},
];

Expand All @@ -62,6 +74,42 @@ fn executable_basename(command: &str) -> String {
}
}

fn normalize_command_identity(command: &str) -> String {
let normalized = command.trim().replace('\\', "/");
let basename = normalized.rsplit('/').next().unwrap_or(normalized.as_str());
let lower = basename
.chars()
.map(|character| match character {
' ' | '_' => '-',
_ => character.to_ascii_lowercase(),
})
.collect::<String>();
let lower = lower.strip_suffix(".exe").unwrap_or(&lower).to_string();

if let Some(suffix) = std::env::consts::EXE_SUFFIX.strip_prefix('.') {
return lower.strip_suffix(&format!(".{suffix}")).unwrap_or(&lower).to_string();
}

if !std::env::consts::EXE_SUFFIX.is_empty() {
return lower
.strip_suffix(std::env::consts::EXE_SUFFIX)
.unwrap_or(&lower)
.to_string();
}

lower
}

fn known_acp_provider(command: &str) -> Option<&'static KnownAcpProvider> {
let normalized = normalize_command_identity(command);

KNOWN_ACP_PROVIDERS.iter().find(|provider| {
normalized == provider.id
|| normalized == normalize_command_identity(provider.command)
|| provider.aliases.iter().any(|alias| normalized == *alias)
})
}

fn command_search_dirs(app: Option<&AppHandle>) -> Vec<PathBuf> {
let mut dirs = vec![
workspace_root_dir().join("target/release"),
Expand Down Expand Up @@ -212,6 +260,11 @@ pub fn discover_local_acp_providers() -> Vec<AcpProviderInfo> {
.collect()
}

pub fn managed_agent_avatar_url(command: &str) -> Option<String> {
let provider = known_acp_provider(command)?;
Some(provider.avatar_url.to_string())
}

pub fn admin_command() -> String {
std::env::var("SPROUT_ADMIN_COMMAND").unwrap_or_else(|_| "sprout-admin".to_string())
}
Expand All @@ -224,6 +277,42 @@ pub fn default_token_scopes() -> Vec<String> {
]
}

#[cfg(test)]
mod tests {
use super::{
managed_agent_avatar_url, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL,
};

#[test]
fn resolves_known_avatar_for_bare_command() {
let avatar_url =
managed_agent_avatar_url("goose").expect("goose avatar should resolve");

assert_eq!(avatar_url, GOOSE_AVATAR_URL);
}

#[test]
fn resolves_known_avatar_for_command_paths_and_aliases() {
assert_eq!(
managed_agent_avatar_url("/usr/local/bin/codex-acp"),
Some(CODEX_AVATAR_URL.to_string())
);
assert_eq!(
managed_agent_avatar_url("Claude Code"),
Some(CLAUDE_CODE_AVATAR_URL.to_string())
);
assert_eq!(
managed_agent_avatar_url(r"C:\Tools\claude-agent-acp.exe"),
Some(CLAUDE_CODE_AVATAR_URL.to_string())
);
}

#[test]
fn returns_none_for_unknown_commands() {
assert!(managed_agent_avatar_url("custom-agent").is_none());
}
}

fn run_sprout_admin_command(
program: &Path,
pubkey: &str,
Expand Down
11 changes: 6 additions & 5 deletions desktop/src-tauri/src/relay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,14 @@ fn token_supports_scope(scopes: &[String], required_scope: &str) -> bool {
scopes.iter().any(|scope| scope == required_scope)
}

pub async fn sync_managed_agent_profile_display_name(
pub async fn sync_managed_agent_profile(
state: &AppState,
relay_url: &str,
pubkey: &str,
api_token: Option<&str>,
token_scopes: &[String],
display_name: &str,
avatar_url: Option<&str>,
) -> Result<(), String> {
let url = format!(
"{}{}",
Expand All @@ -105,21 +106,21 @@ pub async fn sync_managed_agent_profile_display_name(

let request = request.json(&UpdateProfileBody {
display_name: Some(display_name),
avatar_url: None,
avatar_url,
about: None,
nip05_handle: None,
});

send_empty_request(request).await.map_err(|error| {
if api_token.is_some() && !use_bearer_token {
format!(
"Created the agent, but could not sync its profile display name. The minted token does not include `users:write`, and the relay rejected dev-mode pubkey auth: {error}"
"Created the agent, but could not sync its profile metadata. The minted token does not include `users:write`, and the relay rejected dev-mode pubkey auth: {error}"
)
} else if api_token.is_some() {
format!("Created the agent, but could not sync its profile display name: {error}")
format!("Created the agent, but could not sync its profile metadata: {error}")
} else {
format!(
"Created the agent, but could not sync its profile display name without a token: {error}"
"Created the agent, but could not sync its profile metadata without a token: {error}"
)
}
})
Expand Down
Loading