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
|
||||
- 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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
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