# Per-agent terminal: row taxonomy (as built) Snapshot of how the per-agent web UI's live pane renders each event kind today. Every row on the wire is one **`TermMsg`** (`hive-agent/src/term_msg.rs`): `{icon?, level: debug|info|warn|error, summary, body?, body_format?: markdown|diff, coalesce_key?}` — six fields, the same shape for every row, no `kind` tag. A raw claude `stream-json` line (`LiveEvent::Stream`) can classify into zero, one, or several `TermMsg`s (an assistant message with both a text block and a tool_use block produces two); other `LiveEvent` variants (`TurnStart`/`TurnEnd`/`Note`) map straight to one each, and the five agent-state variants (`StatusChanged`/`ModelChanged`/`EffortChanged`/ `TokenUsageChanged`/`TurnStateChanged`) always produce zero — they never render as terminal rows, the header/badges read `/api/state` instead. Classification happens **server-side**, once, in `hive-agent/src/term_msg.rs` (top-level dispatch) + `hive-agent/src/stream_enrich.rs` (the `Stream` payload's per-tool/ per-event breakdown — icon, summary text, expandable body). Both the live SSE tail (`GET /api/events/stream`) and the paginated history replay (`GET /api/events/history`) call the same `classify()` and serve identically-shaped `TermEnvelope { ts, seq?, msgs: TermMsg[] }` frames (`hive-agent/src/web_ui/stream.rs`) — the sqlite event log still stores the raw, unclassified event, so classification logic can change without a DB migration. The frontend (`frontend/packages/agent/src/lib/classifyEvent.ts`) is a thin adapter: `TermMsg` → `StreamRow` (`lib/streamRow.ts`), rendered by `components/Row.tsx`. It does **not** re-derive icons, summaries, or per-tool formatting — that all happened server-side already. Its only real decision is body layout: an empty-`summary` + markdown-`body` row renders as a flat row with just the body (assistant text); every other bodied row is an expandable `
`, opened by default according to the operator's expand-tool-output preference (`getExpandDetailsPref()`, `@hive/shared/prefs.ts`) — uniformly, no per-tool server override. `frontend/packages/shared/src/terminal/terminal.css` supplies the `.live .level-*` colour rules + shared row layout. ## Layout contract Every row — flat `
` and expandable `
` alike — shares one prefix column. The mechanism is `padding-left + negative text-indent` on `.live .row`: the row's first inline box gets pulled back into the column at ~0.5em, and wrapped continuation lines hang under the body, not under the glyph. Rows that carry an icon (the per-tool emoji, `🧠`/`💭` thinking, etc.) set the `StreamRow.icon` prop, which `components/Row.tsx` renders into a fixed-width `.row-glyph` cell (`display: inline-block; width: 1.4em`) rather than as a bare first character. The constant cell width means every icon's left edge lines up in the column regardless of the glyph's rendered width (emoji differ; some carry a variation selector) — a flat row's `🧠` and a `details` summary's `🖥️` align. Rows with a plain single-char glyph (`◆ · ! ←`) still pass it inline; it lands at the same ~0.5em left edge. `
` summaries inherit those metrics. The icon (when present) sits in the `.row-glyph` cell; the summary text lives in a `.summary-text` span and the disclosure caret (`▸` / `▾`) is supplied by CSS `.summary-text::before` so it **leads the text, not the icon** — a leading caret on the icon would push it out of the shared column. Icon-less summaries have no `.row-glyph`, so the caret falls into the prefix column like the old directional glyph. The summary text carries no `→` / `←`; the row colour (cyan = outbound, muted = inbound) carries the direction. Child blocks inside a row (the `.md` markdown wrapper, an inner `
`) get `text-indent: 0` so their content lays out from the body column instead of inheriting the parent's negative pull. ## Row taxonomy Every row carries a **level** (`debug`/`info`/`warn`/`error`), which is the *only* thing that drives its colour — `frontend/packages/shared/src/ terminal/terminal.css`'s `.live .level-debug/-info/-warn/-error` rules. There's no separate per-row-kind class any more (no `.turn-start`, `.tool-use`, `.tool-result.error`, `.sys`, …) — structural identity (this is a turn boundary, this is a tool call, this is an error) is carried by the row's **icon** and **summary text** instead, computed server-side once and read verbatim by the client. | Level | Colour | Used for | |---|---|---| | `debug` | muted | thinking, thinking-token ticks, plugin-install/status ticks, ambient harness chatter, unrecognised-system-subtype notes | | `info` | default fg | turn start/ok, assistant text, tool calls + their (non-error) results, operator-initiated notes | | `warn` | amber, left rule | stderr lines, an unrecognised stream-json shape, API retry backoff | | `error` | red, left rule | turn failed, a tool result with `is_error: true`, a hard API error | Representative summaries (icon + text, `hive-agent/src/term_msg.rs` + `stream_enrich.rs`): | Row | icon | level | summary | body | |---|---|---|---|---| | turn start | `◆` | info | `TURN ← ` | the wake-prompt text (plain) | | turn ok | `✅` | info | `turn ok` | — | | turn failed | `❌` | error | `turn fail — ` | — | | assistant text | — | info | *(empty)* | the text itself (markdown) — renders as a flat row, no prefix line | | thinking | `💭` | debug | the thinking text (or `thinking …`) | — | | thinking-token tick | `🧠` | debug | `thinking… ~N tokens` (`coalesce_key: "thinking-tok"`, in-place updates) | — | | tool call | per-tool, see [salient-arg formatting](#salient-arg-formatting) | info | ` ` | some tools: diff (Edit) / markdown (`send`) / plain (long `mcp__bash__run` cmd) | | tool result, short | `←` | info | the trimmed result text | — | | tool result, long | — | info | `Nl · headline…` | the full text (plain) | | tool result, `recv`-correlated | — | info | `recv ← ` | the full text (markdown) | | tool result, error (short) | `✗` | error | the trimmed error text | — | | tool result, error (long) | — | error | `Nl · headline…` | the full error text (plain) | | subagent task start/notify (dead path — `Task` not in the agent allow-list) | `⌁` | info | `task started · [type]` / `task ✓/✗/◌ · · → ` | — | | plugin install / status tick | — | debug | `⚙ plugin install · loading…\|✓ done` / `⚙ status` (both `coalesce_key`-collapsed) | — | | commands changed | — | debug | `⚙ commands changed · N available` | one `/name` per line (plain) | | compact boundary / API retry / API error / unrecognised system subtype | — | debug / warn / error / debug | `⚙ compact · …` / `⚠ api retry · …` / `✗ api error · …` / `⚙ ` | — | | harness note | — | debug | the note text | — | | stderr line | — | warn | `stderr: ` | — | | operator-initiated note | — | info | `operator: ` | — | | unrecognised stream-json shape | `!` | warn | trimmed raw JSON | — | Whether a bodied row renders open or collapsed is a **client-only** decision — the operator's expand-tool-output preference (`getExpandDetailsPref()`), applied uniformly to every row with a body. There's no server-side per-tool override any more (the old `recv`/`send` "always default-open" special case is gone). ## Classification pipeline 1. `hive-agent/src/term_msg.rs::classify()` is the top-level dispatch, called once per `LiveEvent` at SSE-emit time (both the live tail and history replay — see `web_ui/stream.rs`): - `TurnStart`/`TurnEnd`/`Note` map straight to one `TermMsg` each. - The five agent-state variants (`StatusChanged`/…) always produce zero — never rendered as terminal rows. - `Stream(value)` delegates to `stream_enrich::classify_stream_value`. 2. `classify_stream_value` walks one raw claude `stream-json` line: - top-level `result` / `rate_limit_event` → dropped (no rows). - `type: "system"` → `classify_system`, which computes `(category, summary, body)` per `subtype` (`system_fields()`) and maps `category` to a level + optional `coalesce_key`: `"thinking_tok"` → debug, coalesced; `"details"` (currently just `commands_changed`) → debug, with an expandable body; `"note"` → level varies by subtype (`api_error` → error, `api_retry` → warn, `plugin_install`/`status` → debug + coalesced, everything else → debug). - `subtype: "task_started" | "task_notification"` (regardless of top-level `type`) → `classify_task_event`. - `type: "assistant"` → walk `message.content[]`: `text` → info row, empty summary, markdown body; `thinking` → debug row with `💭`; `tool_use` → `classify_tool_use` (records `id → name` in the connection/page-scoped `ClassifyCtx` for the next step, computes icon + summary via `fmt_tool_use()`/`tool_icon()`, and a body via `rich_tool_body()` for the fixed set of tools that have one). - `type: "user"` → walk `message.content[]` for `tool_result`: `classify_tool_result` correlates `tool_use_id` against the `ClassifyCtx` to spot a `recv` result (rendered `recv ← …` with a markdown body), strips the `` wrapper on errors, and otherwise picks short-flat vs. long-with-body by length. - Unrecognised shape → warn row, trimmed raw JSON, `!` icon. 3. `frontend/packages/agent/src/lib/classifyEvent.ts::classifyEvent` maps each `TermMsg` in the envelope to a `StreamRow` (`lib/streamRow.ts`) — `level` → `level-*` CSS class, `body_format` → which of `markdown`/`diff`/`plain` body prop to set, empty-summary markdown body → the flat "assistant text" shape, everything else with a body → an expandable `
` gated by the operator's preference. `components/Row.tsx` renders the result. `ClassifyCtx` (tool_use `id → name` correlation, needed for step 2's `recv` detection) is scoped per SSE connection on the live path and per page on the history path — it does not cross the live/history boundary, so a `recv` result whose `tool_use` fell on the other side of a reconnect or page load renders as a plain block instead of default-open markdown. Accepted, documented degradation (`hive-agent/src/term_msg.rs`'s `ClassifyCtx` doc), not a bug. ### Salient-arg formatting Server-side (`fmt_tool_use()` and its per-tool-family helpers in `hive-agent/src/stream_enrich.rs`), computed into the `TermMsg`'s `summary` field and read verbatim by the client. The `short` name strips the `mcp__hyperhive__` / `mcp__bash__` / `mcp__matrix__` prefix and appends `*` (e.g. `recv*`, `run*`, `send_message*`). Unprefixed tools (Read, Write, etc.) keep their name as-is. | Tool | Rendered as | |------|-------------| | **Claude built-ins** | | | `Read` | `Read ` | | `Write` | flat, same shape as Read: `Write ` — no diff/count (`content` can be megabytes and is one-sided; open the file to inspect it) | | `Edit` | rich diff row `Edit · -N +N` (just `+N` for a pure insert, i.e. empty `old_string`) | | `Glob` | `Glob ` | | `Grep` | `Grep ` | | `Bash` | `Bash [bg] $ ` (dead path — built-in `Bash` isn't in the agent allow-list either; shell execution goes through `mcp__bash__run` / `run*` below instead) | | `TodoWrite` | `TodoWrite (N items)` (dead path — `TodoWrite` isn't in the agent allow-list; its state lives in claude's in-process session and evaporates on `/compact`, so agents plan in `/state` notes instead) | | **Core hyperhive** | | | `send*` | rich renderer: `send* → to · NL` (markdown body, expand state now follows the operator's preference like every other bodied row — see [Row taxonomy](#row-taxonomy)) | | `recv*` | `recv*()` · `recv* wait Ns` · `recv* max N` | | `remind*` | `remind* +Xm "preview"` or `remind* at HH:MMZ "preview"` | | `set_status*` | `set_status* "text"` | | `get_loose_ends*` | `get_loose_ends*()` or `get_loose_ends* [agent]` | | `get_agent_meta*` | `get_agent_meta*()` or `get_agent_meta* name` | | `cancel_loose_end*` | `cancel_loose_end* kind #id` | | `ack_until*` | `ack_until* ≤N` | | `mark_todos_done*` | `mark_todos_done* [id1, id2, …]` (first 8 ids, `…` past that) | | **Lifecycle** | | | `kill*/restart*/start*/update*` | `kill* name` (etc.) | | `get_logs*` | `get_logs* name` or `get_logs* name NL` | | `get_host_journal*` | `get_host_journal*()` or with `[container] · [/grep/] · NL` | | **Approvals / config** | | | `request_init_config*` | `request_init_config* name` | | `request_update_meta_inputs*` | `request_update_meta_inputs* [inp1, …]` or `all` | | **Scheduling** | | | `list_schedules*` | `list_schedules*()` | | `cancel_schedule*` | `cancel_schedule* #id all` or `#id [t1, t2]` | | `fire_schedule_now*` | `fire_schedule_now* #id` | | `edit_schedule*` | `edit_schedule* #id · body · interval · next · +N tgt · -N tgt` (only changed fields shown) | | `request_schedule_prompt*` | `request_schedule_prompt* → t1, t2 at HH:MMZ` (+ `+Ns` if recurring) | | **Bash MCP** | | | `run*` | `run* [bg] $ cmd` (also rich renderer for full cmd body) | | `status*` (bash) | `status* id:xyz` or `status* id:xyz · wait Ns` | | `kill*` (bash) | `kill* id` or `kill* id [force]` | | **Matrix MCP** | | | `send_message*/send_dm*/send_reply*` | `send_message* → room: "body"` / `send_dm* → @user: "body"` | | `send_reaction*` | `send_reaction* room emoji` | | `read_room*` | `read_room* room` or `read_room* room [N]` | | `mark_read*` | `mark_read* room` | | `join_room*/open_dm*` | `join_room* room` / `open_dm* @user` | | `invite_user*` | `invite_user* @user → room` | | `download_file*` | `download_file* room` | | **Everything else** | `fmt_args_generic` — see [Extra-MCP tools](#extra-mcp-tools) | ## Markdown `frontend/packages/agent/src/lib/markdown.ts`'s `renderMarkdown(text)` runs `marked.parse(text)` through DOMPurify and is rendered into a `
` by `components/Row.tsx`'s `MarkdownBody`. CSS in `terminal.css` scopes paragraph / code / list / blockquote / link styling under `.live .row .md` so the markdown body doesn't bleed into the row's own text-indent. Applied to any `TermMsg` with `body_format: "markdown"` — assistant text, `send`'s body, a `recv`-correlated tool result. ## Extra-MCP tools `fmt_args_generic(name, input)` (`hive-agent/src/stream_enrich.rs`) is the fallback when a tool isn't in the built-in `fmt_tool_use` switch, computed into the `TermMsg`'s `summary` field server-side: - single string field → `name k: "v"` - single number/bool field → `name k: v` - multi-field → first 4 pairs trimmed to `k: "v"` / `k: [N]` / `k: {…}` with a `…+N` overflow This keeps less-frequent tools that don't have a specific `fmt_tool_use` case from dumping raw JSON. Common matrix and hyperhive tools have their own cases and skip this path. ## Dashboard side (not covered here) The main dashboard's message-flow pane is a different shape: broker messages render as `.msgrow` grid lines (ts / arrow / from / → / to / body) with their own styling. `.live .msgrow` explicitly resets `text-indent: 0` so the per-agent terminal's hanging-indent metrics don't leak into the flex-grid broker rows.