Add colibri-glasspane: design doc + Phase-1 state model scaffold (Sam & Claude)

FreeBSD-native agent supervision reimplementing Herdr's glasspane (sessions/panes/agent-state) behind Colibri's unified API; Herdr stays an optional Linux display client (AGPL + Linux-only). Key bet: agent state is derived deterministically from Pi --mode json events (colibri-pi-events taxonomy), not terminal screen-scraping.

docs/COLIBRI-GLASSPANE-DESIGN.md: capability graph, 5-state model + event→state map, unified API, clawdie.glasspane.snapshot.v1 contract, FreeBSD impl notes, 5 phases. crates/colibri-glasspane: Phase-1 pure state model (AgentState, apply_pi_event/fold_pi_events, Pane, GlasspaneSnapshot) + 5 tests. PTY/socket server + orchestrator are later phases.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Sam & Claude 2026-05-27 01:22:52 +02:00
parent dc8371e6d5
commit dbf82c0753
5 changed files with 297 additions and 1 deletions

8
Cargo.lock generated
View file

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

View file

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

View file

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

View file

@ -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<Item = &'a str>,
{
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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
}
/// 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<Pane>,
}
impl GlasspaneSnapshot {
pub fn new(host: impl Into<String>, observed_at: impl Into<String>, panes: Vec<Pane>) -> 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);
}
}

View file

@ -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<br/>(Pi events → 5 states)"]
pn["panes / sessions<br/>(PTY slots, Phase 3)"]
snap["GlasspaneSnapshot<br/>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.