diff --git a/src-rust/crates/core/src/coven_daemon.rs b/src-rust/crates/core/src/coven_daemon.rs index e6a4a11..d060153 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,23 @@ 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!( + "{method} {path} HTTP/1.0\r\nHost: localhost\r\nAccept: application/json\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}", + body.len() + ), + None => format!( + "{method} {path} HTTP/1.0\r\nHost: localhost\r\nAccept: application/json\r\n\r\n" + ), + }; stream.write_all(request.as_bytes()).ok()?; stream.flush().ok()?; @@ -159,11 +174,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 +236,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()) + } } // --------------------------------------------------------------------------- @@ -249,7 +291,9 @@ mod tests { #[test] fn new_returns_none_when_sock_absent() { - let _lock = COVEN_HOME_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 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. @@ -258,7 +302,9 @@ mod tests { #[test] fn new_returns_some_when_sock_present() { - let _lock = COVEN_HOME_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 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(); @@ -290,7 +336,10 @@ mod tests { assert_eq!(raw.len(), 2); let s0 = FamiliarStatus { - display_name: raw[0].display_name.clone().unwrap_or_else(|| raw[0].id.clone()), + 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), @@ -304,7 +353,10 @@ mod tests { assert_eq!(s0.active_sessions, 2); let s1 = FamiliarStatus { - display_name: raw[1].display_name.clone().unwrap_or_else(|| raw[1].id.clone()), + 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), @@ -318,7 +370,9 @@ mod tests { #[test] fn familiar_statuses_returns_empty_on_bad_json() { - let _lock = COVEN_HOME_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 dir = tempfile::tempdir().unwrap(); // Placeholder sock — not a real socket, so connect() will fail. fs::write(dir.path().join("coven.sock"), b"").unwrap(); @@ -327,4 +381,44 @@ mod tests { // connect() will fail → familiar_statuses() must return empty vec, not panic. assert!(client.familiar_statuses().is_empty()); } + + #[cfg(unix)] + #[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("Host: localhost\r\n")); + assert!(request.contains("Content-Type: application/json\r\n")); + 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..960885e 100644 --- a/src-rust/crates/core/src/coven_shared.rs +++ b/src-rust/crates/core/src/coven_shared.rs @@ -11,10 +11,10 @@ //! `~/.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; +use std::path::PathBuf; /// Locate `~/.coven/` if it exists. /// @@ -120,7 +120,11 @@ pub fn list_daemon_skills() -> Vec { if !path.is_dir() { continue; } - let Some(id) = path.file_name().and_then(|s| s.to_str()).map(|s| s.to_string()) else { + let Some(id) = path + .file_name() + .and_then(|s| s.to_str()) + .map(|s| s.to_string()) + else { continue; }; let manifest = path.join("metadata.json"); @@ -158,16 +162,23 @@ mod tests { } fn with_coven_home(setup: F) -> EnvGuard { - let lock = COVEN_HOME_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()); - EnvGuard { _tmp: tmp, _lock: lock } + EnvGuard { + _tmp: tmp, + _lock: lock, + } } #[test] fn coven_home_returns_none_when_dir_missing() { - let _lock = COVEN_HOME_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/app.rs b/src-rust/crates/tui/src/app.rs index f5d9c9b..50a9eee 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"), @@ -2060,6 +2061,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 d788091..fe3ac22 100644 --- a/src-rust/crates/tui/src/lib.rs +++ b/src-rust/crates/tui/src/lib.rs @@ -81,10 +81,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.