From 3f8fea8ee6efa88da37121f03bb955ec0a139dec Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Fri, 29 May 2026 19:36:26 -0500 Subject: [PATCH 1/3] =?UTF-8?q?feat(daemon):=20Tier=20B=20IPC=20client=20?= =?UTF-8?q?=E2=80=94=20live=20familiar=20status=20via=20coven.sock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-rust/crates/core/src/coven_daemon.rs | 310 +++++++++++++++++++++++ src-rust/crates/core/src/coven_shared.rs | 5 +- src-rust/crates/core/src/lib.rs | 2 + src-rust/crates/tui/src/agents_view.rs | 26 +- 4 files changed, 341 insertions(+), 2 deletions(-) create mode 100644 src-rust/crates/core/src/coven_daemon.rs diff --git a/src-rust/crates/core/src/coven_daemon.rs b/src-rust/crates/core/src/coven_daemon.rs new file mode 100644 index 0000000..88c15b1 --- /dev/null +++ b/src-rust/crates/core/src/coven_daemon.rs @@ -0,0 +1,310 @@ +//! Tier B daemon IPC — async-free HTTP-over-Unix-socket client. +//! +//! Talks to the Coven daemon at `~/.coven/coven.sock` using raw +//! `UnixStream` + hand-written HTTP/1.0 requests. No tokio dependency +//! is added; all calls are blocking and degrade gracefully when the +//! daemon is absent. + +use std::io::{Read, Write}; +use std::os::unix::net::UnixStream; +use std::path::PathBuf; +use std::time::Duration; + +use serde::Deserialize; + +use crate::coven_shared::coven_home; + +// --------------------------------------------------------------------------- +// Public data types +// --------------------------------------------------------------------------- + +/// Condensed view of a familiar's live status from the daemon. +#[derive(Debug, Clone)] +pub struct FamiliarStatus { + pub id: String, + pub display_name: String, + pub emoji: String, + pub status: String, + pub active_sessions: u32, + pub memory_freshness: String, +} + +/// Condensed view of a running (non-archived) daemon session. +#[derive(Debug, Clone)] +pub struct DaemonSession { + pub id: String, + pub harness: String, + pub title: String, + pub status: String, + pub project_root: String, +} + +// --------------------------------------------------------------------------- +// Raw JSON shapes (private — only used for deserialization) +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +struct RawFamiliar { + #[serde(default)] + id: String, + #[serde(default)] + display_name: Option, + #[serde(default)] + emoji: Option, + #[serde(default)] + status: Option, + #[serde(default)] + active_sessions: Option, + #[serde(default)] + memory_freshness: Option, +} + +#[derive(Deserialize)] +struct RawSession { + #[serde(default)] + id: String, + #[serde(default)] + harness: Option, + #[serde(default)] + title: Option, + #[serde(default)] + status: Option, + #[serde(default)] + project_root: Option, + #[serde(default)] + archived_at: Option, +} + +// --------------------------------------------------------------------------- +// DaemonClient +// --------------------------------------------------------------------------- + +/// Blocking HTTP-over-Unix-socket client for the Coven daemon. +pub struct DaemonClient { + sock_path: PathBuf, +} + +impl DaemonClient { + /// Create a client targeting the default socket path. + /// + /// Returns `None` when the socket file does not exist (daemon is not + /// running / not installed). Never panics. + pub fn new() -> Option { + let home = coven_home()?; + let sock = home.join("coven.sock"); + if sock.exists() { + Some(Self { sock_path: sock }) + } else { + None + } + } + + // -- internal helpers --------------------------------------------------- + + /// Open a fresh `UnixStream` connection with a short timeout. + fn connect(&self) -> std::io::Result { + let stream = UnixStream::connect(&self.sock_path)?; + let timeout = Duration::from_secs(3); + stream.set_read_timeout(Some(timeout))?; + stream.set_write_timeout(Some(timeout))?; + Ok(stream) + } + + /// Send a minimal HTTP/1.0 GET and return the body string. + /// + /// HTTP/1.0 is used so the server closes the connection after the + /// response — no need to parse `Content-Length` or chunked encoding. + fn get(&self, path: &str) -> Option { + let mut stream = self.connect().ok()?; + let request = format!( + "GET {} HTTP/1.0\r\nHost: localhost\r\nAccept: application/json\r\n\r\n", + path + ); + stream.write_all(request.as_bytes()).ok()?; + stream.flush().ok()?; + + let mut raw = Vec::new(); + stream.read_to_end(&mut raw).ok()?; + + let response = String::from_utf8_lossy(&raw); + + // Split on the blank line that separates headers from body. + if let Some(idx) = response.find("\r\n\r\n") { + // Verify the status line starts with "HTTP/1." 2xx. + let status_line = response.lines().next().unwrap_or(""); + if !status_line.contains(" 2") { + return None; + } + Some(response[idx + 4..].to_string()) + } else { + None + } + } + + // -- public API --------------------------------------------------------- + + /// Quick liveness check — returns `true` if the daemon responds with 200. + pub fn is_online(&self) -> bool { + self.get("/api/v1/familiars").is_some() + } + + /// Fetch all familiar statuses. Returns an empty `Vec` on any error. + pub fn familiar_statuses(&self) -> Vec { + let body = match self.get("/api/v1/familiars") { + Some(b) => b, + None => return Vec::new(), + }; + let raw: Vec = match serde_json::from_str(&body) { + Ok(v) => v, + Err(_) => return Vec::new(), + }; + raw.into_iter() + .map(|r| FamiliarStatus { + display_name: r.display_name.unwrap_or_else(|| r.id.clone()), + emoji: r.emoji.unwrap_or_default(), + status: r.status.unwrap_or_else(|| "unknown".to_string()), + active_sessions: r.active_sessions.unwrap_or(0), + memory_freshness: r.memory_freshness.unwrap_or_default(), + id: r.id, + }) + .collect() + } + + /// Fetch non-archived sessions. Returns an empty `Vec` on any error. + pub fn active_sessions(&self) -> Vec { + let body = match self.get("/api/v1/sessions") { + Some(b) => b, + None => return Vec::new(), + }; + let raw: Vec = match serde_json::from_str(&body) { + Ok(v) => v, + Err(_) => return Vec::new(), + }; + raw.into_iter() + .filter(|r| r.archived_at.is_none()) + .map(|r| DaemonSession { + harness: r.harness.unwrap_or_default(), + title: r.title.unwrap_or_default(), + status: r.status.unwrap_or_else(|| "unknown".to_string()), + project_root: r.project_root.unwrap_or_default(), + id: r.id, + }) + .collect() + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::sync::Mutex; + + /// Guard that temporarily sets `COVEN_HOME` and restores it on drop. + struct EnvGuard { + key: &'static str, + original: Option, + } + impl EnvGuard { + fn set(key: &'static str, val: &str) -> Self { + let original = std::env::var(key).ok(); + std::env::set_var(key, val); + Self { key, original } + } + } + impl Drop for EnvGuard { + fn drop(&mut self) { + match &self.original { + Some(v) => std::env::set_var(self.key, v), + None => std::env::remove_var(self.key), + } + } + } + + // Serialize env mutations so parallel tests don't stomp each other. + static ENV_MX: Mutex<()> = Mutex::new(()); + + #[test] + fn new_returns_none_when_sock_absent() { + let _lock = ENV_MX.lock().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let _g = EnvGuard::set("COVEN_HOME", dir.path().to_str().unwrap()); + // Directory exists but no coven.sock inside → should return None. + assert!(DaemonClient::new().is_none()); + } + + #[test] + fn new_returns_some_when_sock_present() { + let _lock = ENV_MX.lock().unwrap(); + let dir = tempfile::tempdir().unwrap(); + // Create a placeholder file (not a real socket, just needs to exist). + fs::write(dir.path().join("coven.sock"), b"").unwrap(); + let _g = EnvGuard::set("COVEN_HOME", dir.path().to_str().unwrap()); + assert!(DaemonClient::new().is_some()); + } + + #[test] + fn familiar_status_deserializes_from_json() { + let json = r#"[ + { + "id": "sage", + "display_name": "Sage", + "emoji": "🌿", + "role": "researcher", + "description": "Deep research familiar", + "status": "active", + "active_sessions": 2, + "memory_freshness": "fresh" + }, + { + "id": "kitty", + "status": "idle", + "active_sessions": 0 + } + ]"#; + + let raw: Vec = serde_json::from_str(json).unwrap(); + assert_eq!(raw.len(), 2); + + let s0 = FamiliarStatus { + display_name: raw[0].display_name.clone().unwrap_or_else(|| raw[0].id.clone()), + emoji: raw[0].emoji.clone().unwrap_or_default(), + status: raw[0].status.clone().unwrap_or_default(), + active_sessions: raw[0].active_sessions.unwrap_or(0), + memory_freshness: raw[0].memory_freshness.clone().unwrap_or_default(), + id: raw[0].id.clone(), + }; + assert_eq!(s0.id, "sage"); + assert_eq!(s0.display_name, "Sage"); + assert_eq!(s0.emoji, "🌿"); + assert_eq!(s0.status, "active"); + assert_eq!(s0.active_sessions, 2); + + let s1 = FamiliarStatus { + display_name: raw[1].display_name.clone().unwrap_or_else(|| raw[1].id.clone()), + emoji: raw[1].emoji.clone().unwrap_or_default(), + status: raw[1].status.clone().unwrap_or_default(), + active_sessions: raw[1].active_sessions.unwrap_or(0), + memory_freshness: raw[1].memory_freshness.clone().unwrap_or_default(), + id: raw[1].id.clone(), + }; + assert_eq!(s1.id, "kitty"); + assert_eq!(s1.display_name, "kitty"); // falls back to id + assert_eq!(s1.active_sessions, 0); + } + + #[test] + fn familiar_statuses_returns_empty_on_bad_json() { + let _lock = ENV_MX.lock().unwrap(); + let dir = tempfile::tempdir().unwrap(); + // Placeholder sock — not a real socket, so connect() will fail. + fs::write(dir.path().join("coven.sock"), b"").unwrap(); + let _g = EnvGuard::set("COVEN_HOME", dir.path().to_str().unwrap()); + let client = DaemonClient::new().unwrap(); + // connect() will fail → familiar_statuses() must return empty vec, not panic. + assert!(client.familiar_statuses().is_empty()); + } +} diff --git a/src-rust/crates/core/src/coven_shared.rs b/src-rust/crates/core/src/coven_shared.rs index ec334f9..f9124d8 100644 --- a/src-rust/crates/core/src/coven_shared.rs +++ b/src-rust/crates/core/src/coven_shared.rs @@ -8,7 +8,10 @@ //! absent so coven-code keeps working standalone. //! //! Tier A of the "native Coven" integration. Tier B (daemon IPC over -//! `~/.coven/coven.sock`) is not implemented here. +//! `~/.coven/coven.sock`) lives in [`crate::coven_daemon`]. + +// Re-export Tier B IPC types for convenience. +pub use crate::coven_daemon::{DaemonClient, DaemonSession, FamiliarStatus}; use std::path::PathBuf; use serde::Deserialize; diff --git a/src-rust/crates/core/src/lib.rs b/src-rust/crates/core/src/lib.rs index f90b30c..2544b11 100644 --- a/src-rust/crates/core/src/lib.rs +++ b/src-rust/crates/core/src/lib.rs @@ -90,6 +90,8 @@ pub use skill_discovery::{DiscoveredSkill, discover_skills, parse_skill_file}; // Coven daemon shared state — read-only bridge to ~/.coven/. pub mod coven_shared; +// Tier B IPC — blocking HTTP-over-Unix-socket client for the live daemon. +pub mod coven_daemon; pub use cost::CostTracker; pub use history::ConversationSession; pub use feature_flags::FeatureFlagManager; diff --git a/src-rust/crates/tui/src/agents_view.rs b/src-rust/crates/tui/src/agents_view.rs index 3eab0c3..876bcf7 100644 --- a/src-rust/crates/tui/src/agents_view.rs +++ b/src-rust/crates/tui/src/agents_view.rs @@ -433,6 +433,18 @@ pub fn load_agent_definitions(project_root: &std::path::Path) -> Vec = + coven_shared::DaemonClient::new() + .map(|c| { + c.familiar_statuses() + .into_iter() + .map(|s| (s.id.clone(), s)) + .collect() + }) + .unwrap_or_default(); + for fam in &familiars { let display = fam .display_name @@ -443,7 +455,19 @@ pub fn load_agent_definitions(project_root: &std::path::Path) -> Vec 0 { + format!(" · active ({} sessions)", live.active_sessions) + } else if live.status == "online" || live.status == "active" { + " · online".to_string() + } else { + " · offline".to_string() + }; + agent_def.description.push_str(&badge); + } + defs.push(agent_def); } } From 7b89e301909454af8a7566b10d8f939bf418f4e6 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Fri, 29 May 2026 19:45:57 -0500 Subject: [PATCH 2/3] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src-rust/crates/core/src/coven_daemon.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src-rust/crates/core/src/coven_daemon.rs b/src-rust/crates/core/src/coven_daemon.rs index 88c15b1..5295619 100644 --- a/src-rust/crates/core/src/coven_daemon.rs +++ b/src-rust/crates/core/src/coven_daemon.rs @@ -130,9 +130,10 @@ impl DaemonClient { // Split on the blank line that separates headers from body. if let Some(idx) = response.find("\r\n\r\n") { - // Verify the status line starts with "HTTP/1." 2xx. + // Verify the response has a 2xx status code. let status_line = response.lines().next().unwrap_or(""); - if !status_line.contains(" 2") { + let status_code = status_line.split_whitespace().nth(1)?.parse::().ok()?; + if !(200..300).contains(&status_code) { return None; } Some(response[idx + 4..].to_string()) From 3eaaff574dd79c1cabdf84d6b46e6faf2eec09e9 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Fri, 29 May 2026 20:48:20 -0500 Subject: [PATCH 3/3] fix(daemon): harden Tier B IPC status badges --- src-rust/crates/core/src/coven_daemon.rs | 89 +++++++++------ src-rust/crates/core/src/coven_shared.rs | 10 +- src-rust/crates/tui/src/agents_view.rs | 135 ++++++++++++++++++----- 3 files changed, 164 insertions(+), 70 deletions(-) diff --git a/src-rust/crates/core/src/coven_daemon.rs b/src-rust/crates/core/src/coven_daemon.rs index 5295619..e6a4a11 100644 --- a/src-rust/crates/core/src/coven_daemon.rs +++ b/src-rust/crates/core/src/coven_daemon.rs @@ -5,13 +5,18 @@ //! is added; all calls are blocking and degrade gracefully when the //! daemon is absent. +#[cfg(unix)] use std::io::{Read, Write}; +#[cfg(unix)] use std::os::unix::net::UnixStream; +#[cfg(unix)] use std::path::PathBuf; +#[cfg(unix)] use std::time::Duration; use serde::Deserialize; +#[cfg(unix)] use crate::coven_shared::coven_home; // --------------------------------------------------------------------------- @@ -81,6 +86,7 @@ struct RawSession { /// Blocking HTTP-over-Unix-socket client for the Coven daemon. pub struct DaemonClient { + #[cfg(unix)] sock_path: PathBuf, } @@ -90,11 +96,18 @@ impl DaemonClient { /// Returns `None` when the socket file does not exist (daemon is not /// running / not installed). Never panics. pub fn new() -> Option { - let home = coven_home()?; - let sock = home.join("coven.sock"); - if sock.exists() { - Some(Self { sock_path: sock }) - } else { + #[cfg(unix)] + { + let home = coven_home()?; + let sock = home.join("coven.sock"); + if sock.exists() { + Some(Self { sock_path: sock }) + } else { + None + } + } + #[cfg(not(unix))] + { None } } @@ -102,9 +115,10 @@ impl DaemonClient { // -- internal helpers --------------------------------------------------- /// Open a fresh `UnixStream` connection with a short timeout. + #[cfg(unix)] fn connect(&self) -> std::io::Result { let stream = UnixStream::connect(&self.sock_path)?; - let timeout = Duration::from_secs(3); + let timeout = Duration::from_millis(200); stream.set_read_timeout(Some(timeout))?; stream.set_write_timeout(Some(timeout))?; Ok(stream) @@ -115,29 +129,37 @@ impl DaemonClient { /// HTTP/1.0 is used so the server closes the connection after the /// response — no need to parse `Content-Length` or chunked encoding. fn get(&self, path: &str) -> Option { - let mut stream = self.connect().ok()?; - let request = format!( - "GET {} HTTP/1.0\r\nHost: localhost\r\nAccept: application/json\r\n\r\n", - path - ); - stream.write_all(request.as_bytes()).ok()?; - stream.flush().ok()?; - - let mut raw = Vec::new(); - stream.read_to_end(&mut raw).ok()?; - - let response = String::from_utf8_lossy(&raw); - - // Split on the blank line that separates headers from body. - if let Some(idx) = response.find("\r\n\r\n") { - // Verify the response has a 2xx status code. - let status_line = response.lines().next().unwrap_or(""); - let status_code = status_line.split_whitespace().nth(1)?.parse::().ok()?; - if !(200..300).contains(&status_code) { - return None; + #[cfg(unix)] + { + let mut stream = self.connect().ok()?; + let request = format!( + "GET {} HTTP/1.0\r\nHost: localhost\r\nAccept: application/json\r\n\r\n", + path + ); + stream.write_all(request.as_bytes()).ok()?; + stream.flush().ok()?; + + let mut raw = Vec::new(); + stream.read_to_end(&mut raw).ok()?; + + let response = String::from_utf8_lossy(&raw); + + // Split on the blank line that separates headers from body. + if let Some(idx) = response.find("\r\n\r\n") { + // Verify the response has a 2xx status code. + let status_line = response.lines().next().unwrap_or(""); + let status_code = status_line.split_whitespace().nth(1)?.parse::().ok()?; + if !(200..300).contains(&status_code) { + return None; + } + Some(response[idx + 4..].to_string()) + } else { + None } - Some(response[idx + 4..].to_string()) - } else { + } + #[cfg(not(unix))] + { + let _ = path; None } } @@ -201,8 +223,8 @@ impl DaemonClient { #[cfg(test)] mod tests { use super::*; + use crate::coven_shared::COVEN_HOME_ENV_LOCK; use std::fs; - use std::sync::Mutex; /// Guard that temporarily sets `COVEN_HOME` and restores it on drop. struct EnvGuard { @@ -225,12 +247,9 @@ mod tests { } } - // Serialize env mutations so parallel tests don't stomp each other. - static ENV_MX: Mutex<()> = Mutex::new(()); - #[test] fn new_returns_none_when_sock_absent() { - let _lock = ENV_MX.lock().unwrap(); + let _lock = COVEN_HOME_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let dir = tempfile::tempdir().unwrap(); let _g = EnvGuard::set("COVEN_HOME", dir.path().to_str().unwrap()); // Directory exists but no coven.sock inside → should return None. @@ -239,7 +258,7 @@ mod tests { #[test] fn new_returns_some_when_sock_present() { - let _lock = ENV_MX.lock().unwrap(); + let _lock = COVEN_HOME_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let dir = tempfile::tempdir().unwrap(); // Create a placeholder file (not a real socket, just needs to exist). fs::write(dir.path().join("coven.sock"), b"").unwrap(); @@ -299,7 +318,7 @@ mod tests { #[test] fn familiar_statuses_returns_empty_on_bad_json() { - let _lock = ENV_MX.lock().unwrap(); + let _lock = COVEN_HOME_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let dir = tempfile::tempdir().unwrap(); // Placeholder sock — not a real socket, so connect() will fail. fs::write(dir.path().join("coven.sock"), b"").unwrap(); diff --git a/src-rust/crates/core/src/coven_shared.rs b/src-rust/crates/core/src/coven_shared.rs index f9124d8..d177692 100644 --- a/src-rust/crates/core/src/coven_shared.rs +++ b/src-rust/crates/core/src/coven_shared.rs @@ -32,6 +32,9 @@ pub fn coven_home() -> Option { p.is_dir().then_some(p) } +#[cfg(test)] +pub(crate) static COVEN_HOME_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + // --------------------------------------------------------------------------- // Familiars (~/.coven/familiars.toml) // --------------------------------------------------------------------------- @@ -138,14 +141,11 @@ pub fn list_daemon_skills() -> Vec { mod tests { use super::*; use std::fs; - use std::sync::Mutex; use tempfile::TempDir; // coven_home() reads COVEN_HOME from process env, which is shared across // parallel tests in the same binary. Serialize the env-touching tests so // they don't clobber each other's overrides. - static ENV_LOCK: Mutex<()> = Mutex::new(()); - struct EnvGuard { _tmp: TempDir, _lock: std::sync::MutexGuard<'static, ()>, @@ -158,7 +158,7 @@ mod tests { } fn with_coven_home(setup: F) -> EnvGuard { - let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let lock = COVEN_HOME_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let tmp = TempDir::new().unwrap(); setup(tmp.path()); std::env::set_var("COVEN_HOME", tmp.path()); @@ -167,7 +167,7 @@ mod tests { #[test] fn coven_home_returns_none_when_dir_missing() { - let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let _lock = COVEN_HOME_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); std::env::set_var("COVEN_HOME", "/nonexistent/path/cc_test_xyz"); assert!(coven_home().is_none()); std::env::remove_var("COVEN_HOME"); diff --git a/src-rust/crates/tui/src/agents_view.rs b/src-rust/crates/tui/src/agents_view.rs index 876bcf7..765be4d 100644 --- a/src-rust/crates/tui/src/agents_view.rs +++ b/src-rust/crates/tui/src/agents_view.rs @@ -8,7 +8,10 @@ use ratatui::{ text::{Line, Span}, widgets::{Block, Borders, Paragraph, Widget}, }; -use std::path::{Path, PathBuf}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant}; use claurst_core::coven_shared; @@ -434,16 +437,8 @@ pub fn load_agent_definitions(project_root: &std::path::Path) -> Vec = - coven_shared::DaemonClient::new() - .map(|c| { - c.familiar_statuses() - .into_iter() - .map(|s| (s.id.clone(), s)) - .collect() - }) - .unwrap_or_default(); + // Tier B: fetch live status from the daemon (degrades gracefully). + let daemon_statuses = daemon_familiar_statuses(); for fam in &familiars { let display = fam @@ -455,20 +450,15 @@ pub fn load_agent_definitions(project_root: &std::path::Path) -> Vec 0 { - format!(" · active ({} sessions)", live.active_sessions) - } else if live.status == "online" || live.status == "active" { - " · online".to_string() - } else { - " · offline".to_string() - }; - agent_def.description.push_str(&badge); - } - defs.push(agent_def); - } + let mut agent_def = familiar_as_agent_def(fam); + // Annotate with live daemon status when available. + if let Some(live) = daemon_statuses.get(&fam.id) { + if let Some(badge) = familiar_live_badge(live) { + agent_def.description.push_str(&badge); + } + } + defs.push(agent_def); + } } defs @@ -592,7 +582,7 @@ fn extract_yaml_list(front: &str, key: &str) -> Vec { Vec::new() } -fn slugify_agent_name(name: &str) -> String { +fn slugify_agent_name(name: &str) -> String { let mut slug = String::new(); for ch in name.chars() { if ch.is_ascii_alphanumeric() { @@ -601,10 +591,95 @@ fn slugify_agent_name(name: &str) -> String { slug.push('-'); } } - slug.trim_matches('-').to_string() -} - -fn validate_editor(editor: &AgentEditorState) -> Result<(), String> { + slug.trim_matches('-').to_string() +} + +const DAEMON_STATUS_CACHE_TTL: Duration = Duration::from_secs(2); + +type DaemonStatusCache = Option<(Instant, HashMap)>; + +fn daemon_status_cache() -> &'static Mutex { + static CACHE: OnceLock> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(None)) +} + +fn daemon_familiar_statuses() -> HashMap { + let cache = daemon_status_cache(); + if let Ok(guard) = cache.lock() { + if let Some((loaded_at, statuses)) = &*guard { + if loaded_at.elapsed() < DAEMON_STATUS_CACHE_TTL { + return statuses.clone(); + } + } + } + + let statuses: HashMap = coven_shared::DaemonClient::new() + .map(|client| { + client + .familiar_statuses() + .into_iter() + .filter(|status| familiar_live_badge(status).is_some()) + .map(|status| (status.id.clone(), status)) + .collect() + }) + .unwrap_or_default(); + + if let Ok(mut guard) = cache.lock() { + *guard = Some((Instant::now(), statuses.clone())); + } + statuses +} + +fn familiar_live_badge(live: &coven_shared::FamiliarStatus) -> Option { + if live.active_sessions > 0 { + return Some(format!(" · active ({} sessions)", live.active_sessions)); + } + + match live.status.as_str() { + "active" | "online" => Some(" · online".to_string()), + "offline" | "unknown" | "" => None, + status => Some(format!(" · {status}")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn status(status: &str, active_sessions: u32) -> coven_shared::FamiliarStatus { + coven_shared::FamiliarStatus { + id: "sage".to_string(), + display_name: "Sage".to_string(), + emoji: String::new(), + status: status.to_string(), + active_sessions, + memory_freshness: String::new(), + } + } + + #[test] + fn familiar_live_badge_omits_static_offline_status() { + assert_eq!(familiar_live_badge(&status("offline", 0)), None); + } + + #[test] + fn familiar_live_badge_preserves_idle_without_calling_it_offline() { + assert_eq!( + familiar_live_badge(&status("idle", 0)), + Some(" · idle".to_string()) + ); + } + + #[test] + fn familiar_live_badge_prefers_active_session_count() { + assert_eq!( + familiar_live_badge(&status("offline", 2)), + Some(" · active (2 sessions)".to_string()) + ); + } +} + +fn validate_editor(editor: &AgentEditorState) -> Result<(), String> { let name = editor.name.trim(); if name.is_empty() { return Err("Familiar name is required.".to_string());