diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 78db7ff718..0d87022e30 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -367,6 +367,62 @@ fn build_client_capabilities() -> serde_json::Value { }) } +/// Hermes performs substantially more Python/module initialization than the +/// lightweight ACP adapters: live probes on this machine completed in roughly +/// 13–21 seconds. Keep the fast fail for every other harness while giving +/// Hermes enough cold-start headroom to return its native ACP model catalog. +pub(crate) fn model_probe_timeout_for_agent(agent_command: &str) -> std::time::Duration { + match crate::config::normalize_agent_command_identity(agent_command).as_str() { + "hermes" | "hermes-agent" => std::time::Duration::from_secs(45), + _ => crate::MODELS_TIMEOUT, + } +} + +/// Environment overrides required when Buzz owns a Hermes ACP session. +/// +/// Hermes normally starts every configured MCP server before entering its ACP +/// JSON-RPC loop. Buzz passes the session's MCP servers explicitly through +/// `session/new` (an empty list when none are configured), so unrelated global +/// Hermes MCP startup must not block either discovery or a managed session. +/// The marker is Hermes-specific; all other ACP runtimes are unchanged. +pub(crate) fn acp_env_for_agent(agent_command: &str) -> Vec<(String, String)> { + match crate::config::normalize_agent_command_identity(agent_command).as_str() { + "hermes" | "hermes-agent" => vec![( + "HERMES_ACP_SKIP_CONFIGURED_MCP".to_string(), + "1".to_string(), + )], + _ => Vec::new(), + } +} + +/// Build the OS command that hosts an ACP runtime. +/// +/// Hermes's installer exposes a Bash launcher that `exec`s its Python entry +/// point. On Unix/macOS that entry point stops servicing stdio when the launcher +/// itself is made the process-group leader. Keep a non-execing shell supervisor +/// as the group leader instead; Hermes remains a child in the same group, so +/// `killpg` still cleans up Hermes and every tool/MCP descendant. +fn build_agent_spawn_command(command: &str, args: &[String]) -> std::process::Command { + #[cfg(unix)] + if matches!( + crate::config::normalize_agent_command_identity(command).as_str(), + "hermes" | "hermes-agent" + ) { + let mut supervised = std::process::Command::new("/bin/sh"); + supervised + .arg("-c") + .arg("\"$@\"; status=$?; exit \"$status\"") + .arg("buzz-acp-hermes-supervisor") + .arg(command) + .args(args); + return supervised; + } + + let mut direct = std::process::Command::new(command); + direct.args(args); + direct +} + impl AcpClient { /// Kill the agent subprocess and wait for it to exit (no zombies). /// @@ -413,9 +469,8 @@ impl AcpClient { ) -> Result { use std::process::Stdio; - let mut cmd = tokio::process::Command::new(command); - cmd.args(args) - .stdin(Stdio::piped()) + let mut cmd = tokio::process::Command::from(build_agent_spawn_command(command, args)); + cmd.stdin(Stdio::piped()) .stdout(Stdio::piped()) // Inherit stderr so agent logs are visible in the harness terminal. .stderr(Stdio::inherit()) @@ -460,6 +515,16 @@ impl AcpClient { cmd.env("CODEX_CONFIG", merged); } + // Runtime-specific ACP host environment. Applied to every spawn path + // (probes and managed sessions) so runtime isolation cannot silently + // depend on which caller created the client. Operator precedence still + // wins: an explicitly exported value is never overwritten. + for (key, value) in acp_env_for_agent(command) { + if std::env::var(&key).is_err() { + cmd.env(&key, &value); + } + } + // Spawn the agent in its own process group so SIGKILL doesn't propagate // to the harness's own process group on Unix. // tokio::process::Command::process_group is a stable tokio API (no extra imports needed). @@ -2008,6 +2073,62 @@ fn configure_no_window(cmd: &mut tokio::process::Command) { mod tests { use super::*; + #[cfg(unix)] + #[test] + fn hermes_spawn_command_keeps_a_supervisor_as_process_group_leader() { + let args = vec!["acp".to_string()]; + let command = build_agent_spawn_command("/Users/test/.local/bin/hermes", &args); + let actual_args = command + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::>(); + + assert_eq!(command.get_program(), "/bin/sh"); + assert_eq!( + actual_args, + vec![ + "-c", + "\"$@\"; status=$?; exit \"$status\"", + "buzz-acp-hermes-supervisor", + "/Users/test/.local/bin/hermes", + "acp", + ] + ); + } + + #[test] + fn hermes_runtime_gets_the_configured_mcp_skip_marker() { + let env = acp_env_for_agent("/Users/test/.local/bin/hermes"); + assert_eq!( + env, + vec![( + "HERMES_ACP_SKIP_CONFIGURED_MCP".to_string(), + "1".to_string() + )] + ); + } + + #[test] + fn non_hermes_runtimes_get_no_extra_acp_env() { + assert!(acp_env_for_agent("codex-acp").is_empty()); + assert!(acp_env_for_agent("/opt/bin/goose").is_empty()); + } + + #[test] + fn hermes_probe_budget_exceeds_the_default_and_others_keep_it() { + let hermes = model_probe_timeout_for_agent("hermes"); + let other = model_probe_timeout_for_agent("codex-acp"); + + // Contract: Hermes needs a strictly larger cold-start budget than the + // shared default, and every other runtime keeps the fast-fail default. + assert!(hermes > other); + assert_eq!(other, crate::MODELS_TIMEOUT); + assert_eq!( + model_probe_timeout_for_agent("/Users/test/.local/bin/hermes-agent"), + hermes + ); + } + #[test] fn stop_reason_parses_all_known_values() { assert_eq!(StopReason::from_str("end_turn"), Some(StopReason::EndTurn)); diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index a38d6faa14..3f0f335b96 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -619,6 +619,7 @@ fn default_agent_args(command: &str) -> Option> { "goose" => Some(vec!["acp".to_string()]), "codex" | "codex-acp" | "claude-agent-acp" | "claude-code-acp" | "claude-code" | "claudecode" | "buzz-agent" => Some(Vec::new()), + "hermes" | "hermes-agent" => Some(vec!["acp".to_string()]), _ => None, } } @@ -1530,6 +1531,30 @@ mod tests { ); } + #[test] + fn hermes_defaults_to_acp_arg() { + assert_eq!(default_agent_args("hermes"), Some(vec!["acp".to_string()])); + assert_eq!( + default_agent_args("hermes-agent"), + Some(vec!["acp".to_string()]) + ); + // Path-qualified and case variants normalize correctly. + assert_eq!( + default_agent_args("/usr/local/bin/hermes"), + Some(vec!["acp".to_string()]) + ); + assert_eq!(default_agent_args("Hermes"), Some(vec!["acp".to_string()])); + } + + #[test] + fn hermes_not_treated_as_codex_for_network_env() { + assert_eq!(codex_network_env("hermes", "ws://localhost:3000"), None); + assert_eq!( + codex_network_env("hermes-agent", "ws://localhost:3000"), + None + ); + } + // --- codex_network_env tests --- const CODEX_CONFIG_JSON: &str = "{\"sandbox_workspace_write\":{\"network_access\":true}}"; diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 03b75a4211..473daf49d1 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -60,12 +60,43 @@ fn is_subcommand(name: &str) -> bool { } /// Timeout for lightweight helper subcommands (spawn + initialize + model/method probes). -const MODELS_TIMEOUT: Duration = Duration::from_secs(10); +pub(crate) const MODELS_TIMEOUT: Duration = Duration::from_secs(10); /// Timeout for `buzz-acp authenticate`. Browser-based vendor auth can require /// human interaction, so it must not share the short probe timeout. const AUTHENTICATE_TIMEOUT: Duration = Duration::from_secs(10 * 60); +#[cfg(test)] +mod model_probe_timeout_tests { + use super::*; + use crate::acp::{acp_env_for_agent, model_probe_timeout_for_agent}; + + #[test] + fn hermes_gets_a_cold_start_model_probe_budget() { + assert_eq!( + model_probe_timeout_for_agent("hermes"), + Duration::from_secs(45) + ); + assert_eq!( + model_probe_timeout_for_agent("/Users/test/.local/bin/hermes-agent"), + Duration::from_secs(45) + ); + assert_eq!(model_probe_timeout_for_agent("codex-acp"), MODELS_TIMEOUT); + } + + #[test] + fn hermes_acp_sessions_skip_unrelated_configured_mcp_startup() { + assert_eq!( + acp_env_for_agent("/Users/test/.local/bin/hermes"), + vec![( + "HERMES_ACP_SKIP_CONFIGURED_MCP".to_string(), + "1".to_string() + )] + ); + assert!(acp_env_for_agent("codex-acp").is_empty()); + } +} + /// Publish a kind:20001 presence update event via the WebSocket connection. /// /// Ephemeral kinds (20000-29999) are rejected by the HTTP bridge, so presence @@ -3853,6 +3884,7 @@ fn extract_auth_methods(init_result: &serde_json::Value) -> Vec Result<()> { + let probe_timeout = acp::model_probe_timeout_for_agent(&args.agent.agent_command); let mut client = match spawn_auth_client(&args.agent).await { Ok(c) => c, Err(e) => { @@ -3861,7 +3893,7 @@ async fn run_auth_methods(args: AuthMethodsArgs) -> Result<()> { } }; - let init_result = match tokio::time::timeout(MODELS_TIMEOUT, client.initialize()).await { + let init_result = match tokio::time::timeout(probe_timeout, client.initialize()).await { Ok(Ok(result)) => result, Ok(Err(e)) => { client.shutdown().await; @@ -3870,7 +3902,7 @@ async fn run_auth_methods(args: AuthMethodsArgs) -> Result<()> { } Err(_) => { client.shutdown().await; - eprintln!("error: agent timed out ({MODELS_TIMEOUT:?})"); + eprintln!("error: agent timed out ({probe_timeout:?})"); std::process::exit(1); } }; @@ -3901,6 +3933,7 @@ async fn run_auth_methods(args: AuthMethodsArgs) -> Result<()> { /// `buzz-acp authenticate` — invoke one adapter-owned auth method. async fn run_authenticate(args: AuthenticateArgs) -> Result<()> { + let probe_timeout = acp::model_probe_timeout_for_agent(&args.agent.agent_command); let mut client = match spawn_auth_client(&args.agent).await { Ok(c) => c, Err(e) => { @@ -3909,7 +3942,7 @@ async fn run_authenticate(args: AuthenticateArgs) -> Result<()> { } }; - let init_result = match tokio::time::timeout(MODELS_TIMEOUT, client.initialize()).await { + let init_result = match tokio::time::timeout(probe_timeout, client.initialize()).await { Ok(Ok(result)) => result, Ok(Err(e)) => { client.shutdown().await; @@ -3918,7 +3951,7 @@ async fn run_authenticate(args: AuthenticateArgs) -> Result<()> { } Err(_) => { client.shutdown().await; - eprintln!("error: agent initialize timed out ({MODELS_TIMEOUT:?})"); + eprintln!("error: agent initialize timed out ({probe_timeout:?})"); std::process::exit(1); } }; @@ -3961,6 +3994,7 @@ async fn run_authenticate(args: AuthenticateArgs) -> Result<()> { async fn run_models(args: ModelsArgs) -> Result<()> { use acp::{extract_model_config_options, extract_model_state}; + let probe_timeout = acp::model_probe_timeout_for_agent(&args.agent.agent_command); let agent_args = config::normalize_agent_args(&args.agent.agent_command, args.agent.agent_args); let cwd = std::env::current_dir() .unwrap_or_else(|_| std::path::PathBuf::from("/")) @@ -3980,7 +4014,7 @@ async fn run_models(args: ModelsArgs) -> Result<()> { // Initialize + session/new under a timeout. Client is owned above, // so shutdown() runs on all paths (success, error, timeout). - let protocol_result = tokio::time::timeout(MODELS_TIMEOUT, async { + let protocol_result = tokio::time::timeout(probe_timeout, async { let init = client.initialize().await?; let session = client.session_new_full(&cwd, vec![], None).await?; Ok::<_, acp::AcpError>((init, session)) @@ -3996,7 +4030,7 @@ async fn run_models(args: ModelsArgs) -> Result<()> { } Err(_) => { client.shutdown().await; - eprintln!("error: agent timed out ({MODELS_TIMEOUT:?})"); + eprintln!("error: agent timed out ({probe_timeout:?})"); std::process::exit(1); } }; diff --git a/desktop/public/runtime-icons/hermes.png b/desktop/public/runtime-icons/hermes.png new file mode 100644 index 0000000000..c5da175f8e Binary files /dev/null and b/desktop/public/runtime-icons/hermes.png differ diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/hermes.rs b/desktop/src-tauri/src/managed_agents/config_bridge/hermes.rs new file mode 100644 index 0000000000..931b8a2eee --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/config_bridge/hermes.rs @@ -0,0 +1,157 @@ +use std::path::PathBuf; + +use super::types::RuntimeFileConfig; + +/// Read Hermes Agent config from `$HERMES_HOME/config.yaml` or `~/.hermes/config.yaml`. +pub(super) fn read_config_file() -> Option { + let path = hermes_config_path()?; + read_config_from_path(&path) +} + +fn read_config_from_path(path: &std::path::Path) -> Option { + let raw = std::fs::read_to_string(path).ok()?; + parse_hermes_config(&raw) +} + +fn parse_hermes_config(yaml_str: &str) -> Option { + let root: serde_yaml::Value = serde_yaml::from_str(yaml_str).ok()?; + let map = root.as_mapping()?; + + let model_value = mapping_value(map, "model"); + let model = match model_value { + Some(serde_yaml::Value::String(value)) => nonempty(value), + Some(serde_yaml::Value::Mapping(model_map)) => mapping_string(model_map, "default"), + _ => None, + }; + let provider = model_value + .and_then(serde_yaml::Value::as_mapping) + .and_then(|model_map| mapping_string(model_map, "provider")); + let thinking_effort = mapping_value(map, "agent") + .and_then(serde_yaml::Value::as_mapping) + .and_then(|agent_map| mapping_string(agent_map, "reasoning_effort")); + + let mut extra = std::collections::BTreeMap::new(); + if let Some(base_url) = model_value + .and_then(serde_yaml::Value::as_mapping) + .and_then(|model_map| mapping_string(model_map, "base_url")) + { + extra.insert("model.base_url".to_string(), base_url); + } + + let extensions = mapping_value(map, "mcp_servers") + .and_then(serde_yaml::Value::as_mapping) + .map(|servers| { + servers + .iter() + .filter_map(|(name, value)| { + let name = nonempty(name.as_str()?)?; + let server = value.as_mapping()?; + let kind = if mapping_string(server, "url").is_some() { + "http" + } else if mapping_string(server, "command").is_some() { + "stdio" + } else { + "unknown" + }; + let enabled = mapping_value(server, "enabled") + .and_then(serde_yaml::Value::as_bool) + .unwrap_or(true); + Some(super::types::ExtensionEntry { + name, + kind: kind.to_string(), + enabled, + }) + }) + .collect() + }) + .unwrap_or_default(); + + Some(RuntimeFileConfig { + model, + provider, + thinking_effort, + extensions, + extra, + ..RuntimeFileConfig::default() + }) +} + +fn mapping_value<'a>(map: &'a serde_yaml::Mapping, key: &str) -> Option<&'a serde_yaml::Value> { + map.get(serde_yaml::Value::String(key.to_string())) +} + +fn mapping_string(map: &serde_yaml::Mapping, key: &str) -> Option { + mapping_value(map, key) + .and_then(serde_yaml::Value::as_str) + .and_then(nonempty) +} + +fn nonempty(value: &str) -> Option { + let trimmed = value.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) +} + +pub(super) fn hermes_config_path() -> Option { + if let Ok(root) = std::env::var("HERMES_HOME") { + return Some(PathBuf::from(root).join("config.yaml")); + } + dirs::home_dir().map(|home| home.join(".hermes").join("config.yaml")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_model_provider_reasoning_and_mcp_servers() { + let yaml = r#" +model: + default: anthropic/claude-sonnet-4 + provider: openrouter + base_url: https://openrouter.ai/api/v1 +agent: + reasoning_effort: high +mcp_servers: + filesystem: + command: npx + args: ["-y", "@modelcontextprotocol/server-filesystem"] + remote: + url: https://mcp.example.test + enabled: false +"#; + + let cfg = parse_hermes_config(yaml).expect("valid Hermes YAML should parse"); + assert_eq!(cfg.model.as_deref(), Some("anthropic/claude-sonnet-4")); + assert_eq!(cfg.provider.as_deref(), Some("openrouter")); + assert_eq!(cfg.thinking_effort.as_deref(), Some("high")); + assert_eq!( + cfg.extra.get("model.base_url").map(String::as_str), + Some("https://openrouter.ai/api/v1") + ); + assert!(cfg + .extensions + .iter() + .any(|entry| entry.name == "filesystem" && entry.kind == "stdio" && entry.enabled)); + assert!(cfg + .extensions + .iter() + .any(|entry| entry.name == "remote" && entry.kind == "http" && !entry.enabled)); + } + + #[test] + fn accepts_legacy_scalar_model_and_empty_yaml() { + let scalar = parse_hermes_config("model: glm-5").expect("scalar model should parse"); + assert_eq!(scalar.model.as_deref(), Some("glm-5")); + assert!(scalar.provider.is_none()); + + let empty = parse_hermes_config("{}").expect("empty config should parse"); + assert!(empty.model.is_none()); + assert!(empty.provider.is_none()); + assert!(empty.extensions.is_empty()); + } + + #[test] + fn invalid_yaml_returns_none() { + assert!(parse_hermes_config("{{{{not yaml").is_none()); + } +} diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs b/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs index f8b045fc72..a68d27e439 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs @@ -2,6 +2,7 @@ mod buzz_agent; mod claude; mod codex; mod goose; +mod hermes; pub(crate) mod reader; mod schema_walker; pub(crate) mod types; diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs index 372d2cfde1..46dd341b8b 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs @@ -20,6 +20,7 @@ pub(crate) fn read_config_surface( .map(|m| m.id) .and_then(|id| match id { "goose" => super::goose::read_config_file().map(|c| (c, true)), + "hermes" => super::hermes::read_config_file().map(|c| (c, true)), "claude" => super::claude::read_config_file().map(|c| (c, true)), "codex" => super::codex::read_config_file().map(|c| (c, true)), "buzz-agent" => super::buzz_agent::read_config_file().map(|c| (c, true)), @@ -218,6 +219,9 @@ fn mcp_config_file_path_for_runtime(runtime: &KnownAcpRuntime) -> Option "goose" => { super::goose::goose_config_path().map(|path| path.to_string_lossy().into_owned()) } + "hermes" => { + super::hermes::hermes_config_path().map(|path| path.to_string_lossy().into_owned()) + } "claude" => Some(resolve_tilde("~/.claude.json")), "codex" => { super::codex::codex_config_path().map(|path| path.to_string_lossy().into_owned()) diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index f40eed5a13..1cdae3fdbd 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -18,6 +18,8 @@ const CLAUDE_CODE_AVATAR_URL: &str = "https://anthropic.gallerycdn.vsassets.io/e const CODEX_AVATAR_URL: &str = "https://openai.gallerycdn.vsassets.io/extensions/openai/chatgpt/26.5313.41514/1773706730621/Microsoft.VisualStudio.Services.Icons.Default"; const BUZZ_AGENT_AVATAR_URL: &str = "https://raw.githubusercontent.com/block/buzz/refs/heads/main/crates/buzz-agent/buzz-agent.png"; +const HERMES_AVATAR_URL: &str = + "https://raw.githubusercontent.com/NousResearch/hermes-agent/main/website/static/img/apple-touch-icon.png"; fn common_binary_paths() -> &'static [PathBuf] { static PATHS: OnceLock> = OnceLock::new(); @@ -157,6 +159,39 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ // Verified: `codex login status` exits 0 when logged in, non-zero otherwise. auth_probe_args: Some(&["codex", "login", "status"]), }, + KnownAcpRuntime { + id: "hermes", + label: "Hermes Agent", + commands: &["hermes"], + aliases: &["hermes-agent"], + avatar_url: HERMES_AVATAR_URL, + mcp_command: None, + mcp_hooks: false, + underlying_cli: Some("hermes"), + cli_install_commands: &[ + "curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash", + ], + cli_install_commands_windows: &["powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"iex (irm https://hermes-agent.nousresearch.com/install.ps1)\""], + adapter_install_commands: &[], + install_instructions_url: "https://hermes-agent.nousresearch.com/docs/", + cli_install_hint: "Install Hermes Agent via the official install script.", + adapter_install_hint: "", + skill_dir: Some(".hermes/skills"), + supports_acp_model_switching: true, + model_env_var: None, + provider_env_var: None, + provider_locked: false, + default_env: &[], + config_file_path: Some("~/.hermes/config.yaml"), + config_file_format: Some("yaml"), + supports_acp_native_config: false, + thinking_env_var: None, + max_tokens_env_var: None, + context_limit_env_var: None, + required_normalized_fields: &[], + login_hint: Some("Run `hermes model` to configure a provider and model."), + auth_probe_args: Some(&["hermes", "config", "get", "model.provider"]), + }, KnownAcpRuntime { id: "buzz-agent", label: "Buzz Agent", @@ -342,7 +377,7 @@ pub use overrides::{apply_agent_command_update, create_time_agent_command_overri fn default_agent_args(command: &str) -> Option> { match normalize_command_identity(command).as_str() { - "goose" => Some(vec!["acp".to_string()]), + "goose" | "hermes" | "hermes-agent" => Some(vec!["acp".to_string()]), "codex" | "codex-acp" | "claude-agent-acp" | "claude-code-acp" | "claude-code" | "claudecode" | "buzz-agent" => Some(Vec::new()), _ => None, diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index 0ed4fe0f6a..b262d82dc5 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -5,10 +5,10 @@ use super::{ apply_agent_command_update, classify_runtime, codex_adapter_availability, codex_adapter_is_outdated, create_time_agent_command_override, default_agent_command, effective_agent_command, find_nvm_default_bin, find_via_login_shell, - is_login_shell_path_uninit, is_safe_nvm_tag, managed_agent_avatar_url, normalize_agent_args, - parse_semver_tag, probe_codex_acp_major_version, record_agent_command, - refresh_login_shell_path, BUZZ_AGENT_AVATAR_URL, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, - GOOSE_AVATAR_URL, + is_login_shell_path_uninit, is_safe_nvm_tag, known_acp_runtime_exact, + managed_agent_avatar_url, normalize_agent_args, parse_semver_tag, + probe_codex_acp_major_version, record_agent_command, refresh_login_shell_path, + BUZZ_AGENT_AVATAR_URL, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL, }; use crate::managed_agents::AcpAvailabilityStatus; @@ -96,6 +96,45 @@ fn normalizes_buzz_agent_args_to_empty() { ); } +#[test] +fn hermes_runtime_contract_is_acp_native() { + let runtime = known_acp_runtime_exact("hermes").expect("Hermes runtime must be registered"); + + assert_eq!( + normalize_agent_args("hermes", Vec::new()), + vec!["acp".to_string()], + "desktop launches must enter Hermes' ACP stdio mode" + ); + assert_eq!( + managed_agent_avatar_url("/Users/test/.local/bin/hermes"), + Some( + "https://raw.githubusercontent.com/NousResearch/hermes-agent/main/website/static/img/apple-touch-icon.png" + .to_string() + ) + ); + assert_eq!( + runtime.cli_install_commands, + &["curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash"] + ); + assert_eq!( + runtime.cli_install_commands_windows, + &["powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"iex (irm https://hermes-agent.nousresearch.com/install.ps1)\""] + ); + assert!(runtime.adapter_install_commands.is_empty()); + assert!(runtime.supports_acp_model_switching); + assert_eq!(runtime.model_env_var, None); + assert_eq!(runtime.provider_env_var, None); + assert!(runtime.required_normalized_fields.is_empty()); + assert_eq!( + runtime.auth_probe_args, + Some(&["hermes", "config", "get", "model.provider"][..]) + ); + assert_eq!( + runtime.login_hint, + Some("Run `hermes model` to configure a provider and model.") + ); +} + #[test] fn login_shell_lookup_treats_command_as_data() { let marker = diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index 87ee6241ee..e03fea2f9a 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -289,6 +289,11 @@ fn collect_missing_requirements( rt, ), "codex" => cli_login::requirements(&["codex", "login", "status"], "run `codex login`", rt), + "hermes" => cli_login::requirements( + &["hermes", "config", "get", "model.provider"], + "run `hermes model`", + rt, + ), _ => vec![], } } @@ -510,6 +515,35 @@ mod tests { .collect() } + #[cfg(unix)] + #[test] + fn hermes_unconfigured_provider_is_not_ready() { + use std::os::unix::fs::PermissionsExt; + + let _guard = crate::managed_agents::lock_path_mutex(); + let dir = tempfile::tempdir().expect("create temp dir"); + let bin = dir.path().join("hermes"); + std::fs::write(&bin, "#!/bin/sh\nexit 1\n").expect("write fake Hermes binary"); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)) + .expect("chmod fake Hermes binary"); + + let original_path = std::env::var("PATH").unwrap_or_default(); + std::env::set_var("PATH", format!("{}:{original_path}", dir.path().display())); + crate::managed_agents::clear_resolve_cache(); + + let result = agent_readiness(&make_env("hermes", BTreeMap::new())); + + std::env::set_var("PATH", &original_path); + crate::managed_agents::clear_resolve_cache(); + + assert!(!result.is_ready(), "unconfigured Hermes must not be ready"); + assert!(result.requirements().iter().any(|requirement| matches!( + requirement, + Requirement::CliLogin { setup_copy, .. } + if setup_copy == "run `hermes model`" + ))); + } + // ── buzz-agent tests ────────────────────────────────────────────────── #[test] diff --git a/desktop/src/features/onboarding/ui/RuntimeIcon.tsx b/desktop/src/features/onboarding/ui/RuntimeIcon.tsx index a410914377..13a8c03442 100644 --- a/desktop/src/features/onboarding/ui/RuntimeIcon.tsx +++ b/desktop/src/features/onboarding/ui/RuntimeIcon.tsx @@ -13,6 +13,7 @@ const RUNTIME_LOGOS: Record = { claude: claudeLogoUrl, codex: chatgptLogoUrl, goose: gooseLogoUrl, + hermes: "/runtime-icons/hermes.png", }; function isBuzzRuntime(runtime: AcpRuntimeCatalogEntry): boolean { diff --git a/desktop/src/features/onboarding/ui/SetupStep.tsx b/desktop/src/features/onboarding/ui/SetupStep.tsx index 96e7322d14..6fc2ccc1f4 100644 --- a/desktop/src/features/onboarding/ui/SetupStep.tsx +++ b/desktop/src/features/onboarding/ui/SetupStep.tsx @@ -603,7 +603,14 @@ function RuntimeProvidersSection({
{orderedItems.length > 0 ? ( -
+
= 3 + ? "max-w-[896px] md:grid-cols-3" + : "max-w-[592px] md:grid-cols-2", + ) + > {orderedItems.map((runtime) => ( - No supported agent harnesses were detected yet. Install Claude Code - or Codex, then check again. + No supported agent harnesses were detected yet. Install Hermes + Agent, Claude Code, or Codex, then check again.

)} diff --git a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts index 51339e2afe..1c6eed2efa 100644 --- a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts +++ b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts @@ -1,6 +1,6 @@ import type { AcpRuntimeCatalogEntry } from "@/shared/api/types"; -export const ONBOARDING_RUNTIME_ORDER = ["claude", "codex"]; +export const ONBOARDING_RUNTIME_ORDER = ["hermes", "claude", "codex"]; const VISIBLE_ONBOARDING_RUNTIME_IDS = new Set( ONBOARDING_RUNTIME_ORDER, diff --git a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx index cee156ece7..a2cde1b919 100644 --- a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx +++ b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx @@ -39,6 +39,7 @@ const RUNTIME_LOGO_URLS: Record = { claude: "/runtime-icons/claude.png", codex: "/runtime-icons/codex.png", goose: "/runtime-icons/goose.svg", + hermes: "/runtime-icons/hermes.png", }; const RUNTIME_LOGO_SCALE: Record = { @@ -46,11 +47,13 @@ const RUNTIME_LOGO_SCALE: Record = { claude: "scale-110", codex: "scale-110", goose: "scale-125", + hermes: "scale-110", }; const RUNTIME_SORT_PRIORITY: Record = { "buzz-agent": 0, - goose: 1, + hermes: 1, + goose: 2, }; function RuntimeLogo({ runtime }: { runtime: AcpRuntimeCatalogEntry }) { diff --git a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts index f8e6322ed3..d60463a7e5 100644 --- a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts +++ b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts @@ -3,7 +3,7 @@ import { installMockBridge } from "../helpers/bridge"; import { passThroughBackupStep } from "../helpers/onboarding"; function runtime( - id: "buzz-agent" | "claude" | "codex" | "goose", + id: "buzz-agent" | "claude" | "codex" | "goose" | "hermes", availability: string, authStatus: Record, overrides: Record = {}, @@ -17,7 +17,9 @@ function runtime( ? "Claude Code" : id === "codex" ? "Codex" - : "Goose", + : id === "hermes" + ? "Hermes Agent" + : "Goose", avatar_url: "", availability, command: availability === "available" ? id : null, @@ -57,7 +59,7 @@ async function readSavedRuntime(page: Parameters[0]) { }); } -test("setup shows only Claude Code and Codex as detected harnesses", async ({ +test("setup shows Hermes, Claude Code, and Codex as detected harnesses", async ({ page, }) => { await installMockBridge( @@ -66,6 +68,7 @@ test("setup shows only Claude Code and Codex as detected harnesses", async ({ acpRuntimesCatalog: [ runtime("buzz-agent", "available", { status: "not_applicable" }), runtime("goose", "available", { status: "not_applicable" }), + runtime("hermes", "available", { status: "logged_in" }), runtime("codex", "available", { status: "logged_in" }), runtime("claude", "available", { status: "logged_in" }), ], @@ -77,6 +80,14 @@ test("setup shows only Claude Code and Codex as detected harnesses", async ({ await expect(page.getByTestId("onboarding-runtime-claude")).toBeVisible(); await expect(page.getByTestId("onboarding-runtime-codex")).toBeVisible(); + const hermes = page.getByTestId("onboarding-runtime-hermes"); + await expect(hermes).toBeVisible(); + await expect(hermes.getByRole("heading", { name: "Hermes Agent" })).toBeVisible(); + const hermesLogo = hermes.locator('img[src="/runtime-icons/hermes.png"]'); + await expect(hermesLogo).toBeVisible(); + await expect + .poll(() => hermesLogo.evaluate((image: HTMLImageElement) => image.naturalWidth)) + .toBeGreaterThan(0); await expect(page.getByTestId("onboarding-runtime-goose")).toHaveCount(0); await expect(page.getByTestId("onboarding-runtime-buzz-agent")).toHaveCount( 0,