diff --git a/Cargo.lock b/Cargo.lock index 15fdcb6..7297cfb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -129,6 +129,19 @@ dependencies = [ "tokio", ] +[[package]] +name = "colibri-client" +version = "0.0.1" +dependencies = [ + "colibri-daemon", + "colibri-glasspane", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "uuid", +] + [[package]] name = "colibri-contracts" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index 8f020ec..6b375ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/colibri-contracts", "crates/colibri-deepseek", "crates/colibri-runtime", "crates/colibri-glasspane", "crates/colibri-daemon"] +members = ["crates/colibri-contracts", "crates/colibri-deepseek", "crates/colibri-runtime", "crates/colibri-glasspane", "crates/colibri-daemon", "crates/colibri-client"] [package] name = "colibri" diff --git a/crates/colibri-client/Cargo.toml b/crates/colibri-client/Cargo.toml new file mode 100644 index 0000000..314ea0a --- /dev/null +++ b/crates/colibri-client/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "colibri-client" +version = "0.0.1" +edition = "2021" +license = "AGPL-3.0-only" +description = "Typed Unix-socket client for colibri-daemon/Glasspane API" + +[dependencies] +colibri-daemon = { path = "../colibri-daemon" } +colibri-glasspane = { path = "../colibri-glasspane" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" +tokio = { version = "1", features = ["io-util", "net"] } + +[dev-dependencies] +tokio = { version = "1", features = ["fs", "io-util", "macros", "net", "rt-multi-thread"] } +uuid = { version = "1", features = ["v4"] } diff --git a/crates/colibri-client/src/lib.rs b/crates/colibri-client/src/lib.rs new file mode 100644 index 0000000..a169775 --- /dev/null +++ b/crates/colibri-client/src/lib.rs @@ -0,0 +1,211 @@ +//! colibri-client — typed client for the colibri-daemon Unix socket API. +//! +//! Phase 4 starts here: display clients (Herdr on Linux, web boards, editor +//! integrations) should not need to know daemon internals. They connect to the +//! daemon socket, send newline-delimited JSON commands, and deserialize typed +//! responses such as `GlasspaneSnapshot`. + +use std::path::{Path, PathBuf}; + +use colibri_daemon::{HerdrCommand, HerdrResponse}; +use colibri_glasspane::GlasspaneSnapshot; +use serde::de::DeserializeOwned; +use thiserror::Error; +use tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, + net::UnixStream, +}; + +#[derive(Debug, Error)] +pub enum ClientError { + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + #[error("daemon returned error: {0}")] + Daemon(String), + #[error("daemon response missing data")] + MissingData, + #[error("daemon closed socket without response")] + EmptyResponse, +} + +/// Small typed client for the daemon's newline-delimited Unix socket protocol. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DaemonClient { + socket_path: PathBuf, +} + +impl DaemonClient { + pub fn new(socket_path: impl Into) -> Self { + Self { + socket_path: socket_path.into(), + } + } + + pub fn socket_path(&self) -> &Path { + &self.socket_path + } + + /// Send one command and parse the daemon's generic response envelope. + pub async fn send(&self, command: &HerdrCommand) -> Result { + let stream = UnixStream::connect(&self.socket_path).await?; + let (reader, mut writer) = stream.into_split(); + let mut reader = BufReader::new(reader); + + let mut line = serde_json::to_string(command)?; + line.push('\n'); + writer.write_all(line.as_bytes()).await?; + writer.flush().await?; + + let mut response = String::new(); + let read = reader.read_line(&mut response).await?; + if read == 0 { + return Err(ClientError::EmptyResponse); + } + + Ok(serde_json::from_str(response.trim_end())?) + } + + /// Send one command and deserialize its `data` payload to a typed value. + pub async fn request( + &self, + command: &HerdrCommand, + ) -> Result { + let response = self.send(command).await?; + if !response.ok { + return Err(ClientError::Daemon( + response + .error + .unwrap_or_else(|| "unknown daemon error".to_string()), + )); + } + let data = response.data.ok_or(ClientError::MissingData)?; + Ok(serde_json::from_value(data)?) + } + + pub async fn status(&self) -> Result { + self.request(&HerdrCommand::Status).await + } + + pub async fn glasspane_snapshot(&self) -> Result { + self.request(&HerdrCommand::GlasspaneSnapshot).await + } + + pub async fn list_sessions(&self) -> Result { + self.request(&HerdrCommand::ListSessions).await + } + + pub async fn spawn_agent( + &self, + provider: impl Into, + model: impl Into, + session_id: Option, + system_prompt: Option, + ) -> Result { + self.request(&HerdrCommand::SpawnAgent { + provider: provider.into(), + model: model.into(), + session_id, + system_prompt, + }) + .await + } + + pub async fn kill_agent( + &self, + agent_id: impl Into, + ) -> Result { + self.request(&HerdrCommand::KillAgent { + agent_id: agent_id.into(), + }) + .await + } + + pub async fn get_session( + &self, + session_id: impl Into, + ) -> Result { + self.request(&HerdrCommand::GetSession { + session_id: session_id.into(), + }) + .await + } + + pub async fn compact_session( + &self, + session_id: impl Into, + ) -> Result { + self.request(&HerdrCommand::CompactSession { + session_id: session_id.into(), + }) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use colibri_glasspane::{AgentState, GLASSPANE_SNAPSHOT_SCHEMA}; + use tokio::net::UnixListener; + + async fn one_shot_server(response: serde_json::Value) -> PathBuf { + let path = + std::env::temp_dir().join(format!("colibri-client-test-{}.sock", uuid::Uuid::new_v4())); + let _ = tokio::fs::remove_file(&path).await; + let listener = UnixListener::bind(&path).unwrap(); + let server_path = path.clone(); + + tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let (reader, mut writer) = stream.into_split(); + let mut reader = BufReader::new(reader); + let mut request = String::new(); + reader.read_line(&mut request).await.unwrap(); + assert!(request.contains("\"cmd\"")); + writer + .write_all(format!("{}\n", serde_json::to_string(&response).unwrap()).as_bytes()) + .await + .unwrap(); + let _ = tokio::fs::remove_file(server_path).await; + }); + + path + } + + #[tokio::test] + async fn client_reads_glasspane_snapshot() { + let response = serde_json::json!({ + "ok": true, + "data": { + "schema": GLASSPANE_SNAPSHOT_SCHEMA, + "host": "test-host", + "observed_at": "2026-05-27T12:00:00.000Z", + "panes": [{ + "id": "pane-1", + "agent": "pi", + "state": "working", + "pi_session_id": "pi-session-1", + "last_event_at": "2026-05-27T11:59:59.000Z", + "cwd": "/repo" + }] + } + }); + let path = one_shot_server(response).await; + let client = DaemonClient::new(path); + let snapshot = client.glasspane_snapshot().await.unwrap(); + assert_eq!(snapshot.schema, GLASSPANE_SNAPSHOT_SCHEMA); + assert_eq!(snapshot.panes[0].id, "pane-1"); + assert_eq!(snapshot.panes[0].state, AgentState::Working); + assert!(!snapshot.panes[0].stalled); + } + + #[tokio::test] + async fn client_surfaces_daemon_error() { + let response = serde_json::json!({"ok": false, "error": "boom"}); + let path = one_shot_server(response).await; + let client = DaemonClient::new(path); + let err = client.status().await.unwrap_err(); + assert!(matches!(err, ClientError::Daemon(message) if message == "boom")); + } +} diff --git a/crates/colibri-daemon/src/lib.rs b/crates/colibri-daemon/src/lib.rs index 8738aeb..3756d37 100644 --- a/crates/colibri-daemon/src/lib.rs +++ b/crates/colibri-daemon/src/lib.rs @@ -26,7 +26,7 @@ use serde::{Deserialize, Serialize}; // --------------------------------------------------------------------------- /// Inbound command from Herdr over the Unix socket. -#[derive(Debug, Deserialize)] +#[derive(Debug, Serialize, Deserialize)] #[serde(tag = "cmd")] pub enum HerdrCommand { #[serde(rename = "status")] @@ -51,7 +51,7 @@ pub enum HerdrCommand { } /// Outbound response to Herdr. -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Deserialize)] pub struct HerdrResponse { pub ok: bool, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/docs/COLIBRI-DAEMON-GLASSPANE-INTEGRATION.md b/docs/COLIBRI-DAEMON-GLASSPANE-INTEGRATION.md index 91bb05e..77541be 100644 --- a/docs/COLIBRI-DAEMON-GLASSPANE-INTEGRATION.md +++ b/docs/COLIBRI-DAEMON-GLASSPANE-INTEGRATION.md @@ -523,6 +523,7 @@ Unix socket path (`DaemonConfig.socket_path`). | Document | Relationship | |-------------------------------------------------------|------------------------------------------------| | `docs/COLIBRI-GLASSPANE-DESIGN.md` | Glasspane capability design, phase plan, non-goals | +| `crates/colibri-client/src/lib.rs` | Phase-4 typed Unix-socket client for display/UI consumers | | `docs/HERDR-VS-COLIBRI-GRAPH.md` | Hybrid boundary: Herdr as Linux display client | | `docs/CALLER-INVENTORY.md` | Caller-side inventory of Pi/agent invocations | | `crates/colibri-daemon/src/socket.rs` | Socket server implementation | diff --git a/docs/COLIBRI-GLASSPANE-DESIGN.md b/docs/COLIBRI-GLASSPANE-DESIGN.md index a647849..45ad8f1 100644 --- a/docs/COLIBRI-GLASSPANE-DESIGN.md +++ b/docs/COLIBRI-GLASSPANE-DESIGN.md @@ -106,8 +106,10 @@ control plane. Glasspane owns supervision only. a `portable-pty` launch seam. `run_pane_reader` now drains fake or PTY-backed JSONL readers into the same streaming ingestion path. Tests use fake JSONL readers first; live FreeBSD PTY validation is a later osa lane. -4. **Client API** — socket / SSE / HTTP serving `GlasspaneSnapshot`; Herdr-Linux - and Zed/web as display clients. +4. 🟡 **Client API** — started. `colibri-client` provides a typed Unix-socket + client for daemon commands, including `glasspane_snapshot()` returning a + `GlasspaneSnapshot`. Herdr-Linux, Zed, and web boards can consume this API; + HTTP/SSE remains a later transport. 5. **Orchestrator** — route/dispatch work across panes (separate `colibri-orchestrator`). ## Non-goals