# CLI TUI mode — `hnx tui` (issue #39, shipped 2026-09-30) The daemon's live console. One process runs BOTH the daemon (in-process, via `startDaemon`) and an nmon-style stacked-band dashboard: a machine metrics bar, four independently toggleable panes (agents & sessions, ops log, comm log, token usage), and a keybar. Non-TTY callers get a one-shot text snapshot instead of a broken alt-screen. Answers "what is the daemon doing on this machine right now" — including the session-created-then-disconnect class of bug (#38's logbook made the evidence durable; the TUI makes it live). ## Process model - `hnx tui` replaces `hnx daemon` for an interactive session — same identity merge/persist from `~/.hnx/config.json`, same `/ctl` attachment, same capabilities. It is a *view*, not a second daemon: nothing else changes. - `runDaemon` split into `startDaemon` (socket + chat handle + lock, no process-lifetime ownership) and the classic wait-for-SIGINT wrapper. The TUI composes `startDaemon` + renderer. - **Daemon lock** (`~/.hnx/daemon.lock`, advisory, pid-based): a machine may run ONE daemon-owning process (`hnx daemon` or `hnx tui`). Not cosmetic — job dispatch is a broadcast into the machine's /ctl room, so two attached daemons would double-run every deploy/install job. A live holder refuses (`DAEMON_LOCKED` + pid); a dead holder (SIGKILL/OOM) is stolen. Pid reuse can in theory pin a lock on an unrelated process; the failure mode is a refusal with a pid to inspect, never a double dispatch. `hnx mcp serve` takes no lock (it is not a /ctl presence). - **#45 hardening** (after the survey incident — four concurrent daemons triple-billed one prompt): lock creation is ATOMIC (`O_CREAT|O_EXCL`; concurrent starts have exactly one winner, steal retries converge the same way). The lock alone is still deletable, so the SERVER enforces the same invariant: the `/ctl` handshake refuses a machine whose room already holds a live socket (`machine already connected`), plus a `ctl:duplicate` post-handshake fence for the same-tick race. The daemon self-retries such refusals with backoff — Socket.IO treats a middleware refusal as PERMANENT (tears the manager down; without the retry a transiently refused daemon exited silently with code 0) — and stands down (clear error, exit 1) after 10 straight refusals, on `ctl:duplicate`, or on a server-initiated disconnect. `CtlDuplicateEvent` lives in `shared/realtime.ts`. - **Console capture**: while the TUI owns stdout, the global `console.*` is redirected to logbook `log` entries (ops pane renders them, ops.log persists them) — the daemon's own boot banner and any stray dependency print would otherwise land mid-frame and corrupt the alt screen. Restored on exit before the goodbye line. ## The screen ``` hnx tui — machine Kz7wQ2xR · daemon 0.1.0-alpha.7 · server 127.0.0.1:17890 · up 2s · ses 0 load 0.37 0.26 0.24 · cpu 14% · mem 2.9G/3.6G · disk 41.9G/58.9G · rss 104.5M ┌─ agents & sessions ──────────────────────────────────────────────── 1 ┐ │warm claude-code ready 41s │ │claude-code a1b2c3d4 sonnet-4.6 BUSY 12m 18t 412.0k/21.0k │ ├─ ops log ─────────────────────────────────────────────────────────── 2 ┤ │10:41:32 [log] hnx daemon: online (cli 0.1.0-alpha.7, proto 1, …) │ ├─ comm log ────────────────────────────────────────────────────────── 3 ┤ │10:41:33 tx chat:event ×200 total 402.0KB over 3.4s │ ├─ tokens (this daemon run) ────────────────────────────────────────── t ┤ │target/model in out cacheW cacheR ses turns │ └ 1 agents · 2 ops · 3 comm · t tokens · m metrics · a all · space refresh · q quit ┘ ``` Keys (nmon-style): `1`/`2`/`3`/`t` toggle panes, `m` the metrics bar, `a` all-on/all-off, `space` force refresh, `q`/Ctrl-C quit. Toggled-off panes show bracketed in the keybar: `(2) ops`. Layout: stacked full-width bands (no side-by-side split at any width — resize-safe math, and nmon itself is the reference). Header 1 row, metrics 1 row, keybar 1 row; visible panes need ≥3 rows each (border + one content row); a too-short terminal drops TRAILING panes deterministically instead of crushing them, and leftover rows are dealt round-robin (5 spare rows over 4 panes → 5,4,4,4 — no hole above the keybar). Degradation: `--dump` (or stdout not a TTY, automatically) prints a one-shot text snapshot — sessions, prewarm, token rows, recent ops/comm — after a 1.5s settle, then exits 0. Scripts get data, never a broken screen. ## Data sources | Pane | Source | Cadence | | --- | --- | --- | | header | `DAEMON_VERSION`, machine id, server host, session count | 1s poll | | metrics | `node:os` — loadavg, cpu% (cpu-times delta; first sample `--`), mem, `statfs` disk of `~`, daemon RSS | 2s | | agents | `ChatHandlersHandle.sessionsSnapshot()` (live channels: target/sid/model/busy/age/turns/in-out) + `prewarmStatus()` (pool entries with age) | 1s poll | | ops | logbook bus `op` + `log` entries (seeded from the ring) | event | | comm | logbook bus `comm` entries through the pane's OWN `CommAggregator` — same collapse as the file view, seeded from the ring | event | | tokens | `UsageLedger.rows()` (per target×model) + per-session totals inside `sessionsSnapshot` | 1s poll | New introspection surface (all in `attachChatHandlers`' returned handle): - `sessionsSnapshot(): SessionView[]` — the live `DaemonSession` map mapped to a renderable shape; **model = the W9 config snapshot's `model`-category select's `currentValue`**, falling back to the W13 configured set's head. - `prewarmStatus(): PrewarmView[]` — `PrewarmPool.status()` (entries gained `bornAt`). - `usage: UsageLedger` — see below. ## Token accounting Rewritten by #44 after a five-target empirical dialect survey (issue #44 has the captured payloads). The ACP spec's `usage_update` is session-level context occupancy + CUMULATIVE cost ONLY — per-turn token counts are not in the protocol today (the end-turn-token-usage RFD is still Draft, and its v1 carrier is the `session/prompt` RESPONSE, which we now read). - **Dialect pick** (`daemon/usage.ts` `normalizeUsage`): one place knows every upstream spelling — `inputTokens`/`input`, `outputTokens`/`output`, cacheRead ← `cachedReadTokens` (ACP strawman + claude wrapper response) | `cacheReadInputTokens` | `cache_read_input_tokens` | `cachedInputTokens` | `prompt_cache_hit_tokens`, cacheWrite ← `cachedWriteTokens` | `cacheCreationInputTokens` | `cache_creation_input_tokens` | `cacheWriteInputTokens`. deepseek's `prompt_cache_miss_tokens` deliberately does NOT map to cacheWrite ("uncached input" ≠ "cache write"). All mapping layers funnel through it: `mapAcpUpdate`'s `usage_update` arm, the `session/prompt` response capture, the dsh commit mapper, the pi connection's `message_end`. - **Schema** (additive): `usage` events gained `cacheReadTokens`/ `cacheWriteTokens` + `costUsd` (from `usage_update.cost.amount`, USD only); `turn_result` events gained an optional nested `usage` object — the RFD's v1 shape. - **Turns count ONLY on `turn_result`** — the one universal per-turn signal. Counting usage events too would double every dsh/pi/claude turn. - **Response usage** (`session/prompt` result) is applied per KNOWN dialect only (`responseUsageMode`): claude-code = session-CUMULATIVE (the wrapper's `sessionUsage()` off `accumulatedUsage`) → delta-accounted with reset-re-credit; opencode = PER-TURN → summed. Unknown targets are ignored outright — counting an unverified dialect (or one that also reports through usage events) would double-count. - **`cost.amount` is session-cumulative by spec** → delta-accounted into per-(target, model) `costUsd`; the TUI pane gains a `$` column. - Per-session subtotals key off the W9 config's model select; a mid-session model switch re-labels and restarts the delta baselines. History replays ride `emitHistory`, never `emitEvent`, so a resync cannot double-count. Daemon-lifetime, in-memory — nothing persists across restarts. - **Survey results** (2026-10-01, real turns): dsh + pi report real in/out token counts through usage events; claude's usage_update is occupancy + cumulative cost (0 through gateway models that return no usage) with full token+cache totals on the prompt RESPONSE; codex's wire is occupancy-only (Rust binary, unpatchable). - **opencode ground truth** (source-verified against the 1.18.31 binary AND master `acp/service.ts`+`acp/usage.ts`, 2026-09-30): the prompt RESPONSE ALWAYS carries per-turn usage in the RFD shape (`buildUsage`: in/out/total/ thought/cache-read/cache-write — no cost) → our `perTurn` mapping is right. The per-turn `usage_update` (`{used, size, cost: cumulative USD}`) is CONDITIONAL: upstream silently skips it (`if (!size) return`) when the model's context limit is unknown — which is exactly the case for custom providers (`"model": {}` with no `limit.context` — the shape our rig's custom provider has). The round-1 "emitted nothing" reading was our rig's combination: pre-#44 build didn't read response usage + custom provider hit the silence; not version drift (upstream #30118 is about emitting MORE often, mid-stream). Rig note: adding `"limit": {"context": N}` to the provider's model entry makes usage_update flow. ## Renderer (zero dependencies) No ink (React dep tree) and no blessed (unmaintained) — the CLI must stay npx-light, and an nmon-style fixed layout needs ~400 lines, not a framework: - `tui/width.ts`: pragmatic wcwidth — CJK/Hangul/fullwidth/emoji planes are 2 cells, combining marks/ZWJ are 0; `truncateToWidth` clamps (pane lines NEVER wrap — a wrap would push the frame past the terminal and smear every pane below). - `tui/layout.ts`: pure geometry (unit-tested), described above. - `tui/screen.ts`: alt screen + hidden cursor, raw keys (single printable chars; Ctrl-C as 0x03 AND as the SIGINT fallback; every escape sequence swallowed), `stdout.on('resize')` (cross-platform — no SIGWINCH needed), one-`write` full-frame repaint (`\x1b[H` + rows + `\x1b[K` per row + `\x1b[J` tail) so a frame cannot tear. Monochrome v1 — no ANSI colors in frame lines, which keeps width math trivially correct. - `tui/metrics.ts`: zero-dep sampler + the shared humanizers (1024-based bytes, matching #38's comm log spelling). ## Verification - Unit: `usage.test.ts` (dialect pick, sum semantics, model switch, sorting), chat round-trip vs the fixture agent (`sessionsSnapshot` + ledger through a real turn), `tui-base.test.ts` (width tables, layout geometry, formatters), `tui-panes.test.ts` (column discipline, box math, honesty `-`), plus `daemon-lock.test.ts` (grant/refuse/steal) and the logbook additions (`logLine`, `CommAggregator` as a second view). - Rig (tmux pty, scratch memory server + scratch HOME): first frame renders all panes with real traffic; the captured `daemon: online` banner lands in the ops pane; keys 2/t/a verified (pane counts 4→3→0→4); `q` exits clean (screen restored, socket closed, lock file gone); `--dump` prints the snapshot; a second daemon while the TUI runs refuses with `DAEMON_LOCKED` + pid. - Not yet rig'd: Windows terminals (paths are conservative — `resize` event, no SIGWINCH assumption; raw mode is supported by Node on Windows). ## v2 candidates (explicitly not built) Pane scrolling (the 500-entry ring already bounds memory), side-by-side split on very wide terminals, persistent/period-bucketed token history, adapter child-process RSS (needs ps), colors, mouse.