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 tuireplaceshnx daemonfor an interactive session β same identity merge/persist from~/.hnx/config.json, same/ctlattachment, same capabilities. It is a view, not a second daemon: nothing else changes.runDaemonsplit intostartDaemon(socket + chat handle + lock, no process-lifetime ownership) and the classic wait-for-SIGINT wrapper. The TUI composesstartDaemon+ renderer.- Daemon lock (
~/.hnx/daemon.lock, advisory, pid-based): a machine may run ONE daemon-owning process (hnx daemonorhnx 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 servetakes 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/ctlhandshake refuses a machine whose room already holds a live socket (machine already connected), plus actl:duplicatepost-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, onctl:duplicate, or on a server-initiated disconnect.CtlDuplicateEventlives inshared/realtime.ts. - Console capture: while the TUI owns stdout, the global
console.*is redirected to logbooklogentries (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 liveDaemonSessionmap mapped to a renderable shape; model = the W9 config snapshot'smodel-category select'scurrentValue, falling back to the W13 configured set's head.prewarmStatus(): PrewarmView[]βPrewarmPool.status()(entries gainedbornAt).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.tsnormalizeUsage): 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'sprompt_cache_miss_tokensdeliberately does NOT map to cacheWrite ("uncached input" β "cache write"). All mapping layers funnel through it:mapAcpUpdate'susage_updatearm, thesession/promptresponse capture, the dsh commit mapper, the pi connection'smessage_end. - Schema (additive):
usageevents gainedcacheReadTokens/cacheWriteTokens+costUsd(fromusage_update.cost.amount, USD only);turn_resultevents gained an optional nestedusageobject β 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/promptresult) is applied per KNOWN dialect only (responseUsageMode): claude-code = session-CUMULATIVE (the wrapper'ssessionUsage()offaccumulatedUsage) β 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.amountis 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, neveremitEvent, 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) β ourperTurnmapping is right. The per-turnusage_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 nolimit.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;truncateToWidthclamps (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-writefull-frame repaint (\x1b[H+ rows +\x1b[Kper row +\x1b[Jtail) 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-), plusdaemon-lock.test.ts(grant/refuse/steal) and the logbook additions (logLine,CommAggregatoras a second view). - Rig (tmux pty, scratch memory server + scratch HOME): first frame renders
all panes with real traffic; the captured
daemon: onlinebanner lands in the ops pane; keys 2/t/a verified (pane counts 4β3β0β4);qexits clean (screen restored, socket closed, lock file gone);--dumpprints the snapshot; a second daemon while the TUI runs refuses withDAEMON_LOCKED+ pid. - Not yet rig'd: Windows terminals (paths are conservative β
resizeevent, 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.