Skip to content

Commit c5f6c32

Browse files
authored
feat(tui): add Coven familiar handoff command
Adds /handoff support for creating Coven daemon sessions from recent TUI context.\n\nKeeps the daemon POST path in the shared Tier B client so handoff uses the same short socket timeouts, 2xx response validation, and graceful failure behavior as the existing daemon IPC.\n\nVerification:\n- cargo test -p claurst-core coven_daemon --lib\n- cargo test -p claurst-tui handoff --lib\n- cargo check -p claurst-tui\n- git diff --check
1 parent f0891c9 commit c5f6c32

5 files changed

Lines changed: 286 additions & 22 deletions

File tree

src-rust/crates/core/src/coven_daemon.rs

Lines changed: 106 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ use std::path::PathBuf;
1414
#[cfg(unix)]
1515
use std::time::Duration;
1616

17-
use serde::Deserialize;
17+
use serde::{Deserialize, Serialize};
1818

1919
#[cfg(unix)]
2020
use crate::coven_shared::coven_home;
@@ -44,6 +44,16 @@ pub struct DaemonSession {
4444
pub project_root: String,
4545
}
4646

47+
/// Payload for creating a new Coven daemon session.
48+
#[derive(Debug, Clone, Serialize)]
49+
pub struct CreateSessionRequest {
50+
pub familiar: String,
51+
pub project_root: String,
52+
pub harness: String,
53+
pub title: String,
54+
pub initial_message: String,
55+
}
56+
4757
// ---------------------------------------------------------------------------
4858
// Raw JSON shapes (private — only used for deserialization)
4959
// ---------------------------------------------------------------------------
@@ -124,18 +134,23 @@ impl DaemonClient {
124134
Ok(stream)
125135
}
126136

127-
/// Send a minimal HTTP/1.0 GET and return the body string.
137+
/// Send a minimal HTTP/1.0 request and return the body string.
128138
///
129139
/// HTTP/1.0 is used so the server closes the connection after the
130140
/// response — no need to parse `Content-Length` or chunked encoding.
131-
fn get(&self, path: &str) -> Option<String> {
141+
fn request(&self, method: &str, path: &str, body: Option<&str>) -> Option<String> {
132142
#[cfg(unix)]
133143
{
134144
let mut stream = self.connect().ok()?;
135-
let request = format!(
136-
"GET {} HTTP/1.0\r\nHost: localhost\r\nAccept: application/json\r\n\r\n",
137-
path
138-
);
145+
let request = match body {
146+
Some(body) => format!(
147+
"{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}",
148+
body.len()
149+
),
150+
None => format!(
151+
"{method} {path} HTTP/1.0\r\nHost: localhost\r\nAccept: application/json\r\n\r\n"
152+
),
153+
};
139154
stream.write_all(request.as_bytes()).ok()?;
140155
stream.flush().ok()?;
141156

@@ -159,11 +174,18 @@ impl DaemonClient {
159174
}
160175
#[cfg(not(unix))]
161176
{
177+
let _ = method;
162178
let _ = path;
179+
let _ = body;
163180
None
164181
}
165182
}
166183

184+
/// Send a minimal HTTP/1.0 GET and return the body string.
185+
fn get(&self, path: &str) -> Option<String> {
186+
self.request("GET", path, None)
187+
}
188+
167189
// -- public API ---------------------------------------------------------
168190

169191
/// Quick liveness check — returns `true` if the daemon responds with 200.
@@ -214,6 +236,26 @@ impl DaemonClient {
214236
})
215237
.collect()
216238
}
239+
240+
/// Create a daemon session and return its session id.
241+
pub fn create_session(&self, req: CreateSessionRequest) -> Result<String, String> {
242+
let body = serde_json::to_string(&req)
243+
.map_err(|e| format!("Failed to encode daemon session request: {e}"))?;
244+
let response = self
245+
.request("POST", "/api/v1/sessions", Some(&body))
246+
.ok_or_else(|| {
247+
"Coven daemon did not return a successful session response".to_string()
248+
})?;
249+
let value: serde_json::Value = serde_json::from_str(&response)
250+
.map_err(|e| format!("Coven daemon returned invalid session JSON: {e}"))?;
251+
value
252+
.get("id")
253+
.or_else(|| value.get("session_id"))
254+
.or_else(|| value.get("sessionId"))
255+
.and_then(|id| id.as_str())
256+
.map(|id| id.to_string())
257+
.ok_or_else(|| "Coven daemon response did not include a session id".to_string())
258+
}
217259
}
218260

219261
// ---------------------------------------------------------------------------
@@ -249,7 +291,9 @@ mod tests {
249291

250292
#[test]
251293
fn new_returns_none_when_sock_absent() {
252-
let _lock = COVEN_HOME_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
294+
let _lock = COVEN_HOME_ENV_LOCK
295+
.lock()
296+
.unwrap_or_else(|e| e.into_inner());
253297
let dir = tempfile::tempdir().unwrap();
254298
let _g = EnvGuard::set("COVEN_HOME", dir.path().to_str().unwrap());
255299
// Directory exists but no coven.sock inside → should return None.
@@ -258,7 +302,9 @@ mod tests {
258302

259303
#[test]
260304
fn new_returns_some_when_sock_present() {
261-
let _lock = COVEN_HOME_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
305+
let _lock = COVEN_HOME_ENV_LOCK
306+
.lock()
307+
.unwrap_or_else(|e| e.into_inner());
262308
let dir = tempfile::tempdir().unwrap();
263309
// Create a placeholder file (not a real socket, just needs to exist).
264310
fs::write(dir.path().join("coven.sock"), b"").unwrap();
@@ -290,7 +336,10 @@ mod tests {
290336
assert_eq!(raw.len(), 2);
291337

292338
let s0 = FamiliarStatus {
293-
display_name: raw[0].display_name.clone().unwrap_or_else(|| raw[0].id.clone()),
339+
display_name: raw[0]
340+
.display_name
341+
.clone()
342+
.unwrap_or_else(|| raw[0].id.clone()),
294343
emoji: raw[0].emoji.clone().unwrap_or_default(),
295344
status: raw[0].status.clone().unwrap_or_default(),
296345
active_sessions: raw[0].active_sessions.unwrap_or(0),
@@ -304,7 +353,10 @@ mod tests {
304353
assert_eq!(s0.active_sessions, 2);
305354

306355
let s1 = FamiliarStatus {
307-
display_name: raw[1].display_name.clone().unwrap_or_else(|| raw[1].id.clone()),
356+
display_name: raw[1]
357+
.display_name
358+
.clone()
359+
.unwrap_or_else(|| raw[1].id.clone()),
308360
emoji: raw[1].emoji.clone().unwrap_or_default(),
309361
status: raw[1].status.clone().unwrap_or_default(),
310362
active_sessions: raw[1].active_sessions.unwrap_or(0),
@@ -318,7 +370,9 @@ mod tests {
318370

319371
#[test]
320372
fn familiar_statuses_returns_empty_on_bad_json() {
321-
let _lock = COVEN_HOME_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
373+
let _lock = COVEN_HOME_ENV_LOCK
374+
.lock()
375+
.unwrap_or_else(|e| e.into_inner());
322376
let dir = tempfile::tempdir().unwrap();
323377
// Placeholder sock — not a real socket, so connect() will fail.
324378
fs::write(dir.path().join("coven.sock"), b"").unwrap();
@@ -327,4 +381,44 @@ mod tests {
327381
// connect() will fail → familiar_statuses() must return empty vec, not panic.
328382
assert!(client.familiar_statuses().is_empty());
329383
}
384+
385+
#[cfg(unix)]
386+
#[test]
387+
fn create_session_posts_payload_and_returns_session_id() {
388+
let dir = tempfile::tempdir().unwrap();
389+
let sock = dir.path().join("coven.sock");
390+
let listener = std::os::unix::net::UnixListener::bind(&sock).unwrap();
391+
392+
let server = std::thread::spawn(move || {
393+
let (mut stream, _) = listener.accept().unwrap();
394+
let mut buf = [0_u8; 4096];
395+
let n = stream.read(&mut buf).unwrap();
396+
let request = String::from_utf8_lossy(&buf[..n]);
397+
assert!(request.starts_with("POST /api/v1/sessions HTTP/1.0"));
398+
assert!(request.contains("Host: localhost\r\n"));
399+
assert!(request.contains("Content-Type: application/json\r\n"));
400+
assert!(request.contains("\"familiar\":\"sage\""));
401+
assert!(request.contains("\"project_root\":\"/tmp/project\""));
402+
assert!(request.contains("\"initial_message\":\"handoff context\""));
403+
stream
404+
.write_all(
405+
b"HTTP/1.0 201 Created\r\nContent-Type: application/json\r\n\r\n{\"id\":\"sess_123\"}",
406+
)
407+
.unwrap();
408+
});
409+
410+
let client = DaemonClient { sock_path: sock };
411+
let session_id = client
412+
.create_session(CreateSessionRequest {
413+
familiar: "sage".to_string(),
414+
project_root: "/tmp/project".to_string(),
415+
harness: "openclaw".to_string(),
416+
title: "Handoff from coven-code".to_string(),
417+
initial_message: "handoff context".to_string(),
418+
})
419+
.unwrap();
420+
421+
server.join().unwrap();
422+
assert_eq!(session_id, "sess_123");
423+
}
330424
}

src-rust/crates/core/src/coven_shared.rs

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@
1111
//! `~/.coven/coven.sock`) lives in [`crate::coven_daemon`].
1212
1313
// Re-export Tier B IPC types for convenience.
14-
pub use crate::coven_daemon::{DaemonClient, DaemonSession, FamiliarStatus};
14+
pub use crate::coven_daemon::{CreateSessionRequest, DaemonClient, DaemonSession, FamiliarStatus};
1515

16-
use std::path::PathBuf;
1716
use serde::Deserialize;
17+
use std::path::PathBuf;
1818

1919
/// Locate `~/.coven/` if it exists.
2020
///
@@ -120,7 +120,11 @@ pub fn list_daemon_skills() -> Vec<DaemonSkill> {
120120
if !path.is_dir() {
121121
continue;
122122
}
123-
let Some(id) = path.file_name().and_then(|s| s.to_str()).map(|s| s.to_string()) else {
123+
let Some(id) = path
124+
.file_name()
125+
.and_then(|s| s.to_str())
126+
.map(|s| s.to_string())
127+
else {
124128
continue;
125129
};
126130
let manifest = path.join("metadata.json");
@@ -158,16 +162,23 @@ mod tests {
158162
}
159163

160164
fn with_coven_home<F: FnOnce(&std::path::Path)>(setup: F) -> EnvGuard {
161-
let lock = COVEN_HOME_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
165+
let lock = COVEN_HOME_ENV_LOCK
166+
.lock()
167+
.unwrap_or_else(|e| e.into_inner());
162168
let tmp = TempDir::new().unwrap();
163169
setup(tmp.path());
164170
std::env::set_var("COVEN_HOME", tmp.path());
165-
EnvGuard { _tmp: tmp, _lock: lock }
171+
EnvGuard {
172+
_tmp: tmp,
173+
_lock: lock,
174+
}
166175
}
167176

168177
#[test]
169178
fn coven_home_returns_none_when_dir_missing() {
170-
let _lock = COVEN_HOME_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
179+
let _lock = COVEN_HOME_ENV_LOCK
180+
.lock()
181+
.unwrap_or_else(|e| e.into_inner());
171182
std::env::set_var("COVEN_HOME", "/nonexistent/path/cc_test_xyz");
172183
assert!(coven_home().is_none());
173184
std::env::remove_var("COVEN_HOME");

src-rust/crates/tui/src/app.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ use tracing::debug;
4545
const PROMPT_SLASH_COMMANDS: &[(&str, &str)] = &[
4646
("advisor", "Set or unset the server-side advisor model"),
4747
("familiar", "Set your active familiar — changes the TUI mascot live"),
48+
("handoff", "Hand off current session context to a Coven familiar"),
4849
("agent", "List available familiars or show familiar details"),
4950
("agents", "Browse familiar definitions and active familiars"),
5051
("changes", "Inspect changes from the current session"),
@@ -2060,6 +2061,39 @@ impl App {
20602061
if cmd == "mcp" && !args.trim().is_empty() {
20612062
return false;
20622063
}
2064+
if cmd == "handoff" {
2065+
let familiar = args.trim().to_string();
2066+
if familiar.is_empty() {
2067+
self.push_notification(
2068+
NotificationKind::Warning,
2069+
"Usage: /handoff <familiar_name>".to_string(),
2070+
Some(4),
2071+
);
2072+
return true;
2073+
}
2074+
2075+
let context = crate::handoff::build_handoff_context(&self.messages, &familiar);
2076+
let root = self.project_root().to_string_lossy().to_string();
2077+
match crate::handoff::send_handoff(&familiar, context, &root) {
2078+
Ok(session_id) => {
2079+
self.push_notification(
2080+
NotificationKind::Info,
2081+
format!(
2082+
"Handed off to {familiar}. Session created in Coven daemon. (id: {session_id})"
2083+
),
2084+
Some(5),
2085+
);
2086+
}
2087+
Err(err) => {
2088+
self.push_notification(
2089+
NotificationKind::Warning,
2090+
format!("Handoff failed: {err}"),
2091+
Some(5),
2092+
);
2093+
}
2094+
}
2095+
return true;
2096+
}
20632097
self.intercept_slash_command(cmd)
20642098
}
20652099

0 commit comments

Comments
 (0)