From 0b9ea33ce9b3d8b31f2459b003bb41dcd0f3f83a Mon Sep 17 00:00:00 2001 From: Sam & Claude Date: Thu, 25 Jun 2026 22:38:32 +0200 Subject: [PATCH 1/3] fix(tui): make the "All sessions" aggregated view reachable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Once any pane carried a session_id, rebuild_session_list() forced session_filter = Some(first), so the operator could never get back to the aggregated "All sessions" view — Tab only cycled individual sessions. Documented as a known bug in GLASSPANE-TUI-ENHANCEMENTS.md. Model the session cycle as [All, s1, s2, ...]: index 0 is a synthetic "All sessions" entry (filter = None), any other index scopes to sessions[i-1]. Two helpers encode the mapping: - session_count() = sessions.len() + 1 (All is always present) - apply_session_filter() maps session_idx -> filter (0 => None) Behavior changes: - On connect, the operator now lands on "All sessions" (was: the alphabetically-first session). The aggregated view is the more useful default and is always reachable via Tab/BackTab. - The position indicator now shows for any cycle > 1 item, so the "All (1 of 2)" hint appears even with a single session. self.sessions still holds real session ids only (no sentinel string), so the sorted/deduped invariant is unchanged. Tests: - rebuild_session_list_dedupes_and_sorts: updated for the new default + offset mapping (index 1 => s1, index 2 => s2). - all_sessions_view_is_reachable_with_sessions_present: new regression test covering connect-defaults-to-All and the Tab cycle All -> s1 -> s2 -> All. - attention_bar_ignores_other_session_panes: comment corrected (rebuild no longer selects the first session). 19/19 TUI tests pass; fmt + clippy (-D warnings) clean. (Sam & Claude) --- crates/colibri-glasspane-tui/src/main.rs | 124 ++++++++++++++++++----- 1 file changed, 98 insertions(+), 26 deletions(-) diff --git a/crates/colibri-glasspane-tui/src/main.rs b/crates/colibri-glasspane-tui/src/main.rs index bc5b8ec..73b4c9f 100644 --- a/crates/colibri-glasspane-tui/src/main.rs +++ b/crates/colibri-glasspane-tui/src/main.rs @@ -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) { let snap = match &self.snapshot { Some(s) => s, @@ -195,17 +212,15 @@ impl App { .collect(); ids.sort(); ids.dedup(); - if ids.is_empty() { - self.sessions.clear(); + self.sessions = ids; + // 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_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) { @@ -312,12 +327,12 @@ impl App { Some(sid) => format!("Session: {sid}"), None => "All sessions".to_string(), }; - let session_span = if self.sessions.len() > 1 { + let session_span = if self.session_count() > 1 { Span::styled( format!( "{session_label} ({} of {})", self.session_idx + 1, - self.sessions.len() + self.session_count() ), 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() => { - app.session_idx = (app.session_idx + 1) % app.sessions.len(); - app.session_filter = app.sessions.get(app.session_idx).cloned(); + let count = app.session_count(); + app.session_idx = (app.session_idx + 1) % count; + app.apply_session_filter(); app.table_state.select(Some(0)); app.detail_pane = None; - app.set_status(format!( - "session {}/{}", - app.session_idx + 1, - app.sessions.len() - )); + app.set_status(format!("session {}/{}", app.session_idx + 1, count)); } KeyCode::BackTab if !app.sessions.is_empty() => { + let count = app.session_count(); app.session_idx = if app.session_idx == 0 { - app.sessions.len() - 1 + count - 1 } else { 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.detail_pane = None; - app.set_status(format!( - "session {}/{}", - app.session_idx + 1, - app.sessions.len() - )); + app.set_status(format!("session {}/{}", app.session_idx + 1, count)); } KeyCode::Down | KeyCode::Char('j') => { let count = app.filtered_panes().len(); @@ -846,8 +855,71 @@ mod tests { let mut app = App::new(PathBuf::from("/tmp/nonexistent.sock")); app.snapshot = Some(snap); app.rebuild_session_list(); + // Real session ids are sorted + deduped (s1 appeared twice). 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")); + 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) ── @@ -1341,7 +1413,7 @@ mod tests { let mut app = App::new(PathBuf::from("/tmp/nonexistent.sock")); app.snapshot = Some(snap); 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()); let text = render_text(&mut app, 80, 24); -- 2.45.3 From 6de41332f63bf482af539b4f6d04cb170d0a9b4b Mon Sep 17 00:00:00 2001 From: Sam & Claude Date: Thu, 25 Jun 2026 22:39:55 +0200 Subject: [PATCH 2/3] chore(ci): add wiki-lint to CI for parity with ci-checks.sh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/ci-checks.sh runs five gates; .forgejo/workflows/ci.yml ran only four — wiki-lint --strict was missing. quality-gates.md states "ci.yml encodes the same checks" as local, which was not quite true. Add the wiki-lint step to the markdown job so CI matches local the day a runner is registered. wiki-lint is pure POSIX sh (grep/awk/sed/find), so it runs in the existing node:20 container — no new image or job. This does not by itself stop drift reaching main: as quality-gates.md notes, no Forgejo Actions runner is registered, so nothing enforces CI server-side today. The local pre-push hook remains the active enforcement layer; this change ensures CI is ready to take over once a runner exists. Verified wiki-lint passes clean on main (137 pass / 0 fail). (Sam & Claude) --- .forgejo/workflows/ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index e85a009..eecebed 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -39,6 +39,12 @@ jobs: - uses: actions/checkout@v4 - name: Markdown format gate 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: runs-on: ubuntu-latest -- 2.45.3 From 20b65f9577bd5a078b423123df5c84074b4484a3 Mon Sep 17 00:00:00 2001 From: Sam & Claude Date: Thu, 25 Jun 2026 22:50:10 +0200 Subject: [PATCH 3/3] docs(wiki): add terminal + operator-attention pages, fix glasspane drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new decisions captured, one page corrected: terminal.md — the terminal-capability decision. Why colibri-tui and the agents it supervises need modified-key reporting (Tab vs Shift-Tab, n vs N, Enter), why the choice fell on Kitty, the tmux extended-keys + csi-u passthrough for the in-tmux workflow, raw-vs-tmux distinction, the SSH xterm-kitty terminfo gotcha, and pi's identical requirement. The decision is about capability; Kitty is the instance. operator-attention.md — the shipped attention system as one decision. Attention as a derived view over the state machine (not a sixth variant), the TUI bar/jump/filter/row-highlight, and the #193 terminal-capture + signature-triage + edge-triggered alerts. Records the has_attention session-filter bug and fix. Lists what is still open (outbound push, answer-from-dashboard). glasspane.md — corrected drift. The real AgentState enum is {Idle, Working, Blocked, Done, Error}; Stalled is a derived flag, not a variant (the page's diagram omitted Blocked and listed Stalled as a variant). The "Usability roadmap (TODO)" listed the attention half as not-yet-built; it shipped via #191/#193, so those items move to operator-attention.md and the roadmap keeps only the genuinely-unbuilt direction. index.md — two table rows (also satisfies the orphan-page check). Verified: prettier-clean on all 4 files; wiki-lint --strict clean (144 pass / 0 fail, up from 137); no dangling refs, no orphans, no resurrected names. (Sam & Claude) --- docs/wiki/glasspane.md | 41 ++++------- docs/wiki/index.md | 46 ++++++------ docs/wiki/operator-attention.md | 126 ++++++++++++++++++++++++++++++++ docs/wiki/terminal.md | 101 +++++++++++++++++++++++++ 4 files changed, 266 insertions(+), 48 deletions(-) create mode 100644 docs/wiki/operator-attention.md create mode 100644 docs/wiki/terminal.md diff --git a/docs/wiki/glasspane.md b/docs/wiki/glasspane.md index 420130c..093879a 100644 --- a/docs/wiki/glasspane.md +++ b/docs/wiki/glasspane.md @@ -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: ``` -Idle → Working → Done +Idle → Working → Blocked → Done ↳ 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? -stuck? finished?" — without encoding agent-specific semantics. Events that don't -change the state (e.g. a usage report from zot) are recorded in the pane's +blocked? finished?" — without encoding agent-specific semantics. Events that +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. +`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 time (zot adds new event types). The state machine is a stable contract that the 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) -Glasspane has the supervision spine — a stable state machine and a snapshot API. -The open work is operator-facing: making *"does this agent need me right now?"* -the primary, impossible-to-miss object. These are captured as direction, not yet -built. The data mostly already exists; the work is surfacing and pushing it. -Ideas drawn from agent-cockpit terminal UIs; wiring is our own. Working notes, -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. +The **attention** half of this roadmap shipped: the derived attention +predicate, the TUI attention bar / jump keys / filter / row highlight, and +edge-triggered terminal-capture alerts. See +[operator-attention](./operator-attention.md) for the shipped system. What +remains here is the genuinely-unbuilt direction. ### 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 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) 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 - [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` diff --git a/docs/wiki/index.md b/docs/wiki/index.md index 4358e83..73a3183 100644 --- a/docs/wiki/index.md +++ b/docs/wiki/index.md @@ -43,25 +43,27 @@ warning. ## Pages -| Page | What it covers | -| ----------------------------------------------------- | --------------------------------------------------------------------------------------------- | -| [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 | -| [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 | -| [headroom-sidecar](./headroom-sidecar.md) | Optional tool-result compression sidecar and its Unix-socket protocol | -| [jail-confinement](./jail-confinement.md) | Persistent vs ephemeral jails, priv-mode policy, reuse of spawner confinement for MCP servers | -| [mother-hive](./mother-hive.md) | Mother MCP architecture — forced-command SSH, single-home-in-colibri, peer auth, key-on-seed | -| [naming-decisions](./naming-decisions.md) | Ledger of harness-neutral / architecture renames — shipped and in-flight | -| [layered-soul](./layered-soul.md) | How Colibri consumes the layered-soul reviewed-context repo today vs planned | -| [task-board](./task-board.md) | Capability match scoring, cron scheduling, intake drain, SQLite backing | -| [quality-gates](./quality-gates.md) | `ci-checks.sh` as the pre-merge gate; why drift reached `main` before | -| [contracts](./contracts.md) | Stable JSON schemas (run-manifest, runtime-inventory, provider-smoke), golden tests | -| [store-schema](./store-schema.md) | SQLite coordination schema and migration discipline | -| [external-mcp](./external-mcp.md) | MCP bridge for editors + external stdio MCP host; read/write/external-call gates | -| [operator-cli](./operator-cli.md) | The `colibri` CLI as a thin typed Unix-socket client over the daemon API | -| [tui](./tui.md) | Terminal dashboard client (colibri-tui) vs the colibri-glasspane state machine | -| [runtime-inventory](./runtime-inventory.md) | Host runtime inventory + watchdog status reader; additive, read-only integrations | -| [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 | +| Page | What it covers | +| ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| [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 | +| [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 | +| [operator-attention](./operator-attention.md) | The derived "needs the operator" view: attention predicate, TUI bar/jump/filter, edge-triggered terminal alerts | +| [headroom-sidecar](./headroom-sidecar.md) | Optional tool-result compression sidecar and its Unix-socket protocol | +| [jail-confinement](./jail-confinement.md) | Persistent vs ephemeral jails, priv-mode policy, reuse of spawner confinement for MCP servers | +| [mother-hive](./mother-hive.md) | Mother MCP architecture — forced-command SSH, single-home-in-colibri, peer auth, key-on-seed | +| [naming-decisions](./naming-decisions.md) | Ledger of harness-neutral / architecture renames — shipped and in-flight | +| [layered-soul](./layered-soul.md) | How Colibri consumes the layered-soul reviewed-context repo today vs planned | +| [task-board](./task-board.md) | Capability match scoring, cron scheduling, intake drain, SQLite backing | +| [quality-gates](./quality-gates.md) | `ci-checks.sh` as the pre-merge gate; why drift reached `main` before | +| [contracts](./contracts.md) | Stable JSON schemas (run-manifest, runtime-inventory, provider-smoke), golden tests | +| [store-schema](./store-schema.md) | SQLite coordination schema and migration discipline | +| [external-mcp](./external-mcp.md) | MCP bridge for editors + external stdio MCP host; read/write/external-call gates | +| [operator-cli](./operator-cli.md) | The `colibri` CLI as a thin typed Unix-socket client over the daemon API | +| [tui](./tui.md) | Terminal dashboard client (colibri-tui) vs the colibri-glasspane state machine | +| [terminal](./terminal.md) | Terminal capability decision (Kitty, extended-key reporting, tmux passthrough, SSH terminfo) | +| [runtime-inventory](./runtime-inventory.md) | Host runtime inventory + watchdog status reader; additive, read-only integrations | +| [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 | diff --git a/docs/wiki/operator-attention.md b/docs/wiki/operator-attention.md new file mode 100644 index 0000000..8db0410 --- /dev/null +++ b/docs/wiki/operator-attention.md @@ -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 diff --git a/docs/wiki/terminal.md b/docs/wiki/terminal.md new file mode 100644 index 0000000..fefe652 --- /dev/null +++ b/docs/wiki/terminal.md @@ -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 -- 2.45.3