Start Phase 4 typed daemon client

This commit is contained in:
Sam & Claude 2026-05-27 03:02:42 +02:00
parent 0e35c1bc27
commit 20809cda1d
7 changed files with 250 additions and 5 deletions

13
Cargo.lock generated
View file

@ -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"

View file

@ -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"

View file

@ -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"] }

View file

@ -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<PathBuf>) -> 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<HerdrResponse, ClientError> {
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<T: DeserializeOwned>(
&self,
command: &HerdrCommand,
) -> Result<T, ClientError> {
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<serde_json::Value, ClientError> {
self.request(&HerdrCommand::Status).await
}
pub async fn glasspane_snapshot(&self) -> Result<GlasspaneSnapshot, ClientError> {
self.request(&HerdrCommand::GlasspaneSnapshot).await
}
pub async fn list_sessions(&self) -> Result<serde_json::Value, ClientError> {
self.request(&HerdrCommand::ListSessions).await
}
pub async fn spawn_agent(
&self,
provider: impl Into<String>,
model: impl Into<String>,
session_id: Option<String>,
system_prompt: Option<String>,
) -> Result<serde_json::Value, ClientError> {
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<String>,
) -> Result<serde_json::Value, ClientError> {
self.request(&HerdrCommand::KillAgent {
agent_id: agent_id.into(),
})
.await
}
pub async fn get_session(
&self,
session_id: impl Into<String>,
) -> Result<serde_json::Value, ClientError> {
self.request(&HerdrCommand::GetSession {
session_id: session_id.into(),
})
.await
}
pub async fn compact_session(
&self,
session_id: impl Into<String>,
) -> Result<serde_json::Value, ClientError> {
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"));
}
}

View file

@ -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")]

View file

@ -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 |

View file

@ -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