design cli tui - sinrimin/harness-nexus GitHub Wiki

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.