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:
parent
dc8371e6d5
commit
dbf82c0753
5 changed files with 297 additions and 1 deletions
8
Cargo.lock
generated
8
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
10
crates/colibri-glasspane/Cargo.toml
Normal file
10
crates/colibri-glasspane/Cargo.toml
Normal 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"
|
||||
169
crates/colibri-glasspane/src/lib.rs
Normal file
169
crates/colibri-glasspane/src/lib.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
109
docs/COLIBRI-GLASSPANE-DESIGN.md
Normal file
109
docs/COLIBRI-GLASSPANE-DESIGN.md
Normal 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.
|
||||
Loading…
Add table
Reference in a new issue