From 34cce5f7e5e27b432c0b06c240694eea046155fd Mon Sep 17 00:00:00 2001 From: joelbrilliant Date: Fri, 24 Jul 2026 07:58:03 +1000 Subject: [PATCH 1/5] feat(acp): native Hermes runtime and durable session load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register Hermes as a known ACP runtime in Desktop and buzz-acp, with command normalisation for hermes and hermes-acp entrypoints. Persist channel→session bindings across harness restarts and restore via session/load when the agent advertises loadSession (Hermes does). History replay stays on the wire log path and is not re-published to Buzz. Add owner dogfood guide for attaching Rocky with HERMES_HOME and acp.tool_policy: profile. Signed-off-by: joelbrilliant --- Cargo.lock | 2 + crates/buzz-acp/Cargo.toml | 4 + crates/buzz-acp/src/acp.rs | 41 +++ crates/buzz-acp/src/config.rs | 29 ++- crates/buzz-acp/src/lib.rs | 27 +- crates/buzz-acp/src/pool.rs | 117 +++++++++ crates/buzz-acp/src/session_store.rs | 239 ++++++++++++++++++ .../src-tauri/src/managed_agents/discovery.rs | 39 ++- .../src/managed_agents/discovery/tests.rs | 37 ++- .../src-tauri/src/managed_agents/runtime.rs | 3 + docs/hermes-agent-rocky.md | 86 +++++++ 11 files changed, 615 insertions(+), 9 deletions(-) create mode 100644 crates/buzz-acp/src/session_store.rs create mode 100644 docs/hermes-agent-rocky.md diff --git a/Cargo.lock b/Cargo.lock index 32152e41ab..f7f156206b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -771,6 +771,7 @@ dependencies = [ "buzz-sdk", "chrono", "clap", + "dirs", "evalexpr", "futures-util", "hex", @@ -782,6 +783,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.11.0", + "tempfile", "thiserror 2.0.18", "tokio", "tokio-tungstenite 0.29.0", diff --git a/crates/buzz-acp/Cargo.toml b/crates/buzz-acp/Cargo.toml index d047849806..b11ac1206f 100644 --- a/crates/buzz-acp/Cargo.toml +++ b/crates/buzz-acp/Cargo.toml @@ -68,6 +68,9 @@ clap = { version = "4", features = ["derive", "env"] } # Config file toml = "1.0" +# Durable ACP session binding store location +dirs = "6" + # Filter expressions evalexpr = { workspace = true } @@ -78,4 +81,5 @@ nix = { version = "0.31", default-features = false, features = ["signal"] } [dev-dependencies] tokio = { workspace = true, features = ["test-util"] } +tempfile = "3" httparse = "1" diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 78db7ff718..59db3c9f4b 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -601,6 +601,47 @@ impl AcpClient { .session_id) } + /// Send `session/load` for an existing ACP session id. + /// + /// Used after harness restart when a durable channel→session binding is + /// known and the agent advertised `agentCapabilities.loadSession`. + /// History-replay `session/update` notifications are consumed by the + /// request loop and logged only — they are not re-published to Buzz. + pub async fn session_load_full( + &mut self, + cwd: &str, + session_id: &str, + mcp_servers: Vec, + ) -> Result { + let params = serde_json::json!({ + "cwd": cwd, + "sessionId": session_id, + "mcpServers": mcp_servers, + }); + let result = self.send_request("session/load", params).await?; + // Spec-compliant agents may omit sessionId on load (it is implied). + // Prefer the request id so callers always have a concrete binding. + let resolved_id = result + .get("sessionId") + .and_then(|v| v.as_str()) + .unwrap_or(session_id) + .to_owned(); + tracing::info!(target: "acp::session", "session loaded: {resolved_id}"); + Ok(SessionNewResponse { + session_id: resolved_id, + raw: result, + }) + } + + /// Returns true when an initialize result advertises `loadSession`. + pub fn agent_supports_load_session(init_result: &serde_json::Value) -> bool { + init_result + .get("agentCapabilities") + .and_then(|caps| caps.get("loadSession")) + .and_then(|v| v.as_bool()) + .unwrap_or(false) + } + /// Send Goose's custom system-prompt request after `session/new`. pub async fn session_set_goose_system_prompt( &mut self, diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index a38d6faa14..e2ee841201 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -616,7 +616,10 @@ pub(crate) fn normalize_agent_command_identity(command: &str) -> String { fn default_agent_args(command: &str) -> Option> { match normalize_agent_command_identity(command).as_str() { - "goose" => Some(vec!["acp".to_string()]), + // Official Hermes entrypoint takes a subcommand: `hermes acp`. + "goose" | "hermes" => Some(vec!["acp".to_string()]), + // Direct console script already is the ACP server. + "hermes-acp" | "hermes_acp" => Some(Vec::new()), "codex" | "codex-acp" | "claude-agent-acp" | "claude-code-acp" | "claude-code" | "claudecode" | "buzz-agent" => Some(Vec::new()), _ => None, @@ -1492,6 +1495,30 @@ mod tests { ); } + #[test] + fn normalizes_hermes_entrypoints() { + assert_eq!( + normalize_agent_args("hermes", Vec::new()), + vec!["acp".to_string()] + ); + assert_eq!( + normalize_agent_args("hermes-acp", Vec::new()), + Vec::::new() + ); + assert_eq!( + normalize_agent_args("hermes-acp", vec!["acp".into()]), + Vec::::new() + ); + // Explicit profile selection must be preserved for multi-profile hosts. + assert_eq!( + normalize_agent_args( + "hermes", + vec!["-p".into(), "default".into(), "acp".into()] + ), + vec!["-p".to_string(), "default".to_string(), "acp".to_string()] + ); + } + #[test] fn normalize_agent_command_identity_variants() { assert_eq!(normalize_agent_command_identity("goose"), "goose"); diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 03b75a4211..9d5311a146 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -9,6 +9,7 @@ mod pool; mod pool_lifecycle; mod queue; mod relay; +mod session_store; mod setup_mode; mod usage; @@ -1143,7 +1144,7 @@ struct RespawnResult { /// Tuple: (initialized client, protocol version, supports_goose_steer). /// The third element is always `true` — the supervisor uses /// try-and-tolerate for the steer extension. - result: Result<(AcpClient, u32, String)>, + result: Result<(AcpClient, u32, String, bool)>, } /// Outcome of a non-cancelling steer attempt, forwarded from a per-attempt @@ -1187,7 +1188,7 @@ impl RespawnGuard { /// Send the result and disarm the guard. Uses `try_send` (sync) so there /// is no await boundary between marking `sent` and actually enqueueing — /// cancellation cannot slip between the two. - fn send(mut self, result: Result<(AcpClient, u32, String)>) { + fn send(mut self, result: Result<(AcpClient, u32, String, bool)>) { // Invariant: try_send succeeds because the channel capacity equals the // slot count, and respawn_in_flight guarantees at most one outstanding // result per slot. If this ever fails, the channel sizing or the @@ -1560,6 +1561,14 @@ async fn tokio_main() -> Result<()> { memory_enabled: config.memory_enabled, harness_name: crate::config::normalize_agent_command_identity(&config.agent_command), relay_url: config.relay_url.clone(), + agent_command: config.agent_command.clone(), + agent_args: config.agent_args.clone(), + session_store: std::sync::Arc::new(crate::session_store::SessionStore::open( + crate::session_store::SessionStore::default_path( + &config.agent_command, + &config.agent_args, + ), + )), }); if !config.memory_enabled { @@ -1785,7 +1794,7 @@ async fn tokio_main() -> Result<()> { while let Ok(rr) = respawn_rx.try_recv() { crash_history[rr.index].respawn_in_flight = false; match rr.result { - Ok((acp, protocol_version, agent_name)) => { + Ok((acp, protocol_version, agent_name, supports_load_session)) => { let agent = OwnedAgent { index: rr.index, acp, @@ -1796,6 +1805,7 @@ async fn tokio_main() -> Result<()> { agent_name, goose_system_prompt_supported: None, protocol_version, + supports_load_session, }; pool.return_agent(agent); tracing::info!(agent = rr.index, "respawn complete"); @@ -2665,7 +2675,7 @@ async fn tokio_main() -> Result<()> { // Drain any respawn results that completed before the abort. Explicitly // shut down returned agents instead of relying on AcpClient::Drop. while let Ok(rr) = respawn_rx.try_recv() { - if let Ok((mut acp, _, _)) = rr.result { + if let Ok((mut acp, _, _, _)) = rr.result { acp.shutdown().await; tracing::debug!(agent = rr.index, "reaped respawned agent on shutdown"); } @@ -3748,6 +3758,8 @@ async fn initialize_agent_pool( }), ); let agent_name = normalized_agent_name(&init_result); + let supports_load_session = + AcpClient::agent_supports_load_session(&init_result); agent_slots.push(Some(OwnedAgent { index: i, acp, @@ -3758,6 +3770,7 @@ async fn initialize_agent_pool( agent_name, goose_system_prompt_supported: None, protocol_version, + supports_load_session, })); } Ok(Err(e)) => { @@ -3808,7 +3821,7 @@ async fn spawn_and_init( has_generated_codex_config: bool, agent_index: usize, observer: Option, -) -> Result<(AcpClient, u32, String)> { +) -> Result<(AcpClient, u32, String, bool)> { let mut acp = AcpClient::spawn(command, args, extra_env, has_generated_codex_config) .await .map_err(|e| anyhow::anyhow!("failed to spawn agent: {e}"))?; @@ -3818,6 +3831,7 @@ async fn spawn_and_init( Ok(init_result) => { tracing::info!("agent initialized: {init_result}"); let protocol_version = init_result["protocolVersion"].as_u64().unwrap_or(1) as u32; + let supports_load_session = AcpClient::agent_supports_load_session(&init_result); acp.observe( "agent_initialized", serde_json::json!({ @@ -3826,7 +3840,7 @@ async fn spawn_and_init( }), ); let agent_name = normalized_agent_name(&init_result); - Ok((acp, protocol_version, agent_name)) + Ok((acp, protocol_version, agent_name, supports_load_session)) } Err(e) => { // Explicitly shut down the spawned child to prevent zombie/leak. @@ -5143,6 +5157,7 @@ mod error_outcome_emission_tests { // Error branches under test never read this; 1 is the legacy // non-systemPrompt path, the simplest valid value. protocol_version: 1, + supports_load_session: false, } } diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index cc537f8683..2c5fb7d99b 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -168,6 +168,8 @@ pub struct OwnedAgent { pub goose_system_prompt_supported: Option, /// Protocol version reported by the agent in its initialize response. pub protocol_version: u32, + /// Whether the agent advertised `agentCapabilities.loadSession` at init. + pub supports_load_session: bool, } fn has_system_prompt_support( @@ -529,6 +531,12 @@ pub struct PromptContext { /// the desktop keys per (agent, relay) pair, e.g. `session_config_captured`, /// mirroring the `managed_agent_runtime_lifecycle` frames. pub relay_url: String, + /// Agent binary as configured (for durable session binding identity). + pub agent_command: String, + /// Agent args as configured (for durable session binding identity). + pub agent_args: Vec, + /// Durable channel→session bindings surviving harness restarts. + pub session_store: std::sync::Arc, } impl AgentPool { @@ -795,6 +803,81 @@ const CONTROL_CANCEL_GRACE: Duration = Duration::from_secs(5); /// Timeout for permission-mode requests (`session/set_config_option` with `configId: "mode"`). const PERMISSION_MODE_TIMEOUT: Duration = Duration::from_secs(5); +/// Try to restore a durable channel session via `session/load`. +/// +/// Returns `Some(session_id)` on success. On miss, capability absence, or load +/// failure, clears the stale binding (when present) and returns `None` so the +/// caller can fall through to `session/new`. +async fn try_load_persisted_session( + agent: &mut OwnedAgent, + ctx: &PromptContext, + channel_id: &Uuid, + _agent_core: Option<&str>, + _agent_canvas: Option<&str>, +) -> Option { + if !agent.supports_load_session { + return None; + } + let stored = ctx + .session_store + .get(&ctx.agent_command, &ctx.agent_args, channel_id)?; + match agent + .acp + .session_load_full(&ctx.cwd, &stored, ctx.mcp_servers.clone()) + .await + { + Ok(resp) => { + if agent.model_capabilities.is_none() { + agent.model_capabilities = Some(AgentModelCapabilities { + config_options_raw: extract_model_config_options(&resp.raw), + available_models_raw: extract_model_state(&resp.raw), + }); + } + // Re-apply desired model after load when present. + if let Some(ref desired) = agent.desired_model { + if let Some(method) = resolve_model_switch_method(&resp.raw, desired) { + if let Err(e) = + apply_model_switch(&mut agent.acp, &resp.session_id, desired, &method).await + { + tracing::warn!( + target: "pool::session", + error = %e, + "model re-apply after session/load failed — continuing with loaded session" + ); + } + } + } + if !ctx.permission_mode.is_default() + && agent_supports_mode(&resp.raw, ctx.permission_mode.as_wire_str()) + { + if let Err(e) = + apply_permission_mode(&mut agent.acp, &resp.session_id, &ctx.permission_mode) + .await + { + tracing::warn!( + target: "pool::session", + error = %e, + "permission mode after session/load failed — continuing" + ); + } + } + Some(resp.session_id) + } + Err(e) => { + tracing::warn!( + target: "pool::session", + session_id = %stored, + channel_id = %channel_id, + error = %e, + "session/load failed — clearing binding and creating a new session" + ); + ctx.session_store + .remove(&ctx.agent_command, &ctx.agent_args, channel_id); + None + } + } +} + /// Create a new ACP session via `session_new_full()`, populate model capabilities /// on the agent (first session only), and apply `desired_model` if set. /// @@ -1469,6 +1552,24 @@ pub async fn run_prompt_task( PromptSource::Channel(cid) => { if let Some(sid) = agent.state.sessions.get(cid) { (sid.clone(), false) + } else if let Some(sid) = try_load_persisted_session( + &mut agent, + &ctx, + cid, + agent_core.as_deref(), + agent_canvas.as_deref(), + ) + .await + { + tracing::info!( + target: "pool::session", + "loaded session {sid} for channel {cid}" + ); + agent.state.sessions.insert(*cid, sid.clone()); + if let Some((pending_cid, section)) = pending_canvas.take() { + agent.state.canvas_sections.insert(pending_cid, section); + } + (sid, false) } else { // Create new session with model application. match create_session_and_apply_model( @@ -1485,6 +1586,12 @@ pub async fn run_prompt_task( "created session {sid} for channel {cid}" ); agent.state.sessions.insert(*cid, sid.clone()); + ctx.session_store.put( + &ctx.agent_command, + &ctx.agent_args, + cid, + &sid, + ); // Commit canvas only after session creation succeeds (I3). if let Some((pending_cid, section)) = pending_canvas.take() { agent.state.canvas_sections.insert(pending_cid, section); @@ -4998,6 +5105,7 @@ mod tests { agent_name: "unknown".into(), goose_system_prompt_supported: None, protocol_version: 2, + supports_load_session: false, }; // Simulate dispatch: install a steer receiver (normally done by @@ -5056,6 +5164,7 @@ mod tests { agent_name: "unknown".into(), goose_system_prompt_supported: None, protocol_version: 2, + supports_load_session: false, }; // Simulate a completed turn: `steer_rx` was consumed by the read loop @@ -5307,6 +5416,14 @@ mod tests { memory_enabled: false, harness_name: "goose".to_string(), relay_url: "ws://127.0.0.1:3000".to_string(), + agent_command: "goose".to_string(), + agent_args: vec!["acp".to_string()], + session_store: std::sync::Arc::new(crate::session_store::SessionStore::open( + std::env::temp_dir().join(format!( + "buzz-acp-test-sessions-{}.json", + uuid::Uuid::new_v4() + )), + )), } } diff --git a/crates/buzz-acp/src/session_store.rs b/crates/buzz-acp/src/session_store.rs new file mode 100644 index 0000000000..67573c3f64 --- /dev/null +++ b/crates/buzz-acp/src/session_store.rs @@ -0,0 +1,239 @@ +//! Durable channel → ACP session bindings for harness restarts. +//! +//! `SessionState` is in-memory only. Agents that advertise `loadSession` (e.g. +//! Hermes) can restore a prior ACP conversation after the harness respawns if +//! the channel→session mapping survives. This module persists that mapping as +//! a small JSON sidecar under the process data directory. +//! +//! Keyed by `(agent_command_identity, agent_args, channel_id)` so different +//! agent binaries / profiles do not share bindings. Heartbeats are never +//! stored — they stay ephemeral. + +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::config::normalize_agent_command_identity; + +/// Environment override for the session store path (tests / operators). +pub const SESSION_STORE_ENV: &str = "BUZZ_ACP_SESSION_STORE"; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +struct StoreFile { + /// version for future migrations + version: u32, + /// map key → ACP session id + sessions: HashMap, +} + +/// Process-wide durable session binding store. +pub struct SessionStore { + path: PathBuf, + inner: Mutex, +} + +impl SessionStore { + /// Open or create the store at the resolved path. + pub fn open(path: PathBuf) -> Self { + let data = load_store(&path); + Self { + path, + inner: Mutex::new(data), + } + } + + /// Resolve the default store path for this agent identity. + pub fn default_path(agent_command: &str, agent_args: &[String]) -> PathBuf { + if let Ok(override_path) = std::env::var(SESSION_STORE_ENV) { + if !override_path.trim().is_empty() { + return PathBuf::from(override_path); + } + } + let identity = store_identity(agent_command, agent_args); + let base = dirs::data_local_dir() + .or_else(dirs::data_dir) + .unwrap_or_else(|| PathBuf::from(".")); + base.join("buzz-acp") + .join("sessions") + .join(format!("{identity}.json")) + } + + /// Look up a stored ACP session id for a channel. + pub fn get(&self, agent_command: &str, agent_args: &[String], channel_id: &Uuid) -> Option { + let key = binding_key(agent_command, agent_args, channel_id); + self.inner + .lock() + .ok() + .and_then(|guard| guard.sessions.get(&key).cloned()) + } + + /// Persist a channel → session binding. + pub fn put( + &self, + agent_command: &str, + agent_args: &[String], + channel_id: &Uuid, + session_id: &str, + ) { + let key = binding_key(agent_command, agent_args, channel_id); + let Ok(mut guard) = self.inner.lock() else { + return; + }; + guard.version = 1; + guard.sessions.insert(key, session_id.to_owned()); + if let Err(e) = save_store(&self.path, &guard) { + tracing::warn!( + target: "session_store", + path = %self.path.display(), + error = %e, + "failed to persist ACP session binding" + ); + } + } + + /// Remove a binding (after invalidation / failed load). + pub fn remove(&self, agent_command: &str, agent_args: &[String], channel_id: &Uuid) { + let key = binding_key(agent_command, agent_args, channel_id); + let Ok(mut guard) = self.inner.lock() else { + return; + }; + if guard.sessions.remove(&key).is_some() { + if let Err(e) = save_store(&self.path, &guard) { + tracing::warn!( + target: "session_store", + path = %self.path.display(), + error = %e, + "failed to persist ACP session binding removal" + ); + } + } + } +} + +fn store_identity(agent_command: &str, agent_args: &[String]) -> String { + let cmd = normalize_agent_command_identity(agent_command); + let args = agent_args.join(" "); + let raw = if args.is_empty() { + cmd + } else { + format!("{cmd} {args}") + }; + // Keep the filename filesystem-safe and short. + let mut out = String::with_capacity(raw.len()); + for ch in raw.chars() { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { + out.push(ch); + } else { + out.push('_'); + } + } + if out.is_empty() { + "agent".into() + } else { + out + } +} + +fn binding_key(agent_command: &str, agent_args: &[String], channel_id: &Uuid) -> String { + format!( + "{}|{}|{}", + normalize_agent_command_identity(agent_command), + agent_args.join("\u{1f}"), + channel_id + ) +} + +fn load_store(path: &Path) -> StoreFile { + match fs::read_to_string(path) { + Ok(text) => match serde_json::from_str(&text) { + Ok(data) => data, + Err(e) => { + tracing::warn!( + target: "session_store", + path = %path.display(), + error = %e, + "corrupt session store — starting empty" + ); + StoreFile::default() + } + }, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => StoreFile::default(), + Err(e) => { + tracing::warn!( + target: "session_store", + path = %path.display(), + error = %e, + "could not read session store — starting empty" + ); + StoreFile::default() + } + } +} + +fn save_store(path: &Path, data: &StoreFile) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let tmp = path.with_extension("json.tmp"); + let json = serde_json::to_string_pretty(data) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + fs::write(&tmp, json)?; + fs::rename(&tmp, path)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn round_trip_binding() { + let dir = tempdir().unwrap(); + let path = dir.path().join("sessions.json"); + let store = SessionStore::open(path); + let channel = Uuid::new_v4(); + assert!(store.get("hermes", &["acp".into()], &channel).is_none()); + store.put("hermes", &["acp".into()], &channel, "sess-1"); + assert_eq!( + store.get("hermes", &["acp".into()], &channel).as_deref(), + Some("sess-1") + ); + // Re-open from disk. + let store2 = SessionStore::open(store.path.clone()); + assert_eq!( + store2.get("hermes", &["acp".into()], &channel).as_deref(), + Some("sess-1") + ); + store2.remove("hermes", &["acp".into()], &channel); + assert!(store2.get("hermes", &["acp".into()], &channel).is_none()); + } + + #[test] + fn different_args_are_isolated() { + let dir = tempdir().unwrap(); + let store = SessionStore::open(dir.path().join("s.json")); + let channel = Uuid::new_v4(); + store.put("hermes", &["acp".into()], &channel, "a"); + store.put( + "hermes", + &["-p".into(), "chad".into(), "acp".into()], + &channel, + "b", + ); + assert_eq!( + store.get("hermes", &["acp".into()], &channel).as_deref(), + Some("a") + ); + assert_eq!( + store + .get("hermes", &["-p".into(), "chad".into(), "acp".into()], &channel) + .as_deref(), + Some("b") + ); + } +} diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index f40eed5a13..f3acbad2f0 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/acp_registry/icon.svg"; fn common_binary_paths() -> &'static [PathBuf] { static PATHS: OnceLock> = OnceLock::new(); @@ -188,6 +190,40 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ login_hint: None, auth_probe_args: None, }, + KnownAcpRuntime { + id: "hermes", + label: "Hermes Agent", + commands: &["hermes", "hermes-acp"], + aliases: &[], + avatar_url: HERMES_AVATAR_URL, + // Hermes owns its own tools/MCP/skills from the selected profile. + // Do not inject Buzz MCP by default — profile policy stays authoritative. + mcp_command: None, + mcp_hooks: false, + underlying_cli: Some("hermes"), + cli_install_commands: &[], + cli_install_commands_windows: &[], + adapter_install_commands: &[], + install_instructions_url: "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp/", + cli_install_hint: + "Install Hermes Agent and ensure optional ACP support so `hermes acp --check` succeeds.", + 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: true, + thinking_env_var: None, + max_tokens_env_var: None, + context_limit_env_var: None, + required_normalized_fields: &[], + login_hint: Some("Configure Hermes providers in `~/.hermes` (or the selected profile home)."), + auth_probe_args: Some(&["hermes", "acp", "--check"]), + }, ]; /// Skill discovery directories declared by known runtimes. @@ -342,7 +378,8 @@ 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" => Some(vec!["acp".to_string()]), + "hermes-acp" | "hermes_acp" => Some(Vec::new()), "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..9bffb1ecc7 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -8,7 +8,7 @@ use super::{ 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, + GOOSE_AVATAR_URL, HERMES_AVATAR_URL, }; use crate::managed_agents::AcpAvailabilityStatus; @@ -96,6 +96,41 @@ fn normalizes_buzz_agent_args_to_empty() { ); } +#[test] +fn normalizes_hermes_entrypoints() { + assert_eq!( + normalize_agent_args("hermes", Vec::new()), + vec!["acp".to_string()] + ); + assert_eq!( + normalize_agent_args("hermes-acp", Vec::new()), + Vec::::new() + ); + assert_eq!( + normalize_agent_args("hermes-acp", vec!["acp".into()]), + Vec::::new() + ); + assert_eq!( + normalize_agent_args( + "hermes", + vec!["-p".into(), "default".into(), "acp".into()] + ), + vec!["-p".to_string(), "default".to_string(), "acp".to_string()] + ); +} + +#[test] +fn resolves_hermes_avatar() { + assert_eq!( + managed_agent_avatar_url("hermes"), + Some(HERMES_AVATAR_URL.to_string()) + ); + assert_eq!( + managed_agent_avatar_url("/Users/openclaw/.local/bin/hermes-acp"), + Some(HERMES_AVATAR_URL.to_string()) + ); +} + #[test] fn login_shell_lookup_treats_command_as_data() { let marker = diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 6687cbbcf2..c78349daa9 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -46,6 +46,9 @@ pub(crate) const KNOWN_AGENT_BINARIES: &[&str] = &[ "codex-acp", "codex_acp", "goose", + "hermes", + "hermes-acp", + "hermes_acp", // buzz-dev-mcp's multicall personalities (rg, tree, buzz, // git-credential-nostr, git-sign-nostr) are short-lived per-tool-call // invocations — not listed here. diff --git a/docs/hermes-agent-rocky.md b/docs/hermes-agent-rocky.md new file mode 100644 index 0000000000..74f070ebc1 --- /dev/null +++ b/docs/hermes-agent-rocky.md @@ -0,0 +1,86 @@ +# Attach Rocky (Hermes default profile) to Buzz + +This guide is for an **owner-only** Buzz attachment of an existing Hermes profile. +Hermes keeps config, tools, skills, memory, credentials and approvals. Buzz is the +desktop + phone conversation surface. + +## Prerequisites + +1. Hermes Agent installed with ACP support: + ```bash + hermes acp --check + ``` +2. Profile tool policy enabled in the Hermes home you will attach (Rocky default): + ```yaml + # ~/.hermes/config.yaml + acp: + tool_policy: profile + ``` + This uses the profile’s local CLI tool configuration (full capability), not the + coding-only `hermes-acp` toolset. +3. A Buzz build that includes Hermes runtime discovery and durable `session/load` + (branch `feat/hermes-native-profile` or later). + +## Managed agent settings + +| Field | Value | +|---|---| +| Runtime | Hermes Agent | +| Command | `hermes` | +| Args | leave empty (normalises to `acp`) or set `-p acp` for a non-default profile | +| Parallelism | **1** (one process per profile home) | +| Respond-to | **owner-only** | +| Memory | disable Buzz NIP-AE memory (`BUZZ_ACP_NO_MEMORY=1` / no-memory) so Hermes memory stays sole memory | + +### Environment + +For Rocky (default profile home): + +```text +HERMES_HOME=/Users/openclaw/.hermes +``` + +Optional (recommended for dogfood): + +```text +BUZZ_ACP_NO_MEMORY=1 +``` + +Do **not** put Buzz transport secrets in Hermes config. Keep them on the Buzz agent record only. + +## Behaviour notes + +- **Full tools:** with `acp.tool_policy: profile`, Rocky gets the same tool surface as interactive CLI for that profile (skills, memory, browser, kanban, cron, delegation, … as configured). +- **Session continuity:** after Buzz/harness restart, `session/load` restores the prior ACP session when the agent advertises `loadSession`. +- **Permissions:** current Buzz auto-approves ACP permission requests. Acceptable for private owner-only dogfood only. +- **Workers:** Chad/Oscar stay Hermes-internal via Rocky’s `delegate_task`. Do not attach them to Buzz unless you later want separate identities. +- **Gateway concurrency:** if Telegram/Discord gateway is also running against the same `HERMES_HOME`, prefer Buzz as the primary interactive surface and pause the gateway if browser/session contention appears. + +## Headless harness (optional) + +```bash +export BUZZ_PRIVATE_KEY=... +export BUZZ_RELAY_URL=... +export BUZZ_ACP_AGENT_COMMAND=hermes +export BUZZ_ACP_AGENT_ARGS=acp # or leave empty; default becomes acp +export HERMES_HOME=/Users/openclaw/.hermes +export BUZZ_ACP_NO_MEMORY=1 +# respond-to owner-only via your usual harness flags +buzz-acp +``` + +## Multi-profile later + +When you want another Hermes profile on Buzz, add a **second** managed agent with either: + +```text +arguments: -p chad acp +``` + +or + +```text +HERMES_HOME=/Users/openclaw/.hermes/profiles/chad +``` + +Keep `parallelism=1` per profile. From 4c5b80fcd110131097f9f66479a7190dd0d61aa5 Mon Sep 17 00:00:00 2001 From: joelbrilliant Date: Fri, 24 Jul 2026 08:11:32 +1000 Subject: [PATCH 2/5] feat(onboarding): show Hermes on first-run harness setup Include Hermes Agent in the onboarding runtime picker alongside Claude and Codex, treat it as a CLI-native ready path (no Buzz provider pick), and lock provider selection the same way as other profile-owned runtimes. Signed-off-by: joelbrilliant --- .../agents/ui/personaRuntimeModel.test.mjs | 1 + .../src/features/agents/ui/personaRuntimeModel.ts | 5 +++-- .../src/features/onboarding/ui/agentReadiness.ts | 7 +++++-- .../ui/onboardingRuntimeSelection.test.mjs | 15 ++++++++++++--- .../onboarding/ui/onboardingRuntimeSelection.ts | 3 ++- 5 files changed, 23 insertions(+), 8 deletions(-) diff --git a/desktop/src/features/agents/ui/personaRuntimeModel.test.mjs b/desktop/src/features/agents/ui/personaRuntimeModel.test.mjs index 967bd3e129..76947a0464 100644 --- a/desktop/src/features/agents/ui/personaRuntimeModel.test.mjs +++ b/desktop/src/features/agents/ui/personaRuntimeModel.test.mjs @@ -193,6 +193,7 @@ test("resolveRuntimeProviderCapability classifies provider-capable runtimes as c test("resolveRuntimeProviderCapability classifies known CLI-login runtimes as locked before the catalog loads", () => { // The core fix: a not-yet-loaded catalog must not force these to "unknown". + assert.equal(resolveRuntimeProviderCapability("hermes", false), "locked"); assert.equal(resolveRuntimeProviderCapability("claude", false), "locked"); assert.equal(resolveRuntimeProviderCapability("codex", false), "locked"); assert.equal(resolveRuntimeProviderCapability(" claude ", false), "locked"); diff --git a/desktop/src/features/agents/ui/personaRuntimeModel.ts b/desktop/src/features/agents/ui/personaRuntimeModel.ts index d8da4108c9..a50c1f2c87 100644 --- a/desktop/src/features/agents/ui/personaRuntimeModel.ts +++ b/desktop/src/features/agents/ui/personaRuntimeModel.ts @@ -18,7 +18,8 @@ export type ProviderRuntimeCapability = "capable" | "locked" | "unknown"; * provider. To avoid that, we resolve capability STATICALLY for known ids: * * - buzz-agent / goose → "capable" (`isProviderCapable`, id-based). - * - claude / codex → "locked" (CLI-login runtimes; no LLM provider selection). + * - hermes / claude / codex → "locked" (profile/CLI runtimes; no Buzz LLM + * provider selection — the runtime owns model/provider config). * - anything else (custom, empty, genuinely unknown) → "unknown". * * `isProviderCapable` is the caller-supplied {@link @@ -33,7 +34,7 @@ export function resolveRuntimeProviderCapability( return "capable"; } const id = runtimeId.trim(); - if (id === "claude" || id === "codex") { + if (id === "hermes" || id === "claude" || id === "codex") { return "locked"; } return "unknown"; diff --git a/desktop/src/features/onboarding/ui/agentReadiness.ts b/desktop/src/features/onboarding/ui/agentReadiness.ts index 86b9721af3..27e2b71d3f 100644 --- a/desktop/src/features/onboarding/ui/agentReadiness.ts +++ b/desktop/src/features/onboarding/ui/agentReadiness.ts @@ -12,7 +12,8 @@ export type AgentReadinessResult = /** * Determine whether the user has a working agent path configured. * - * CLI path: the preferred Claude or Codex runtime is available and logged in. + * CLI path: the preferred Hermes, Claude, or Codex runtime is available and + * authenticated (or auth-not-applicable). * Provider path: the preferred Buzz Agent or Goose runtime has provider and * model set, plus all required credential env vars for that provider. * @@ -47,7 +48,9 @@ export function resolveAgentReadiness( } if ( - (preferredRuntime.id === "claude" || preferredRuntime.id === "codex") && + (preferredRuntime.id === "hermes" || + preferredRuntime.id === "claude" || + preferredRuntime.id === "codex") && (preferredRuntime.authStatus.status === "logged_in" || preferredRuntime.authStatus.status === "not_applicable") ) { diff --git a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.test.mjs b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.test.mjs index b10aa19154..8f143a09c7 100644 --- a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.test.mjs +++ b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.test.mjs @@ -12,7 +12,8 @@ function runtime(id, availability, status) { return { id, availability, authStatus: { status } }; } -test("only Claude Code and Codex are visible in onboarding", () => { +test("Hermes, Claude Code, and Codex are visible in onboarding", () => { + assert.equal(runtimeIsVisibleInOnboarding("hermes"), true); assert.equal(runtimeIsVisibleInOnboarding("claude"), true); assert.equal(runtimeIsVisibleInOnboarding("codex"), true); assert.equal(runtimeIsVisibleInOnboarding("goose"), false); @@ -25,12 +26,13 @@ test("visible onboarding runtimes use the product order", () => { runtime("buzz-agent", "available", "not_applicable"), runtime("codex", "available", "logged_in"), runtime("goose", "available", "not_applicable"), + runtime("hermes", "available", "logged_in"), runtime("claude", "available", "logged_in"), ]; assert.deepEqual( getVisibleOnboardingRuntimes(runtimes).map(({ id }) => id), - ["claude", "codex"], + ["hermes", "claude", "codex"], ); }); @@ -45,6 +47,12 @@ test("readiness requires an available and authenticated runtime", () => { ), true, ); + assert.equal( + runtimeIsReadyForOnboarding( + runtime("hermes", "available", "logged_in"), + ), + true, + ); assert.equal( runtimeIsReadyForOnboarding(runtime("claude", "available", "logged_out")), false, @@ -60,11 +68,12 @@ test("ready onboarding runtimes exclude hidden ready harnesses", () => { runtime("goose", "available", "not_applicable"), runtime("codex", "available", "logged_out"), runtime("buzz-agent", "available", "not_applicable"), + runtime("hermes", "available", "logged_in"), runtime("claude", "available", "logged_in"), ]; assert.deepEqual( getReadyOnboardingRuntimes(runtimes).map(({ id }) => id), - ["claude"], + ["hermes", "claude"], ); }); diff --git a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts index 51339e2afe..49fdab8e89 100644 --- a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts +++ b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts @@ -1,6 +1,7 @@ import type { AcpRuntimeCatalogEntry } from "@/shared/api/types"; -export const ONBOARDING_RUNTIME_ORDER = ["claude", "codex"]; +/** First-run harness picker. Hermes is included so owner profiles can attach natively. */ +export const ONBOARDING_RUNTIME_ORDER = ["hermes", "claude", "codex"]; const VISIBLE_ONBOARDING_RUNTIME_IDS = new Set( ONBOARDING_RUNTIME_ORDER, From 12168f8e4452ee19d5d976f69eb48d319c2dd554 Mon Sep 17 00:00:00 2001 From: joelbrilliant Date: Fri, 24 Jul 2026 08:36:54 +1000 Subject: [PATCH 3/5] feat(acp): Hermes readiness probe and python process cleanup Steal the strongest Hermes-specific pieces from #2468 without the conflicting external-agent directory work: - command-specific hermes/hermes-acp --check readiness as AdapterMissing - exact python/python3 marker-owned interpreter recognition - dogfood guide fixes (BUZZ_ACP_NO_MEMORY=true, parallelism=1) - buzz-acp README Hermes section Signed-off-by: joelbrilliant --- crates/buzz-acp/README.md | 34 ++++++++++++ .../src-tauri/src/managed_agents/discovery.rs | 46 +++++++++++++++- .../src-tauri/src/managed_agents/runtime.rs | 11 ++-- .../src/managed_agents/runtime/tests.rs | 47 ++++++++++++++-- docs/hermes-agent-rocky.md | 55 +++++++++++++------ 5 files changed, 164 insertions(+), 29 deletions(-) diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index d9cd362cb8..a9c04b44a4 100644 --- a/crates/buzz-acp/README.md +++ b/crates/buzz-acp/README.md @@ -89,6 +89,40 @@ buzz-acp Older installs that still expose `claude-code-acp` are also supported. `buzz-acp` treats both Claude ACP command names as the same zero-arg runtime. +## Running with Hermes Agent + +[Hermes Agent](https://github.com/NousResearch/hermes-agent) includes a native ACP +server that uses the same personal configuration, credentials, memory, skills, and +tools as the Hermes CLI. + +```bash +# Install Hermes, configure the personal agent, and add its optional ACP extra. +# See https://hermes-agent.nousresearch.com/docs/user-guide/features/acp/ +# hermes acp --check + +export BUZZ_ACP_AGENT_COMMAND="hermes" +export BUZZ_ACP_AGENT_ARGS="acp" # empty also normalises to acp +export HERMES_HOME="$HOME/.hermes" # or a named profile home +export BUZZ_ACP_NO_MEMORY="true" # must be the string true (not 1) +# Keep a single worker per profile home (Desktop: parallelism=1). + +buzz-acp +``` + +If `hermes-acp` is on `PATH`, it can be launched directly with empty agent +arguments instead. Desktop discovers both entrypoints as the first-class +**Hermes Agent** runtime. + +For full profile tools over ACP (not the coding-only default toolset), set on +the Hermes home: + +```yaml +acp: + tool_policy: profile +``` + +Owner-only Rocky attachment notes: [docs/hermes-agent-rocky.md](../../docs/hermes-agent-rocky.md). + ## Configuration All configuration is via environment variables (or CLI flags — every env var has a matching flag). diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index f3acbad2f0..f4fd6640e3 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -207,7 +207,8 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ install_instructions_url: "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp/", cli_install_hint: "Install Hermes Agent and ensure optional ACP support so `hermes acp --check` succeeds.", - adapter_install_hint: "", + adapter_install_hint: + "Hermes is installed but ACP support is missing. Install optional ACP deps so `hermes acp --check` succeeds.", skill_dir: Some(".hermes/skills"), supports_acp_model_switching: true, model_env_var: None, @@ -222,7 +223,9 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ context_limit_env_var: None, required_normalized_fields: &[], login_hint: Some("Configure Hermes providers in `~/.hermes` (or the selected profile home)."), - auth_probe_args: Some(&["hermes", "acp", "--check"]), + // ACP readiness uses hermes_readiness_probe_args so a base install + // without optional ACP deps is AdapterMissing, not "logged out". + auth_probe_args: None, }, ]; @@ -386,6 +389,19 @@ fn default_agent_args(command: &str) -> Option> { } } +/// Command-specific ACP readiness probe for Hermes entrypoints. +/// +/// Official installs expose `hermes` even when optional ACP dependencies are +/// missing. Path lookup alone is not enough; each entrypoint has its own +/// `--check` form. Stolen/adapted from #2468 (nytemode). +pub(crate) fn hermes_readiness_probe_args(command: &str) -> Option<&'static [&'static str]> { + match normalize_command_identity(command).as_str() { + "hermes" => Some(&["hermes", "acp", "--check"]), + "hermes-acp" | "hermes_acp" => Some(&["hermes-acp", "--check"]), + _ => None, + } +} + pub fn normalize_agent_args(command: &str, agent_args: Vec) -> Vec { let normalized = agent_args .into_iter() @@ -1209,7 +1225,7 @@ pub fn discover_acp_runtimes() -> Vec { .underlying_cli .map(|cli| find_command(cli).is_some()) .unwrap_or(false); - let (mut availability, command, binary_path) = + let (mut availability, mut command, mut binary_path) = classify_runtime(adapter_result, runtime.underlying_cli, underlying_cli_found); // For codex-acp: when the adapter resolves as Available, probe the @@ -1224,6 +1240,30 @@ pub fn discover_acp_runtimes() -> Vec { } } + // Hermes's official installer exposes the native `hermes` binary + // even when the optional ACP dependencies are absent. A successful + // path lookup therefore is not enough to call the runtime ready. + // Probe the selected entrypoint with its command-specific `--check` + // invocation and surface a failure as AdapterMissing, not as an + // authentication problem. Pattern from #2468 (nytemode). + if runtime.id == "hermes" && availability == AcpAvailabilityStatus::Available { + let readiness = command + .as_deref() + .and_then(hermes_readiness_probe_args) + .zip(binary_path.as_deref()) + .map(|(args, path)| { + matches!( + probe_auth_status(Path::new(path), args), + AuthStatus::LoggedIn + ) + }); + if readiness == Some(false) { + availability = AcpAvailabilityStatus::AdapterMissing; + command = None; + binary_path = None; + } + } + // Warm the adapter-availability cache for the badge fallback. // The cache is scoped to the codex runtime; other runtimes leave it // unchanged. Invalidated by `clear_resolve_cache`. diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index c78349daa9..8d8403788d 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -56,11 +56,12 @@ pub(crate) const KNOWN_AGENT_BINARIES: &[&str] = &[ "buzz_dev_mcp", ]; -/// Script interpreters that may host managed agent wrappers (e.g. npm shims). -/// A process whose name matches here is NOT immediately claimed — it must also -/// carry `BUZZ_MANAGED_AGENT` in its environment (checked by the caller via -/// `process_has_buzz_marker()`). This avoids sweeping unrelated node processes. -pub(crate) const KNOWN_SCRIPT_INTERPRETERS: &[&str] = &["node"]; +/// Script interpreters that may host managed agent wrappers (e.g. npm shims, +/// Hermes console scripts under a venv Python). A process whose name matches +/// here is NOT immediately claimed — it must also carry `BUZZ_MANAGED_AGENT` +/// in its environment (checked by the caller via `process_has_buzz_marker()`). +/// Exact names only: versioned prefixes like `python3.12` stay excluded. +pub(crate) const KNOWN_SCRIPT_INTERPRETERS: &[&str] = &["node", "python", "python3"]; /// Check if a process name matches any of our known agent binaries. /// Uses exact match or prefix-with-separator to avoid false positives diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index fd758047c3..34bc273511 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -105,6 +105,38 @@ fn goose_has_no_mcp_hooks() { assert_eq!(p.mcp_command, None); } +#[test] +fn hermes_uses_its_native_tools_and_personal_config() { + use crate::managed_agents::{hermes_readiness_probe_args, normalize_agent_args}; + + let runtime = known_acp_runtime("hermes").expect("should resolve"); + assert_eq!(runtime.id, "hermes"); + assert_eq!(runtime.commands, &["hermes", "hermes-acp"]); + assert!(!runtime.mcp_hooks); + assert_eq!(runtime.mcp_command, None); + assert_eq!(runtime.config_file_path, Some("~/.hermes/config.yaml")); + assert_eq!(runtime.auth_probe_args, None); + assert_eq!( + normalize_agent_args(runtime.commands[0], Vec::new()), + vec!["acp".to_string()] + ); + assert_eq!( + hermes_readiness_probe_args("hermes"), + Some(&["hermes", "acp", "--check"][..]) + ); + assert_eq!( + hermes_readiness_probe_args("hermes-acp"), + Some(&["hermes-acp", "--check"][..]) + ); +} + +#[test] +fn hermes_process_names_are_owned_runtime_candidates() { + assert!(super::name_matches_known_binary("hermes")); + assert!(super::name_matches_known_binary("hermes-acp")); + assert!(super::name_matches_known_binary("hermes_acp")); +} + #[test] fn unknown_command_returns_none() { assert!(known_acp_runtime("custom-agent").is_none()); @@ -557,26 +589,31 @@ fn name_matches_known_binary_rejects_node() { } #[test] -fn name_matches_interpreter_accepts_node() { - // `node` IS a known script interpreter and must be recognized. +fn name_matches_interpreter_accepts_exact_known_names() { + // Script interpreters are candidates only when the separate + // BUZZ_MANAGED_AGENT marker check proves ownership. assert!(super::name_matches_interpreter("node")); + assert!(super::name_matches_interpreter("python")); + assert!(super::name_matches_interpreter("python3")); } #[test] fn name_matches_interpreter_rejects_unknown() { // Interpreters not in KNOWN_SCRIPT_INTERPRETERS must not match. - assert!(!super::name_matches_interpreter("python3")); assert!(!super::name_matches_interpreter("deno")); assert!(!super::name_matches_interpreter("bun")); } #[test] -fn name_matches_interpreter_rejects_node_prefix() { - // A name that starts with "node" but is longer must not match — +fn name_matches_interpreter_rejects_interpreter_prefixes() { + // A name that starts with a known interpreter but is longer must not match — // exact equality is required to avoid false positives. assert!(!super::name_matches_interpreter("node_modules")); assert!(!super::name_matches_interpreter("nodejs")); assert!(!super::name_matches_interpreter("node-gyp")); + assert!(!super::name_matches_interpreter("python3.12")); + assert!(!super::name_matches_interpreter("python3-config")); + assert!(!super::name_matches_interpreter("pythonw")); } #[test] diff --git a/docs/hermes-agent-rocky.md b/docs/hermes-agent-rocky.md index 74f070ebc1..5eb6ba960f 100644 --- a/docs/hermes-agent-rocky.md +++ b/docs/hermes-agent-rocky.md @@ -4,11 +4,15 @@ This guide is for an **owner-only** Buzz attachment of an existing Hermes profil Hermes keeps config, tools, skills, memory, credentials and approvals. Buzz is the desktop + phone conversation surface. +```text +Buzz Desktop / relay <--> buzz-acp <--> hermes acp (stdio ACP) +``` + ## Prerequisites 1. Hermes Agent installed with ACP support: ```bash - hermes acp --check + hermes acp --check # or: hermes-acp --check ``` 2. Profile tool policy enabled in the Hermes home you will attach (Rocky default): ```yaml @@ -17,9 +21,10 @@ desktop + phone conversation surface. tool_policy: profile ``` This uses the profile’s local CLI tool configuration (full capability), not the - coding-only `hermes-acp` toolset. + coding-only `hermes-acp` toolset. Requires Hermes + [PR #70326](https://github.com/NousResearch/hermes-agent/pull/70326) or equivalent. 3. A Buzz build that includes Hermes runtime discovery and durable `session/load` - (branch `feat/hermes-native-profile` or later). + (this PR / branch `feat/hermes-native-profile` or later). ## Managed agent settings @@ -28,33 +33,44 @@ desktop + phone conversation surface. | Runtime | Hermes Agent | | Command | `hermes` | | Args | leave empty (normalises to `acp`) or set `-p acp` for a non-default profile | -| Parallelism | **1** (one process per profile home) | +| Parallelism | **1** (one process per profile home — higher values spawn N ACP processes against the same home and fail cold-start turns) | | Respond-to | **owner-only** | -| Memory | disable Buzz NIP-AE memory (`BUZZ_ACP_NO_MEMORY=1` / no-memory) so Hermes memory stays sole memory | +| Memory | disable Buzz NIP-AE memory so Hermes memory stays sole memory | ### Environment For Rocky (default profile home): ```text -HERMES_HOME=/Users/openclaw/.hermes +HERMES_HOME=/Users/you/.hermes +BUZZ_ACP_NO_MEMORY=true ``` -Optional (recommended for dogfood): - -```text -BUZZ_ACP_NO_MEMORY=1 -``` +Important: -Do **not** put Buzz transport secrets in Hermes config. Keep them on the Buzz agent record only. +- `BUZZ_ACP_NO_MEMORY` must be the string **`true`** (not `1`). Other values exit the harness with code 2. +- Do **not** put Buzz transport secrets in Hermes config. Keep them on the Buzz agent record only. +- Prefer **instance** config (not only definition) for `parallelism=1` and env — definition defaults can be overridden by instance values. ## Behaviour notes - **Full tools:** with `acp.tool_policy: profile`, Rocky gets the same tool surface as interactive CLI for that profile (skills, memory, browser, kanban, cron, delegation, … as configured). -- **Session continuity:** after Buzz/harness restart, `session/load` restores the prior ACP session when the agent advertises `loadSession`. +- **Session continuity:** after Buzz/harness restart, `session/load` restores the prior ACP session when the agent advertises `loadSession`. History replay stays on the wire log path and is not re-published to Buzz. - **Permissions:** current Buzz auto-approves ACP permission requests. Acceptable for private owner-only dogfood only. - **Workers:** Chad/Oscar stay Hermes-internal via Rocky’s `delegate_task`. Do not attach them to Buzz unless you later want separate identities. - **Gateway concurrency:** if Telegram/Discord gateway is also running against the same `HERMES_HOME`, prefer Buzz as the primary interactive surface and pause the gateway if browser/session contention appears. +- **Process cleanup:** Hermes console scripts often run under `python`/`python3`. Desktop treats those exact names as marker-owned interpreter candidates (same pattern as `node`). + +## Live dogfood (2026-07-24) + +Owner-only Rocky DM on Desktop succeeded after: + +1. `acp.tool_policy: profile` on the Hermes home +2. Managed agent: command `hermes`, args empty, `HERMES_HOME` set, `BUZZ_ACP_NO_MEMORY=true` +3. Instance **parallelism forced to 1** (definition-only edits were not enough while instance still had 24) +4. Harness restart after the instance change + +Observed: full Hermes tools (skills, memory, browser, …), successful multi-turn DM replies, durable session bind via `session/load` path. ## Headless harness (optional) @@ -63,9 +79,10 @@ export BUZZ_PRIVATE_KEY=... export BUZZ_RELAY_URL=... export BUZZ_ACP_AGENT_COMMAND=hermes export BUZZ_ACP_AGENT_ARGS=acp # or leave empty; default becomes acp -export HERMES_HOME=/Users/openclaw/.hermes -export BUZZ_ACP_NO_MEMORY=1 +export HERMES_HOME=/Users/you/.hermes +export BUZZ_ACP_NO_MEMORY=true # respond-to owner-only via your usual harness flags +# keep parallelism at 1 for a single profile home buzz-acp ``` @@ -80,7 +97,13 @@ arguments: -p chad acp or ```text -HERMES_HOME=/Users/openclaw/.hermes/profiles/chad +HERMES_HOME=/Users/you/.hermes/profiles/chad ``` Keep `parallelism=1` per profile. + +## Scope relative to other PRs + +- **This PR:** local Desktop/managed Hermes runtime discovery, arg normalisation, durable `session/load`, onboarding picker, owner Rocky dogfood guide. Intentionally does **not** include external-agent directory / kind `10100` / relay-observer presentation work. +- **#2468 (nytemode):** broader external-agent hosting + VPS observer path. Currently conflicting with `main`. Ready-to-steal pieces already folded here: command-specific `hermes acp --check` readiness as AdapterMissing, exact `python`/`python3` process recognition. +- **Deeper native Hermes integration** (first-class Nous product surface inside Buzz, shared memory protocols, etc.) is still best done by the Nous Research team upstream. From 3025359ae3eaa9530dca6e118d2d43755136d7e3 Mon Sep 17 00:00:00 2001 From: joelbrilliant Date: Fri, 24 Jul 2026 10:50:46 +1000 Subject: [PATCH 4/5] fix(acp): prevent session store lost updates SessionStore was process-local cache + whole-file rewrite, so two buzz-acp processes sharing the same agent command/args identity could clobber each other's channel bindings. Reload under a sibling lockfile for every get/put/remove, keep atomic writes, recover put from corrupt sidecars, and cover independent-open interleaving in regression tests. Reported and prototyped by @rungmc357 on PR #2633. Co-authored-by: Georgio Constantinou <210088133+rungmc357@users.noreply.github.com> Co-authored-by: joelbrilliant Signed-off-by: joelbrilliant --- Cargo.lock | 13 +- crates/buzz-acp/Cargo.toml | 2 + crates/buzz-acp/src/session_store.rs | 240 ++++++++++++++++++++------- 3 files changed, 197 insertions(+), 58 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f7f156206b..e9e697a5ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -773,6 +773,7 @@ dependencies = [ "clap", "dirs", "evalexpr", + "fs2", "futures-util", "hex", "httparse", @@ -2172,7 +2173,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.117", ] [[package]] @@ -2853,6 +2854,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "fs_extra" version = "1.3.0" diff --git a/crates/buzz-acp/Cargo.toml b/crates/buzz-acp/Cargo.toml index b11ac1206f..d573f3e522 100644 --- a/crates/buzz-acp/Cargo.toml +++ b/crates/buzz-acp/Cargo.toml @@ -70,6 +70,8 @@ toml = "1.0" # Durable ACP session binding store location dirs = "6" +# Cross-process flock for shared session bindings +fs2 = "0.4" # Filter expressions evalexpr = { workspace = true } diff --git a/crates/buzz-acp/src/session_store.rs b/crates/buzz-acp/src/session_store.rs index 67573c3f64..6d2726de64 100644 --- a/crates/buzz-acp/src/session_store.rs +++ b/crates/buzz-acp/src/session_store.rs @@ -8,12 +8,18 @@ //! Keyed by `(agent_command_identity, agent_args, channel_id)` so different //! agent binaries / profiles do not share bindings. Heartbeats are never //! stored — they stay ephemeral. +//! +//! Cross-process safety: the store is a shared file. Every read and mutation +//! takes a sibling lockfile, reloads the on-disk map under that lock, then +//! writes atomically. A process-local cache alone is unsafe when two +//! `buzz-acp` processes share the same agent command/args identity. use std::collections::HashMap; -use std::fs; +use std::ffi::OsString; +use std::fs::{self, File, OpenOptions}; use std::path::{Path, PathBuf}; -use std::sync::Mutex; +use fs2::FileExt; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -30,20 +36,30 @@ struct StoreFile { sessions: HashMap, } -/// Process-wide durable session binding store. +/// Durable session binding store shared across buzz-acp processes. pub struct SessionStore { path: PathBuf, - inner: Mutex, + lock_path: PathBuf, +} + +/// RAII wrapper that unlocks the OS file lock on drop. +struct StoreLock { + file: File, +} + +impl Drop for StoreLock { + fn drop(&mut self) { + let _ = FileExt::unlock(&self.file); + } } impl SessionStore { /// Open or create the store at the resolved path. + /// + /// Does not cache file contents; each operation reloads under lock. pub fn open(path: PathBuf) -> Self { - let data = load_store(&path); - Self { - path, - inner: Mutex::new(data), - } + let lock_path = sibling_lock_path(&path); + Self { path, lock_path } } /// Resolve the default store path for this agent identity. @@ -63,12 +79,21 @@ impl SessionStore { } /// Look up a stored ACP session id for a channel. - pub fn get(&self, agent_command: &str, agent_args: &[String], channel_id: &Uuid) -> Option { + pub fn get( + &self, + agent_command: &str, + agent_args: &[String], + channel_id: &Uuid, + ) -> Option { let key = binding_key(agent_command, agent_args, channel_id); - self.inner - .lock() - .ok() - .and_then(|guard| guard.sessions.get(&key).cloned()) + let _lock = self.acquire_lock(false)?; + match load_store(&self.path) { + Ok(data) => data.sessions.get(&key).cloned(), + Err(e) => { + self.warn_io("failed to read ACP session bindings", &e); + None + } + } } /// Persist a channel → session binding. @@ -80,38 +105,96 @@ impl SessionStore { session_id: &str, ) { let key = binding_key(agent_command, agent_args, channel_id); - let Ok(mut guard) = self.inner.lock() else { + let Some(_lock) = self.acquire_lock(true) else { return; }; - guard.version = 1; - guard.sessions.insert(key, session_id.to_owned()); - if let Err(e) = save_store(&self.path, &guard) { - tracing::warn!( - target: "session_store", - path = %self.path.display(), - error = %e, - "failed to persist ACP session binding" - ); + let mut data = match load_store(&self.path) { + Ok(data) => data, + Err(e) if e.kind() == std::io::ErrorKind::InvalidData => { + // Corrupt sidecar: log and recover empty rather than wedging puts forever. + self.warn_io( + "corrupt ACP session store on update — rewriting from empty map", + &e, + ); + StoreFile::default() + } + Err(e) => { + self.warn_io("failed to read ACP session bindings before update", &e); + return; + } + }; + data.version = 1; + data.sessions.insert(key, session_id.to_owned()); + if let Err(e) = save_store(&self.path, &data) { + self.warn_io("failed to persist ACP session binding", &e); } } /// Remove a binding (after invalidation / failed load). pub fn remove(&self, agent_command: &str, agent_args: &[String], channel_id: &Uuid) { let key = binding_key(agent_command, agent_args, channel_id); - let Ok(mut guard) = self.inner.lock() else { + let Some(_lock) = self.acquire_lock(true) else { return; }; - if guard.sessions.remove(&key).is_some() { - if let Err(e) = save_store(&self.path, &guard) { - tracing::warn!( - target: "session_store", - path = %self.path.display(), - error = %e, - "failed to persist ACP session binding removal" - ); + match load_store(&self.path) { + Ok(mut data) => { + if data.sessions.remove(&key).is_some() { + if let Err(e) = save_store(&self.path, &data) { + self.warn_io("failed to persist ACP session binding removal", &e); + } + } } + Err(e) => self.warn_io("failed to read ACP session bindings before removal", &e), } } + + fn acquire_lock(&self, exclusive: bool) -> Option { + if let Some(parent) = self.lock_path.parent() { + if let Err(e) = fs::create_dir_all(parent) { + self.warn_io("failed to create ACP session store directory", &e); + return None; + } + } + let file = match OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&self.lock_path) + { + Ok(file) => file, + Err(e) => { + self.warn_io("failed to open ACP session store lock", &e); + return None; + } + }; + let result = if exclusive { + FileExt::lock_exclusive(&file) + } else { + FileExt::lock_shared(&file) + }; + if let Err(e) = result { + self.warn_io("failed to lock ACP session store", &e); + return None; + } + Some(StoreLock { file }) + } + + fn warn_io(&self, message: &'static str, error: &std::io::Error) { + tracing::warn!( + target: "session_store", + path = %self.path.display(), + lock_path = %self.lock_path.display(), + error = %error, + "{message}" + ); + } +} + +fn sibling_lock_path(path: &Path) -> PathBuf { + let mut name = path.as_os_str().to_owned(); + name.push(OsString::from(".lock")); + PathBuf::from(name) } fn store_identity(agent_command: &str, agent_args: &[String]) -> String { @@ -147,30 +230,12 @@ fn binding_key(agent_command: &str, agent_args: &[String], channel_id: &Uuid) -> ) } -fn load_store(path: &Path) -> StoreFile { +fn load_store(path: &Path) -> std::io::Result { match fs::read_to_string(path) { - Ok(text) => match serde_json::from_str(&text) { - Ok(data) => data, - Err(e) => { - tracing::warn!( - target: "session_store", - path = %path.display(), - error = %e, - "corrupt session store — starting empty" - ); - StoreFile::default() - } - }, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => StoreFile::default(), - Err(e) => { - tracing::warn!( - target: "session_store", - path = %path.display(), - error = %e, - "could not read session store — starting empty" - ); - StoreFile::default() - } + Ok(text) => serde_json::from_str(&text) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(StoreFile::default()), + Err(e) => Err(e), } } @@ -231,9 +296,70 @@ mod tests { ); assert_eq!( store - .get("hermes", &["-p".into(), "chad".into(), "acp".into()], &channel) + .get( + "hermes", + &["-p".into(), "chad".into(), "acp".into()], + &channel + ) .as_deref(), Some("b") ); } + + #[test] + fn independently_opened_stores_do_not_lose_updates() { + let dir = tempdir().unwrap(); + let path = dir.path().join("sessions.json"); + let store_a = SessionStore::open(path.clone()); + let store_b = SessionStore::open(path.clone()); + let channel_a = Uuid::new_v4(); + let channel_b = Uuid::new_v4(); + let channel_c = Uuid::new_v4(); + let args = ["acp".into()]; + + store_a.put("hermes", &args, &channel_a, "session-a"); + store_b.put("hermes", &args, &channel_b, "session-b"); + + let reopened = SessionStore::open(path.clone()); + assert_eq!( + reopened.get("hermes", &args, &channel_a).as_deref(), + Some("session-a") + ); + assert_eq!( + reopened.get("hermes", &args, &channel_b).as_deref(), + Some("session-b") + ); + + // Open both before either mutation. A stale process-local snapshot would + // resurrect channel A when the second store writes channel C. + let remover = SessionStore::open(path.clone()); + let writer = SessionStore::open(path.clone()); + remover.remove("hermes", &args, &channel_a); + writer.put("hermes", &args, &channel_c, "session-c"); + + let final_store = SessionStore::open(path); + assert!(final_store.get("hermes", &args, &channel_a).is_none()); + assert_eq!( + final_store.get("hermes", &args, &channel_b).as_deref(), + Some("session-b") + ); + assert_eq!( + final_store.get("hermes", &args, &channel_c).as_deref(), + Some("session-c") + ); + } + + #[test] + fn put_recovers_from_corrupt_store() { + let dir = tempdir().unwrap(); + let path = dir.path().join("sessions.json"); + fs::write(&path, "{not-json").unwrap(); + let store = SessionStore::open(path.clone()); + let channel = Uuid::new_v4(); + store.put("hermes", &["acp".into()], &channel, "recovered"); + assert_eq!( + store.get("hermes", &["acp".into()], &channel).as_deref(), + Some("recovered") + ); + } } From 3c83c1580452bc75598533bb193e94a09f478879 Mon Sep 17 00:00:00 2001 From: joelbrilliant Date: Fri, 24 Jul 2026 14:28:27 +1000 Subject: [PATCH 5/5] fix(acp): conditional session unbind + multi-profile store guidance Failed session/load used key-only remove, so process A reading X then failing load could delete process B's newer Y for the same channel. Clear bindings with remove_if_equals under the existing lock, and cover the interleaving race in regression tests. Durable store identity is command + args + channel, not HERMES_HOME. Require unique -p acp args for multi-profile managed agents and drop the HERMES_HOME-only alternative from dogfood docs until the store is namespaced. Also note long-lived ACP OAuth stay-alive ops in the Rocky dogfood guide. Reported by @rungmc357 on PR #2633. Co-authored-by: Georgio Constantinou <210088133+rungmc357@users.noreply.github.com> Co-authored-by: joelbrilliant Signed-off-by: joelbrilliant --- crates/buzz-acp/README.md | 4 +- crates/buzz-acp/src/pool.rs | 12 +++- crates/buzz-acp/src/session_store.rs | 97 +++++++++++++++++++++++++++- docs/hermes-agent-rocky.md | 36 +++++++++-- 4 files changed, 138 insertions(+), 11 deletions(-) diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index a9c04b44a4..3695e5c246 100644 --- a/crates/buzz-acp/README.md +++ b/crates/buzz-acp/README.md @@ -102,9 +102,11 @@ tools as the Hermes CLI. export BUZZ_ACP_AGENT_COMMAND="hermes" export BUZZ_ACP_AGENT_ARGS="acp" # empty also normalises to acp -export HERMES_HOME="$HOME/.hermes" # or a named profile home +export HERMES_HOME="$HOME/.hermes" # process home; does not namespace the durable store export BUZZ_ACP_NO_MEMORY="true" # must be the string true (not 1) # Keep a single worker per profile home (Desktop: parallelism=1). +# For a second Hermes profile, use unique args (e.g. BUZZ_ACP_AGENT_ARGS="-p chad acp") +# so session bindings do not collide. HERMES_HOME alone is not enough. buzz-acp ``` diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 2c5fb7d99b..fc14635ba4 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -869,10 +869,16 @@ async fn try_load_persisted_session( session_id = %stored, channel_id = %channel_id, error = %e, - "session/load failed — clearing binding and creating a new session" + "session/load failed — clearing stale binding (if unchanged) and creating a new session" + ); + // Only drop the binding we failed to load. A concurrent process may + // already have written a newer session for this channel. + let _ = ctx.session_store.remove_if_equals( + &ctx.agent_command, + &ctx.agent_args, + channel_id, + &stored, ); - ctx.session_store - .remove(&ctx.agent_command, &ctx.agent_args, channel_id); None } } diff --git a/crates/buzz-acp/src/session_store.rs b/crates/buzz-acp/src/session_store.rs index 6d2726de64..7e06eb794f 100644 --- a/crates/buzz-acp/src/session_store.rs +++ b/crates/buzz-acp/src/session_store.rs @@ -130,7 +130,7 @@ impl SessionStore { } } - /// Remove a binding (after invalidation / failed load). + /// Remove a binding (after invalidation / explicit rotate). pub fn remove(&self, agent_command: &str, agent_args: &[String], channel_id: &Uuid) { let key = binding_key(agent_command, agent_args, channel_id); let Some(_lock) = self.acquire_lock(true) else { @@ -148,6 +148,53 @@ impl SessionStore { } } + /// Remove a binding only if it still points at `expected_session_id`. + /// + /// Used after a failed `session/load`: another process may have already + /// written a newer session for the same channel, and a key-only remove + /// would delete that fresher binding. + /// + /// Returns `true` when a matching binding was removed. + pub fn remove_if_equals( + &self, + agent_command: &str, + agent_args: &[String], + channel_id: &Uuid, + expected_session_id: &str, + ) -> bool { + let key = binding_key(agent_command, agent_args, channel_id); + let Some(_lock) = self.acquire_lock(true) else { + return false; + }; + match load_store(&self.path) { + Ok(mut data) => { + let matches = data + .sessions + .get(&key) + .is_some_and(|current| current == expected_session_id); + if !matches { + return false; + } + data.sessions.remove(&key); + if let Err(e) = save_store(&self.path, &data) { + self.warn_io( + "failed to persist conditional ACP session binding removal", + &e, + ); + return false; + } + true + } + Err(e) => { + self.warn_io( + "failed to read ACP session bindings before conditional removal", + &e, + ); + false + } + } + } + fn acquire_lock(&self, exclusive: bool) -> Option { if let Some(parent) = self.lock_path.parent() { if let Err(e) = fs::create_dir_all(parent) { @@ -362,4 +409,52 @@ mod tests { Some("recovered") ); } + + #[test] + fn remove_if_equals_does_not_delete_newer_binding() { + let dir = tempdir().unwrap(); + let path = dir.path().join("sessions.json"); + let args = ["acp".into()]; + let channel = Uuid::new_v4(); + + // Process A reads X. + let process_a = SessionStore::open(path.clone()); + process_a.put("hermes", &args, &channel, "session-x"); + let read_x = process_a + .get("hermes", &args, &channel) + .expect("process A read X"); + assert_eq!(read_x, "session-x"); + + // Process B writes Y for the same channel. + let process_b = SessionStore::open(path.clone()); + process_b.put("hermes", &args, &channel, "session-y"); + assert_eq!( + process_b.get("hermes", &args, &channel).as_deref(), + Some("session-y") + ); + + // Process A's failed load of X must not delete Y. + let removed = process_a.remove_if_equals("hermes", &args, &channel, &read_x); + assert!(!removed); + + let final_store = SessionStore::open(path); + assert_eq!( + final_store.get("hermes", &args, &channel).as_deref(), + Some("session-y") + ); + } + + #[test] + fn remove_if_equals_clears_matching_stale_binding() { + let dir = tempdir().unwrap(); + let path = dir.path().join("sessions.json"); + let args = ["acp".into()]; + let channel = Uuid::new_v4(); + let store = SessionStore::open(path.clone()); + store.put("hermes", &args, &channel, "session-x"); + assert!(store.remove_if_equals("hermes", &args, &channel, "session-x")); + assert!(store.get("hermes", &args, &channel).is_none()); + // No-op when already gone. + assert!(!store.remove_if_equals("hermes", &args, &channel, "session-x")); + } } diff --git a/docs/hermes-agent-rocky.md b/docs/hermes-agent-rocky.md index 5eb6ba960f..1fd245a307 100644 --- a/docs/hermes-agent-rocky.md +++ b/docs/hermes-agent-rocky.md @@ -88,17 +88,22 @@ buzz-acp ## Multi-profile later -When you want another Hermes profile on Buzz, add a **second** managed agent with either: +When you want another Hermes profile on Buzz, add a **second** managed agent +with **unique ACP args** so durable session bindings do not collide: ```text +command: hermes arguments: -p chad acp +HERMES_HOME=/Users/you/.hermes/profiles/chad # optional, for the process env +parallelism: 1 ``` -or - -```text -HERMES_HOME=/Users/you/.hermes/profiles/chad -``` +Do **not** run two managed agents with identical command/args (e.g. both +`hermes` + empty/`acp` args) and only different `HERMES_HOME` values. The durable +session store is keyed by command + args + channel, not by environment or +managed-agent identity, so those two agents would overwrite each other's channel +bindings. Until the store is namespaced by agent identity, unique `-p +acp` args are required for multi-profile. Keep `parallelism=1` per profile. @@ -107,3 +112,22 @@ Keep `parallelism=1` per profile. - **This PR:** local Desktop/managed Hermes runtime discovery, arg normalisation, durable `session/load`, onboarding picker, owner Rocky dogfood guide. Intentionally does **not** include external-agent directory / kind `10100` / relay-observer presentation work. - **#2468 (nytemode):** broader external-agent hosting + VPS observer path. Currently conflicting with `main`. Ready-to-steal pieces already folded here: command-specific `hermes acp --check` readiness as AdapterMissing, exact `python`/`python3` process recognition. - **Deeper native Hermes integration** (first-class Nous product surface inside Buzz, shared memory protocols, etc.) is still best done by the Nous Research team upstream. + +## Staying alive (OAuth / long-lived ACP) + +Rocky over Buzz is a **long-lived** `hermes acp` process. Telegram/Discord/gateway +stay warm and refresh tokens; a parked ACP worker can hold a stale xAI access +token after hours of idle. + +**One-time ops** +1. Rocky managed agent: **Start on app launch** on +2. **Parallelism = 1** (instance, not only definition) +3. After this Hermes fix is installed: restart Rocky once so new ACP sessions + get `credential_pool` and can self-heal on `403 bad-credentials` + +**You should not need reauth** when Telegram still works. Prefer restart Rocky +in Buzz Agents if a single turn fails with OAuth 403 after long idle. + +**Fixed upstream** (Hermes): ACP now wires `credential_pool` like the gateway, +and 403 auth refreshes the same path as 401 for xAI OAuth. +