diff --git a/Cargo.lock b/Cargo.lock index 6f8130a..2ccdf72 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -111,6 +111,14 @@ dependencies = [ "tokio", ] +[[package]] +name = "colibri-glasspane" +version = "0.0.1" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "colibri-runtime" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index cbb86ee..8da1be9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/colibri-contracts", "crates/colibri-deepseek", "crates/colibri-runtime"] +members = ["crates/colibri-contracts", "crates/colibri-deepseek", "crates/colibri-runtime", "crates/colibri-glasspane"] [package] name = "colibri" diff --git a/crates/colibri-glasspane/Cargo.toml b/crates/colibri-glasspane/Cargo.toml new file mode 100644 index 0000000..e6bac93 --- /dev/null +++ b/crates/colibri-glasspane/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "colibri-glasspane" +version = "0.0.1" +edition = "2021" +license = "AGPL-3.0-only" +description = "FreeBSD-native agent supervision (sessions/panes/agent-state) for Colibri" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/crates/colibri-glasspane/src/lib.rs b/crates/colibri-glasspane/src/lib.rs new file mode 100644 index 0000000..2ef2575 --- /dev/null +++ b/crates/colibri-glasspane/src/lib.rs @@ -0,0 +1,169 @@ +//! colibri-glasspane — FreeBSD-native agent supervision ("the radar"). +//! +//! Reimplements Herdr's glasspane capability (sessions, panes, agent-state) +//! behind Colibri's unified API. Herdr is AGPL + Linux/macOS-only, so on FreeBSD +//! we re-implement the API/state model, not the code. +//! +//! Key bet (see `docs/COLIBRI-GLASSPANE-DESIGN.md`): agent state is **derived +//! deterministically from Pi `--mode json` events** (the colibri-pi-events +//! taxonomy), not by screen-scraping terminal output. +//! +//! Phase 1 (this module): the pure state model + snapshot contract. PTY/pane +//! supervision and the socket server are later phases. + +use serde::{Deserialize, Serialize}; + +pub const GLASSPANE_SNAPSHOT_SCHEMA: &str = "clawdie.glasspane.snapshot.v1"; + +/// The semantic state of a supervised agent — Herdr's 5-state model. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AgentState { + /// Ready, between turns. + #[default] + Idle, + /// Actively producing (turn/message/tool in progress). + Working, + /// Waiting on steering / approval / input. + Blocked, + /// Turn or agent finished. + Done, + /// Failed or retries exhausted. + Error, +} + +/// Fold one Pi `--mode json` event (its raw `type` field) into the agent state. +/// Mirrors the colibri-pi-events taxonomy; unknown types preserve the state +/// (forward-compatible with new Pi event kinds). +pub fn apply_pi_event(state: AgentState, pi_type: &str) -> AgentState { + match pi_type { + "session" | "session_started" => AgentState::Idle, + "agent_start" | "turn_start" | "message_start" | "message_update" + | "tool_execution_start" | "tool_execution_update" | "tool_execution_end" + | "compaction_start" | "compaction_end" | "auto_retry_start" + | "auto_retry_end" => AgentState::Working, + // Pending steering / follow-up / approval — the operator's attention is needed. + "queue_update" => AgentState::Blocked, + "turn_end" | "agent_end" => AgentState::Done, + "error" => AgentState::Error, + // Unknown / unmodeled event → state unchanged. + _ => state, + } +} + +/// Fold a sequence of Pi event types into a final state (starting from `Idle`). +pub fn fold_pi_events<'a, I>(events: I) -> AgentState +where + I: IntoIterator, +{ + events.into_iter().fold(AgentState::Idle, apply_pi_event) +} + +/// A supervised pane — one agent occupying one PTY/session slot. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Pane { + pub id: String, + pub agent: String, + pub state: AgentState, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_event_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option, +} + +/// A point-in-time view of every supervised pane — what a display client +/// (Herdr-Linux, Zed, web board) renders. Wire contract: +/// `clawdie.glasspane.snapshot.v1`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct GlasspaneSnapshot { + pub schema: String, + pub host: String, + pub observed_at: String, + pub panes: Vec, +} + +impl GlasspaneSnapshot { + pub fn new(host: impl Into, observed_at: impl Into, panes: Vec) -> Self { + Self { + schema: GLASSPANE_SNAPSHOT_SCHEMA.to_string(), + host: host.into(), + observed_at: observed_at.into(), + panes, + } + } + + /// Count of panes in each non-idle state — the "radar" summary Herdr shows + /// (e.g. "2 blocked, 5 working"). + pub fn count(&self, state: AgentState) -> usize { + self.panes.iter().filter(|p| p.state == state).count() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lifecycle_idle_working_done() { + assert_eq!(fold_pi_events(["session"]), AgentState::Idle); + assert_eq!( + fold_pi_events(["session", "turn_start", "tool_execution_start"]), + AgentState::Working + ); + assert_eq!( + fold_pi_events(["session", "turn_start", "message_update", "turn_end"]), + AgentState::Done + ); + } + + #[test] + fn queue_update_blocks() { + assert_eq!( + apply_pi_event(AgentState::Working, "queue_update"), + AgentState::Blocked + ); + } + + #[test] + fn error_sets_error() { + assert_eq!(apply_pi_event(AgentState::Working, "error"), AgentState::Error); + } + + #[test] + fn unknown_event_preserves_state() { + assert_eq!( + apply_pi_event(AgentState::Working, "some_future_event"), + AgentState::Working + ); + } + + #[test] + fn snapshot_round_trips_and_counts() { + let snap = GlasspaneSnapshot::new( + "domedog", + "2026-05-27T00:00:00Z", + vec![ + Pane { + id: "p1".into(), + agent: "pi".into(), + state: AgentState::Working, + last_event_at: None, + cwd: Some("/repo".into()), + }, + Pane { + id: "p2".into(), + agent: "pi".into(), + state: AgentState::Blocked, + last_event_at: None, + cwd: None, + }, + ], + ); + assert_eq!(snap.count(AgentState::Blocked), 1); + let json = serde_json::to_string(&snap).unwrap(); + assert!(json.contains("\"working\"")); + assert!(json.contains(GLASSPANE_SNAPSHOT_SCHEMA)); + let back: GlasspaneSnapshot = serde_json::from_str(&json).unwrap(); + assert_eq!(snap, back); + } +} diff --git a/docs/COLIBRI-GLASSPANE-DESIGN.md b/docs/COLIBRI-GLASSPANE-DESIGN.md new file mode 100644 index 0000000..a3b8ff2 --- /dev/null +++ b/docs/COLIBRI-GLASSPANE-DESIGN.md @@ -0,0 +1,109 @@ +# colibri-glasspane — design + +FreeBSD-native **agent supervision** ("the radar"): sessions, panes, and a +semantic **agent-state** per pane. This is Herdr's glasspane capability +reimplemented as our own Rust crate — Herdr is AGPL **and Linux/macOS-only**, so +on FreeBSD we re-implement the *API/state model*, not the code. Herdr stays an +optional Linux **display client** behind the same socket/contract (hybrid +boundary from `docs/HERDR-VS-COLIBRI-GRAPH.md`). + +## The key bet: state from structured events, not screen-scraping + +Herdr infers agent state by parsing terminal output (heuristic, fragile). +Colibri already has the **Pi `--mode json` event taxonomy** (`colibri-pi-events` +in clawdie-ai), so glasspane **derives agent state deterministically from those +events** — no scraping. That's the FreeBSD-native, robust advantage, and it +reuses a contract we already proved. + +## Capability graph + +```mermaid +graph LR + pi["Pi agents (--mode json)"] -->|JSONL events| GP + subgraph GP["colibri-glasspane (FreeBSD-native, source of truth)"] + st["agent-state machine
(Pi events → 5 states)"] + pn["panes / sessions
(PTY slots, Phase 3)"] + snap["GlasspaneSnapshot
clawdie.glasspane.snapshot.v1"] + st --> pn --> snap + end + snap -->|socket / SSE / HTTP| herdr["Herdr (Linux display client)"] + snap --> zed["Zed / web board"] + snap --> orch["colibri-orchestrator (Phase 5)"] + GP -. reuses .-> ev["colibri-pi-events taxonomy"] +``` + +```json +{ + "nodes": [ + {"id":"pi","type":"agent_runtime","emits":"pi --mode json events"}, + {"id":"glasspane","type":"rust_crate","role":"supervision source of truth","status":"scaffold"}, + {"id":"snapshot","type":"contract","schema":"clawdie.glasspane.snapshot.v1"}, + {"id":"herdr","type":"linux_client","role":"display only"}, + {"id":"orchestrator","type":"future_crate","role":"dispatch/route"} + ], + "edges": [ + {"from":"pi","to":"glasspane","rel":"events"}, + {"from":"glasspane","to":"snapshot","rel":"produces"}, + {"from":"snapshot","to":"herdr","rel":"display_over_socket"}, + {"from":"glasspane","to":"orchestrator","rel":"feeds (phase 5)"} + ] +} +``` + +## Agent-state model (Herdr's 5 states, event-derived) + +| State | Meaning | Pi event types that set it | +|---|---|---| +| `idle` | ready, between turns | `session`, `session_started` | +| `working` | actively producing | `agent_start`, `turn_start`, `message_start`, `message_update`, `tool_execution_*`, `compaction_*`, `auto_retry_*` | +| `blocked` | waiting (steering/approval/input) | `queue_update` (pending) | +| `done` | turn/agent finished | `turn_end`, `agent_end` | +| `error` | failed / retries exhausted | `error` | + +Unknown event types preserve the current state (forward-compatible). +"Stalled" (blocked for too long — Herdr's headline value) is derived later from +`last_event_at` + a threshold, in the snapshot layer. + +## Unified API vocabulary + +Glasspane (supervision) names, aligned across Rust core + display clients: + +- `glasspane.snapshot()` → `GlasspaneSnapshot` (all panes + states) +- `glasspane.attach(pane)` / `glasspane.list()` / `glasspane.state(pane)` +- internal: `apply_pi_event(state, type)` / `fold_pi_events(types)` (the state machine) + +Provider/cost names stay in `colibri-deepseek`; coordination stays in the TS +control plane. Glasspane owns supervision only. + +## Contracts + +- **`clawdie.glasspane.snapshot.v1`** — `{ schema, host, observed_at, panes:[{id, + agent, state, last_event_at?, cwd?}] }`. Lives in the crate for now; promote to + `colibri-contracts` once a second consumer (a client) needs it. +- Reuses the **colibri-pi-events** taxonomy as the event source (Rust ingestion + is Phase 2 — port or consume the JSONL). + +## FreeBSD implementation notes + +- Phase-3 PTY/pane multiplexing: `portable-pty` (supports FreeBSD) or raw + `posix_openpt`; `tokio` for the socket server (`UnixStream`, loopback HTTP/SSE). +- No Herdr code in the FreeBSD core (AGPL + Linux-only). Reimplement the API. +- Single small binary, no Node/Electron — consistent with the Colibri footprint. + +## Phases + +1. **State model (this scaffold)** — `AgentState` + `apply_pi_event` / + `fold_pi_events` + `Pane` / `GlasspaneSnapshot`, unit-tested. Pure, no I/O. +2. **Pi-events ingestion (Rust)** — feed real `--mode json` JSONL through the + state machine (port the `colibri-pi-events` normalizer or consume it). +3. **PTY/pane supervision** — own real panes/sessions on FreeBSD; track + `last_event_at`; compute "stalled". +4. **Client API** — socket / SSE / HTTP serving `GlasspaneSnapshot`; Herdr-Linux + and Zed/web as display clients. +5. **Orchestrator** — route/dispatch work across panes (separate `colibri-orchestrator`). + +## Non-goals + +- No Herdr vendoring into the FreeBSD core; no Herdr-on-FreeBSD. +- No provider/cost logic (that's `colibri-deepseek`); no scheduling/task-ownership + in display clients — glasspane/daemon is the source of truth.