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..e6a4a11 --- /dev/null +++ b/src-rust/crates/core/src/coven_daemon.rs @@ -0,0 +1,330 @@ +//! 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. + +#[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; + +// --------------------------------------------------------------------------- +// 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 { + #[cfg(unix)] + 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 { + #[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 + } + } + + // -- 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_millis(200); + 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 { + #[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 + } + } + #[cfg(not(unix))] + { + let _ = path; + 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 crate::coven_shared::COVEN_HOME_ENV_LOCK; + use std::fs; + + /// 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), + } + } + } + + #[test] + fn new_returns_none_when_sock_absent() { + 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. + assert!(DaemonClient::new().is_none()); + } + + #[test] + fn new_returns_some_when_sock_present() { + 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(); + 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 = 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(); + 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..d177692 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; @@ -29,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) // --------------------------------------------------------------------------- @@ -135,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, ()>, @@ -155,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()); @@ -164,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/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..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; @@ -433,6 +436,10 @@ pub fn load_agent_definitions(project_root: &std::path::Path) -> Vec Vec 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() { @@ -577,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());