Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 106 additions & 12 deletions src-rust/crates/core/src/coven_daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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<String> {
fn request(&self, method: &str, path: &str, body: Option<&str>) -> Option<String> {
#[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()?;

Expand All @@ -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<String> {
self.request("GET", path, None)
}

// -- public API ---------------------------------------------------------

/// Quick liveness check — returns `true` if the daemon responds with 200.
Expand Down Expand Up @@ -214,6 +236,26 @@ impl DaemonClient {
})
.collect()
}

/// Create a daemon session and return its session id.
pub fn create_session(&self, req: CreateSessionRequest) -> Result<String, String> {
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())
}
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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.
Expand All @@ -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();
Expand Down Expand Up @@ -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),
Expand All @@ -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),
Expand All @@ -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();
Expand All @@ -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");
}
}
23 changes: 17 additions & 6 deletions src-rust/crates/core/src/coven_shared.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -120,7 +120,11 @@ pub fn list_daemon_skills() -> Vec<DaemonSkill> {
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");
Expand Down Expand Up @@ -158,16 +162,23 @@ mod tests {
}

fn with_coven_home<F: FnOnce(&std::path::Path)>(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");
Expand Down
34 changes: 34 additions & 0 deletions src-rust/crates/tui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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 <familiar_name>".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)
}

Expand Down
Loading