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/4] =?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/4] 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/4] 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()); From 47132ffd93aa5b15df11e6f9085df222f1ef331a Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Fri, 29 May 2026 20:46:58 -0500 Subject: [PATCH 4/4] feat(tui): add coven familiar handoff command --- src-rust/crates/core/src/coven_daemon.rs | 97 ++++++++++++++++-- src-rust/crates/core/src/coven_shared.rs | 2 +- src-rust/crates/tui/src/app.rs | 34 +++++++ src-rust/crates/tui/src/handoff.rs | 123 +++++++++++++++++++++++ src-rust/crates/tui/src/lib.rs | 10 +- 5 files changed, 254 insertions(+), 12 deletions(-) create mode 100644 src-rust/crates/tui/src/handoff.rs diff --git a/src-rust/crates/core/src/coven_daemon.rs b/src-rust/crates/core/src/coven_daemon.rs index e6a4a11..95009d6 100644 --- a/src-rust/crates/core/src/coven_daemon.rs +++ b/src-rust/crates/core/src/coven_daemon.rs @@ -14,7 +14,7 @@ use std::path::PathBuf; #[cfg(unix)] use std::time::Duration; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; #[cfg(unix)] use crate::coven_shared::coven_home; @@ -44,6 +44,16 @@ pub struct DaemonSession { pub project_root: String, } +/// Payload for creating a new Coven daemon session. +#[derive(Debug, Clone, Serialize)] +pub struct CreateSessionRequest { + pub familiar: String, + pub project_root: String, + pub harness: String, + pub title: String, + pub initial_message: String, +} + // --------------------------------------------------------------------------- // Raw JSON shapes (private — only used for deserialization) // --------------------------------------------------------------------------- @@ -124,18 +134,27 @@ impl DaemonClient { Ok(stream) } - /// Send a minimal HTTP/1.0 GET and return the body string. + /// Send a minimal HTTP/1.0 request 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 { + fn request(&self, method: &str, path: &str, body: Option<&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 - ); + let request = match body { + Some(body) => format!( + "{} {} HTTP/1.0\r\nHost: localhost\r\nAccept: application/json\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", + method, + path, + body.len(), + body + ), + None => format!( + "{} {} HTTP/1.0\r\nHost: localhost\r\nAccept: application/json\r\n\r\n", + method, path + ), + }; stream.write_all(request.as_bytes()).ok()?; stream.flush().ok()?; @@ -159,11 +178,18 @@ impl DaemonClient { } #[cfg(not(unix))] { + let _ = method; let _ = path; + let _ = body; None } } + /// Send a minimal HTTP/1.0 GET and return the body string. + fn get(&self, path: &str) -> Option { + self.request("GET", path, None) + } + // -- public API --------------------------------------------------------- /// Quick liveness check — returns `true` if the daemon responds with 200. @@ -214,6 +240,26 @@ impl DaemonClient { }) .collect() } + + /// Create a daemon session and return its session id. + pub fn create_session(&self, req: CreateSessionRequest) -> Result { + let body = serde_json::to_string(&req) + .map_err(|e| format!("Failed to encode daemon session request: {e}"))?; + let response = self + .request("POST", "/api/v1/sessions", Some(&body)) + .ok_or_else(|| { + "Coven daemon did not return a successful session response".to_string() + })?; + let value: serde_json::Value = serde_json::from_str(&response) + .map_err(|e| format!("Coven daemon returned invalid session JSON: {e}"))?; + value + .get("id") + .or_else(|| value.get("session_id")) + .or_else(|| value.get("sessionId")) + .and_then(|id| id.as_str()) + .map(|id| id.to_string()) + .ok_or_else(|| "Coven daemon response did not include a session id".to_string()) + } } // --------------------------------------------------------------------------- @@ -327,4 +373,41 @@ mod tests { // connect() will fail → familiar_statuses() must return empty vec, not panic. assert!(client.familiar_statuses().is_empty()); } + + #[test] + fn create_session_posts_payload_and_returns_session_id() { + let dir = tempfile::tempdir().unwrap(); + let sock = dir.path().join("coven.sock"); + let listener = std::os::unix::net::UnixListener::bind(&sock).unwrap(); + + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut buf = [0_u8; 4096]; + let n = stream.read(&mut buf).unwrap(); + let request = String::from_utf8_lossy(&buf[..n]); + assert!(request.starts_with("POST /api/v1/sessions HTTP/1.0")); + assert!(request.contains("\"familiar\":\"sage\"")); + assert!(request.contains("\"project_root\":\"/tmp/project\"")); + assert!(request.contains("\"initial_message\":\"handoff context\"")); + stream + .write_all( + b"HTTP/1.0 201 Created\r\nContent-Type: application/json\r\n\r\n{\"id\":\"sess_123\"}", + ) + .unwrap(); + }); + + let client = DaemonClient { sock_path: sock }; + let session_id = client + .create_session(CreateSessionRequest { + familiar: "sage".to_string(), + project_root: "/tmp/project".to_string(), + harness: "openclaw".to_string(), + title: "Handoff from coven-code".to_string(), + initial_message: "handoff context".to_string(), + }) + .unwrap(); + + server.join().unwrap(); + assert_eq!(session_id, "sess_123"); + } } diff --git a/src-rust/crates/core/src/coven_shared.rs b/src-rust/crates/core/src/coven_shared.rs index d177692..3e736c2 100644 --- a/src-rust/crates/core/src/coven_shared.rs +++ b/src-rust/crates/core/src/coven_shared.rs @@ -11,7 +11,7 @@ //! `~/.coven/coven.sock`) lives in [`crate::coven_daemon`]. // Re-export Tier B IPC types for convenience. -pub use crate::coven_daemon::{DaemonClient, DaemonSession, FamiliarStatus}; +pub use crate::coven_daemon::{CreateSessionRequest, DaemonClient, DaemonSession, FamiliarStatus}; use std::path::PathBuf; use serde::Deserialize; diff --git a/src-rust/crates/tui/src/app.rs b/src-rust/crates/tui/src/app.rs index 04ce468..0f0a71c 100644 --- a/src-rust/crates/tui/src/app.rs +++ b/src-rust/crates/tui/src/app.rs @@ -45,6 +45,7 @@ use tracing::debug; const PROMPT_SLASH_COMMANDS: &[(&str, &str)] = &[ ("advisor", "Set or unset the server-side advisor model"), ("familiar", "Set your active familiar — changes the TUI mascot live"), + ("handoff", "Hand off current session context to a Coven familiar"), ("agent", "List available familiars or show familiar details"), ("agents", "Browse familiar definitions and active familiars"), ("changes", "Inspect changes from the current session"), @@ -2031,6 +2032,39 @@ impl App { if cmd == "mcp" && !args.trim().is_empty() { return false; } + if cmd == "handoff" { + let familiar = args.trim().to_string(); + if familiar.is_empty() { + self.push_notification( + NotificationKind::Warning, + "Usage: /handoff ".to_string(), + Some(4), + ); + return true; + } + + let context = crate::handoff::build_handoff_context(&self.messages, &familiar); + let root = self.project_root().to_string_lossy().to_string(); + match crate::handoff::send_handoff(&familiar, context, &root) { + Ok(session_id) => { + self.push_notification( + NotificationKind::Info, + format!( + "Handed off to {familiar}. Session created in Coven daemon. (id: {session_id})" + ), + Some(5), + ); + } + Err(err) => { + self.push_notification( + NotificationKind::Warning, + format!("Handoff failed: {err}"), + Some(5), + ); + } + } + return true; + } self.intercept_slash_command(cmd) } diff --git a/src-rust/crates/tui/src/handoff.rs b/src-rust/crates/tui/src/handoff.rs new file mode 100644 index 0000000..aa933e7 --- /dev/null +++ b/src-rust/crates/tui/src/handoff.rs @@ -0,0 +1,123 @@ +//! `/handoff` command support. +//! +//! The TUI builds a compact handoff packet from recent conversation context and +//! asks the Coven daemon to open a new session for the requested familiar. + +use claurst_core::coven_shared::{CreateSessionRequest, DaemonClient}; +use claurst_core::types::{Message, Role}; + +/// Format the last messages as a markdown context block suitable for handoff. +pub fn build_handoff_context(messages: &[Message], familiar_name: &str) -> String { + let conversation = messages + .iter() + .rev() + .take(20) + .collect::>() + .into_iter() + .rev() + .map(format_message) + .collect::>() + .join("\n"); + + let topic = messages + .iter() + .rev() + .find(|m| m.role == Role::User) + .map(|m| truncate_chars(&m.get_all_text(), 120)) + .unwrap_or_else(|| "(unknown topic)".to_string()); + + format!( + "# Handoff Context\n\ + **From:** coven-code session\n\ + **Familiar:** {familiar_name}\n\ + \n\ + ## Recent conversation\n\ + \n\ + {conversation}\n\ + ## Handoff request\n\ + Continue this work as {familiar_name}. The user was working on: {topic}.\n" + ) +} + +/// Create a Coven daemon session for a named familiar. +pub fn send_handoff( + familiar_name: &str, + context: String, + project_root: &str, +) -> Result { + let client = DaemonClient::new() + .ok_or_else(|| "Coven daemon not running; install coven to use /handoff".to_string())?; + let title = format!("Handoff from coven-code: {}", infer_short_topic(&context)); + client.create_session(CreateSessionRequest { + familiar: familiar_name.to_string(), + project_root: project_root.to_string(), + harness: "openclaw".to_string(), + title, + initial_message: context, + }) +} + +fn format_message(message: &Message) -> String { + let role = match message.role { + Role::User => "User", + Role::Assistant => "Assistant", + }; + let text = truncate_chars(&message.get_all_text(), 500); + text.lines() + .map(|line| format!("> **{role}**: {line}")) + .collect::>() + .join("\n") +} + +fn infer_short_topic(context: &str) -> String { + let Some((_, topic)) = context.split_once("The user was working on: ") else { + return "session context".to_string(); + }; + let topic = topic.lines().next().unwrap_or("").trim_end_matches('.'); + if topic.is_empty() { + "session context".to_string() + } else { + truncate_chars(topic, 60) + } +} + +fn truncate_chars(text: &str, max_chars: usize) -> String { + let mut chars = text.chars(); + let truncated: String = chars.by_ref().take(max_chars).collect(); + if chars.next().is_some() { + format!("{truncated}...") + } else { + truncated + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn handoff_context_handles_empty_messages() { + let ctx = build_handoff_context(&[], "sage"); + assert!(ctx.contains("**Familiar:** sage")); + assert!(ctx.contains("Continue this work as sage")); + assert!(ctx.contains("(unknown topic)")); + } + + #[test] + fn handoff_context_includes_recent_messages_and_topic() { + let msgs = vec![ + Message::user("Fix the login bug"), + Message::assistant("I'll inspect auth"), + ]; + let ctx = build_handoff_context(&msgs, "astra"); + assert!(ctx.contains("> **User**: Fix the login bug")); + assert!(ctx.contains("> **Assistant**: I'll inspect auth")); + assert!(ctx.contains("The user was working on: Fix the login bug")); + } + + #[test] + fn truncate_chars_does_not_split_unicode_boundaries() { + let text = "sage 🌿 keeps context"; + assert_eq!(truncate_chars(text, 6), "sage 🌿..."); + } +} diff --git a/src-rust/crates/tui/src/lib.rs b/src-rust/crates/tui/src/lib.rs index 4492401..a40d902 100644 --- a/src-rust/crates/tui/src/lib.rs +++ b/src-rust/crates/tui/src/lib.rs @@ -79,10 +79,12 @@ pub mod virtual_list; pub mod messages; /// Turn-aware transcript grouping and metadata helpers. pub mod transcript_turn; -/// Agent definitions list and coordinator progress view. -pub mod agents_view; -/// Stats dialog with token usage and cost charts. -pub mod stats_dialog; +/// Agent definitions list and coordinator progress view. +pub mod agents_view; +/// Coven familiar handoff command support. +pub mod handoff; +/// Stats dialog with token usage and cost charts. +pub mod stats_dialog; /// MCP server management UI. pub mod mcp_view; /// Complete prompt input with vim mode, history, typeahead, and paste handling.