The model was enumerating options inside the question string (dead prose the UI
can't render as pickable rows). Schema description now spells out: choices[] is
REQUIRED for selectable options; question holds ONLY the question.
Ink had no continuous memory trace (only point-in-time heapdumps + a threshold
monitor), so HERMES_TUI_DIAGNOSTICS=1 gave OpenTUI dogfood data with no Ink
equivalent. Port OpenTUI's memlog collector to Ink so both engines emit
byte-identical ~/.hermes/logs/memwatch/<boot>-<pid>.jsonl traces feeding one
memwatch-report.mjs.
- lib/memlog.ts: 1Hz unref'd sampler, {t,rss_kb,heap_used_kb,external_kb}
(no mounted — Ink has no windowing), 14-day prune, silent-disable on error
- gated by HERMES_TUI_MEMLOG defaulting to the HERMES_TUI_DIAGNOSTICS master
switch (same as OpenTUI — one export covers both engines)
- wired into entry.tsx alongside the existing monitor; stop on beforeExit
- lib/memlog.test.ts: gate/schema/retention/silent-disable (7 tests)
- docs/ink-env-flags.md (new) + docs/opentui-env-flags.md updated
The default lumpy-turn fixture's tool bodies are tiny (2/7/18 short lines), so
W3 (HERMES_TUI_TOOL_OUTPUTS=off) shows no RSS delta at realistic sizes — the
saved bytes sit below the ~20MB run-to-run noise floor. This adds an env knob
to scale every tool result body to N lines, making the retention asymmetry
measurable in the bench (a `find /` / big-file-read class fixture).
UNSET = the original tiny bodies, byte-identical — existing benches and the
determinism digest are unaffected. Set HERMES_BENCH_TOOL_BODY_LINES=N (the
bench harness inherits it at fixture-generation time) for a fat run.
Measured with this (mem300, ~100KB/tool, ~27MB retained tool text): OpenTUI
OFF 244-259MB vs ON 260-261MB vs Ink 269-279MB — i.e. W3 OFF is a real
~5-15MB win at heavy output, and OpenTUI's windowing actually beats Ink at
scale (Ink mounts every row; OpenTUI windows).
OpenTUI-only by design (verified: Ink never calls global.gc proactively — it
only exposes it for heapdumps; spec D5 sanctions the divergence since this is
opt-in). Default / unconstrained sessions do NOTHING.
boundary/proactiveGc.ts: a low-frequency watcher that calls global.gc() only
when ALL hold: (a) the low-mem opt-in is active — HERMES_TUI_HEAP_MB set at or
below 4096 (the same W1 knob; HERMES_TUI_PROACTIVE_GC can force on/off), AND
global.gc is exposed (W1's --expose-gc — else a silent no-op); (b) a turn is
NOT streaming (reads the store's info.running) so it never collects mid-reply;
(c) a full idle window has elapsed since the last activity. Eagerness: once RSS
crosses 400MB the idle window tightens (8s→3s) but STILL waits for idle — never
a mid-stream pause (the jank the campaign fought). Reuses
process.memoryUsage().rss (same read as memlog); the timer is unref'd and every
failure path disables silently. Wired in entry/main.tsx next to startMemlog,
scoped acquire→release.
Tests: gating (low-cap-on / high-cap-off / no-gc-off / explicit on|off) +
timing (fires after the idle window, never while streaming, stop() halts).
proactiveGc.test.ts 10 passed. npm run check OK (785 tests). Verified live that
the gate enables under HERMES_TUI_HEAP_MB=256 and global.gc is callable.
Low-mem enabler — default unchanged (8192 for both engines), low blast radius.
- Heap knob honored by BOTH engines via the shared NODE_OPTIONS injection:
HERMES_TUI_HEAP_MB env (highest precedence, matches the HERMES_TUI_ENGINE
env-first pattern) > display.tui_heap_mb config (minimal early YAML read,
mirrors _config_tui_engine_early) > the existing cgroup-aware default. The
override REPLACES the 8192 default inside _resolve_tui_heap_mb (D3): low =
the low-mem opt-in, high = raise the ceiling. The cgroup-fit 75% clamp still
applies on top, so an override never exceeds the container. A non-secret
behavioral setting → config.yaml, NOT the denylisted NODE_OPTIONS bridge.
- --expose-gc added to the OpenTUI argv in _make_opentui_argv (D4, parity with
Ink which already has it). Must be an argv flag — Node rejects --expose-gc in
NODE_OPTIONS. Makes global.gc() a real call so the engine's GC hooks
(/heapdump; W2's proactive idle GC) work instead of silent no-ops. Verified:
`node --expose-gc -e 'typeof global.gc'` → "function" (vs "undefined").
Tests: TestHeapOverride (env>config precedence, cgroup clamp on a too-high
override, low override honored under a big container, garbage/non-positive
fall-through) + TestExposeGcOnOpenTuiArgv. test_tui_heap_sizing.py 29 passed.
The biggest real memory lever: OpenTUI retained full resultText + the raw
result dict + the args dict per tool call, while Ink discards tool bodies
(keeps only a short context line). That retention asymmetry is the bulk of the
Ink-vs-OpenTUI memory gap.
New `HERMES_TUI_TOOL_OUTPUTS` flag (toolOutputsEnabled() in env.ts, default
ON — the rich tool cards are OpenTUI's differentiator). When OFF, the
tool.complete reducer (store.ts) neither BUILDS nor STORES the body: skip the
whole result_text/result stringify+envelope-strip work and suppress
part.resultText / part.result / part.args / part.argsText / part.lineCount /
part.omittedNote. KEPT either way: name, state, duration, error, summary,
argsPreview (the redaction-safe one-liner from tool.start context = Ink's
context line), and the file-edit diff (diffUnified/diffStats — a diff is a
high-value surface, not generic "output"). Render is automatic: with no
resultText/result, defaultRenderer.expandable() is false → header-only row =
Ink parity, no extra view gating needed.
This powers the bench's fair Ink-vs-OpenTUI comparison (D8 — launch OpenTUI
with outputs off so both engines are body-less = pure engine overhead) and the
low-mem mode.
Tests: store retains rich outputs by default; OFF drops the bodies but keeps
name/duration/error/argsPreview/diff. npm run check OK (770 tests).
Bump the three @opentui pins (core/keymap/solid) 0.4.0 -> 0.4.1 + lockfile.
The headline upstream change is native-yoga (#1126); per the locked spec
decision D11 this is NOT a memory-floor lever at typical sizes, and a fresh
bench on this branch confirms it — OpenTUI capped VmHWM is within run-to-run
noise across mem50/100/300 (0.4.0: 193/200/220 vs 0.4.1: 192/218/217 MB).
The value is tail-session layout wins + upstream alignment, not the floor.
D14 (ffiSafe re-verify): the FFI signatures ffiSafe.ts clamps (OptimizedBuffer
fillRect/drawText/setCell/setCellWithAlphaBlending/drawChar + TextBufferView
setViewport) are byte-identical across 0.4.0->0.4.1 (still u32, still crash on
negatives under node:ffi), so the shim stays as-is and remains load-bearing.
Verified live: scrolled a 300-msg transcript with syntax-highlighted code +
tool cards past the viewport top (the negative-y fillRect trigger) on 0.4.1 —
no ERR_INVALID_ARG_VALUE loop, clean render.
Also pick up openConsoleOnError:false (public createCliRenderer option): stops
core's uncaught-error handler from calling the ALLOCATING console.show(), which
exit-7-masks the original error under native-handle exhaustion (the bench
mem3000 postmortem). guardRendererErrorHandlers stays as belt-and-suspenders.
Gate: npm run check OK (768 tests). Determinism gate green both engines.
The OpenTUI engine received the gateway's credits/usage notices but
mis-rendered them as scrolling inline transcript cards with no lifecycle.
Render them instead as a persistent, level-tinted chrome banner pinned
directly above the status bar, matching the Ink engine — no gateway/agent
changes (the wire + credits policy stay the source of truth).
- backgroundActivity.ts: widen level to include `success` (was silently
dropped to info) + add isChromeNotice() (kind sticky|ttl) discriminator.
- store.ts: port the Ink turnController notice lifecycle — showNotice/
applyNotice/clearNotice/flushPendingNotice/clearNoticeState, a single TTL
timer (latest-wins, id-guarded), mid-turn hold + turn-end reveal (the
three end sites: message.complete, gateway.exited, error), flash-and-yield
for credits.usage/grant_spent at message.start, and a notice reset on
clearTranscript + commitSnapshot so it can't bleed across sessions. Route
notification.show by kind: sticky|ttl -> chrome banner, everything else
(process/background completion cards) -> existing inline path, unchanged.
Distinct clones for notice vs lastNotification (createStore aliasing).
- noticeBanner.tsx + App.tsx: a single sticky row above the status bar,
text rendered verbatim (already glyphed by the policy), tinted by level,
width-truncated so it can never wrap and push the composer.
Tests: statusNotice.test.ts (lifecycle/routing/TTL/flash-and-yield),
noticeBanner.test.tsx (render/color/truncation), backgroundActivity +
render additions. npm run check OK (768 tests).
Three user-reported TUI fixes:
- clarify prompt rendered raw markdown (literal **bold** / `code`). The
question + each choice now go through the native <markdown> renderable
(same engine as the transcript) in a flex column so wrapping + the
selection accent are preserved. Tests assert structural chrome since
tree-sitter markdown doesn't paint in the headless renderer (same
limitation as render.test.tsx); painted markdown verified in a live smoke.
- a leading `/path` first message broke @-mention completion afterward:
onType fired completion RPCs per keystroke with no out-of-order guard,
and the transport doesn't guarantee in-order delivery, so a slow orphaned
complete.slash could land after a later @-mention complete.path and blank
the dropdown. Add createCompletionGate (pure) — claim() per keystroke,
isCurrent(token) drops any superseded response.
- slash-command highlighting was hit-or-miss (only highlighted a /command
if its completion batch had been browsed earlier): LEARNED_NAMES started
empty and grew lazily. Seed it once at boot from the full uncapped
commands.catalog via seedLearnedNames, so a cold /command highlights on
the first keystroke.
npm run check OK (768 tests).
bench/ moved to github.com/NousResearch/tui-bench; repoint the lingering
references in dev-handoff, env-flags, memory-story, ui-opentui README, and
the memlog/memSampler/reconciler source comments.
A notify_on_complete background process (the agent's terminal tool, proc_*) only
reached the TUI as the AGENT'S NARRATION — the completion is fed to the model as a
synthetic prompt (_run_prompt_submit) and the gateway emitted only message.start +
the reply, never the completion itself. So the OpenTUI card mechanism had nothing
to render and the synthetic turn read as a context-less line.
Additive fix (glitch-approved relaxation of the no-core rule for this one emit):
new _emit_process_completion_card() fires a `notification.show` (text "<cmd> exited
<code>", level info/warn by exit code, kind process.complete, key proc:<id>) at the
two completion-delivery sites, just before the agent turn. The OpenTUI engine
renders it as a distinct inline card (P1) + an OSC ping; Ink treats it as a notice.
Completion events only (watch matches skipped); call-site dedup → one card per exit.
No existing behavior changes — the agent turn still happens.
Gateway suite green (321 passed incl. 5 new TestProcessCompletionCard cases).
I conflated two "background" concepts. In hermes, /bg (aliases /background, /btw)
launches a background PROMPT (prompt.background → background.complete), and the
`bg: N` badge counts in-flight prompt tasks — but P3 hijacked /bg + bg: for the
OS-process registry and never handled background.complete (so completions were
silent). Corrected:
- /bg <prompt> now launches a background prompt (Ink parity): prompt.background,
echoes "bg <id> started", tracks the task in store.bgTasks.
- background.complete → drops the task + renders a distinct inline completion
CARD with the result (the missing completion notification; a completion-ish
kind also fires the OSC desktop ping).
- `bg: N` badge counts store.bgTasks (in-flight background prompts), not OS procs.
- the OS-process panel moved off /bg to /processes (+ /procs); its header count
uses runningCount. Dropped the ambient agents.list poll — the badge is now
event-driven and the panel fetches on open.
Gate green; new store test for background.complete (card + badge decrement).
/simplify pass over this session's diff (4 cleanup agents → applied the high-value
findings):
- reuse: extract the duplicated truncRight/truncLeft (statusBar/agentsDashboard/
backgroundPanel) to logic/truncate.ts; export DONE_STATUSES/procIsRunning from
backgroundActivity and drop backgroundPanel's re-declared copy.
- simplification: remove dead/speculative exports (upsertNotification,
clearNotificationsByKey, BackgroundRun) — the campaign models notifications as
Message rows, not a notifications array — and drop runningCount's always-empty
`runs` param; collapse notificationDispatcher's always-true `card` field into a
plain notificationOsc(): TermNotification | undefined.
- efficiency: the bg-process poll now idles at 30s (was a flat 8s) and tightens to
8s only when something is running — most sessions have zero background processes.
- altitude: the `⚡ agents` chip joins the statusSegments width-ladder (was an inline
width gate) so all bar segments share one drop policy; refreshed the stale
statusBar header (bg: is wired now, not "reserved").
Net −95 LOC. Gate green. Skipped (noted): statusColor merge (divergent domains),
the pushNotification double-clone (necessary — guards the Solid aliasing footgun),
moving the notification-card dispatch out of messageLine, and the Message-row model
(deliberate "inline in transcript" design).
Input-zone density (rpiw): the agents tray kept a persistent collapsed line under
the composer (`⚡ N agents running — ↓ to inspect`), stacking with the status bar +
composer. The running count now lives in a status-bar `⚡ N` chip (next to bg:/mcp:),
and the tray renders NOTHING when collapsed — one fewer persistent line under the
transcript. The tray stays mounted + focusable, so composer-Down still hands focus
over and expands it into the rows (focus-routing tests unchanged + green).
Gate green; agentsTray tests repointed from the removed tray line to the chip; the
Down/Esc/printable focus-routing coverage is intact.
The OS process registry (the qxpe "Claude Code background process" gap) had NO
surface in the TUI. Now:
- /bg (aliases /background, /jobs) opens a Background Processes panel listing the
registry from agents.list — per-process command + uptime + status, running
count, and a single STOP-ALL action (x → process.stop; the gateway exposes
kill_all only, so there's no per-row kill — noted in the panel).
- the reserved status-bar `bg: N` badge (A) now shows the running-process count,
fed by a slow (8s) scoped poll of agents.list so it stays live with the panel
closed; hidden at zero.
Background *runs* are intentionally NOT duplicated here — they're already the
resume picker's active-sessions tab; this panel targets the process registry,
the actual gap. All TUI-only (agents.list + process.stop already exist). Gate
green; new backgroundPanel.test (parse + list + running-count + empty state).
The agents dashboard (rplj pain) dumped each subagent's full multi-line prompt
into the master list, wrapping it into a wall of text and squeezing the trace.
Now each master row is ONE line: status + a width-budgeted truncated goal + model.
The detail pane still shows the full goal (the inspect half) and renders the
activity as a TYPED transcript instead of flat lines: SubagentInfo.trace is now
TraceEntry[] ({kind:'start'|'tool'|'progress'|'summary'}), drawn with per-kind
glyph+color (▶ start / ⚡ tool accent / · progress muted / ✓ summary green).
No foregrounding (kept subagent UX unchanged — inspection only, per the brainstorm).
TUI-only. Gate green; new agentsDashboard.test.tsx asserts one-line truncation +
typed-trace render; store.test trace assertion updated to the typed shape.
The TUI now consumes notification.show/clear gateway events (it dropped them
before — they leaked into the transcript as plain model-output-looking lines,
the qxpe pain). They render as a distinct, level-tinted inline card (role
'notification'): gold ◆ for info, amber for warn, red for error — clearly chrome,
not the agent. Important ones (error/warn/'complete'|'done'|'finish' kinds) also
fire the EXISTING focus-gated OSC desktop notification via termChrome.
Shared substrate (pure, unit-tested) for the rest of the campaign:
- logic/backgroundActivity.ts — parse notification.show/agents.list payloads,
dedupe-by-id upsert, clear-by-key, runningCount (badge, used in P3).
- logic/notificationDispatcher.ts — card-always + OSC-when-important decision.
- store: notification.show → pushNotification (distinct clones to avoid Solid
createStore reference-aliasing), notification.clear → drop matching cards,
lastNotification → OSC seam (terminalChrome).
All TUI-layer; builds only on events/RPCs the gateway already emits. Gate green
(737 tests incl. new unit + frame coverage); verified live in tmux.
Three Tier-1 fixes from live use:
- bare `/` hydrates the full command menu again (reverses F1's "name char
first" gate). The lead-token grammar still rejects `/abs/path` (F2) and a
`/ ` trailing-space is still not arg-completion on an empty name.
- `!cmd` shell mode now reads unmistakably: the composer glyph flips ❯ → `$`
in the alert (warn) color and an amber "shell mode — Enter runs this in your
shell (no model turn)" note rides the slot the slash/path dropdown would use
(they never coexist). New optional `brand.shellPrompt` ($), skin-overridable.
- the transcript scrollbox reserves a 1-cell right gutter (contentOptions
paddingRight) so the vertical scrollbar no longer paints OVER hard-width
content — markdown table / code-block right borders were clipped under it.
Gate green (714 tests); F1/F2/F7/F8 slash specs + the slashMenu frame test
updated to the new bare-/ behavior. Verified live in tmux.
The status-bar cost segment must show cost ONLY when running against the Nous
portal — per-model cache/input/output pricing is unreliable across the model
long tail, so a guessed figure is worse than none.
- New nous_header_cost_usd(agent): the chrome cost source, derived ONLY from the
x-nous-credits-* header delta (deliberately ignores the OpenRouter usage.cost
accumulator). _get_usage now uses it for cost_usd, so a non-Nous session
reports no cost and the TUI hides the segment.
- The /usage accounting page is unchanged in spirit: it now reads
real_session_cost_usd(agent) directly (both provider-reported sources) instead
of the chrome-narrowed _get_usage cost_usd, so OpenRouter cost still shows there.
Tests: new TestNousHeaderCost (header-only, OR-accumulator ignored, clamp,
no-method); updated gateway _get_usage tests for the chrome narrowing; /usage
page test still asserts the full provider-reported figure. 316 gateway + 25 cost
tests green.
- F4: a paste while the composer is unfocused (transcript scrollbox grabbed
focus) now lands — a renderer-level paste listener focuses the textarea and
applies the bytes; the focused path stays the textarea's own onPaste (no
double insert). Paste logic shared via applyPaste(text, native).
- F5: clarify prompt rewritten off the native <select> onto a custom list —
long options WRAP instead of clipping, options are numbered, the selected
row gets a real background + accent (three signals), and the custom answer
is an always-present inline <input> in the same screen.
- F6: Up/Down/Enter are preventDefault'd so arrows drive selection and never
leak to the transcript scrollbox.
Verified live via tmux screenshot (wrapping + numbering + highlight + inline
input all correct). 714 tests green; new clarifyPrompt.test.tsx covers wrap,
numbering, selection, inline custom input, no-choices, Esc cancel.
- F1: slash menu opens only after a name char (bare / no longer fires)
- F2: /abs/path is no longer mistaken for a slash command (lead token must match NAME_RE)
- F7/F8: completion survives newlines — computed at the cursor token, not whole-buffer bail
- F8b: @ is the only file/dir mention trigger (~ / ./ / bare paths dropped)
- F9: !cmd runs a shell command via gateway shell.exec (Ink parity), output as a system line
- F10: cwd is right-pinned on the chrome bar so dirname+branch hug the right edge
planCompletion is now cursor-aware (onType threads ta.cursorOffset). classifySubmit
extracted as a pure, tested router. 708 tests green.
Two reasons the local TUI stopped running OpenTUI / showed the wrong directory:
1. Node resolution. OpenTUI needs Node >= 26.3 (node:ffi floor), but
_node26_bin_or_none only checked HERMES_NODE + `which node`. When fnm's
default flips to an older line (e.g. v25.9) the active node fails the gate
and the engine silently falls back to Ink even though a usable v26.3 sits
installed. _fnm_node26_candidates now discovers fnm's installed versions
(FNM_DIR / XDG_DATA_HOME/fnm / ~/.local/share/fnm / macOS Library path),
newest first, version-probed — so the engine launches without the user
re-aliasing their global default.
2. Launch cwd. The launcher runs the engine with cwd=<engine package dir> so
its build/resolution works; the gateway it spawns then auto-detected THAT
dir as the workspace (chrome bar showed 'ui-opentui (feat/opentui-native-
engine)' regardless of where you ran hermes). TERMINAL_CWD — the gateway's
canonical launch-dir channel — was only exported in worktree mode; now it's
set to the real cwd for every launch (worktree mode still overrides to the
worktree path). The TUI's session.create no longer sends process.cwd() (the
engine dir) — a new launchCwd() reads the launcher's HERMES_CWD/TERMINAL_CWD,
falling back to process.cwd() only for standalone smokes.
Together: session cwd, chrome bar, terminal-tool cwd, and /sessions grouping
all anchor to where you actually ran hermes. Verified live — chrome bar shows
'/tmp/cwd-probe (my-feature)' launched from there with fnm default on v25.9.
8 new tests (fnm discovery order/precedence/empty-safety; launchCwd env
precedence).
The resume picker never had cwd grouping — deliberately deferred in the v1
spec because TUI session rows had no cwd to group by: the TUI's
session.create sent only {cols}, so explicit_cwd stayed false and
_ensure_session_db_row skipped cwd stamping by design (the desktop's launch
dir is meaningless — 'No workspace' grouping is its desired default).
In a terminal the launch directory IS the workspace choice, so the entry now
passes cwd: process.cwd() at session.create — the existing explicit-workspace
machinery persists it to the session row on first message (covered by
test_ensure_session_db_row_persists_explicit_cwd; zero gateway changes).
Picker: while browsing (no search), sessions whose cwd matches the TUI's
current directory order first under a '▾ this directory (N)' caption, the
rest under '▾ other directories' — one flat reordered list, so selection/
windowing/load-more math is untouched, and captions are pure render
decoration keyed off hereCount. During search the fuzzy score keeps owning
the order. Trailing-slash-normalized comparison, no fs calls.
Old sessions can't be backfilled (their cwd was never recorded); coverage
accumulates from here. 6 new tests (pure ordering edges + grouped frames,
search-drops-grouping, no-cwd passthrough).
@opentui/core@0.4.0 bundles only 5 grammars (ts/js/markdown/markdown_inline/
zig) and Hermes registered none of its own — Python/Rust/Go/bash/JSON/C/HTML/
CSS/YAML/TOML tool bodies and fences rendered plain text (never a regression:
no addDefaultParsers existed anywhere in branch history).
Now: parsers/manifest.json curates the 10 grammars (cpp deliberately dropped —
3.28MB alone); scripts/update-parsers.mjs vendors wasm+highlights.scm with
magic/content validation (plain Node fetch — core's update-assets generator is
Bun-flavored and its import-module won't bundle under esbuild, so registration
skips it and points at the vendored files by runtime-resolved path instead);
boundary/parsers.ts registers via the public addDefaultParsers() at entry
module load, before the first <code>/<markdown> mount initializes the global
tree-sitter client. ~4MB vendored, committed (build inputs, offline-safe).
Markdown fence injections need no infoStringMap: fence labels resolve as
filetype ids and core's ext maps already normalize py→python, zsh→bash, h→c.
Live-smoked in a real renderer: python tool body draws 6 distinct token
colors; ```python and ```yaml fences inside markdown highlight too. 6 new
tests pin the wiring (vendored assets valid, registration set, filetype
routing); visuals stay live-smoke territory per codeBlock.tsx.
Window title: a render-nothing <TerminalChrome> tracks session.info — the
native renderer.setTerminalTitle (frame-safe, zig-side OSC emit) shows
'{session title} — Hermes' once the session is titled, 'Hermes Agent' until
then. The user's previous title is bracketed with the XTWINOPS title stack
(save on boot, best-effort restore on quit). Gateway: _session_info now
carries the live title (DB row, pending_title fallback) and a session.info
refresh follows every title change — pending-title application, the
auto-title worker landing (via maybe_auto_title's title_callback), and
session.title renames — so the window retitles without waiting for the
next turn.
Notifications: when the TUI starts waiting on the user — any blocking
prompt (clarify/approval/sudo/secret/confirm) or turn completion — three
dialect sequences go out through renderer.writeOut: OSC 9 (iTerm2/wezterm),
OSC 99 (kitty), OSC 777 (urxvt/foot); terminals ignore what they don't
speak. Suppressed while the terminal reports focused (core's mode-1004
focus/blur events; until a first blur proves reporting works, notify
unconditionally). HERMES_TUI_NOTIFY=0/false/off kills notifications; the
title is not gated. All text is OSC-sanitized (control chars stripped,
777's semicolon fields spliced-proof, length-capped).
13 new TUI tests (pure shaping/sequences/env gate + store-edge wiring via
an injected seam) and 2 gateway tests (title resolution order, thread-safe
refresh emitter). Live-smoked: tmux pane_title shows 'Hermes Agent' from
the native title path.
Instead of an external watcher chasing 5-10 concurrent session pids,
every TUI samples ITSELF at 1Hz (rss/heap/external + windowing
mounted/peak-mounted counters) into ~/.hermes/logs/memwatch/, gated by
HERMES_TUI_MEMLOG (defaults to the HERMES_TUI_DIAGNOSTICS master
switch) — one shell-rc export covers every session a dev ever starts.
Unref'd timer, every failure path silently disables, 14-day retention.
bench/memwatch-report.mjs aggregates the fleet: per-session
baseline/peak/last, steady-state MB/h slope, peak mounted rows, and
SLOPE/PEAK/MOUNTED anomaly flags. Verified live: two fake-gateway
smoke sessions logged and aggregated (102MB base, mounted ≤60).
Main's rewritten test_tui_npm_install.py tests call _make_tui_argv expecting
the Ink/npm flow unconditionally; with the dual-engine dispatch merged in,
_resolve_tui_engine() auto-selects opentui whenever ui-opentui/dist is built
in the repo, routing the call away from the path under test (first subprocess
became 'node --version' instead of 'npm run build'). Pin the engine to ink
via an autouse fixture, mirroring the existing pinning precedent in
test_tui_resume_flow.py.
Regular users get zero diagnostic surface by default: /mem and /heapdump
disappear from /help and completion, and invoking them prints the
one-line enable hint (relaunch with HERMES_TUI_DIAGNOSTICS=1) instead of
executing — an enable switch, not a secret. With the switch on, the
commands work as before and HERMES_TUI_WINDOW_STATS defaults on (still
individually settable either way). Full env-flag ledger (master switch /
user config / dev tuning / internal plumbing) in docs/opentui-env-flags.md.
672 tests exit 0.
Rebasing onto fcf49f313 (multi-click selection) collided in the test
probe (both sides added 'mouse' — deduped) and surfaced two test debts
the cap-restore commit (3cc56517a) had shipped masked by a piped exit
code: store cap tests still asserted the 1000 default (now: 3000
windowed, 1000 with HERMES_TUI_WINDOWING=0, both covered), and the
burst-interplay test relied on the old cap trimming a 1500-row burst
(now pins HERMES_TUI_MAX_MESSAGES=1000 explicitly for both stores).
Also: .demo/ build artifacts excluded from typed linting. 669 tests
exit 0 (verified unpiped). Multi-click selections flow through the
same renderer selection seam, so windowing's drag-freeze + row-pinning
covers them with no changes.
Maintainer signals (native yoga next release, 2x layout, opencode's
100-cap was a legacy perf workaround): what changes for us (WASM ratchet
dies), what doesn't (the 65k handle table still makes windowing
load-bearing at 3000 rows), the boundary/ shim ledger with
delete-on-upstream-fix criteria, and the per-release upgrade playbook
that uses the bench suite as the acceptance contract.
Shareable explainer: every primitive at play (native handle table, Yoga
WASM grow-only memory, renderables, Solid surgical unmount, V8 GC
laziness, scrollbox draw-only culling) and every decision (windowing vs
store-cull, exact-heights-at-unmount vs Ink's estimate-correct, the
correctionIsLegal zero-jank law, append-time adjudication, never-window
rules, windowing-aware cap restore, heap right-sizing), with the
measured scoreboard and honest open items.
With transcript windowing (S1+S2) the mounted set no longer scales with
the store (peak 31 rows over a 1500-row burst), so the handle-table
clamp that forced 1000 rows is unnecessary when windowing is on. The
ceiling is now windowing-aware: 3000 rows (the originally-shipped
default, regression documented in opentui-fixes-audit.md §2) with
windowing, 1000 with HERMES_TUI_WINDOWING=0 (every row mounts again).
Measured at the restored cap (full 3000-msg store): mem3000 360MB peak
styled end-to-end (pre-campaign: ~870MB + unstyled past ~1,400 rows;
before that: crash). scroll3000 p50=2 p90=3 p99=8 max=17ms (Ink same
workload: p90=35 p99=96). Gate digest unchanged.
Pins Node 26.3 to ui-opentui/ only (fnm/mise auto-switch on cd; leaving
the directory restores whatever the dev had — no global default change).
engines.node >= 26.3 makes a wrong-Node npm ci warn. README covers
install paths (fnm/mise/nvm/absolute-binary), the ABI-locked
node_modules gotcha, and build/run commands.
Adversarial-review follow-up to the S2 slice. The S1 rule froze ALL window
recomputes while renderer.getSelection()?.isActive — but a finished mouse
selection persists by design (boundary/renderer.ts keeps the highlight so
Ctrl+C can re-copy), so a long streaming turn behind a lingering highlight
ballooned the mounted set exactly like pre-windowing (and permanently
ratcheted the Yoga-WASM high-water).
Refinement:
- full freeze only while selection.isDragging (the native walk touches the
live tree on every drag update — destroying a row mid-walk corrupts the
highlight; unchanged from S1 where it matters),
- a finished highlight instead PINS the rows containing
selection.selectedRenderables (parent-climb to the row wrapper via a
WeakMap) as neverWindow — the highlight and a later Ctrl+C copy stay
byte-exact while everything else keeps windowing,
- an active highlight counts as activity (no idle measure churn under it).
Test (headless mock-mouse drag): finished selection persists (isActive,
!isDragging) → 300-row burst keeps peakMounted < 120 AND
getSelectedText() returns the identical text afterward, the selected rows
having been pinned while long scrolled past the margin.
Verified on this build: gate digest otui-capped d5e9558583159eac… (2/2),
mem2000 otui-capped windowing-ON vmhwm 312MB (target ≤ 350), scroll2000
otui-capped p50 2.0ms / p99 6.0ms (gate ≤ 17ms). check exit 0 (648 tests).
Review verdicts on the remaining findings (verified against core source):
- "scrollTop compensation race": rejected — scrollTop is an imperative
scrollbar property (no signal staleness); records fire in document order,
each compensation immediately visible to the next.
- "heights map leak on /new": rejected — the countChanged cleanup prunes
every per-key map against the live key set (test-verified).
- remount-in-viewport estimate shift: only reachable when one frame jumps
past the margin (> 1 viewport); the design's accepted "remounted for
view" path — documented in the header.
- expanded tool/reasoning re-collapse on far-remount: S1-accepted,
deferred (component-local state; out of S2 file ownership).
S2 of docs/plans/opentui-transcript-windowing.md (#27), behind
HERMES_TUI_WINDOWING (OFF path renders the byte-identical legacy tree).
Append-time adjudication: the window now recomputes on transcript GROWTH,
not just scroll — a createComputed on messages.length re-windows
synchronously per append, and while pinned at the bottom computeWindow
anchors to the cumulative content BOTTOM (pinnedBottom) instead of the
stale pre-layout scrollTop, so burst-appended rows are spacer-swapped the
moment they pass the margin. The frame driver additionally treats a
≥ ¼-viewport scrollHeight change (streaming growth) like scroll movement.
Unseen-row default changed from "always mounted" to "mounted iff created
streaming or within the bottom-30" — live rows still paint instantly with
zero added latency; a bulk commitSnapshot (resume) mounts ONLY the bottom
window and everything above starts as line-count-estimate spacers (chip-
and-spacing-aware estimateMessageHeight).
Spacer corrections (zero-jank rule): when a measure lands a height
different from what the spacer occupied, the wrapper's onSizeChange fires
inside the layout traversal, pre-paint. Pinned at bottom the scrollbox's
own sticky re-pin (content onSizeChange runs before the row wrappers')
already compensated — verified by test; otherwise scrollTop is compensated
same-frame for rows fully above the viewport (correctionIsLegal). Frames
stay byte-stable across corrections in both pinned and mid-history tests.
Lazy exact-measure (design §4 — the simple choice, documented): no true
offscreen layout exists in @opentui/core, so an idle pulse (no appends,
no scroll, no turn, no selection for HERMES_TUI_WINDOW_IDLE_MS≈1s) mounts
MEASURE_BATCH_ROWS=10 never-measured rows nearest the bottom window edge
(edgeMeasureBatch), records exact heights (incl. a direct post-layout pull
for rows whose mount changed nothing — no onSizeChange fires), and the
next recompute swaps them back to now-exact spacers. Scrolling itself
still measures the margin band.
DEV counter: windowRowStats (current/peak simultaneously-mounted rows),
exposed on globalThis behind HERMES_TUI_WINDOW_STATS; tests assert it.
Measured (this build, 39f9f433e+S2):
- check: exit 0 (647 tests / 39 files; +11 pure window cases, +4 headless)
- peak mounted: 31 rows over a 1500-row burst; 30 rows on a 600-row
resume snapshot (bound asserted < 120)
- gate digest: otui-capped d5e9558583159eac… — byte-identical, 2/2 reps
- mem2000 (otui-capped, windowing ON, 8GB heap): vmhwm 300MB
(S1 same-heap 518MB; S1 right-sized-heap 427MB; Ink 229-239MB;
target ≤ 350MB)
- scroll2000 otui-capped: p50 2.0ms / p99 5.0ms / max 18ms
(gate ≤ 17ms p99; S1 baseline p99 15ms)
Known S2 limits (deferred to S3, design §5): /compact·/details toggles and
width resizes leave out-of-window spacer heights stale until remount or
the idle march; expanded-body state above the window may re-collapse on
remount (S1-accepted).
Core machinery of docs/plans/opentui-transcript-windowing.md (#27): rows
outside [scrollTop − viewport, scrollTop + 2·viewport) swap to an exact-height
empty <box> (1 yoga node, no text buffers / native handles), so the mounted
set stays ~3 viewports regardless of transcript length.
Flag: HERMES_TUI_WINDOWING — unset → ON; 0/false/no/off → OFF (envFlag
semantics, the bench A/B + one-env escape hatch). OFF renders the exact
legacy tree (no wrapper boxes).
Pieces:
- logic/window.ts (pure, table-tested): computeWindow (viewport ± 1-viewport
margin intersection over cumulative exact heights; null heights fall back
to a per-row line-count estimate), hysteresisFor/shouldRecompute (≥ ¼
viewport between recomputes), correctionIsLegal (the jank rule: corrections
only fully above the viewport with same-frame scrollTop compensation, or
fully below it), estimateMessageHeight (line-count estimate; wrong values
are fixed by remount only — S1 never corrects a spacer in place).
- view/transcript.tsx: per-row measuring wrapper records exact heights via
onSizeChange (only while the real row is mounted); window driver is a
renderer frame callback (setFrameCallback — scroll always renders, so no
extra timer) publishing the mounted set through one signal + createSelector
so only flipped rows re-render. Stable row keys via WeakMap<Message, n>
(messages have no id; store proxies are reference-stable). Solid <Show>
unmount destroys the row's renderables (@opentui/solid _removeNode →
destroyRecursively).
Never-window rules:
- streaming rows (remount would restart native markdown streaming),
- the last row while a turn is running (deltas land there),
- the bottom 30 rows (fixed K — sticky-bottom region; rows under
viewport+margin are mounted by the window calc anyway),
- rows the window has never adjudicated default to MOUNTED (new live rows
paint instantly),
- the whole window FREEZES while a mouse selection is active
(renderer.getSelection()?.isActive — a swap would destroy highlighted
renderables under the native selection walk).
Tests: 30 pure window.test.ts cases + 2 headless integration cases
(transcriptWindow.test.tsx) pinning the zero-jank invariant (scrollHeight
identical ON vs OFF), the renderable shedding, and remount-on-scroll-back.
Editor-grade mouse selection parity with the Ink TUI (hermes-ink selection.ts):
a second click in the 500ms/1-cell chain selects the same-class character run
under the cursor (iTerm2 word set, wide-glyph aware), a third selects the line,
and dragging with the button held extends word-by-word / line-by-line while the
clicked span stays selected — anchor flips across the span on direction change.
Core knows only press-drag char selection, so this is a boundary shim
(multiClickSelect.ts) wrapping the renderer's startSelection/updateSelection
seam; word bounds read the presented frame's char grid. Native quirks probed
and pinned: per-renderable selection anchors are fixed at set time (anchor
flips restart the selection) and forward selections exclude the focus cell
(inclusive spans seed focus at hi+1). Pure scanning logic in logic/multiClick.ts;
20 new tests (pure + real-mouse-path frames); demo.tsx installs the seam for
tmux smokes.
Three behavior changes to the hermes -w worktree lifecycle:
1. Git-native locks. _setup_worktree now locks its worktree
(git worktree lock --reason "hermes session pid=<pid>"), and
_prune_stale_worktrees skips locked worktrees at ANY age — a lock
from a live or crashed session means "do not touch". New helpers
_lock_worktree / _unlock_worktree / _worktree_is_locked (fail-safe:
any error reads as locked) / _worktree_is_dirty (fail-safe: any
error reads as dirty).
2. Dirty trees are preserved. _cleanup_worktree previously destroyed
worktrees with uncommitted changes if there were no unpushed
commits; it now keeps the worktree, branch, and lock when the tree
is dirty OR has unpushed commits, and prints manual cleanup hints
(git worktree unlock + remove --force). The >72h "force remove
regardless" prune tier is removed: pruning may only ever delete
clean, unlocked, fully-pushed worktrees.
3. Branch deletion is gated on removal success. Both cleanup and
prune previously deleted the branch without checking the
git worktree remove returncode, dropping easy reachability of the
commits even when removal failed; the branch is now only deleted
after a successful remove.
Root cause of the bench-suite crash (every otui mem3000/slope cell died at
~3000 lumpy fixture msgs, exit 7, ~880MB RSS — not a cgroup kill):
- @opentui/core 0.4.0 routes EVERY native object through ONE global handle
registry with 16-bit slot indices (core src/zig/handles.zig: INDEX_BITS=16,
MAX_SLOTS=65535, slot 0 reserved). Measured on this install: exactly 65,534
live handles; the next createSyntaxStyle() fails. destroy() DOES recycle
slots — exhaustion means LIVE objects.
- Every TextBufferRenderable burns THREE slots in its constructor
(TextBufferRenderable.ts:77-80: TextBuffer + TextBufferView + SyntaxStyle),
so the mount-everything transcript hits the wall at ~1,400 store rows
(~16 text renderables/row x 3 ~ 47 handles/row): "Failed to create
SyntaxStyle" (zig.ts:4554) throws out of a Solid mount effect.
- The crash was MASKED: CliRenderer's own uncaughtException handler
(handleError -> console.show()) allocates the console-overlay
OptimizedBuffer — another handle — so the handler itself threw "Failed to
create optimized buffer: WxH" and Node died with exit 7 (fatal error in
the uncaughtException handler), hiding the real error.
Why not share one SyntaxStyle (the obvious 3->2): the per-buffer style is
load-bearing — native setStyledText (text-buffer.zig) registers each chunk's
color by NAME ("chunk{i}") into the buffer's OWN style, and registration is
name-keyed-overwrite (syntax-style.zig putStyle), so a shared style would
cross-corrupt chunk colors between every styled <text>. Pooling is unsound
at our layer in core 0.4.0.
The fix, at the seams that are ours:
- boundary/nativeHandles.ts (ffiSafe.ts sibling): SyntaxStyle.create() on a
full table DEGRADES to a detached style (native handle 0) instead of
throwing — JS-side styleDefs/mergeStyles (what markdown/code chunk colors
actually use) keep working; all native calls on handle 0 are inert no-ops.
- boundary/renderer.ts: guard the process error listeners createCliRenderer
installs so an exception INSIDE the handler can never exit-7-mask the
original error again (logged honestly; original error stays the story).
- logic/store.ts: HERMES_TUI_MAX_MESSAGES clamped to a handle-safe ceiling
(1000 rows ~ 47k handles ~ 72% of the table on the realistic fixture).
The old default of 3000 was unreachable — the TUI crashed at ~1,400 rows,
before the cap ever bound. Renderable-weight-aware capping is #27's
(virtualization) to do properly; until then the degrade shim backstops
pathological rows.
TODO(upstream) — issue-shaped, for the OpenTUI repo:
(a) a global 64k handle table with a 3-slot cost per text renderable is
too small for transcript-style TUIs (61k renderables ~ 3k messages);
(b) native allocation failures throw out of the render loop with no
degrade path;
(c) handleError allocates (console overlay buffer) and so crashes on the
very condition it is reporting, masking the root cause with exit 7.
Also: eslint now ignores ui-opentui/.bench/** (bench `nodes`-cell build
artifact broke the lint gate) and .gitignore covers it.
Gate: npm run check green, 599 tests (595 baseline + 3 degrade-path tests
+ 1 cap-clamp test).