Post-attention follow-ups: All-sessions view fix, wiki-lint CI parity, terminal/attention wiki pages #199
6 changed files with 370 additions and 74 deletions
|
|
@ -39,6 +39,12 @@ jobs:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Markdown format gate
|
- name: Markdown format gate
|
||||||
run: ./scripts/check-format.sh
|
run: ./scripts/check-format.sh
|
||||||
|
# Keep CI in parity with scripts/ci-checks.sh, which also runs wiki-lint.
|
||||||
|
# Pure POSIX sh (grep/awk/sed/find) — runs in the node:20 container.
|
||||||
|
# quality-gates.md claims CI encodes the same checks as local; this makes
|
||||||
|
# that true. (CI still only enforces once a Forgejo runner is registered.)
|
||||||
|
- name: Wiki lint (dangling refs, orphan pages, resurrected names)
|
||||||
|
run: ./scripts/wiki-lint --strict
|
||||||
|
|
||||||
port:
|
port:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,23 @@ impl App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Total items in the session cycle: the synthetic "All sessions" entry
|
||||||
|
/// plus one per real session id. Always >= 1 — "All" is always present.
|
||||||
|
fn session_count(&self) -> usize {
|
||||||
|
self.sessions.len() + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map the current `session_idx` to a filter. Index 0 is the synthetic
|
||||||
|
/// "All sessions" aggregated view (filter = None); any other index scopes
|
||||||
|
/// to the session id at `index - 1`. Call after every `session_idx` change.
|
||||||
|
fn apply_session_filter(&mut self) {
|
||||||
|
self.session_filter = if self.session_idx == 0 {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
self.sessions.get(self.session_idx - 1).cloned()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
fn rebuild_session_list(&mut self) {
|
fn rebuild_session_list(&mut self) {
|
||||||
let snap = match &self.snapshot {
|
let snap = match &self.snapshot {
|
||||||
Some(s) => s,
|
Some(s) => s,
|
||||||
|
|
@ -195,17 +212,15 @@ impl App {
|
||||||
.collect();
|
.collect();
|
||||||
ids.sort();
|
ids.sort();
|
||||||
ids.dedup();
|
ids.dedup();
|
||||||
if ids.is_empty() {
|
self.sessions = ids;
|
||||||
self.sessions.clear();
|
// The cycle is [All, s1, s2, ...] = sessions.len() + 1. Keep the
|
||||||
|
// previous selection when it still maps; otherwise fall back to "All".
|
||||||
|
// Default on first connect is "All sessions" (the aggregated view).
|
||||||
|
let count = self.session_count();
|
||||||
|
if self.session_idx >= count {
|
||||||
self.session_idx = 0;
|
self.session_idx = 0;
|
||||||
self.session_filter = None;
|
|
||||||
} else {
|
|
||||||
self.sessions = ids;
|
|
||||||
if self.session_idx >= self.sessions.len() {
|
|
||||||
self.session_idx = self.sessions.len().saturating_sub(1);
|
|
||||||
}
|
|
||||||
self.session_filter = self.sessions.get(self.session_idx).cloned();
|
|
||||||
}
|
}
|
||||||
|
self.apply_session_filter();
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn refresh(&mut self) {
|
async fn refresh(&mut self) {
|
||||||
|
|
@ -312,12 +327,12 @@ impl App {
|
||||||
Some(sid) => format!("Session: {sid}"),
|
Some(sid) => format!("Session: {sid}"),
|
||||||
None => "All sessions".to_string(),
|
None => "All sessions".to_string(),
|
||||||
};
|
};
|
||||||
let session_span = if self.sessions.len() > 1 {
|
let session_span = if self.session_count() > 1 {
|
||||||
Span::styled(
|
Span::styled(
|
||||||
format!(
|
format!(
|
||||||
"{session_label} ({} of {})",
|
"{session_label} ({} of {})",
|
||||||
self.session_idx + 1,
|
self.session_idx + 1,
|
||||||
self.sessions.len()
|
self.session_count()
|
||||||
),
|
),
|
||||||
Style::default().add_modifier(Modifier::BOLD),
|
Style::default().add_modifier(Modifier::BOLD),
|
||||||
)
|
)
|
||||||
|
|
@ -638,30 +653,24 @@ async fn run(socket_path: PathBuf) -> io::Result<()> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Tab | KeyCode::Char('\t') if !app.sessions.is_empty() => {
|
KeyCode::Tab | KeyCode::Char('\t') if !app.sessions.is_empty() => {
|
||||||
app.session_idx = (app.session_idx + 1) % app.sessions.len();
|
let count = app.session_count();
|
||||||
app.session_filter = app.sessions.get(app.session_idx).cloned();
|
app.session_idx = (app.session_idx + 1) % count;
|
||||||
|
app.apply_session_filter();
|
||||||
app.table_state.select(Some(0));
|
app.table_state.select(Some(0));
|
||||||
app.detail_pane = None;
|
app.detail_pane = None;
|
||||||
app.set_status(format!(
|
app.set_status(format!("session {}/{}", app.session_idx + 1, count));
|
||||||
"session {}/{}",
|
|
||||||
app.session_idx + 1,
|
|
||||||
app.sessions.len()
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
KeyCode::BackTab if !app.sessions.is_empty() => {
|
KeyCode::BackTab if !app.sessions.is_empty() => {
|
||||||
|
let count = app.session_count();
|
||||||
app.session_idx = if app.session_idx == 0 {
|
app.session_idx = if app.session_idx == 0 {
|
||||||
app.sessions.len() - 1
|
count - 1
|
||||||
} else {
|
} else {
|
||||||
app.session_idx - 1
|
app.session_idx - 1
|
||||||
};
|
};
|
||||||
app.session_filter = app.sessions.get(app.session_idx).cloned();
|
app.apply_session_filter();
|
||||||
app.table_state.select(Some(0));
|
app.table_state.select(Some(0));
|
||||||
app.detail_pane = None;
|
app.detail_pane = None;
|
||||||
app.set_status(format!(
|
app.set_status(format!("session {}/{}", app.session_idx + 1, count));
|
||||||
"session {}/{}",
|
|
||||||
app.session_idx + 1,
|
|
||||||
app.sessions.len()
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
KeyCode::Down | KeyCode::Char('j') => {
|
KeyCode::Down | KeyCode::Char('j') => {
|
||||||
let count = app.filtered_panes().len();
|
let count = app.filtered_panes().len();
|
||||||
|
|
@ -846,8 +855,71 @@ mod tests {
|
||||||
let mut app = App::new(PathBuf::from("/tmp/nonexistent.sock"));
|
let mut app = App::new(PathBuf::from("/tmp/nonexistent.sock"));
|
||||||
app.snapshot = Some(snap);
|
app.snapshot = Some(snap);
|
||||||
app.rebuild_session_list();
|
app.rebuild_session_list();
|
||||||
|
// Real session ids are sorted + deduped (s1 appeared twice).
|
||||||
assert_eq!(app.sessions, vec!["s1", "s2"]);
|
assert_eq!(app.sessions, vec!["s1", "s2"]);
|
||||||
|
// Default selection is "All sessions" (index 0) — the aggregated view
|
||||||
|
// stays reachable. Regression for the "All sessions unreachable" bug.
|
||||||
|
assert_eq!(app.session_idx, 0);
|
||||||
|
assert!(app.session_filter.is_none());
|
||||||
|
assert_eq!(app.session_count(), 3); // [All, s1, s2]
|
||||||
|
// Index 1 -> s1, index 2 -> s2.
|
||||||
|
app.session_idx = 1;
|
||||||
|
app.apply_session_filter();
|
||||||
assert_eq!(app.session_filter.as_deref(), Some("s1"));
|
assert_eq!(app.session_filter.as_deref(), Some("s1"));
|
||||||
|
app.session_idx = 2;
|
||||||
|
app.apply_session_filter();
|
||||||
|
assert_eq!(app.session_filter.as_deref(), Some("s2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn all_sessions_view_is_reachable_with_sessions_present() {
|
||||||
|
// Regression for the pre-existing bug documented in
|
||||||
|
// GLASSPANE-TUI-ENHANCEMENTS.md: once any pane had a session_id,
|
||||||
|
// rebuild_session_list() forced session_filter = Some(first), making
|
||||||
|
// the aggregated "All sessions" view unreachable. It must now default
|
||||||
|
// to All and stay selectable via Tab.
|
||||||
|
let snap = GlasspaneSnapshot::new(
|
||||||
|
"osa",
|
||||||
|
"2026-06-25T12:00:00Z",
|
||||||
|
vec![
|
||||||
|
colibri_glasspane::Pane {
|
||||||
|
id: "a".into(),
|
||||||
|
agent: "zot".into(),
|
||||||
|
state: AgentState::Working,
|
||||||
|
session_id: Some("s1".into()),
|
||||||
|
last_event_at: None,
|
||||||
|
cwd: None,
|
||||||
|
stalled: false,
|
||||||
|
},
|
||||||
|
colibri_glasspane::Pane {
|
||||||
|
id: "b".into(),
|
||||||
|
agent: "zot".into(),
|
||||||
|
state: AgentState::Working,
|
||||||
|
session_id: Some("s2".into()),
|
||||||
|
last_event_at: None,
|
||||||
|
cwd: None,
|
||||||
|
stalled: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let mut app = App::new(PathBuf::from("/tmp/nonexistent.sock"));
|
||||||
|
app.snapshot = Some(snap);
|
||||||
|
app.rebuild_session_list();
|
||||||
|
// After connect, the operator lands on the aggregated view.
|
||||||
|
assert_eq!(app.session_idx, 0);
|
||||||
|
assert!(app.session_filter.is_none());
|
||||||
|
assert_eq!(app.filtered_panes().len(), 2);
|
||||||
|
// Tab cycles All -> s1 -> s2 -> All.
|
||||||
|
let count = app.session_count();
|
||||||
|
app.session_idx = (app.session_idx + 1) % count;
|
||||||
|
app.apply_session_filter();
|
||||||
|
assert_eq!(app.session_filter.as_deref(), Some("s1"));
|
||||||
|
app.session_idx = (app.session_idx + 1) % count;
|
||||||
|
app.apply_session_filter();
|
||||||
|
assert_eq!(app.session_filter.as_deref(), Some("s2"));
|
||||||
|
app.session_idx = (app.session_idx + 1) % count;
|
||||||
|
app.apply_session_filter();
|
||||||
|
assert!(app.session_filter.is_none(), "wrap back to All sessions");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── render tests (TestBackend) ──
|
// ── render tests (TestBackend) ──
|
||||||
|
|
@ -1341,7 +1413,7 @@ mod tests {
|
||||||
let mut app = App::new(PathBuf::from("/tmp/nonexistent.sock"));
|
let mut app = App::new(PathBuf::from("/tmp/nonexistent.sock"));
|
||||||
app.snapshot = Some(snap);
|
app.snapshot = Some(snap);
|
||||||
app.rebuild_session_list();
|
app.rebuild_session_list();
|
||||||
// Navigate to session s1 (rebuild selects first alphabetically = s1).
|
// rebuild now defaults to "All sessions"; force the view to session s1.
|
||||||
app.session_filter = Some("s1".into());
|
app.session_filter = Some("s1".into());
|
||||||
|
|
||||||
let text = render_text(&mut app, 80, 24);
|
let text = render_text(&mut app, 80, 24);
|
||||||
|
|
|
||||||
|
|
@ -18,17 +18,21 @@ Glasspane doesn't just relay raw agent events. It ingests JSONL lines and
|
||||||
transitions a **named pane** through a finite set of states:
|
transitions a **named pane** through a finite set of states:
|
||||||
|
|
||||||
```
|
```
|
||||||
Idle → Working → Done
|
Idle → Working → Blocked → Done
|
||||||
↳ Error
|
↳ Error
|
||||||
↳ Stalled (no events within a timeout window)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The `AgentState` enum (`Idle, Working, Done, Error, Stalled`) is deliberately
|
The `AgentState` enum (`Idle, Working, Blocked, Done, Error`) is deliberately
|
||||||
small. It captures what a supervisor needs to know — "is the agent working?
|
small. It captures what a supervisor needs to know — "is the agent working?
|
||||||
stuck? finished?" — without encoding agent-specific semantics. Events that don't
|
blocked? finished?" — without encoding agent-specific semantics. Events that
|
||||||
change the state (e.g. a usage report from zot) are recorded in the pane's
|
don't change the state (e.g. a usage report from zot) are recorded in the pane's
|
||||||
metadata but don't affect the state machine.
|
metadata but don't affect the state machine.
|
||||||
|
|
||||||
|
`Stalled` is **not** a sixth variant — it is a derived flag: a pane is stalled
|
||||||
|
when no event has arrived within `DEFAULT_STALL_AFTER` (4 hours). Derived
|
||||||
|
attention (Error / Blocked / Stalled) is covered by
|
||||||
|
[operator-attention](./operator-attention.md).
|
||||||
|
|
||||||
**Why not just tail the log**: raw event logs are agent-specific and change over
|
**Why not just tail the log**: raw event logs are agent-specific and change over
|
||||||
time (zot adds new event types). The state machine is a stable contract that the
|
time (zot adds new event types). The state machine is a stable contract that the
|
||||||
daemon, TUI, and client CLI can all rely on.
|
daemon, TUI, and client CLI can all rely on.
|
||||||
|
|
@ -93,21 +97,11 @@ that's fundamentally about current state, not event delivery.
|
||||||
|
|
||||||
## Usability roadmap (TODO)
|
## Usability roadmap (TODO)
|
||||||
|
|
||||||
Glasspane has the supervision spine — a stable state machine and a snapshot API.
|
The **attention** half of this roadmap shipped: the derived attention
|
||||||
The open work is operator-facing: making *"does this agent need me right now?"*
|
predicate, the TUI attention bar / jump keys / filter / row highlight, and
|
||||||
the primary, impossible-to-miss object. These are captured as direction, not yet
|
edge-triggered terminal-capture alerts. See
|
||||||
built. The data mostly already exists; the work is surfacing and pushing it.
|
[operator-attention](./operator-attention.md) for the shipped system. What
|
||||||
Ideas drawn from agent-cockpit terminal UIs; wiring is our own. Working notes,
|
remains here is the genuinely-unbuilt direction.
|
||||||
reference shortcut vocabulary, and a draft keymap live in
|
|
||||||
[`../GLASSPANE-TUI-ENHANCEMENTS.md`](../GLASSPANE-TUI-ENHANCEMENTS.md).
|
|
||||||
|
|
||||||
### Attention as a first-class derived signal
|
|
||||||
|
|
||||||
Today a pane is `Idle/Working/Done/Error/Stalled`. Promote "needs the operator"
|
|
||||||
into an explicit **attention flag** derived from the existing states (`Error`,
|
|
||||||
`Stalled`, and a future "waiting for input") rather than a sixth state. The state
|
|
||||||
machine stays small; attention is a view over it. The TUI highlights attention
|
|
||||||
rows; the daemon and notifier read the same flag.
|
|
||||||
|
|
||||||
### Push notifications outbound, not just on-screen
|
### Push notifications outbound, not just on-screen
|
||||||
|
|
||||||
|
|
@ -118,12 +112,6 @@ already provisioned). An explicit `colibri notify`-style path — or a glasspane
|
||||||
event type that a zot/Pi hook fires — lets an agent say "I'm blocked" rather than
|
event type that a zot/Pi hook fires — lets an agent say "I'm blocked" rather than
|
||||||
relying only on inferred state. Highest real-world impact item.
|
relying only on inferred state. Highest real-world impact item.
|
||||||
|
|
||||||
### Jump-to-next-attention navigation in the TUI
|
|
||||||
|
|
||||||
A keybinding in `colibri-tui` that cycles to the next attention pane. Trivial
|
|
||||||
given the attention flag; large ergonomic win when supervising many agents.
|
|
||||||
→ `crates/colibri-glasspane-tui/src/main.rs`
|
|
||||||
|
|
||||||
### Richer pane rows (context at a glance)
|
### Richer pane rows (context at a glance)
|
||||||
|
|
||||||
Glasspane already stashes non-state events in pane metadata. Surface that in the
|
Glasspane already stashes non-state events in pane metadata. Surface that in the
|
||||||
|
|
@ -149,4 +137,5 @@ control and needs its own design pass.
|
||||||
## See also
|
## See also
|
||||||
|
|
||||||
- [agent-harness](./agent-harness.md) — the zot/Colibri split that Glasspane observes
|
- [agent-harness](./agent-harness.md) — the zot/Colibri split that Glasspane observes
|
||||||
|
- [operator-attention](./operator-attention.md) — the shipped attention/alert layer over this state machine
|
||||||
- [naming-decisions](./naming-decisions.md) — `pi_session_id → session_id`, `pi_type → event_type`
|
- [naming-decisions](./naming-decisions.md) — `pi_session_id → session_id`, `pi_type → event_type`
|
||||||
|
|
|
||||||
|
|
@ -43,25 +43,27 @@ warning.
|
||||||
|
|
||||||
## Pages
|
## Pages
|
||||||
|
|
||||||
| Page | What it covers |
|
| Page | What it covers |
|
||||||
| ----------------------------------------------------- | --------------------------------------------------------------------------------------------- |
|
| ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
|
||||||
| [agent-harness](./agent-harness.md) | The zot (agent) + Colibri (control plane) split; autospawn + RPC driver |
|
| [agent-harness](./agent-harness.md) | The zot (agent) + Colibri (control plane) split; autospawn + RPC driver |
|
||||||
| [agent-events-reference](./agent-events-reference.md) | Per-harness zot event reference, Glasspane mappings, and verified transcript fields |
|
| [agent-events-reference](./agent-events-reference.md) | Per-harness zot event reference, Glasspane mappings, and verified transcript fields |
|
||||||
| [cost-model](./cost-model.md) | Byte-stable prefixes, cache-hit metering, auto-escalation, T14 compaction |
|
| [cost-model](./cost-model.md) | Byte-stable prefixes, cache-hit metering, auto-escalation, T14 compaction |
|
||||||
| [glasspane](./glasspane.md) | Agent state machine, JSONL streaming, AgentRuntime taxonomy, snapshot API |
|
| [glasspane](./glasspane.md) | Agent state machine, JSONL streaming, AgentRuntime taxonomy, snapshot API |
|
||||||
| [headroom-sidecar](./headroom-sidecar.md) | Optional tool-result compression sidecar and its Unix-socket protocol |
|
| [operator-attention](./operator-attention.md) | The derived "needs the operator" view: attention predicate, TUI bar/jump/filter, edge-triggered terminal alerts |
|
||||||
| [jail-confinement](./jail-confinement.md) | Persistent vs ephemeral jails, priv-mode policy, reuse of spawner confinement for MCP servers |
|
| [headroom-sidecar](./headroom-sidecar.md) | Optional tool-result compression sidecar and its Unix-socket protocol |
|
||||||
| [mother-hive](./mother-hive.md) | Mother MCP architecture — forced-command SSH, single-home-in-colibri, peer auth, key-on-seed |
|
| [jail-confinement](./jail-confinement.md) | Persistent vs ephemeral jails, priv-mode policy, reuse of spawner confinement for MCP servers |
|
||||||
| [naming-decisions](./naming-decisions.md) | Ledger of harness-neutral / architecture renames — shipped and in-flight |
|
| [mother-hive](./mother-hive.md) | Mother MCP architecture — forced-command SSH, single-home-in-colibri, peer auth, key-on-seed |
|
||||||
| [layered-soul](./layered-soul.md) | How Colibri consumes the layered-soul reviewed-context repo today vs planned |
|
| [naming-decisions](./naming-decisions.md) | Ledger of harness-neutral / architecture renames — shipped and in-flight |
|
||||||
| [task-board](./task-board.md) | Capability match scoring, cron scheduling, intake drain, SQLite backing |
|
| [layered-soul](./layered-soul.md) | How Colibri consumes the layered-soul reviewed-context repo today vs planned |
|
||||||
| [quality-gates](./quality-gates.md) | `ci-checks.sh` as the pre-merge gate; why drift reached `main` before |
|
| [task-board](./task-board.md) | Capability match scoring, cron scheduling, intake drain, SQLite backing |
|
||||||
| [contracts](./contracts.md) | Stable JSON schemas (run-manifest, runtime-inventory, provider-smoke), golden tests |
|
| [quality-gates](./quality-gates.md) | `ci-checks.sh` as the pre-merge gate; why drift reached `main` before |
|
||||||
| [store-schema](./store-schema.md) | SQLite coordination schema and migration discipline |
|
| [contracts](./contracts.md) | Stable JSON schemas (run-manifest, runtime-inventory, provider-smoke), golden tests |
|
||||||
| [external-mcp](./external-mcp.md) | MCP bridge for editors + external stdio MCP host; read/write/external-call gates |
|
| [store-schema](./store-schema.md) | SQLite coordination schema and migration discipline |
|
||||||
| [operator-cli](./operator-cli.md) | The `colibri` CLI as a thin typed Unix-socket client over the daemon API |
|
| [external-mcp](./external-mcp.md) | MCP bridge for editors + external stdio MCP host; read/write/external-call gates |
|
||||||
| [tui](./tui.md) | Terminal dashboard client (colibri-tui) vs the colibri-glasspane state machine |
|
| [operator-cli](./operator-cli.md) | The `colibri` CLI as a thin typed Unix-socket client over the daemon API |
|
||||||
| [runtime-inventory](./runtime-inventory.md) | Host runtime inventory + watchdog status reader; additive, read-only integrations |
|
| [tui](./tui.md) | Terminal dashboard client (colibri-tui) vs the colibri-glasspane state machine |
|
||||||
| [skills-catalog](./skills-catalog.md) | Read-only runtime consumer for reviewed Clawdie-AI skill artifacts |
|
| [terminal](./terminal.md) | Terminal capability decision (Kitty, extended-key reporting, tmux passthrough, SSH terminfo) |
|
||||||
| [vault-provision](./vault-provision.md) | Vaultwarden-driven env-file provisioning into jails after agent spawn |
|
| [runtime-inventory](./runtime-inventory.md) | Host runtime inventory + watchdog status reader; additive, read-only integrations |
|
||||||
| [deployment](./deployment.md) | Host installer (clawdie): ZFS layout, rc.d/systemd service, dry-run safety |
|
| [skills-catalog](./skills-catalog.md) | Read-only runtime consumer for reviewed Clawdie-AI skill artifacts |
|
||||||
|
| [vault-provision](./vault-provision.md) | Vaultwarden-driven env-file provisioning into jails after agent spawn |
|
||||||
|
| [deployment](./deployment.md) | Host installer (clawdie): ZFS layout, rc.d/systemd service, dry-run safety |
|
||||||
|
|
|
||||||
126
docs/wiki/operator-attention.md
Normal file
126
docs/wiki/operator-attention.md
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
# Operator attention — "does this agent need me right now?"
|
||||||
|
|
||||||
|
← [index](./index.md)
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
|
||||||
|
Glasspane's supervision spine answers _"what state is this agent in?"_. Attention
|
||||||
|
is the layer on top that answers the question an operator actually asks first:
|
||||||
|
_"does any agent need me **right now**?"_ It is a **derived view** over the state
|
||||||
|
machine and the terminal, surfaced in the TUI and backed by edge-triggered
|
||||||
|
alerts — not a sixth state, not a new subsystem.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### Attention is a view, not a state
|
||||||
|
|
||||||
|
`AgentState` stays small (`Idle`, `Working`, `Blocked`, `Done`, `Error`).
|
||||||
|
"Needs the operator" is a free predicate over it, not another variant:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn needs_attention(pane: &Pane) -> bool {
|
||||||
|
pane.state == AgentState::Error
|
||||||
|
|| pane.state == AgentState::Blocked
|
||||||
|
|| pane.stalled
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Blocked` is included because the state-machine doc comment says it means
|
||||||
|
"waiting on steering / approval / input" — i.e. the agent is parked on the
|
||||||
|
operator. One predicate, consumed by the attention bar, the filter, and the
|
||||||
|
jump keys, so the definition changes in one place.
|
||||||
|
|
||||||
|
`stalled` is itself derived — a pane is stalled when no event has arrived
|
||||||
|
within `DEFAULT_STALL_AFTER` (4 hours). It is rare on purpose: attention is
|
||||||
|
mostly Errors and Blocked panes; Stalled is the "something is deeply wrong"
|
||||||
|
escalation, not a frequent one.
|
||||||
|
|
||||||
|
→ [`crates/colibri-glasspane/src/lib.rs`](../../crates/colibri-glasspane/src/lib.rs)
|
||||||
|
(`AgentState`, `SupervisedPane::is_stalled_at`, `DEFAULT_STALL_AFTER`)
|
||||||
|
|
||||||
|
### The TUI makes attention impossible to miss
|
||||||
|
|
||||||
|
When any pane in the **current view** needs attention, the normal header is
|
||||||
|
replaced by a red-bordered attention bar listing the offending panes. Rows that
|
||||||
|
need attention get an inverted background; the cursor inverts again so the
|
||||||
|
operator can still see which one is selected. Two jump keys (`n` / `N`) cycle
|
||||||
|
forward/backward through attention panes with wrapping, and `a` toggles an
|
||||||
|
attention-only filter. All three operate over the already-filtered pane set.
|
||||||
|
|
||||||
|
**Filter composition is AND.** Attention filter composes with the session
|
||||||
|
filter, so the bar reflects only what the operator is looking at. A 2026-06
|
||||||
|
bug shipped where `has_attention` was computed from the _unfiltered_ snapshot:
|
||||||
|
an error in session `s2` lit the bar while viewing session `s1`, and the bar's
|
||||||
|
own `filtered_panes()` early-return then drew nothing — so the operator lost
|
||||||
|
their header to a blank red box. Fixed by computing attention from
|
||||||
|
`filtered_panes()`; covered by a cross-session render test.
|
||||||
|
|
||||||
|
→ [`crates/colibri-glasspane-tui/src/main.rs`](../../crates/colibri-glasspane-tui/src/main.rs)
|
||||||
|
(`needs_attention`, `render_attention_bar`, `attention_indices`)
|
||||||
|
|
||||||
|
### Terminal capture is the complementary signal
|
||||||
|
|
||||||
|
The state machine is _"what the agent says"_ (structured JSONL events).
|
||||||
|
Terminal capture is _"what the screen shows"_ — the actual text of a pane,
|
||||||
|
triaged for known-broken patterns. A pane can be `Working` while its screen
|
||||||
|
reads `Active: failed (Result: exit-code)`. Attention is a view over **both**.
|
||||||
|
|
||||||
|
A `TerminalRecorder` keeps a bounded frame history (`DEFAULT_HISTORY_CAPACITY`
|
||||||
|
= 256 frames). A frame's identity is the **SHA-256 of its stripped text**, so
|
||||||
|
polling a near-static pane every second collapses into a compact log of actual
|
||||||
|
state transitions instead of thousands of duplicate frames. `capture_tmux_pane`
|
||||||
|
shells out to `tmux` for the capture, but `observe()` takes raw text directly —
|
||||||
|
the dedup and triage logic is fully testable with no terminal attached.
|
||||||
|
|
||||||
|
→ [`crates/colibri-glasspane/src/terminal.rs`](../../crates/colibri-glasspane/src/terminal.rs)
|
||||||
|
(`TerminalRecorder`, `Observation`, `capture_tmux_pane`)
|
||||||
|
|
||||||
|
### Signature triage, data-driven per OS
|
||||||
|
|
||||||
|
A `SignatureSet` scans stripped terminal text and classifies the screen into
|
||||||
|
`failures` / `warnings` / `info` / `healthy` (`Severity::{Error, Warn, Info, Ok}`).
|
||||||
|
Patterns match as case-insensitive substrings; the first hit records a
|
||||||
|
signature and it is not double-reported. Every match carries a human
|
||||||
|
`next_action` and an optional `invoke` (a skill the agent can run to
|
||||||
|
remediate) — a hit is not "something happened" but "here is what it means and
|
||||||
|
what to do".
|
||||||
|
|
||||||
|
The detection engine is **data-driven**: a FreeBSD host and a Linux host load
|
||||||
|
different `Signature` sets but share the same matcher. `SignatureSet::linux_default`
|
||||||
|
ships a small starter set; callers build their own with `SignatureSet::new`.
|
||||||
|
This is the per-OS knob Colibri's capability routing leans on.
|
||||||
|
|
||||||
|
→ [`crates/colibri-glasspane/src/signatures.rs`](../../crates/colibri-glasspane/src/signatures.rs)
|
||||||
|
(`SignatureSet`, `Severity`, `Signature`, `Detection::alertable`)
|
||||||
|
|
||||||
|
### Alerts are edge-triggered, not level-triggered
|
||||||
|
|
||||||
|
A failure/warning signature is reported **only on the frame where it first
|
||||||
|
appears**, not on every subsequent frame that still shows it — returned as
|
||||||
|
`Observation::Recorded { uuid, new_alerts }` with only the newly-fired matches.
|
||||||
|
When the condition clears and later recurs, it fires again. Level-triggered
|
||||||
|
alerts on a 1s poll would re-notify every second for the lifetime of a stuck
|
||||||
|
pane; edge-triggering makes each alert mean "this just started."
|
||||||
|
|
||||||
|
→ [`crates/colibri-glasspane/src/terminal.rs`](../../crates/colibri-glasspane/src/terminal.rs)
|
||||||
|
(`TerminalRecorder::observe`, `Observation::new_alerts`)
|
||||||
|
|
||||||
|
## What is still open
|
||||||
|
|
||||||
|
- **Outbound push.** Attention is surfaced on-screen (the bar, the highlight).
|
||||||
|
The operator supervises headless hosts over Tailscale, not by watching the
|
||||||
|
TUI. Pushing attention **out** — a desktop notification on the live image
|
||||||
|
and a Telegram message — is the highest-impact unfinished piece. Token is
|
||||||
|
already provisioned; transport (`colibri notify` vs. a glasspane event a
|
||||||
|
harness hook fires) is undecided.
|
||||||
|
- **Answering a blocked agent from the dashboard.** The snapshot API is
|
||||||
|
read-heavy by design. A write path ("send input to pane N" over the daemon
|
||||||
|
socket) would let the operator respond to a `Blocked` pane from the TUI.
|
||||||
|
Changes the socket from supervision to interactive control — its own design
|
||||||
|
pass.
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [glasspane](./glasspane.md) — the state machine attention is a view over
|
||||||
|
- [tui](./tui.md) — the dashboard that surfaces attention
|
||||||
|
- [terminal](./terminal.md) — the terminal capability attention's keybindings rely on
|
||||||
101
docs/wiki/terminal.md
Normal file
101
docs/wiki/terminal.md
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
# Terminal — capability, not brand
|
||||||
|
|
||||||
|
← [index](./index.md)
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
|
||||||
|
A decision about **which terminal capability** Colibri's operator surfaces
|
||||||
|
depend on, and why the choice fell on Kitty. The decision is about
|
||||||
|
_capabilities_ (extended-key reporting); Kitty is the instance that provides
|
||||||
|
them. A terminal without the capability is not "wrong" — it just degrades
|
||||||
|
specific keybindings.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
The dashboard client (`colibri-tui`) and the agents it supervises (pi, zot)
|
||||||
|
are keyboard-driven, and several of their bindings rely on distinguishing
|
||||||
|
**modified keys**: `Tab` vs `Shift-Tab` (cycle sessions), `n` vs `N` (next vs
|
||||||
|
previous attention pane), `Enter` (open detail). Terminals built on VTE
|
||||||
|
(xfce4-terminal, GNOME Terminal, Sakura) and Qt-based Konsole **collapse**
|
||||||
|
modifiers: `Shift-Enter`, `Ctrl-Enter`, and `Alt-Enter` all arrive as a plain
|
||||||
|
`Enter`, so two distinct bindings become indistinguishable.
|
||||||
|
|
||||||
|
The recommended terminal is **Kitty**: GPU-accelerated, keyboard-driven, and it
|
||||||
|
reports modified keys via the Kitty keyboard protocol / extended-keys. It is the
|
||||||
|
shipped default on the operator USB, with `xterm` retained as the always-works
|
||||||
|
fallback (Kitty is GPU-only; it cannot start on a headless `bhyve` surface with
|
||||||
|
no GL, so the rescue path falls back to `xterm`).
|
||||||
|
|
||||||
|
→ [`crates/colibri-glasspane-tui/src/main.rs`](../../crates/colibri-glasspane-tui/src/main.rs)
|
||||||
|
(the bindings above)
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### tmux must forward modifiers, not strip them
|
||||||
|
|
||||||
|
Inside tmux the same collapse happens by default: tmux strips modifier
|
||||||
|
information unless told otherwise. The live USB ships a `~/.config/tmux/tmux.conf`
|
||||||
|
that enables passthrough:
|
||||||
|
|
||||||
|
```
|
||||||
|
set -g extended-keys on
|
||||||
|
set -g extended-keys-format csi-u
|
||||||
|
```
|
||||||
|
|
||||||
|
`csi-u` is the most reliable format and needs tmux 3.5+ (the live USB's port
|
||||||
|
is 3.5a). Pre-3.5 tmux (e.g. an older Linux build host) omits the second line
|
||||||
|
and falls back to the xterm `modifyOtherKeys` format, which Colibri also
|
||||||
|
parses. **Where this matters:** `colibri-tui` launched raw (no tmux) gets
|
||||||
|
modified keys natively; the tmux config only matters for the run-inside-tmux
|
||||||
|
workflow.
|
||||||
|
|
||||||
|
→ `crates/colibri-glasspane-tui/src/main.rs` (the event loop's key handling)
|
||||||
|
|
||||||
|
### Raw-kitty vs in-tmux is a real distinction
|
||||||
|
|
||||||
|
Two equally-valid ways to run the dashboard:
|
||||||
|
|
||||||
|
- **Raw** — `kitty /usr/local/bin/colibri-tui`. Kitty reports modifiers
|
||||||
|
directly; no tmux config needed. This is what the live-USB desktop launcher
|
||||||
|
does.
|
||||||
|
- **In tmux** — `kitty` then `tmux` then `colibri-tui`. Now the tmux
|
||||||
|
extended-keys config above is load-bearing; without it, `Shift-Tab` and `N`
|
||||||
|
stop reaching the app.
|
||||||
|
|
||||||
|
The desktop launcher path is raw by design, so the dashboard works without any
|
||||||
|
operator tmux setup. The in-tmux path is for operators who want tabs/splits
|
||||||
|
around the dashboard.
|
||||||
|
|
||||||
|
### The SSH terminfo gotcha
|
||||||
|
|
||||||
|
Kitty sets `TERM=xterm-kitty`. A remote host that has never seen Kitty does not
|
||||||
|
carry that terminfo entry, so `tmux a` fails with `missing or unsuitable
|
||||||
|
terminal: xterm-kitty`. The fix is Kitty's SSH kitten, which copies the terminfo
|
||||||
|
to the remote on connect — used as the `ssh` alias on operator machines. Lying
|
||||||
|
with `TERM=xterm-256color` works but discards the extended-key capability,
|
||||||
|
defeating the reason for the terminal choice.
|
||||||
|
|
||||||
|
### pi surfaces the same requirement
|
||||||
|
|
||||||
|
The pi harness — a spawnable Colibri backend — prints a startup warning when
|
||||||
|
tmux extended-keys is off, because its own bindings (`Enter` to submit,
|
||||||
|
`Shift-Enter` for newline) hit the identical collapse. The decision here is the
|
||||||
|
same one, stated for Colibri's surfaces: pick a terminal that reports
|
||||||
|
modifiers, and configure tmux to forward them.
|
||||||
|
|
||||||
|
## Requirements, stated once
|
||||||
|
|
||||||
|
- A terminal that reports modified keys (Kitty, Ghostty, WezTerm). On the
|
||||||
|
operator USB that terminal is Kitty.
|
||||||
|
- For the in-tmux workflow: tmux 3.5+ with `extended-keys on` +
|
||||||
|
`extended-keys-format csi-u` (pre-3.5: `extended-keys on` only).
|
||||||
|
- For SSH into a Kitty session: the Kitty SSH kitten, or a one-time terminfo
|
||||||
|
install, so `TERM=xterm-kitty` resolves on the remote.
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [tui](./tui.md) — the dashboard whose bindings drive this requirement
|
||||||
|
- [operator-attention](./operator-attention.md) — the jump keys (`n` / `N`)
|
||||||
|
that depend on modifier reporting
|
||||||
|
- [deployment](./deployment.md) — the operator USB that ships Kitty + the tmux
|
||||||
|
config
|
||||||
Loading…
Add table
Reference in a new issue