Compare commits

..

213 Commits

Author SHA1 Message Date
alt-glitch
6331a12ecd docs(handoff): concrete tmux-pane-screenshot usage + note skills are TUI-reachable 2026-06-13 19:11:06 +05:30
alt-glitch
463eda6276 docs: OpenTUI dev handoff — base operating manual for continuing memory+UX on the canonical branch 2026-06-13 18:43:30 +05:30
alt-glitch
a77e2083c5 bench: post-consolidation verification — mem2000 303MB, digest unchanged, 700 tests 2026-06-13 18:05:48 +05:30
alt-glitch
2a28bbcc72 Merge feat/opentui-native-engine into feat/opentui-memory-window
Brings the memory-window branch current with the base PR: multi-click already
in; adds OSC window title/notifications, +10 tree-sitter languages, /sessions
this-directory grouping + TUI cwd persistence, and the node26-fnm-discovery +
launch-cwd fix. All ui-opentui/gateway/launcher additions; windowing files are
ours alone.

# Conflicts:
#	ui-opentui/src/entry/main.tsx
2026-06-13 18:05:11 +05:30
alt-glitch
3d1691c48b fix(tui): opentui launches when fnm default is older than 26.3; chrome bar reads the real cwd
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).
2026-06-13 13:24:54 +05:30
alt-glitch
27a455d301 opentui(v6): /sessions groups this directory's sessions first + TUI persists its cwd
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).
2026-06-12 23:50:59 +05:30
alt-glitch
7d5fe2c39f opentui(v6): fleet memory self-sampling — HERMES_TUI_MEMLOG + memwatch-report aggregator
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).
2026-06-12 19:24:00 +05:30
alt-glitch
2402e7777c opentui(v6): syntax highlighting for 10 more languages (vendored tree-sitter grammars)
@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.
2026-06-12 13:26:17 +05:30
alt-glitch
a14d44482e bench: post-merge gate verification results (gate/mem2000/scroll2000 @ f0ec24a) 2026-06-12 13:09:23 +05:30
alt-glitch
e9fe618fce opentui(v6): terminal window title (OSC 0/2) + waiting-on-you notifications (OSC 9/99/777)
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.
2026-06-12 13:09:23 +05:30
alt-glitch
fc0774b7f2 Merge feat/opentui-native-engine (origin/main @ 24f74eb88 merged in) into feat/opentui-memory-window
Brings the memory-window branch up to current main + the multi-click
selection feature, via the base PR branch's merge commits (history
preserved, no rewrites). No conflicts: the 13 windowing/diagnostics
commits touch ui-opentui/bench/docs only.
2026-06-12 10:37:23 +05:30
alt-glitch
3d7a64c383 Merge origin/main (follow-up delta) into feat/opentui-native-engine 2026-06-12 10:35:38 +05:30
alt-glitch
8356b10afa tests: pin ink engine in _make_tui_argv npm-bootstrap tests (post-merge semantic fix)
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.
2026-06-12 10:32:40 +05:30
brooklyn!
24f74eb888 fix(desktop): make file-preview source + markdown selectable (#44648)
body sets user-select:none for native feel and opts text back in only via
[data-selectable-text='true']; the preview's source and rendered-markdown
panes never set it, so code couldn't be selected or copied. Tag the Shiki
code column and the markdown root. The attribute stays off the SourceView
grid root so the gutter keeps its select-none and line numbers don't bleed
into copied text.
2026-06-12 04:15:06 +00:00
brooklyn!
6e41ca956b fix(desktop): bundle JetBrains Mono for the terminal pane (#44642)
The terminal listed JetBrains Mono only as a late fallback and shipped no
webfont, so on machines without SF Mono/Menlo xterm measured the grid on the
regular system face while styled SGR spans fell back to a font with different
advances — glyphs squeezed and overlapped.

Bundle the regular/bold/italic woff2 (Apache-2.0, the faces the dashboard
already ships), put the family first in the xterm stack, pin the weights, and
warm every face before mount (fonts.ready only settles already-requested
faces; bold/italic aren't asked for until styled output paints, past atlas
init). Vite emits them as hashed assets under dist/** with base './', so the
fonts ship in the asar and every install path inherits them.
2026-06-12 04:11:51 +00:00
alt-glitch
f0ec24ad50 Merge origin/main (6db65e687) into feat/opentui-native-engine
439 main commits in; 144 branch commits preserved (no rewrite — merge over
rebase per glitch's call to keep history). Conflict resolutions:

- Dockerfile: ui-opentui build folded into main's new cached frontend-build
  layer (COPY ui-opentui/ + install/build/prune beside web+ui-tui); node:26
  base from our side kept.
- gateway/run.py: took main's extraction (slash handlers moved to
  gateway/slash_commands.py); re-applied our /usage real-cost block
  (real_session_cost_usd / resolve_billing_route, no estimation) at its new
  home in slash_commands.py.
- tui_gateway/server.py: _LONG_HANDLERS union (our model.options + main's
  plugins.manage).
- tests/test_tui_gateway_server.py: both sides' appended tests kept.

Verified during resolution: cli.py worktree prune/cleanup lock fix survived
auto-merge intact and main's new prune call-site (hermes_cli/main.py:2139)
inherits its clean/locked/dirty/unpushed skip semantics; HERMES_TUI_ENGINE
selection in hermes_cli/main.py intact.
2026-06-12 09:35:30 +05:30
alt-glitch
ed2cffd450 opentui(v6): diagnostics-gate review nits — completion-mechanism precision + client-only design note 2026-06-12 09:28:15 +05:30
alt-glitch
50059ea403 opentui(v6): HERMES_TUI_DIAGNOSTICS master switch — gate /mem, /heapdump + window-stats default
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.
2026-06-12 09:17:25 +05:30
brooklyn!
6db65e687c Merge pull request #44627 from NousResearch/bb/desktop-tool-row-copy-affordance
fix(desktop): move tool-row copy control into expanded body
2026-06-11 22:32:52 -05:00
Brooklyn Nicholson
09bcf5a937 fix(desktop): move tool-row copy control into expanded body
The per-row copy control lived in the header's trailing slot as a 24px
button that depended on a `group-hover/tool-row` group that exists nowhere
in the tree. It therefore stayed `opacity-0` yet remained clickable — an
invisible hit-target straddling the disclosure caret and duration, making
the caret hard to click without firing a copy.

Move copy into the expanded body's top-right (matching the code-block
convention) where it can't fight the caret for the right edge, and make it
actually visible (subtle at rest, full on hover/focus). The header right
edge now belongs solely to the duration label + caret.

Tradeoff: copy is only reachable once a row is expanded; rows with no
expandable body no longer surface a copy control.
2026-06-11 22:27:39 -05:00
alt-glitch
22434f4d07 bench: rebased-branch verification — digest unchanged, mem2000 289MB 2026-06-12 08:42:19 +05:30
alt-glitch
dc57ad98db opentui(v6): post-rebase fixups — dedup probe mouse, .demo lint ignore, cap tests to windowing-aware contract
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.
2026-06-12 08:40:53 +05:30
alt-glitch
44c896a5e2 docs: upstream alignment playbook — forkless invariant, shim ledger, upgrade contract
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.
2026-06-12 08:29:24 +05:30
alt-glitch
6f5f7457fe docs: the OpenTUI memory story — ELI5 walkthrough of the 686MB→300MB campaign
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.
2026-06-12 08:29:24 +05:30
alt-glitch
dc3c7dc405 opentui(v6): restore scrollback cap 1000 → 3000 under windowing (#27 payoff)
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.
2026-06-12 08:29:24 +05:30
alt-glitch
360388f627 opentui: Node 26 onboarding — scoped .node-version, engines floor, README setup guide
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.
2026-06-12 08:29:24 +05:30
alt-glitch
375899f89c opentui(v6): windowing S2 — pin selected rows instead of freezing on a lingering highlight
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).
2026-06-12 08:29:24 +05:30
alt-glitch
fd956d3189 bench: S2 controller verification — mem2000 307/373MB, scroll p99 6ms, digest unchanged 2026-06-12 08:29:24 +05:30
alt-glitch
fcbe525a63 opentui(v6): transcript windowing S2 — append-time adjudication + windowed resume + edge measure
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).
2026-06-12 08:29:24 +05:30
alt-glitch
f7381800f7 bench: windowing A/B knobs + S1 results — windowing −170MB, GC laziness −90MB at 2k msgs
composeEnv passes HERMES_TUI_WINDOWING through (clean env stripped it);
--heap N overrides the mem-cell V8 cap. Same-build A/B at 2000 msgs:
OFF 686MB peak / ON 518MB / ON+512MB-heap 427MB; scroll p99 16ms vs
17ms baseline (no jank regression), determinism digest byte-identical.
Residual slope ~108MB/1k (Ink ~37) — burst-time mounted peak is the S2
target.
2026-06-12 08:29:24 +05:30
alt-glitch
411334b3d0 opentui(v6): transcript windowing S1 — exact-height spacers behind HERMES_TUI_WINDOWING
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.
2026-06-12 08:29:24 +05:30
brooklyn!
4d67ac6172 Merge pull request #44596 from NousResearch/bb/desktop-rtl-bidi
feat(desktop): auto-detect RTL/bidi text direction in chat
2026-06-11 21:44:13 -05:00
Brooklyn Nicholson
6c00077d38 feat(desktop): auto-detect RTL/bidi text direction in chat
Arabic/Hebrew/Persian/Urdu chat text rendered left-to-right and
left-aligned, and mixed RTL/English technical messages (the common case)
read backwards. Resolve each chat block's base direction from its own
first strong character (UAX#9) with pure CSS, scoped to the chat
surfaces only:

- `unicode-bidi: plaintext` + `text-align: start` on assistant prose
  blocks (p, h1-h6, li, blockquote), the user bubble's text lines, and
  both composers (main + edit share the composer-rich-input slot). RTL
  blocks read and right-align RTL; English stays LTR; mixed
  conversations resolve per block. `text-align: start` is required
  because the user bubble hardcodes `text-left`.
- Inline `code` and KaTeX are pinned `direction: ltr; unicode-bidi:
  isolate`, so the bidi first-strong heuristic skips them: a sentence
  that *starts* with a command (`./run.sh ...`) followed by Arabic
  still resolves RTL, and the command's own neutrals keep their order.
- Fenced code surfaces (code-card, user fences) are pinned LTR so they
  never mirror or right-align inside an RTL list item or blockquote.

`direction` is never forced, so app chrome, layout, and list indent
stay LTR per the issue's request not to flip the whole UI. English-only
content is byte-for-byte unchanged.

Salvaged and unified from #44065 and #44169; verified in Chromium that
isolate removes inline code from the paragraph direction vote (the
code-first case), making the JS dir-resolution in #44065 unnecessary.

Fixes #44150

Co-authored-by: Adolanium <Adolanium@users.noreply.github.com>
Co-authored-by: Adalsteinn Helgason <AIalliAI@users.noreply.github.com>
2026-06-11 21:06:26 -05:00
brooklyn!
9e484f052a Merge pull request #44559 from NousResearch/bb/persistent-terminal-env
fix(terminal): advertise persistent env state
2026-06-11 20:07:11 -05:00
Brooklyn Nicholson
ab06ef8ed6 fix(coding): teach agents terminal env state persists
Tell coding agents to activate shell setup once per session instead of re-sourcing it before every command, and pin the existing LocalEnvironment env-snapshot behavior with regression tests.
2026-06-11 19:50:08 -05:00
brooklyn!
afe53708ee Merge pull request #44545 from NousResearch/hermes-worktree-code
fix(coding): don't expose primary worktree path in coding context
2026-06-11 19:35:18 -05:00
Teknium
5affecb443 fix(mcp): capability-gate tools/list so prompt-only MCP servers can connect (#44550)
Port from anomalyco/opencode#31271: only call tools/list when the server
advertises the 'tools' capability in InitializeResult.capabilities.

Previously, _discover_tools() unconditionally called session.list_tools()
right after initialize. Prompt-only / resource-only servers (which omit
the tools capability per the MCP spec) raise McpError(-32601 Method not
found), which aborted the connection — burning all 3 initial-connect
retries and permanently failing the server even though its prompts and
resources were perfectly usable. The 180s keepalive had the same problem:
it probed with list_tools(), so even a successfully connected prompt-only
server would be torn down on the first keepalive cycle.

Changes:
- MCPServerTask._advertises_tools(): capability check with a legacy
  fallback (no captured InitializeResult -> behave as before)
- _discover_tools(): skip tools/list for non-tool servers
- keepalive: use the universal ping request for non-tool servers
- _refresh_tools(): guard against tools/list_changed from non-tool servers

E2E verified with a real stdio prompt-only FastMCP-style server: on main
it fails all 3 connection attempts with Method-not-found; with this fix
it connects, lists prompts, answers ping keepalives, and shuts down
cleanly.
2026-06-11 17:34:49 -07:00
ethernet
96cc7ee1e3 fix(coding): don't provide worktree root in context
this makes the agent frequently edit files in the wrong worktree.
what the agent doesn't know can't hurt it.
2026-06-11 20:27:06 -04:00
brooklyn!
880107ab24 Merge pull request #44529 from NousResearch/bb/desktop-profile-fallout
fix(desktop): close out the multi-profile desktop fallout — WS auth + cross-profile session reads
2026-06-11 19:06:00 -05:00
brooklyn!
4ddb03390a fix(desktop): collect + persist API key for custom OpenAI endpoints (#43896)
The desktop "Local / custom endpoint" onboarding never collected an API
key and /api/model/set silently dropped one, so an auth-gated endpoint
(e.g. a hosted vLLM behind a key) could never enumerate models — and
Settings' "Set up custom endpoint" routed `custom` into a non-existent
OAuth flow, booting the user back to the first screen (the reported loop).

Backend (web_server.py):
- /api/providers/validate accepts an optional api_key and sends it as a
  Bearer header when probing a custom endpoint's /v1/models.
- /api/model/set accepts api_key, persists it to model.api_key (same
  switch/preserve lifecycle as base_url), and registers a named
  custom_providers entry via _save_custom_provider — matching the
  `hermes model` CLI flow so the endpoint shows up as a ready picker row.

Desktop:
- ApiKeyForm shows an optional API key field for the local/custom option;
  the key is threaded through saveOnboardingLocalEndpoint → validate +
  setModelAssignment.
- New onboarding `localEndpoint` intent + startManualLocalEndpoint(); the
  Settings "Set up custom endpoint" button now opens the local-endpoint
  form (URL + key) instead of the OAuth dead-end.
- Added localApiKeyPlaceholder i18n key (en + types + zh).

Tests: api_key lifecycle on _apply_main_model_assignment, key persistence
+ custom_providers registration on /api/model/set, Bearer-header probe;
onboarding store forwards + persists the key.
2026-06-12 00:03:55 +00:00
brooklyn!
c6007e5c1a Merge pull request #44534 from NousResearch/bb/approval-allow-permanent
fix(approval): carry allow_permanent to TUI + desktop approval prompts
2026-06-11 18:49:58 -05:00
Austin Pickett
e2145a5c9c fix(ui-tui): stabilize embedded dashboard chat gateway (#44528)
Cherry-picked from #39840 by @flyinhigh and rebased cleanly on main.

- Defer config fetch in createGatewayEventHandler until gateway.ready to
  avoid render-phase RPC that can mutate transcript state and trigger
  React error 301 in embedded dashboard PTYs.
- Use undici WebSocket fallback when globalThis.WebSocket is unavailable
  (Node attach mode and sidecar mirror sockets).
- Add regression tests for both fixes.

Co-authored-by: flyinhigh <flyinhigh@users.noreply.github.com>
2026-06-11 19:47:53 -04:00
Brooklyn Nicholson
55a18e6860 chore(approval): tighten allow_permanent comments + DRY the no-always opt set
Collapse the verbose multi-line rationale comments across the TUI/desktop/
backend approval surfaces into single-line "why" notes, and derive
APPROVAL_OPTS_NO_ALWAYS from APPROVAL_OPTS instead of re-listing it.
No behavior change.
2026-06-11 18:42:59 -05:00
Brooklyn Nicholson
b097d7b033 refactor(desktop): use native fetch in dashboard-token
Node >=18 / Electron 40 ship fetch; the hand-rolled http/https.request
plumbing buys nothing. AbortSignal.timeout replaces the socket timeout,
protocol guard and >=400 rejection semantics preserved. 13/13 unit
tests and the live web_server.py repro both green over the new
transport.
2026-06-11 18:41:16 -05:00
Brooklyn Nicholson
cc726aad68 refactor(desktop): fold served-token adoption + foreign-backend refusal into one helper
Both spawn paths (startHermes, spawnPoolBackend) duplicated the same
resolve -> log-fallback -> foreign-check -> throw dance. Collapse it into
adoptServedDashboardToken(baseUrl, spawnToken, {childAlive, label}) in
dashboard-token.cjs; childAlive is a thunk so liveness is sampled after
the fetch. Drop the redundant backendPool.delete in the pool's throw
path (the child exit/error handlers already own pool eviction).

Validated end-to-end against a real web_server.py backend, not just
units: token-injection regex vs the actual served index.html, foreign
refusal (dead child + live squatter), benign drift adoption, and the
401-vs-200 token auth split on /api/sessions.
2026-06-11 18:33:05 -05:00
Brooklyn Nicholson
81436e143e fix(approval): carry allow_permanent to TUI + desktop approval prompts
When a tirith content-security warning is present the approval backend
forces allow_permanent=False and silently downgrades an "always" choice to
session scope (the persistence loop in check_all_command_guards only honors
"always" → permanent when no tirith finding exists). But the gateway notify
payload that drives the TUI and the Electron desktop app never carried that
flag, so both surfaces always rendered "Always allow" — offering a permanent
allow the backend would quietly refuse to persist.

Plumb allow_permanent end-to-end:
- tools/approval.py: include `allow_permanent: not has_tirith` in the gateway
  approval_data the notify callback emits as `approval.request`.
- ui-tui: thread `allowPermanent` through the event handler, gateway types,
  and ApprovalReq; ApprovalPrompt drops the "always" option (and renumbers the
  quick-pick keys) when it's false.
- apps/desktop: thread `allow_permanent` through the gateway payload type, the
  per-session approval store, and the inline ApprovalBar, which now hides the
  "Always allow…" dropdown item when permanent allow is disallowed — reusing
  the existing DropdownMenu / confirm-Dialog UI.

The desktop/TUI render path for approvals already landed in #38578 (the root
cause of approvals not surfacing in the GUI); this completes the salvage of
#37856 by carrying allow_permanent across both surfaces. #37856's original
thread-local _block() approach is dropped: desktop/TUI approvals resolve via
approval.respond → resolve_gateway_approval (the per-session queue), not the
_block()/request_id correlation, so a worker-thread callback waiting on _block
would never be released by the real UI.

Tests: gateway notify payload carries allow_permanent (True without tirith,
False with a tirith warning); ui-tui approvalAction reduced option set +
event-handler allowPermanent propagation; desktop store round-trip + the
ApprovalBar showing/hiding "Always allow".

Supersedes #37856
Closes #37812

Co-authored-by: LeonSGP43 <cine.dreamer.one@gmail.com>
2026-06-11 18:23:59 -05:00
Mani Saint-Victor, MD
9ff0ba0827 fix(desktop): prevent backend port-squat boot loop and pickPort self-collision
Two fixes to the Electron desktop launch path, with the port-reservation logic extracted into a unit-tested module:

1. hermes:bootstrap:reset ("Reload and retry") only cleared connectionPromise, leaving the live backend alive; the orphan kept binding PORT_FLOOR (9120) so the next startHermes() hit EADDRINUSE / "Object has been destroyed" and the window looped. Await teardownPrimaryBackendAndWait() so the reset stops the old backend before restarting.

2. pickPort() probes-then-closes a socket before the real bind happens in a separate Python child, so two concurrent spawns (primary + pool backend) could both be handed PORT_FLOOR and one died with EADDRINUSE. The reservation bookkeeping is extracted into electron/port-pool.cjs (PortPool): pickPort() reserves the chosen port until the child exits and releases it on every exit/error/throw-before-spawn path, closing the TOCTOU window.

PortPool is dependency-injected (probe passed in) and socket-free, unit-tested in electron/port-pool.test.cjs (8 cases) and wired into the test:desktop:platforms script.

(cherry picked from commit d4133945b9)
2026-06-11 18:22:54 -05:00
Brooklyn Nicholson
e3ed7722b5 fix(desktop): refuse a foreign backend's session token after readiness
The served-token fallback adopts whatever token the dashboard HTML
injects. That is correct when our own child regenerated the token (env
pin lost across a shell-wrapped spawn), but wrong when the readiness
probe answered from a process we did not spawn: /api/status is public,
so an orphaned dashboard squatting the port passes waitForHermes while
our child dies on the bind conflict. Silently adopting that process's
token would authenticate the renderer against a foreign backend,
possibly on the wrong profile.

Discriminate on child liveness: the desktop pins
HERMES_DASHBOARD_SESSION_TOKEN on every spawn, so a live child always
serves our token. Served-token mismatch + dead child = foreign backend;
fail the boot loudly instead of connecting. Mismatch + live child keeps
the adopt-served-token salvage from #43720.
2026-06-11 18:18:22 -05:00
Evis
7a2d498b9d fix(desktop): route profile session reads
(cherry picked from commit 64aaf58f5e)
2026-06-11 18:09:24 -05:00
Jeff
e96fe06e49 fix(desktop): use served dashboard token for websocket auth
(cherry picked from commit f8209f91d3)
(cherry picked from commit 72290f0809)
2026-06-11 18:07:19 -05:00
Gille
9102d4a588 fix(dashboard): show Windows 11 in host panel (#44511) 2026-06-11 19:06:29 -04:00
Andrew Fiebert
d221e369b8 fix(desktop): recover from transient assistant-ui index-lookup crash (#44493)
`@assistant-ui/store`'s index-keyed child-scope lookup (`tapClientLookup`)
throws — rather than returning undefined — when a subscriber reads an index
the message/parts list no longer has. During high-frequency store replacement
(switching sessions mid-stream, gateway reconnect replay) a subscriber from
the previous, longer list is still in React's notification queue and reads one
slot past the new, shorter array before it can unmount. The throw
(`Index N out of bounds (length: N)`, the classic index === length off-by-one)
unwinds all the way to the root error boundary and blanks the entire window,
even though the store self-heals on the very next consistent snapshot.

Wrap each virtualized message group in a tiny boundary that swallows ONLY this
transient lookup race and auto-recovers when the message signature changes
(the existing list-mutation key). Any other error re-throws to the root
boundary, so genuine bugs still surface.

Upstream-tracked and unresolved: assistant-ui/assistant-ui#4051, #3652.

Co-authored-by: mollusk <mollusk@users.noreply.github.com>
2026-06-11 22:52:37 +00:00
brooklyn!
b1fe2107d6 fix(desktop): keep named-profile desktop backends per-profile (#44510)
Desktop spawns its dashboard backend with `--profile <name>` and
`HERMES_DESKTOP=1`. cmd_dashboard's unified-launch routing treats any
named profile as a request for the shared machine dashboard: it re-execs
as the default profile (dropping HERMES_HOME) or, when one is already
listening, prints "Machine dashboard already running ... Managing profile
'<name>'" and exits 0. Either way the desktop-spawned child exits before
the app sees a ready backend, so Desktop retries forever — the Windows
named-profile boot loop in the post-mortem.

Skip the machine-dashboard reroute when HERMES_DESKTOP=1 so desktop pool
backends stay per-profile (which is what the pool expects). Carved out of
#44478.

Co-authored-by: AJ <yspdev@gmail.com>
2026-06-11 22:47:28 +00:00
brooklyn!
73969771a5 fix(desktop): discover MCP tools for dashboard /api/ws backends (#44512)
The desktop chat surface talks to the dashboard's in-process /api/ws
gateway, which builds agents through tui_gateway.server._make_agent. That
path only snapshots the existing tool registry — MCP discovery is started
by tui_gateway/entry.py (the stdio TUI), which the dashboard process never
runs. So a profile's configured MCP servers never connect under the
desktop app and sessions show no MCP tools.

Start a shared background MCP discovery thread at dashboard startup (via
hermes_cli.mcp_startup, bounded so a slow/dead server can't block boot),
and have _make_agent briefly join that thread in addition to the existing
entry-owned TUI thread before snapshotting tools.

Carved out of #44478.

Co-authored-by: AJ <yspdev@gmail.com>
2026-06-11 22:45:45 +00:00
Austin Pickett
2ee69d0579 fix(skills): let ClawHub index build walk past the 12s browse budget (#44500)
The deploy-site skills index crawl was capped at ~3k ClawHub entries
because CATALOG_WALK_BUDGET_SECONDS applied to max_items=0 walks too.
Only enforce the wall-clock budget for bounded browse requests and pass
limit=0 from build_skills_index so CI walks the full catalog.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 18:03:11 -04:00
Austin Pickett
021ed69141 docs: finish Automation Blueprints terminology rebrand (#44470)
* docs: finish Automation Blueprints terminology rebrand

Replace leftover "Automation Templates" wording from the Cron Recipes
rebrand, rename the copy-paste cookbook guide to Automation Recipes, and
point the marketing gallery link at the blueprints catalog.

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs: use Automation Blueprints instead of Recipes in guide

Rename the cookbook guide from automation-recipes to
automation-blueprints so sidebar and copy match the product term.

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs: rename automation-blueprints-catalog to automation-blueprints

Drop the -catalog suffix from the reference page slug and title, and
move the copy-paste cookbook to automation-blueprint-examples so the
main Automation Blueprints doc is unambiguous.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Revert "docs: rename automation-blueprints-catalog to automation-blueprints"

This reverts commit 605f1eeab5.

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 17:22:22 -04:00
Teknium
6c752ca3a5 refactor(agent): tighten SUMMARY_PREFIX wording and fix stale doc references
Legibility pass on the consolidated prefix: collapse the topic-overlap rule
from three overlapping sentences into one WINS sentence + one discard/no-wrap-up
sentence (same constraints, less dilution), fix the module docstring to
describe the headings that actually shipped, and correct the #10896 comment's
heading name (Historical Pending User Asks).
2026-06-11 13:57:13 -07:00
Teknium
acb2954d82 fix(agent): freeze carveout-era SUMMARY_PREFIX for renormalization
The prompt consolidation above retires the carveout-era prefix. Without a
frozen copy in _HISTORICAL_SUMMARY_PREFIXES, summaries persisted by
pre-upgrade builds would lose detection (_is_context_summary_content) and
renormalization (_strip_summary_prefix) — the exact regression class the
tuple exists to prevent. Adds contract tests covering every frozen prefix.

Refs #41607 #38364 #42812
2026-06-11 13:57:13 -07:00
kyssta-exe
8f8cad7ec5 fix(agent): strengthen compression preamble against stale task execution (#41607) 2026-06-11 13:57:13 -07:00
konsisumer
d5e2fbf244 fix(agent): frame compaction handoff sections as historical context 2026-06-11 13:57:13 -07:00
brooklyn!
484f484c25 fix(desktop): carve sidebar nav rows out of the titlebar drag region (#44453)
A WSL2 user reported the top two left-sidebar items being unclickable
while the rest of the UI works. That symptom shape matches an
-webkit-app-region:drag hit-test band eating clicks, not GPU/compositing:
the shell's titlebar drag strips (app-shell.tsx) span the top 34px and
the nav group clears them by only 6px, and drag regions win hit-testing
over DOM regardless of pointer-events. Linux WCO (Electron >=32) is the
newest implementation and has known region quirks (electron#43030).

Apply the same no-drag carve-out the codebase already uses for sticky
user bubbles (USER_BUBBLE_BASE_CLASS in thread.tsx) to the sidebar nav
buttons. Harmless on every platform: the rows were never meant to be
draggable surface.
2026-06-11 15:10:09 -05:00
teknium1
114e265737 fix(plugins): don't cache a failed discovery sweep as discovered
Root-cause hardening for the stranded-empty-registry failure behind
'No web search/extract provider configured': discover_and_load() set
_discovered=True before scanning, so a sweep that raised partway was
swallowed by callers as a warning and every later call early-returned
against an empty registry for the process lifetime. The flag now acts
only as a re-entrancy guard and is reset when the sweep raises, so the
next call retries discovery.
2026-06-11 12:56:44 -07:00
xxxigm
32a73010bb test(web): cover keyless default surviving a failed plugin sweep
Pins the invariant that _ensure_web_plugins_loaded registers the keyless
Parallel default (and the wider bundled set) even when the general plugin
discovery raises, that the direct-registration fallback honors plugins.disabled,
and that it stays a no-op on the healthy path.
2026-06-11 12:56:44 -07:00
xxxigm
93764b9303 fix(web): guarantee the keyless web default registers even if discovery doesn't
web_search/web_extract are documented to work with zero setup via the bundled
keyless Parallel free-MCP backend, but that only holds when the bundled
plugins/web/* providers are registered. The dispatch relied entirely on the
general plugin sweep to do that; when the sweep finishes without registering
them (its exception swallowed as a warning, a packaged layout where it ran
before the bundled tree was importable, or a stale empty-discovery cache), the
registry is empty and BOTH tools dead-end on "No web {search,extract} provider
configured" — despite needing no setup at all.

_ensure_web_plugins_loaded now verifies the keyless default landed after the
sweep and, if not, registers the bundled web providers directly against the
registry. Idempotent, a no-op on the healthy path (one dict lookup), and honors
an explicit plugins.disabled entry.
2026-06-11 12:56:44 -07:00
Austin Pickett
c3464ecf45 fix(discord): recover from runtime gateway task exits (#44383)
* fix(discord): recover from runtime gateway task exits

Salvaged from #39416 (AMEOBIUS) — cherry-picked only the task-exit
recovery; the original PR was 1081 commits behind with 28 unrelated
commits.

A post-ready discord.py WebSocket crash left the gateway split-brained:
producers stayed active while Discord stopped responding. After this fix
the adapter calls _set_fatal_error(retryable=True) + _notify_fatal_error()
so the existing GatewayRunner reconnect watcher replaces the dead adapter.

Also adds _wait_for_ready_or_bot_exit() so startup failures (SOCKS/proxy
errors, invalid tokens) surface fast instead of burning the full ready
timeout. Because connect() no longer waits via asyncio.wait_for on that
path, test_connect_releases_token_lock_on_timeout is updated to trigger
the timeout through the new helper (same lock-release contract).

3 tests pass (2 new runtime-failure tests + the updated timeout test);
test_discord_connect.py and test_discord_slash_commands.py green.

Co-Authored-By: ameobius <ameobius@local.host>

* fix(test): patch _wait_for_ready_or_bot_exit in timeout cancel test

connect() no longer uses asyncio.wait_for for the ready handshake, so
test_connect_timeout_cancels_bot_task was hanging for 30s in CI.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: ameobius <ameobius@local.host>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 15:39:01 -04:00
ethernet
e080365a7a fix(tui): new weird typeerror 2026-06-11 15:36:39 -04:00
ethernet
5e5308d34d fix(node): fix @types/node version
TODO lock to a specific node/npm version.
this is a fix for a diff between 10 and 11.
2026-06-11 15:36:39 -04:00
teknium1
08b1c44a53 fix(discord): extend bot-task cancellation to connect()'s generic exception branch
Follow-up to #44389: the generic 'except Exception' branch in connect()
had the same orphaned-task hazard as the timeout branch. Extract the
cancel-and-await logic into _cancel_bot_task() and call it from all
three sites (timeout branch, exception branch, disconnect()).

Also adds deaneeth to AUTHOR_MAP.
2026-06-11 12:09:18 -07:00
Dineth Hettiarachchi
020ef76cf1 fix(discord): cancel _bot_task on connect() timeout to prevent zombie client
When connect() times out waiting for the Discord ready event, the background
asyncio.Task running client.start() was not cancelled. discord.py's internal
reconnect loop can ignore client.close() while a WebSocket handshake is in
flight, so the orphaned task eventually completes and fires on_ready.

A later successful reconnect then leaves two live Discord clients in the same
process — each with its own on_message handler and MessageDeduplicator instance
— so every @mention creates two threads because the per-adapter dedup caches
cannot catch cross-client duplicates.

Fix: explicitly cancel and await _bot_task in two places:
1. The asyncio.TimeoutError handler inside connect() — catches the case where
   the adapter's own inner wait_for fires before the gateway's outer timeout.
2. The start of disconnect() — the load-bearing path, always reached via
   _dispose_unused_adapter regardless of which timeout fired first.

Root cause confirmed from production logs: a Jun 8 network outage caused three
consecutive connect() timeouts. The first attempt's bot_task completed its
handshake 4 minutes later ("Connected as") with no preceding watcher line,
then the watcher's real reconnect also connected 90 seconds after that. The two
clients ran continuously for 41+ hours, confirmed by the same user message
appearing as two separate inbound events in two different thread IDs 357ms apart.

Regression tests added to tests/gateway/test_discord_connect.py:
- test_connect_timeout_cancels_bot_task: simulates a connect() timeout with a
  NeverReadyBot and asserts _bot_task is None afterward
- test_disconnect_cancels_running_bot_task: injects a live zombie task, calls
  disconnect(), and asserts the task is cancelled and the attribute cleared
2026-06-11 12:09:18 -07:00
alt-glitch
fcf49f313e opentui(v6): double-click word / triple-click line selection with held drag-extend
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.
2026-06-11 15:58:33 +05:30
alt-glitch
6c76908fde bench: emulator-leg memory verification — tmux server flat ~5MB for both UIs
Pipeline cell re-run with external VmRSS/VmHWM sampling on the dedicated
tmux servers: ink 5.07MB peak, otui 4.94MB peak, zero growth across the
800-msg stream (alt-screen = fixed grid, no scrollback accrual). The
emulator leg is a tie on memory as well as CPU.
2026-06-11 10:30:41 +05:30
alt-glitch
7776aeb064 bench: render refresh + controller gate-replay results
Re-rendered after committing the verification gate replays so the
report's result-file count matches the results directory.
2026-06-11 09:36:18 +05:30
alt-glitch
ad16ec9c53 bench: report rewrite — plain-language verdicts up top, real-workload memory framing, chaos/pipeline/echo sections 2026-06-11 09:32:41 +05:30
alt-glitch
de446a26a5 bench: chaos + pipeline + echo results — both UIs auto-heal gateway death; total-pipeline CPU is parity
Chaos (5 scenarios x 2 UIs): every gateway-death/hang scenario fully
recovers on BOTH UIs — Ink respawns immediately (~80ms, no backoff),
OpenTUI after its 1s backoff; transcripts converge byte-identical to
the never-killed digest; zero orphans. PTY EOF: both exit and reap the
gateway in ~100ms (Ink takes 4.1s to die vs OpenTUI 0.2s).

Pipeline (800 msgs @30ev/s inside a dedicated tmux server): UI CPU
82.4s (ink) vs 79.1s (otui); tmux-server leg ~0.4s BOTH — the
'Ink costs more in the emulator' hypothesis is not supported at this
workload. Frame pacing: otui 22.3fps vs ink 15.8fps, interframe p95
103ms vs 209ms. Echo latency: both excellent (p50 1-2ms); submit to
first-token-paint 44ms (ink) vs 107ms (otui).
2026-06-11 09:19:07 +05:30
alt-glitch
af1e4bb9ab bench: total-pipeline CPU (tmux leg) + frame pacing + input-echo latency 2026-06-11 09:16:08 +05:30
alt-glitch
22792d2791 bench: chaos/stability cells — gateway death, hang, resize storm, PTY EOF 2026-06-11 09:15:04 +05:30
alt-glitch
cbe703cf48 bench: forensics.sh — merged gateway/TUI/OOM/sessions/worktree timeline for a time window
Reconstructs what killed gateways and TUI sessions: merges
~/.hermes/logs, journalctl/dmesg OOM kills, sessions-DB abnormal ends,
and git worktree state into one timestamp-sorted timeline plus a
summary (OOM victims, tui_gateway exit-reason histogram, orphaned
sessions). Findings from the 2026-06-04..11 window: gateway deaths were
kernel OOM kills selecting hermes via OOMScoreAdjust=200 under pressure
from OTHER tools' multi-GB leaks; TUI deaths were top-down SIGHUP/EIO
cascades from unit teardown; tui_gateway itself crashed in 0/152 exits.
2026-06-11 08:18:55 +05:30
alt-glitch
5c6438fd28 bench: T1 real-workload memory cells — session-DB distribution + mem100/300/2000
Real sessions DB (~/.hermes/state.db, 444 tui+cli sessions): p50=20,
p75=53, p90=182, p95=340, p99=1941 msgs. The assumed 200-300 'realistic
band' is actually the p90-p95 region; the typical session is ~20 msgs
and the p99 tail reaches ~1940 (real ~2k-msg sessions exist).

New cells at those anchors (2 reps, ink vs otui-capped, 2G scope), VmHWM
medians: 100 msgs 163 vs 222MB; 300 msgs 180 vs 268MB; 2000 msgs 234 vs
671MB (2.9x). session-distribution.mjs regenerates the JSON; run.mjs
gains --configs to skip redundant configs (otui-uncapped == capped below
the 3000-row cap).
2026-06-11 08:13:00 +05:30
alt-glitch
448e6ee68f cli: worktree lock + dirty-tree preservation — stop pruning uncommitted work
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.
2026-06-11 08:10:55 +05:30
alt-glitch
805e08081f bench: document build/run parity audit (expose-gc inert; pinned-Node caveat) 2026-06-11 08:01:39 +05:30
alt-glitch
fe50861c2e bench: live-attach kit — sample/profile a running TUI session (Ink or OpenTUI) 2026-06-11 07:34:08 +05:30
alt-glitch
e3973050df bench: post-fix otui results — crash eliminated
Re-ran the cells that crashed, against the fixed binary (a939c9a):

- mem3000 otui-capped:   crashed_after_stream exit 7 @ ~900MB
                       → completed exit 0, vmhwm 859MB
- mem3000 otui-uncapped: crashed_after_stream exit 7
                       → completed exit 0, vmhwm 834MB
- slope10000 otui-uncapped: died exit 7 at 3,000 msgs (197d499 result)
                       → completed ALL 10,000 msgs, exit 0, vmhwm 1.57GB —
                         under the 2GB cgroup cap, no cap-hit, no crash
- fresh ink baselines for both cells (mem3000 257MB / slope10k 328MB).

No "Failed to create SyntaxStyle" anywhere; the store cap now binds at the
handle-safe ceiling (1000 rows) long before the native 65,534-slot handle
table exhausts. report.html + report-assets regenerated via render.mjs
(results are append-only; pre-fix runs remain as the baseline).
2026-06-11 04:09:27 +05:30
alt-glitch
a939c9a712 opentui(v6): degrade SyntaxStyle exhaustion, unmask the exit-7 crash, clamp the cap to the 65k native handle table
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).
2026-06-11 04:06:19 +05:30
alt-glitch
e35d953a45 bench: E1/E3 results + report render 2026-06-11 03:22:27 +05:30
alt-glitch
197d499480 ui-tui: env-gated yoga-node sampler for bench instrumentation (dark by default) 2026-06-11 02:29:48 +05:30
alt-glitch
14ee1a52c0 bench: fake-gateway + PTY harness + matrix runner (methodology in docs/plans) 2026-06-11 02:29:37 +05:30
alt-glitch
50e34713b6 opentui(v6): tier-A latex — unicode math with fence-aware preprocessing 2026-06-11 01:44:48 +05:30
alt-glitch
e9af6a5110 opentui(v6): status chrome v3 — one left-aligned labeled line; copy chip off the scrollbar edge 2026-06-11 01:42:59 +05:30
alt-glitch
4f66a7cf09 opentui(v6): code-token scopes in the shared syntax style (highlighting was parsing but painting monochrome) 2026-06-11 00:56:45 +05:30
alt-glitch
5f997247d9 opentui(v6): composer — shift+enter newline (kitty), height cap + internal scroll, line navigation 2026-06-11 00:34:54 +05:30
alt-glitch
ccc89a327d tests: pin envelope fragment-peel guards incl. the known tail-shape tradeoff 2026-06-11 00:24:51 +05:30
alt-glitch
408789d909 opentui(v6): ink-budget follow-up — transparent root canvas; muted stops borrowing banner_dim 2026-06-11 00:16:00 +05:30
alt-glitch
a089614451 gateway: compact /usage with current-session per-model costs
The OpenTUI /usage went through the slash-worker subprocess, which
resumes the session WITHOUT a live agent — so it could never show
current-session tokens or costs, and what it did show landed as a
full-screen page.

- slash.exec now answers /usage in-process from the live agent:
  per-model rows (requests, tokens in/out, cache, provider-reported
  cost when present), session totals/context, a one-line 30-day
  summary (SessionDB.usage_totals, real costs only) and a one-line
  Nous credits gauge (nous_credits_compact_line, refactored out of
  nous_credits_lines). ~8 lines instead of a page.
- Unreported costs render as 'not reported by provider' — never
  $0.00 — and the 30d summary omits cost when no session in the
  window has a provider-reported figure.
- /usage full keeps the detailed legacy CLI page via the worker.
2026-06-11 00:15:00 +05:30
alt-glitch
7592b996a6 gateway: capture real provider-reported cost (openrouter usage accounting)
Cost displays were estimates from a pricing table; on OpenRouter the
status bar never reflected what was actually charged. Now cost is
provider-REPORTED only, end to end:

- OpenRouter requests carry usage:{include:true} (profile + legacy
  transport paths); the response usage.cost field (credits, 1:1 USD)
  is captured per call into agent.session_actual_cost_usd and
  persisted to the sessions DB actual_cost_usd column (NULL-safe:
  unreported calls never touch the stored value).
- Nous keeps its x-nous-credits-* header capture; the header delta
  now surfaces as the session's real cost via real_session_cost_usd.
- Providers that report nothing accumulate NOTHING: cost fields stay
  absent/None (the TUI hides its cost segment), never a fabricated
  $0.00 and never an estimate. _get_usage, gateway /usage and the
  CLI usage page all switched off estimate_usage_cost for display.
- Per-model session accumulator (session_model_usage) records real
  per-call counts and provider-reported cost per model.
2026-06-11 00:14:21 +05:30
alt-glitch
380f0b53dd opentui(v6): responsive two-line chrome at wide widths 2026-06-11 00:09:31 +05:30
alt-glitch
62537a99bf opentui(v6): per-block copy affordance 2026-06-10 23:58:55 +05:30
alt-glitch
ee4fb837ed opentui(v6): ink budget — earned gold, blue machinery, neutral muted (design pass) 2026-06-10 23:55:04 +05:30
alt-glitch
ddf4cca5c0 opentui(v6): resume picker — tabbed /sessions with peek preview (supersedes switcher) 2026-06-10 23:46:07 +05:30
alt-glitch
9122ffffc5 opentui(v6): per-tool content fixes — clarify/skill_view/read/search/exec + tree-sitter outputs 2026-06-10 23:34:03 +05:30
alt-glitch
ebb58f750c opentui(v6): dedupe model.options prefetch with /model open 2026-06-10 23:10:11 +05:30
alt-glitch
b957dc6f72 opentui(v6): model picker provider tabs (nous-first chip strip) 2026-06-10 23:09:41 +05:30
alt-glitch
018c8fb17f opentui(v6): kill expand/collapse scroll jitter (suspend stickyScroll across the toggle)
User feedback: tool/thinking rows did a "v small quick lil jump up and
down" when toggled, worst on the bottom rows.

Root cause (verified live with 10ms tmux capture sampling): the
transcript scrollbox's sticky-bottom re-pin and the scroll anchor fought
AFTER paint. On a toggle near the bottom, the content-height change runs
ScrollBox.recalculateBarProps -> applyStickyStart("bottom") (the user is
at the sticky position, so _hasManualScroll is false), which paints a
fully bottom-pinned frame; the anchor's 4x16ms scrollTo re-asserts then
yanked the viewport back up. The capture burst shows the transient
pinned frame between two anchored ones on every expand — the visible
down-up flick.

Fix at the cause instead of correcting after the effect: suspend
stickyScroll (a runtime get/set property on ScrollBoxRenderable) BEFORE
running the toggle and restore it ~100ms later, once the content height
has settled. With sticky off, the toggle's layout pass leaves scrollTop
untouched — the clicked header's document position is unchanged (content
grows/shrinks below it), so nothing moves and there is nothing left to
flicker; a collapse past the new bottom clamps naturally via the
ScrollBar scrollSize setter. Restoring recomputes the manual-scroll
state from the actual position: still at the bottom -> keeps pinning for
new content; mid-content -> manual-scroll semantics until the user
returns (the same end state the old anchor produced). Rapid re-toggles
inside the window keep the ORIGINAL saved value.

The far-from-bottom anchor guarantee is unchanged (scrollTop is simply
never touched), pinned headlessly in scrollAnchor.test.tsx along with
the suspension sequencing, the clamp-then-re-pin collapse path, and the
double-toggle restore. ffiSafe's tall-diff scroll-cut regression now
drives the negative-y condition explicitly via wheel scrolls (the old
anchor exercised it through the very transient sticky-bottom frames this
fix removes).

Verified live (tmux, real gateway): before — toggling the bottom rows
painted a transient bottom-pinned frame (f141 of a 10ms burst); after —
three toggle bursts produce ONLY the clean before/after states (4
distinct frames in 458 samples), headers hold their row, including the
bottom-most rows.
2026-06-10 22:39:12 +05:30
alt-glitch
7e3936f47d opentui(v6): tool output uncapped by default (env restores a cap)
User feedback: "for all tools, i'd want all their output viewing enabled
to be infinite by default."

Flip envOutputLines (HERMES_TUI_TOOL_OUTPUT_LINES): unset -> Infinity
(was 200); a positive integer RESTORES a cap (e.g. =200); 0 stays
Infinity for back-compat with the old opt-in-unlimited value; garbage ->
Infinity (unrecognized = no cap asked for). The semantic is now "cap
only when the user asked for one".

The store's raw-result preference follows the same rule: envOutputLinesSet
becomes envOutputUnlimited — whenever the cap is unlimited (the default
now) and a gateway tail-capped result_text (omittedNote) arrives with the
always-full raw result on the wire, the raw result wins, since an
uncapped view of a tail would silently miss the head. With an explicit
finite cap the gateway tail + honest omitted note are kept.

Memory safety is unchanged: tool bodies mount only while EXPANDED (rows
default collapsed and free their Yoga nodes on collapse/unmount), and the
rolling HERMES_TUI_MAX_MESSAGES cap bounds the transcript's high-water
mark.

Tests: env.test.ts expectations flipped (unset/garbage -> Infinity, 0
documented as back-compat); tools.test.tsx "flag unset caps at 200"
becomes "unset renders all 250 lines", plus an explicit =50 cap (+note)
test and =200 restored-cap test; the store preference matrix covers
unset/0 (raw wins), =50 (tail+note kept), and no-raw (tail+note, no
crash). Verified live: seq 1 220 expanded renders rows 201-220 with no
"+N more lines" note.
2026-06-10 22:38:49 +05:30
alt-glitch
2f666d2e9b opentui(v6): fix popup-boot latency regression (model.options prefetch blocked the gateway dispatcher)
The native TUI prefetches model.options right after session.create (91df32545,
picker instant-open). The handler is network-bound (~3.7s: pricing fetch + Nous
tier check in build_models_payload) and ran on the gateway's main dispatcher
thread, so every fast-path RPC issued in the first seconds after launch —
complete.slash for the '/' dropdown, session.list, config.get — sat unread
behind it. Measured: first '/' dropdown 1718ms at HEAD vs 53ms at 394f45a3d
(pre-prefetch baseline); 52ms after routing model.options onto the existing
RPC thread pool (_LONG_HANDLERS). The /model picker keeps its 29ms cached open.
2026-06-10 22:20:09 +05:30
alt-glitch
c146a69b1d tests: align dropdown-hint + wrap expectations with arrows-everywhere menus 2026-06-10 22:15:12 +05:30
alt-glitch
773690b1f7 opentui(v6): arrows + enter navigate every completion menu (paths, args) 2026-06-10 22:14:11 +05:30
alt-glitch
ddfff88a58 tests(cli): align tui argv prebuild test with the node-probe launcher 2026-06-10 22:09:43 +05:30
alt-glitch
443a1be509 opentui(v6): port utility commands — compact, details, replay, heapdump, mem 2026-06-10 22:09:39 +05:30
alt-glitch
d96657e2dc opentui(v6): monotonic double-press clock + consume the viewer's closing Esc 2026-06-10 22:08:39 +05:30
alt-glitch
0e65d54b6d opentui(v6): tray-exit Esc never arms the prompt-history double-press 2026-06-10 21:58:15 +05:30
alt-glitch
3ebcc3439e opentui(v6): Esc+Esc session prompt history — rollback/undo confirm 2026-06-10 21:49:17 +05:30
alt-glitch
f86bc5170a tui_gateway: session.list reports scan-cap truncation honestly 2026-06-10 21:44:17 +05:30
alt-glitch
529d8084be tui_gateway+cli: session.list filters + session.peek + bare --resume picker sentinel 2026-06-10 21:31:59 +05:30
alt-glitch
daa4412378 opentui(v6): picker v2.1 — provider search, availability toggle, native input, manual refresh 2026-06-10 21:30:55 +05:30
alt-glitch
7ad05a3129 opentui(v6): skill highlighting + one-edit autocorrect (anti-jank) 2026-06-10 21:27:36 +05:30
alt-glitch
4d1d1e8f52 opentui(v6): header chrome — dense status bar (Variant A) 2026-06-10 21:24:58 +05:30
alt-glitch
0bceb219e6 opentui(v6): standardize fuzzy search on fuzzysort (adapter keeps our API) 2026-06-10 21:04:22 +05:30
alt-glitch
579fb58e86 opentui(v6): background-agents tray — down-arrow focus + enter to dashboard 2026-06-10 21:00:59 +05:30
alt-glitch
91df325458 opentui(v6): model picker v2 — fuzzy search + provider groups + instant open 2026-06-10 20:50:17 +05:30
alt-glitch
43b096eedb tui_gateway: blocking prompts wait for the human (drop _block timeouts) 2026-06-10 20:23:25 +05:30
alt-glitch
394f45a3d5 opentui(v6): slash menu — arrow navigation + enter accept 2026-06-10 20:05:39 +05:30
alt-glitch
6a6693b182 opentui(v6): trust gateway payload.error — drop client-side result sniffing 2026-06-10 19:34:27 +05:30
alt-glitch
03b16c51a6 opentui(v6): tool-name emphasis, thought styling, HERMES_TUI_TOOL_OUTPUT_LINES 2026-06-10 19:31:45 +05:30
alt-glitch
ac84fe7ea1 tui_gateway: surface tool failure as payload.error (result convention) 2026-06-10 19:27:49 +05:30
alt-glitch
afe5152314 opentui(v6): tool lifecycle states — live elapsed tick + failed glyph 2026-06-10 19:12:45 +05:30
alt-glitch
5fd2b5bb7b opentui(v6): suppress redundant JSON/diff-echo output under rendered diffs
A patch tool's result is a JSON record whose payload IS the diff. In a verbose
session the gateway redacts + TAIL-caps result_text (_cap_tui_verbose_text),
so the echo arrived under the native diff in two broken shapes: truncated
mid-JSON (unparseable, so the old JSON.parse check failed open), or — for tall
edits — capped PAST the JSON head, which the store's normalizeOutput then
un-escapes into plain lines that duplicate the diff. North star: no raw JSON
in the transcript, ever.

Three layers:
- gateway: when diff_unified ships, result_text drops the in-JSON diff echo
  (_result_sans_diff_echo) — small, parseable, carries only the non-diff
  signal (success/files_modified/warnings/lsp_diagnostics).
- fileTool diffOutputPlan: anything starting with '{' under a rendered diff is
  suppressed regardless of parseability; parseable JSON with real non-diff
  signal (error/warning/lsp_diagnostics) renders JUST those as labeled notes;
  a non-JSON fragment whose lines echo the rendered diff is suppressed too
  (guards older emitters). Plain-text results (lint tails) still render.
2026-06-10 18:49:03 +05:30
alt-glitch
0bde6a890f opentui(v6): clamp negative draw coords at the node:ffi seam (diff crash fix)
Expanding a tall <diff showLineNumbers> pinned to the scrollbox bottom froze
the TUI with ERR_INVALID_ARG_VALUE looping out of CliRenderer.loop every
frame. Root cause: @opentui/core 0.4.0 marshals OptimizedBuffer
fillRect/drawText/setCell* coordinates as u32 in the FFI table while
LineNumberRenderable.renderSelf passes raw screen coordinates — NEGATIVE when
the diff is partially scrolled above the viewport. Bun's FFI silently wraps
negatives (native side bounds-checks them into a no-op); Node's experimental
node:ffi rejects them. bufferDrawBox already uses i32, which is why ordinary
boxes/text scroll fine and only the diff line-background path crashed.

Fix at the seam we own: boundary/ffiSafe.ts patches OptimizedBuffer to clip
fillRect to the non-negative quadrant and skip negative-origin
drawText/setCell*/drawChar before the FFI call (Bun parity). Installed from
boundary/renderer.ts (live) and test/lib/render.ts (headless). TODO(upstream):
widen those FFI params to i32 so this shim can be deleted.
2026-06-10 18:48:51 +05:30
alt-glitch
e17e94c8de opentui(v6): file tool renderer — relative path + full native diff 2026-06-10 16:48:15 +05:30
alt-glitch
99d163a8ae tui_gateway: send full unified diff (diff_unified) on file-edit tool.complete 2026-06-10 16:42:14 +05:30
alt-glitch
b537a3ba50 opentui(v6): prefer gateway-redacted args_text over raw args in tool renderers 2026-06-10 16:24:16 +05:30
alt-glitch
e7e8c820fc opentui(v6): bash tool renderer — command + full output 2026-06-10 16:18:08 +05:30
alt-glitch
8c26b14931 opentui(v6): tool renderer registry + labeled-args default (no raw JSON) 2026-06-10 16:15:37 +05:30
alt-glitch
a38152cd91 feat(tui): run on Node 26 (one runtime), finalize copy UX, rename to ui-opentui
Ports the engine off the second JS runtime onto Node 26.3 (node:ffi) so the
repo ships a single JavaScript runtime: child_process for the gateway, vitest
for tests, an esbuild + Solid build step. Mouse selection copies the rendered
text you highlight, and the clipboard path is crash-proofed (a broken copy
pipe no longer quits the UI). Renames the engine dir ui-tui-opentui-v2/ ->
ui-opentui/ and updates the launcher/installer/Docker references.
2026-06-09 16:16:48 +00:00
alt-glitch
7af4055ddc opentui(bench): scripts/demo.tsx — view the fixture in a real attachable TUI
Dev demo (not a test): seeds the bench fixture into the store via the resume path
and renders <App> under a real CliRenderer (no gateway) so you can attach over
tmux, scroll, and eyeball the transcript + the rolling-cap truncation notice.
Run: DEMO_TOTAL=240 HERMES_TUI_MAX_MESSAGES=80 bun scripts/demo.tsx
2026-06-09 10:41:13 +00:00
alt-glitch
de9f3effbb opentui(memory): cap default 1500→3000 + honest truncation notice
Bench (realistic fat-turn fixture) put numbers on the cap tradeoff: ~0.65 MB/msg,
~20.4 renderables/msg → 3000 ≈ 2 GB steady RSS, the highest cap within a sane TUI
budget (that ceiling only hit by marathon 3000+-msg sessions; typical cost a
fraction). 1500 was too little scrollback. Tunable via HERMES_TUI_MAX_MESSAGES.

Adds a store `dropped` counter (live overflow in capMessages + the resume slice in
commitSnapshot; reset on clearTranscript) and a dim, selectable=false top-of-
transcript notice — '⤒ N earlier messages — scroll-back capped; full transcript on
the dashboard · session <id>' — so display truncation is visible + points to the
deep-history surface. Display-only: never touches the model's gateway-side context.
2026-06-09 10:25:16 +00:00
alt-glitch
ac7ab6c0c0 opentui(bench): realistic heavy-session fixture (fat tool-turns) + multi-cap matrix
Replaces the synthetic ~5.5-node/msg pushes with a deterministic generator
(scripts/fixture.ts): lorem-ipsum user turns + fat assistant turns (markdown +
reasoning + 1-15 tool parts with multi-line results) driven through the real
apply()/commitSnapshot paths. mem-bench.tsx pumps it + checks the resume path.
Realistic cost is ~20.4 renderables/msg (3.7x synthetic); informed the cap tune.
2026-06-09 10:25:16 +00:00
alt-glitch
8580172d11 opentui(harden): slice the resume snapshot before mounting (no transient over-cap)
commitSnapshot set the full fetched history then trimmed — briefly handing the
whole transcript to <For>. Since Yoga (WASM) layout memory is grow-only, even a
transient over-cap mount permanently ratchets the high-water mark, partly
defeating the cap when resuming a large session (a real one has ~1980 messages).
Slice to MESSAGE_CAP BEFORE the first setState so resume mounts at most the cap.
2026-06-09 09:41:37 +00:00
alt-glitch
af98e6deef opentui(bench): headless memory bench proving the cap bounds Yoga-node growth
Dev bench (not a test, not in the gate suite): mounts <App> under the Solid
test renderer, pushes N streamed turns, samples RSS + mounted-renderable count
with Bun.gc. Demonstrates HERMES_TUI_MAX_MESSAGES=400 pins mounted renderables
at ~2218 vs an unbounded climb to ~55k at 10k messages (RSS flat ~350MB vs
1.3GB). Run: bun scripts/mem-bench.tsx (MEM_BENCH_TOTAL/SAMPLE tunable).
2026-06-09 08:44:30 +00:00
alt-glitch
52aa2f98f9 docs: OpenTUI is the default engine on supported hosts; Ink is the fallback
Note in the README CLI section that the terminal UI defaults to the native
OpenTUI engine on Linux/macOS with Bun (provisioned by the installer), and
that the legacy Ink engine remains the automatic fallback (Windows, Termux,
no Bun) and can be selected explicitly with HERMES_TUI_ENGINE=ink. Ink is
not removed — it's the kept fallback.

No in-repo config example documents display.tui_engine (the published config
reference lives on the docs site, not the repo), so there was nothing to
annotate there.
2026-06-09 08:35:43 +00:00
alt-glitch
fb1fb1e5ca install: provision Bun + OpenTUI engine (best-effort, Ink fallback on failure)
Add an opt-in-safe `install_opentui` stage that provisions the native
OpenTUI TUI engine: it resolves/installs Bun (~/.bun/bin/bun) and runs
`bun install` in ui-tui-opentui-v2 so the launcher's _opentui_available()
probe (Bun + node_modules/@opentui) passes and OpenTUI becomes the default.

Strictly best-effort: skipped on Windows/Termux/Android and when the v2
package is absent; any sub-step failure (no network, Bun install fails,
`bun install` fails) logs a warning via log_warn and returns 0. The stage
never `exit`s and never returns non-zero, so it can't abort the install — a
failed/skipped setup simply leaves the user on the kept Ink fallback.

Registered after node-deps in all three drivers: the monolithic main()
flow, the run_stage_body case dispatcher (opentui-engine), and the
emit_manifest staged-installer JSON.
2026-06-09 08:35:38 +00:00
alt-glitch
87b33cb10c tui: default to the OpenTUI engine when the host can run it (Ink fallback)
Flip the default engine: with no explicit HERMES_TUI_ENGINE env / display.tui_engine
config, resolve to 'opentui' when this host is genuinely set up for it (Bun resolves +
the v2 package's entry + node_modules present + not Windows/Termux), else 'ink'. An
explicit env/config choice still wins, and 'ink' remains the universal opt-out. Hosts
without the OpenTUI setup are unaffected (stay on Ink), so nothing strands a user.

- _config_tui_engine_early() now returns None (not 'ink') when unset, so the caller
  distinguishes 'explicitly ink' from 'unset' and applies the availability-gated default.
- _bun_bin() split: _bun_bin_or_none() is the non-fatal probe; _bun_bin() still exit(1)s
  on the explicit launch path. New _opentui_available() gates the default.
- Verified the full resolution matrix (7 cases) + that the platform/availability gates hold.
2026-06-09 08:31:30 +00:00
alt-glitch
51031ec655 opentui(ts): shared envFlag parser
Extract one boolean env-flag parser (src/logic/env.ts: envFlag + the shared
TRUE_RE/FALSE_RE) instead of per-file regexes. Rewire entry/main.tsx
(HERMES_TUI_FAKE → envFlag(…, false); HERMES_TUI_MOUSE → envFlag(…, true)) and
logic/theme.ts (detectLightMode's HERMES_TUI_LIGHT tri-state now uses the
shared regexes; the lowercased-input + /i regex is behaviorally identical to
the prior lowercased-input + non-/i regex). Semantics are byte-identical.
Adds src/test/env.test.ts (true/false/unset/garbage→fallback).
2026-06-09 08:26:43 +00:00
alt-glitch
edc6e67add opentui(ts): collapse prompt accessors into a generic narrow()
Replace the ~5 near-identical `as*()` accessors in
view/prompts/promptOverlay.tsx (one per ActivePrompt kind) with one generic
`narrow(kind)` helper that narrows the discriminated union via a typed type
guard (`p is Extract<ActivePrompt, { kind: K }>`) — no `as`. Each <Match>
branch keeps its precise typed payload. Behavior is identical.
2026-06-09 08:26:36 +00:00
alt-glitch
2d3cf85d67 opentui(ts): deferClose helper for overlay-close defers
Extract the repeated `setTimeout(() => …close…, 0)` overlay/prompt-close
pattern into a single `deferClose(fn)` helper (src/logic/defer.ts) so the
"why deferred" rationale (let the closing keystroke finish dispatching before
the composer remounts/refocuses) lives in one place.

Rewires the 5 close-defer sites: closePager/closeDashboard/closeSwitcher/
closePicker in view/App.tsx and clearSoon in view/prompts/promptOverlay.tsx.
Timing is unchanged (0ms). Other setTimeout uses (quit window, flashHint,
scroll re-anchor, resize debounce, transport) are NOT close-defers and are
left untouched.
2026-06-09 08:26:24 +00:00
alt-glitch
8f112b0633 opentui(ts): enforce no-unsafe-* + require-await as errors (prod .ts), exempt JSX views + tests
Production boundary/logic .ts is clean of the no-unsafe-* family (gateway
payloads are Schema-decoded), so promote it from warn to error. *.tsx is
exempted: @opentui/solid's JSX namespace types every component return as
error/unknown — a framework limitation, not unsafe app code. Test helpers/
mocks (loose fixtures + async signatures) are exempted too. Remaining warns
are no-unnecessary-condition: intentional defensive guards on untrusted
runtime/gateway data that TS's narrowing can't model.
2026-06-09 08:21:13 +00:00
alt-glitch
216790a8f8 opentui(ts): decode SessionInfo + Catalog via Schema (drop the as-casts)
Replace the two ad-hoc as-cast loose readers in src/logic/store.ts with
effect Schema decode-at-boundary. New src/boundary/schema/SessionInfo.ts
defines SessionInfoPatchSchema + CatalogSchema (decodeUnknownOption),
mirroring GatewayEvent.ts. readInfoPatch + setCatalog now decode once and
build the typed patch/Catalog from the result (Option.none → empty
patch / catalog unset, never crashes). Wire field names verified against
tui_gateway/server.py. Removed the now-dead readOptBool helper. Tests
extended for nested-usage vs top-level context fallback, malformed/partial
payloads, and a garbage catalog.
2026-06-09 08:17:47 +00:00
alt-glitch
2a25c1c40b opentui(ts): rotate the NDJSON log file (bounded disk use)
The ring buffer is bounded (2000) but the NDJSON file was append-only and grew
forever. Add size-based rotation mirroring opencode's keep-N model: track bytes
written in-process (seeded from statSync on open, so we avoid a statSync on every
write) and, when the next line would cross LOG_MAX_BYTES (5 MiB), shift
.log -> .log.1 -> ... -> .log.5 (LOG_KEEP=5, oldest dropped) and resume on a
fresh file. Rotation is best-effort and fully try/catch-wrapped: any fs failure
leaves us appending to the existing file rather than crashing logging. Adds a
temp-dir rotation test (seeds >5 MiB to force a rotation on next write).
2026-06-09 08:11:01 +00:00
alt-glitch
d0b14bc6ef opentui(ts): safe-stringify log payloads (circular/BigInt-proof)
A caller-supplied `data` with a circular reference or BigInt makes plain
JSON.stringify throw inside the file-write catch, flipping `fileBroken` and
killing ALL file logging for the session. Add `safeStringify` (WeakSet circular
guard, BigInt -> `${n}n`, wrapped to never throw) and use it for entry
serialization, so a bad payload degrades to a placeholder instead of breaking
the sink. Also model LogLevel schema-first via Schema.Literals + inferred type
(matches boundary/schema/GatewayEvent.ts), and add focused safeStringify +
poison-payload tests.
2026-06-09 08:10:20 +00:00
alt-glitch
c70620e4a0 opentui(ts): enforce prettier in the gate
Add a [1/4] format step to scripts/check.sh running
`bunx prettier --check src` (matching how the script invokes the other
tools), renumbering the existing steps to 2-4. Future formatting drift
now fails the gate.

The unused-imports/no-unused-vars warn → error promotion shipped in the
no-non-null-assertion commit (where the eslint rule changes live).
2026-06-09 08:03:38 +00:00
alt-glitch
2b1564199c opentui(ts): normalize formatting with prettier
Run `prettier --write src` over ui-tui-opentui-v2 to normalize formatting
to the repo .prettierrc (no semicolons, single quotes, width 120,
arrowParens avoid, trailingComma none). This worktree had pre-existing
prettier-version divergences across 19 files; normalizing is correct.
No behavior changes — formatting only. The gate (type-check → lint →
bun test) stays green.
2026-06-09 08:03:08 +00:00
alt-glitch
fdc0e5fea5 opentui(ts): no-non-null-assertion + noUnusedLocals + noImplicitReturns
Promote strictness in ui-tui-opentui-v2:
- eslint: @typescript-eslint/no-non-null-assertion: error (with a test
  override block keeping `!` in *.test.ts/tsx fixtures), and promote
  unused-imports/no-unused-vars warn → error.
- tsconfig: add noUnusedLocals + noImplicitReturns.

Remove all 24 production `!` non-null assertions by replacing each with
a real guard / default / early-return, preserving rendered behavior:
- gateway/client.ts: read the pending entry once and guard (vs has()+get()!).
- logic/theme.ts: guard the parseHex regex match; `?? 0` on the always-
  in-bounds XTERM_6_LEVELS lookups; restructure backgroundLuminance to
  branch into a typed tuple and use charAt() for the 3-digit hex expand.
- view/homeHint.tsx: use Solid's <Show>{value => …} callback form to
  narrow info().model / info().cwd instead of `!`.
- view/reasoningPart.tsx: guard the regex match before slicing m[0].
- view/statusBar.tsx: read model/cwd/pct into locals + guard; `?? 0` on
  the showBar()-guarded context-bar percentage.
- view/toolPart.tsx: guard the single-arg entry; `?? 0` on the
  duration-guarded fmtDuration.
2026-06-09 08:02:25 +00:00
alt-glitch
b3d2de87f9 opentui(ts): type-aware eslint (projectService + recommendedTypeChecked); defer cast-family to warn
Enable type-aware linting in ui-tui-opentui-v2: add projectService +
tsconfigRootDir to the TS files block and switch the preset to
recommendedTypeChecked. Turn ON as ERROR the high-value promise rules
(no-floating-promises, no-misused-promises, await-thenable) and fix the
3 real floating-promise sites in the gateway client (FileSink
write/flush/end are fire-and-forget on a piped child stdin — marked
with explicit `void`).

Defer the cast/unknown family + the noisy type-checked rules to 'warn'
(gate stays green; eslint exits 0 on warnings) for Phase 2, which will
replace the `as`/`unknown` boundary casts with Schema decoding:
no-unsafe-{assignment,member-access,argument,return,call},
no-unnecessary-condition, no-base-to-string, restrict-template-
expressions, no-unnecessary-type-assertion, require-await.
2026-06-09 07:58:50 +00:00
alt-glitch
cff7b365d2 tui(opentui): preflight node_modules before spawning the Bun engine
Bun runs the TS entry directly (no build step), so a missing `bun install`
otherwise surfaces as a cryptic '@opentui' resolve crash + blank UI. Fail
loudly with the fix instead. Part of the gateway build/run hardening.
2026-06-09 07:54:18 +00:00
alt-glitch
0240299fb0 opentui(harden): startup-readiness timeout + stderr-tail diagnostic
Arm a startup watchdog after spawning the gateway child: if the unsolicited
gateway.ready handshake never arrives within HERMES_TUI_STARTUP_TIMEOUT_MS
(floor 2s, default 20s), emit a gateway.start_timeout event so the store can
surface a failure line + the captured stderr tail instead of a silent blank UI.
Cleared on ready (dispatch), on stop(); re-arms per recovery respawn.
2026-06-09 07:53:23 +00:00
alt-glitch
2f30c09378 opentui(harden): configurable RPC timeout
Read HERMES_TUI_RPC_TIMEOUT_MS for the JSON-RPC request timeout (floor 5s,
default 120s) — Ink parity, env-tunable for slow handlers.
2026-06-09 07:52:40 +00:00
alt-glitch
90840708f1 opentui(harden): clear the recovering status once the gateway is ready again 2026-06-09 07:49:46 +00:00
alt-glitch
60f47eab37 opentui(harden): auto-heal — restart + resume on gateway crash 2026-06-09 07:45:58 +00:00
alt-glitch
04704c103e opentui(harden): gateway recovery policy (count-cap + exp backoff) 2026-06-09 07:39:36 +00:00
alt-glitch
6d2211d9d0 opentui(harden): surface gateway exit/recovery + transport errors to the UI 2026-06-09 07:39:31 +00:00
alt-glitch
cdeef30c62 opentui(harden): rolling message cap bounds the Yoga node high-water mark 2026-06-09 07:31:14 +00:00
alt-glitch
3d3fc24d9a opentui(copy): theme the selection highlight
Apply the existing theme selectionBg token to the plain <text> content
renderables (TextBufferRenderable supports selectionBg/selectionFg) so a
selection draws a clean solid bar that PRESERVES the text fg (no selectionFg →
no SGR-inverse fragmenting). Applied to:
- messageLine: the flat settled/user/system message text.
- toolPart: the args value lines + the output body lines.
Limitation: assistant answers rendered via the native <markdown> renderable
(MarkdownRenderable extends Renderable, not TextBufferRenderable, and
MarkdownOptions has no selectionBg/selectionFg) cannot take the themed highlight
— they fall back to the renderer's default selection style.
2026-06-09 07:19:39 +00:00
alt-glitch
2e31140728 opentui(copy): mask chrome/gutters so free-form copy is clean
Audit selectable masking (free-code noSelect model) so a free-form drag over an
agent turn yields CLEAN pasteable content — no labels, summaries, carets, or
annotations. Newly masked (selectable={false}):
- toolPart: the whole collapsed header row (name + args-preview + duration +
  "(N lines)") summary; the "args"/"output" section labels; the args overflow
  "… +N more"; the "… omitted N" / "… +N more lines" truncation notes.
- messageLine: the streaming caret (▍) — a cursor glyph, not content.
- reasoningPart: the collapsible-section header label (Thinking/Thought + title).
- composer: the completion dropdown rows + the "Tab complete · Esc dismiss" hint.
Kept selectable (real content): assistant markdown, tool args values + output
body, user/system message text.
2026-06-09 07:18:48 +00:00
alt-glitch
f4a83c9298 opentui(copy): copy-on-select (auto-copy on selection finish)
Subscribe to the renderer's "selection" event (fires once when a free-form
mouse selection completes) and auto-copy the spanned selectable text via the
existing onCopySelection callback. Unlike the Ctrl+C path, this does NOT
clearSelection() — the highlight persists so the user sees what was copied and
Ctrl+C still works. writeClipboard is idempotent so both paths are harmless.
2026-06-09 07:14:09 +00:00
alt-glitch
00cb21de3e opentui(copy): /copy [n] copies the agent response 2026-06-09 07:09:10 +00:00
alt-glitch
0437dd060c opentui(copy): assistant-text extraction helpers 2026-06-09 07:09:07 +00:00
alt-glitch
bc9447d23b opentui(harden): fix 3 triaged findings (timer leak, tool-match scope, complete-only)
Subagent hardening pass over boundary/logic/view, findings triaged (most were
false positives or app-lifetime-moot). The 3 genuine fixes:
- liveGateway.stop() now clears the pending 16ms coalesce timer before
  client.stop() — a queued flush() could otherwise fire batch()/handlers into a
  torn-down store after the layer scope releases.
- store.findToolPart now scans only the LIVE (last) assistant turn, not every
  message — a tool.complete pairs with a tool.start in the current turn, so this
  avoids matching a same-id tool in an older/resumed turn (and is O(parts)).
- store message.complete with text but NO prior start/delta now creates the turn
  (complete-only gateways) instead of dropping the final text; still no empty
  bubble when there's no text. +2 regression tests.

Triaged as NOT-a-bug / accepted-risk (documented so they're not relitigated):
@opentui/solid useKeyboard DOES auto-cleanup (onCleanup keyHandler.off, index.js:59);
dimensions/scrollAnchor timers are app-lifetime / try-catch-safe; unbounded-growth,
duplicate-dedup, and split-frame are theoretical for a trusted local newline-framed
subprocess; clipboard spawn timeout + atomic active-session write are minor follow-ups.
93 pass.
2026-06-09 06:35:29 +00:00
alt-glitch
79dc862680 opentui(test): track the test harness (test/lib/) swallowed by global lib/ ignore
The Solid render-test harness (src/test/lib/render.ts + effect.ts) was never
committed — a global ~/.gitignore_global `lib/` rule silently excluded it, so the
opentui-v2 test suite wasn't reproducible from a clean checkout (render.test.tsx
imports ./lib/render). Force-add both + add a repo .gitignore negation
(!src/test/lib/). render.ts also carries the withKeymap() wrapper the keymap
migration needs (view tests mount under a KeymapProvider). 91 pass.
2026-06-09 06:25:55 +00:00
alt-glitch
01fa8dcc00 opentui(keymap): adopt native @opentui/keymap for overlay close + confirm
@opentui/keymap@0.3.2 was installed but unused (the spec said we'd use it). Wire
it natively: createDefaultOpenTuiKeymap(renderer) + <KeymapProvider> at the render
root, and a useCloseLayer(target,onClose) helper that registers a focus-within
Esc/Ctrl+C → close layer. Migrated the close handling of sessionSwitcher, picker,
approvalPrompt (close-only), confirmPrompt (y/n via confirm/cancel commands), and
pager + agentsDashboard (close via keymap; scroll/select stay raw — not cleanly
focus-gated). Overlays gain a root ref + focus-on-mount so the focus-within layer
activates. q-close re-added to pager/dashboard (footer advertises it).

Composer history/refocus + masked prompt + the Ctrl+C quit machine stay raw by
design (need the in-flight keystroke / careful state). Test harness gains a
withKeymap() wrapper so view tests mount under a provider. 91 pass; live-verified
/sessions + /tools Esc-close and composer focus recovery after.
2026-06-09 06:24:14 +00:00
alt-glitch
3882cc6e61 opentui(input): "Pasted text" placeholder for large pastes
Large bracketed pastes no longer flood the composer. On paste, if the text is
≥4 lines or >400 chars, insert a compact `[Pasted text #N +M lines]` chip and
hold the real content in a PasteStore; on submit, expand the chip back to the
full text before sending (free-code model). Single-pass String.replace keeps a
pasted block that itself contains a `[Pasted text #k]` literal safe.

The store is created ONCE in main.tsx and passed App→Composer (NOT per-composer)
so it survives the composer remounting on overlay open/close — a per-composer
store would lose a pending paste mid-compose. +6 unit tests (91 pass). Verified
live: paste 10 lines → chip; submit → transcript shows the full expanded code;
composer cleared.
2026-06-09 06:09:49 +00:00
alt-glitch
6b87243ecd opentui(input): auto-expanding composer textbox
The composer was a fixed height:3. Match free-code/opencode: native textarea
auto-grow via direct minHeight={1} maxHeight={max(6,⌊rows/3⌋)} props (opencode's
prompt sizing) — 1 row when empty, grows with wrapped/multiline content up to ~a
third of the screen, then scrolls internally. maxHeight is a DIRECT reactive prop
(not in style) so the cap tracks terminal resize via useDimensions. 85 pass;
verified live (a long wrapping line grew the box to 3 rows).
2026-06-09 06:05:05 +00:00
alt-glitch
49d90e68c6 opentui(v5b): fix first-letter duplication on always-active refocus
Typing while the textarea was unfocused doubled the FIRST letter: the always-active
handler did ta.focus() AND ta.insertText(key.sequence), but the renderer runs the
global useKeyboard handler BEFORE routing the key to the focused renderable — so
after focus() the same keystroke was also delivered to the now-focused textarea,
inserting it twice. Subsequent keys were fine (textarea already focused → block
skipped). Fix: focus() only; let the textarea insert the char it now receives.
Verified live: typing 'x' then 'y' while blurred yields '❯ xy' (no dup). 85 pass.
2026-06-09 05:36:37 +00:00
alt-glitch
2cd122c9c1 opentui(v5b): frame the startup panel in a themed border box
Design-judge top nit: Ink's bordered-box-around-the-session-info is the single
biggest 'designed home screen vs log output' signal, and the flat left-aligned
version lacked it. Wrap the model/dir/session block + Tools/Skills/MCP sections +
summary in a full border box (theme border token); banner+tagline stay above,
tips below. 85 pass.
2026-06-09 05:22:26 +00:00
alt-glitch
53438228ee opentui(v5b/item1): Ink-parity startup banner panel
Rebuilt the home screen to match hermes --tui: the HERMES-AGENT banner + tagline,
then a session info block (model · Nous Research / dir (branch) / Session: <id>),
then SEPARATE collapsible sections — Available Tools (enabled toolsets each as
'name: tool1, tool2', capped + '(and N more toolsets…)'), Available Skills (N) in
M categories, MCP Servers (N) connected — and a '… /help for commands' summary.
Previously it was one combined '▶ N tools · M skills · K MCP' dropdown that only
listed tools and showed no model/dir/session.

- gateway startup.catalog now returns per-toolset {enabled, tools} (resolved_tools,
  session-aware enabled set — mirrors tools.list); py_compile OK.
- store Catalog gains toolset.enabled/tools; new sessionId field + setSessionId,
  set on session create/resume (alongside the active-session-file write).
- homeHint takes the store, reads info (model/cwd/branch) + sessionId + catalog.
85 pass; verified live (model·Nous·dir·session + enabled toolsets w/ tools).
2026-06-09 05:19:21 +00:00
alt-glitch
503c1201ff opentui(v5b): visual hierarchy — color-code roles + clean turn spacing
The transcript read as one undifferentiated gold blob (user/assistant/tool all
the same color). Adopt the Ink model where color IS the hierarchy, in 3 brightness
tiers:
- USER input  → label (gold)        — the human's turn stands out.
- ASSISTANT answer → text (bright)   — the primary content, brightest.
- TOOL / REASONING → muted (dim) with an ACCENT glyph (/▶/▼ amber) that marks
  the block — clearly the secondary 'working area' below the answer.
Spacing: one blank line above every turn (was cramped: user had a blank, the
reply didn't) + the existing gap:1 between parts. Dropped the transcript's extra
marginTop (turns own their spacing now). 85 pass; verified live — gold ask, dim
tool, white answer read as three distinct things.
2026-06-09 05:13:37 +00:00
alt-glitch
e44b43ad16 opentui(v5b/item5): track active session for the post-quit resume epilogue
The launcher (hermes_cli/main.py _print_tui_exit_summary) reads
HERMES_TUI_ACTIVE_SESSION_FILE to print 'Resume this session with…' on exit. The
Ink TUI writes the current session id there on every session change
(useSessionLifecycle.writeActiveSessionFile); the native engine never did, so
after a /session switch the launcher fell back to the INITIAL launch session and
showed resume info for the wrong session (the reported leak).

Now writeActiveSession() writes {session_id} on session.create AND inside
resumeInto (every /session switch), mirroring Ink. Verified live: file shows the
created session, then updates to the switched-to session. 85 pass.
2026-06-09 05:08:57 +00:00
alt-glitch
cd09aa61ef opentui(v5b/item4): hold viewport on tool/thinking expand (no scroll jump)
The transcript scrollbox (stickyScroll+stickyStart=bottom) re-pins to the bottom
on any content-height change when the user is at the bottom (@opentui/core
ScrollBox: `if (stickyStart && !_hasManualScroll) applyStickyStart`). So expanding
a tool/thinking block scrolled the clicked header up off-screen. A
ScrollAnchorProvider (transcript owns the scrollbox ref) lets toolPart/reasoningPart
wrap their toggle so scrollTop is held constant across the height change (re-asserted
over a few frames as layout settles) — the clicked header stays put and the
expansion reveals beneath it. 85 pass.
2026-06-09 05:05:01 +00:00
alt-glitch
c507ca6b3b opentui(v5b/item2+3): fix streaming flicker + native markdown tables
#2 (flicker regression): my item-7 AssistantText wrapped text in
<For each={segmentMarkdown(text)}> — segmentMarkdown returns NEW objects per
delta, so <For> (keyed by reference) DISPOSED and re-created the markdown
renderable on EVERY streamed delta. Each remount re-measured from zero → content
height oscillated → the scrollbar grew/shrank (exactly the reported symptom).

Fix (deep opencode parity): render assistant text as ONE stable native
<markdown> (MarkdownRenderable) fed the growing content in place, with
internalBlockMode="top-level" — opencode's anti-flicker mode where settled
top-level blocks aren't re-rendered per delta (_stableBlockCount, managed
internally). This is opencode's TextPart verbatim (routes/session/index.tsx:1687).

#3 (table inline formatting): the native <markdown tableOptions={{style:grid}}>
renders GFM tables as a grid WITH inline bold/italic/code in cells — so the
hand-rolled segmentMarkdown + MdTable grid are deleted (obsolete). Switched from
<code filetype=markdown> to <markdown> (the former re-measured the whole buffer
each delta and never aligned tables). 85 pass; verified live (smooth stream,
boxed table, concealed **/* markers styled).
2026-06-09 04:57:57 +00:00
alt-glitch
d90e195670 opentui(v5/item4): coalesce resize via a shared debounced dimensions signal
Raw useTerminalDimensions fires on every SIGWINCH tick; during a drag that's a
recompute/reflow storm across every width-sensitive component (tool bodies,
tables, status bar, banner). Add a DimensionsProvider that runs the raw hook
ONCE and feeds a single leading+trailing-debounced (40ms) signal — mirroring the
gateway's 16ms event coalescing / opencode's createLeadingTrailingSignal — that
every consumer shares via useDimensions(). They now reflow together (no tearing)
and at most once per window. Falls back to the raw hook outside a provider
(headless tests). Verified: single resizes converge clean (wide banner ⇄ compact
brand at the 102-col threshold); rapid bursts coalesce. 90 pass.
2026-06-09 04:08:56 +00:00
alt-glitch
793462a395 opentui(v5/item9): startup HERMES banner + collapsible tools/skills/MCP panel
Home screen now shows the canonical HERMES-AGENT block logo (hermes_cli/banner.py,
gold->amber->bronze via primary/accent/border tokens; width-guarded to a compact
brand line under 102 cols) plus a collapsible '▶ N tools · M skills · K MCP' panel
that expands to per-toolset / per-category / per-server detail.

Data comes from a new opt-in gateway RPC 'startup.catalog' (aggregates
get_all_toolsets + banner.get_available_skills + config mcp_servers); the native
engine fetches it best-effort on session start (Effect.catchCause swallows it on
old gateways). Opt-in => Ink path untouched. py_compile OK. Store gains a typed
Catalog + defensive setCatalog mapper. +2 tests (90 pass); verified live
(1185 tools / 196 skills / 2 MCP, expand shows the full lists).
2026-06-09 04:04:43 +00:00
alt-glitch
636bb6e928 opentui(v5/item8): design polish — header chrome, status segments, ANSI strip
Visual-hierarchy pass (design-reviewed against free-code/opencode):
- header: brand glyph in accent + name in primary/bold + a bottom rule, so it
  reads as chrome and bookends the transcript with the status bar's top rule
  (fixes 'nothing differentiates the header from the text stream').
- status bar: a dim │ divider segments model·effort from the context meter.
- user/assistant turn glyphs bold + the user ❯ in accent so turns are scannable.
- reasoning 'Thought' label uses label (not warn) so it matches tool headers —
  warn is reserved for warnings; reasoning/tool now read as one aside family.
- home screen: brand in primary/bold, command names in accent vs muted descs,
  wider column.
- FIX (load-bearing): strip ANSI/SGR escape sequences from slash/notice text
  (pushSystem + openPager) — the gateway colors them for Ink, which interprets
  them; the native <text> rendered them as literal  glyphs. +stripAnsi
  + 3 tests. All tokens themed (no hardcoded colors). 88 pass.
2026-06-09 03:56:44 +00:00
alt-glitch
0da48b0c7f opentui(v5/item7): render GFM markdown tables as aligned grids
The native <code filetype=markdown> colorizes pipes but never aligns tables.
Add a pure segmenter (segmentMarkdown) that splits assistant text into prose
runs (native renderable) and GFM table blocks, plus an MdTable grid renderer:
per-column widths (free-code's stringWidth+padAligned), :--- / :--: / ---:
alignment, bold header, dim │ separators, a ┼ header rule, width-aware column
shrink on resize. Incomplete tables (no separator yet, e.g. mid-stream) stay
prose until they close. +5 unit tests (85 pass); verified live with a 3-col table.
2026-06-09 03:48:02 +00:00
alt-glitch
50023fd151 opentui(v5/item5): stabilize inter-part spacing (kill streaming jitter)
Blank lines between reasoning/tool/text grew and shrank mid-stream because
spacing was ad-hoc: tools carried marginTop:1, text/reasoning none, and the
markdown text part rendered the model's leading/trailing newlines as transient
blank lines that filled in as deltas arrived.

Now the parts column owns ALL spacing via gap:1 (uniform 1 line between any two
parts regardless of type/order), per-part marginTop is dropped, and text parts
are stripped of leading/trailing blank lines so the gap is the sole source —
no double gaps, no popping. Verified live: Thought/tool/tool/answer all spaced
by exactly one line. (80 pass.)
2026-06-09 03:43:43 +00:00
alt-glitch
5352aec064 opentui(v5/item6): collapsible thinking traces
Reasoning rendered as an always-expanded plain muted blob. Now it's a proper
collapsible part (opencode ReasoningPart): auto-EXPANDED while the turn streams
(watch it think), then collapses to a one-line `▶ Thought: <title>` when settled;
click toggles. Title is the model's leading `**bold**` line (reasoningSummary).
Body renders as DIM markdown in a left-`│`-border block (Markdown gained an
optional `fg` so reasoning is muted vs the answer). +1 render test (80 pass).
2026-06-09 03:37:42 +00:00
alt-glitch
7b14c51e7b opentui(v5/item1): resumed tools render like live (collapsible + output)
Resumed tool calls were flat `name arg` rows — no output, not collapsible —
because the resume snapshot (_history_to_messages) dropped each tool's result.
Now the native engine passes `with_tool_output: true` on session.resume and the
gateway folds the tool's redacted+capped result + args into its row, so resumed
turns show `▶ name arg (N lines)` collapsible blocks identical to a live turn.

The flag is OPT-IN: Ink doesn't pass it, so _history_to_messages stays byte-for-
byte unchanged for the Ink path (its expanded verbose-trail render OOM'd on big
output, #34095; the native engine renders tools collapsed, so the capped tail is
safe there). resume.ts maps context→argsPreview, result_text→resultText (label
peeled + envelope stripped), args→argsText — same shape as the live tool part.

py_compile OK. resume tests updated + 1 added (79 pass).
2026-06-09 03:32:52 +00:00
alt-glitch
8c1b62e72f opentui(v5/item2): surface tool-call args + de-pad output
The gateway already ships per-tool arg metadata the client was discarding:
`context` (build_tool_preview's primary-arg line, always sent), `args` (full
dict on complete), `args_text` (redacted JSON, verbose), `duration_s`. Capture
them on the tool part and render free-code style:

- collapsed header: `▶ name <arg-preview> · <duration> (N lines)` — args are
  finally visible without expanding (the core item-2 complaint).
- expanded: a single left-bordered (`│`) column with a key:value args block
  (suppressed when the lone arg is already the header preview — judge nit) then
  the output block.
- strip the gateway's `[showing verbose tail; omitted N chars]` banner into a
  tidy `… omitted N chars` note; unwrap tail-capped `{"output":…}` envelope
  fragments so the last line isn't a dangling JSON tail.

Left bar is a border glyph (opencode BlockTool style), not a bg fill — cleaner
and renders faithfully. +4 unit tests, +1 render test (78 pass).
2026-06-09 03:24:15 +00:00
alt-glitch
e136314039 opentui(v5/item3): composer flush to bottom — drop root paddingBottom
The root box used padding:1 (all edges), reserving a blank row BELOW the
status-bar+composer block. Switch to paddingTop/Left/Right only so the input
hugs the last terminal row. Transcript stays flexGrow:1 minHeight:0; the
bottom block is the flexShrink:0 last child. StatusLine already renders
zero-height when idle, so no other change is needed.
2026-06-09 03:00:16 +00:00
alt-glitch
73d5c2871d opentui(v2): home hint (item 12) + verify /goal + expand feature matrix (item 8)
Item 12 — the missing helper/home screen: view/homeHint.tsx renders on an empty
transcript (Ink helpHint.tsx parity) — brand line, common commands (/help /model
/sessions /skills /agents /clear), and input tips (type · ↑↓ history · @file ·
Ctrl+C). Decorative → selectable={false}. Replaced by the transcript on the first
turn.

Item 8 — /goal verified live: slash.exec rejects it (pending-input) → dispatch
falls to command.dispatch {name:'goal'} → {type:'send', notice:'⊙ Goal set…',
message} → notice shown + the goal turn submitted (handleDispatchResult). Wired.

Docs: opentui-feature-map.md gains the full 15-item live-feedback parity matrix
(Ink/opencode primitive · v2 file · status); opentui-smoke.md gains the 15-item
run log. All 15 items  (image-paste wired but unverified in the clipboard-less
CI env).

Tests: home-hint render. 72 pass. Live-smoked: empty launch shows the home hint.
2026-06-08 18:26:13 +00:00
alt-glitch
d46a8f4492 opentui(v2): clipboard copy/paste, image paste, glyph-free selection (items 1, 4)
Item 1 — copy/paste:
- boundary/clipboard.ts (ported/trimmed from opencode): writeClipboard = OSC 52
  (SSH/tmux-safe) + a native command (pbcopy/wl-copy/xclip/xsel/clip);
  readClipboardImage = clipboard PNG via wl-paste/xclip/pngpaste/powershell.
- Ctrl+C copies a live MOUSE SELECTION (renderer.getSelection) before the
  interrupt/quit machine runs (opencode's selection-key precedence), with a
  "Copied to clipboard" hint; falls through to interrupt/quit when there's no
  selection.
- text paste inserts natively (textarea handlePaste); the composer's onPaste only
  intercepts an EMPTY bracketed paste (image-only clipboard) → readClipboardImage
  → image.attach_bytes (the next prompt.submit picks it up).

Item 4 — mouse selection now ignores decorative glyphs: selectable={false} on the
message/tool gutter glyphs and all chrome (header, status bar, status line,
composer prompt glyph), so a drag copies the message text, not ❯/⚕/▶/.

Live-smoked (this env has no clipboard tools/DISPLAY, so native copy + image read
can't be confirmed here, but): drag-select + Ctrl+C → "Copied to clipboard" (not
quit); no-selection Ctrl+C still arms quit; bracketed text paste lands in the
composer. 71 pass.
2026-06-08 18:20:59 +00:00
alt-glitch
eaee382b47 opentui(v2): fix streaming caret alignment during model response (item 10)
A just-started assistant turn (message.start, no deltas yet) rendered an EMPTY
fallback <text> on the glyph's line plus the `▍` caret on a SEPARATE line below —
so `⚕` sat alone with the caret dangling beneath it, indented. Folded the caret
into the no-parts fallback so it renders inline with the glyph (` ⚕ ▍`); a settled
row still shows its flat text, a turn with parts renders the parts. 71 pass.

Live-smoked: streaming start now shows `⚕ ▍` on one line; the reply text then
aligns with the glyph.
2026-06-08 18:13:04 +00:00
alt-glitch
59e9e6a26e opentui(v2): live agent trace + /tools navigable overlay (items 9, 15)
Item 15 — "/agents doesn't let me look into an agent trace live":
- store accumulates a concise per-subagent trace from the subagent.* stream
  (▶ start /  tool — preview / progress text / ✓ summary), capped at 200 lines;
  thinking deltas update a transient `thought` (not appended — they'd flood).
- AgentsDashboard is now master-detail: ↑/↓ select a subagent (▸ + accent), and
  the bottom pane shows the selected agent's goal · status · model, its latest
  thought, and a sticky-bottom (live) trace scrollbox. PgUp/PgDn scroll the trace.

Item 9 — /tools wired to a deliberate navigable overlay (fetch the roster via
slash.exec → pager) instead of incidental fallthrough; /skills already opens the
native picker.

Tests: store trace accumulation + dashboard render (trace line + footer). 71 pass.

Live-smoked: /tools → tool roster pager; /skills → picker; a real delegation
(spawn a subagent → reply PURPLE) → /agents showed the subagent with its goal ·
completed · model, 🧠 PURPLE thought, and ▶/✓ trace lines.
2026-06-08 18:09:32 +00:00
alt-glitch
a046cee754 opentui(v2): collapsible tools + composer glyph, drop the blue tint (items 3, 7)
Item 7 — tools were non-collapsible and "ugly-interlaced":
- ToolPart now renders COLLAPSED by default as one line: `▶ name  summary  (N
  lines)` (summary = explicit summary / first output line / error). A ▶/▼ glyph
  marks expandable tools; clicking the header toggles a left-bar block of the
  full (capped) output. Running tools show `name …`; single-line/erroring tools
  render inline. Compact by default → far less interlacing clutter.
- toolOutput.normalizeOutput: un-double-escapes literal \n/\t when they dominate
  over real newlines (some gateway tool tails are repr'd, so newlines arrived as
  backslash-n and rendered as one ugly line). Conservative — genuine multi-line
  output and legit `\n`-in-code are left alone. Applied in stripToolEnvelope.

Item 3 — the input "blue tint": dropped the textarea's blue focusedBackgroundColor
and added a `❯` prompt glyph. The composer is now distinguished by structure (the
glyph + the status-bar rule above it), not a background tint.

Tests: normalizeOutput (dominant-literal vs genuine-multiline). 70 pass.

Live-smoked: `ls -la` tool → collapsed `▶ terminal  total 3460  (N lines)`;
SGR-click → `▼` + clean per-line output; composer shows `❯` with no blue tint.
2026-06-08 18:04:08 +00:00
alt-glitch
15ccaf9ab9 opentui(v2): slash-arg autocomplete + file/@-mention completion (items 5, 13)
onType used to fire complete.slash only for an argless `/command`, and Tab
replaced the whole line. Now:

- planCompletion(text) (pure, in slash.ts) routes: a `/command [args]` line →
  complete.slash (the gateway completes names AND args, e.g. /details section
  names); a trailing path-like word (@…, ~/…, ./…, /…, or anything with /) →
  complete.path for file/dir tagging; else nothing.
- the accepted item splices ONLY its token: store tracks completionFrom (gateway
  replace_from via readReplaceFrom, or the path-token start), and the composer's
  Tab handler keeps the text before `from` and appends the candidate.

Tests: planCompletion (slash/path/prose/multiline) + readReplaceFrom. 69 pass.

Live-smoked: `/details ` → section dropdown (hidden/collapsed/.../activity), Tab
→ `/details hidden` (arg-only splice); `tui_gateway/` → its .py files;
`@hermes_cli/m` → m-prefixed files.
2026-06-08 17:57:07 +00:00
alt-glitch
c391add579 opentui(v2): prompt history — Up/Down cycling, per-directory scope (item 6)
New logic/history.ts: createPromptHistory (pure cursor cycling — Up walks older,
Down walks newer back to the stashed draft, push dedupes a consecutive duplicate
+ resets) plus best-effort per-dir JSONL persistence under
$HERMES_HOME/tui-history/<sha1(cwd)>.jsonl (one JSON-encoded prompt per line,
multiline-safe).

Scoping matches the ask: prior prompts from the SAME launch dir are loaded on
start (recallable across relaunches), but a different dir keeps its own list — no
cross-dir/cross-session bleed.

Composer: Up at the first line → older prompt; Down at the last line → newer/draft
(at the boundary the textarea's own up/down is a no-op, so no conflict; mid-buffer
it still moves the cursor). setText + cursor-to-end on recall; any edit resets the
recall cursor. submit() pushes the prompt. Threaded entry → App → Composer; cwd =
process.cwd() (the launch dir under the real launcher).

Tests: 5 pure cursor-cycling cases. Live-smoked: seeded a dir file → Up/Up/Down
cycled two→one→two; a freshly submitted prompt was recalled via Up. 65 pass.
2026-06-08 17:52:38 +00:00
alt-glitch
1e55b3b294 opentui(v2): always-active input — typing reclaims the composer (item 2)
The textarea focuses on mount and when an overlay closes (remount), but focus
could drift to the transcript scrollbox on a mouse-scroll, dropping keystrokes.
Now (opencode's keep-the-prompt-focused idea, adapted):
- onMouseDown → focus the textarea (click-to-focus).
- a global keystroke net: a PRINTABLE, unmodified key while the textarea is
  unfocused reclaims focus AND recovers the char (the in-flight event went to
  the global handler, not the unfocused textarea, so insert it). Nav/scroll keys
  (arrows/page/home/end/…) are deliberately left alone so keyboard transcript
  scroll still works; kitty `release` events are skipped to avoid double-insert.
Completion accept/dismiss handler folded into the same useKeyboard with early
returns.

Live-smoked: type → text lands; `/` → completions; Esc → dismiss; type again →
lands; clean quit. 60 pass.
2026-06-08 17:46:37 +00:00
alt-glitch
76cf809066 opentui(v2): Ctrl-C stops the agent; second press (debounced) quits
Item 11 — "stopping the agent doesn't work". Ctrl+C used to immediately destroy
the renderer. Now a turn-aware state machine (opencode's double-press model, the
user's preferred behaviour):

- While a turn runs (store.info.running): first Ctrl+C → session.interrupt
  {session_id} (STOP the agent), and arms a 3s quit window with a warn hint
  "⏹ stopped — Ctrl+C again to quit".
- Idle: first Ctrl+C arms the window ("Ctrl+C again to quit"); a stray single
  press never nukes the session.
- A second Ctrl+C within the window KILLS the TUI (renderer.destroy → clean
  scope teardown → gateway child EOF).
- A blocking prompt still owns Ctrl+C (deny/cancel) — unchanged.

Wiring: renderer.ts gains an `onCtrlC` hook (owns Ctrl+C when not blocked);
entry builds the machine (gateway yielded before the renderer so it can read
`running` + send interrupt). store gains a transient `hint` slice; StatusLine
shows hint (warn, priority) or the busy face (dim).

Live-smoked: long turn → Ctrl+C shows "stopped" + idle dot; second press exits
cleanly with no orphaned gateway child (the user's installed-venv sessions
untouched). 60 pass.
2026-06-08 17:42:21 +00:00
alt-glitch
915b9b5f6f opentui(v2): status bar (status·model·effort·context·dir) above composer
Item 14: a persistent bottom-chrome status bar, ported from Ink's appChrome
StatusRule. Sourced from the session.info event (model / reasoning_effort /
fast / cwd / branch / running / usage.context_*) which was decoded but dropped
until now; also folded session.create/resume result.info and message.complete
usage into a new store `info` slice.

- store: SessionInfo slice + applyInfo(); session.info handler; message.start/
  complete flip `running` (the flag the Ctrl-C interrupt will read); refresh
  usage on complete.
- schema: MessageComplete.payload gains loose `usage` so it survives decode.
- view/statusBar.tsx: width-aware (Ink progressive disclosure) — context bar
  drops on narrow terminals, cwd compacts to last two segments + left-truncates
  so the row never wraps. Turn/connection dot ◐/●/○.
- App: status bar sits ABOVE the composer; a top-edge rule (border:['top'])
  visually separates the status bar + textbox input region from the transcript.
- tests: store info slice (3) + headless status-bar render (1); bumped the
  approval-prompt capture height for the taller input region. 59 pass.

Live-smoked: bar shows model·effort·context%·dir; context updates 0→4% across a
turn; running dot flips; separator divides input region from transcript.
2026-06-08 17:38:10 +00:00
alt-glitch
1bf9dff1fb fix(opentui-v2): route thinking-faces to a transient status line (not the transcript)
Live-usage issue 3/5: the kaomoji faces ("(¬_¬) processing…") lingered in the
transcript. Traced (instrumented capture): they arrive via `thinking.delta` —
Hermes's transient kaomoji busy *indicator* (_INDICATOR_DEFAULT=kaomoji), which I
was rendering as a persistent reasoning part.

- store: new transient `status` field. thinking.delta / status.update → `status`
  (not a part); message.start + message.complete clear it. Only the real
  `reasoning.delta` still becomes a (dim) transcript part.
- view/statusLine.tsx: a dim busy line above the composer shown while `status` is
  set (Ink's FaceTicker analog), rendering nothing when idle; wired into App
  between the transcript and the input zone.

Verified: bun run check green (55 tests / 7 files) — store tests assert
thinking.delta → status (no transcript part) + cleared on complete; status.update
→ status. Live tmux: a turn showed "٩(๑❛ᴗ❛๑)۶ cogitating…" on the transient status
line (cleared on completion) with NO face left in the transcript.
2026-06-08 16:54:07 +00:00
alt-glitch
e14dfa86c6 fix(opentui-v2): live UX — enable mouse + smooth streaming markdown (opencode parity)
From live-usage feedback (driving the real TUI):

- Mouse ON by default (opencode parity; HERMES_TUI_MOUSE=0 opts out). Was hardcoded
  off, which is why transcript wheel-scroll, scrollbar drag, and click-to-expand
  tools didn't work and the terminal's native region-select polluted copy. With
  useMouse the scrollbox handles the wheel + scrollbar and tools are click-expandable;
  selection becomes OpenTUI's text-aware select. (Mouse can't be driven via tmux
  send-keys — verify wheel/drag/click interactively.)
- Streaming markdown: match opencode's v2 text path —
  <code filetype="markdown" streaming drawUnstyledText={false}>. The previous
  drawUnstyledText:true drew raw text then overlaid styling each delta (a flash);
  false avoids that and re-tokenizes incrementally for smoother streaming. (The
  native renderable's tree-sitter doesn't settle in the headless test renderer with
  drawUnstyledText:false, so the two markdown frame tests now assert the assistant
  text via the store — paint is verified in the live smoke; render.ts also settles
  to waitForVisualIdle.)

Verified: bun run check green (53 tests / 7 files). Live mouse + streaming
smoothness for glitch to confirm. Part of the live-feedback polish goal.
2026-06-08 16:48:23 +00:00
alt-glitch
055bc3e3a2 feat(opentui-v2): Phase 8 — launcher cutover to the v4 Solid engine
Repoint hermes_cli/main.py `_make_opentui_argv` from the superseded React entry
to the v4 Solid + Effect-at-boundary entry: it now prefers
`ui-tui-opentui-v2/src/entry/main.tsx` (cwd ui-tui-opentui-v2) and falls back to
`ui-tui-opentui/src/entry.real.tsx` only if the v2 package is absent (graceful
during coexistence). The engine gate (_resolve_tui_engine: HERMES_TUI_ENGINE /
display.tui_engine → opentui; Windows/Termux → Ink fallback) and the dual-engine
dispatch in _make_tui_argv are unchanged; Ink (ui-tui/) is untouched. The spawned
tui_gateway's source-root default lands on PROJECT_ROOT (package at
<root>/ui-tui-opentui-v2), so it loads Python from the same checkout, no extra env.

So `HERMES_TUI_ENGINE=opentui hermes --tui` now launches the v4 engine — the exact
`bun …/v2/src/entry/main.tsx` invocation live-smoked across P1–P5e, making every
first-class surface reachable from the real CLI.

Also: a consolidated 3-way acceptance summary (Ink ↔ opencode ↔ build) at the top
of opentui-feature-map.md covering all 7 first-class surfaces + the foundation +
the launcher, each  + tested + smoked.

Verified: py_compile main.py OK (dev-skill rule for the 4k-line file); imported
the worktree CLI with HERMES_TUI_ENGINE=opentui → _resolve_tui_engine()='opentui',
_make_opentui_argv() → [bun, …/ui-tui-opentui-v2/src/entry/main.tsx] (cwd
ui-tui-opentui-v2, --watch in dev). v2 `bun run check` green (53 tests / 7 files).
Smoke P8 + matrix updated. Remaining: header chrome detail (5b), agent-feature
trail (5d), distribution (§10) — polish, not first-class blockers.
2026-06-08 16:27:21 +00:00
alt-glitch
c019a9d2d5 feat(opentui-v2): Phase 5e — agents dashboard (7th first-class surface; ALL done)
The agents dashboard (spec §2b; Ink agentsOverlay) — the last first-class
interactive surface. Subagent delegations are tracked from the `subagent.*`
event stream and shown in a full-height overlay.

- store: subagents[] built from subagent.{spawn_requested,start,thinking,tool,
  progress,complete} by subagent_id (status·goal·model·depth·lastTool·summary);
  clearTranscript clears them. dashboard flag + openDashboard/closeDashboard.
- view/overlays/agentsDashboard.tsx: full-height overlay (replaces transcript+
  composer), depth-indented subagent rows colored by status, scroll via
  scrollBy/scrollTo, Esc/q close. Empty state prompts to delegate.
- view/App.tsx: content zone is now a <Switch> — pager / agents dashboard /
  (transcript + input zone).
- logic/slash.ts: /agents, /tasks → openDashboard (SlashContext.openDashboard).

Verified: bun run check green (53 tests / 7 files) — subagent reducer + a
dashboard frame test (seeded tree renders, transcript replaced) + /agents
dispatch. LIVE tmux: /agents opened empty; then a REAL delegation spawned a
subagent → /agents showed "⛓ Agents · 1 subagent · ● completed <goal>
(model) terminal". ALL 7 first-class surfaces are now +tested+smoked
(blocking prompts, pager, session switcher, model picker, skills hub,
completions, agents dashboard). Smoke P5e + matrix updated. Remaining: chrome
(5b), agent-feature polish (5d), launcher (8).
2026-06-08 16:23:17 +00:00
alt-glitch
99b24f6747 feat(opentui-v2): Phase 5a — slash completions dropdown (last first-class overlay)
A live slash-completion dropdown renders above the composer as you type `/…`
(spec §1 autocomplete) — the 6th and final first-class overlay surface.

- view/composer.tsx: onContentChange → onType (reads ta.plainText); a dropdown
  of candidates (display + meta) renders above the textarea when completions are
  set. The textarea owns key input (live refine-by-typing), so Tab accepts the
  top match (ta.clear()+insertText) and Esc dismisses; arrow-nav would fight the
  cursor (noted polish).
- store: completions state + setCompletions/clearCompletions; CompletionItem.
- logic/slash.ts: mapCompletions(complete.slash result) → candidates.
- entry: onType queries complete.slash for `/word` (no space) and sets/clears the
  store completions; cleared on submit / non-slash / space.

Verified: bun run check green (49 tests / 7 files) — mapCompletions + a
composer-dropdown frame test. LIVE tmux: typing `/comp` showed /compress,
/composio, /compact (with descriptions); Tab accepted the top + cleared the
dropdown. ALL 6 first-class overlays are now +tested+smoked (blocking prompts,
pager, session switcher, model picker, skills hub, completions). Smoke P5a +
matrix updated. Remaining: chrome (5b), agent features (5d), agents dashboard (5e).
2026-06-08 16:15:38 +00:00
alt-glitch
d4d7c9b0ae feat(opentui-v2): Phase 5c — model picker + skills hub (generic Picker overlay)
A reusable generic picker (titled <select> + onPick) powers two more first-class
overlays (spec §2b):

- view/overlays/picker.tsx + store picker/openPicker/closePicker + PickerItem.
- /model: bare → model.options → a picker of authenticated providers' models
  (current marked ✓), pick switches via `slash.exec model <name>`; `/model <name>`
  switches directly without the picker.
- /skills: skills.manage {action:list} → a picker flattened from
  {category: names[]}; picking inspects (skills.manage inspect) → the pager.
- view/App.tsx: the input zone is now a <Switch> — prompt → switcher → picker →
  composer (overlays replace, never stack, so the composer remounts/refocuses).

Verified: bun run check green (47 tests / 7 files) — /model bare→picker (auth
filtered, current marked, pick→slash.exec), /model <name> direct, /skills flatten.
LIVE tmux: /model → picker listing 8 models (anthropic/claude-opus-4.8 ▶, nous,
…), Esc closed clean; /skills → hub listing skills w/ category descriptions.
5 of 6 first-class overlays done (prompts, pager, session switcher, model picker,
skills hub) — completions dropdown remains. Smoke P5c + matrix updated.
(Note: model.options is ~5s server-side; a loading indicator is a polish TODO.)
2026-06-08 16:08:25 +00:00
alt-glitch
ba10594322 feat(opentui-v2): Phase 5c — session switcher overlay (list → pick → resume)
A first-class picker (spec §2b, Ink activeSessionSwitcher): /sessions (aliases
/resume, /switch, /session) → session.list → a native <select> overlay; Enter
resumes the chosen session via the SAME resumeInto hydrate path as launch, so
tool rows + transcript hydrate correctly. Esc closes. Reuses Phase 4b resume.

- view/overlays/sessionSwitcher.tsx: <select> of sessions (title / preview /
  message count), onSelect → onPick(id); Esc cancels.
- store: switcher state + openSwitcher/closeSwitcher; SessionItem type.
- logic/resume.ts: mapSessionList(session.list result) → SessionItem[].
- logic/slash.ts: /sessions|/resume|/switch|/session client commands +
  listSessions/openSwitcher on SlashContext.
- entry: resumeInto extracted (shared by bootstrap + switcher); slashCtx wires
  listSessions (session.list → mapSessionList) + openSwitcher; onResume runs
  resumeInto via runFork. App input zone is now prompt → switcher → composer
  (overlays replace, not stack, so the composer remounts/refocuses on close).

Verified: bun run check green (43 tests / 7 files) — slash /sessions → switcher,
+ a switcher frame test (rows render, composer replaced). LIVE tmux: /sessions
listed real titled sessions w/ counts/previews; ↓+Enter resumed the picked one
(hydrate_ms=8) → transcript hydrated incl. the terminal tool row; switcher
closed, composer returned; /quit clean. 3 of 6 first-class overlays done
(prompts, pager, switcher). Smoke P5c + matrix updated.
2026-06-08 16:00:39 +00:00
alt-glitch
0d0e9203cf feat(opentui-v2): Phase 5a — pager overlay for long slash output
A full-height scrollable pager (the FloatBox analog) — porting it unlocks the
long-output slash commands (/status /logs /history /tools) at once (spec §2b).

- view/overlays/pager.tsx: bordered full-height overlay (title + scrollbox +
  footer), scrolling driven explicitly via useKeyboard → scrollBy/scrollTo (no
  reliance on scrollbox auto-focus), Esc/q/Ctrl+C close. §8 #2 scrollbox gotchas.
- store: pager state + openPager/closePager.
- view/App.tsx: content zone swaps to the Pager (replacing transcript+composer)
  when store.state.pager is set; the close is deferred a tick so the closing key
  can't leak into the remounting composer.
- logic/slash.ts: present() routes output to the pager when long (>180 chars or
  >2 non-empty lines, Ink parity) else a system line; titled by command; /logs
  always pages. New openPager on SlashContext.

Verified: bun run check green (41 tests / 7 files) — present() routing
(short→system, long→pager) + a pager frame test (renders title/content, replaces
the transcript/composer). LIVE tmux: /logs → pager (title "Logs", scroll via
PageDown, Esc closed → composer refocused, no key-leak); /version (5-line output)
→ pager titled "Version". Smoke P5a + parity matrix updated. Completions dropdown
+ pickers + chrome are the next slices.
2026-06-08 15:52:36 +00:00
alt-glitch
abdc21f39a feat(opentui-v2): Phase 4b — session resume with tool/transcript hydration
HERMES_TUI_RESUME=<id|recent> resumes a session instead of creating one:
session.most_recent (for "recent") → session.resume {cols, session_id} →
commitSnapshot(mapResumeHistory(messages)), buffering live events across the RPC.

- logic/resume.ts: maps the session.resume history into Message[]. Resumed tool
  rows arrive as {role:'tool', name, context} (NO text — gotcha §8 #5); they're
  FOLDED into the preceding assistant turn's ordered parts (state:'complete',
  summary=context) so a resumed transcript renders the tools INLINE like a live
  one. Assistant text gets a text part (renders via native markdown). User/system
  stay flat. Unknown roles / non-arrays are ignored.
- logic/store.ts: hydrate split into beginBuffer() + commitSnapshot() so the live
  event buffer spans the async resume RPC (events that arrive during resume are
  replayed after the snapshot, in order).
- entry/main.tsx: bootstrap branches create vs resume; the resume path is timed
  (rpc_ms / hydrate_ms) for profiling.

Verified: bun run check green (40 tests / 7 files) — resume mapper (fold tool
rows, standalone holder, ignore junk) + beginBuffer/commitSnapshot replay. LIVE
tmux: Launch A created a session with a terminal tool call; Launch B
(HERMES_TUI_RESUME=recent) hydrated user + assistant + the tool row inline.
STRESS+PROFILE on a real 103-message session (~/.hermes/sessions): client hydrate
= 76ms, bun RSS = 214MB STABLE (no leak), tool rows hydrated, PageUp scroll works;
the 1.6s cost is the server-side session.resume RPC, not the TUI. Smoke P4 +
matrix updated. Note: rows instantiate for the full history (scrollbox culls
render only) → RSS ~linear in turns; list virtualization is the lever if
multi-thousand-turn sessions become a target.
2026-06-08 15:31:46 +00:00
alt-glitch
87634e19fd feat(opentui-v2): Phase 4a — slash command system + local confirm dialog
The composer now routes `/command` through the Ink-parity dispatch ladder
instead of submitting it as a prompt (spec §1):

- logic/slash.ts: parseSlash + dispatchSlash — client-local command →
  slash.exec {command, session_id} (output → system line) → on reject
  command.dispatch {arg, name, session_id} with typed handling
  (exec/plugin→system · alias→re-dispatch · skill/send→submit a turn ·
  prefill→notice). 6 client commands: help/quit/exit/clear/new/logs.
- /help renders the live `commands.catalog` (reads the `pairs` shape).
- view/prompts/confirmPrompt.tsx + store.setConfirm: a LOCAL (non-gateway) Y/N
  dialog for /clear and /new; store gains pushSystem + clearTranscript.
- entry: a Promise-returning `request` adapter + the SlashContext wiring (quit →
  renderer.destroy, confirm, clearTranscript, logTail, submit).

Also fixes a keystroke-leak: the key that ANSWERED a prompt was bleeding into the
freshly-refocused composer (`/clear`→y left "y" in the input, breaking the next
`/quit`). PromptOverlay now defers the prompt-clear (composer remount) past the
current keystroke — this hardens every Phase 3 prompt too.

Verified: bun run check green (36 tests / 6 files) — slash.test covers parse + the
full ladder against a fake context. LIVE tmux: /help → full gateway catalog;
/version → slash.exec output; /clear → confirm → cleared, no key-leak (typed "hi"
not "yhi"); /quit → clean quit, child reaped. Remaining TUI-only commands,
completions, pager routing, and session resume are 4b/4c. Smoke P4 + matrix updated.
2026-06-08 15:20:06 +00:00
alt-glitch
d01b573796 feat(opentui-v2): Phase 3 — blocking prompts (clarify/approval/sudo/secret), no deadlock
The 4 gateway *.request events now drive a blocking-prompt overlay instead of
deadlocking the agent (spec §8 #6). Native OpenTUI paradigm (per glitch's steer):

- view/prompts/approvalPrompt.tsx: native <select> (once/session/always/deny)
  → approval.respond {choice, session_id}.
- view/prompts/clarifyPrompt.tsx: native <select> over choices + an "✎ Other…"
  option that swaps to a native <input> for free-text → clarify.respond
  {answer, request_id}.
- view/prompts/maskedPrompt.tsx: sudo (🔐) / secret (🔑) — native <input> has no
  mask, so we own a buffer via useKeyboard and render '*' per char →
  sudo/secret.respond {password|value, request_id}.
- view/prompts/promptOverlay.tsx: dispatches by prompt kind, binds each
  answer/cancel to the matching *.respond; Esc/Ctrl+C → deny/empty so the agent
  always unblocks.

Wiring: store gains ActivePrompt state + the 4 reducer cases + clearPrompt;
App swaps Composer↔PromptOverlay on store.state.prompt (so the composer textarea
stops capturing keys while blocked); renderer.ts gates the global Ctrl+C-quit on
isBlocked() so a prompt owns Ctrl+C (→ cancel); entry adds a generic `respond`
runFork callback + passes sessionId.

Verified: bun run check green (28 tests / 5 files) — reducer set/clear for all 4,
+ a frame test (approval overlay renders the command + all options as a bordered
modal, composer hidden while blocked). LIVE tmux: a real `rm -rf` approval fired;
Approve-once → command ran → unblocked; Esc → deny → "BLOCKED by user" →
unblocked; Ctrl+C-while-blocked cancelled WITHOUT quitting; Ctrl+C-unblocked quit
clean, no orphan. Smoke P3 + parity matrix updated. confirm (local) → Phase 4.
2026-06-08 15:06:58 +00:00
alt-glitch
a572a1eae4 feat(opentui-v2): Phase 2b-ii — native markdown for assistant text (Phase 2 done)
Assistant text parts now render through the NATIVE markdown renderable instead of
plain spans — bold/headings/lists/fences render, raw `**`/backtick markup is
concealed (spec §7; never hand-roll a parser).

- view/markdown.tsx: `<code filetype="markdown" streaming conceal drawUnstyledText>`
  (CodeRenderable — opencode's v2 AssistantText path; `<markdown>` +
  internalBlockMode="top-level" deferred paint headlessly). SyntaxStyle.fromStyles
  is derived from the theme (markup.* → theme.color.*, non-hex colors guarded) and
  cached by theme-object identity so all text parts share one instance, rebuilt
  only on skin change. drawUnstyledText paints raw text immediately while
  Tree-sitter highlighting settles (and makes it headless-capturable).
- view/messageLine.tsx: text-part Match renders <Markdown> instead of <text>.
- test/lib/render.ts: settle async markdown via flush(); captureFrame gains an
  `until` option (waitForFrame) for content that paints after the first pass.

Verified: bun run check green (23 tests / 5 files). Live tmux: a markdown reply
(heading + bold word + 2-item list) rendered with `**` concealed (grep -c '**' = 0);
Ctrl+C clean, no orphan. Phase 2 complete (2a shell + 2b-i parts/tools + 2b-ii
markdown) — smoke steps 1–4 run live. Next: Phase 3 blocking prompts.
2026-06-08 14:49:52 +00:00
alt-glitch
b72ac77783 feat(opentui-v2): Phase 2b-i — ordered parts + inline tool render
An assistant turn is now ONE ordered parts[] (text/reasoning/tool) instead of a
flat string, so tool calls render INLINE between text blocks rather than dumped
as separate rows below (spec §7 — the "dump-below" bug opencode's sync-v2 avoids).

- logic/store.ts: Part discriminated union + reducer rework. message.delta
  appends to the open text part (or opens one); tool.start pushes a running tool
  part; tool.complete matches by tool_id and updates that part IN PLACE (state,
  envelope-stripped resultText, summary, error, lineCount); reasoning.delta
  accumulates a reasoning part. User/system rows stay flat text; settled/resumed
  assistant rows fall back to text.
- logic/toolOutput.ts: ported pure helpers — stripToolEnvelope (unwrap
  {output,exit_code}, append [exit N]/[error] suffix) + collapseToolOutput +
  truncate.
- view/messageLine.tsx: <For>+<Switch> dispatch by part.type with stable id keys.
- view/toolPart.tsx: two-tier render — inline one-liner (≤1 output line) or a
  capped left-bar block (TOOL_MAX_LINES, "… +N more", click-to-expand) keyed off
  the theme; reactive width via useTerminalDimensions.

Verified: bun run check green (23 tests / 5 files / 64 expects) — store
interleave/in-place/reasoning, a frame test asserting the tool renders inline +
envelope stripped, and toolOutput unit tests. Live tmux: a terminal-tool prompt
rendered " terminal" with its alpha/beta output inline between the assistant's
text parts; Ctrl+C clean, no orphan. Smoke P2b + parity matrix updated. Native
<markdown> for text parts is the next slice (2b-ii).
2026-06-08 14:42:24 +00:00
alt-glitch
53b37463c4 feat(opentui-v2): Phase 2a — scrollbox transcript + textarea composer + header
Turns the read-only Phase-1 view into an interactive shell, split into focused
view components (spec v4 §2 layout):

- view/transcript.tsx: ONE full-height <scrollbox> with a reactive <For>
  (opencode's no-scrollback model). Applies the §8 #2 gotchas exactly:
  minHeight:0 on the wrapper AND the scrollbox, NO flexDirection on the
  scrollbox root, stickyScroll + stickyStart="bottom".
- view/composer.tsx: a native <textarea> captured by ref — flexShrink:0,
  focus-on-mount, Enter->submit via keyBindings, imperative .clear() on submit,
  and a `submitting` re-entrancy guard. Wired by the entry to fire prompt.submit
  (Effect.runFork on the in-hand service value); it's now the PRIMARY input, with
  the HERMES_TUI_PROMPT stand-in kept only for launch-with-prompt.
- view/header.tsx + view/messageLine.tsx: extracted, themed (no hardcoded
  styles). MessageLine stays flat-text this slice; ordered parts (§7) land in 2b.

test/lib/render.ts now flushes 3 renderOnce passes before capture — a <scrollbox>
needs more than one pass to measure content + apply sticky, else the transcript
row paints blank.

Verified: bun run check green (12 tests / 4 files / 31 expects). Live tmux drive:
typed into the composer -> cleared -> user row -> streamed reply ("Here are three
words"); Ctrl+C quits cleanly even with the textarea focused, no orphan child.
Composer placeholder rendered the live skin's welcome string (skin->theme live).
Smoke P2a + parity matrix updated. Phase 2b (ordered parts/tool render/markdown)
is the next slice.
2026-06-08 14:28:59 +00:00
alt-glitch
cc2c881fd1 feat(opentui-v2): Phase 1 — live tui_gateway transport + Solid store + theming
GatewayService/liveGateway over the real Python tui_gateway: JSON-RPC stdio
framing (Bun.spawn), 16ms event coalescing flushed inside Solid batch(), typed
GatewayError, and a decode-once GatewayEvent Schema (~35-member tagged union;
unknown/malformed events skip via Option.none, never crash the stream).

The Solid sync-v2-style store grows to: streaming text concat (prefer
payload.text), gateway.ready{skin}/skin.changed -> fromSkin reactive re-theme,
LRU id-dedup, and hydrate-while-buffering (resume scaffold). Theming is a 1:1
port of Ink's theme.ts (DARK/LIGHT, detectLightMode, ANSI-256 normalization,
fromSkin) behind a Solid ThemeProvider so existing skins work unchanged and the
view carries NO hardcoded styles. A console-safe diagnostics log (in-memory
ring + NDJSON file) is the single logging path.

Entry gains a live launch path (default; HERMES_TUI_FAKE=1 -> scripted hello)
with an initial-prompt bootstrap (session.create -> prompt.submit) as the
Phase-2-composer stand-in, plus a minimal Ctrl+C graceful quit
(renderer.destroy -> shutdown Deferred -> scope finalizers -> client.stop) so
the engine reaps its own gateway child instead of orphaning it.

Verified: bun run check green (tsc + eslint + 12 tests / 4 files); live tmux
drive connect -> gateway.ready -> prompt -> streamed reply ("pong") -> clean
teardown with no orphan bun/python. Parity matrix + smoke P1 run log updated.
2026-06-08 14:19:21 +00:00
alt-glitch
12342a4bce feat(opentui-v2): Phase 0 scaffold — Solid + Effect-at-boundary native TUI
New from-scratch package ui-tui-opentui-v2/ (NOT a port of the superseded React
ui-tui-opentui/; Ink ui-tui/ untouched). Mirrors opencode's method: @opentui/solid
view, Effect 4.0-beta only at the boundary (renderer lifecycle, GatewayService
transport, runtime), plain Solid for the logic/view.

Phase 0 (per docs/plans/opentui-rewrite-v4-spec.md §11):
- deps pinned: effect@4.0.0-beta.78, @opentui/{core,solid,keymap}@0.3.2, solid-js@1.9.10
- strict rails: tsconfig (verbatimModuleSyntax, exactOptionalPropertyTypes,
  noUncheckedIndexedAccess, jsxImportSource @opentui/solid), eslint, prettier
- boundary: acquireRelease(createCliRenderer) + finalizers + Deferred-on-destroy;
  GatewayService (Context.Service) shape; typed errors (Data.TaggedError); AppLayer
- logic (Solid): createSessionStore + apply(event) reducer (sync-v2 model, minimal)
- view (Solid): App shell (header + transcript); inline color via <span style={{fg}}>
- entry: the one-line render(() => <App/>, renderer) bridge + Effect.provide(layer)
- FakeGateway layer (test/dev seam) streaming a scripted hello
- test rails: test/lib/effect.ts (testEffect/testLayer over ManagedRuntime + TestClock,
  no @effect/vitest), test/lib/render.ts (testRender + renderOnce + captureCharFrame)
- 4-layer tests (boundary/store/render) 5/5 green; scripts/check.sh gate green
- docs: v4 spec + living smoke doc (Phase 0 PASS logged); v3 spec + parts/markdown
  plan marked superseded

Verified: bun run check green (tsc 0, eslint 0, bun test 5/5); live tmux drive paints
'hermes · opentui · ready' + '✦ Hi there, glitch!' in a real TTY.
2026-06-08 13:36:54 +00:00
alt-glitch
ea0de82422 opentui(phase3): launcher integration — HERMES_TUI_ENGINE dual-engine
hermes --tui launches the native OpenTUI engine (Bun) when
HERMES_TUI_ENGINE=opentui (env) or display.tui_engine=opentui (config);
Ink stays the default and the shipping path is untouched.

- _resolve_tui_engine() (env > config > ink); refuses opentui on
  Windows/Termux (no Bun) -> falls back to ink with a notice.
- _make_opentui_argv() -> [bun, src/entry.real.tsx] (no build step).
- _bun_bin() with HERMES_BUN override.
- Branch at top of _make_tui_argv BEFORE _ensure_tui_node (Bun-only host
  must not bootstrap Node).
- Gate _launch_tui NODE_OPTIONS/--max-old-space-size on engine==ink (Bun
  is JSC; the V8 flag errors/ignores).

Verified end-to-end via tmux: real hermes --tui -> Bun -> OpenTUI ->
real Python gateway streamed a real reply. No-flag default still ink.
2026-06-08 11:11:54 +00:00
495 changed files with 269591 additions and 600 deletions

View File

@@ -1,12 +1,14 @@
FROM ghcr.io/astral-sh/uv:0.11.6-python3.13-trixie@sha256:b3c543b6c4f23a5f2df22866bd7857e5d304b67a564f4feab6ac22044dde719b AS uv_source
# Node 22 LTS source stage. Debian trixie's bundled nodejs is pinned to 20.x
# which reached EOL in April 2026 we copy node + npm + corepack from the
# upstream node:22 image instead so we can stay on a supported LTS without
# waiting for Debian 14 (forky, ~mid-2027). Bookworm-based slim image used
# so the produced binary links against glibc 2.36, which runs cleanly on
# our Debian 13 (trixie, glibc 2.41) runtime. Bumping to a new Node major
# is a one-line ARG change; see #4977.
FROM node:22-bookworm-slim@sha256:7af03b14a13c8cdd38e45058fd957bf00a72bbe17feac43b1c15a689c029c732 AS node_source
# Node 26 source stage. Debian trixie's bundled nodejs is pinned to 20.x
# (EOL April 2026), so we copy node + npm + corepack from the upstream node:26
# image instead. Node 26 (Current; LTS promotion ~Oct 2026) is REQUIRED by the
# native OpenTUI TUI engine, which loads its renderer via the experimental
# `node:ffi` API that only exists on Node 26.3+ (the Ink engine + web build run
# on it too). Bookworm-based slim image used so the produced binary links
# against glibc 2.36, which runs cleanly on our Debian 13 (trixie, glibc 2.41)
# runtime. The pinned tag ships v26.3.0. Bumping Node is a one-line change here.
# NOTE: verify the full image build + Ink/web/Playwright on Node 26 in CI.
FROM node:26-bookworm-slim@sha256:79723b41edbedf595f62e943a9f8b0ba9af5b1e61045c5f8f59c2c02c1212a16 AS node_source
FROM debian:13.4
# Disable Python stdout buffering to ensure logs are printed immediately
@@ -90,7 +92,7 @@ RUN useradd -u 10000 -m -d /opt/data hermes
COPY --chmod=0755 --from=uv_source /usr/local/bin/uv /usr/local/bin/uvx /usr/local/bin/
# Node 22 LTS: copy the node binary plus the bundled npm + corepack JS
# Node 26: copy the node binary plus the bundled npm + corepack JS
# installs from the upstream image. npm and npx are recreated as symlinks
# because they're symlinks in the source image (and need to live on PATH).
# See node_source stage at the top of the file for the version-bump
@@ -119,7 +121,7 @@ COPY ui-tui/packages/hermes-ink/ ui-tui/packages/hermes-ink/
# `npm_config_install_links=false` forces npm to install `file:` deps as
# symlinks instead of copies. This is the default since npm 10+, which is
# what the image ships now (via the node:22 source stage). We set it
# what the image ships now (via the node:26 source stage). We set it
# explicitly anyway as defense-in-depth: the previous Debian-bundled npm
# 9.x defaulted to install-as-copy, which produced a hidden
# node_modules/.package-lock.json that permanently disagreed with the root
@@ -181,8 +183,16 @@ RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra
# invalidate the (relatively slow) web + ui-tui build layer.
COPY web/ web/
COPY ui-tui/ ui-tui/
COPY ui-opentui/ ui-opentui/
# ui-opentui is the opt-in native OpenTUI engine (HERMES_TUI_ENGINE=opentui;
# default stays Ink). .dockerignore strips its node_modules/dist, so install +
# esbuild-build it here -> dist/main.js, then prune devDeps (esbuild/babel/
# vitest); the runtime only needs the prod deps (the external @opentui/core +
# its native blob -- the bundle inlines solid/effect). Build needs Node 26.3
# (node:ffi floor), which this image ships.
RUN cd web && npm run build && \
cd ../ui-tui && npm run build
cd ../ui-tui && npm run build && \
cd ../ui-opentui && npm install --no-audit --no-fund && npm run build && npm prune --omit=dev
# ---------- Source code ----------
# .dockerignore excludes node_modules, so the installs above survive.

View File

@@ -107,6 +107,8 @@ You can still bring your own keys per-tool whenever you want — the gateway is
Hermes has two entry points: start the terminal UI with `hermes`, or run the gateway and talk to it from Telegram, Discord, Slack, WhatsApp, Signal, or Email. Once you're in a conversation, many slash commands are shared across both interfaces.
> **TUI engine:** On supported hosts (Linux/macOS with Node 26.3+), the terminal UI defaults to the native **OpenTUI** engine, which the installer provisions for you. The legacy **Ink** engine remains the fallback — it's used automatically on Windows, Termux, or when the native engine can't run, and you can select it explicitly with `HERMES_TUI_ENGINE=ink hermes`. Ink is not going away; it's the kept fallback.
| Action | CLI | Messaging platforms |
| ------------------------------ | --------------------------------------------- | -------------------------------------------------------------------------------- |
| Start chatting | `hermes` | Run `hermes gateway setup` + `hermes gateway start`, then send the bot a message |

View File

@@ -242,6 +242,17 @@ def nous_credits_lines(*, markdown: bool = False, timeout: float = 10.0) -> list
renders from that fixture instead of the real portal (so the block + gauge are
testable without a live account). Throwaway scaffolding.
"""
snapshot = _fetch_nous_credits_snapshot(timeout=timeout)
return render_account_usage_lines(snapshot, markdown=markdown)
def _fetch_nous_credits_snapshot(timeout: float = 10.0) -> Optional[AccountUsageSnapshot]:
"""Auth-gate + portal fetch + snapshot build for the Nous credits block.
Shared by ``nous_credits_lines`` (full block) and
``nous_credits_compact_line`` (one-liner). Honors the
HERMES_DEV_CREDITS_FIXTURE dev override. Fail-open → None.
"""
# Dev fixture short-circuit — render /usage from the injected state, no portal.
try:
from agent.credits_tracker import dev_fixture_credits_state
@@ -250,17 +261,16 @@ def nous_credits_lines(*, markdown: bool = False, timeout: float = 10.0) -> list
except Exception:
fixture = None
if fixture is not None:
snapshot = _snapshot_from_credits_state(fixture)
return render_account_usage_lines(snapshot, markdown=markdown)
return _snapshot_from_credits_state(fixture)
try:
from hermes_cli.auth import get_provider_auth_state
tok = (get_provider_auth_state("nous") or {}).get("access_token")
if not (isinstance(tok, str) and tok.strip()):
return []
return None
except Exception:
return []
return None
try:
import concurrent.futures
@@ -270,13 +280,36 @@ def nous_credits_lines(*, markdown: bool = False, timeout: float = 10.0) -> list
account = pool.submit(
get_nous_portal_account_info, force_fresh=True
).result(timeout=timeout)
snapshot = build_nous_credits_snapshot(account)
return render_account_usage_lines(snapshot, markdown=markdown)
return build_nous_credits_snapshot(account)
except Exception:
# Fail-open (caller shows nothing), but leave a breadcrumb so a dead
# /usage credits block is diagnosable in agent.log without a dev flag.
logger.debug("credits ▸ /usage portal fetch/render failed (fail-open)", exc_info=True)
return []
return None
def nous_credits_compact_line(*, timeout: float = 10.0) -> Optional[str]:
"""One-line Nous credits summary for the compact /usage view, or None.
Condenses the snapshot's own detail strings (stable, locally-built
formats) into ``Nous credits (Plan): Total usable: $X · Renews: …``.
Same gating/fail-open semantics as ``nous_credits_lines``.
"""
snap = _fetch_nous_credits_snapshot(timeout=timeout)
if snap is None or not snap.available:
return None
picked = [
d for d in snap.details
if d.startswith(("Total usable:", "Renews:", "Status:"))
]
if not picked:
picked = [d for d in snap.details if not d.startswith("Manage / top up:")][:2]
if not picked:
return None
title = snap.title
if snap.plan:
title += f" ({snap.plan})"
return f"{title}: " + " · ".join(picked)
def _snapshot_from_credits_state(state) -> Optional[AccountUsageSnapshot]:

View File

@@ -1624,6 +1624,12 @@ def init_agent(
agent.session_cache_write_tokens = 0
agent.session_reasoning_tokens = 0
agent.session_estimated_cost_usd = 0.0
# Provider-REPORTED cost only (e.g. OpenRouter usage.cost). None means
# "nothing reported" — distinct from a real $0.00.
agent.session_actual_cost_usd = None
# Per-model session usage rows for /usage: {model: {calls, input, output,
# cache_read, cache_write, cost_usd|None}}.
agent.session_model_usage = {}
agent.session_cost_status = "unknown"
agent.session_cost_source = "none"

View File

@@ -190,6 +190,10 @@ CODING_AGENT_GUIDANCE = (
"Verify, and know when to stop:\n"
"- Use `terminal` for git, builds, tests, and inspection. Run the relevant "
"tests/linter/build and confirm they pass before claiming the work is done.\n"
"- Terminal state persists across calls: current directory and exported "
"environment variables carry forward. Activate a virtualenv or export setup "
"vars once, then reuse that state instead of re-sourcing it before every "
"test command.\n"
"- Fix root causes, not symptoms: when you find a bug, check sibling call "
"paths for the same flaw and fix the class, not just the reported site.\n"
"- When fixing linter/type errors on a file, stop after about three "
@@ -711,10 +715,13 @@ def build_coding_workspace_block(cwd: Optional[str | Path] = None) -> str:
lines.append("- Branch: (detached HEAD)")
# Linked worktree: the per-worktree git dir differs from the shared common dir.
# We surface the fact that it's a worktree (so the model knows branches/stashes
# are shared state) but deliberately do NOT expose the primary tree path —
# giving the model a second absolute path causes it to sometimes run commands
# in the wrong directory.
git_dir, common_dir = _git(root, "rev-parse", "--git-dir"), _git(root, "rev-parse", "--git-common-dir")
if git_dir and common_dir and Path(git_dir).resolve() != Path(common_dir).resolve():
main_tree = Path(common_dir).resolve().parent
lines.append(f"- Worktree: linked (primary tree at {main_tree})")
lines.append("- Worktree: linked (git state shared with primary tree)")
dirty = [f"{n} {label}" for label, n in (
("staged", counts["staged"]), ("modified", counts["modified"]),

View File

@@ -7,7 +7,7 @@ protecting head and tail context.
Improvements over v2:
- Structured summary template with Resolved/Pending question tracking
- Filter-safe summarizer preamble that treats prior turns as source material
- "Remaining Work" replaces "Next Steps" to avoid reading as active instructions
- Historical (reference-only) section headings replace "Next Steps"/"Remaining Work" to avoid reading as active instructions
- Clear separator when summary merges into tail message
- Iterative summary updates (preserves info across multiple compactions)
- Token-budget tail protection instead of fixed message count
@@ -34,7 +34,50 @@ from agent.redact import redact_sensitive_text
logger = logging.getLogger(__name__)
HISTORICAL_TASK_HEADING = "## Historical Task Snapshot"
HISTORICAL_IN_PROGRESS_HEADING = "## Historical In-Progress State"
HISTORICAL_PENDING_ASKS_HEADING = "## Historical Pending User Asks"
HISTORICAL_REMAINING_WORK_HEADING = "## Historical Remaining Work"
SUMMARY_PREFIX = (
"[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted "
"into the summary below. This is a handoff from a previous context "
"window — treat it as background reference, NOT as active instructions. "
"Do NOT answer questions or fulfill requests mentioned in this summary; "
"they were already addressed. "
"Respond ONLY to the latest user message that appears AFTER this "
"summary — that message is the single source of truth for what to do "
"right now. "
"Topic overlap with the summary does NOT mean you should resume its "
"task: even on similar topics, the latest user message WINS. Treat ONLY "
"the latest message as the active task and discard stale items from "
f"'{HISTORICAL_TASK_HEADING}' / '{HISTORICAL_IN_PROGRESS_HEADING}' / "
f"'{HISTORICAL_PENDING_ASKS_HEADING}' / "
f"'{HISTORICAL_REMAINING_WORK_HEADING}' entirely — do not 'wrap up' or "
"'finish' work described there unless the latest message explicitly "
"asks for it. "
"Reverse signals in the latest message (e.g. 'stop', 'undo', 'roll "
"back', 'just verify', 'don't do that anymore', 'never mind', a new "
"topic) must immediately end any in-flight work described in the "
"summary; do not re-surface it in later turns. "
"IMPORTANT: Your persistent memory (MEMORY.md, USER.md) in the system "
"prompt is ALWAYS authoritative and active — never ignore or deprioritize "
"memory content due to this compaction note. "
"The current session state (files, config, etc.) may reflect work "
"described here — avoid repeating it:"
)
LEGACY_SUMMARY_PREFIX = "[CONTEXT SUMMARY]:"
# Handoff prefixes that shipped in earlier releases. A summary persisted under
# one of these can be inherited into a resumed lineage (#35344); when it is
# re-normalized on re-compaction we must strip the OLD prefix too, otherwise the
# stale directive it carried (e.g. "resume exactly from Active Task") survives
# embedded in the body and keeps hijacking replies. Keep newest-first; entries
# are matched literally. Add a frozen copy here whenever SUMMARY_PREFIX changes.
_HISTORICAL_SUMMARY_PREFIXES = (
# Carveout era (#41607/#38364/#42812): "consistent → use as background"
# licensed stale-task resumption on topic overlap.
"[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted "
"into the summary below. This is a handoff from a previous context "
"window — treat it as background reference, NOT as active instructions. "
@@ -57,17 +100,7 @@ SUMMARY_PREFIX = (
"prompt is ALWAYS authoritative and active — never ignore or deprioritize "
"memory content due to this compaction note. "
"The current session state (files, config, etc.) may reflect work "
"described here — avoid repeating it:"
)
LEGACY_SUMMARY_PREFIX = "[CONTEXT SUMMARY]:"
# Handoff prefixes that shipped in earlier releases. A summary persisted under
# one of these can be inherited into a resumed lineage (#35344); when it is
# re-normalized on re-compaction we must strip the OLD prefix too, otherwise the
# stale directive it carried (e.g. "resume exactly from Active Task") survives
# embedded in the body and keeps hijacking replies. Keep newest-first; entries
# are matched literally. Add a frozen copy here whenever SUMMARY_PREFIX changes.
_HISTORICAL_SUMMARY_PREFIXES = (
"described here — avoid repeating it:",
# Pre-#35344: contained the self-contradicting "resume exactly" directive.
"[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted "
"into the summary below. This is a handoff from a previous context "
@@ -1155,7 +1188,7 @@ class ContextCompressor(ContextEngine):
)
reason_text = f" Summary failure reason: {reason}." if reason else ""
body = f"""## Active Task
body = f"""{HISTORICAL_TASK_HEADING}
{active_task}
## Goal
@@ -1172,7 +1205,7 @@ Recovered from a deterministic fallback because the LLM context summarizer was u
## Active State
Unknown from deterministic fallback. Inspect current repository/session state if needed.
## In Progress
{HISTORICAL_IN_PROGRESS_HEADING}
{active_task}
## Blocked
@@ -1184,13 +1217,13 @@ None recoverable from deterministic fallback.
## Resolved Questions
None recoverable from deterministic fallback.
## Pending User Asks
{HISTORICAL_PENDING_ASKS_HEADING}
{active_task}
## Relevant Files
{_bullets(relevant_files, limit=12)}
## Remaining Work
{HISTORICAL_REMAINING_WORK_HEADING}
Continue from the most recent unfulfilled user ask and protected tail messages. Verify state with tools before making claims.
## Last Dropped Turns
@@ -1312,7 +1345,7 @@ Summary generation was unavailable, so this is a best-effort deterministic fallb
_temporal_anchoring_rule = ""
# Shared structured template (used by both paths).
_template_sections = f"""## Active Task
_template_sections = f"""{HISTORICAL_TASK_HEADING}
[THE SINGLE MOST IMPORTANT FIELD. Capture the user's most recent unfulfilled
input verbatim — the exact words they used. This includes:
- Explicit task assignments ("refactor the auth module")
@@ -1359,7 +1392,7 @@ Be specific with file paths, commands, line numbers, and results.]
- Any running processes or servers
- Environment details that matter]
## In Progress
{HISTORICAL_IN_PROGRESS_HEADING}
[Work currently underway — what was being done when compaction fired]
## Blocked
@@ -1371,14 +1404,14 @@ Be specific with file paths, commands, line numbers, and results.]
## Resolved Questions
[Questions the user asked that were ALREADY answered — include the answer so it is not repeated]
## Pending User Asks
[Questions or requests from the user that have NOT yet been answered or fulfilled. If none, write "None."]
{HISTORICAL_PENDING_ASKS_HEADING}
[Questions or requests from the user that have NOT yet been answered or fulfilled. These are STALE — they were from the compacted turns. Write them here for reference only. The agent must NOT act on them unless the latest user message explicitly requests it. If none, write "None."]
## Relevant Files
[Files read, modified, or created — with brief note on each]
## Remaining Work
[What remains to be done — framed as context, not instructions]
{HISTORICAL_REMAINING_WORK_HEADING}
[What remains to be done — framed as STALE context for reference only. The agent must NOT resume this work unless the latest user message explicitly asks for it.]
## Critical Context
[Any specific values, error messages, configuration details, or data that would be lost without explicit preservation. NEVER include API keys, tokens, passwords, or credentials — write [REDACTED] instead.]
@@ -1753,7 +1786,7 @@ The user has requested that this compaction PRIORITISE preserving all informatio
Context compressor bug (#10896): ``_align_boundary_backward`` can pull
``cut_idx`` past a user message when it tries to keep tool_call/result
groups together. If the last user message ends up in the *compressed*
middle region the LLM summariser writes it into "Pending User Asks",
middle region the LLM summariser writes it into "Historical Pending User Asks",
but ``SUMMARY_PREFIX`` tells the next model to respond only to user
messages *after* the summary — so the task effectively disappears from
the active context, causing the agent to stall, repeat completed work,

View File

@@ -57,7 +57,11 @@ from agent.process_bootstrap import _install_safe_stdio
from agent.prompt_caching import apply_anthropic_cache_control
from agent.retry_utils import jittered_backoff
from agent.trajectory import has_incomplete_scratchpad
from agent.usage_pricing import estimate_usage_cost, normalize_usage
from agent.usage_pricing import (
estimate_usage_cost,
extract_provider_cost_usd,
normalize_usage,
)
from hermes_constants import PARTIAL_STREAM_STUB_ID
from hermes_logging import set_session_context
from tools.skill_provenance import set_current_write_origin
@@ -1633,6 +1637,37 @@ def run_conversation(
agent.session_cost_status = cost_result.status
agent.session_cost_source = cost_result.source
# ── Real provider-REPORTED cost (never estimated) ──
# OpenRouter usage accounting returns ``usage.cost`` on the
# response when the request carries usage:{include:true}
# (added on OpenRouter routes). When the provider reports
# nothing, this stays None — absent, NOT zero — so cost
# displays hide instead of showing a fabricated $0.00.
reported_cost_usd = extract_provider_cost_usd(response.usage)
if reported_cost_usd is not None:
_prev_actual = getattr(agent, "session_actual_cost_usd", None)
agent.session_actual_cost_usd = (_prev_actual or 0.0) + reported_cost_usd
agent.session_cost_status = "actual"
agent.session_cost_source = "provider_cost_api"
# Per-model session breakdown for /usage — counts are always
# real; cost_usd only accumulates provider-reported values
# and stays None when the provider reports nothing.
_model_usage = getattr(agent, "session_model_usage", None)
if _model_usage is None:
_model_usage = agent.session_model_usage = {}
_mrow = _model_usage.setdefault(agent.model, {
"calls": 0, "input": 0, "output": 0,
"cache_read": 0, "cache_write": 0, "cost_usd": None,
})
_mrow["calls"] += 1
_mrow["input"] += canonical_usage.input_tokens
_mrow["output"] += canonical_usage.output_tokens
_mrow["cache_read"] += canonical_usage.cache_read_tokens
_mrow["cache_write"] += canonical_usage.cache_write_tokens
if reported_cost_usd is not None:
_mrow["cost_usd"] = (_mrow["cost_usd"] or 0.0) + reported_cost_usd
# Persist token counts to session DB for /insights.
# Do this for every platform with a session_id so non-CLI
# sessions (gateway, cron, delegated runs) cannot lose
@@ -1659,8 +1694,14 @@ def run_conversation(
reasoning_tokens=canonical_usage.reasoning_tokens,
estimated_cost_usd=float(cost_result.amount_usd)
if cost_result.amount_usd is not None else None,
cost_status=cost_result.status,
cost_source=cost_result.source,
# Provider-reported per-call cost delta. NULL
# (not 0) when the provider reported nothing —
# the SQL CASE keeps actual_cost_usd untouched.
actual_cost_usd=reported_cost_usd,
cost_status="actual"
if reported_cost_usd is not None else cost_result.status,
cost_source="provider_cost_api"
if reported_cost_usd is not None else cost_result.source,
billing_provider=agent.provider,
billing_base_url=agent.base_url,
billing_mode="subscription_included"

View File

@@ -388,6 +388,13 @@ class ChatCompletionsTransport(ProviderTransport):
if provider_prefs and is_openrouter:
extra_body["provider"] = provider_prefs
# OpenRouter usage accounting — response `usage.cost` carries the REAL
# charged cost (credits are 1:1 USD). Parity with the profile path in
# plugins/model-providers/openrouter/__init__.py; this branch only runs
# when the OpenRouter profile isn't loaded.
if is_openrouter:
extra_body["usage"] = {"include": True}
# Pareto Code router plugin — model-gated. Same shape as the
# profile path in plugins/model-providers/openrouter/__init__.py;
# this branch only runs when the OpenRouter profile isn't loaded.

View File

@@ -852,6 +852,73 @@ def estimate_usage_cost(
)
def _finite_nonneg_number(value: Any) -> Optional[float]:
"""Return ``value`` as a float when it is a real, finite, non-negative
number (int/float, not bool); otherwise None."""
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
try:
f = float(value)
except (TypeError, ValueError):
return None
if f != f or f in (float("inf"), float("-inf")) or f < 0:
return None
return f
def extract_provider_cost_usd(response_usage: Any) -> Optional[float]:
"""Provider-REPORTED cost (USD) from a response ``usage`` object, or None.
Reads the ``usage.cost`` field that OpenRouter's usage accounting returns
(``usage: {"include": true}`` request param; OpenRouter credits are 1:1
USD). OpenRouter-compatible aggregators use the same field. This NEVER
estimates: when the provider reports nothing, the result is None — callers
must treat None as "no cost data", not zero. A reported ``0`` is a real
zero (e.g. free-tier models) and is returned as ``0.0``.
"""
if response_usage is None:
return None
cost = getattr(response_usage, "cost", None)
if cost is None and isinstance(response_usage, dict):
cost = response_usage.get("cost")
return _finite_nonneg_number(cost)
def real_session_cost_usd(agent: Any) -> Optional[float]:
"""Session-cumulative provider-REPORTED cost in USD, or None.
Combines the two real sources Hermes has — no estimation, ever:
- ``agent.session_actual_cost_usd``: per-response ``usage.cost``
accumulator (OpenRouter usage accounting).
- Nous ``x-nous-credits-*`` header delta via
``agent.get_credits_spent_micros()`` (account-level spend since the
session first saw a header; clamped at 0 so a mid-session top-up
doesn't render a negative cost).
Returns None when neither source has reported anything — callers must
hide their cost display in that case rather than showing $0.00.
"""
total: Optional[float] = None
actual = _finite_nonneg_number(getattr(agent, "session_actual_cost_usd", None))
if actual is not None:
total = actual
try:
spent_micros = agent.get_credits_spent_micros()
except Exception:
spent_micros = None
if spent_micros is not None:
try:
spent_usd = max(0, int(spent_micros)) / 1_000_000
except (TypeError, ValueError):
spent_usd = None
if spent_usd is not None:
total = (total or 0.0) + spent_usd
return total
def has_known_pricing(
model_name: str,
provider: Optional[str] = None,

View File

@@ -0,0 +1,99 @@
/**
* Helpers for local dashboard session-token discovery.
*
* The desktop main process can pass HERMES_DASHBOARD_SESSION_TOKEN when it
* spawns the local dashboard, but the dashboard is the source of truth for the
* token it actually serves to the renderer. If those drift, HTTP readiness
* probes still pass while /api/ws rejects the renderer's token.
*/
const DEFAULT_TOKEN_FETCH_TIMEOUT_MS = 3_000
async function fetchPublicText(url, options = {}) {
const { protocol } = new URL(url)
if (protocol !== 'http:' && protocol !== 'https:') {
throw new Error(`Unsupported Hermes backend URL protocol: ${protocol}`)
}
const timeoutMs = options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }).catch(error => {
if (error.name === 'TimeoutError') {
throw new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`)
}
throw error
})
const text = await res.text()
if (!res.ok) throw new Error(`${res.status}: ${text || res.statusText}`)
return text
}
function extractInjectedDashboardToken(html) {
const match = /window\.__HERMES_SESSION_TOKEN__\s*=\s*("(?:\\.|[^"\\])*")/.exec(String(html || ''))
if (!match) return null
try {
return JSON.parse(match[1])
} catch {
return null
}
}
function dashboardIndexUrl(baseUrl) {
return `${String(baseUrl || '').replace(/\/+$/, '')}/`
}
async function resolveServedDashboardToken(baseUrl, fallbackToken, options = {}) {
const fetchText = options.fetchText || fetchPublicText
const html = await fetchText(dashboardIndexUrl(baseUrl), {
timeoutMs: options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS
})
const servedToken = extractInjectedDashboardToken(html)
if (servedToken && servedToken !== fallbackToken && typeof options.rememberLog === 'function') {
options.rememberLog('[boot] dashboard served a different session token; using served token for WebSocket auth')
}
return servedToken || fallbackToken
}
/**
* A served token that differs from our spawn token while our child is DEAD
* came from a process we did not spawn (orphan/port squatter that satisfied
* the public /api/status readiness probe). With a live child the mismatch is
* benign: our own backend regenerated the token because the env pin did not
* survive the spawn.
*/
function isForeignBackendToken({ servedToken, spawnToken, childAlive }) {
return Boolean(servedToken) && servedToken !== spawnToken && !childAlive
}
/**
* Resolve the token the backend actually serves, adopting benign drift and
* failing loudly on a foreign backend. `childAlive` is a thunk so liveness is
* sampled after the fetch, not before.
*/
async function adoptServedDashboardToken(baseUrl, spawnToken, { childAlive, label = 'Hermes backend', ...options }) {
const servedToken = await resolveServedDashboardToken(baseUrl, spawnToken, options).catch(error => {
options.rememberLog?.(`[boot] could not read served dashboard token (${label}): ${error.message}`)
return spawnToken
})
if (isForeignBackendToken({ servedToken, spawnToken, childAlive: childAlive() })) {
throw new Error(
`${label} exited and ${dashboardIndexUrl(baseUrl)} is served by a process we did not spawn; refusing its session token.`
)
}
return servedToken
}
module.exports = {
DEFAULT_TOKEN_FETCH_TIMEOUT_MS,
adoptServedDashboardToken,
dashboardIndexUrl,
extractInjectedDashboardToken,
fetchPublicText,
isForeignBackendToken,
resolveServedDashboardToken
}

View File

@@ -0,0 +1,142 @@
/**
* Tests for electron/dashboard-token.cjs.
*
* Run with: node --test electron/dashboard-token.test.cjs
* (Wired into npm test:desktop:platforms in package.json.)
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const {
adoptServedDashboardToken,
dashboardIndexUrl,
extractInjectedDashboardToken,
fetchPublicText,
isForeignBackendToken,
resolveServedDashboardToken
} = require('./dashboard-token.cjs')
test('extractInjectedDashboardToken reads the JSON-encoded dashboard token', () => {
const html = '<script>window.__HERMES_SESSION_TOKEN__="served-token";window.__HERMES_BASE_PATH__=""</script>'
assert.equal(extractInjectedDashboardToken(html), 'served-token')
})
test('extractInjectedDashboardToken handles escaped token strings', () => {
const html = '<script>window.__HERMES_SESSION_TOKEN__="served\\\\token\\"quoted";</script>'
assert.equal(extractInjectedDashboardToken(html), 'served\\token"quoted')
})
test('extractInjectedDashboardToken returns null for missing or malformed values', () => {
assert.equal(extractInjectedDashboardToken('<html></html>'), null)
assert.equal(extractInjectedDashboardToken('<script>window.__HERMES_SESSION_TOKEN__={bad}</script>'), null)
})
test('dashboardIndexUrl preserves dashboard path prefixes', () => {
assert.equal(dashboardIndexUrl('http://127.0.0.1:9120'), 'http://127.0.0.1:9120/')
assert.equal(dashboardIndexUrl('https://host.example/hermes/'), 'https://host.example/hermes/')
})
test('resolveServedDashboardToken uses the served token and logs when it differs', async () => {
const logs = []
const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
fetchText: async url => {
assert.equal(url, 'http://127.0.0.1:9120/')
return '<script>window.__HERMES_SESSION_TOKEN__="served-token";</script>'
},
rememberLog: line => logs.push(line)
})
assert.equal(token, 'served-token')
assert.equal(logs.length, 1)
assert.match(logs[0], /served a different session token/)
})
test('resolveServedDashboardToken falls back when the served HTML has no token', async () => {
const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
fetchText: async () => '<html></html>',
rememberLog: () => {
throw new Error('should not log when no served token is present')
}
})
assert.equal(token, 'spawn-token')
})
test('resolveServedDashboardToken does not log when served token matches fallback', async () => {
const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'same-token', {
fetchText: async () => '<script>window.__HERMES_SESSION_TOKEN__="same-token";</script>',
rememberLog: () => {
throw new Error('should not log when token already matches')
}
})
assert.equal(token, 'same-token')
})
test('resolveServedDashboardToken propagates fetch errors so callers can fall back explicitly', async () => {
await assert.rejects(
() =>
resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
fetchText: async () => {
throw new Error('boom')
}
}),
/boom/
)
})
test('fetchPublicText rejects unsupported protocols', async () => {
await assert.rejects(() => fetchPublicText('file:///tmp/index.html'), /Unsupported Hermes backend URL protocol/)
})
test('isForeignBackendToken only flags a mismatched token from a dead child', () => {
const cases = [
[{ servedToken: 'other', spawnToken: 'mine', childAlive: false }, true],
// Live child + drift = our backend regenerated the token (env pin lost).
[{ servedToken: 'other', spawnToken: 'mine', childAlive: true }, false],
[{ servedToken: 'mine', spawnToken: 'mine', childAlive: false }, false],
[{ servedToken: 'mine', spawnToken: 'mine', childAlive: true }, false],
[{ servedToken: null, spawnToken: 'mine', childAlive: false }, false],
[{ servedToken: '', spawnToken: 'mine', childAlive: false }, false]
]
for (const [input, expected] of cases) {
assert.equal(isForeignBackendToken(input), expected, JSON.stringify(input))
}
})
test('adoptServedDashboardToken adopts drift from a live child', async () => {
const token = await adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
childAlive: () => true,
fetchText: async () => '<script>window.__HERMES_SESSION_TOKEN__="served-token";</script>'
})
assert.equal(token, 'served-token')
})
test('adoptServedDashboardToken refuses a foreign token when our child is dead', async () => {
await assert.rejects(
() =>
adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
childAlive: () => false,
fetchText: async () => '<script>window.__HERMES_SESSION_TOKEN__="squatter-token";</script>',
label: 'Hermes backend for profile "work"'
}),
/profile "work".*process we did not spawn/
)
})
test('adoptServedDashboardToken falls back to the spawn token when the fetch fails', async () => {
const logs = []
const token = await adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
childAlive: () => true,
fetchText: async () => {
throw new Error('boom')
},
rememberLog: line => logs.push(line)
})
assert.equal(token, 'spawn-token')
assert.equal(logs.length, 1)
assert.match(logs[0], /could not read served dashboard token \(Hermes backend\): boom/)
})

View File

@@ -29,6 +29,8 @@ const { runBootstrap } = require('./bootstrap-runner.cjs')
const { buildSessionWindowUrl, createSessionWindowRegistry } = require('./session-windows.cjs')
const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
const { adoptServedDashboardToken } = require('./dashboard-token.cjs')
const { PortPool } = require('./port-pool.cjs')
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
const { readDirForIpc } = require('./fs-read-dir.cjs')
@@ -107,6 +109,10 @@ if (USER_DATA_OVERRIDE) {
const PORT_FLOOR = 9120
const PORT_CEILING = 9199
// In-process port reservations that close the pickPort() TOCTOU window where
// two concurrent backend spawns could be handed the same port. See
// port-pool.cjs for the full rationale.
const portPool = new PortPool(PORT_FLOOR, PORT_CEILING)
const DEV_SERVER = process.env.HERMES_DESKTOP_DEV_SERVER
const IS_PACKAGED = app.isPackaged
const IS_MAC = process.platform === 'darwin'
@@ -2452,10 +2458,11 @@ function isPortAvailable(port) {
}
async function pickPort() {
for (let port = PORT_FLOOR; port <= PORT_CEILING; port += 1) {
if (await isPortAvailable(port)) return port
const port = await portPool.reserve(isPortAvailable)
if (port === null) {
throw new Error(`No free localhost port in ${PORT_FLOOR}-${PORT_CEILING}`)
}
throw new Error(`No free localhost port in ${PORT_FLOOR}-${PORT_CEILING}`)
return port
}
function fetchJson(url, token, options = {}) {
@@ -4539,9 +4546,20 @@ async function spawnPoolBackend(profile, entry) {
// --profile wins over the inherited HERMES_HOME env (see _apply_profile_override
// step 3 in hermes_cli/main.py), so the child re-homes to this profile.
const dashboardArgs = ['--profile', profile, 'dashboard', '--no-open', '--host', '127.0.0.1', '--port', String(port)]
const backend = await ensureRuntime(resolveHermesBackend(dashboardArgs))
const hermesCwd = resolveHermesCwd()
const webDist = resolveWebDist()
let backend
let hermesCwd
let webDist
try {
backend = await ensureRuntime(resolveHermesBackend(dashboardArgs))
hermesCwd = resolveHermesCwd()
webDist = resolveWebDist()
} catch (error) {
// These run before the child exists / its exit handler is attached, so a
// throw here would otherwise leak the reservation and slowly exhaust the
// 9120-9199 range across switch cycles in one app session.
portPool.release(port)
throw error
}
rememberLog(`Starting Hermes backend for profile "${profile}" via ${backend.label}`)
@@ -4579,11 +4597,13 @@ async function spawnPoolBackend(profile, entry) {
child.once('error', error => {
rememberLog(`Hermes backend for profile "${profile}" failed to start: ${error.message}`)
backendPool.delete(profile)
portPool.release(port)
rejectStart?.(error)
})
child.once('exit', (code, signal) => {
rememberLog(`Hermes backend for profile "${profile}" exited (${signal || code})`)
backendPool.delete(profile)
portPool.release(port)
if (!ready) {
rejectStart?.(
new Error(`Hermes backend for profile "${profile}" exited before it became ready (${signal || code}).`)
@@ -4594,15 +4614,21 @@ async function spawnPoolBackend(profile, entry) {
const baseUrl = `http://127.0.0.1:${port}`
await Promise.race([waitForHermes(baseUrl, token), startFailed])
ready = true
const authToken = await adoptServedDashboardToken(baseUrl, token, {
childAlive: () => child.exitCode === null && !child.killed,
label: `Hermes backend for profile "${profile}"`,
rememberLog
})
entry.token = authToken
return {
baseUrl,
mode: 'local',
source: 'local',
authMode: 'token',
token,
token: authToken,
profile,
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(token)}`,
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`,
logs: hermesLog.slice(-80),
...getWindowState()
}
@@ -4612,6 +4638,7 @@ function stopPoolBackend(profile) {
const entry = backendPool.get(profile)
if (!entry) return
backendPool.delete(profile)
if (entry.port) portPool.release(entry.port)
if (entry.process && !entry.process.killed) {
try {
entry.process.kill('SIGTERM')
@@ -4697,6 +4724,11 @@ async function startHermes() {
}
if (connectionPromise) return connectionPromise
// Hoisted so the outer .catch can release a port reserved by pickPort() when
// a throw (e.g. ensureRuntime failing) happens before the child's exit
// handler is attached. Stays null on the remote path (no port picked).
let reservedPort = null
connectionPromise = (async () => {
await advanceBootProgress('backend.resolve', 'Resolving Hermes backend', 8)
// Resolve for the desktop's primary profile so a per-profile remote
@@ -4726,6 +4758,7 @@ async function startHermes() {
await advanceBootProgress('backend.port', 'Finding an open local port', 16)
const port = await pickPort()
reservedPort = port
const token = crypto.randomBytes(32).toString('base64url')
const dashboardArgs = ['dashboard', '--no-open', '--host', '127.0.0.1', '--port', String(port)]
// Pin the desktop's chosen profile via the global --profile flag. This is
@@ -4790,6 +4823,7 @@ async function startHermes() {
)
hermesProcess = null
connectionPromise = null
portPool.release(port)
sendBackendExit({ code: null, signal: null, error: error.message })
rejectBackendStart?.(error)
})
@@ -4797,6 +4831,7 @@ async function startHermes() {
rememberLog(`Hermes backend exited (${signal || code})`)
hermesProcess = null
connectionPromise = null
portPool.release(port)
sendBackendExit({ code, signal })
if (!backendReady) {
const message = `Hermes backend exited before it became ready (${signal || code}).`
@@ -4821,6 +4856,11 @@ async function startHermes() {
await advanceBootProgress('backend.wait', 'Waiting for Hermes backend to become ready', 90)
await Promise.race([waitForHermes(baseUrl, token), backendStartFailed])
backendReady = true
const authToken = await adoptServedDashboardToken(baseUrl, token, {
// The exit/error handlers null hermesProcess when the child dies.
childAlive: () => hermesProcess !== null && hermesProcess.exitCode === null && !hermesProcess.killed,
rememberLog
})
updateBootProgress({
phase: 'backend.ready',
message: 'Hermes backend is ready. Finalizing desktop startup',
@@ -4834,8 +4874,8 @@ async function startHermes() {
mode: 'local',
source: 'local',
authMode: 'token',
token,
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(token)}`,
token: authToken,
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`,
logs: hermesLog.slice(-80),
...getWindowState()
}
@@ -4851,6 +4891,7 @@ async function startHermes() {
{ allowDecrease: true }
)
connectionPromise = null
portPool.release(reservedPort)
throw error
})
@@ -5125,8 +5166,8 @@ ipcMain.handle('hermes:bootstrap:reset', async () => {
// reset connection state so the next startHermes() call restarts the
// full backend flow (including a fresh runBootstrap pass).
rememberLog('[bootstrap] reset requested by renderer; clearing latched failure')
await teardownPrimaryBackendAndWait()
bootstrapFailure = null
connectionPromise = null
bootstrapState = {
active: false,
manifest: null,

View File

@@ -0,0 +1,73 @@
'use strict'
/**
* In-process port reservation pool for the desktop backend launcher.
*
* pickPort() probes a localhost port with a throwaway server and closes it
* before the real bind happens in a separate Python child. Between that probe
* and the child's bind there is a TOCTOU window: a second concurrent spawn
* (the primary backend racing a pool backend) can be handed the SAME port, and
* one then dies with EADDRINUSE ("address already in use" -> "Object has been
* destroyed" boot loop). Reserving the chosen port in THIS process until the
* child exits closes that window.
*
* The OS bind remains the source of truth; this only deconflicts racers inside
* this process — it can't stop a foreign squatter, which the probe + the
* EADDRINUSE self-heal still cover.
*
* The pool is dependency-injected (the availability probe is passed in) and
* free of Electron/Node socket I/O, so it is unit-tested without real sockets
* (see port-pool.test.cjs).
*/
class PortPool {
/**
* @param {number} floor inclusive lowest port to hand out
* @param {number} ceiling inclusive highest port to hand out
*/
constructor(floor, ceiling) {
this.floor = floor
this.ceiling = ceiling
this._reserved = new Set()
}
/** @returns {boolean} whether `port` is currently reserved in-process. */
has(port) {
return this._reserved.has(port)
}
/** Release a previously reserved port. No-op if it was not reserved. */
release(port) {
this._reserved.delete(port)
}
/** Drop all reservations. */
clear() {
this._reserved.clear()
}
/** @returns {number} count of currently reserved ports. */
get size() {
return this._reserved.size
}
/**
* Reserve and return the lowest port in [floor, ceiling] that is neither
* already reserved in-process nor rejected by `isAvailable(port)`, or null
* if every port is taken. `isAvailable` may be sync (boolean) or async
* (Promise<boolean>); it is awaited either way.
*
* @param {(port: number) => boolean | Promise<boolean>} isAvailable
* @returns {Promise<number|null>}
*/
async reserve(isAvailable) {
for (let port = this.floor; port <= this.ceiling; port += 1) {
if (this._reserved.has(port)) continue
if (!(await isAvailable(port))) continue
this._reserved.add(port)
return port
}
return null
}
}
module.exports = { PortPool }

View File

@@ -0,0 +1,77 @@
/**
* Tests for electron/port-pool.cjs.
*
* Run with: node --test electron/port-pool.test.cjs
*
* PortPool is the in-process reservation that closes the pickPort() TOCTOU
* window. These cover selection order, skipping reserved/unavailable ports,
* release/reuse, exhaustion, and async probes — without real sockets.
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const { PortPool } = require('./port-pool.cjs')
const allFree = () => true
test('reserve returns the lowest free port and reserves it', async () => {
const pool = new PortPool(9120, 9199)
const port = await pool.reserve(allFree)
assert.equal(port, 9120)
assert.ok(pool.has(9120))
assert.equal(pool.size, 1)
})
test('reserve skips ports already reserved in-process', async () => {
const pool = new PortPool(9120, 9199)
const first = await pool.reserve(allFree)
const second = await pool.reserve(allFree)
assert.equal(first, 9120)
assert.equal(second, 9121)
})
test('reserve skips ports the probe rejects', async () => {
const pool = new PortPool(9120, 9199)
const busy = new Set([9120, 9121])
const port = await pool.reserve(p => !busy.has(p))
assert.equal(port, 9122)
})
test('reserve returns null when every port is taken', async () => {
const pool = new PortPool(9120, 9121)
await pool.reserve(allFree)
await pool.reserve(allFree)
assert.equal(await pool.reserve(allFree), null)
})
test('release frees a reserved port for reuse', async () => {
const pool = new PortPool(9120, 9120)
assert.equal(await pool.reserve(allFree), 9120)
assert.equal(await pool.reserve(allFree), null) // exhausted
pool.release(9120)
assert.ok(!pool.has(9120))
assert.equal(await pool.reserve(allFree), 9120) // reusable
})
test('release is a no-op for an unreserved port', () => {
const pool = new PortPool(9120, 9199)
pool.release(9120)
assert.equal(pool.size, 0)
})
test('reserve awaits an async probe', async () => {
const pool = new PortPool(9120, 9199)
const busy = new Set([9120])
const port = await pool.reserve(p => Promise.resolve(!busy.has(p)))
assert.equal(port, 9121)
})
test('clear drops all reservations', async () => {
const pool = new PortPool(9120, 9199)
await pool.reserve(allFree)
await pool.reserve(allFree)
assert.equal(pool.size, 2)
pool.clear()
assert.equal(pool.size, 0)
})

View File

@@ -8,7 +8,7 @@ const path = require('node:path')
const ELECTRON_DIR = __dirname
function readElectronFile(name) {
return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8')
return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8').replace(/\r\n/g, '\n')
}
function requireHiddenChildOptions(source, needle) {

View File

@@ -35,7 +35,7 @@
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/port-pool.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs",
"typecheck": "tsc -p . --noEmit",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",
@@ -105,7 +105,7 @@
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.3.2",
"@types/hast": "^3.0.4",
"@types/node": "^24.12.0",
"@types/node": "^24.13.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.59.1",

View File

@@ -18,7 +18,7 @@ import {
} from '@/components/ui/pagination'
import { TextTab, TextTabMeta } from '@/components/ui/text-tab'
import { Tip } from '@/components/ui/tooltip'
import { getSessionMessages, listSessions } from '@/hermes'
import { getSessionMessages, listAllProfileSessions } from '@/hermes'
import { type Translations, useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import { ExternalLink, ExternalLinkIcon, hostPathLabel, urlSlugTitleLabel, useLinkTitle } from '@/lib/external-link'
@@ -388,8 +388,8 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
setRefreshing(true)
try {
const sessions = (await listSessions(30, 1)).sessions
const results = await Promise.allSettled(sessions.map(session => getSessionMessages(session.id)))
const sessions = (await listAllProfileSessions(30, 1)).sessions
const results = await Promise.allSettled(sessions.map(session => getSessionMessages(session.id, session.profile)))
const nextArtifacts: ArtifactRecord[] = []
results.forEach((result, index) => {

View File

@@ -287,7 +287,7 @@ const MARKDOWN_COMPONENTS = {
function MarkdownPreview({ text }: { text: string }) {
return (
<div className="preview-markdown mx-auto max-w-3xl px-4 py-3 text-sm text-foreground">
<div className="preview-markdown mx-auto max-w-3xl px-4 py-3 text-sm text-foreground" data-selectable-text="true">
<Streamdown components={MARKDOWN_COMPONENTS} controls={false} mode="static" parseIncompleteMarkdown={false}>
{text}
</Streamdown>
@@ -383,7 +383,10 @@ function SourceView({ filePath, language, text }: { filePath: string; language:
)
})}
</div>
<div className="relative [&_pre]:m-0 [&_pre]:px-3 [&_pre]:py-3 [&_pre]:bg-transparent!">
<div
className="relative [&_pre]:m-0 [&_pre]:px-3 [&_pre]:py-3 [&_pre]:bg-transparent!"
data-selectable-text="true"
>
{selection && (
<div
aria-hidden

View File

@@ -797,7 +797,14 @@ export function ChatSidebar({
<SidebarMenuButton
aria-disabled={!isInteractive}
className={cn(
'flex h-7 w-full justify-start gap-2 rounded-md border border-transparent px-2 text-left text-[0.8125rem] font-medium text-(--ui-text-secondary) transition-colors duration-100 ease-out hover:bg-(--ui-control-hover-background) hover:text-foreground hover:transition-none',
// no-drag: these rows sit directly under the titlebar's
// [-webkit-app-region:drag] strips (app-shell.tsx), with only
// 6px of clearance. Drag regions win hit-testing over DOM
// (pointer-events can't override), and on Linux/WSLg the
// resolved region has been observed to swallow clicks on the
// top rows. Same carve-out as USER_BUBBLE_BASE_CLASS in
// thread.tsx.
'flex h-7 w-full justify-start gap-2 rounded-md border border-transparent px-2 text-left text-[0.8125rem] font-medium text-(--ui-text-secondary) transition-colors duration-100 ease-out [-webkit-app-region:no-drag] hover:bg-(--ui-control-hover-background) hover:text-foreground hover:transition-none',
active &&
'border-(--ui-stroke-tertiary) bg-(--ui-control-active-background) text-foreground shadow-none hover:border-(--ui-stroke-tertiary)!',
!isInteractive &&

View File

@@ -88,7 +88,7 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
label: r.export,
onSelect: () => {
triggerHaptic('selection')
void exportSession(sessionId, { title })
void exportSession(sessionId, { profile, title })
}
},
{

View File

@@ -8,7 +8,7 @@ import { HUD_HEADING, HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from '@/ap
import { setTerminalTakeover } from '@/app/right-sidebar/store'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { KbdGroup } from '@/components/ui/kbd'
import { getHermesConfigRecord, listSessions } from '@/hermes'
import { getHermesConfigRecord, listAllProfileSessions } from '@/hermes'
import { useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import {
@@ -119,7 +119,7 @@ const paletteFilter = (value: string, search: string, keywords?: string[]): numb
return needle.split(/\s+/).every(term => haystack.includes(term)) ? 1 : 0
}
type SessionRow = Awaited<ReturnType<typeof listSessions>>['sessions'][number]
type SessionRow = Awaited<ReturnType<typeof listAllProfileSessions>>['sessions'][number]
const toSessionEntry = (session: SessionRow): SessionEntry => ({
id: session.id,
@@ -218,13 +218,13 @@ export function CommandPalette() {
const sessionsQuery = useQuery({
queryKey: ['command-palette', 'sessions'],
queryFn: () => listSessions(200, 1, 'exclude'),
queryFn: () => listAllProfileSessions(200, 1, 'exclude'),
enabled: open
})
const archivedQuery = useQuery({
queryKey: ['command-palette', 'archived'],
queryFn: () => listSessions(200, 0, 'only'),
queryFn: () => listAllProfileSessions(200, 0, 'only'),
enabled: open
})

View File

@@ -547,7 +547,9 @@ export function DesktopController() {
return
}
const storedProfile = $sessions.get().find(session => session.id === storedSessionId)?.profile
const storedProfile = $sessions
.get()
.find(session => session.id === storedSessionId || session._lineage_root_id === storedSessionId)?.profile
for (let index = 0; index < Math.max(1, attempts); index += 1) {
try {

View File

@@ -315,8 +315,11 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
allowTransparency: true,
convertEol: true,
cursorBlink: true,
fontFamily: "'SF Mono', 'Menlo', 'Cascadia Code', 'JetBrains Mono', monospace",
fontFamily: "'JetBrains Mono', 'Cascadia Code', 'SF Mono', Menlo, Consolas, monospace",
fontSize: 11,
fontWeight: '400',
fontWeightBold: '700',
letterSpacing: 0,
lineHeight: 1.12,
// Full-screen TUIs (hermes --tui, vim) grab the mouse, so a plain drag
// can't select — ⌥-drag (macOS) / Shift-drag (else) forces a native
@@ -598,13 +601,13 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
startSession()
}
const fonts = typeof document !== 'undefined' ? document.fonts : undefined
// fonts.ready settles only already-requested faces; bold/italic aren't asked
// for until styled output paints (past atlas init), so warm them up front.
const warm = document.fonts?.load
? Promise.allSettled(['400', '700', 'italic 400'].map(v => document.fonts.load(`${v} 11px 'JetBrains Mono'`)))
: Promise.resolve()
if (fonts?.ready) {
void fonts.ready.then(mount, mount)
} else {
mount()
}
void warm.then(mount, mount)
return () => {
disposed = true

View File

@@ -933,6 +933,8 @@ export function useMessageStream({
// raise it and wait — the sidebar flags "needs input" and the inline bar
// surfaces once the user focuses that chat.
setApprovalRequest({
// false only when a tirith warning forbids it; backend omits the field otherwise.
allowPermanent: payload?.allow_permanent !== false,
command: typeof payload?.command === 'string' ? payload.command : '',
description: typeof payload?.description === 'string' ? payload.description : 'dangerous command',
sessionId: sessionId ?? null

View File

@@ -2,7 +2,7 @@ import type { MutableRefObject } from 'react'
import { useCallback, useRef } from 'react'
import type { NavigateFunction } from 'react-router-dom'
import { deleteSession, getSessionMessages, setSessionArchived } from '@/hermes'
import { deleteSession, getSessionMessages, listAllProfileSessions, setSessionArchived } from '@/hermes'
import { useI18n } from '@/i18n'
import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages'
import { normalizePersonalityValue } from '@/lib/chat-runtime'
@@ -209,6 +209,46 @@ function patchSessionWorkspace(sessionId: string, cwd: string | undefined) {
setSessions(prev => prev.map(session => (session.id === sessionId ? { ...session, cwd } : session)))
}
function sessionMatchesStoredId(session: SessionInfo, storedSessionId: string): boolean {
return session.id === storedSessionId || session._lineage_root_id === storedSessionId
}
function upsertResolvedSession(session: SessionInfo, storedSessionId: string) {
const lineage = session._lineage_root_id ?? session.id
setSessions(prev => [
session,
...prev.filter(existing => {
if (sessionMatchesStoredId(existing, storedSessionId)) {
return false
}
return (existing._lineage_root_id ?? existing.id) !== lineage
})
])
}
async function resolveStoredSession(storedSessionId: string): Promise<SessionInfo | undefined> {
const cached = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
if (cached) {
return cached
}
try {
const result = await listAllProfileSessions(500, 0, 'include', 'recent', 'all')
const resolved = result.sessions.find(session => sessionMatchesStoredId(session, storedSessionId))
if (resolved) {
upsertResolvedSession(resolved, storedSessionId)
}
return resolved
} catch {
return undefined
}
}
type SessionRuntimeStatePatch = Partial<
Pick<
ClientSessionState,
@@ -480,8 +520,13 @@ export function useSessionActions({
// Swap the single live gateway to this session's profile before any
// gateway call (no-op when it's already on that profile / single-profile).
const storedForProfile = $sessions.get().find(session => session.id === storedSessionId)
const storedForProfile = await resolveStoredSession(storedSessionId)
const sessionProfile = storedForProfile?.profile
if (resumeRequestRef.current !== requestId) {
return
}
await ensureGatewayProfile(sessionProfile)
const cachedRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
@@ -549,7 +594,7 @@ export function useSessionActions({
setSelectedStoredSessionId(storedSessionId)
selectedStoredSessionIdRef.current = storedSessionId
setSessionStartedAt(Date.now())
const stored = $sessions.get().find(session => session.id === storedSessionId)
const stored = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
applyStoredSessionPreviewRuntimeInfo(stored)
if (stored) {
@@ -799,7 +844,7 @@ export function useSessionActions({
async (storedSessionId: string) => {
clearNotifications()
const removed = $sessions.get().find(s => s.id === storedSessionId)
const removed = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
const wasSelected = selectedStoredSessionId === storedSessionId
const closingRuntimeId = wasSelected ? activeSessionId : null
const previousMessages = $messages.get()
@@ -808,7 +853,7 @@ export function useSessionActions({
// live tip after compression. Drop both so the pin can't linger.
const removedPinId = removed ? sessionPinId(removed) : storedSessionId
setSessions(prev => prev.filter(s => s.id !== storedSessionId))
setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId)))
// Keep $sessionsTotal in sync so the sidebar's "Load N more" footer
// doesn't keep claiming the removed row is still on the server.
setSessionsTotal(prev => Math.max(0, prev - 1))
@@ -843,7 +888,7 @@ export function useSessionActions({
setFreshDraftReady(false)
setSelectedStoredSessionId(storedSessionId)
selectedStoredSessionIdRef.current = storedSessionId
const stored = $sessions.get().find(session => session.id === storedSessionId)
const stored = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
if (stored) {
setCurrentUsage(current => ({
@@ -882,7 +927,7 @@ export function useSessionActions({
async (storedSessionId: string) => {
clearNotifications()
const archived = $sessions.get().find(s => s.id === storedSessionId)
const archived = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
const wasSelected = selectedStoredSessionId === storedSessionId
const previousPinned = $pinnedSessionIds.get()
// Pins are keyed on the durable lineage-root id; the stored id may be the
@@ -890,7 +935,7 @@ export function useSessionActions({
const archivedPinId = archived ? sessionPinId(archived) : storedSessionId
// Soft-hide: drop from the sidebar immediately, keep the data.
setSessions(prev => prev.filter(s => s.id !== storedSessionId))
setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId)))
// Archived sessions are hidden by the listSessions(min_messages=1) query
// on the next refresh, so they count as "removed" for the load-more
// footer math.
@@ -907,12 +952,12 @@ export function useSessionActions({
// in flight and briefly reinsert the still-unarchived backend row. Win
// that race after the mutation succeeds so right-click → Archive does
// not appear to do nothing until the next full refresh.
setSessions(prev => prev.filter(s => s.id !== storedSessionId))
setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId)))
$pinnedSessionIds.set($pinnedSessionIds.get().filter(id => id !== storedSessionId && id !== archivedPinId))
notify({ durationMs: 2_000, kind: 'success', message: copy.archived })
} catch (err) {
if (archived) {
setSessions(prev => [archived, ...prev.filter(s => s.id !== storedSessionId)])
setSessions(prev => [archived, ...prev.filter(session => !sessionMatchesStoredId(session, storedSessionId))])
setSessionsTotal(prev => prev + 1)
}

View File

@@ -15,7 +15,7 @@ import type { AuxiliaryModelsResponse, ModelOptionProvider, StaleAuxAssignment }
import { useI18n } from '@/i18n'
import { AlertTriangle, Cpu, Loader2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { startManualProviderOAuth } from '@/store/onboarding'
import { startManualLocalEndpoint, startManualProviderOAuth } from '@/store/onboarding'
import { CONTROL_TEXT } from './constants'
import { ListRow, LoadingState, Pill, SectionHeading } from './primitives'
@@ -224,10 +224,23 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
}, [apiKeyDraft, selectedProviderRow])
// OAuth / external providers can't be activated with a pasted key — hand off
// to the shared onboarding flow scoped to this provider's real sign-in.
// to the shared onboarding flow scoped to this provider's real sign-in. The
// custom / local endpoint is NOT an OAuth provider, so it gets the dedicated
// local-endpoint form (URL + optional API key) instead of being dead-ended
// on the OAuth picker (the original "booted back to the first screen" loop).
const startProviderSetup = useCallback(() => {
if (selectedProviderRow?.slug) {
startManualProviderOAuth(selectedProviderRow.slug)
const slug = selectedProviderRow?.slug
if (!slug) {
return
}
const lower = slug.toLowerCase()
if (lower === 'custom' || lower === 'local' || lower.startsWith('custom:')) {
startManualLocalEndpoint()
} else {
startManualProviderOAuth(slug)
}
}, [selectedProviderRow])

View File

@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Tip } from '@/components/ui/tooltip'
import { deleteSession, listSessions, setSessionArchived } from '@/hermes'
import { deleteSession, listAllProfileSessions, setSessionArchived } from '@/hermes'
import { useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
@@ -43,14 +43,14 @@ export function SessionsSettings() {
setLoading(true)
try {
const result = await listSessions(ARCHIVED_FETCH_LIMIT, 0, 'only')
const result = await listAllProfileSessions(ARCHIVED_FETCH_LIMIT, 0, 'only')
setLocalSessions(result.sessions)
} catch (err) {
notifyError(err, s.failedLoad)
} finally {
setLoading(false)
}
}, [])
}, [s.failedLoad])
useEffect(() => {
void load()

View File

@@ -0,0 +1,80 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MessageRenderBoundary } from './message-render-boundary'
afterEach(cleanup)
function Boom({ error }: { error: Error | null }): null {
if (error) {
throw error
}
return null
}
const lookupError = new Error('tapClientLookup: Index 2 out of bounds (length: 2)')
describe('MessageRenderBoundary', () => {
it('renders children when nothing throws', () => {
render(
<MessageRenderBoundary resetKey="a">
<div>content</div>
</MessageRenderBoundary>
)
expect(screen.getByText('content')).toBeTruthy()
})
it('swallows the transient tapClientLookup out-of-bounds store race', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
const { container } = render(
<MessageRenderBoundary resetKey="a">
<Boom error={lookupError} />
</MessageRenderBoundary>
)
expect(container.innerHTML).toBe('')
spy.mockRestore()
})
it('recovers on the next consistent snapshot when resetKey changes', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
const { rerender } = render(
<MessageRenderBoundary resetKey="a">
<Boom error={lookupError} />
</MessageRenderBoundary>
)
rerender(
<MessageRenderBoundary resetKey="b">
<Boom error={null} />
</MessageRenderBoundary>
)
rerender(
<MessageRenderBoundary resetKey="b">
<div>recovered</div>
</MessageRenderBoundary>
)
expect(screen.getByText('recovered')).toBeTruthy()
spy.mockRestore()
})
it('re-throws unrelated errors so real bugs still surface', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
expect(() =>
render(
<MessageRenderBoundary resetKey="a">
<Boom error={new Error('genuine render bug')} />
</MessageRenderBoundary>
)
).toThrow('genuine render bug')
spy.mockRestore()
})
})

View File

@@ -0,0 +1,48 @@
import { Component, type ReactNode } from 'react'
// `@assistant-ui/store`'s index-keyed child-scope lookup (`tapClientLookup`)
// throws — rather than returning undefined — when a subscriber reads an index
// that the message/parts list no longer has. This races during high-frequency
// store replacement (session switch mid-stream, gateway reconnect replay): a
// subscriber from the previous, longer list is still in React's notification
// queue and reads one slot past the new, shorter array before it can unmount.
// The throw is transient and self-heals on the next consistent snapshot, but
// without a local boundary it unwinds to the root and blanks the whole app.
// Upstream-tracked: assistant-ui/assistant-ui#4051, #3652.
const isTransientLookupError = (error: unknown): boolean =>
error instanceof Error && /tapClient(Lookup|Resource).*out of bounds/.test(error.message)
interface Props {
// Changes whenever the message list mutates; remounting clears the caught
// error so the next consistent render recovers silently.
resetKey: string
children: ReactNode
}
export class MessageRenderBoundary extends Component<Props, { error: Error | null }> {
state: { error: Error | null } = { error: null }
static getDerivedStateFromError(error: Error) {
return { error }
}
componentDidUpdate(prev: Props) {
if (this.state.error && prev.resetKey !== this.props.resetKey) {
this.setState({ error: null })
}
}
render() {
if (this.state.error) {
// Only swallow the transient store race; re-throw anything else so real
// bugs still reach the root error boundary.
if (!isTransientLookupError(this.state.error)) {
throw this.state.error
}
return null
}
return this.props.children
}
}

View File

@@ -16,6 +16,8 @@ import { setMutableRef } from '@/lib/mutable-ref'
import { cn } from '@/lib/utils'
import { setThreadScrolledUp } from '@/store/thread-scroll'
import { MessageRenderBoundary } from './message-render-boundary'
const ESTIMATED_ITEM_HEIGHT = 220
const OVERSCAN = 4
const AT_BOTTOM_THRESHOLD = 4
@@ -180,18 +182,20 @@ const VirtualizedThreadInner: FC<VirtualizedThreadProps> = ({
key={virtualItem.key}
ref={virtualizer.measureElement}
>
{group.kind === 'turn' ? (
<div
className="composer-human-ai-pair-container relative flex min-w-0 flex-col gap-(--conversation-turn-gap)"
data-slot="aui_turn-pair"
>
{group.indices.map(index => (
<ThreadPrimitive.MessageByIndex components={components} index={index} key={index} />
))}
</div>
) : (
<ThreadPrimitive.MessageByIndex components={components} index={group.index} />
)}
<MessageRenderBoundary resetKey={messageSignature}>
{group.kind === 'turn' ? (
<div
className="composer-human-ai-pair-container relative flex min-w-0 flex-col gap-(--conversation-turn-gap)"
data-slot="aui_turn-pair"
>
{group.indices.map(index => (
<ThreadPrimitive.MessageByIndex components={components} index={index} key={index} />
))}
</div>
) : (
<ThreadPrimitive.MessageByIndex components={components} index={group.index} />
)}
</MessageRenderBoundary>
</div>
)
})}

View File

@@ -1,5 +1,5 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import type { HermesGateway } from '@/hermes'
import { $gateway } from '@/store/gateway'
@@ -9,13 +9,30 @@ import { $activeSessionId } from '@/store/session'
import { PendingToolApproval } from './tool-approval'
import type { ToolPart } from './tool-fallback-model'
// Radix's DropdownMenu touches pointer-capture + scrollIntoView, which jsdom
// doesn't implement; stub them so the menu can open in tests.
beforeAll(() => {
const proto = window.HTMLElement.prototype as unknown as Record<string, () => unknown>
const stubs: Record<string, () => unknown> = {
hasPointerCapture: () => false,
releasePointerCapture: () => undefined,
scrollIntoView: () => undefined,
setPointerCapture: () => undefined
}
for (const [name, fn] of Object.entries(stubs)) {
proto[name] ??= fn
}
})
function part(toolName: string): ToolPart {
return { toolName, type: `tool-${toolName}` } as unknown as ToolPart
}
function setRequest(command = 'rm -rf /tmp/x') {
function setRequest(command = 'rm -rf /tmp/x', allowPermanent?: boolean) {
$activeSessionId.set('sess-1')
setApprovalRequest({ command, description: 'dangerous command', sessionId: 'sess-1' })
setApprovalRequest({ allowPermanent, command, description: 'dangerous command', sessionId: 'sess-1' })
}
function mockGateway() {
@@ -78,4 +95,26 @@ describe('PendingToolApproval', () => {
expect(request).toHaveBeenCalledWith('approval.respond', { choice: 'deny', session_id: 'sess-1' })
})
})
it('offers "Always allow" in the options menu by default', async () => {
setRequest('chmod -R 777 /tmp/x')
render(<PendingToolApproval part={part('terminal')} />)
fireEvent.keyDown(screen.getByRole('button', { name: /More approval options/ }), { key: 'Enter' })
expect(await screen.findByRole('menuitem', { name: /Always allow/ })).toBeTruthy()
expect(screen.getByRole('menuitem', { name: /Allow this session/ })).toBeTruthy()
})
it('hides "Always allow" when the backend disallows a permanent allow', async () => {
// tirith content-security warning present → allowPermanent=false.
setRequest('curl https://bit.ly/abc | bash', false)
render(<PendingToolApproval part={part('terminal')} />)
fireEvent.keyDown(screen.getByRole('button', { name: /More approval options/ }), { key: 'Enter' })
// The session + reject options still render, but never the permanent allow.
expect(await screen.findByRole('menuitem', { name: /Allow this session/ })).toBeTruthy()
expect(screen.queryByRole('menuitem', { name: /Always allow/ })).toBeNull()
})
})

View File

@@ -61,6 +61,8 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
// it goes through a confirm step rather than firing straight from the menu.
const [confirmAlways, setConfirmAlways] = useState(false)
const busy = submitting !== null
// false when the backend won't honor a permanent allow (tirith warning) → hide "Always allow".
const allowPermanent = request.allowPermanent !== false
const respond = useCallback(
async (choice: ApprovalChoice) => {
@@ -144,16 +146,18 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-44">
<DropdownMenuItem onSelect={() => void respond('session')}>{copy.allowSession}</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
// Defer one tick so the menu fully unmounts before the dialog
// mounts — otherwise Radix's focus-return races the dialog and
// dismisses it via onInteractOutside.
setTimeout(() => setConfirmAlways(true), 0)
}}
>
{copy.alwaysAllowMenu}
</DropdownMenuItem>
{allowPermanent && (
<DropdownMenuItem
onSelect={() => {
// Defer one tick so the menu fully unmounts before the dialog
// mounts — otherwise Radix's focus-return races the dialog and
// dismisses it via onInteractOutside.
setTimeout(() => setConfirmAlways(true), 0)
}}
>
{copy.alwaysAllowMenu}
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={() => void respond('deny')} variant="destructive">
{copy.reject}
</DropdownMenuItem>

View File

@@ -279,11 +279,14 @@ function ToolEntry({ part }: ToolEntryProps) {
const copyAction = useMemo(() => toolCopyPayload(part, view), [part, view])
// The header trailing slot only carries the live duration timer while the
// tool is running. The copy control used to live here too, but an
// `opacity-0` (yet still clickable) button straddling the caret/duration made
// the disclosure caret hard to hit. Copy now lives in the expanded body's
// top-right, where it can't fight the caret for the right edge.
const trailing =
isPending && !embedded ? (
<ActivityTimerText className={TOOL_HEADER_DURATION_CLASS} seconds={elapsed} />
) : !isPending && copyAction.text ? (
<CopyButton appearance="tool-row" label={copyAction.label} stopPropagation text={copyAction.text} />
) : undefined
return (
@@ -322,7 +325,18 @@ function ToolEntry({ part }: ToolEntryProps) {
</div>
{isPending && <PendingToolApproval part={part} />}
{open && (
<div className="grid w-full min-w-0 max-w-full gap-1.5 overflow-hidden p-1.5">
<div className="relative grid w-full min-w-0 max-w-full gap-1.5 overflow-hidden p-1.5">
{copyAction.text && (
<CopyButton
appearance="inline"
className="absolute right-1.5 top-1.5 z-10 h-5 gap-0 rounded-md border border-(--ui-stroke-tertiary) bg-background/80 px-1 opacity-60 backdrop-blur-sm transition-opacity hover:opacity-100 focus-visible:opacity-100"
iconClassName="size-3"
label={copyAction.label}
showLabel={false}
stopPropagation
text={copyAction.text}
/>
)}
{!embedded && view.previewTarget && isPreviewableTarget(view.previewTarget) && (
<PreviewAttachment source="tool-result" target={view.previewTarget} />
)}

View File

@@ -127,7 +127,9 @@ const InlineSegmentView: FC<{ text: string }> = ({ text }) => {
const nodes = useMemo(() => splitInlineCode(text), [text])
return (
<span className="wrap-anywhere block whitespace-pre-line">
// styles.css bidi hook (#44150); whitespace-pre-line makes each line its own
// UAX#9 paragraph so it resolves direction independently.
<span className="wrap-anywhere block whitespace-pre-line" data-slot="aui_user-inline-text">
{nodes.map((node, nodeIndex) =>
node.kind === 'inline-code' ? (
<code

View File

@@ -26,7 +26,8 @@ function setProviders(providers: OAuthProvider[]) {
reason: null,
requested: false,
firstRunSkipped: false,
manual: false
manual: false,
localEndpoint: false
} satisfies DesktopOnboardingState)
}
@@ -49,7 +50,8 @@ afterEach(() => {
reason: null,
requested: false,
firstRunSkipped: false,
manual: false
manual: false,
localEndpoint: false
})
})

View File

@@ -430,19 +430,24 @@ const persistShowAll = (value: boolean) => {
export function Picker({ ctx }: { ctx: OnboardingContext }) {
const { t } = useI18n()
const { manual, mode, providers } = useStore($desktopOnboarding)
const { localEndpoint, manual, mode, providers } = useStore($desktopOnboarding)
const [showAll, setShowAll] = useState(readShowAll)
const ordered = useMemo(() => (providers ? sortProviders(providers) : []), [providers])
const hasOauth = ordered.length > 0
const apiKeyOptions = useApiKeyCatalog()
if (mode === 'apikey' || !hasOauth) {
// localEndpoint forces the key form regardless of `mode` (which a manual
// provider refresh may flip back to 'oauth'); it preselects the local option
// and hides the "back to sign in" link since the user came specifically to
// configure a custom endpoint.
if (localEndpoint || mode === 'apikey' || !hasOauth) {
return (
<div className="grid gap-3">
<ApiKeyForm
canGoBack={hasOauth}
canGoBack={hasOauth && !localEndpoint}
initialEnvKey={localEndpoint ? 'OPENAI_BASE_URL' : undefined}
onBack={() => setOnboardingMode('oauth')}
onSave={(envKey, value, name) => saveOnboardingApiKey(envKey, value, name, ctx)}
onSave={(envKey, value, name, apiKey) => saveOnboardingApiKey(envKey, value, name, ctx, apiKey)}
options={apiKeyOptions}
/>
{manual ? null : (
@@ -630,6 +635,7 @@ export function ProviderRow({
// surfaces render the identical form.
export function ApiKeyForm({
canGoBack,
initialEnvKey,
isSet,
onBack,
onClear,
@@ -638,16 +644,31 @@ export function ApiKeyForm({
redactedValue
}: {
canGoBack: boolean
/** Preselect a specific option by env key (e.g. 'OPENAI_BASE_URL' to land on
* the local / custom endpoint form). Falls back to the first option. */
initialEnvKey?: string
isSet?: (envKey: string) => boolean
onBack: () => void
onClear?: (envKey: string) => void
onSave: (envKey: string, value: string, name: string) => Promise<{ message?: string; ok: boolean }>
onSave: (
envKey: string,
value: string,
name: string,
apiKey?: string
) => Promise<{ message?: string; ok: boolean }>
options?: ApiKeyOption[]
redactedValue?: (envKey: string) => null | string | undefined
}) {
const { t } = useI18n()
const [option, setOption] = useState<ApiKeyOption>(options[0])
const [option, setOption] = useState<ApiKeyOption>(
() => options.find(o => o.envKey === initialEnvKey) ?? options[0]
)
const [value, setValue] = useState('')
// Optional endpoint API key, only used by the local / custom endpoint option
// (whose `value` is the base URL). Cleared whenever the option changes.
const [localKey, setLocalKey] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<null | string>(null)
// `options` can change at runtime when callers filter the catalog (e.g. the
@@ -657,6 +678,7 @@ export function ApiKeyForm({
if (options.length > 0 && !options.some(o => o.envKey === option.envKey)) {
setOption(options[0])
setValue('')
setLocalKey('')
setError(null)
}
}, [option.envKey, options])
@@ -668,6 +690,7 @@ export function ApiKeyForm({
const pick = (o: ApiKeyOption) => {
setOption(o)
setValue('')
setLocalKey('')
setError(null)
requestAnimationFrame(() => {
entryRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
@@ -693,10 +716,11 @@ export function ApiKeyForm({
setSaving(true)
setError(null)
const result = await onSave(option.envKey, value, option.name)
const result = await onSave(option.envKey, value, option.name, isLocal ? localKey : undefined)
if (result.ok) {
setValue('')
setLocalKey('')
} else {
setError(result.message ?? t.onboarding.couldNotSave)
}
@@ -759,6 +783,17 @@ export function ApiKeyForm({
type={isLocal ? 'text' : 'password'}
value={value}
/>
{isLocal ? (
<Input
autoComplete="off"
className="font-mono"
onChange={e => setLocalKey(e.target.value)}
onKeyDown={e => e.key === 'Enter' && void submit()}
placeholder={t.onboarding.localApiKeyPlaceholder}
type="password"
value={localKey}
/>
) : null}
{error ? <p className="text-xs text-destructive">{error}</p> : null}
</div>

View File

@@ -41,7 +41,8 @@ function resetStores() {
reason: null,
requested: false,
firstRunSkipped: false,
manual: false
manual: false,
localEndpoint: false
})
}

View File

@@ -3,7 +3,7 @@ import { Dialog as DialogPrimitive } from 'radix-ui'
import { useEffect, useMemo, useState } from 'react'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { listSessions } from '@/hermes'
import { listAllProfileSessions } from '@/hermes'
import { useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import { Check, MessageCircle } from '@/lib/icons'
@@ -35,7 +35,7 @@ export function SessionPickerDialog({
const sessionsQuery = useQuery({
enabled: open,
queryFn: () => listSessions(200, 1, 'exclude'),
queryFn: () => listAllProfileSessions(200, 1, 'exclude'),
queryKey: ['session-picker', 'sessions']
})

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { listAllProfileSessions, listSessions } from './hermes'
import { getSessionMessages, listAllProfileSessions, listSessions } from './hermes'
const emptySessionsResponse = {
limit: 0,
@@ -46,4 +46,15 @@ describe('Hermes REST session helpers', () => {
})
)
})
it('tags cross-profile message reads for Electron routing and backend lookup', async () => {
api.mockResolvedValue({ messages: [], session_id: 'session-1' })
await getSessionMessages('session-1', 'xiaoxuxu')
expect(api).toHaveBeenCalledWith({
path: '/api/sessions/session-1/messages?profile=xiaoxuxu',
profile: 'xiaoxuxu'
})
})
})

View File

@@ -54,10 +54,10 @@ export type {
AnalyticsSkillEntry,
AnalyticsSkillsSummary,
AnalyticsTotals,
BackendUpdateCheckResponse,
AudioSpeakResponse,
AudioTranscriptionResponse,
AuxiliaryModelsResponse,
BackendUpdateCheckResponse,
ConfigFieldSchema,
ConfigSchemaResponse,
CronJob,
@@ -218,6 +218,7 @@ export function getSessionMessages(id: string, profile?: string | null): Promise
const suffix = profile ? `?profile=${encodeURIComponent(profile)}` : ''
return window.hermesDesktop.api<SessionMessagesResponse>({
...(profile ? { profile } : {}),
path: `/api/sessions/${encodeURIComponent(id)}/messages${suffix}`
})
}
@@ -343,13 +344,14 @@ export function setEnvVar(key: string, value: string): Promise<{ ok: boolean }>
export function validateProviderCredential(
key: string,
value: string
value: string,
apiKey?: string
): Promise<{ ok: boolean; reachable: boolean; message: string; models?: string[] }> {
return window.hermesDesktop.api<{ ok: boolean; reachable: boolean; message: string; models?: string[] }>({
...profileScoped(),
path: '/api/providers/validate',
method: 'POST',
body: { key, value }
body: { key, value, api_key: apiKey ?? '' }
})
}

View File

@@ -1372,6 +1372,7 @@ export const en: Translations = {
getKey: 'Get a key',
replaceCurrent: 'Replace current value',
pasteApiKey: 'Paste API key',
localApiKeyPlaceholder: 'API key (optional — only if your endpoint requires one)',
couldNotSave: 'Could not save credential.',
connecting: 'Connecting',
update: 'Update',

View File

@@ -1041,6 +1041,7 @@ export interface Translations {
getKey: string
replaceCurrent: string
pasteApiKey: string
localApiKeyPlaceholder: string
couldNotSave: string
connecting: string
update: string

View File

@@ -1554,6 +1554,7 @@ export const zh: Translations = {
getKey: '获取密钥',
replaceCurrent: '替换当前值',
pasteApiKey: '粘贴 API 密钥',
localApiKeyPlaceholder: 'API 密钥(可选 — 仅当端点需要时填写)',
couldNotSave: '无法保存凭据。',
connecting: '连接中',
update: '更新',

View File

@@ -58,6 +58,8 @@ export type GatewayEventPayload = {
// approval.request (dangerous command / execute_code) — session-keyed
command?: string
description?: string
// False when a tirith content-security warning forbids a permanent allow.
allow_permanent?: boolean
// secret.request (skill credential capture)
env_var?: string
prompt?: string

View File

@@ -5,6 +5,7 @@ import { notify, notifyError } from '@/store/notifications'
interface ExportSessionParams {
sessionId: string
profile?: string | null
title?: string | null
session?: SessionInfo
}
@@ -31,7 +32,8 @@ export async function exportSession(sessionId: string, params: Omit<ExportSessio
}
try {
const { messages } = await getSessionMessages(sessionId)
const profile = params.profile ?? params.session?.profile
const { messages } = await getSessionMessages(sessionId, profile)
const payload = {
exported_at: new Date().toISOString(),

View File

@@ -33,6 +33,7 @@ function baseState(overrides: Partial<DesktopOnboardingState> = {}): DesktopOnbo
requested: false,
firstRunSkipped: false,
manual: false,
localEndpoint: false,
...overrides
}
}
@@ -233,10 +234,12 @@ describe('OAuth onboarding', () => {
const state = $desktopOnboarding.get()
expect(state.reason).toBeNull()
expect(state.flow.status).toBe('confirming_model')
if (state.flow.status === 'confirming_model') {
expect(state.flow.label).toBe('Nous Portal')
expect(state.flow.currentModel).toBe(model)
}
expect(calls.some(c => c.path === '/api/model/set')).toBe(true)
})
})
@@ -283,7 +286,7 @@ describe('saveOnboardingLocalEndpoint', () => {
throw new Error(`unexpected api path: ${path}`)
})
const result = await saveOnboardingLocalEndpoint('http://127.0.0.1:8000/v1', {
const result = await saveOnboardingLocalEndpoint('http://127.0.0.1:8000/v1', '', {
requestGateway: readyGateway()
})
@@ -313,7 +316,7 @@ describe('saveOnboardingLocalEndpoint', () => {
installApiMock(api)
const onCompleted = vi.fn()
const result = await saveOnboardingLocalEndpoint('http://127.0.0.1:8000/v1', {
const result = await saveOnboardingLocalEndpoint('http://127.0.0.1:8000/v1', '', {
onCompleted,
requestGateway: readyGateway()
})
@@ -332,6 +335,46 @@ describe('saveOnboardingLocalEndpoint', () => {
expect($desktopOnboarding.get().configured).toBe(true)
})
it('forwards the API key to the probe and persists it for auth-gated endpoints', async () => {
const calls: { body?: unknown; path: string }[] = []
const api = vi.fn(async ({ body, path }: { body?: unknown; path: string }) => {
calls.push({ body, path })
if (path === '/api/providers/validate') {
return { ok: true, reachable: true, message: '', models: ['gpt-oss-120b'] }
}
if (path === '/api/model/set') {
return { ok: true, provider: 'custom', model: 'gpt-oss-120b', base_url: 'https://text.example.com/v1' }
}
throw new Error(`unexpected api path: ${path}`)
})
installApiMock(api)
const result = await saveOnboardingLocalEndpoint('https://text.example.com/v1', 'sk-secret', {
requestGateway: readyGateway()
})
expect(result.ok).toBe(true)
// The probe must receive the key so an auth-gated /v1/models enumerates.
const probe = calls.find(c => c.path === '/api/providers/validate')
expect(probe?.body).toMatchObject({ key: 'OPENAI_BASE_URL', value: 'https://text.example.com/v1', api_key: 'sk-secret' })
// And the key must be persisted alongside the endpoint for runtime auth.
const assign = calls.find(c => c.path === '/api/model/set')
expect(assign?.body).toMatchObject({
scope: 'main',
provider: 'custom',
model: 'gpt-oss-120b',
base_url: 'https://text.example.com/v1',
api_key: 'sk-secret'
})
})
it('reports the runtime reason when resolution still fails after saving', async () => {
installApiMock(async ({ path }: { path: string }) => {
if (path === '/api/providers/validate') {
@@ -361,7 +404,7 @@ describe('saveOnboardingLocalEndpoint', () => {
throw new Error(`unexpected gateway method: ${method}`)
}
const result = await saveOnboardingLocalEndpoint('http://127.0.0.1:8000/v1', {
const result = await saveOnboardingLocalEndpoint('http://127.0.0.1:8000/v1', '', {
requestGateway: failingGateway
})

View File

@@ -72,6 +72,11 @@ export interface DesktopOnboardingState {
* picker's "Add provider" button). Forces the overlay to show the picker
* even when configured === true, and adds a close affordance. */
manual: boolean
/** True when the overlay was opened specifically to configure a local /
* custom OpenAI-compatible endpoint (e.g. from Settings → Model's "Set up
* custom endpoint"). Forces the API-key form with the local option
* preselected instead of the OAuth picker. */
localEndpoint: boolean
}
export interface OnboardingContext {
@@ -150,7 +155,8 @@ const INITIAL: DesktopOnboardingState = {
reason: null,
requested: false,
firstRunSkipped: readCachedSkipped(),
manual: false
manual: false,
localEndpoint: false
}
export const $desktopOnboarding = atom<DesktopOnboardingState>(INITIAL)
@@ -392,6 +398,7 @@ export function startManualOnboarding(reason: null | string = DEFAULT_MANUAL_ONB
patch({
manual: true,
requested: true,
localEndpoint: false,
// `null` opts out of the prompt banner entirely (e.g. when the user already
// picked a specific provider and we auto-start its sign-in).
reason: reason ? reason.trim() || DEFAULT_ONBOARDING_REASON : null,
@@ -400,6 +407,24 @@ export function startManualOnboarding(reason: null | string = DEFAULT_MANUAL_ONB
void refreshProviders()
}
// Open the onboarding overlay directly on the local / custom endpoint form
// (URL + optional API key), bypassing the OAuth picker. Used by Settings →
// Model's "Set up custom endpoint" so it lands on a form that can actually
// configure the endpoint instead of dead-ending on the OAuth provider list
// (`custom` is not an OAuth provider, so the generic manual flow would just
// re-show the picker — the original "booted back to the first screen" loop).
export function startManualLocalEndpoint(reason: null | string = null) {
pendingProviderOAuthId = null
patch({
manual: true,
requested: true,
localEndpoint: true,
mode: 'apikey',
reason: reason ? reason.trim() || DEFAULT_ONBOARDING_REASON : null,
flow: { status: 'idle' }
})
}
// One-shot hand-off used when the dedicated Providers settings page launches a
// specific provider's sign-in: we open the manual onboarding overlay AND
// remember which provider to start, so the overlay drives that exact OAuth
@@ -431,7 +456,7 @@ export function clearPendingProviderOAuth() {
export function closeManualOnboarding() {
pendingProviderOAuthId = null
patch({ manual: false, requested: false, flow: { status: 'idle' } })
patch({ manual: false, requested: false, localEndpoint: false, flow: { status: 'idle' } })
}
export function completeDesktopOnboarding() {
@@ -448,7 +473,8 @@ export function completeDesktopOnboarding() {
reason: null,
requested: false,
firstRunSkipped: false,
manual: false
manual: false,
localEndpoint: false
})
}
@@ -461,7 +487,7 @@ export function completeDesktopOnboarding() {
export function dismissFirstRunOnboarding() {
clearPoll()
writeCachedSkipped(true)
patch({ firstRunSkipped: true, requested: false, manual: false, flow: { status: 'idle' } })
patch({ firstRunSkipped: true, requested: false, manual: false, localEndpoint: false, flow: { status: 'idle' } })
}
export function setOnboardingMode(mode: OnboardingMode) {
@@ -701,18 +727,28 @@ export async function recheckExternalSignin(ctx: OnboardingContext) {
)
}
export async function saveOnboardingApiKey(envKey: string, value: string, label: string, ctx: OnboardingContext) {
export async function saveOnboardingApiKey(
envKey: string,
value: string,
label: string,
ctx: OnboardingContext,
// Optional endpoint key — only meaningful for the "Local / custom endpoint"
// option, whose primary `value` is the base URL. Ignored for plain API-key
// providers (their key IS `value`).
endpointApiKey?: string
) {
const trimmed = value.trim()
if (!trimmed) {
return { ok: false, message: 'Enter a value first.' }
}
// The "Local / custom endpoint" option carries a base URL, not an API key.
// It must be wired into config (provider=custom + base_url + model), not
// dropped into .env — runtime resolution ignores OPENAI_BASE_URL.
// The "Local / custom endpoint" option carries a base URL (in `value`) plus
// an optional API key. It must be wired into config (provider=custom +
// base_url + model + api_key), not dropped into .env — runtime resolution
// ignores OPENAI_BASE_URL.
if (envKey === 'OPENAI_BASE_URL') {
return saveOnboardingLocalEndpoint(trimmed, ctx)
return saveOnboardingLocalEndpoint(trimmed, endpointApiKey?.trim() ?? '', ctx)
}
// No key validation here on purpose: we previously live-probed the key and
@@ -748,14 +784,17 @@ export async function saveOnboardingApiKey(envKey: string, value: string, label:
// env var that resolution never consults.
//
// The model is auto-discovered from the endpoint's /v1/models (surfaced by the
// validate probe) so the user only has to paste a URL — no extra UI field.
// validate probe). The optional API key is forwarded to the probe (so hosted
// endpoints that gate /v1/models behind auth still enumerate models) and
// persisted to model.api_key so the runtime can authenticate.
//
// We deliberately don't route through completeWithModelConfirm: that path
// re-assigns the model from /api/model/options WITHOUT a base_url, which would
// wipe the base_url we just wrote. We have a concrete model already, so we
// verify the runtime directly and finish.
export async function saveOnboardingLocalEndpoint(baseUrl: string, ctx: OnboardingContext) {
export async function saveOnboardingLocalEndpoint(baseUrl: string, apiKey: string, ctx: OnboardingContext) {
const url = baseUrl.trim()
const key = apiKey.trim()
if (!url) {
return { ok: false, message: 'Enter the endpoint URL first.' }
@@ -767,7 +806,7 @@ export async function saveOnboardingLocalEndpoint(baseUrl: string, ctx: Onboardi
let model = ''
try {
const probe = await validateProviderCredential('OPENAI_BASE_URL', url)
const probe = await validateProviderCredential('OPENAI_BASE_URL', url, key)
if (!probe.ok && probe.reachable) {
return { ok: false, message: probe.message || 'Could not reach that endpoint.' }
@@ -790,7 +829,7 @@ export async function saveOnboardingLocalEndpoint(baseUrl: string, ctx: Onboardi
}
try {
await setModelAssignment({ scope: 'main', provider: 'custom', model, base_url: url })
await setModelAssignment({ scope: 'main', provider: 'custom', model, base_url: url, api_key: key })
await ctx.requestGateway('reload.env').catch(() => undefined)
const runtime = await checkRuntime(ctx)

View File

@@ -53,6 +53,12 @@ describe('approval prompt store', () => {
expect($approvalRequest.get()).toBeNull()
})
it('carries allowPermanent so the bar can hide "Always allow"', () => {
setApprovalRequest({ allowPermanent: false, command: 'curl x | bash', description: 'content-security', sessionId: 's1' })
expect($approvalRequest.get()?.allowPermanent).toBe(false)
})
})
describe('sudo prompt store', () => {

View File

@@ -68,6 +68,8 @@ function keyedPromptStore<T extends KeyedPrompt>(): PromptStore<T> {
// resolved via approval.respond {choice, session_id}). It carries no request_id,
// unlike sudo/secret which are _block()-style request/response.
export interface ApprovalRequest extends KeyedPrompt {
// false when the backend won't honor a permanent allow (tirith warning) → hide "Always allow".
allowPermanent?: boolean
command: string
description: string
}

View File

@@ -17,6 +17,30 @@
src: url('../../../node_modules/@nous-research/ui/dist/fonts/Collapse-Bold.woff2') format('woff2');
}
/* JetBrains Mono — bundled terminal font (Apache-2.0) so bold/italic share the
regular face's metrics instead of squeezing against a system fallback. */
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('./fonts/JetBrainsMono-Regular.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('./fonts/JetBrainsMono-Bold.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: italic;
font-weight: 400;
font-display: swap;
src: url('./fonts/JetBrainsMono-Italic.woff2') format('woff2');
}
@theme inline {
--color-background: var(--dt-background);
--color-foreground: var(--dt-foreground);
@@ -823,6 +847,37 @@ canvas {
content's --message-text-indent). No extra prose indent — a single gutter
reads cleaner than a ragged tool-vs-reply column. */
/* RTL/bidi chat text (#44150): each block resolves its own base direction from
its first strong char (UAX#9 plaintext). text-align:start makes that resolved
direction drive alignment too — load-bearing, since the user bubble pins
text-left. direction is never set, so chrome/layout/list-indent stay LTR (the
issue asks not to flip the whole UI). Covers assistant prose, user lines, and
both composers (main + edit share composer-rich-input). */
[data-slot='aui_assistant-message-content'] .aui-md :where(p, h1, h2, h3, h4, h5, h6, li, blockquote),
[data-slot='aui_user-inline-text'],
[data-slot='composer-rich-input'] {
unicode-bidi: plaintext;
text-align: start;
}
/* Inline code/KaTeX don't vote on direction and keep their own order: isolate
makes bidi treat each as one neutral, so a block that *starts* with `./run.sh`
then Arabic still resolves RTL, and the command's neutrals (dots/slashes)
aren't reordered by the surrounding RTL run. */
[data-slot='aui_assistant-message-content'] .aui-md :where(:not(pre) > code),
[data-slot='aui_user-inline-code'],
[data-slot='aui_assistant-message-content'] .aui-md .katex {
direction: ltr;
unicode-bidi: isolate;
}
/* Fenced code stays LTR even inside an RTL list item/blockquote — never mirrors. */
[data-slot='aui_assistant-message-content'] .aui-md [data-slot='code-card'],
[data-slot='aui_user-fence'] {
direction: ltr;
text-align: left;
}
[data-slot='aui_user-message-root'] {
top: var(--sticky-human-top);
}

View File

@@ -638,6 +638,10 @@ export interface AuxiliaryModelsResponse {
}
export interface ModelAssignmentRequest {
/** Optional API key for a custom/local endpoint. Persisted to model.api_key
* (where the runtime reads it) for self-hosted endpoints that require auth.
* Only honored for custom/local providers on the main slot. */
api_key?: string
/** OpenAI-compatible endpoint URL. Only honored for custom/local providers
* on the main slot — wires a self-hosted endpoint into runtime resolution. */
base_url?: string

3
bench/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
.cache/
*.cpuprofile

148
bench/README.md Normal file
View File

@@ -0,0 +1,148 @@
# TUI benchmark suite — Ink (`ui-tui`) vs OpenTUI (`ui-opentui`)
Methodology (settled, binding): `docs/plans/opentui-bench-suite.md`. This
directory is the implementation: real binaries over a real node-pty PTY
(120×40, xterm-256color), a fake gateway substituted via `HERMES_PYTHON`
(ZERO changes to either UI), external `/proc` sampling, cgroup-v2 memory caps.
No tmux anywhere in measurement — except the `pipeline` cell, whose entire
point is measuring the tmux emulator leg (see its note below).
## Pieces
| file | role |
|---|---|
| `fake-gateway.mjs` | NDJSON JSON-RPC gateway stand-in. Both UIs spawn it as `$HERMES_PYTHON -m tui_gateway.entry`. Answers every startup RPC with canned results, then streams the fixture (burst / paced / load-then-idle). Never writes stderr (the UIs render gateway stderr). |
| `fixture-stream.mjs` | Serializes the deterministic lumpy-turn fixture (`ui-opentui/scripts/fixture.ts`, imported directly via Node ≥26 type stripping — no port) to NDJSON. Cached under `.cache/`, sha256-stamped. |
| `harness.mjs` | One scenario = one UI boot: node-pty PTY, tight drain loop (event-loop starvation probe, 10ms budget asserted), `/proc/PID/{smaps_rollup,status,stat}` samples on 100-msg boundaries (UI PID only), `systemd-run --user --scope -p MemoryMax=… -p MemorySwapMax=0` caps, SGR wheel injection, resize-jiggle digest capture. |
| `run.mjs` | The matrix runner (protocol: determinism gate first, sequential SUTs, randomized per-rep config order, 10s cooldowns, load gate). |
| `render.mjs` | `results/*.json` → self-contained `report.html` (inline SVG, no CDN) + PNGs in `report-assets/`. |
## Running cells
Node 26 is required (`BENCH_NODE_BIN` overrides the default fnm path). Build
both UIs first; results land in `results/<utc>-<sha7>-<cell>-<ui>-<config>-r<rep>.json`.
```sh
cd ui-opentui && node scripts/build.mjs && cd ../ui-tui && node scripts/build.mjs && cd ../bench
npm install # node-pty (bench-local devDep)
node run.mjs --cell gate # determinism gate (digest replay ×2 per UI) — run FIRST
node run.mjs --cell mem3000 # clean memory runs, 3 reps × 3 configs, 2GB cap
node run.mjs --cell slope10k # one 10k-msg slope run: ink + otui-uncapped (cap-hit IS a datapoint)
node run.mjs --cell nodes # instrumented node counts (ink fd-3 sampler; opentui headless walk)
node run.mjs --cell cpu # paced 30 ev/s streaming ×3
node run.mjs --cell scroll # SGR wheel 30Hz×15s on a 3000-msg transcript ×3
node run.mjs --cell startup # ×10, fake gateway
node run.mjs --cell chaos # stability: gw SIGKILL mid-stream/mid-tool, SIGSTOP 30s, resize storm, PTY EOF — 5 scenarios × {ink, otui-capped}
node run.mjs --cell pipeline # total-pipeline CPU: UI inside a DEDICATED tmux server (the user's real emulator leg), /proc utime+stime for UI + gateway + tmux @1Hz
node run.mjs --cell echo # M7 input latency: 30 keystrokes → first echoed paint (p50/p95/p99) + one \r submit → first-token-paint
node render.mjs # report.html + report-assets/*.png
```
### Chaos / pipeline / echo cell notes
- **chaos** (5 scenarios × ink/otui-capped, one JSON each, `summary.chaos`):
gateway death is SELF-inflicted (`HERMES_FAKE_DIE_AT=<msg>:<kill|tool-kill>`
→ SIGKILL at fixture msg N, or at the first `tool.*` event after N) because
self-termination is deterministic vs racy external timing; a die-once flag
file keeps the auto-heal respawn from dying again. SIGSTOP (gw-stop) is
external via `HERMES_FAKE_PIDFILE`. Respawn detection = the respawned
gateway REWRITING that pidfile. Both UIs auto-heal (budget 3 respawns/60s):
OpenTUI with exponential backoff (`ui-opentui/src/boundary/gateway/liveGateway.ts`
`onExit`), Ink immediately (`ui-tui/src/app/useMainApp.ts` `exitHandler`).
`transcript_preserved` = after a forced full repaint (resize jiggle), the
screen still shows a recent pre-kill turn (`const xN` fixture markers).
`summary.result` keeps its usual semantics — for pty-eof the UI *should*
die, so read `summary.chaos`, not `summary.result`, for the verdicts.
- **pipeline**: the ONLY cell that uses tmux — deliberately. The user's real
stack runs the TUI inside tmux (verified via /proc environ), so a dedicated
`tmux -L hermes-bench-<runId> -f /dev/null` server is the locally measurable
terminal-emulator leg. The harness PTY attaches a client (unattached tmux
skips most output work; `data_flowing` asserts bytes actually arrived) and
samples /proc utime+stime at 1Hz for UI, fake gateway, and the tmux server
(`summary.pipeline.cpu_s`). Only that socket's server is killed at the end.
Note tmux re-encodes the UI's output for the outer client, so `pty_bytes_total`
here is the post-tmux byte count, not the UI's raw output.
- **frame pacing (M6)**: cpu-paced and pipeline record every PTY chunk
timestamp+size; bursts separated by >4ms gaps are frames →
`summary.frame_pacing` (fps, interframe p50/p95, bytes/frame p50/p95,
coalesced count). Scroll runs record the wheel phase only. There is no
env-gated renderer frame counter in ui-opentui to use as ground truth —
@opentui/core keeps `renderStats.fps` internally but nothing exports it;
wiring it would need a ui-source patch (out of scope here).
- **echo (M7)**: keystroke chars avoid `u`/`p`/`s`/digits (the OpenTUI status
clock repaints `up: Ns` at 1Hz) and matching runs on ANSI-stripped output
(raw chunks are full of CSI final letters). The submit leg works because the
fake gateway answers `prompt.submit` with a tiny streamed reply carrying the
marker token `zqxjv` when `HERMES_FAKE_SUBMIT_RESPONSE=1`.
Configs: `ink` · `otui-capped` (`HERMES_TUI_MAX_MESSAGES=3000`, the default) ·
`otui-uncapped` (`=100000`). Launch parity with `hermes_cli/main.py`:
Ink = `node --expose-gc ui-tui/dist/entry.js`, OpenTUI =
`node --experimental-ffi --no-warnings ui-opentui/dist/main.js`, both with
`NODE_OPTIONS=--max-old-space-size=<heap>` (8192 on the unconstrained host —
what the launcher picks outside a container).
## E3 (constrained Docker survival)
`E3-lite` runs the same harness inside a generic `node:26` container (NOT the
shipped image) with the worktree bind-mounted read-only and `--memory=1g
--memory-swap=1g`; the whole container (UI + fake gateway + harness) shares the
limit. See `run-e3.sh` if present, or the report's survival table for the exact
invocation used.
## What actually ran on 2026-06-11 (E1 host + E3-lite) — deviations from the plan
- **3 reps** for mem3000 (not 5) and **scroll at 2000 msgs** (not 3000): the
OpenTUI engine on this tree (sha 197d499, dist built from 50e3471 tree state)
**crashes at ≈3000 fixture msgs** — an uncaught `Error: Failed to create
SyntaxStyle` (native handle allocation fails; every `TextBufferRenderable`
creates one in @opentui/core 0.4.0), masked by a second
`Failed to create optimized buffer` crash inside the renderer's
uncaughtException handler. Postmortems are in each result's `pty_tail`;
RSS at crash ≈880MB — far below the 2GB cap, so it is a handle/pool limit,
not memory. This dominates every OpenTUI cell past ~3000 msgs.
- **OpenTUI headless node-count: not run.** `scripts/mem-bench.tsx` under Node
FFI dies on the first fixture turn with `ERR_INVALID_ARG_VALUE …
textBufferViewSetViewport` (the known Bun→Node u32-coordinate class; the
production binary carries the ffiSafe clamp, the headless test renderer path
does not) and then hangs. The Ink fd-3 sampler ran fine.
- **Startup real-gateway variant: probed, not run as a cell.** A full run would
forge real sessions in the user's `~/.hermes` store. Measured standalone:
the real `tui_gateway` (venv python) emits `gateway.ready` in **131ms median**
(×10, range 130138ms) — add that to the fake-gateway startup numbers.
- **No cgroup OOM kills observed** anywhere (Ink at 10k msgs peaks ~321MB;
OpenTUI crashes before reaching the cap), so the cap-hit machinery
(memory.events / journal fallback) never fired in anger; E3-lite classified
the OpenTUI death correctly as a crash (`oom_kill=0`, exit 7).
- E2 (shipped Docker image): not run — image build time prohibitive in this
session; E3-lite (generic node:26) covers the constrained-memory question.
- Drain-loop starvation: a handful of OpenTUI burst runs recorded 1118ms max
event-loop lag in the harness (>10ms budget, flagged `drain_ok:false` in
those results); all paced/scroll/startup runs stayed under 10ms.
## Accounting + known deviations (by design)
- **"messages" = fixture rows** (`rowsPerTurn` accounting, identical to
`ui-opentui/scripts/mem-bench.tsx`), so numbers are comparable with the
pre-registered expectations. ~46% of fixture rows are user/system rows.
- **User/system rows are not streamed**: they are composer-local in both UIs
(no wire event exists), so PTY runs mount only the assistant/tool rows —
the renderable-heavy part that carries the memory claim. Consequence: the
OpenTUI store cap (3000 rows) binds at ≈6.6k fixture-msgs in PTY runs.
- **Digest gate**: final-screen digest after a resize-forced repaint, ANSI
stripped, cut at the composer hint, `up: Ns` normalized (the OpenTUI status
bar has a 1Hz uptime clock; the transcript region itself is deterministic).
- The headless `scripts/mem-bench.tsx` numbers are diagnostic-only and flagged
`instrumented`/`diagnostic_only` — never headlined.
## Build/run parity vs an installed hermes (audit, 2026-06-11)
- Both UIs are built by their own repo build scripts (same artifacts an install produces) and
spawned at their real entries: otui `node --experimental-ffi --no-warnings dist/main.js`
(identical to production); ink `dist/entry.js` with env mirroring `_launch_tui`
(NODE_ENV=production).
- Two deviations: (1) ink's spawn adds `--expose-gc` — audited: nothing ever calls gc(), the
flag is inert; kept for the instrumented sampler runs, harmless in clean runs. (2) both UIs
run on the pinned Node 26.3 per protocol ("never compare across Node majors") — installed ink
commonly runs Node 20/22, so ink's ABSOLUTE numbers are "ink on Node 26"; the relative
comparison is unaffected. An as-installed-Node ink re-run is a worthwhile extra cell.

239
bench/fake-gateway.mjs Executable file
View File

@@ -0,0 +1,239 @@
#!/usr/bin/env node
// Fake tui_gateway — substituted via HERMES_PYTHON so BOTH UIs spawn THIS
// executable as `$HERMES_PYTHON -m tui_gateway.entry` (argv ignored) and speak
// the identical NDJSON JSON-RPC wire over stdio. ZERO changes to either UI.
//
// Wire contract (mirrors tui_gateway/entry.py + both UI clients):
// - unsolicited {jsonrpc:"2.0",method:"event",params:{type:"gateway.ready",payload:{skin:{}}}}
// - events: {jsonrpc:"2.0",method:"event",params:{type,payload?}} (no id)
// - responses: {jsonrpc:"2.0",id,result} for every request, canned per method.
//
// NEVER writes to stderr (both UIs surface gateway stderr lines INTO the UI as
// activity rows / gateway.stderr events, which would perturb the rendered
// transcript). Progress/telemetry goes to HERMES_FAKE_PROGRESS (append-only
// NDJSON file the harness tails).
//
// Env config:
// HERMES_FAKE_FIXTURE NDJSON fixture path (from fixture-stream.mjs). Optional.
// HERMES_FAKE_MODE burst | paced | load-then-idle (default burst)
// HERMES_FAKE_RATE events/sec for paced mode (default 30)
// HERMES_FAKE_START_DELAY_MS delay after session.create reply before streaming (default 1500)
// HERMES_FAKE_SAMPLE_EVERY fixture-msg boundary cadence for progress lines (default 100)
// HERMES_FAKE_PROGRESS progress NDJSON file path (required for harness runs)
// HERMES_FAKE_PIDFILE write own pid here at startup (harness discovers the
// gateway pid; a REWRITE by a respawned instance is the
// harness's auto-heal detection signal)
// HERMES_FAKE_DIE_AT "<msgIndex>:<kill|tool-kill>" — chaos cells: self-SIGKILL
// at fixture msg N (kill), or at the first tool.* event
// after msg N (tool-kill). Self-termination is deterministic
// vs racy external timing. SIGSTOP stays external (a stopped
// process can't stop itself usefully).
// HERMES_FAKE_DIE_FLAG die-once flag file: created just before the self-kill so
// the UI's auto-heal RESPAWN (same env) does not die again
// HERMES_FAKE_SUBMIT_RESPONSE "1" → answer prompt.submit with a tiny streamed reply
// carrying the marker token "zqxjv" (echo-latency cells)
//
// Modes: burst = write as fast as the pipe accepts (await 'drain' on
// backpressure, so emission tracks UI ingestion within the ~64KB pipe buffer);
// paced = HERMES_FAKE_RATE events/sec; load-then-idle = burst, then sit idle
// (scroll-latency runs drive input afterwards). Exits on stdin EOF (the UIs
// close stdin to stop the gateway) — same lifecycle as the real child.
import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
import { createInterface } from 'node:readline'
const FIXTURE = process.env.HERMES_FAKE_FIXTURE || ''
const MODE = process.env.HERMES_FAKE_MODE || 'burst'
const RATE = Math.max(1, Number.parseInt(process.env.HERMES_FAKE_RATE ?? '30', 10) || 30)
const START_DELAY_MS = Number.parseInt(process.env.HERMES_FAKE_START_DELAY_MS ?? '1500', 10) || 1500
const SAMPLE_EVERY = Math.max(1, Number.parseInt(process.env.HERMES_FAKE_SAMPLE_EVERY ?? '100', 10) || 100)
const PROGRESS = process.env.HERMES_FAKE_PROGRESS || ''
const PIDFILE = process.env.HERMES_FAKE_PIDFILE || ''
const DIE_FLAG = process.env.HERMES_FAKE_DIE_FLAG || ''
const SUBMIT_RESPONSE = process.env.HERMES_FAKE_SUBMIT_RESPONSE === '1'
// Chaos self-termination (deterministic, no external kill races). Die-once:
// if the flag file exists a previous instance already died here — this is the
// auto-heal respawn, which must stream to completion.
let dieAtMsgs = null
let dieKind = 'kill'
{
const m = (process.env.HERMES_FAKE_DIE_AT || '').match(/^(\d+):(kill|tool-kill)$/)
if (m) {
dieAtMsgs = Number(m[1])
dieKind = m[2]
}
if (dieAtMsgs !== null && DIE_FLAG && existsSync(DIE_FLAG)) dieAtMsgs = null
}
if (PIDFILE) {
try {
writeFileSync(PIDFILE, String(process.pid))
} catch {
/* best-effort */
}
}
const t0 = Date.now()
const progress = obj => {
if (!PROGRESS) return
try {
appendFileSync(PROGRESS, JSON.stringify({ ...obj, t: Date.now() - t0, wall: Date.now() }) + '\n')
} catch {
/* progress is best-effort; never crash the wire */
}
}
// UI gone (pipe closed) → exit quietly like the real child on stdin EOF.
process.stdout.on('error', () => process.exit(0))
const writeFrame = obj => {
const ok = process.stdout.write(JSON.stringify(obj) + '\n')
return ok ? null : new Promise(r => process.stdout.once('drain', r))
}
const emitEvent = params => writeFrame({ jsonrpc: '2.0', method: 'event', params })
// ── Canned RPC results (recon'd from both UIs' startup sequences) ──────
const SESSION_ID = 'bench-session-0001'
const INFO = {
model: 'bench/fake-model',
version: '0.0.0-bench',
cwd: process.env.HERMES_CWD || process.cwd(),
skills: {},
tools: { core: ['terminal', 'read_file'] },
usage: { calls: 0, input: 0, output: 0, total: 0 }
}
function resultFor(method, params) {
switch (method) {
case 'setup.status':
return { provider_configured: true }
case 'session.create':
return { session_id: SESSION_ID, info: INFO }
case 'session.resume':
case 'session.activate':
return { session_id: SESSION_ID, messages: [], info: INFO }
case 'session.most_recent':
return {}
case 'session.list':
case 'session.active_list':
return { sessions: [] }
case 'config.get':
if (params && params.key === 'mtime') return { mtime: 1 }
if (params && params.key === 'full') return { config: { display: {} } }
return { value: '' }
case 'commands.catalog':
return { pairs: [['help', 'show help']], canon: {}, categories: [], sub: {}, skill_count: 0 }
case 'startup.catalog':
return { tools: {}, skills: {}, mcp_servers: [] }
case 'model.options':
return { providers: [] }
case 'session.title':
return { title: 'bench' }
case 'prompt.submit':
return { ok: true }
case 'session.interrupt':
return { ok: true }
case 'complete.slash':
case 'complete.path':
return { items: [] }
default:
return {}
}
}
// ── Chaos self-kill ────────────────────────────────────────────────────
// Flag first (sync — survives SIGKILL), then a 'dying' progress line (gives
// the harness the precise kill wall-clock), then SIGKILL self.
function dieNow(msgs) {
if (DIE_FLAG) {
try {
writeFileSync(DIE_FLAG, '1')
} catch {
/* best-effort */
}
}
progress({ k: 'dying', kind: dieKind, msgs })
process.kill(process.pid, 'SIGKILL')
}
// ── Fixture streaming ──────────────────────────────────────────────────
let streaming = false
async function streamFixture() {
if (streaming || !FIXTURE) return
streaming = true
const lines = readFileSync(FIXTURE, 'utf8').split('\n')
let msgs = 0
let events = 0
let nextBoundary = SAMPLE_EVERY
const paced = MODE === 'paced'
const interval = paced ? 1000 / RATE : 0
let nextAt = Date.now()
progress({ k: 'stream_start', mode: MODE })
for (const raw of lines) {
if (!raw) continue
const item = JSON.parse(raw)
if (item.k === 'e') {
if (paced) {
const wait = nextAt - Date.now()
if (wait > 0) await new Promise(r => setTimeout(r, wait))
nextAt += interval
}
const drained = emitEvent(item.v)
if (drained) await drained
events++
// tool-kill: die exactly as a tool-call event goes over the wire (the
// first tool.* event after the armed msg index — the UI is left with a
// started, never-completed tool).
if (dieAtMsgs !== null && dieKind === 'tool-kill' && msgs >= dieAtMsgs && typeof item.v?.type === 'string' && item.v.type.startsWith('tool.')) {
dieNow(msgs)
}
} else if (item.k === 't') {
msgs = item.msgs
if (msgs >= nextBoundary) {
progress({ k: 'boundary', msgs, events })
while (nextBoundary <= msgs) nextBoundary += SAMPLE_EVERY
}
if (dieAtMsgs !== null && dieKind === 'kill' && msgs >= dieAtMsgs) dieNow(msgs)
}
// {"k":"r"} row markers: composer-local rows, nothing on the wire.
}
progress({ k: 'done', msgs, events })
}
// ── Main: handshake + request loop ─────────────────────────────────────
progress({ k: 'start', pid: process.pid, mode: MODE, fixture: FIXTURE })
emitEvent({ type: 'gateway.ready', payload: { skin: {} } })
const rl = createInterface({ input: process.stdin })
rl.on('line', line => {
let msg
try {
msg = JSON.parse(line)
} catch {
return
}
if (!msg || typeof msg !== 'object' || msg.id === undefined) return
const method = String(msg.method ?? '')
progress({ k: 'req', method })
void writeFrame({ jsonrpc: '2.0', id: msg.id, result: resultFor(method, msg.params) })
if (method === 'session.create' || method === 'session.resume') {
setTimeout(() => {
streamFixture().catch(() => process.exit(1))
}, START_DELAY_MS)
}
// Echo cells: a real (tiny) reply to prompt.submit so input→first-token-paint
// is measurable. The marker token "zqxjv" never occurs in the lorem fixture.
if (method === 'prompt.submit' && SUBMIT_RESPONSE) {
setTimeout(() => {
progress({ k: 'submit_response' })
void emitEvent({ type: 'message.start' })
void emitEvent({ type: 'message.delta', payload: { text: 'Echo probe reply zqxjv — bench token-paint marker.' } })
void emitEvent({ type: 'message.complete' })
}, 30)
}
})
rl.on('close', () => {
progress({ k: 'eof' })
process.exit(0)
})

86
bench/fixture-stream.mjs Normal file
View File

@@ -0,0 +1,86 @@
#!/usr/bin/env node
// Serialize the deterministic lumpy-turn fixture (ui-opentui/scripts/fixture.ts)
// to NDJSON for the fake gateway. We check in THIS generator invocation, not the
// generated file (it is megabytes); the stream is byte-reproducible for a given
// message count because the fixture is seeded by turn index.
//
// The generator is imported DIRECTLY from ui-opentui/scripts/fixture.ts via
// Node >=26 type stripping — no port, no drift. `applyTurn(store, turn)` only
// calls store.pushUser/pushSystem/apply, so a recorder stub extracts the exact
// per-turn action stream the OpenTUI mem-bench drives.
//
// Line format (one JSON object per line):
// {"k":"e","v":{...GatewayEvent...}} → sent on the wire as
// {jsonrpc:"2.0",method:"event",params:v}
// {"k":"r","role":"user"|"system"} → row marker, NOT sent (composer-local
// rows have no wire representation —
// see README "deviation: user rows")
// {"k":"t","msgs":N} → end-of-turn marker with the CUMULATIVE
// fixture-message count (rowsPerTurn
// accounting, same as scripts/mem-bench.tsx)
//
// Usage: node fixture-stream.mjs --msgs 3000 [--out path]
// Default out: bench/.cache/fixture-<msgs>.ndjson (prints path + sha256)
import { createHash } from 'node:crypto'
import { createWriteStream, mkdirSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
const here = dirname(fileURLToPath(import.meta.url))
const fixtureTs = resolve(here, '../ui-opentui/scripts/fixture.ts')
function parseArgs(argv) {
const args = { msgs: 3000, out: null }
for (let i = 2; i < argv.length; i++) {
if (argv[i] === '--msgs') args.msgs = Number.parseInt(argv[++i], 10)
else if (argv[i] === '--out') args.out = argv[++i]
}
if (!Number.isFinite(args.msgs) || args.msgs <= 0) throw new Error('--msgs must be a positive integer')
return args
}
export async function generate(msgs, outPath) {
const { applyTurn, rowsPerTurn } = await import(pathToFileURL(fixtureTs).href)
mkdirSync(dirname(outPath), { recursive: true })
const out = createWriteStream(outPath)
const hash = createHash('sha256')
const write = line => {
const data = line + '\n'
hash.update(data)
if (!out.write(data)) return new Promise(r => out.once('drain', r))
return null
}
let pushed = 0
let events = 0
let turn = 0
while (pushed < msgs) {
const lines = []
const recorder = {
pushUser: () => lines.push('{"k":"r","role":"user"}'),
pushSystem: () => lines.push('{"k":"r","role":"system"}'),
apply: ev => {
lines.push(JSON.stringify({ k: 'e', v: ev }))
events++
}
}
applyTurn(recorder, turn)
pushed += rowsPerTurn(turn)
lines.push(JSON.stringify({ k: 't', msgs: pushed }))
for (const line of lines) {
const wait = write(line)
if (wait) await wait
}
turn++
}
await new Promise((res, rej) => out.end(err => (err ? rej(err) : res())))
return { path: outPath, msgs: pushed, events, turns: turn, sha256: hash.digest('hex') }
}
if (import.meta.main) {
const args = parseArgs(process.argv)
const outPath = args.out ?? resolve(here, `.cache/fixture-${args.msgs}.ndjson`)
const info = await generate(args.msgs, outPath)
process.stdout.write(JSON.stringify(info) + '\n')
}

371
bench/forensics.sh Executable file
View File

@@ -0,0 +1,371 @@
#!/usr/bin/env bash
# forensics.sh — assemble a chronological "what killed my gateway" timeline.
#
# Usage: bench/forensics.sh <since>
# <since> is anything `date -d` accepts: '3 days ago', '2026-06-08', 'yesterday' ...
#
# Read-only against system state: it greps logs, queries the sessions DB via a
# read-only sqlite URI, reads journalctl/dmesg, lists worktrees and running
# processes. It never kills, restarts or writes anything outside mktemp.
#
# Sources merged into one timestamp-sorted timeline (local time, ISO):
# [gateway.log] ~/.hermes/logs/gateway.log* gateway lifecycle lines
# [errors.log] ~/.hermes/logs/errors.log* ERROR/CRITICAL lines
# [exit-diag] ~/.hermes/logs/gateway-exit-diag.log (JSONL, UTC)
# [tui-crash] ~/.hermes/logs/tui_gateway_crash.log exit/signal/exception markers
# [opentui] ~/.hermes/logs/opentui-v2.log (JSONL, epoch ms)
# [shutdown-diag] ~/.hermes/logs/gateway-shutdown-diag.log SIGTERM dump headers
# [oom]/[systemd]/[sleep] journalctl --user / -k (dmesg fallback)
# [sessions] ~/.hermes/state.db sessions table (tui/cli sources)
# [worktree] git worktree lists + dir mtimes under ~/github
# [proc] currently running tui_gateway / dist/main.js / dist/entry.js
set -uo pipefail
SINCE_SPEC="${1:-}"
if [ -z "$SINCE_SPEC" ]; then
echo "usage: $0 <since> (e.g. '3 days ago', '2026-06-08')" >&2
exit 2
fi
SINCE_EPOCH="$(date -d "$SINCE_SPEC" +%s 2>/dev/null)" || {
echo "error: date -d could not parse: $SINCE_SPEC" >&2
exit 2
}
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
PY="$REPO_ROOT/.venv/bin/python"
[ -x "$PY" ] || PY="$(command -v python3)"
TMP="$(mktemp -d /tmp/forensics.XXXXXX)"
trap 'rm -rf "$TMP"' EXIT
# ---------------------------------------------------------------- journal ---
# User journal: OOM notices, hermes-gateway unit lifecycle, suspend/resume.
journalctl --user --since "@$SINCE_EPOCH" -o short-iso-precise --no-pager 2>"$TMP/jr-user.err" \
| grep -iE 'oom|out of memory|killed process|hermes-gateway[^ ]*\.service|suspend|hibernat|Scheduled restart' \
> "$TMP/journal-user.txt" || true
# Kernel journal: the authoritative OOM-kill records.
if ! journalctl -k --since "@$SINCE_EPOCH" -o short-iso-precise --no-pager 2>"$TMP/jr-kern.err" \
| grep -iE 'out of memory|oom-kill|oom_reaper|invoked oom-killer' \
> "$TMP/journal-kernel.txt"; then
: > "$TMP/journal-kernel.txt"
fi
if [ -s "$TMP/jr-kern.err" ] && [ ! -s "$TMP/journal-kernel.txt" ]; then
echo "note: journalctl -k unavailable ($(head -1 "$TMP/jr-kern.err")); trying dmesg" >&2
dmesg -T 2>/dev/null | grep -iE 'out of memory|oom-kill|invoked oom-killer' > "$TMP/dmesg.txt" || true
fi
[ -e "$TMP/dmesg.txt" ] || : > "$TMP/dmesg.txt"
# ------------------------------------------------------------- processes ---
ps -eo pid,lstart,rss,args --sort=lstart 2>/dev/null \
| grep -E 'tui_gateway|dist/main\.js|dist/entry\.js' \
| grep -vE 'grep|forensics' > "$TMP/ps.txt" || true
# -------------------------------------------------------------- worktrees ---
{
for d in "$HOME"/github/*/; do
[ -e "$d/.git" ] || continue
echo "## repo $d"
timeout 10 git -C "$d" worktree list 2>/dev/null || echo "(git worktree list failed)"
done
} > "$TMP/worktrees.txt" 2>/dev/null || true
# ---------------------------------------------------------------- python ---
export FORENSICS_SINCE="$SINCE_EPOCH" FORENSICS_TMP="$TMP" FORENSICS_SINCE_SPEC="$SINCE_SPEC"
exec "$PY" - <<'PYEOF'
import json, os, re, sqlite3, sys, time
from datetime import datetime, timezone
SINCE = float(os.environ["FORENSICS_SINCE"])
TMP = os.environ["FORENSICS_TMP"]
NOW = time.time()
HOME = os.path.expanduser("~")
LOGS = os.path.join(HOME, ".hermes", "logs")
events = [] # (epoch, tag, msg)
def add(ep, tag, msg):
if ep is None or ep < SINCE or ep > NOW + 120:
return
msg = " ".join(str(msg).split())
if msg:
events.append((ep, tag, msg[:500]))
def local_naive(s, fmt):
"""Parse a naive local-time string -> epoch."""
try:
return datetime.strptime(s, fmt).timestamp()
except ValueError:
return None
def iso_any(s):
"""Parse an ISO timestamp (Z / +00:00 / +0530 offsets) -> epoch."""
s = s.strip().replace("Z", "+00:00")
# journald short-iso uses +0530 (no colon); fromisoformat on 3.11+ copes.
try:
return datetime.fromisoformat(s).timestamp()
except ValueError:
m = re.match(r"(.*)([+-]\d{2})(\d{2})$", s)
if m:
try:
return datetime.fromisoformat(f"{m.group(1)}{m.group(2)}:{m.group(3)}").timestamp()
except ValueError:
return None
return None
def read_lines(path):
try:
with open(path, errors="replace") as f:
return f.readlines()
except OSError:
return []
PYLOG = re.compile(r"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}),\d+\s+(\w+)\s+(.*)$")
# --- gateway.log* : lifecycle lines -----------------------------------------
LIFECYCLE = re.compile(
r"Starting Hermes Gateway|Gateway running|Press Ctrl\+C|Shutting down|shutdown"
r"|stopp(ed|ing)|Recovered \d+ background|reaped|restart|Cron ticker started",
re.I,
)
for path in sorted(p for p in os.listdir(LOGS) if p.startswith("gateway.log")):
for line in read_lines(os.path.join(LOGS, path)):
m = PYLOG.match(line)
if m and LIFECYCLE.search(m.group(3)):
add(local_naive(m.group(1), "%Y-%m-%d %H:%M:%S"), "gateway.log", m.group(3))
# --- errors.log* : ERROR/CRITICAL header lines ------------------------------
for path in sorted(p for p in os.listdir(LOGS) if p.startswith("errors.log")):
for line in read_lines(os.path.join(LOGS, path)):
m = PYLOG.match(line)
if m and m.group(2) in ("ERROR", "CRITICAL"):
add(local_naive(m.group(1), "%Y-%m-%d %H:%M:%S"), "errors.log",
f"{m.group(2)} {m.group(3)}")
# --- gateway-exit-diag.log : JSONL, UTC ISO ---------------------------------
for line in read_lines(os.path.join(LOGS, "gateway-exit-diag.log")):
try:
rec = json.loads(line)
except (json.JSONDecodeError, ValueError):
continue
tag = rec.get("tag", "?")
extra = ""
if tag == "asyncio.run.SystemExit":
extra = f" code={rec.get('code')}"
elif tag == "gateway.start":
extra = f" replace={rec.get('replace')} argv={' '.join(rec.get('argv', [])[-3:])}"
elif tag == "asyncio.run.returned":
extra = f" success={rec.get('success')}"
add(iso_any(rec.get("ts", "")), "exit-diag", f"{tag} pid={rec.get('pid')}{extra}")
# --- tui_gateway_crash.log : section markers + [tui-parent] lines -----------
SECTION = re.compile(r"^=== (.+?) · (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})(?: · (.*?))? ===\s*$")
TUIPARENT = re.compile(r"^\[tui-parent\] (\S+Z) (.*)$")
for line in read_lines(os.path.join(LOGS, "tui_gateway_crash.log")):
m = SECTION.match(line)
if m:
what, ts, detail = m.group(1), m.group(2), m.group(3) or ""
add(local_naive(ts, "%Y-%m-%d %H:%M:%S"), "tui-crash",
f"{what}{' · ' + detail if detail else ''}")
continue
m = TUIPARENT.match(line)
if m and ("[lifecycle]" in m.group(2) or "uncaughtException" in m.group(2)):
add(iso_any(m.group(1)), "tui-parent", m.group(2))
# --- opentui-v2.log : JSONL, epoch ms ---------------------------------------
for line in read_lines(os.path.join(LOGS, "opentui-v2.log")):
try:
rec = json.loads(line)
except (json.JSONDecodeError, ValueError):
continue
keep = (rec.get("scope") == "gateway"
or rec.get("level") in ("warn", "error")
or "transport" in str(rec.get("msg", "")))
if keep:
data = rec.get("data") or {}
brief = {k: v for k, v in data.items() if k in
("python", "reason", "code", "signal", "cause", "sid", "attempt")}
add(rec.get("t", 0) / 1000.0, "opentui",
f"{rec.get('level')} {rec.get('scope')}: {rec.get('msg')} {brief if brief else ''}")
# --- gateway-shutdown-diag.log : SIGTERM dump headers -----------------------
lines = read_lines(os.path.join(LOGS, "gateway-shutdown-diag.log"))
for i, line in enumerate(lines):
if line.startswith("=== shutdown diagnostic"):
for j in range(i, min(i + 4, len(lines))):
mm = re.match(r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)\s*$", lines[j])
if mm:
add(iso_any(mm.group(1)), "shutdown-diag",
line.strip().strip("= ").strip())
break
# --- journal files ----------------------------------------------------------
JLINE = re.compile(r"^(\S+)\s+\S+\s+(.*)$")
def journal(path, default_tag):
for line in read_lines(path):
m = JLINE.match(line)
if not m:
continue
ep, msg = iso_any(m.group(1)), m.group(2)
low = msg.lower()
if "out of memory" in low or "oom-kill" in low or "oom killer" in low \
or "invoked oom-killer" in low or "result 'oom-kill'" in low:
tag = "oom"
elif "suspend" in low or "hibernat" in low:
tag = "sleep"
else:
tag = default_tag
add(ep, tag, msg)
journal(os.path.join(TMP, "journal-user.txt"), "systemd")
journal(os.path.join(TMP, "journal-kernel.txt"), "oom")
for line in read_lines(os.path.join(TMP, "dmesg.txt")):
m = re.match(r"^\[(\w{3} \w{3} +\d+ \d{2}:\d{2}:\d{2} \d{4})\]\s*(.*)$", line)
if m:
add(local_naive(re.sub(r" +", " ", m.group(1)), "%a %b %d %H:%M:%S %Y"),
"oom", m.group(2))
# --- sessions DB ------------------------------------------------------------
abnormal_sessions = []
db = os.path.join(HOME, ".hermes", "state.db")
try:
con = sqlite3.connect(f"file:{db}?mode=ro", uri=True, timeout=5)
rows = con.execute(
"SELECT id, source, started_at, ended_at, end_reason, message_count "
"FROM sessions WHERE source IN ('tui','cli') AND "
"(started_at >= ? OR (ended_at IS NOT NULL AND ended_at >= ?)) "
"ORDER BY started_at", (SINCE, SINCE)).fetchall()
con.close()
for sid, source, st, en, reason, mc in rows:
add(st, "sessions", f"START {source} session={sid} messages={mc}")
if en is not None:
flag = "" if reason else " ABNORMAL(no end_reason)"
add(en, "sessions",
f"END {source} session={sid} reason={reason or 'NULL'} messages={mc}{flag}")
if not reason:
abnormal_sessions.append((sid, source, st, "ended, no end_reason"))
else:
add(st, "sessions",
f"NOEND {source} session={sid} messages={mc} "
f"ABNORMAL(no ended_at recorded — crashed parent or still running)")
abnormal_sessions.append((sid, source, st, "no ended_at"))
except sqlite3.Error as e:
print(f"note: sessions DB unreadable: {e}", file=sys.stderr)
# --- worktrees: current list + dir mtimes -----------------------------------
worktree_snapshot = open(os.path.join(TMP, "worktrees.txt"), errors="replace").read() \
if os.path.exists(os.path.join(TMP, "worktrees.txt")) else ""
wt_dirs = []
for base in ([os.path.join(HOME, "github", d, ".worktrees")
for d in (os.listdir(os.path.join(HOME, "github"))
if os.path.isdir(os.path.join(HOME, "github")) else [])]
+ [os.path.join(HOME, "github", "worktrees", d)
for d in (os.listdir(os.path.join(HOME, "github", "worktrees"))
if os.path.isdir(os.path.join(HOME, "github", "worktrees")) else [])]):
if not os.path.isdir(base):
continue
for name in os.listdir(base):
p = os.path.join(base, name)
if os.path.isdir(p):
try:
mt = os.stat(p).st_mtime
except OSError:
continue
wt_dirs.append((p, mt))
add(mt, "worktree", f"last-modified {p} "
f"(age {round((NOW - mt) / 3600, 1)}h)")
# --- process snapshot -------------------------------------------------------
running = []
PSLINE = re.compile(r"^\s*(\d+)\s+(\w{3} \w{3} +\d+ \d{2}:\d{2}:\d{2} \d{4})\s+(\d+)\s+(.*)$")
for line in read_lines(os.path.join(TMP, "ps.txt")):
m = PSLINE.match(line)
if not m:
continue
pid, lstart, rss, args = m.groups()
ep = local_naive(re.sub(r" +", " ", lstart), "%a %b %d %H:%M:%S %Y")
running.append((pid, ep, int(rss), args))
add(ep, "proc", f"STILL-RUNNING pid={pid} rss={int(rss)//1024}MB started-here: {args[:200]}")
# --- emit timeline ----------------------------------------------------------
def iso(ep):
return datetime.fromtimestamp(ep).astimezone().strftime("%Y-%m-%dT%H:%M:%S%z")
print(f"# forensics timeline since {os.environ['FORENSICS_SINCE_SPEC']!r} "
f"({iso(SINCE)}) — generated {iso(NOW)}")
print(f"# {len(events)} events\n")
events.sort(key=lambda e: e[0])
prev = None
dup = 0
def flush(prev, dup):
if prev is None:
return
suffix = f" (x{dup + 1})" if dup else ""
print(f"{iso(prev[0])} [{prev[1]}] {prev[2]}{suffix}")
for ev in events:
if prev and ev[1] == prev[1] and ev[2] == prev[2] and ev[0] - prev[0] < 5:
dup += 1
continue
flush(prev, dup)
prev, dup = ev, 0
flush(prev, dup)
# --- summary ----------------------------------------------------------------
print("\n" + "=" * 72)
print("SUMMARY")
print("=" * 72)
from collections import Counter
by_tag = Counter(e[1] for e in events)
for tag, n in by_tag.most_common():
print(f" {n:6d} [{tag}]")
ooms = [e for e in events if e[1] == "oom" and "Killed process" in e[2]]
print(f"\nOOM kernel kills in window: {len(ooms)}")
for e in ooms:
m = re.search(r"Killed process (\d+) \(([^)]+)\).*?anon-rss:(\d+)kB", e[2])
if m:
print(f" {iso(e[0])} pid={m.group(1)} comm={m.group(2)} anon-rss={int(m.group(3))//1024}MB")
else:
print(f" {iso(e[0])} {e[2][:140]}")
oomd = [e for e in events if e[1] == "oom" and "Killed process" not in e[2]
and ("oom" in e[2].lower())]
print(f"OOM-related systemd/unit notices: {len(oomd)}")
gexits = Counter()
for e in events:
if e[1] == "tui-crash" and e[2].startswith("gateway exit"):
m = re.search(r"reason=(.*)$", e[2])
gexits[m.group(1) if m else "?"] += 1
print(f"\ntui_gateway exits by reason (tui_gateway_crash.log):")
for r, n in gexits.most_common():
print(f" {n:4d} {r}")
sigs = Counter(e[2].split(" received")[0] for e in events
if e[1] == "tui-crash" and " received" in e[2])
print(f"tui_gateway signals received: {dict(sigs) if sigs else 'none'}")
starts = sum(1 for e in events if e[1] == "exit-diag" and e[2].startswith("gateway.start"))
nz = sum(1 for e in events if e[1] == "exit-diag" and "exit_nonzero" in e[2])
print(f"\nplatform gateway (hermes-gateway.service): {starts} start(s), {nz} nonzero-exit(s) in window")
print(f"\nabnormal tui/cli sessions (no ended_at or no end_reason): {len(abnormal_sessions)}")
for sid, source, st, why in abnormal_sessions[-20:]:
print(f" {iso(st)} {source} {sid}: {why}")
sleeps = [e for e in events if e[1] == "sleep"]
print(f"\nsuspend/hibernate events: {len(sleeps)}")
print(f"\ncurrently running TUI/gateway processes: {len(running)}")
for pid, ep, rss, args in running:
print(f" pid={pid} since={iso(ep) if ep else '?'} rss={rss//1024}MB {args[:120]}")
print(f"\ncurrent git worktrees (snapshot, not historical):")
for line in worktree_snapshot.splitlines():
print(f" {line}")
print("\nNOTE: worktree DELETIONS leave no on-disk record; only surviving dirs are")
print("listed. Prune suspects: cli.py _prune_stale_worktrees (24h/72h tiers) and")
print("the atexit _cleanup_worktree hook (removes dirty worktrees w/o unpushed commits).")
PYEOF

1304
bench/harness.mjs Normal file

File diff suppressed because it is too large Load Diff

59
bench/live-attach.sh Executable file
View File

@@ -0,0 +1,59 @@
#!/usr/bin/env bash
# live-attach.sh — plug into a RUNNING hermes TUI (Ink or OpenTUI) and measure it.
#
# bench/live-attach.sh <pid> [out-dir] # sample memory+cpu until Ctrl-C
# bench/live-attach.sh <pid> --profile [secs] # also grab a CPU profile window (default 30s)
# bench/live-attach.sh <pid> --heap # grab a heap snapshot (large file!)
#
# Find your TUI pid: pgrep -af 'dist/main.js' (OpenTUI)
# pgrep -af 'dist/entry.js' (Ink)
# Works on any live session — no restart, no flags needed at launch:
# profiling uses SIGUSR1 (Node opens an inspector port on demand).
# In-TUI complements (OpenTUI only): /mem (live stats line), /heapdump.
set -euo pipefail
PID="${1:?usage: live-attach.sh <pid> [outdir|--profile [secs]|--heap]}"
shift || true
OUT="${1:-/tmp/tui-live-$PID}"; MODE="sample"; SECS=30
[[ "${1:-}" == "--profile" ]] && { MODE=profile; OUT="/tmp/tui-live-$PID"; SECS="${2:-30}"; }
[[ "${1:-}" == "--heap" ]] && { MODE=heap; OUT="/tmp/tui-live-$PID"; }
mkdir -p "$OUT"
echo "target pid=$PID cmd=$(tr '\0' ' ' </proc/$PID/cmdline | cut -c1-80)"
echo "out: $OUT"
sample() {
local f="$OUT/samples.jsonl"
echo "sampling 1Hz → $f (Ctrl-C to stop; render: node bench/live-render.mjs $OUT)"
local prev_cpu=0 hz; hz=$(getconf CLK_TCK)
while kill -0 "$PID" 2>/dev/null; do
local rss pss pdirty hwm cpu t
rss=$(awk '/^Rss:/{print $2}' /proc/$PID/smaps_rollup 2>/dev/null || echo 0)
pss=$(awk '/^Pss:/{print $2}' /proc/$PID/smaps_rollup 2>/dev/null || echo 0)
pdirty=$(awk '/^Private_Dirty:/{print $2}' /proc/$PID/smaps_rollup 2>/dev/null || echo 0)
hwm=$(awk '/^VmHWM:/{print $2}' /proc/$PID/status 2>/dev/null || echo 0)
cpu=$(awk '{print $14+$15}' /proc/$PID/stat 2>/dev/null || echo 0)
t=$(date +%s.%N)
printf '{"t":%s,"rss_kb":%s,"pss_kb":%s,"private_dirty_kb":%s,"vmhwm_kb":%s,"cpu_ticks":%s,"cpu_hz":%s}\n' \
"$t" "$rss" "$pss" "$pdirty" "$hwm" "$cpu" "$hz" >> "$f"
sleep 1
done
echo "process exited; $(wc -l <"$f") samples in $f"
}
cdp() { # open inspector on demand, find the ws url
kill -USR1 "$PID"; sleep 0.7
local port; port=$(ss -tlnp 2>/dev/null | grep "pid=$PID" | grep -oE ':(92[0-9]{2})' | head -1 | tr -d ':')
[[ -z "$port" ]] && port=9229
curl -s "http://127.0.0.1:$port/json" | grep -oE 'ws://[^"]+' | head -1
}
case "$MODE" in
sample) sample ;;
profile)
WS=$(cdp); echo "CDP: $WS — profiling ${SECS}s (interact with the TUI now!)"
node "$(dirname "$0")/live-cdp.mjs" "$WS" profile "$SECS" "$OUT/live.cpuprofile"
echo "$OUT/live.cpuprofile (open in https://speedscope.app or chrome://inspect)" ;;
heap)
WS=$(cdp); echo "CDP: $WS — heap snapshot (may pause the TUI briefly)"
node "$(dirname "$0")/live-cdp.mjs" "$WS" heap 0 "$OUT/live.heapsnapshot"
echo "$OUT/live.heapsnapshot (Chrome DevTools → Memory → Load)" ;;
esac

44
bench/live-cdp.mjs Normal file
View File

@@ -0,0 +1,44 @@
#!/usr/bin/env node
// live-cdp.mjs — minimal CDP client for live-attach.sh (no deps; Node ws via raw socket
// is overkill — use the built-in WebSocket of Node >=22).
// usage: node live-cdp.mjs <ws-url> profile <secs> <out> | heap 0 <out>
const [, , url, mode, secsArg, out] = process.argv
const { writeFileSync, appendFileSync } = await import('node:fs')
const ws = new WebSocket(url)
let id = 0
const pending = new Map()
const send = (method, params = {}) =>
new Promise((res, rej) => {
const i = ++id
pending.set(i, { res, rej })
ws.send(JSON.stringify({ id: i, method, params }))
})
const chunks = []
ws.onmessage = e => {
const m = JSON.parse(e.data)
if (m.id && pending.has(m.id)) {
const { res, rej } = pending.get(m.id)
pending.delete(m.id)
m.error ? rej(new Error(m.error.message)) : res(m.result)
} else if (m.method === 'HeapProfiler.addHeapSnapshotChunk') chunks.push(m.params.chunk)
}
ws.onopen = async () => {
try {
if (mode === 'profile') {
await send('Profiler.enable')
await send('Profiler.start')
await new Promise(r => setTimeout(r, Number(secsArg) * 1000))
const { profile } = await send('Profiler.stop')
writeFileSync(out, JSON.stringify(profile))
} else {
await send('HeapProfiler.enable')
await send('HeapProfiler.takeHeapSnapshot', { reportProgress: false })
writeFileSync(out, chunks.join(''))
}
process.exit(0)
} catch (err) {
console.error(String(err))
process.exit(1)
}
}
ws.onerror = err => { console.error('ws error', err.message ?? err); process.exit(1) }

17
bench/live-render.mjs Normal file
View File

@@ -0,0 +1,17 @@
#!/usr/bin/env node
// live-render.mjs — quick chart from live-attach samples: node bench/live-render.mjs <dir>
import { readFileSync, writeFileSync } from 'node:fs'
const dir = process.argv[2] ?? '.'
const rows = readFileSync(`${dir}/samples.jsonl`, 'utf8').trim().split('\n').map(l => JSON.parse(l))
const t0 = rows[0].t
const pts = rows.map(r => ({ t: r.t - t0, rss: r.rss_kb / 1024, hwm: r.vmhwm_kb / 1024 }))
const W = 900, H = 360, mt = (v, max) => H - 30 - (v / max) * (H - 60)
const maxY = Math.max(...pts.map(p => p.hwm)) * 1.1
const path = k => pts.map((p, i) => `${i ? 'L' : 'M'}${30 + (p.t / pts.at(-1).t) * (W - 60)},${mt(p[k], maxY)}`).join('')
const cpu = rows.map((r, i) => i ? (r.cpu_ticks - rows[i-1].cpu_ticks) / r.cpu_hz / (r.t - rows[i-1].t) : 0)
writeFileSync(`${dir}/live.svg`, `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" style="background:#0d0d12">
<text x="30" y="20" fill="#ccc" font-family="monospace">live session: RSS (gold) / VmHWM (grey) MB · avg cpu ${(cpu.reduce((a,b)=>a+b,0)/Math.max(1,cpu.length-1)*100).toFixed(1)}% · ${rows.length}s</text>
<path d="${path('hwm')}" stroke="#888" fill="none"/><path d="${path('rss')}" stroke="#F5B820" fill="none" stroke-width="2"/>
<text x="30" y="${H-10}" fill="#888" font-family="monospace">0s</text><text x="${W-80}" y="${H-10}" fill="#888" font-family="monospace">${Math.round(pts.at(-1).t)}s</text>
<text x="${W-120}" y="${mt(pts.at(-1).rss,maxY)}" fill="#F5B820" font-family="monospace">${pts.at(-1).rss.toFixed(0)}MB</text></svg>`)
console.log(`${dir}/live.svg`)

71
bench/memwatch-report.mjs Normal file
View File

@@ -0,0 +1,71 @@
#!/usr/bin/env node
// memwatch-report — aggregate the per-session NDJSON written by the TUI's
// in-process sampler (ui-opentui/src/boundary/memlog.ts) into one fleet table.
//
// Usage: node memwatch-report.mjs [dir] (default ~/.hermes/logs/memwatch)
// Output: one row per session file — start, duration, baseline/peak/last RSS,
// peak mounted rows, and a crude steady-state slope (MB/h over the last half) —
// plus anomaly flags: SLOPE (last-half slope > 20MB/h), PEAK (> 450MB),
// MOUNTED (peak mounted rows > 200 — windowing should bound ~30-120).
import { readdirSync, readFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
const dir = process.argv[2] ?? join(homedir(), '.hermes', 'logs', 'memwatch')
let files = []
try {
files = readdirSync(dir).filter(f => f.endsWith('.jsonl')).sort()
} catch {
console.error(`no memwatch dir at ${dir} — enable with HERMES_TUI_DIAGNOSTICS=1 (or HERMES_TUI_MEMLOG=1)`)
process.exit(1)
}
if (!files.length) {
console.error(`no sessions logged yet in ${dir}`)
process.exit(1)
}
const rows = []
for (const f of files) {
const samples = []
for (const line of readFileSync(join(dir, f), 'utf8').split('\n')) {
if (!line.trim()) continue
try { samples.push(JSON.parse(line)) } catch { /* torn write */ }
}
if (samples.length < 2) continue
const rss = samples.map(s => s.rss_kb / 1024)
const peak = Math.max(...rss)
const durMin = (samples.at(-1).t - samples[0].t) / 60
// steady-state slope: least-squares over the last half of the samples
const half = samples.slice(Math.floor(samples.length / 2))
const t0 = half[0].t
const xs = half.map(s => (s.t - t0) / 3600)
const ys = half.map(s => s.rss_kb / 1024)
const n = xs.length
const mx = xs.reduce((a, b) => a + b, 0) / n
const my = ys.reduce((a, b) => a + b, 0) / n
const denom = xs.reduce((a, x) => a + (x - mx) ** 2, 0)
const slope = denom > 0 ? xs.reduce((a, x, i) => a + (x - mx) * (ys[i] - my), 0) / denom : 0
const peakMounted = Math.max(...samples.map(s => s.peak_mounted ?? 0))
const flags = []
if (slope > 20 && durMin > 10) flags.push('SLOPE')
if (peak > 450) flags.push('PEAK')
if (peakMounted > 200) flags.push('MOUNTED')
rows.push({
session: f.replace('.jsonl', ''),
start: new Date(samples[0].t * 1000).toISOString().slice(0, 16),
min: Math.round(durMin),
base: Math.round(rss[0]),
peak: Math.round(peak),
last: Math.round(rss.at(-1)),
mounted: peakMounted,
'MB/h': Math.round(slope * 10) / 10,
flags: flags.join(',') || '—'
})
}
console.table(rows)
const flagged = rows.filter(r => r.flags !== '—')
console.log(flagged.length
? `\n${flagged.length} session(s) flagged — investigate with bench/live-attach.sh <pid> --heap on a live one.`
: `\nall ${rows.length} sessions healthy (no slope/peak/mounted anomalies).`)

31
bench/package-lock.json generated Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "@hermes/bench",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@hermes/bench",
"version": "0.0.0",
"dependencies": {
"node-pty": "^1.1.0"
}
},
"node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"license": "MIT"
},
"node_modules/node-pty": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz",
"integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^7.1.0"
}
}
}
}

13
bench/package.json Normal file
View File

@@ -0,0 +1,13 @@
{
"name": "@hermes/bench",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "TUI benchmark suite: Ink (ui-tui) vs OpenTUI (ui-opentui) over a real PTY with a fake gateway. Methodology: docs/plans/opentui-bench-suite.md.",
"scripts": {
"check": "node --check fake-gateway.mjs && node --check fixture-stream.mjs && node --check harness.mjs && node --check run.mjs && node --check render.mjs"
},
"dependencies": {
"node-pty": "^1.1.0"
}
}

1102
bench/render.mjs Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

584
bench/report.html Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,393 @@
{
"meta": {
"cell": "gate",
"ui": "ink",
"config": "ink",
"mode": "digest",
"rep": 0,
"run_id": "mq8jcwon-vztv",
"utc": "2026-06-10T20:43:15.191Z",
"sha": "50e34713b",
"node": "/home/daimon/.local/share/fnm/node-versions/v26.3.0/installation/bin/node",
"node_version": "v26.3.0",
"pty": {
"cols": 120,
"rows": 40,
"term": "xterm-256color"
},
"heap_mb": 8192,
"memory_max": null,
"opentui_cap": null,
"fixture": {
"path": "/home/daimon/github/worktrees/hermes-agent/lively-thrush/hermes-agent/bench/.cache/fixture-300.ndjson",
"msgs": 300,
"sha256": "ac81e975c299da7d50cfda7fc0fee33c356bd31a4e17a7f0f8e49905e205051a"
},
"sample_every": 100,
"mode_params": {},
"ui_pid": 2350535,
"gw_pid": 2350547,
"cgroup": null,
"load_avg_at_start": [
0.09,
0.18,
0.29
],
"instrumented": false
},
"samples": [
{
"kind": "periodic",
"t_ms": 28,
"msgs": null,
"events": null,
"pty_bytes": 0,
"pty_writes": 0,
"rss_kb": 54864,
"pss_kb": 28606,
"private_dirty_kb": 17440,
"vmhwm_kb": 54904,
"utime_ticks": 1,
"stime_ticks": 1
},
{
"kind": "periodic",
"t_ms": 1035,
"msgs": null,
"events": null,
"pty_bytes": 6615,
"pty_writes": 11,
"rss_kb": 109772,
"pss_kb": 70491,
"private_dirty_kb": 50616,
"vmhwm_kb": 109772,
"utime_ticks": 23,
"stime_ticks": 3
},
{
"kind": "boundary",
"t_ms": 1461,
"msgs": 100,
"events": 355,
"pty_bytes": 14431,
"pty_writes": 16,
"rss_kb": 127628,
"pss_kb": 88252,
"private_dirty_kb": 67748,
"vmhwm_kb": 127648,
"utime_ticks": 37,
"stime_ticks": 4
},
{
"kind": "boundary",
"t_ms": 1512,
"msgs": 200,
"events": 738,
"pty_bytes": 21088,
"pty_writes": 19,
"rss_kb": 162132,
"pss_kb": 122735,
"private_dirty_kb": 102252,
"vmhwm_kb": 162148,
"utime_ticks": 45,
"stime_ticks": 5
},
{
"kind": "boundary",
"t_ms": 1538,
"msgs": 300,
"events": 1051,
"pty_bytes": 21088,
"pty_writes": 19,
"rss_kb": 172044,
"pss_kb": 132615,
"private_dirty_kb": 112100,
"vmhwm_kb": 172288,
"utime_ticks": 50,
"stime_ticks": 5
},
{
"kind": "done",
"t_ms": 1541,
"msgs": 300,
"events": 1051,
"pty_bytes": 21088,
"pty_writes": 19,
"rss_kb": 172640,
"pss_kb": 133211,
"private_dirty_kb": 112696,
"vmhwm_kb": 173288,
"utime_ticks": 50,
"stime_ticks": 5
},
{
"kind": "periodic",
"t_ms": 2039,
"msgs": 300,
"events": null,
"pty_bytes": 28559,
"pty_writes": 23,
"rss_kb": 179128,
"pss_kb": 139667,
"private_dirty_kb": 119184,
"vmhwm_kb": 184828,
"utime_ticks": 57,
"stime_ticks": 5
},
{
"kind": "final",
"t_ms": 2919,
"msgs": 300,
"events": 1051,
"pty_bytes": 28604,
"pty_writes": 24,
"rss_kb": 179136,
"pss_kb": 139675,
"private_dirty_kb": 119192,
"vmhwm_kb": 184828,
"utime_ticks": 57,
"stime_ticks": 5
},
{
"kind": "periodic",
"t_ms": 3046,
"msgs": 300,
"events": null,
"pty_bytes": 32118,
"pty_writes": 26,
"rss_kb": 185304,
"pss_kb": 145843,
"private_dirty_kb": 125360,
"vmhwm_kb": 185304,
"utime_ticks": 61,
"stime_ticks": 5
},
{
"kind": "periodic",
"t_ms": 4050,
"msgs": 300,
"events": null,
"pty_bytes": 42828,
"pty_writes": 32,
"rss_kb": 186164,
"pss_kb": 146703,
"private_dirty_kb": 126220,
"vmhwm_kb": 186164,
"utime_ticks": 63,
"stime_ticks": 6
}
],
"events": [
{
"kind": "rpc",
"method": "config.get",
"t_ms": 177
},
{
"kind": "rpc",
"method": "commands.catalog",
"t_ms": 202
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 202
},
{
"kind": "rpc",
"method": "setup.status",
"t_ms": 202
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 202
},
{
"kind": "rpc",
"method": "session.create",
"t_ms": 202
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 202
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 227
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 227
},
{
"kind": "rpc",
"method": "session.active_list",
"t_ms": 227
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 227
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 227
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 1410
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 1435
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 1435
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 1461
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 1461
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 1461
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 1485
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 1512
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 1512
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 1541
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 1560
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 1560
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 1585
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 1585
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 1585
},
{
"kind": "rpc",
"method": "session.active_list",
"t_ms": 1710
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 2944
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 2944
},
{
"kind": "rpc",
"method": "terminal.resize",
"t_ms": 3044
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 3094
},
{
"kind": "rpc",
"method": "session.active_list",
"t_ms": 3220
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 3322
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 3346
},
{
"kind": "rpc",
"method": "terminal.resize",
"t_ms": 3422
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 3497
}
],
"summary": {
"result": "completed",
"cap_hit": false,
"cap_hit_basis": null,
"at_messages": null,
"exit": {
"exitCode": 0,
"signal": 0,
"t": 4530
},
"stream_done": true,
"msgs_streamed": 300,
"events_streamed": 1051,
"pty_bytes_total": 43054,
"pty_data_callbacks": 44,
"first_byte_ms": 71,
"session_create_ms": 202,
"stream_start_ms": 1410,
"vmhwm_kb": 186164,
"cg_peak": null,
"drain_max_loop_lag_ms": 6,
"drain_lag_violations": 0,
"drain_ok": true,
"digest": "7775bee02e57da2be0f73880cfe828666f51e08209a28ba81ec599817b95eb2c"
},
"digest_text": "Excepteur irure nostrud tempor ipsum cupidatat voluptate ullamco labore sit.│Amet culpa cillum commodo enim adipiscing deserunt nulla duis veniam sed.│Quis eiusmod lorem occaecat reprehenderit exercitation incididunt dolor proident velit laboris magna.│Esse aliquip aliqua consectetur officia fugiat consequat minim elit mollit pariatur aute quis.│Anim excepteur irure nostrud tempor ipsum cupidatat voluptate ullamco labore sit sunt esse aliquip.│Magna amet culpa cillum.│Aute quis eiusmod lorem occaecat.│ └─ ● Terminal(\"Proident velit laboris magna.\") (0.3s)│ └─ Args:│Proident velit laboris magna amet culpa cillum commodo enim adipiscing deserunt nulla.│Elit mollit pariatur aute quis eiusmod lorem occaecat reprehenderit exercitation incididunt dolor proident.│Ullamco labore sit sunt esse aliquip aliqua consectetur officia fugiat consequat minim elit mollit.│Nulla duis veniam sed.│Dolor proident velit laboris magna.│Result:│Pariatur aute quis eiusmod lorem occaecat reprehenderit exercitation incididunt dolor proident velit laboris.│Sit sunt esse aliquip aliqua consectetur officia fugiat consequat minim elit mollit pariatur aute.││┊ Minimelitmollitpariaturautequiseiusmodloremoccaecatreprehenderitexercitationincididuntdolor.Officia│fugiatconsequatminimelitmollitpariaturautequiseiusmodloremoccaecatreprehenderitexercitation.│┊ Temporipsumcupidatatvoluptateullamcolaboresitsuntessealiquipaliqua.Excepteurirurenostrudtempor│ipsumcupidatatvoluptateullamcolaboresitsuntesse.Veniamsedanimexcepteurirurenostrudtempor│ipsumcupidatatvoluptateullamcolaboresit.││• Suntessealiquipaliquaconsectetur.│• Consequatminimelitmollitpariaturautequis.│• Eiusmodloremoccaecatreprehenderit.││─ ts│constx4=58│functionf3(){│returnx│}││Exercitationincididuntdolorproidentvelitlaborismagnaametculpacillum.Loremoccaecatreprehenderit┃exercitationincididuntdolorproidentvelitlaborismagnaamet.─ ready │ fake model │ 3s │ voice off ─ …es-agent (…i-native-engine)│┃Excepteur irure nostrud tempor ipsum cupidatat voluptate ullamco labore sit.│Amet culpa cillum commodo enim adipiscing deserunt nulla duis veniam sed.│Quis eiusmod lorem occaecat reprehenderit exercitation incididunt dolor proident velit laboris magna.│Esse aliquip aliqua consectetur officia fugiat consequat minim elit mollit pariatur aute quis.│Anim excepteur irure nostrud tempor ipsum cupidatat voluptate ullamco labore sit sunt esse aliquip.│Magna amet culpa cillum.│Aute quis eiusmod lorem occaecat.│ └─ ● Terminal(\"Proident velit laboris magna.\") (0.3s)│ └─ Args:│Proident velit laboris magna amet culpa cillum commodo enim adipiscing deserunt nulla.│Elit mollit pariatur aute quis eiusmod lorem occaecat reprehenderit exercitation incididunt dolor proident.│Ullamco labore sit sunt esse aliquip aliqua consectetur officia fugiat consequat minim elit mollit.│Nulla duis veniam sed.│Dolor proident velit laboris magna.│Result:│Pariatur aute quis eiusmod lorem occaecat reprehenderit exercitation incididunt dolor proident velit laboris.│Sit sunt esse aliquip aliqua consectetur officia fugiat consequat minim elit mollit pariatur aute.││┊ Minimelitmollitpariaturautequiseiusmodloremoccaecatreprehenderitexercitationincididuntdolor.Officia│fugiatconsequatminimelitmollitpariaturautequiseiusmodloremoccaecatreprehenderitexercitation.│┊ Temporipsumcupidatatvoluptateullamcolaboresitsuntessealiquipaliqua.Excepteurirurenostrudtempor│ipsumcupidatatvoluptateullamcolaboresitsuntesse.Veniamsedanimexcepteurirurenostrudtempor│ipsumcupidatatvoluptateullamcolaboresit.││• Suntessealiquipaliquaconsectetur.│• Consequatminimelitmollitpariaturautequis.│• Eiusmodloremoccaecatreprehenderit.││─ ts│constx4=58│functionf3(){│returnx│}││Exercitationincididuntdolorproidentvelitlaborismagnaametculpacillum.Loremoccaecatreprehenderit│exercitationincididuntdolorproidentvelitlaborismagnaamet.┃─ ready │ fake model │ 3s │ voice off ─ …es-agent (…i-native-engine)4"
}

View File

@@ -0,0 +1,408 @@
{
"meta": {
"cell": "gate",
"ui": "ink",
"config": "ink",
"mode": "digest",
"rep": 1,
"run_id": "mq8jd80l-wl8i",
"utc": "2026-06-10T20:43:29.877Z",
"sha": "50e34713b",
"node": "/home/daimon/.local/share/fnm/node-versions/v26.3.0/installation/bin/node",
"node_version": "v26.3.0",
"pty": {
"cols": 120,
"rows": 40,
"term": "xterm-256color"
},
"heap_mb": 8192,
"memory_max": null,
"opentui_cap": null,
"fixture": {
"path": "/home/daimon/github/worktrees/hermes-agent/lively-thrush/hermes-agent/bench/.cache/fixture-300.ndjson",
"msgs": 300,
"sha256": "ac81e975c299da7d50cfda7fc0fee33c356bd31a4e17a7f0f8e49905e205051a"
},
"sample_every": 100,
"mode_params": {},
"ui_pid": 2350865,
"gw_pid": 2350874,
"cgroup": null,
"load_avg_at_start": [
0.07,
0.17,
0.29
],
"instrumented": false
},
"samples": [
{
"kind": "periodic",
"t_ms": 26,
"msgs": null,
"events": null,
"pty_bytes": 0,
"pty_writes": 0,
"rss_kb": 56380,
"pss_kb": 30130,
"private_dirty_kb": 18964,
"vmhwm_kb": 56432,
"utime_ticks": 1,
"stime_ticks": 1
},
{
"kind": "periodic",
"t_ms": 1034,
"msgs": null,
"events": null,
"pty_bytes": 6615,
"pty_writes": 12,
"rss_kb": 108556,
"pss_kb": 69243,
"private_dirty_kb": 49468,
"vmhwm_kb": 108556,
"utime_ticks": 23,
"stime_ticks": 2
},
{
"kind": "boundary",
"t_ms": 1442,
"msgs": 100,
"events": 355,
"pty_bytes": 10643,
"pty_writes": 15,
"rss_kb": 124760,
"pss_kb": 85364,
"private_dirty_kb": 64888,
"vmhwm_kb": 124768,
"utime_ticks": 32,
"stime_ticks": 3
},
{
"kind": "boundary",
"t_ms": 1493,
"msgs": 200,
"events": 738,
"pty_bytes": 18617,
"pty_writes": 18,
"rss_kb": 158216,
"pss_kb": 118777,
"private_dirty_kb": 98344,
"vmhwm_kb": 158248,
"utime_ticks": 41,
"stime_ticks": 4
},
{
"kind": "boundary",
"t_ms": 1544,
"msgs": 300,
"events": 1051,
"pty_bytes": 22520,
"pty_writes": 19,
"rss_kb": 192596,
"pss_kb": 153125,
"private_dirty_kb": 132660,
"vmhwm_kb": 192732,
"utime_ticks": 50,
"stime_ticks": 5
},
{
"kind": "done",
"t_ms": 1546,
"msgs": 300,
"events": 1051,
"pty_bytes": 22520,
"pty_writes": 19,
"rss_kb": 192964,
"pss_kb": 153493,
"private_dirty_kb": 133028,
"vmhwm_kb": 193460,
"utime_ticks": 50,
"stime_ticks": 5
},
{
"kind": "periodic",
"t_ms": 2049,
"msgs": 300,
"events": null,
"pty_bytes": 34018,
"pty_writes": 23,
"rss_kb": 186672,
"pss_kb": 147199,
"private_dirty_kb": 126736,
"vmhwm_kb": 195184,
"utime_ticks": 60,
"stime_ticks": 5
},
{
"kind": "final",
"t_ms": 2909,
"msgs": 300,
"events": 1051,
"pty_bytes": 34063,
"pty_writes": 24,
"rss_kb": 186680,
"pss_kb": 147209,
"private_dirty_kb": 126744,
"vmhwm_kb": 195184,
"utime_ticks": 60,
"stime_ticks": 5
},
{
"kind": "periodic",
"t_ms": 3057,
"msgs": 300,
"events": null,
"pty_bytes": 37577,
"pty_writes": 26,
"rss_kb": 187024,
"pss_kb": 147553,
"private_dirty_kb": 127088,
"vmhwm_kb": 195184,
"utime_ticks": 63,
"stime_ticks": 5
},
{
"kind": "periodic",
"t_ms": 4072,
"msgs": 300,
"events": null,
"pty_bytes": 48287,
"pty_writes": 32,
"rss_kb": 187088,
"pss_kb": 147617,
"private_dirty_kb": 127152,
"vmhwm_kb": 195184,
"utime_ticks": 65,
"stime_ticks": 6
}
],
"events": [
{
"kind": "rpc",
"method": "config.get",
"t_ms": 176
},
{
"kind": "rpc",
"method": "commands.catalog",
"t_ms": 201
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 201
},
{
"kind": "rpc",
"method": "setup.status",
"t_ms": 201
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 201
},
{
"kind": "rpc",
"method": "session.create",
"t_ms": 201
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 201
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 201
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 226
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 226
},
{
"kind": "rpc",
"method": "session.active_list",
"t_ms": 226
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 226
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 226
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 1416
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 1416
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 1442
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 1442
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 1466
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 1492
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 1517
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 1517
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 1517
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 1546
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 1567
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 1567
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 1592
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 1592
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 1616
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 1616
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 1616
},
{
"kind": "rpc",
"method": "session.active_list",
"t_ms": 1717
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 2930
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 2930
},
{
"kind": "rpc",
"method": "terminal.resize",
"t_ms": 3030
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 3081
},
{
"kind": "rpc",
"method": "session.active_list",
"t_ms": 3207
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 3332
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 3332
},
{
"kind": "rpc",
"method": "terminal.resize",
"t_ms": 3410
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 3488
}
],
"summary": {
"result": "completed",
"cap_hit": false,
"cap_hit_basis": null,
"at_messages": null,
"exit": {
"exitCode": 0,
"signal": 0,
"t": 4520
},
"stream_done": true,
"msgs_streamed": 300,
"events_streamed": 1051,
"pty_bytes_total": 48513,
"pty_data_callbacks": 42,
"first_byte_ms": 65,
"session_create_ms": 201,
"stream_start_ms": 1416,
"vmhwm_kb": 195184,
"cg_peak": null,
"drain_max_loop_lag_ms": 2,
"drain_lag_violations": 0,
"drain_ok": true,
"digest": "7775bee02e57da2be0f73880cfe828666f51e08209a28ba81ec599817b95eb2c"
},
"digest_text": "Excepteur irure nostrud tempor ipsum cupidatat voluptate ullamco labore sit.│Amet culpa cillum commodo enim adipiscing deserunt nulla duis veniam sed.│Quis eiusmod lorem occaecat reprehenderit exercitation incididunt dolor proident velit laboris magna.│Esse aliquip aliqua consectetur officia fugiat consequat minim elit mollit pariatur aute quis.│Anim excepteur irure nostrud tempor ipsum cupidatat voluptate ullamco labore sit sunt esse aliquip.│Magna amet culpa cillum.│Aute quis eiusmod lorem occaecat.│ └─ ● Terminal(\"Proident velit laboris magna.\") (0.3s)│ └─ Args:│Proident velit laboris magna amet culpa cillum commodo enim adipiscing deserunt nulla.│Elit mollit pariatur aute quis eiusmod lorem occaecat reprehenderit exercitation incididunt dolor proident.│Ullamco labore sit sunt esse aliquip aliqua consectetur officia fugiat consequat minim elit mollit.│Nulla duis veniam sed.│Dolor proident velit laboris magna.│Result:│Pariatur aute quis eiusmod lorem occaecat reprehenderit exercitation incididunt dolor proident velit laboris.│Sit sunt esse aliquip aliqua consectetur officia fugiat consequat minim elit mollit pariatur aute.││┊ Minimelitmollitpariaturautequiseiusmodloremoccaecatreprehenderitexercitationincididuntdolor.Officia│fugiatconsequatminimelitmollitpariaturautequiseiusmodloremoccaecatreprehenderitexercitation.│┊ Temporipsumcupidatatvoluptateullamcolaboresitsuntessealiquipaliqua.Excepteurirurenostrudtempor│ipsumcupidatatvoluptateullamcolaboresitsuntesse.Veniamsedanimexcepteurirurenostrudtempor│ipsumcupidatatvoluptateullamcolaboresit.││• Suntessealiquipaliquaconsectetur.│• Consequatminimelitmollitpariaturautequis.│• Eiusmodloremoccaecatreprehenderit.││─ ts│constx4=58│functionf3(){│returnx│}││Exercitationincididuntdolorproidentvelitlaborismagnaametculpacillum.Loremoccaecatreprehenderit┃exercitationincididuntdolorproidentvelitlaborismagnaamet.─ ready │ fake model │ 3s │ voice off ─ …es-agent (…i-native-engine)│┃Excepteur irure nostrud tempor ipsum cupidatat voluptate ullamco labore sit.│Amet culpa cillum commodo enim adipiscing deserunt nulla duis veniam sed.│Quis eiusmod lorem occaecat reprehenderit exercitation incididunt dolor proident velit laboris magna.│Esse aliquip aliqua consectetur officia fugiat consequat minim elit mollit pariatur aute quis.│Anim excepteur irure nostrud tempor ipsum cupidatat voluptate ullamco labore sit sunt esse aliquip.│Magna amet culpa cillum.│Aute quis eiusmod lorem occaecat.│ └─ ● Terminal(\"Proident velit laboris magna.\") (0.3s)│ └─ Args:│Proident velit laboris magna amet culpa cillum commodo enim adipiscing deserunt nulla.│Elit mollit pariatur aute quis eiusmod lorem occaecat reprehenderit exercitation incididunt dolor proident.│Ullamco labore sit sunt esse aliquip aliqua consectetur officia fugiat consequat minim elit mollit.│Nulla duis veniam sed.│Dolor proident velit laboris magna.│Result:│Pariatur aute quis eiusmod lorem occaecat reprehenderit exercitation incididunt dolor proident velit laboris.│Sit sunt esse aliquip aliqua consectetur officia fugiat consequat minim elit mollit pariatur aute.││┊ Minimelitmollitpariaturautequiseiusmodloremoccaecatreprehenderitexercitationincididuntdolor.Officia│fugiatconsequatminimelitmollitpariaturautequiseiusmodloremoccaecatreprehenderitexercitation.│┊ Temporipsumcupidatatvoluptateullamcolaboresitsuntessealiquipaliqua.Excepteurirurenostrudtempor│ipsumcupidatatvoluptateullamcolaboresitsuntesse.Veniamsedanimexcepteurirurenostrudtempor│ipsumcupidatatvoluptateullamcolaboresit.││• Suntessealiquipaliquaconsectetur.│• Consequatminimelitmollitpariaturautequis.│• Eiusmodloremoccaecatreprehenderit.││─ ts│constx4=58│functionf3(){│returnx│}││Exercitationincididuntdolorproidentvelitlaborismagnaametculpacillum.Loremoccaecatreprehenderit│exercitationincididuntdolorproidentvelitlaborismagnaamet.┃─ ready │ fake model │ 3s │ voice off ─ …es-agent (…i-native-engine)4"
}

View File

@@ -0,0 +1,223 @@
{
"meta": {
"cell": "gate",
"ui": "opentui",
"config": "otui-capped",
"mode": "digest",
"rep": 0,
"run_id": "mq8jdjbz-h2u1",
"utc": "2026-06-10T20:43:44.543Z",
"sha": "50e34713b",
"node": "/home/daimon/.local/share/fnm/node-versions/v26.3.0/installation/bin/node",
"node_version": "v26.3.0",
"pty": {
"cols": 120,
"rows": 40,
"term": "xterm-256color"
},
"heap_mb": 8192,
"memory_max": null,
"opentui_cap": 3000,
"fixture": {
"path": "/home/daimon/github/worktrees/hermes-agent/lively-thrush/hermes-agent/bench/.cache/fixture-300.ndjson",
"msgs": 300,
"sha256": "ac81e975c299da7d50cfda7fc0fee33c356bd31a4e17a7f0f8e49905e205051a"
},
"sample_every": 100,
"mode_params": {},
"ui_pid": 2351291,
"gw_pid": 2351309,
"cgroup": null,
"load_avg_at_start": [
0.05,
0.17,
0.28
],
"instrumented": false
},
"samples": [
{
"kind": "periodic",
"t_ms": 25,
"msgs": null,
"events": null,
"pty_bytes": 0,
"pty_writes": 0,
"rss_kb": 54500,
"pss_kb": 28086,
"private_dirty_kb": 16976,
"vmhwm_kb": 54544,
"utime_ticks": 1,
"stime_ticks": 0
},
{
"kind": "periodic",
"t_ms": 1032,
"msgs": null,
"events": null,
"pty_bytes": 28911,
"pty_writes": 13,
"rss_kb": 105296,
"pss_kb": 65020,
"private_dirty_kb": 48488,
"vmhwm_kb": 107988,
"utime_ticks": 17,
"stime_ticks": 2
},
{
"kind": "boundary",
"t_ms": 1383,
"msgs": 100,
"events": 355,
"pty_bytes": 31413,
"pty_writes": 16,
"rss_kb": 112456,
"pss_kb": 72079,
"private_dirty_kb": 55520,
"vmhwm_kb": 112468,
"utime_ticks": 22,
"stime_ticks": 2
},
{
"kind": "boundary",
"t_ms": 1384,
"msgs": 200,
"events": 738,
"pty_bytes": 31413,
"pty_writes": 16,
"rss_kb": 112480,
"pss_kb": 72103,
"private_dirty_kb": 55544,
"vmhwm_kb": 112480,
"utime_ticks": 23,
"stime_ticks": 2
},
{
"kind": "boundary",
"t_ms": 1384,
"msgs": 300,
"events": 1051,
"pty_bytes": 31413,
"pty_writes": 16,
"rss_kb": 112488,
"pss_kb": 72111,
"private_dirty_kb": 55552,
"vmhwm_kb": 112508,
"utime_ticks": 23,
"stime_ticks": 2
},
{
"kind": "done",
"t_ms": 1385,
"msgs": 300,
"events": 1051,
"pty_bytes": 31413,
"pty_writes": 16,
"rss_kb": 112612,
"pss_kb": 72235,
"private_dirty_kb": 55676,
"vmhwm_kb": 112640,
"utime_ticks": 23,
"stime_ticks": 3
},
{
"kind": "periodic",
"t_ms": 2035,
"msgs": 300,
"events": null,
"pty_bytes": 35562,
"pty_writes": 19,
"rss_kb": 274188,
"pss_kb": 231176,
"private_dirty_kb": 213680,
"vmhwm_kb": 301852,
"utime_ticks": 106,
"stime_ticks": 11
},
{
"kind": "final",
"t_ms": 2863,
"msgs": 300,
"events": 1051,
"pty_bytes": 35634,
"pty_writes": 20,
"rss_kb": 274220,
"pss_kb": 231208,
"private_dirty_kb": 213712,
"vmhwm_kb": 301852,
"utime_ticks": 107,
"stime_ticks": 11
},
{
"kind": "periodic",
"t_ms": 3045,
"msgs": 300,
"events": null,
"pty_bytes": 46545,
"pty_writes": 24,
"rss_kb": 274276,
"pss_kb": 231264,
"private_dirty_kb": 213768,
"vmhwm_kb": 301852,
"utime_ticks": 108,
"stime_ticks": 11
},
{
"kind": "periodic",
"t_ms": 4051,
"msgs": 300,
"events": null,
"pty_bytes": 57867,
"pty_writes": 29,
"rss_kb": 274312,
"pss_kb": 231300,
"private_dirty_kb": 213804,
"vmhwm_kb": 301852,
"utime_ticks": 109,
"stime_ticks": 11
}
],
"events": [
{
"kind": "rpc",
"method": "session.create",
"t_ms": 175
},
{
"kind": "rpc",
"method": "startup.catalog",
"t_ms": 175
},
{
"kind": "rpc",
"method": "model.options",
"t_ms": 175
}
],
"summary": {
"result": "completed",
"cap_hit": false,
"cap_hit_basis": null,
"at_messages": null,
"exit": {
"exitCode": 0,
"signal": 0,
"t": 4725
},
"stream_done": true,
"msgs_streamed": 300,
"events_streamed": 1051,
"pty_bytes_total": 62678,
"pty_data_callbacks": 35,
"first_byte_ms": 128,
"session_create_ms": 175,
"stream_start_ms": 1382,
"vmhwm_kb": 301852,
"cg_peak": null,
"drain_max_loop_lag_ms": 3,
"drain_lag_violations": 0,
"drain_ok": true,
"digest": "d5e9558583159eac9e72e450848f98505ac9f48804d4f8c80a18baaab0f0f28c"
},
"digest_text": "⚕ Hermes Agent · opentui · ready ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── $ terminal Culpa cillum commodo enim. · 3.7s (2 lines) ◇ read_file Duis veniam sed anim. · 3.8s (18 lines) ◦ edit_file Tempor ipsum cupidatat voluptate. · 3.9s (7 lines) ◦ grep Sunt esse aliquip aliqua. · 4.0s (2 lines) ● web_search Consequat minim elit mollit. · 0.1s (18 lines) ◆ write_file Eiusmod lorem occaecat reprehenderit. · 0.2s (7 lines) $ terminal Proident velit laboris magna. · 0.3s (2 lines) Minim elit mollit pariatur aute quis eiusmod lorem occaecat reprehenderit exercitation incididunt dolor. Officia fugiat consequat minim elit mollit pariatur aute quis eiusmod lorem occaecat reprehenderit exercitation. ⧉ copy ⚕ Tempor ipsum cupidatat voluptate ullamco labore sit sunt esse aliquip aliqua. Excepteur irure nostrud tempor ipsum cupidatat voluptate ullamco labore sit sunt esse. Veniam sed anim excepteur irure nostrud tempor ipsum cupidatat voluptate ullamco labore sit. - Sunt esse aliquip aliqua consectetur. - Consequat minim elit mollit pariatur aute quis. - Eiusmod lorem occaecat reprehenderit. const x4 = 58 function f3() { return x } Exercitation incididunt dolor proident velit laboris magna amet culpa cillum. Lorem occaecat reprehenderit exercitation incididunt dolor proident velit laboris magna amet. ⧉ copy ▄ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ● fake-model │ up: Ns │ …/lively-thrush/hermes-agent Type your message"
}

View File

@@ -0,0 +1,223 @@
{
"meta": {
"cell": "gate",
"ui": "opentui",
"config": "otui-capped",
"mode": "digest",
"rep": 1,
"run_id": "mq8jdupc-im2a",
"utc": "2026-06-10T20:43:59.280Z",
"sha": "50e34713b",
"node": "/home/daimon/.local/share/fnm/node-versions/v26.3.0/installation/bin/node",
"node_version": "v26.3.0",
"pty": {
"cols": 120,
"rows": 40,
"term": "xterm-256color"
},
"heap_mb": 8192,
"memory_max": null,
"opentui_cap": 3000,
"fixture": {
"path": "/home/daimon/github/worktrees/hermes-agent/lively-thrush/hermes-agent/bench/.cache/fixture-300.ndjson",
"msgs": 300,
"sha256": "ac81e975c299da7d50cfda7fc0fee33c356bd31a4e17a7f0f8e49905e205051a"
},
"sample_every": 100,
"mode_params": {},
"ui_pid": 2351822,
"gw_pid": 2351831,
"cgroup": null,
"load_avg_at_start": [
0.04,
0.16,
0.28
],
"instrumented": false
},
"samples": [
{
"kind": "periodic",
"t_ms": 27,
"msgs": null,
"events": null,
"pty_bytes": 0,
"pty_writes": 0,
"rss_kb": 52404,
"pss_kb": 26288,
"private_dirty_kb": 15184,
"vmhwm_kb": 52628,
"utime_ticks": 1,
"stime_ticks": 0
},
{
"kind": "periodic",
"t_ms": 1033,
"msgs": null,
"events": null,
"pty_bytes": 28911,
"pty_writes": 10,
"rss_kb": 104956,
"pss_kb": 64728,
"private_dirty_kb": 48196,
"vmhwm_kb": 106820,
"utime_ticks": 17,
"stime_ticks": 2
},
{
"kind": "boundary",
"t_ms": 1385,
"msgs": 100,
"events": 355,
"pty_bytes": 31413,
"pty_writes": 12,
"rss_kb": 112924,
"pss_kb": 72595,
"private_dirty_kb": 56036,
"vmhwm_kb": 112968,
"utime_ticks": 23,
"stime_ticks": 2
},
{
"kind": "boundary",
"t_ms": 1386,
"msgs": 200,
"events": 738,
"pty_bytes": 31413,
"pty_writes": 12,
"rss_kb": 112968,
"pss_kb": 72639,
"private_dirty_kb": 56080,
"vmhwm_kb": 112968,
"utime_ticks": 23,
"stime_ticks": 2
},
{
"kind": "boundary",
"t_ms": 1386,
"msgs": 300,
"events": 1051,
"pty_bytes": 31413,
"pty_writes": 12,
"rss_kb": 112972,
"pss_kb": 72643,
"private_dirty_kb": 56084,
"vmhwm_kb": 112972,
"utime_ticks": 23,
"stime_ticks": 2
},
{
"kind": "done",
"t_ms": 1387,
"msgs": 300,
"events": 1051,
"pty_bytes": 31413,
"pty_writes": 12,
"rss_kb": 112984,
"pss_kb": 72655,
"private_dirty_kb": 56096,
"vmhwm_kb": 113052,
"utime_ticks": 23,
"stime_ticks": 2
},
{
"kind": "periodic",
"t_ms": 2046,
"msgs": 300,
"events": null,
"pty_bytes": 35562,
"pty_writes": 15,
"rss_kb": 283176,
"pss_kb": 240212,
"private_dirty_kb": 222716,
"vmhwm_kb": 303016,
"utime_ticks": 105,
"stime_ticks": 10
},
{
"kind": "final",
"t_ms": 2862,
"msgs": 300,
"events": 1051,
"pty_bytes": 35634,
"pty_writes": 16,
"rss_kb": 283208,
"pss_kb": 240244,
"private_dirty_kb": 222748,
"vmhwm_kb": 303016,
"utime_ticks": 105,
"stime_ticks": 10
},
{
"kind": "periodic",
"t_ms": 3060,
"msgs": 300,
"events": null,
"pty_bytes": 46545,
"pty_writes": 20,
"rss_kb": 283264,
"pss_kb": 240300,
"private_dirty_kb": 222804,
"vmhwm_kb": 303016,
"utime_ticks": 107,
"stime_ticks": 10
},
{
"kind": "periodic",
"t_ms": 4074,
"msgs": 300,
"events": null,
"pty_bytes": 57867,
"pty_writes": 25,
"rss_kb": 283308,
"pss_kb": 240344,
"private_dirty_kb": 222848,
"vmhwm_kb": 303016,
"utime_ticks": 107,
"stime_ticks": 10
}
],
"events": [
{
"kind": "rpc",
"method": "session.create",
"t_ms": 176
},
{
"kind": "rpc",
"method": "startup.catalog",
"t_ms": 176
},
{
"kind": "rpc",
"method": "model.options",
"t_ms": 176
}
],
"summary": {
"result": "completed",
"cap_hit": false,
"cap_hit_basis": null,
"at_messages": null,
"exit": {
"exitCode": 0,
"signal": 0,
"t": 4706
},
"stream_done": true,
"msgs_streamed": 300,
"events_streamed": 1051,
"pty_bytes_total": 62678,
"pty_data_callbacks": 30,
"first_byte_ms": 130,
"session_create_ms": 176,
"stream_start_ms": 1383,
"vmhwm_kb": 303016,
"cg_peak": null,
"drain_max_loop_lag_ms": 4,
"drain_lag_violations": 0,
"drain_ok": true,
"digest": "d5e9558583159eac9e72e450848f98505ac9f48804d4f8c80a18baaab0f0f28c"
},
"digest_text": "⚕ Hermes Agent · opentui · ready ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── $ terminal Culpa cillum commodo enim. · 3.7s (2 lines) ◇ read_file Duis veniam sed anim. · 3.8s (18 lines) ◦ edit_file Tempor ipsum cupidatat voluptate. · 3.9s (7 lines) ◦ grep Sunt esse aliquip aliqua. · 4.0s (2 lines) ● web_search Consequat minim elit mollit. · 0.1s (18 lines) ◆ write_file Eiusmod lorem occaecat reprehenderit. · 0.2s (7 lines) $ terminal Proident velit laboris magna. · 0.3s (2 lines) Minim elit mollit pariatur aute quis eiusmod lorem occaecat reprehenderit exercitation incididunt dolor. Officia fugiat consequat minim elit mollit pariatur aute quis eiusmod lorem occaecat reprehenderit exercitation. ⧉ copy ⚕ Tempor ipsum cupidatat voluptate ullamco labore sit sunt esse aliquip aliqua. Excepteur irure nostrud tempor ipsum cupidatat voluptate ullamco labore sit sunt esse. Veniam sed anim excepteur irure nostrud tempor ipsum cupidatat voluptate ullamco labore sit. - Sunt esse aliquip aliqua consectetur. - Consequat minim elit mollit pariatur aute quis. - Eiusmod lorem occaecat reprehenderit. const x4 = 58 function f3() { return x } Exercitation incididunt dolor proident velit laboris magna amet culpa cillum. Lorem occaecat reprehenderit exercitation incididunt dolor proident velit laboris magna amet. ⧉ copy ▄ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ● fake-model │ up: Ns │ …/lively-thrush/hermes-agent Type your message"
}

View File

@@ -0,0 +1,799 @@
{
"meta": {
"cell": "mem3000",
"ui": "opentui",
"config": "otui-capped",
"mode": "mem",
"rep": 0,
"run_id": "mq8jwe2j-9ikb",
"utc": "2026-06-10T20:58:24.187Z",
"sha": "50e34713b",
"node": "/home/daimon/.local/share/fnm/node-versions/v26.3.0/installation/bin/node",
"node_version": "v26.3.0",
"pty": {
"cols": 120,
"rows": 40,
"term": "xterm-256color"
},
"heap_mb": 8192,
"memory_max": "2G",
"container_cap": false,
"container_memory": null,
"opentui_cap": 3000,
"fixture": {
"path": "/home/daimon/github/worktrees/hermes-agent/lively-thrush/hermes-agent/bench/.cache/fixture-3000.ndjson",
"msgs": 3000,
"sha256": "0df05a04a611dda68aa07865f21c45b08edc78e0a71d4c8cb2b674729778d96d"
},
"sample_every": 100,
"mode_params": {},
"ui_pid": 2367235,
"gw_pid": 2367245,
"cgroup": "/sys/fs/cgroup/user.slice/user-1001.slice/user@1001.service/app.slice/hermes-bench-mq8jwe2j-9ikb.scope",
"load_avg_at_start": [
0.25,
0.35,
0.39
],
"instrumented": false
},
"samples": [
{
"kind": "periodic",
"t_ms": 53,
"msgs": null,
"events": null,
"pty_bytes": 0,
"pty_writes": 0,
"rss_kb": 60864,
"pss_kb": 34205,
"private_dirty_kb": 22956,
"vmhwm_kb": 60992,
"utime_ticks": 3,
"stime_ticks": 0,
"cg_current": 25100288,
"cg_peak": 25100288,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 1062,
"msgs": null,
"events": null,
"pty_bytes": 28911,
"pty_writes": 10,
"rss_kb": 104924,
"pss_kb": 64715,
"private_dirty_kb": 48120,
"vmhwm_kb": 107988,
"utime_ticks": 17,
"stime_ticks": 2,
"cg_current": 61902848,
"cg_peak": 66801664,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 1695,
"msgs": 100,
"events": 355,
"pty_bytes": 31413,
"pty_writes": 12,
"rss_kb": 106908,
"pss_kb": 66676,
"private_dirty_kb": 50104,
"vmhwm_kb": 107988,
"utime_ticks": 18,
"stime_ticks": 2,
"cg_current": 72585216,
"cg_peak": 72609792,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 1719,
"msgs": 200,
"events": 738,
"pty_bytes": 31413,
"pty_writes": 12,
"rss_kb": 116132,
"pss_kb": 75807,
"private_dirty_kb": 59200,
"vmhwm_kb": 116156,
"utime_ticks": 24,
"stime_ticks": 3,
"cg_current": 83578880,
"cg_peak": 83726336,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 1720,
"msgs": 300,
"events": 1051,
"pty_bytes": 31413,
"pty_writes": 12,
"rss_kb": 116200,
"pss_kb": 75875,
"private_dirty_kb": 59268,
"vmhwm_kb": 116352,
"utime_ticks": 24,
"stime_ticks": 3,
"cg_current": 83578880,
"cg_peak": 83726336,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 1721,
"msgs": 400,
"events": 1432,
"pty_bytes": 31413,
"pty_writes": 12,
"rss_kb": 116356,
"pss_kb": 75945,
"private_dirty_kb": 59296,
"vmhwm_kb": 116356,
"utime_ticks": 24,
"stime_ticks": 3,
"cg_current": 83578880,
"cg_peak": 83726336,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 1721,
"msgs": 501,
"events": 1792,
"pty_bytes": 31413,
"pty_writes": 12,
"rss_kb": 116356,
"pss_kb": 75945,
"private_dirty_kb": 59296,
"vmhwm_kb": 116364,
"utime_ticks": 24,
"stime_ticks": 3,
"cg_current": 83345408,
"cg_peak": 83726336,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 2073,
"msgs": null,
"events": null,
"pty_bytes": 31413,
"pty_writes": 12,
"rss_kb": 244776,
"pss_kb": 202114,
"private_dirty_kb": 184712,
"vmhwm_kb": 244784,
"utime_ticks": 81,
"stime_ticks": 7,
"cg_current": 214659072,
"cg_peak": 214659072,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 2222,
"msgs": 601,
"events": 2154,
"pty_bytes": 38613,
"pty_writes": 15,
"rss_kb": 251684,
"pss_kb": 208873,
"private_dirty_kb": 191432,
"vmhwm_kb": 251712,
"utime_ticks": 100,
"stime_ticks": 8,
"cg_current": 223088640,
"cg_peak": 223354880,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 2449,
"msgs": 701,
"events": 2496,
"pty_bytes": 42490,
"pty_writes": 17,
"rss_kb": 319152,
"pss_kb": 276164,
"private_dirty_kb": 258648,
"vmhwm_kb": 320132,
"utime_ticks": 137,
"stime_ticks": 11,
"cg_current": 292880384,
"cg_peak": 295047168,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 2727,
"msgs": 801,
"events": 2857,
"pty_bytes": 47176,
"pty_writes": 19,
"rss_kb": 337492,
"pss_kb": 294504,
"private_dirty_kb": 276988,
"vmhwm_kb": 349112,
"utime_ticks": 181,
"stime_ticks": 14,
"cg_current": 312528896,
"cg_peak": 325537792,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 3054,
"msgs": 901,
"events": 3245,
"pty_bytes": 49632,
"pty_writes": 21,
"rss_kb": 370356,
"pss_kb": 327363,
"private_dirty_kb": 309852,
"vmhwm_kb": 370368,
"utime_ticks": 216,
"stime_ticks": 15,
"cg_current": 347090944,
"cg_peak": 347090944,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 3079,
"msgs": null,
"events": null,
"pty_bytes": 49632,
"pty_writes": 21,
"rss_kb": 377708,
"pss_kb": 334715,
"private_dirty_kb": 317204,
"vmhwm_kb": 377752,
"utime_ticks": 220,
"stime_ticks": 15,
"cg_current": 354160640,
"cg_peak": 354422784,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 3305,
"msgs": 1001,
"events": 3588,
"pty_bytes": 50573,
"pty_writes": 22,
"rss_kb": 398476,
"pss_kb": 355483,
"private_dirty_kb": 337972,
"vmhwm_kb": 398476,
"utime_ticks": 243,
"stime_ticks": 17,
"cg_current": 375959552,
"cg_peak": 375963648,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 3683,
"msgs": 1101,
"events": 3928,
"pty_bytes": 54942,
"pty_writes": 25,
"rss_kb": 436236,
"pss_kb": 393243,
"private_dirty_kb": 375732,
"vmhwm_kb": 436236,
"utime_ticks": 290,
"stime_ticks": 18,
"cg_current": 414687232,
"cg_peak": 414982144,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 3687,
"msgs": 1201,
"events": 4298,
"pty_bytes": 54942,
"pty_writes": 25,
"rss_kb": 436236,
"pss_kb": 393243,
"private_dirty_kb": 375732,
"vmhwm_kb": 436512,
"utime_ticks": 291,
"stime_ticks": 19,
"cg_current": 415211520,
"cg_peak": 415211520,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 3692,
"msgs": 1300,
"events": 4659,
"pty_bytes": 54942,
"pty_writes": 25,
"rss_kb": 436760,
"pss_kb": 393767,
"private_dirty_kb": 376256,
"vmhwm_kb": 436772,
"utime_ticks": 293,
"stime_ticks": 19,
"cg_current": 415473664,
"cg_peak": 415473664,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 3697,
"msgs": 1400,
"events": 5011,
"pty_bytes": 54942,
"pty_writes": 25,
"rss_kb": 437124,
"pss_kb": 394131,
"private_dirty_kb": 376620,
"vmhwm_kb": 437308,
"utime_ticks": 295,
"stime_ticks": 19,
"cg_current": 415997952,
"cg_peak": 415997952,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 4086,
"msgs": null,
"events": null,
"pty_bytes": 54942,
"pty_writes": 25,
"rss_kb": 534280,
"pss_kb": 491287,
"private_dirty_kb": 473776,
"vmhwm_kb": 534288,
"utime_ticks": 348,
"stime_ticks": 23,
"cg_current": 517648384,
"cg_peak": 517742592,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 4236,
"msgs": 1500,
"events": 5384,
"pty_bytes": 57789,
"pty_writes": 27,
"rss_kb": 539180,
"pss_kb": 496187,
"private_dirty_kb": 478676,
"vmhwm_kb": 539344,
"utime_ticks": 364,
"stime_ticks": 23,
"cg_current": 523218944,
"cg_peak": 523218944,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 4241,
"msgs": 1600,
"events": 5730,
"pty_bytes": 57789,
"pty_writes": 27,
"rss_kb": 539508,
"pss_kb": 496515,
"private_dirty_kb": 479004,
"vmhwm_kb": 539584,
"utime_ticks": 364,
"stime_ticks": 23,
"cg_current": 523743232,
"cg_peak": 523743232,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 4244,
"msgs": 1700,
"events": 6100,
"pty_bytes": 57789,
"pty_writes": 27,
"rss_kb": 539728,
"pss_kb": 496735,
"private_dirty_kb": 479224,
"vmhwm_kb": 539900,
"utime_ticks": 365,
"stime_ticks": 23,
"cg_current": 523743232,
"cg_peak": 523743232,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 4260,
"msgs": 1800,
"events": 6455,
"pty_bytes": 57789,
"pty_writes": 27,
"rss_kb": 541568,
"pss_kb": 498575,
"private_dirty_kb": 481064,
"vmhwm_kb": 541648,
"utime_ticks": 367,
"stime_ticks": 23,
"cg_current": 525840384,
"cg_peak": 525840384,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 4265,
"msgs": 1900,
"events": 6838,
"pty_bytes": 57789,
"pty_writes": 27,
"rss_kb": 542688,
"pss_kb": 499695,
"private_dirty_kb": 482184,
"vmhwm_kb": 543040,
"utime_ticks": 368,
"stime_ticks": 24,
"cg_current": 527626240,
"cg_peak": 527626240,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 4269,
"msgs": 2000,
"events": 7151,
"pty_bytes": 57789,
"pty_writes": 27,
"rss_kb": 543204,
"pss_kb": 500211,
"private_dirty_kb": 482700,
"vmhwm_kb": 543388,
"utime_ticks": 368,
"stime_ticks": 24,
"cg_current": 528150528,
"cg_peak": 528150528,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 4965,
"msgs": 2100,
"events": 7532,
"pty_bytes": 59760,
"pty_writes": 28,
"rss_kb": 676032,
"pss_kb": 633039,
"private_dirty_kb": 615528,
"vmhwm_kb": 676100,
"utime_ticks": 446,
"stime_ticks": 28,
"cg_current": 665362432,
"cg_peak": 665362432,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 5094,
"msgs": null,
"events": null,
"pty_bytes": 59760,
"pty_writes": 28,
"rss_kb": 702136,
"pss_kb": 659143,
"private_dirty_kb": 641632,
"vmhwm_kb": 702136,
"utime_ticks": 460,
"stime_ticks": 30,
"cg_current": 693010432,
"cg_peak": 693010432,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 5395,
"msgs": 2201,
"events": 7892,
"pty_bytes": 62265,
"pty_writes": 29,
"rss_kb": 705816,
"pss_kb": 662823,
"private_dirty_kb": 645312,
"vmhwm_kb": 705816,
"utime_ticks": 491,
"stime_ticks": 30,
"cg_current": 696999936,
"cg_peak": 696999936,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 6099,
"msgs": null,
"events": null,
"pty_bytes": 65269,
"pty_writes": 30,
"rss_kb": 739096,
"pss_kb": 696103,
"private_dirty_kb": 678592,
"vmhwm_kb": 739136,
"utime_ticks": 564,
"stime_ticks": 32,
"cg_current": 731422720,
"cg_peak": 731422720,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 6126,
"msgs": 2301,
"events": 8254,
"pty_bytes": 67277,
"pty_writes": 31,
"rss_kb": 739464,
"pss_kb": 696471,
"private_dirty_kb": 678960,
"vmhwm_kb": 739464,
"utime_ticks": 567,
"stime_ticks": 32,
"cg_current": 731725824,
"cg_peak": 731856896,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 6880,
"msgs": 2401,
"events": 8596,
"pty_bytes": 70614,
"pty_writes": 33,
"rss_kb": 771508,
"pss_kb": 728515,
"private_dirty_kb": 711004,
"vmhwm_kb": 771508,
"utime_ticks": 645,
"stime_ticks": 35,
"cg_current": 765329408,
"cg_peak": 765329408,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 7109,
"msgs": null,
"events": null,
"pty_bytes": 72844,
"pty_writes": 34,
"rss_kb": 773340,
"pss_kb": 730347,
"private_dirty_kb": 712836,
"vmhwm_kb": 773496,
"utime_ticks": 668,
"stime_ticks": 36,
"cg_current": 767463424,
"cg_peak": 767463424,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 7658,
"msgs": 2501,
"events": 8957,
"pty_bytes": 74604,
"pty_writes": 35,
"rss_kb": 802688,
"pss_kb": 759695,
"private_dirty_kb": 742184,
"vmhwm_kb": 802688,
"utime_ticks": 723,
"stime_ticks": 38,
"cg_current": 797646848,
"cg_peak": 797773824,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 8113,
"msgs": null,
"events": null,
"pty_bytes": 76216,
"pty_writes": 36,
"rss_kb": 831540,
"pss_kb": 788547,
"private_dirty_kb": 771036,
"vmhwm_kb": 831680,
"utime_ticks": 769,
"stime_ticks": 40,
"cg_current": 828358656,
"cg_peak": 828514304,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 8512,
"msgs": 2601,
"events": 9345,
"pty_bytes": 77698,
"pty_writes": 37,
"rss_kb": 834300,
"pss_kb": 791307,
"private_dirty_kb": 773796,
"vmhwm_kb": 834300,
"utime_ticks": 810,
"stime_ticks": 40,
"cg_current": 830693376,
"cg_peak": 831066112,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 8794,
"msgs": 2701,
"events": 9688,
"pty_bytes": 79263,
"pty_writes": 38,
"rss_kb": 838572,
"pss_kb": 795579,
"private_dirty_kb": 778068,
"vmhwm_kb": 838584,
"utime_ticks": 839,
"stime_ticks": 40,
"cg_current": 835891200,
"cg_peak": 835891200,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 9118,
"msgs": null,
"events": null,
"pty_bytes": 79263,
"pty_writes": 38,
"rss_kb": 862420,
"pss_kb": 819427,
"private_dirty_kb": 801916,
"vmhwm_kb": 862420,
"utime_ticks": 871,
"stime_ticks": 42,
"cg_current": 860319744,
"cg_peak": 860368896,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 9622,
"msgs": 2801,
"events": 10028,
"pty_bytes": 83086,
"pty_writes": 40,
"rss_kb": 865588,
"pss_kb": 822595,
"private_dirty_kb": 805084,
"vmhwm_kb": 865616,
"utime_ticks": 922,
"stime_ticks": 42,
"cg_current": 863592448,
"cg_peak": 863592448,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 10123,
"msgs": null,
"events": null,
"pty_bytes": 83086,
"pty_writes": 40,
"rss_kb": 895276,
"pss_kb": 852283,
"private_dirty_kb": 834772,
"vmhwm_kb": 895276,
"utime_ticks": 972,
"stime_ticks": 44,
"cg_current": 894144512,
"cg_peak": 894468096,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 10275,
"msgs": 2901,
"events": 10398,
"pty_bytes": 85844,
"pty_writes": 42,
"rss_kb": 898960,
"pss_kb": 855967,
"private_dirty_kb": 838456,
"vmhwm_kb": 899064,
"utime_ticks": 988,
"stime_ticks": 45,
"cg_current": 898830336,
"cg_peak": 898830336,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 10281,
"msgs": 3000,
"events": 10759,
"pty_bytes": 85844,
"pty_writes": 42,
"rss_kb": 899388,
"pss_kb": 856395,
"private_dirty_kb": 838884,
"vmhwm_kb": 899412,
"utime_ticks": 988,
"stime_ticks": 45,
"cg_current": 899354624,
"cg_peak": 899354624,
"cg_oom_kill": 0
},
{
"kind": "done",
"t_ms": 10287,
"msgs": 3000,
"events": 10759,
"pty_bytes": 85844,
"pty_writes": 42,
"rss_kb": 899652,
"pss_kb": 856659,
"private_dirty_kb": 839148,
"vmhwm_kb": 899988,
"utime_ticks": 988,
"stime_ticks": 45,
"cg_current": 899878912,
"cg_peak": 899878912,
"cg_oom_kill": 0
}
],
"events": [
{
"kind": "rpc",
"method": "session.create",
"t_ms": 177
},
{
"kind": "rpc",
"method": "startup.catalog",
"t_ms": 177
},
{
"kind": "rpc",
"method": "model.options",
"t_ms": 177
}
],
"summary": {
"result": "crashed_after_stream",
"cap_hit": false,
"cap_hit_basis": null,
"at_messages": null,
"exit": {
"exitCode": 7,
"signal": 0,
"t": 10362
},
"stream_done": true,
"msgs_streamed": 3000,
"events_streamed": 10759,
"pty_bytes_total": 87107,
"pty_data_callbacks": 43,
"first_byte_ms": 141,
"session_create_ms": 177,
"stream_start_ms": 1692,
"vmhwm_kb": 899988,
"cg_peak": 899878912,
"drain_max_loop_lag_ms": 18,
"drain_lag_violations": 2,
"drain_ok": false,
"digest": null
},
"pty_tail": "urn x}⧉ copy⚕Minim elit mollit pariatur aute quis eiusmod lorem occaecat reprehenderit exercitation incididunt dolor proident. Officia fugiat consequat minim elit mollit. Aliquip aliqua consectetur officia fugiat consequat minim. - Lorem occaecat reprehenderit exercitation incididunt.- Velit laboris magna amet culpa cillum commodo.- Enim adipiscing deserunt nulla.Duis veniam sed anim excepteur irure nostrud tempor ipsum cupidatat voluptate ullamco labore. Adipiscing deserunt nulla duis veniam sed anim excepteur irure nostrud tempor ipsum cupidatat voluptate.◦edit_file Minim elit mollit pariatur. · 2.9s (7 lines)⚡grep Lorem occaecat reprehendet exercitation. · 0s 7⧉ copy ⚕◐Thought: Irure nostrud tempor ⧉ copy ⚕ ⚕ ◐ Thought: Irure nostrud tempor - - - const x3 = 26function f1() { return x}⧉ copy⚕Officia fugiat consequat minim elit mollit pariatur aute quis eiusmod lorem. Aliquip aliqua consectetur officia fugiat consequat minim elit mollit pariatur aute quis. Sit sunt esse aliquip aliqua consectetur officia fugiat consequat minim elit mollit pariatur.- Aute quis eiusmod lorem occaecat.- Incididunt dolor proident velit laboris magna amet.- Culpa cillum commodo enim.Adipiscing deserunt nulla duis veniam sed anim excepteur irure nostrud. Cillum commodo enim adipiscing deserunt nulla duis veniam sed anim excepteur.⚡edit_file Officia fugiat consequat minim. · 0s ⧉ copy⚕ ⧉ copy ● web_search Proident velit laboris magna. · 2.5s (18 lines) ◆ write_file Commodo enim adipiscing deserunt. · 2.6s (7 lines) $ terminal Sed anim excepteur irure. · 2.7s (2 lines)◇read_file Cupidatat voluptate ullamco labore. · 2.8s (18 lines)◦edit_file Aliquip aliqua consectetur officia. · 2.9s (7 lines) ⧉ copy ⚕▍ 8 return x} ⧉ copy●web_search Proident velit laboris magna. · 2.5s (18 lines)◆write_file Commodo enim adipiscing deserunt. · 2.6s (7 lines)$terminal Sed anim excepteur irure. · 2.7s (2 lines)◇read_file Cupidatat voluptate ullamco labore. · 2.8s (18 lines)◦edit_file Aliquip aliqua consectetur officia. · 2.9s (7 lines) ⧉ copy ⚕ ⧉ copy ⚕ ◦edit_file Sit sunt esse aliquip. · 0.9s (7 lines)◦grep Fugiat consequat minim elit. · 1.0s (2 lines)●web_search Quis eiusmod lorem occaecat. · 1.1s (18 lines)◆write_file Dolor proident velit laboris. · 1.2s (7 lines)⚡terminal Cillum commodo enim adipiscing. · 0s- const x5 = 47function f2() { return x}Sit sunt esse aliquip aliqua consectetur officia fugiat consequat minim elit mollit pariatur aute. Voluptate ullamco labore sit sunt esse. Tempor ipsum cupidatat voluptate ullamco labore sit.- Fugiat consequat minim elit mollit.- Quis eiusmod lorem occaecat reprehenderit exercitation incididunt.- Dolor proident velit laboris.Magna amet culpa cillum commodo enim adipiscing deserunt nulla duis veniam sed anim. Proident velit laboris magna amet culpa cillum commodo enim adipiscing deserunt nulla duis veniam.$ terminal1.3s (2 lines)10s │ …/lively-thrush/hermes-agentfile:///home/daimon/github/worktrees/hermes-agent/lively-thrush/hermes-agent/ui-opentui/node_modules/@opentui/core/index-59t85rvq.js:14915\n throw new Error(`Failed to create optimized buffer: ${width}x${height}`);\n ^\nError: Failed to create optimized buffer: 120x12\n at FFIRenderLib.createOptimizedBuffer (file:///home/daimon/github/worktrees/hermes-agent/lively-thrush/hermes-agent/ui-opentui/node_modules/@opentui/core/index-59t85rvq.js:14915:13)\n at OptimizedBuffer.create (file:///home/daimon/github/worktrees/hermes-agent/lively-thrush/hermes-agent/ui-opentui/node_modules/@opentui/core/index-59t85rvq.js:11918:24)\n at TerminalConsole.show (file:///home/daimon/github/worktrees/hermes-agent/lively-thrush/hermes-agent/ui-opentui/node_modules/@opentui/core/index-59t85rvq.js:21058:44)\n at CliRenderer.<anonymous> (file:///home/daimon/github/worktrees/hermes-agent/lively-thrush/hermes-agent/ui-opentui/node_modules/@opentui/core/index-59t85rvq.js:23222:20)\n at process.emit (node:events:509:20)\n at process._fatalException (node:internal/process/execution:190:32)\nNode.js v26.3.0"
}

View File

@@ -0,0 +1,799 @@
{
"meta": {
"cell": "mem3000",
"ui": "opentui",
"config": "otui-uncapped",
"mode": "mem",
"rep": 0,
"run_id": "mq8jwty0-1hks",
"utc": "2026-06-10T20:58:44.760Z",
"sha": "50e34713b",
"node": "/home/daimon/.local/share/fnm/node-versions/v26.3.0/installation/bin/node",
"node_version": "v26.3.0",
"pty": {
"cols": 120,
"rows": 40,
"term": "xterm-256color"
},
"heap_mb": 8192,
"memory_max": "2G",
"container_cap": false,
"container_memory": null,
"opentui_cap": 100000,
"fixture": {
"path": "/home/daimon/github/worktrees/hermes-agent/lively-thrush/hermes-agent/bench/.cache/fixture-3000.ndjson",
"msgs": 3000,
"sha256": "0df05a04a611dda68aa07865f21c45b08edc78e0a71d4c8cb2b674729778d96d"
},
"sample_every": 100,
"mode_params": {},
"ui_pid": 2367592,
"gw_pid": 2367601,
"cgroup": "/sys/fs/cgroup/user.slice/user-1001.slice/user@1001.service/app.slice/hermes-bench-mq8jwty0-1hks.scope",
"load_avg_at_start": [
0.71,
0.44,
0.42
],
"instrumented": false
},
"samples": [
{
"kind": "periodic",
"t_ms": 51,
"msgs": null,
"events": null,
"pty_bytes": 0,
"pty_writes": 0,
"rss_kb": 57692,
"pss_kb": 31342,
"private_dirty_kb": 20212,
"vmhwm_kb": 57964,
"utime_ticks": 2,
"stime_ticks": 1,
"cg_current": 22855680,
"cg_peak": 22855680,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 1056,
"msgs": null,
"events": null,
"pty_bytes": 28911,
"pty_writes": 12,
"rss_kb": 104624,
"pss_kb": 62753,
"private_dirty_kb": 47868,
"vmhwm_kb": 107084,
"utime_ticks": 17,
"stime_ticks": 3,
"cg_current": 61587456,
"cg_peak": 65724416,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 1709,
"msgs": 100,
"events": 355,
"pty_bytes": 31413,
"pty_writes": 14,
"rss_kb": 114272,
"pss_kb": 63664,
"private_dirty_kb": 57388,
"vmhwm_kb": 114436,
"utime_ticks": 21,
"stime_ticks": 3,
"cg_current": 80728064,
"cg_peak": 81027072,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 1711,
"msgs": 200,
"events": 738,
"pty_bytes": 31413,
"pty_writes": 14,
"rss_kb": 114436,
"pss_kb": 63828,
"private_dirty_kb": 57552,
"vmhwm_kb": 114444,
"utime_ticks": 21,
"stime_ticks": 3,
"cg_current": 81117184,
"cg_peak": 81117184,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 1712,
"msgs": 300,
"events": 1051,
"pty_bytes": 31413,
"pty_writes": 14,
"rss_kb": 114536,
"pss_kb": 63928,
"private_dirty_kb": 57652,
"vmhwm_kb": 114536,
"utime_ticks": 21,
"stime_ticks": 3,
"cg_current": 80982016,
"cg_peak": 81117184,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 1713,
"msgs": 400,
"events": 1432,
"pty_bytes": 31413,
"pty_writes": 14,
"rss_kb": 114748,
"pss_kb": 64140,
"private_dirty_kb": 57864,
"vmhwm_kb": 114888,
"utime_ticks": 21,
"stime_ticks": 3,
"cg_current": 81768448,
"cg_peak": 82030592,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 2069,
"msgs": null,
"events": null,
"pty_bytes": 31413,
"pty_writes": 14,
"rss_kb": 218304,
"pss_kb": 165381,
"private_dirty_kb": 158348,
"vmhwm_kb": 218320,
"utime_ticks": 74,
"stime_ticks": 7,
"cg_current": 186974208,
"cg_peak": 187183104,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 2274,
"msgs": 501,
"events": 1792,
"pty_bytes": 31890,
"pty_writes": 15,
"rss_kb": 241724,
"pss_kb": 188614,
"private_dirty_kb": 181708,
"vmhwm_kb": 241724,
"utime_ticks": 98,
"stime_ticks": 8,
"cg_current": 211410944,
"cg_peak": 212041728,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 2551,
"msgs": 601,
"events": 2154,
"pty_bytes": 35126,
"pty_writes": 17,
"rss_kb": 286064,
"pss_kb": 242065,
"private_dirty_kb": 225732,
"vmhwm_kb": 286084,
"utime_ticks": 135,
"stime_ticks": 10,
"cg_current": 258056192,
"cg_peak": 258289664,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 2604,
"msgs": 701,
"events": 2496,
"pty_bytes": 36863,
"pty_writes": 18,
"rss_kb": 321584,
"pss_kb": 278638,
"private_dirty_kb": 261128,
"vmhwm_kb": 321740,
"utime_ticks": 151,
"stime_ticks": 12,
"cg_current": 295292928,
"cg_peak": 295944192,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 2826,
"msgs": 801,
"events": 2857,
"pty_bytes": 39692,
"pty_writes": 20,
"rss_kb": 338924,
"pss_kb": 295973,
"private_dirty_kb": 278468,
"vmhwm_kb": 344240,
"utime_ticks": 187,
"stime_ticks": 16,
"cg_current": 313622528,
"cg_peak": 319459328,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 2828,
"msgs": 901,
"events": 3245,
"pty_bytes": 39692,
"pty_writes": 20,
"rss_kb": 338976,
"pss_kb": 296025,
"private_dirty_kb": 278520,
"vmhwm_kb": 344240,
"utime_ticks": 187,
"stime_ticks": 16,
"cg_current": 313614336,
"cg_peak": 319459328,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 2830,
"msgs": 1001,
"events": 3588,
"pty_bytes": 39692,
"pty_writes": 20,
"rss_kb": 339104,
"pss_kb": 296153,
"private_dirty_kb": 278648,
"vmhwm_kb": 344240,
"utime_ticks": 187,
"stime_ticks": 16,
"cg_current": 313876480,
"cg_peak": 319459328,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 2832,
"msgs": 1101,
"events": 3928,
"pty_bytes": 39692,
"pty_writes": 20,
"rss_kb": 339404,
"pss_kb": 296453,
"private_dirty_kb": 278948,
"vmhwm_kb": 344240,
"utime_ticks": 187,
"stime_ticks": 16,
"cg_current": 314130432,
"cg_peak": 319459328,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 2834,
"msgs": 1201,
"events": 4298,
"pty_bytes": 39692,
"pty_writes": 20,
"rss_kb": 339588,
"pss_kb": 296637,
"private_dirty_kb": 279132,
"vmhwm_kb": 344240,
"utime_ticks": 187,
"stime_ticks": 16,
"cg_current": 314392576,
"cg_peak": 319459328,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 2835,
"msgs": 1300,
"events": 4659,
"pty_bytes": 39692,
"pty_writes": 20,
"rss_kb": 339780,
"pss_kb": 296829,
"private_dirty_kb": 279324,
"vmhwm_kb": 344240,
"utime_ticks": 187,
"stime_ticks": 16,
"cg_current": 314654720,
"cg_peak": 319459328,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 3078,
"msgs": null,
"events": null,
"pty_bytes": 39692,
"pty_writes": 20,
"rss_kb": 425380,
"pss_kb": 381530,
"private_dirty_kb": 364924,
"vmhwm_kb": 425492,
"utime_ticks": 219,
"stime_ticks": 19,
"cg_current": 404750336,
"cg_peak": 404750336,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 3455,
"msgs": 1400,
"events": 5011,
"pty_bytes": 41169,
"pty_writes": 21,
"rss_kb": 487696,
"pss_kb": 443846,
"private_dirty_kb": 427240,
"vmhwm_kb": 487712,
"utime_ticks": 278,
"stime_ticks": 22,
"cg_current": 469049344,
"cg_peak": 469057536,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 3757,
"msgs": 1500,
"events": 5384,
"pty_bytes": 41699,
"pty_writes": 22,
"rss_kb": 519032,
"pss_kb": 475182,
"private_dirty_kb": 458576,
"vmhwm_kb": 519036,
"utime_ticks": 310,
"stime_ticks": 23,
"cg_current": 501985280,
"cg_peak": 501985280,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 4083,
"msgs": 1600,
"events": 5730,
"pty_bytes": 43564,
"pty_writes": 24,
"rss_kb": 546612,
"pss_kb": 502762,
"private_dirty_kb": 486156,
"vmhwm_kb": 546680,
"utime_ticks": 345,
"stime_ticks": 25,
"cg_current": 531128320,
"cg_peak": 531128320,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 4087,
"msgs": 1700,
"events": 6100,
"pty_bytes": 43564,
"pty_writes": 24,
"rss_kb": 546828,
"pss_kb": 502978,
"private_dirty_kb": 486372,
"vmhwm_kb": 546896,
"utime_ticks": 345,
"stime_ticks": 25,
"cg_current": 531390464,
"cg_peak": 531390464,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 4091,
"msgs": 1800,
"events": 6455,
"pty_bytes": 43564,
"pty_writes": 24,
"rss_kb": 546932,
"pss_kb": 503082,
"private_dirty_kb": 486476,
"vmhwm_kb": 547220,
"utime_ticks": 345,
"stime_ticks": 25,
"cg_current": 531914752,
"cg_peak": 531914752,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 4094,
"msgs": 1900,
"events": 6838,
"pty_bytes": 43564,
"pty_writes": 24,
"rss_kb": 547332,
"pss_kb": 503482,
"private_dirty_kb": 486876,
"vmhwm_kb": 547456,
"utime_ticks": 345,
"stime_ticks": 25,
"cg_current": 532176896,
"cg_peak": 532176896,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 4097,
"msgs": 2000,
"events": 7151,
"pty_bytes": 43564,
"pty_writes": 24,
"rss_kb": 547764,
"pss_kb": 503914,
"private_dirty_kb": 487308,
"vmhwm_kb": 547908,
"utime_ticks": 346,
"stime_ticks": 25,
"cg_current": 532439040,
"cg_peak": 532439040,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 4100,
"msgs": null,
"events": null,
"pty_bytes": 43564,
"pty_writes": 24,
"rss_kb": 548212,
"pss_kb": 504362,
"private_dirty_kb": 487756,
"vmhwm_kb": 548288,
"utime_ticks": 346,
"stime_ticks": 25,
"cg_current": 532963328,
"cg_peak": 532963328,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 4411,
"msgs": 2100,
"events": 7532,
"pty_bytes": 43564,
"pty_writes": 24,
"rss_kb": 658764,
"pss_kb": 614914,
"private_dirty_kb": 598308,
"vmhwm_kb": 658832,
"utime_ticks": 385,
"stime_ticks": 31,
"cg_current": 648835072,
"cg_peak": 648835072,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 4791,
"msgs": 2201,
"events": 7892,
"pty_bytes": 44965,
"pty_writes": 25,
"rss_kb": 692780,
"pss_kb": 648930,
"private_dirty_kb": 632324,
"vmhwm_kb": 692908,
"utime_ticks": 424,
"stime_ticks": 32,
"cg_current": 683880448,
"cg_peak": 683880448,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 5114,
"msgs": null,
"events": null,
"pty_bytes": 44965,
"pty_writes": 25,
"rss_kb": 719796,
"pss_kb": 675946,
"private_dirty_kb": 659340,
"vmhwm_kb": 719796,
"utime_ticks": 456,
"stime_ticks": 35,
"cg_current": 711639040,
"cg_peak": 711864320,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 5468,
"msgs": 2301,
"events": 8254,
"pty_bytes": 47938,
"pty_writes": 27,
"rss_kb": 724840,
"pss_kb": 680990,
"private_dirty_kb": 664384,
"vmhwm_kb": 725152,
"utime_ticks": 494,
"stime_ticks": 35,
"cg_current": 717672448,
"cg_peak": 717672448,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 6121,
"msgs": null,
"events": null,
"pty_bytes": 49397,
"pty_writes": 28,
"rss_kb": 753180,
"pss_kb": 709330,
"private_dirty_kb": 692724,
"vmhwm_kb": 753180,
"utime_ticks": 562,
"stime_ticks": 38,
"cg_current": 746872832,
"cg_peak": 747012096,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 6222,
"msgs": 2401,
"events": 8596,
"pty_bytes": 51085,
"pty_writes": 29,
"rss_kb": 756036,
"pss_kb": 712186,
"private_dirty_kb": 695580,
"vmhwm_kb": 756092,
"utime_ticks": 572,
"stime_ticks": 38,
"cg_current": 749625344,
"cg_peak": 749625344,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 7002,
"msgs": 2501,
"events": 8957,
"pty_bytes": 55291,
"pty_writes": 31,
"rss_kb": 786164,
"pss_kb": 742314,
"private_dirty_kb": 725708,
"vmhwm_kb": 786172,
"utime_ticks": 651,
"stime_ticks": 40,
"cg_current": 781586432,
"cg_peak": 781586432,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 7127,
"msgs": null,
"events": null,
"pty_bytes": 55291,
"pty_writes": 31,
"rss_kb": 807644,
"pss_kb": 763794,
"private_dirty_kb": 747188,
"vmhwm_kb": 807644,
"utime_ticks": 664,
"stime_ticks": 42,
"cg_current": 803647488,
"cg_peak": 803848192,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 7830,
"msgs": 2601,
"events": 9345,
"pty_bytes": 58421,
"pty_writes": 33,
"rss_kb": 817624,
"pss_kb": 773774,
"private_dirty_kb": 757168,
"vmhwm_kb": 817692,
"utime_ticks": 735,
"stime_ticks": 43,
"cg_current": 814092288,
"cg_peak": 814092288,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 8130,
"msgs": null,
"events": null,
"pty_bytes": 58421,
"pty_writes": 33,
"rss_kb": 842436,
"pss_kb": 798586,
"private_dirty_kb": 781980,
"vmhwm_kb": 842436,
"utime_ticks": 764,
"stime_ticks": 45,
"cg_current": 839323648,
"cg_peak": 840339456,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 8409,
"msgs": 2701,
"events": 9688,
"pty_bytes": 59910,
"pty_writes": 34,
"rss_kb": 844204,
"pss_kb": 800354,
"private_dirty_kb": 783748,
"vmhwm_kb": 844204,
"utime_ticks": 793,
"stime_ticks": 45,
"cg_current": 841596928,
"cg_peak": 841596928,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 9134,
"msgs": null,
"events": null,
"pty_bytes": 61517,
"pty_writes": 35,
"rss_kb": 875368,
"pss_kb": 831518,
"private_dirty_kb": 814912,
"vmhwm_kb": 875368,
"utime_ticks": 866,
"stime_ticks": 48,
"cg_current": 874221568,
"cg_peak": 874221568,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 9286,
"msgs": 2801,
"events": 10028,
"pty_bytes": 63074,
"pty_writes": 36,
"rss_kb": 875868,
"pss_kb": 832018,
"private_dirty_kb": 815412,
"vmhwm_kb": 875868,
"utime_ticks": 882,
"stime_ticks": 48,
"cg_current": 874745856,
"cg_peak": 874749952,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 10138,
"msgs": null,
"events": null,
"pty_bytes": 64846,
"pty_writes": 37,
"rss_kb": 906312,
"pss_kb": 862462,
"private_dirty_kb": 845856,
"vmhwm_kb": 906312,
"utime_ticks": 968,
"stime_ticks": 51,
"cg_current": 906104832,
"cg_peak": 906543104,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 10191,
"msgs": 2901,
"events": 10398,
"pty_bytes": 68029,
"pty_writes": 39,
"rss_kb": 908240,
"pss_kb": 864390,
"private_dirty_kb": 847784,
"vmhwm_kb": 908264,
"utime_ticks": 973,
"stime_ticks": 51,
"cg_current": 908509184,
"cg_peak": 908607488,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 10198,
"msgs": 3000,
"events": 10759,
"pty_bytes": 68029,
"pty_writes": 39,
"rss_kb": 908380,
"pss_kb": 864530,
"private_dirty_kb": 847924,
"vmhwm_kb": 908432,
"utime_ticks": 974,
"stime_ticks": 51,
"cg_current": 908509184,
"cg_peak": 908607488,
"cg_oom_kill": 0
},
{
"kind": "done",
"t_ms": 10205,
"msgs": 3000,
"events": 10759,
"pty_bytes": 68029,
"pty_writes": 39,
"rss_kb": 908828,
"pss_kb": 864978,
"private_dirty_kb": 848372,
"vmhwm_kb": 908852,
"utime_ticks": 974,
"stime_ticks": 51,
"cg_current": 909033472,
"cg_peak": 909033472,
"cg_oom_kill": 0
}
],
"events": [
{
"kind": "rpc",
"method": "session.create",
"t_ms": 177
},
{
"kind": "rpc",
"method": "startup.catalog",
"t_ms": 203
},
{
"kind": "rpc",
"method": "model.options",
"t_ms": 203
}
],
"summary": {
"result": "crashed_after_stream",
"cap_hit": false,
"cap_hit_basis": null,
"at_messages": null,
"exit": {
"exitCode": 7,
"signal": 0,
"t": 10266
},
"stream_done": true,
"msgs_streamed": 3000,
"events_streamed": 10759,
"pty_bytes_total": 69292,
"pty_data_callbacks": 40,
"first_byte_ms": 146,
"session_create_ms": 177,
"stream_start_ms": 1707,
"vmhwm_kb": 908852,
"cg_peak": 909033472,
"drain_max_loop_lag_ms": 22,
"drain_lag_violations": 3,
"drain_ok": false,
"digest": null
},
"pty_tail": "() { return x}⧉ copy⚕ ▼ Thinking: Occaecat reprehenderit exercitation│Consectetur officia fugiat consequat minim elit mollit pariatur aute quis eiusmod. Esse aliquip aliqua │ consectetur officia fugiat consequat minim elit mollit pariatur aute. Occaecat reprehenderit exercitation incididunt dolor proident.- Laboris magna amet culpa cillum.- Adipiscing deserunt nulla duis veniam sed anim.- Excepteur irure nostrud tempor.Ipsum cupidatat voluptate ullamco labore sit sunt esse aliquip aliqua consectetur officia fugiat consequat. Irure nostrud tempor ipsum cupidatat voluptate.⚡terminal Occaecat reprehenderit exercitation incididunt. · 0s ⚕ ⧉ copy ⚕▼Thinking: Quis eiusmod lorem │ │ - - - const x4 = 55function f0() { return x}⧉ copy⚕ ▼ Thinking: Quis eiusmod lorem│Esse aliquip aliqua consectetur officia fugiat consequat minim. Labore sit sunt esse aliquip aliqua │ consectetur officia fugiat. Quis eiusmod lorem occaecat reprehenderit exercitation incididunt dolor proident velit laboris magna.- Dolor proident velit laboris magna.- Cillum commodo enim adipiscing deserunt nulla duis.- Veniam sed anim excepteur.Irure nostrud tempor ipsum cupidatat voluptate ullamco labore sit sunt esse. Sed anim excepteur irure nostrud tempor ipsum cupidatat voluptate ullamco labore sit.⚡terminal Quis eiusmod lorem occaecat. · 0s8⧉ copy⚕◐Thought: Deserunt nulla duis ⧉ copy ⚕ ● web_search Irure nostrud tempor ipsum. · 2.1s (18 lines)⚕ ◐ Thought: Deserunt nulla duis - - - const x5 = 19function f4() { return x}⧉ copy⚕Irure nostrud tempor ipsum cupidatat voluptate ullamco labore sit sunt esse aliquip aliqua. Sed anim excepteur irure nostrud tempor ipsum cupidatat voluptate ullamco labore sit sunt esse. - Labore sit sunt esse aliquip.- Officia fugiat consequat minim elit mollit pariatur.- Aute quis eiusmod lorem.Occaecat reprehenderit exercitation incididunt dolor proident velit laboris magna amet culpa cillum. Quis eiusmod lorem occaecat reprehenderit exercitation incididunt dolor proident velit laboris magna amet.●web_search Irure nostrud tempor ipsum. · 2.1s (18 lines)⚡write_fileLabore si sun esse. · 0s ⧉ copy ⚕ ▼ Thinking: Consequat minim elit │ │ $terminal Consequat minim elit mollit. · 3.7s (2 lines) ◇ read_file Eiusmod lorem occaecat reprehenderit. · 3.8s (18 lines) ◦edit_file Proidnt velitlaboris magna. · 3.9s (7gep Commodo enimadipiscing dserunt. · 0s9const x5 = 75function f0() { return x}Cupidatat voluptate ullamco labore sit sunt esse aliquip aliqua consectetur officia. Nostrud tempor ipsum cupidatat voluptate ullamco labore sit sunt esse aliquip aliqua.Consequat minim elit mollit pariatur aute.- Eiusmod lorem occaecat reprehenderit exercitation.- Proident velit laboris magna amet culpa cillum.- Commodo enim adipiscing deserunt.Nulla duis veniam sed anim excepteur irure nostrud tempor ipsum cupidatat voluptate ullamco labore. Enim adipiscingdeserunt nulla duis veniam.◦ grep4.0s (2 lines)file:///home/daimon/github/worktrees/hermes-agent/lively-thrush/hermes-agent/ui-opentui/node_modules/@opentui/core/index-59t85rvq.js:14915\n throw new Error(`Failed to create optimized buffer: ${width}x${height}`);\n ^\nError: Failed to create optimized buffer: 120x12\n at FFIRenderLib.createOptimizedBuffer (file:///home/daimon/github/worktrees/hermes-agent/lively-thrush/hermes-agent/ui-opentui/node_modules/@opentui/core/index-59t85rvq.js:14915:13)\n at OptimizedBuffer.create (file:///home/daimon/github/worktrees/hermes-agent/lively-thrush/hermes-agent/ui-opentui/node_modules/@opentui/core/index-59t85rvq.js:11918:24)\n at TerminalConsole.show (file:///home/daimon/github/worktrees/hermes-agent/lively-thrush/hermes-agent/ui-opentui/node_modules/@opentui/core/index-59t85rvq.js:21058:44)\n at CliRenderer.<anonymous> (file:///home/daimon/github/worktrees/hermes-agent/lively-thrush/hermes-agent/ui-opentui/node_modules/@opentui/core/index-59t85rvq.js:23222:20)\n at process.emit (node:events:509:20)\n at process._fatalException (node:internal/process/execution:190:32)\nNode.js v26.3.0"
}

View File

@@ -0,0 +1,782 @@
{
"meta": {
"cell": "mem3000",
"ui": "opentui",
"config": "otui-uncapped",
"mode": "mem",
"rep": 1,
"run_id": "mq8jy0dw-48jl",
"utc": "2026-06-10T20:59:39.764Z",
"sha": "50e34713b",
"node": "/home/daimon/.local/share/fnm/node-versions/v26.3.0/installation/bin/node",
"node_version": "v26.3.0",
"pty": {
"cols": 120,
"rows": 40,
"term": "xterm-256color"
},
"heap_mb": 8192,
"memory_max": "2G",
"container_cap": false,
"container_memory": null,
"opentui_cap": 100000,
"fixture": {
"path": "/home/daimon/github/worktrees/hermes-agent/lively-thrush/hermes-agent/bench/.cache/fixture-3000.ndjson",
"msgs": 3000,
"sha256": "0df05a04a611dda68aa07865f21c45b08edc78e0a71d4c8cb2b674729778d96d"
},
"sample_every": 100,
"mode_params": {},
"ui_pid": 2369054,
"gw_pid": 2369063,
"cgroup": "/sys/fs/cgroup/user.slice/user-1001.slice/session-7349.scope",
"load_avg_at_start": [
0.97,
0.58,
0.47
],
"instrumented": false
},
"samples": [
{
"kind": "periodic",
"t_ms": 26,
"msgs": null,
"events": null,
"pty_bytes": 0,
"pty_writes": 0,
"rss_kb": 26616,
"pss_kb": 9810,
"private_dirty_kb": 3112,
"vmhwm_kb": 26616,
"utime_ticks": 0,
"stime_ticks": 0,
"cg_current": 3192561664,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 1032,
"msgs": null,
"events": null,
"pty_bytes": 28911,
"pty_writes": 11,
"rss_kb": 105124,
"pss_kb": 64072,
"private_dirty_kb": 48416,
"vmhwm_kb": 107976,
"utime_ticks": 18,
"stime_ticks": 3,
"cg_current": 3193053184,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 1711,
"msgs": 100,
"events": 355,
"pty_bytes": 31413,
"pty_writes": 13,
"rss_kb": 113336,
"pss_kb": 72247,
"private_dirty_kb": 56628,
"vmhwm_kb": 113472,
"utime_ticks": 20,
"stime_ticks": 3,
"cg_current": 3192659968,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 1712,
"msgs": 200,
"events": 738,
"pty_bytes": 31413,
"pty_writes": 13,
"rss_kb": 113720,
"pss_kb": 72567,
"private_dirty_kb": 56884,
"vmhwm_kb": 113772,
"utime_ticks": 21,
"stime_ticks": 3,
"cg_current": 3192659968,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 1713,
"msgs": 300,
"events": 1051,
"pty_bytes": 31413,
"pty_writes": 13,
"rss_kb": 113976,
"pss_kb": 72823,
"private_dirty_kb": 57140,
"vmhwm_kb": 114008,
"utime_ticks": 21,
"stime_ticks": 3,
"cg_current": 3192659968,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 1713,
"msgs": 400,
"events": 1432,
"pty_bytes": 31413,
"pty_writes": 13,
"rss_kb": 114224,
"pss_kb": 73071,
"private_dirty_kb": 57388,
"vmhwm_kb": 114240,
"utime_ticks": 21,
"stime_ticks": 3,
"cg_current": 3192659968,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 2039,
"msgs": null,
"events": null,
"pty_bytes": 31413,
"pty_writes": 13,
"rss_kb": 233888,
"pss_kb": 190414,
"private_dirty_kb": 173920,
"vmhwm_kb": 233908,
"utime_ticks": 75,
"stime_ticks": 7,
"cg_current": 3192918016,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 2139,
"msgs": 501,
"events": 1792,
"pty_bytes": 31881,
"pty_writes": 14,
"rss_kb": 242104,
"pss_kb": 198630,
"private_dirty_kb": 182136,
"vmhwm_kb": 242104,
"utime_ticks": 86,
"stime_ticks": 8,
"cg_current": 3192664064,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 2341,
"msgs": 601,
"events": 2154,
"pty_bytes": 36276,
"pty_writes": 16,
"rss_kb": 277724,
"pss_kb": 234112,
"private_dirty_kb": 217568,
"vmhwm_kb": 277744,
"utime_ticks": 113,
"stime_ticks": 9,
"cg_current": 3192410112,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 2415,
"msgs": 701,
"events": 2496,
"pty_bytes": 38144,
"pty_writes": 17,
"rss_kb": 293016,
"pss_kb": 249217,
"private_dirty_kb": 232608,
"vmhwm_kb": 319420,
"utime_ticks": 136,
"stime_ticks": 11,
"cg_current": 3192414208,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 2665,
"msgs": 801,
"events": 2857,
"pty_bytes": 42361,
"pty_writes": 19,
"rss_kb": 347140,
"pss_kb": 303341,
"private_dirty_kb": 286732,
"vmhwm_kb": 347192,
"utime_ticks": 174,
"stime_ticks": 13,
"cg_current": 3192664064,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 2890,
"msgs": 901,
"events": 3245,
"pty_bytes": 45955,
"pty_writes": 21,
"rss_kb": 378252,
"pss_kb": 334448,
"private_dirty_kb": 317844,
"vmhwm_kb": 378328,
"utime_ticks": 200,
"stime_ticks": 15,
"cg_current": 3191242752,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 2894,
"msgs": 1001,
"events": 3588,
"pty_bytes": 45955,
"pty_writes": 21,
"rss_kb": 378556,
"pss_kb": 334752,
"private_dirty_kb": 318148,
"vmhwm_kb": 378632,
"utime_ticks": 201,
"stime_ticks": 15,
"cg_current": 3191500800,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 2896,
"msgs": 1101,
"events": 3928,
"pty_bytes": 45955,
"pty_writes": 21,
"rss_kb": 378840,
"pss_kb": 335036,
"private_dirty_kb": 318432,
"vmhwm_kb": 378944,
"utime_ticks": 201,
"stime_ticks": 15,
"cg_current": 3191242752,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 2898,
"msgs": 1201,
"events": 4298,
"pty_bytes": 45955,
"pty_writes": 21,
"rss_kb": 379096,
"pss_kb": 335292,
"private_dirty_kb": 318688,
"vmhwm_kb": 379164,
"utime_ticks": 201,
"stime_ticks": 15,
"cg_current": 3191242752,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 2899,
"msgs": 1300,
"events": 4659,
"pty_bytes": 45955,
"pty_writes": 21,
"rss_kb": 379292,
"pss_kb": 335488,
"private_dirty_kb": 318884,
"vmhwm_kb": 379340,
"utime_ticks": 201,
"stime_ticks": 15,
"cg_current": 3191242752,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 2900,
"msgs": 1400,
"events": 5011,
"pty_bytes": 45955,
"pty_writes": 21,
"rss_kb": 379448,
"pss_kb": 335644,
"private_dirty_kb": 319040,
"vmhwm_kb": 379464,
"utime_ticks": 201,
"stime_ticks": 15,
"cg_current": 3191242752,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 2902,
"msgs": 1500,
"events": 5384,
"pty_bytes": 45955,
"pty_writes": 21,
"rss_kb": 379524,
"pss_kb": 335720,
"private_dirty_kb": 319116,
"vmhwm_kb": 379600,
"utime_ticks": 201,
"stime_ticks": 15,
"cg_current": 3191242752,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 3041,
"msgs": null,
"events": null,
"pty_bytes": 45955,
"pty_writes": 21,
"rss_kb": 429064,
"pss_kb": 385260,
"private_dirty_kb": 368656,
"vmhwm_kb": 432296,
"utime_ticks": 222,
"stime_ticks": 17,
"cg_current": 3191476224,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 3342,
"msgs": 1600,
"events": 5730,
"pty_bytes": 45955,
"pty_writes": 21,
"rss_kb": 520268,
"pss_kb": 476464,
"private_dirty_kb": 459860,
"vmhwm_kb": 520268,
"utime_ticks": 280,
"stime_ticks": 20,
"cg_current": 3191271424,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 4018,
"msgs": 1700,
"events": 6100,
"pty_bytes": 48813,
"pty_writes": 23,
"rss_kb": 595392,
"pss_kb": 551588,
"private_dirty_kb": 534984,
"vmhwm_kb": 595392,
"utime_ticks": 350,
"stime_ticks": 22,
"cg_current": 3191713792,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 4044,
"msgs": null,
"events": null,
"pty_bytes": 48813,
"pty_writes": 23,
"rss_kb": 595392,
"pss_kb": 551588,
"private_dirty_kb": 534984,
"vmhwm_kb": 595392,
"utime_ticks": 353,
"stime_ticks": 22,
"cg_current": 3191713792,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 4195,
"msgs": 1800,
"events": 6455,
"pty_bytes": 50669,
"pty_writes": 24,
"rss_kb": 597364,
"pss_kb": 553560,
"private_dirty_kb": 536956,
"vmhwm_kb": 597404,
"utime_ticks": 369,
"stime_ticks": 23,
"cg_current": 3191312384,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 4646,
"msgs": 1900,
"events": 6838,
"pty_bytes": 53455,
"pty_writes": 26,
"rss_kb": 629256,
"pss_kb": 585452,
"private_dirty_kb": 568848,
"vmhwm_kb": 629392,
"utime_ticks": 416,
"stime_ticks": 25,
"cg_current": 3192115200,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 4662,
"msgs": 2000,
"events": 7151,
"pty_bytes": 53455,
"pty_writes": 26,
"rss_kb": 629784,
"pss_kb": 585980,
"private_dirty_kb": 569376,
"vmhwm_kb": 629888,
"utime_ticks": 417,
"stime_ticks": 25,
"cg_current": 3192115200,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 4668,
"msgs": 2100,
"events": 7532,
"pty_bytes": 53455,
"pty_writes": 26,
"rss_kb": 630020,
"pss_kb": 586216,
"private_dirty_kb": 569612,
"vmhwm_kb": 630032,
"utime_ticks": 417,
"stime_ticks": 25,
"cg_current": 3192377344,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 4672,
"msgs": 2201,
"events": 7892,
"pty_bytes": 53455,
"pty_writes": 26,
"rss_kb": 630268,
"pss_kb": 586464,
"private_dirty_kb": 569860,
"vmhwm_kb": 630344,
"utime_ticks": 418,
"stime_ticks": 25,
"cg_current": 3192377344,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 4676,
"msgs": 2301,
"events": 8254,
"pty_bytes": 53455,
"pty_writes": 26,
"rss_kb": 630488,
"pss_kb": 586684,
"private_dirty_kb": 570080,
"vmhwm_kb": 630532,
"utime_ticks": 418,
"stime_ticks": 25,
"cg_current": 3192377344,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 5060,
"msgs": null,
"events": null,
"pty_bytes": 53455,
"pty_writes": 26,
"rss_kb": 751880,
"pss_kb": 708076,
"private_dirty_kb": 691472,
"vmhwm_kb": 751948,
"utime_ticks": 466,
"stime_ticks": 30,
"cg_current": 3192451072,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 5360,
"msgs": 2401,
"events": 8596,
"pty_bytes": 55789,
"pty_writes": 27,
"rss_kb": 760368,
"pss_kb": 716564,
"private_dirty_kb": 699960,
"vmhwm_kb": 760428,
"utime_ticks": 496,
"stime_ticks": 30,
"cg_current": 3192664064,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 6061,
"msgs": null,
"events": null,
"pty_bytes": 57896,
"pty_writes": 28,
"rss_kb": 791384,
"pss_kb": 747580,
"private_dirty_kb": 730976,
"vmhwm_kb": 791384,
"utime_ticks": 569,
"stime_ticks": 32,
"cg_current": 3193569280,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 6112,
"msgs": 2501,
"events": 8957,
"pty_bytes": 60544,
"pty_writes": 29,
"rss_kb": 794628,
"pss_kb": 750824,
"private_dirty_kb": 734220,
"vmhwm_kb": 794632,
"utime_ticks": 574,
"stime_ticks": 32,
"cg_current": 3193577472,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 6664,
"msgs": 2601,
"events": 9345,
"pty_bytes": 63209,
"pty_writes": 30,
"rss_kb": 821804,
"pss_kb": 778000,
"private_dirty_kb": 761396,
"vmhwm_kb": 821804,
"utime_ticks": 630,
"stime_ticks": 35,
"cg_current": 3193458688,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 7066,
"msgs": null,
"events": null,
"pty_bytes": 65585,
"pty_writes": 31,
"rss_kb": 847596,
"pss_kb": 803792,
"private_dirty_kb": 787188,
"vmhwm_kb": 847616,
"utime_ticks": 671,
"stime_ticks": 37,
"cg_current": 3193528320,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 7492,
"msgs": 2701,
"events": 9688,
"pty_bytes": 66999,
"pty_writes": 32,
"rss_kb": 852344,
"pss_kb": 808540,
"private_dirty_kb": 791936,
"vmhwm_kb": 852344,
"utime_ticks": 714,
"stime_ticks": 37,
"cg_current": 3193872384,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 8072,
"msgs": null,
"events": null,
"pty_bytes": 68029,
"pty_writes": 33,
"rss_kb": 881744,
"pss_kb": 837940,
"private_dirty_kb": 821336,
"vmhwm_kb": 881744,
"utime_ticks": 773,
"stime_ticks": 39,
"cg_current": 3194839040,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 8372,
"msgs": 2801,
"events": 10028,
"pty_bytes": 70059,
"pty_writes": 34,
"rss_kb": 883640,
"pss_kb": 839835,
"private_dirty_kb": 823232,
"vmhwm_kb": 883640,
"utime_ticks": 804,
"stime_ticks": 39,
"cg_current": 3199139840,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 9075,
"msgs": null,
"events": null,
"pty_bytes": 72544,
"pty_writes": 35,
"rss_kb": 915004,
"pss_kb": 871200,
"private_dirty_kb": 854596,
"vmhwm_kb": 915004,
"utime_ticks": 874,
"stime_ticks": 43,
"cg_current": 3200159744,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 9299,
"msgs": 2901,
"events": 10398,
"pty_bytes": 74276,
"pty_writes": 36,
"rss_kb": 915420,
"pss_kb": 871616,
"private_dirty_kb": 855012,
"vmhwm_kb": 915428,
"utime_ticks": 898,
"stime_ticks": 43,
"cg_current": 3199324160,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 9325,
"msgs": 3000,
"events": 10759,
"pty_bytes": 75521,
"pty_writes": 37,
"rss_kb": 917560,
"pss_kb": 873756,
"private_dirty_kb": 857152,
"vmhwm_kb": 917668,
"utime_ticks": 901,
"stime_ticks": 43,
"cg_current": 3199324160,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "done",
"t_ms": 9332,
"msgs": 3000,
"events": 10759,
"pty_bytes": 75521,
"pty_writes": 37,
"rss_kb": 917692,
"pss_kb": 873888,
"private_dirty_kb": 857284,
"vmhwm_kb": 917736,
"utime_ticks": 901,
"stime_ticks": 43,
"cg_current": 3199324160,
"cg_peak": 6536753152,
"cg_oom_kill": 0
}
],
"events": [
{
"kind": "rpc",
"method": "session.create",
"t_ms": 201
},
{
"kind": "rpc",
"method": "startup.catalog",
"t_ms": 201
},
{
"kind": "rpc",
"method": "model.options",
"t_ms": 201
}
],
"summary": {
"result": "crashed_after_stream",
"cap_hit": false,
"cap_hit_basis": null,
"at_messages": null,
"exit": {
"exitCode": 7,
"signal": 0,
"t": 9384
},
"stream_done": true,
"msgs_streamed": 3000,
"events_streamed": 10759,
"pty_bytes_total": 76784,
"pty_data_callbacks": 38,
"first_byte_ms": 153,
"session_create_ms": 201,
"stream_start_ms": 1709,
"vmhwm_kb": 917736,
"cg_peak": 6536753152,
"drain_max_loop_lag_ms": 33,
"drain_lag_violations": 3,
"drain_ok": false,
"digest": null
},
"pty_tail": "nes)⚡write_fileAliquip aliqua consectetur officias $ terminal Occaecat reprehenderit exercitation incididunt. · 1.7s (2 lines) ◇read_file Laboris magna amet culpa. · 1.8s (18 lines) ◦ edit_file Adipiscing deserunt nulla duis. · 1.9s (7 lines) ◦grep Excepteur irure nostrud tempor. · 2.0s (2 lines)●web_search Ullamco labore sit sunt. · 2.1s (18 lines)◆write_file Consectetur officia fugiat consequat. · 2.2s (7 lines) $ terminal Pariatur aute quis eiusmod. · 2.3s (2 lines) ⧉ copy ⚕ ⧉ copy $terminal Occaecat reprehenderit exercitation incididunt. · 1.7s (2 lines) ◇read_file Laboris magna amet culpa. · 1.8s (18 lines) ◦edit_file Adipiscing deserunt nulla duis. · 1.9s (7 lines) ◦grep Excepteur irure nostrud tempor. · 2.0s (2 lines) ●web_search Ullamco labore sit sunt. · 2.1s (18 lines) ◆write_file Consectetur officia fugiat consequat. · 2.2s (7 lines) $terminal Pariatur aute quis eiusmod. · 2.3s (2 lines) ⧉ copy ⚕ - - - const x2 = 97function f2() { return x}⧉ copy●6⧉ copy ⚕ ⧉ copy ⚕ ⧉ copy⚕▍ ◐⚕ - - - const x6 = 57function f2() { return x}⧉ copy⚕ - - - const x0 = 58function f3() { return x}⧉ copy⚕ ▼ Thinking: Veniam sed anim│ │ ◇read_file Aute quis eiusmod lorem. · 2.4s (18 lines) ◦edit_file Incididunt dolor proident velit. · 2.5s (7 lines) ◦grep Culpa cillum commodo enim. · 2.6s (2 lines) ● web_search Duis veniam sed anim. · 2.7s (18 lines) ◆ write_file Tempor ipsum cupidatat voluptate. · 2.8s (7 lines) $terminal Sunt esse aliquip aliqua. · 2.9s (2 lines) ◇ read_file Consequat minim elit mollit. · 3.0s (18 lines) ◦edit_file Eiusmod lorem occaecat reprehenderit. · 3.1s (7 lines) ⧉ copy ⚕ 7◇read_file Aute quis eiusmod lorem. · 2.4s (18 lines) ◦edit_file Incididunt dolor proident velit. · 2.5s (7 lines) ◦grep Culpa cillum commodo enim. · 2.6s (2 lines) ●web_search Duis veniam sed anim. · 2.7s (18 lines) ◆write_file Tempor ipsum cupidatat voluptate. · 2.8s (7 lines) $terminal Sunt esse aliquip aliqua. · 2.9s (2 lines) ◇read_file Consequat minim elit mollit. · 3.0s (18 lines) ◦edit_file Eiusmod lorem occaecat reprehenderit. · 3.1s (7 lines) ⧉ copy ⚕ - - - const x0 = 21function f1() { return x}⧉ copy● ⧉ copy ⚕ ◦ edit_file Occaecat reprehenderit exercitation incididunt. · 0.5s (7 lines) ◦grep Laboris magna amet culpa. · 0.6s (2 lines) ⚡web_search Adipiscing deserunt nulla duis. · 0s◐- - - const x6 = 83function f3() { return x}Occaecat reprehenderit exercitation incididunt dolor proident velit laboris magna amet culpa cillum commodo enim. Quis eiusmod lorem occaecat reprehenderit exercitation. Mollit pariatur aute quis eiusmod lorem occaecat.- Laboris magna amet culpa cillum.- Adipiscing deserunt nulla duis veniam sed anim.- Excepteur irure nostrud tempor.Ipsum cupidatat voluptate ullamco labore sit sunt esse aliquip aliqua consectetur officia fugiat. Irure nostrud tempor ipsum cupidatat voluptate ullamco labore sit sunt esse aliquip aliqua consectetur.● web_search.7s (18 lines)9file:///home/daimon/github/worktrees/hermes-agent/lively-thrush/hermes-agent/ui-opentui/node_modules/@opentui/core/index-59t85rvq.js:14915\n throw new Error(`Failed to create optimized buffer: ${width}x${height}`);\n ^\nError: Failed to create optimized buffer: 120x12\n at FFIRenderLib.createOptimizedBuffer (file:///home/daimon/github/worktrees/hermes-agent/lively-thrush/hermes-agent/ui-opentui/node_modules/@opentui/core/index-59t85rvq.js:14915:13)\n at OptimizedBuffer.create (file:///home/daimon/github/worktrees/hermes-agent/lively-thrush/hermes-agent/ui-opentui/node_modules/@opentui/core/index-59t85rvq.js:11918:24)\n at TerminalConsole.show (file:///home/daimon/github/worktrees/hermes-agent/lively-thrush/hermes-agent/ui-opentui/node_modules/@opentui/core/index-59t85rvq.js:21058:44)\n at CliRenderer.<anonymous> (file:///home/daimon/github/worktrees/hermes-agent/lively-thrush/hermes-agent/ui-opentui/node_modules/@opentui/core/index-59t85rvq.js:23222:20)\n at process.emit (node:events:509:20)\n at process._fatalException (node:internal/process/execution:190:32)\nNode.js v26.3.0"
}

View File

@@ -0,0 +1,765 @@
{
"meta": {
"cell": "mem3000",
"ui": "opentui",
"config": "otui-capped",
"mode": "mem",
"rep": 1,
"run_id": "mq8jyfi3-9iyw",
"utc": "2026-06-10T20:59:59.356Z",
"sha": "50e34713b",
"node": "/home/daimon/.local/share/fnm/node-versions/v26.3.0/installation/bin/node",
"node_version": "v26.3.0",
"pty": {
"cols": 120,
"rows": 40,
"term": "xterm-256color"
},
"heap_mb": 8192,
"memory_max": "2G",
"container_cap": false,
"container_memory": null,
"opentui_cap": 3000,
"fixture": {
"path": "/home/daimon/github/worktrees/hermes-agent/lively-thrush/hermes-agent/bench/.cache/fixture-3000.ndjson",
"msgs": 3000,
"sha256": "0df05a04a611dda68aa07865f21c45b08edc78e0a71d4c8cb2b674729778d96d"
},
"sample_every": 100,
"mode_params": {},
"ui_pid": 2369510,
"gw_pid": 2369523,
"cgroup": "/sys/fs/cgroup/user.slice/user-1001.slice/session-7349.scope",
"load_avg_at_start": [
0.85,
0.57,
0.47
],
"instrumented": false
},
"samples": [
{
"kind": "periodic",
"t_ms": 25,
"msgs": null,
"events": null,
"pty_bytes": 0,
"pty_writes": 0,
"rss_kb": 26620,
"pss_kb": 9806,
"private_dirty_kb": 3108,
"vmhwm_kb": 26620,
"utime_ticks": 0,
"stime_ticks": 0,
"cg_current": 3198296064,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 1033,
"msgs": null,
"events": null,
"pty_bytes": 28911,
"pty_writes": 11,
"rss_kb": 105328,
"pss_kb": 64253,
"private_dirty_kb": 48596,
"vmhwm_kb": 107756,
"utime_ticks": 20,
"stime_ticks": 2,
"cg_current": 3198902272,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 1692,
"msgs": 100,
"events": 355,
"pty_bytes": 31413,
"pty_writes": 14,
"rss_kb": 106572,
"pss_kb": 65476,
"private_dirty_kb": 49840,
"vmhwm_kb": 107756,
"utime_ticks": 21,
"stime_ticks": 2,
"cg_current": 3199021056,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 1721,
"msgs": 200,
"events": 738,
"pty_bytes": 31413,
"pty_writes": 14,
"rss_kb": 117112,
"pss_kb": 75936,
"private_dirty_kb": 60252,
"vmhwm_kb": 117112,
"utime_ticks": 27,
"stime_ticks": 3,
"cg_current": 3199021056,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 1722,
"msgs": 300,
"events": 1051,
"pty_bytes": 31413,
"pty_writes": 14,
"rss_kb": 117120,
"pss_kb": 75944,
"private_dirty_kb": 60260,
"vmhwm_kb": 117124,
"utime_ticks": 28,
"stime_ticks": 3,
"cg_current": 3199021056,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 1723,
"msgs": 400,
"events": 1432,
"pty_bytes": 31413,
"pty_writes": 14,
"rss_kb": 117148,
"pss_kb": 75972,
"private_dirty_kb": 60288,
"vmhwm_kb": 117152,
"utime_ticks": 28,
"stime_ticks": 3,
"cg_current": 3199021056,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 1724,
"msgs": 501,
"events": 1792,
"pty_bytes": 31413,
"pty_writes": 14,
"rss_kb": 117160,
"pss_kb": 75984,
"private_dirty_kb": 60300,
"vmhwm_kb": 117164,
"utime_ticks": 28,
"stime_ticks": 3,
"cg_current": 3199021056,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 2046,
"msgs": null,
"events": null,
"pty_bytes": 31413,
"pty_writes": 14,
"rss_kb": 240808,
"pss_kb": 197341,
"private_dirty_kb": 180876,
"vmhwm_kb": 240824,
"utime_ticks": 81,
"stime_ticks": 7,
"cg_current": 3199107072,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 2245,
"msgs": 601,
"events": 2154,
"pty_bytes": 37880,
"pty_writes": 17,
"rss_kb": 257716,
"pss_kb": 214080,
"private_dirty_kb": 197536,
"vmhwm_kb": 257812,
"utime_ticks": 106,
"stime_ticks": 8,
"cg_current": 3199311872,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 2422,
"msgs": 701,
"events": 2496,
"pty_bytes": 40363,
"pty_writes": 19,
"rss_kb": 290072,
"pss_kb": 246426,
"private_dirty_kb": 229892,
"vmhwm_kb": 290428,
"utime_ticks": 128,
"stime_ticks": 9,
"cg_current": 3200040960,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 2447,
"msgs": 801,
"events": 2857,
"pty_bytes": 41355,
"pty_writes": 20,
"rss_kb": 298000,
"pss_kb": 254285,
"private_dirty_kb": 237692,
"vmhwm_kb": 298144,
"utime_ticks": 136,
"stime_ticks": 9,
"cg_current": 3199799296,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 2450,
"msgs": 901,
"events": 3245,
"pty_bytes": 41355,
"pty_writes": 20,
"rss_kb": 298400,
"pss_kb": 254685,
"private_dirty_kb": 238092,
"vmhwm_kb": 297592,
"utime_ticks": 137,
"stime_ticks": 9,
"cg_current": 3199799296,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 2452,
"msgs": 1001,
"events": 3588,
"pty_bytes": 41355,
"pty_writes": 20,
"rss_kb": 295160,
"pss_kb": 251445,
"private_dirty_kb": 234852,
"vmhwm_kb": 297592,
"utime_ticks": 137,
"stime_ticks": 9,
"cg_current": 3199799296,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 2454,
"msgs": 1101,
"events": 3928,
"pty_bytes": 41355,
"pty_writes": 20,
"rss_kb": 295568,
"pss_kb": 251853,
"private_dirty_kb": 235260,
"vmhwm_kb": 297592,
"utime_ticks": 137,
"stime_ticks": 9,
"cg_current": 3199799296,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 2456,
"msgs": 1201,
"events": 4298,
"pty_bytes": 41355,
"pty_writes": 20,
"rss_kb": 295800,
"pss_kb": 252085,
"private_dirty_kb": 235492,
"vmhwm_kb": 297592,
"utime_ticks": 137,
"stime_ticks": 9,
"cg_current": 3199799296,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 2457,
"msgs": 1300,
"events": 4659,
"pty_bytes": 41355,
"pty_writes": 20,
"rss_kb": 296056,
"pss_kb": 252341,
"private_dirty_kb": 235748,
"vmhwm_kb": 297592,
"utime_ticks": 138,
"stime_ticks": 9,
"cg_current": 3199799296,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 2903,
"msgs": 1400,
"events": 5011,
"pty_bytes": 41355,
"pty_writes": 20,
"rss_kb": 468980,
"pss_kb": 425153,
"private_dirty_kb": 408548,
"vmhwm_kb": 468996,
"utime_ticks": 235,
"stime_ticks": 18,
"cg_current": 3200602112,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 3054,
"msgs": null,
"events": null,
"pty_bytes": 41355,
"pty_writes": 20,
"rss_kb": 499912,
"pss_kb": 456085,
"private_dirty_kb": 439480,
"vmhwm_kb": 499928,
"utime_ticks": 250,
"stime_ticks": 18,
"cg_current": 3200593920,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 3534,
"msgs": 1500,
"events": 5384,
"pty_bytes": 44170,
"pty_writes": 22,
"rss_kb": 534956,
"pss_kb": 491129,
"private_dirty_kb": 474524,
"vmhwm_kb": 534956,
"utime_ticks": 300,
"stime_ticks": 20,
"cg_current": 3201155072,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 3684,
"msgs": 1600,
"events": 5730,
"pty_bytes": 46055,
"pty_writes": 23,
"rss_kb": 535820,
"pss_kb": 491993,
"private_dirty_kb": 475388,
"vmhwm_kb": 535868,
"utime_ticks": 317,
"stime_ticks": 20,
"cg_current": 3200622592,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 4060,
"msgs": null,
"events": null,
"pty_bytes": 48276,
"pty_writes": 24,
"rss_kb": 565124,
"pss_kb": 521297,
"private_dirty_kb": 504692,
"vmhwm_kb": 565124,
"utime_ticks": 355,
"stime_ticks": 23,
"cg_current": 3201978368,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 4237,
"msgs": 1700,
"events": 6100,
"pty_bytes": 50759,
"pty_writes": 25,
"rss_kb": 570876,
"pss_kb": 527049,
"private_dirty_kb": 510444,
"vmhwm_kb": 570904,
"utime_ticks": 375,
"stime_ticks": 23,
"cg_current": 3201060864,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 4787,
"msgs": 1800,
"events": 6455,
"pty_bytes": 55578,
"pty_writes": 27,
"rss_kb": 600836,
"pss_kb": 557009,
"private_dirty_kb": 540404,
"vmhwm_kb": 601100,
"utime_ticks": 432,
"stime_ticks": 25,
"cg_current": 3201822720,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 5063,
"msgs": null,
"events": null,
"pty_bytes": 55578,
"pty_writes": 27,
"rss_kb": 630232,
"pss_kb": 586405,
"private_dirty_kb": 569800,
"vmhwm_kb": 630320,
"utime_ticks": 460,
"stime_ticks": 28,
"cg_current": 3202007040,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 5392,
"msgs": 1900,
"events": 6838,
"pty_bytes": 60725,
"pty_writes": 29,
"rss_kb": 632396,
"pss_kb": 588569,
"private_dirty_kb": 571964,
"vmhwm_kb": 632616,
"utime_ticks": 493,
"stime_ticks": 28,
"cg_current": 3202117632,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 5844,
"msgs": 2000,
"events": 7151,
"pty_bytes": 63787,
"pty_writes": 30,
"rss_kb": 663860,
"pss_kb": 620033,
"private_dirty_kb": 603428,
"vmhwm_kb": 663860,
"utime_ticks": 539,
"stime_ticks": 30,
"cg_current": 3202842624,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 6070,
"msgs": null,
"events": null,
"pty_bytes": 66765,
"pty_writes": 31,
"rss_kb": 668208,
"pss_kb": 624381,
"private_dirty_kb": 607776,
"vmhwm_kb": 668252,
"utime_ticks": 562,
"stime_ticks": 31,
"cg_current": 3203407872,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 6520,
"msgs": 2100,
"events": 7532,
"pty_bytes": 68991,
"pty_writes": 32,
"rss_kb": 693816,
"pss_kb": 649989,
"private_dirty_kb": 633384,
"vmhwm_kb": 693816,
"utime_ticks": 608,
"stime_ticks": 33,
"cg_current": 3203493888,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 7075,
"msgs": null,
"events": null,
"pty_bytes": 71150,
"pty_writes": 33,
"rss_kb": 723308,
"pss_kb": 679481,
"private_dirty_kb": 662876,
"vmhwm_kb": 723308,
"utime_ticks": 665,
"stime_ticks": 35,
"cg_current": 3203358720,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 7226,
"msgs": 2201,
"events": 7892,
"pty_bytes": 72878,
"pty_writes": 34,
"rss_kb": 723708,
"pss_kb": 679881,
"private_dirty_kb": 663276,
"vmhwm_kb": 723708,
"utime_ticks": 681,
"stime_ticks": 35,
"cg_current": 3203919872,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 7377,
"msgs": 2301,
"events": 8254,
"pty_bytes": 73927,
"pty_writes": 35,
"rss_kb": 727704,
"pss_kb": 683877,
"private_dirty_kb": 667272,
"vmhwm_kb": 727712,
"utime_ticks": 696,
"stime_ticks": 35,
"cg_current": 3203964928,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 7878,
"msgs": 2401,
"events": 8596,
"pty_bytes": 76342,
"pty_writes": 37,
"rss_kb": 755644,
"pss_kb": 711817,
"private_dirty_kb": 695212,
"vmhwm_kb": 755748,
"utime_ticks": 750,
"stime_ticks": 37,
"cg_current": 3204108288,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 7903,
"msgs": 2501,
"events": 8957,
"pty_bytes": 76342,
"pty_writes": 37,
"rss_kb": 758700,
"pss_kb": 714873,
"private_dirty_kb": 698268,
"vmhwm_kb": 758712,
"utime_ticks": 752,
"stime_ticks": 37,
"cg_current": 3204087808,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 7908,
"msgs": 2601,
"events": 9345,
"pty_bytes": 76342,
"pty_writes": 37,
"rss_kb": 758724,
"pss_kb": 714897,
"private_dirty_kb": 698292,
"vmhwm_kb": 758748,
"utime_ticks": 752,
"stime_ticks": 37,
"cg_current": 3204087808,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 8078,
"msgs": null,
"events": null,
"pty_bytes": 76342,
"pty_writes": 37,
"rss_kb": 820220,
"pss_kb": 776393,
"private_dirty_kb": 759788,
"vmhwm_kb": 820220,
"utime_ticks": 774,
"stime_ticks": 40,
"cg_current": 3204931584,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 8532,
"msgs": 2701,
"events": 9688,
"pty_bytes": 77954,
"pty_writes": 38,
"rss_kb": 846112,
"pss_kb": 802285,
"private_dirty_kb": 785680,
"vmhwm_kb": 846184,
"utime_ticks": 819,
"stime_ticks": 41,
"cg_current": 3204521984,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 9009,
"msgs": 2801,
"events": 10028,
"pty_bytes": 78613,
"pty_writes": 39,
"rss_kb": 868832,
"pss_kb": 825004,
"private_dirty_kb": 808400,
"vmhwm_kb": 868832,
"utime_ticks": 867,
"stime_ticks": 43,
"cg_current": 3215708160,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 9085,
"msgs": null,
"events": null,
"pty_bytes": 78613,
"pty_writes": 39,
"rss_kb": 869044,
"pss_kb": 825216,
"private_dirty_kb": 808612,
"vmhwm_kb": 869044,
"utime_ticks": 875,
"stime_ticks": 43,
"cg_current": 3212128256,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 9913,
"msgs": 2901,
"events": 10398,
"pty_bytes": 81674,
"pty_writes": 41,
"rss_kb": 901240,
"pss_kb": 857412,
"private_dirty_kb": 840808,
"vmhwm_kb": 901240,
"utime_ticks": 959,
"stime_ticks": 46,
"cg_current": 3172499456,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 10088,
"msgs": null,
"events": null,
"pty_bytes": 81674,
"pty_writes": 41,
"rss_kb": 901584,
"pss_kb": 857756,
"private_dirty_kb": 841152,
"vmhwm_kb": 901584,
"utime_ticks": 977,
"stime_ticks": 46,
"cg_current": 3173011456,
"cg_peak": 6536753152,
"cg_oom_kill": 0
}
],
"events": [
{
"kind": "rpc",
"method": "session.create",
"t_ms": 201
},
{
"kind": "rpc",
"method": "startup.catalog",
"t_ms": 201
},
{
"kind": "rpc",
"method": "model.options",
"t_ms": 201
}
],
"summary": {
"result": "died",
"cap_hit": false,
"cap_hit_basis": null,
"at_messages": null,
"exit": {
"exitCode": 7,
"signal": 0,
"t": 10302
},
"stream_done": false,
"msgs_streamed": 2901,
"events_streamed": null,
"pty_bytes_total": 83955,
"pty_data_callbacks": 44,
"first_byte_ms": 149,
"session_create_ms": 201,
"stream_start_ms": 1691,
"vmhwm_kb": 901584,
"cg_peak": 6536753152,
"drain_max_loop_lag_ms": 10,
"drain_lag_violations": 0,
"drain_ok": true,
"digest": null
},
"pty_tail": " lines) ◦ edit_fileOfficia fugia consequat minim. ·0.7s (7 lines)const x0 = 43function f3() { return x}⧉ copy⚕ ▼ Thinking: Irure nostrud tempor│Culpa cillum commodo enim adipiscing deserunt nulla duis veniam sed anim. Laboris magna amet culpa cillum │ commodo enim adipiscing deserunt nulla duis veniam. Irure nostrud tempor ipsum cupidatat voluptate.- Labore sit sunt esse aliquip.- Officia fugiat consequat minim elit mollit pariatur.- Aute quis eiusmod lorem.Occaecat reprehenderit exercitation incididunt dolor proident velit laboris magna amet culpa cillum commodo enim. Quis eiusmod lorem occaecat reprehenderit exercitation.$terminal Irure nostrud tempor ipsum. · 0.5s (2 lines)◇rad_fie Labore it sunt esse. · 0.6s (18lines) ◦editOfficia fugiat conquatminim. ·0.7s (7 lines)⚡grep Autequis eiusmod lorem. · 0s ⧉ copy ⚕ ⧉ copy ⚕▼Thinking: Elit mollit pariatur││ ⧉ copy ⚕- - - const x0 = 6function f1() { return x} ⧉ copy ⚕◐Thought: Elit mollit pariatur - - - const x1 = 7function f2() { return x}⧉ copy●7 ⧉ copy ⚕ ● web_search Quis eiusmod lorem occaecat. · 0.9s (18 lines) ◆write_file Dolor proident velit laboris. · 1.0s (7 lines) ⚡terminal Cillum commodo enim adipiscing. · 0s◐- - - const x5 = 67function f2() { return x}Quis eiusmod lorem occaecat reprehenderit exercitation incididunt dolor proident velit laboris magna amet. Mollit pariatur aute quis eiusmod lorem occaecat reprehenderit exercitation incididunt dolor proident velit laboris.- Dolor proident velit laboris magna.- Cillum commodo enim adipiscing deserunt nulla duis.- Veniam sed anim excepteur.Irure nostrud tempor ipsum cupidatat voluptate ullamco labore sit sunt esse aliquip. Sed anim excepteur irure nostrud tempor ipsum cupidatat voluptate ullamco labore sit sunt.$ terminal1.1s (2 lines)⚕ ⧉ copy ⚕◐Thought: Exercitation incididunt dolor ⧉ copy ⚕▍ ⚕◐Thought: Amet culpa cillum ⧉ copy ⚕ ◦edit_file Nostrud tempor ipsum cupidatat. · 0.1s (7 lines)8 - - - const x5 = 98function f3() { return x}⧉ copy⚕Nostrud tempor ipsum cupidatat voluptate ullamco labore sit sunt esse aliquip. Anim excepteur irure nostrud tempor ipsum cupidatat voluptate ullamco labore sit sunt. Duis veniam sed anim excepteur irure nostrud tempor ipsum cupidatat voluptate ullamco labore.- Sit sunt esse aliquip aliqua.- Fugiat consequat minim elit mollit pariatur aute.- Quis eiusmod lorem occaecat.Reprehenderit exercitation incididunt dolor proident velit laboris magna amet culpa. Eiusmod lorem occaecat reprehenderit exercitation incididunt dolor proident velit laboris magna.◦edit_file Nostrud tempor ipsum cupidatat. · 0.1s (7 lines)⚡grep Sitsunt esse aliquip. ·0s ⚕ ⧉ copy ⚕▼Thinking: Reprehenderit exercitation incididunt │ │ ⚕- - - const x0 = 63function f3() { return x} ⧉ copy⚕▼Thinking: Reprehenderit exercitation incididunt│Officia fugiat consequat minim elit mollit pariatur aute. Aliquip aliqua consectetur officia fugiat consequat│minim elit mollit. 9file:///home/daimon/github/worktrees/hermes-agent/lively-thrush/hermes-agent/ui-opentui/node_modules/@opentui/core/index-59t85rvq.js:14915\n throw new Error(`Failed to create optimized buffer: ${width}x${height}`);\n ^\nError: Failed to create optimized buffer: 120x12\n at FFIRenderLib.createOptimizedBuffer (file:///home/daimon/github/worktrees/hermes-agent/lively-thrush/hermes-agent/ui-opentui/node_modules/@opentui/core/index-59t85rvq.js:14915:13)\n at OptimizedBuffer.create (file:///home/daimon/github/worktrees/hermes-agent/lively-thrush/hermes-agent/ui-opentui/node_modules/@opentui/core/index-59t85rvq.js:11918:24)\n at TerminalConsole.show (file:///home/daimon/github/worktrees/hermes-agent/lively-thrush/hermes-agent/ui-opentui/node_modules/@opentui/core/index-59t85rvq.js:21058:44)\n at CliRenderer.<anonymous> (file:///home/daimon/github/worktrees/hermes-agent/lively-thrush/hermes-agent/ui-opentui/node_modules/@opentui/core/index-59t85rvq.js:23222:20)\n at process.emit (node:events:509:20)\n at process._fatalException (node:internal/process/execution:190:32)\nNode.js v26.3.0"
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,782 @@
{
"meta": {
"cell": "mem3000",
"ui": "opentui",
"config": "otui-capped",
"mode": "mem",
"rep": 2,
"run_id": "mq8jyv88-jxa2",
"utc": "2026-06-10T21:00:19.736Z",
"sha": "50e34713b",
"node": "/home/daimon/.local/share/fnm/node-versions/v26.3.0/installation/bin/node",
"node_version": "v26.3.0",
"pty": {
"cols": 120,
"rows": 40,
"term": "xterm-256color"
},
"heap_mb": 8192,
"memory_max": "2G",
"container_cap": false,
"container_memory": null,
"opentui_cap": 3000,
"fixture": {
"path": "/home/daimon/github/worktrees/hermes-agent/lively-thrush/hermes-agent/bench/.cache/fixture-3000.ndjson",
"msgs": 3000,
"sha256": "0df05a04a611dda68aa07865f21c45b08edc78e0a71d4c8cb2b674729778d96d"
},
"sample_every": 100,
"mode_params": {},
"ui_pid": 2369887,
"gw_pid": 2369896,
"cgroup": "/sys/fs/cgroup/user.slice/user-1001.slice/session-7349.scope",
"load_avg_at_start": [
1.06,
0.64,
0.49
],
"instrumented": false
},
"samples": [
{
"kind": "periodic",
"t_ms": 27,
"msgs": null,
"events": null,
"pty_bytes": 0,
"pty_writes": 0,
"rss_kb": 44476,
"pss_kb": 19055,
"private_dirty_kb": 8312,
"vmhwm_kb": 45344,
"utime_ticks": 1,
"stime_ticks": 0,
"cg_current": 3178012672,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 1034,
"msgs": null,
"events": null,
"pty_bytes": 28911,
"pty_writes": 12,
"rss_kb": 104504,
"pss_kb": 63459,
"private_dirty_kb": 47832,
"vmhwm_kb": 107224,
"utime_ticks": 17,
"stime_ticks": 2,
"cg_current": 3179597824,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 1689,
"msgs": 100,
"events": 355,
"pty_bytes": 31413,
"pty_writes": 14,
"rss_kb": 108328,
"pss_kb": 67246,
"private_dirty_kb": 51656,
"vmhwm_kb": 108332,
"utime_ticks": 18,
"stime_ticks": 3,
"cg_current": 3185025024,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 1714,
"msgs": 200,
"events": 738,
"pty_bytes": 31413,
"pty_writes": 14,
"rss_kb": 120484,
"pss_kb": 79217,
"private_dirty_kb": 63496,
"vmhwm_kb": 120492,
"utime_ticks": 23,
"stime_ticks": 3,
"cg_current": 3186782208,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 1715,
"msgs": 300,
"events": 1051,
"pty_bytes": 31413,
"pty_writes": 14,
"rss_kb": 120512,
"pss_kb": 79245,
"private_dirty_kb": 63524,
"vmhwm_kb": 120520,
"utime_ticks": 23,
"stime_ticks": 3,
"cg_current": 3186782208,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 1716,
"msgs": 400,
"events": 1432,
"pty_bytes": 31413,
"pty_writes": 14,
"rss_kb": 120520,
"pss_kb": 79253,
"private_dirty_kb": 63532,
"vmhwm_kb": 120520,
"utime_ticks": 24,
"stime_ticks": 3,
"cg_current": 3186782208,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 1990,
"msgs": 501,
"events": 1792,
"pty_bytes": 31413,
"pty_writes": 14,
"rss_kb": 221328,
"pss_kb": 177861,
"private_dirty_kb": 161396,
"vmhwm_kb": 221404,
"utime_ticks": 74,
"stime_ticks": 6,
"cg_current": 3179270144,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 2041,
"msgs": null,
"events": null,
"pty_bytes": 31413,
"pty_writes": 14,
"rss_kb": 232640,
"pss_kb": 189143,
"private_dirty_kb": 172648,
"vmhwm_kb": 232648,
"utime_ticks": 80,
"stime_ticks": 7,
"cg_current": 3179270144,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 2266,
"msgs": 601,
"events": 2154,
"pty_bytes": 35690,
"pty_writes": 16,
"rss_kb": 265936,
"pss_kb": 222429,
"private_dirty_kb": 205944,
"vmhwm_kb": 265936,
"utime_ticks": 108,
"stime_ticks": 9,
"cg_current": 3176169472,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 2527,
"msgs": 701,
"events": 2496,
"pty_bytes": 40354,
"pty_writes": 19,
"rss_kb": 304772,
"pss_kb": 261057,
"private_dirty_kb": 244464,
"vmhwm_kb": 305004,
"utime_ticks": 143,
"stime_ticks": 11,
"cg_current": 3176157184,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 2531,
"msgs": 801,
"events": 2857,
"pty_bytes": 40354,
"pty_writes": 19,
"rss_kb": 305492,
"pss_kb": 261777,
"private_dirty_kb": 245184,
"vmhwm_kb": 305504,
"utime_ticks": 144,
"stime_ticks": 11,
"cg_current": 3176157184,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 2533,
"msgs": 901,
"events": 3245,
"pty_bytes": 40354,
"pty_writes": 19,
"rss_kb": 306248,
"pss_kb": 262533,
"private_dirty_kb": 245940,
"vmhwm_kb": 306616,
"utime_ticks": 145,
"stime_ticks": 11,
"cg_current": 3176157184,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 2535,
"msgs": 1001,
"events": 3588,
"pty_bytes": 40354,
"pty_writes": 19,
"rss_kb": 308884,
"pss_kb": 265169,
"private_dirty_kb": 248576,
"vmhwm_kb": 308916,
"utime_ticks": 146,
"stime_ticks": 11,
"cg_current": 3176157184,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 2537,
"msgs": 1101,
"events": 3928,
"pty_bytes": 40354,
"pty_writes": 19,
"rss_kb": 309176,
"pss_kb": 265461,
"private_dirty_kb": 248868,
"vmhwm_kb": 309192,
"utime_ticks": 146,
"stime_ticks": 11,
"cg_current": 3176157184,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 2539,
"msgs": 1201,
"events": 4298,
"pty_bytes": 40354,
"pty_writes": 19,
"rss_kb": 309444,
"pss_kb": 265729,
"private_dirty_kb": 249136,
"vmhwm_kb": 309492,
"utime_ticks": 147,
"stime_ticks": 11,
"cg_current": 3176157184,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 3050,
"msgs": null,
"events": null,
"pty_bytes": 40354,
"pty_writes": 19,
"rss_kb": 460952,
"pss_kb": 417124,
"private_dirty_kb": 400520,
"vmhwm_kb": 460952,
"utime_ticks": 231,
"stime_ticks": 20,
"cg_current": 3176210432,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 3126,
"msgs": 1300,
"events": 4659,
"pty_bytes": 43401,
"pty_writes": 20,
"rss_kb": 463816,
"pss_kb": 419988,
"private_dirty_kb": 403384,
"vmhwm_kb": 463832,
"utime_ticks": 239,
"stime_ticks": 20,
"cg_current": 3175964672,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 3427,
"msgs": 1400,
"events": 5011,
"pty_bytes": 46365,
"pty_writes": 21,
"rss_kb": 493888,
"pss_kb": 450060,
"private_dirty_kb": 433456,
"vmhwm_kb": 493964,
"utime_ticks": 273,
"stime_ticks": 22,
"cg_current": 3175645184,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 3901,
"msgs": 1500,
"events": 5384,
"pty_bytes": 51265,
"pty_writes": 23,
"rss_kb": 525952,
"pss_kb": 482124,
"private_dirty_kb": 465520,
"vmhwm_kb": 525952,
"utime_ticks": 323,
"stime_ticks": 24,
"cg_current": 3176116224,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 4057,
"msgs": null,
"events": null,
"pty_bytes": 51265,
"pty_writes": 23,
"rss_kb": 526368,
"pss_kb": 482540,
"private_dirty_kb": 465936,
"vmhwm_kb": 526368,
"utime_ticks": 339,
"stime_ticks": 24,
"cg_current": 3176116224,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 4430,
"msgs": 1600,
"events": 5730,
"pty_bytes": 53320,
"pty_writes": 25,
"rss_kb": 557660,
"pss_kb": 513832,
"private_dirty_kb": 497228,
"vmhwm_kb": 557660,
"utime_ticks": 382,
"stime_ticks": 25,
"cg_current": 3175940096,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 5006,
"msgs": 1700,
"events": 6100,
"pty_bytes": 57903,
"pty_writes": 28,
"rss_kb": 578676,
"pss_kb": 534848,
"private_dirty_kb": 518244,
"vmhwm_kb": 578704,
"utime_ticks": 465,
"stime_ticks": 27,
"cg_current": 3175710720,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 5009,
"msgs": 1800,
"events": 6455,
"pty_bytes": 57903,
"pty_writes": 28,
"rss_kb": 578944,
"pss_kb": 535116,
"private_dirty_kb": 518512,
"vmhwm_kb": 579020,
"utime_ticks": 465,
"stime_ticks": 27,
"cg_current": 3175710720,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 5012,
"msgs": 1900,
"events": 6838,
"pty_bytes": 57903,
"pty_writes": 28,
"rss_kb": 579192,
"pss_kb": 535364,
"private_dirty_kb": 518760,
"vmhwm_kb": 579280,
"utime_ticks": 465,
"stime_ticks": 27,
"cg_current": 3175710720,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 5015,
"msgs": 2000,
"events": 7151,
"pty_bytes": 57903,
"pty_writes": 28,
"rss_kb": 579412,
"pss_kb": 535584,
"private_dirty_kb": 518980,
"vmhwm_kb": 579512,
"utime_ticks": 465,
"stime_ticks": 27,
"cg_current": 3175710720,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 5017,
"msgs": 2100,
"events": 7532,
"pty_bytes": 57903,
"pty_writes": 28,
"rss_kb": 579704,
"pss_kb": 535876,
"private_dirty_kb": 519272,
"vmhwm_kb": 579748,
"utime_ticks": 466,
"stime_ticks": 27,
"cg_current": 3175710720,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 5020,
"msgs": 2201,
"events": 7892,
"pty_bytes": 57903,
"pty_writes": 28,
"rss_kb": 579840,
"pss_kb": 536012,
"private_dirty_kb": 519408,
"vmhwm_kb": 579892,
"utime_ticks": 466,
"stime_ticks": 27,
"cg_current": 3175710720,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 5023,
"msgs": 2301,
"events": 8254,
"pty_bytes": 57903,
"pty_writes": 28,
"rss_kb": 580160,
"pss_kb": 536332,
"private_dirty_kb": 519728,
"vmhwm_kb": 580244,
"utime_ticks": 467,
"stime_ticks": 27,
"cg_current": 3175710720,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 5056,
"msgs": null,
"events": null,
"pty_bytes": 57903,
"pty_writes": 28,
"rss_kb": 590484,
"pss_kb": 546656,
"private_dirty_kb": 530052,
"vmhwm_kb": 590492,
"utime_ticks": 471,
"stime_ticks": 27,
"cg_current": 3175710720,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 5735,
"msgs": 2401,
"events": 8596,
"pty_bytes": 60575,
"pty_writes": 29,
"rss_kb": 733132,
"pss_kb": 689304,
"private_dirty_kb": 672700,
"vmhwm_kb": 733140,
"utime_ticks": 550,
"stime_ticks": 33,
"cg_current": 3175919616,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 6059,
"msgs": null,
"events": null,
"pty_bytes": 60575,
"pty_writes": 29,
"rss_kb": 765664,
"pss_kb": 721836,
"private_dirty_kb": 705232,
"vmhwm_kb": 765664,
"utime_ticks": 584,
"stime_ticks": 35,
"cg_current": 3176443904,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 6460,
"msgs": 2501,
"events": 8957,
"pty_bytes": 63490,
"pty_writes": 31,
"rss_kb": 769720,
"pss_kb": 725892,
"private_dirty_kb": 709288,
"vmhwm_kb": 769736,
"utime_ticks": 624,
"stime_ticks": 35,
"cg_current": 3176296448,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 7064,
"msgs": null,
"events": null,
"pty_bytes": 65023,
"pty_writes": 32,
"rss_kb": 797028,
"pss_kb": 753200,
"private_dirty_kb": 736596,
"vmhwm_kb": 797028,
"utime_ticks": 686,
"stime_ticks": 38,
"cg_current": 3176087552,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 7241,
"msgs": 2601,
"events": 9345,
"pty_bytes": 66694,
"pty_writes": 33,
"rss_kb": 798308,
"pss_kb": 754480,
"private_dirty_kb": 737876,
"vmhwm_kb": 798316,
"utime_ticks": 704,
"stime_ticks": 38,
"cg_current": 3175837696,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 7798,
"msgs": 2701,
"events": 9688,
"pty_bytes": 68453,
"pty_writes": 34,
"rss_kb": 825628,
"pss_kb": 781800,
"private_dirty_kb": 765196,
"vmhwm_kb": 825628,
"utime_ticks": 761,
"stime_ticks": 40,
"cg_current": 3176284160,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 8076,
"msgs": null,
"events": null,
"pty_bytes": 69629,
"pty_writes": 35,
"rss_kb": 831440,
"pss_kb": 787612,
"private_dirty_kb": 771008,
"vmhwm_kb": 834344,
"utime_ticks": 789,
"stime_ticks": 40,
"cg_current": 3176284160,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 8654,
"msgs": 2801,
"events": 10028,
"pty_bytes": 71439,
"pty_writes": 36,
"rss_kb": 856908,
"pss_kb": 813080,
"private_dirty_kb": 796476,
"vmhwm_kb": 856908,
"utime_ticks": 847,
"stime_ticks": 42,
"cg_current": 3176873984,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 9080,
"msgs": null,
"events": null,
"pty_bytes": 74354,
"pty_writes": 37,
"rss_kb": 885440,
"pss_kb": 841612,
"private_dirty_kb": 825008,
"vmhwm_kb": 885440,
"utime_ticks": 890,
"stime_ticks": 45,
"cg_current": 3177000960,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 9505,
"msgs": 2901,
"events": 10398,
"pty_bytes": 76085,
"pty_writes": 38,
"rss_kb": 888096,
"pss_kb": 844268,
"private_dirty_kb": 827664,
"vmhwm_kb": 888096,
"utime_ticks": 934,
"stime_ticks": 45,
"cg_current": 3176677376,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 9807,
"msgs": 3000,
"events": 10759,
"pty_bytes": 76940,
"pty_writes": 39,
"rss_kb": 891124,
"pss_kb": 847296,
"private_dirty_kb": 830692,
"vmhwm_kb": 891164,
"utime_ticks": 965,
"stime_ticks": 45,
"cg_current": 3176747008,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "done",
"t_ms": 9814,
"msgs": 3000,
"events": 10759,
"pty_bytes": 76940,
"pty_writes": 39,
"rss_kb": 891240,
"pss_kb": 847412,
"private_dirty_kb": 830808,
"vmhwm_kb": 891248,
"utime_ticks": 965,
"stime_ticks": 45,
"cg_current": 3176747008,
"cg_peak": 6536753152,
"cg_oom_kill": 0
}
],
"events": [
{
"kind": "rpc",
"method": "session.create",
"t_ms": 177
},
{
"kind": "rpc",
"method": "startup.catalog",
"t_ms": 177
},
{
"kind": "rpc",
"method": "model.options",
"t_ms": 177
}
],
"summary": {
"result": "crashed_after_stream",
"cap_hit": false,
"cap_hit_basis": null,
"at_messages": null,
"exit": {
"exitCode": 7,
"signal": 0,
"t": 9862
},
"stream_done": true,
"msgs_streamed": 3000,
"events_streamed": 10759,
"pty_bytes_total": 78203,
"pty_data_callbacks": 40,
"first_byte_ms": 141,
"session_create_ms": 177,
"stream_start_ms": 1688,
"vmhwm_kb": 891248,
"cg_peak": 6536753152,
"drain_max_loop_lag_ms": 22,
"drain_lag_violations": 3,
"drain_ok": false,
"digest": null
},
"pty_tail": " commodo enim adipiscing.- Duis veniam sed anim excepteur irure nostrud.- Tempor ipsum cupidatat voluptate.Ullamco labore sit sunt esse aliquip aliqua consectetur. Ipsum cupidatat voluptate ullamco labore sit sunt esse aliquip.⚡terminal Incididunt dolor proident velit. · 0s◐Thought: Proident velit laboris ⧉ copy ⚕ ◦ edit_file Sed anim excepteu rure. · 13s (7 lines) - - - const x2 = 90function f0() { return x}⧉ copy⚕Sed anim excepteur irure nostrud tempor ipsum cupidatat voluptate ullamco labore. Nulla duis veniam sed anim excepteur irure nostrud tempor ipsum cupidatat voluptate. Enim adipiscing deserunt nulla duis veniam sed anim excepteur irure nostrud tempor ipsum.- Cupidatat voluptate ullamco labore sit.- Aliquip aliqua consectetur officia fugiat consequat minim.- Elit mollit pariatur aute.Quis eiusmod lorem occaecat reprehenderit exercitation incididunt dolor proident velit. Mollit pariatur aute quis eiusmod lorem occaecat reprehenderit exercitation incididunt dolor.◦edit_file Sed anim excepteur irure. · 1.3s (7 lines)⚡grep Cupidatat voluptate ullamco labore. ·0s 6 ⧉ copy⚕ ◦edit_file Nulla duis veniam sed. · 3.3s (7 lines) ◦ grep Nostrud tempor ipsum cupidatat. · 3.4s (2 lines) ●web_search Sit sunt esse aliquip. · 3.5s (18 lines) - const x0 = 51function f1() { return x}⧉ copy⚕ ⧉ copy◦edit_file Nulla duis veniam sed. · 3.3s (7 lines) ◦grep Nostrud tempor ipsum cupidatat. · 3.4s (2 lines) ●web_search Sit sunt esse aliquip. · 3.5s (18 lines) ⧉ copy● ⧉ copy ⚕ ▼ Thinking: Mollit pariatur aute ││ $terminal Mollit pariatur aute quis. · 1.7s (2 lines) ◇ read_file Reprehenderit exercitation incididunt dolor. · 1.8s (18 lines)Magnametculp cillum. · 1.9s (7 lines)Deent nulladuis veniam. · 2.0s (2lines) Irure ostrudtempor ipsum. ·2.s (18 lines)◆write_file Labore sit sunt esse. · 2.2s (7 lines) $ terminal Officia fugiat consequat minim. · 2.3s (2 lines)◐7⚕▼Thinking: Mollit pariatur aute │ Labore sit sunt esse aliquip aliqua consectetur officia fugiat consequat minim elit mollit pariatur. │Cupidatat voluptate ullamco labore sit sunt. Mollit pariatur aute quis eiusmod lorem occaecat reprehenderit exercitation. - Reprehenderit exercitation incididunt dolor proident.- Magna amet culpa cillum commodo enim adipiscing.- Deserunt nulla duis veniam.Sed anim excepteur irure nostrud tempor ipsum cupidatat. Nulla duis veniam sed anim excepteur irure nostrud tempor.$terminal Mollit pariatur aute quis. · 1.7s (2 lines)◇read_file Reprehenderit exercitation incididunt dolor. · 1.8s (18 lines) ◦edit_file Magna amet culpa cillum. · 1.9s (7 lines) ◦grep Deserunt nulla duis veniam. · 2.0s (2 lines) ●web_search Irure nostrud tempor ipsum. · 2.1s (18 lines) ◆write_file Labore sit sunt esse. · 2.2s (7 lines) $terminal Officia fugiat consequat minim. · 2.3s (2 lines) ⧉ copy ⚕ ⧉ copy ⚕ ⧉ copy ⚕ - - - const x4 = 74function f4() { return x}⧉ copy ⚕ - - - const x5 = 75function f0() { return x}⧉ copy●9file:///home/daimon/github/worktrees/hermes-agent/lively-thrush/hermes-agent/ui-opentui/node_modules/@opentui/core/index-59t85rvq.js:14915\n throw new Error(`Failed to create optimized buffer: ${width}x${height}`);\n ^\nError: Failed to create optimized buffer: 120x12\n at FFIRenderLib.createOptimizedBuffer (file:///home/daimon/github/worktrees/hermes-agent/lively-thrush/hermes-agent/ui-opentui/node_modules/@opentui/core/index-59t85rvq.js:14915:13)\n at OptimizedBuffer.create (file:///home/daimon/github/worktrees/hermes-agent/lively-thrush/hermes-agent/ui-opentui/node_modules/@opentui/core/index-59t85rvq.js:11918:24)\n at TerminalConsole.show (file:///home/daimon/github/worktrees/hermes-agent/lively-thrush/hermes-agent/ui-opentui/node_modules/@opentui/core/index-59t85rvq.js:21058:44)\n at CliRenderer.<anonymous> (file:///home/daimon/github/worktrees/hermes-agent/lively-thrush/hermes-agent/ui-opentui/node_modules/@opentui/core/index-59t85rvq.js:23222:20)\n at process.emit (node:events:509:20)\n at process._fatalException (node:internal/process/execution:190:32)\nNode.js v26.3.0"
}

View File

@@ -0,0 +1,816 @@
{
"meta": {
"cell": "mem3000",
"ui": "opentui",
"config": "otui-uncapped",
"mode": "mem",
"rep": 2,
"run_id": "mq8jzaoz-onls",
"utc": "2026-06-10T21:00:39.779Z",
"sha": "50e34713b",
"node": "/home/daimon/.local/share/fnm/node-versions/v26.3.0/installation/bin/node",
"node_version": "v26.3.0",
"pty": {
"cols": 120,
"rows": 40,
"term": "xterm-256color"
},
"heap_mb": 8192,
"memory_max": "2G",
"container_cap": false,
"container_memory": null,
"opentui_cap": 100000,
"fixture": {
"path": "/home/daimon/github/worktrees/hermes-agent/lively-thrush/hermes-agent/bench/.cache/fixture-3000.ndjson",
"msgs": 3000,
"sha256": "0df05a04a611dda68aa07865f21c45b08edc78e0a71d4c8cb2b674729778d96d"
},
"sample_every": 100,
"mode_params": {},
"ui_pid": 2370298,
"gw_pid": 2370307,
"cgroup": "/sys/fs/cgroup/user.slice/user-1001.slice/session-7349.scope",
"load_avg_at_start": [
0.91,
0.63,
0.49
],
"instrumented": false
},
"samples": [
{
"kind": "periodic",
"t_ms": 27,
"msgs": null,
"events": null,
"pty_bytes": 0,
"pty_writes": 0,
"rss_kb": 33220,
"pss_kb": 14193,
"private_dirty_kb": 6396,
"vmhwm_kb": 33228,
"utime_ticks": 0,
"stime_ticks": 0,
"cg_current": 3181150208,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 1032,
"msgs": null,
"events": null,
"pty_bytes": 28911,
"pty_writes": 10,
"rss_kb": 104900,
"pss_kb": 63836,
"private_dirty_kb": 48180,
"vmhwm_kb": 107872,
"utime_ticks": 18,
"stime_ticks": 2,
"cg_current": 3182235648,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 1689,
"msgs": 100,
"events": 355,
"pty_bytes": 31413,
"pty_writes": 13,
"rss_kb": 107280,
"pss_kb": 66195,
"private_dirty_kb": 50560,
"vmhwm_kb": 107872,
"utime_ticks": 19,
"stime_ticks": 2,
"cg_current": 3185168384,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 1714,
"msgs": 200,
"events": 738,
"pty_bytes": 31413,
"pty_writes": 13,
"rss_kb": 119640,
"pss_kb": 78475,
"private_dirty_kb": 62792,
"vmhwm_kb": 119756,
"utime_ticks": 24,
"stime_ticks": 2,
"cg_current": 3183779840,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 1716,
"msgs": 300,
"events": 1051,
"pty_bytes": 31413,
"pty_writes": 13,
"rss_kb": 119912,
"pss_kb": 78747,
"private_dirty_kb": 63064,
"vmhwm_kb": 119944,
"utime_ticks": 24,
"stime_ticks": 2,
"cg_current": 3183779840,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 1717,
"msgs": 400,
"events": 1432,
"pty_bytes": 31413,
"pty_writes": 13,
"rss_kb": 119992,
"pss_kb": 78827,
"private_dirty_kb": 63144,
"vmhwm_kb": 120252,
"utime_ticks": 24,
"stime_ticks": 2,
"cg_current": 3183525888,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 2041,
"msgs": null,
"events": null,
"pty_bytes": 31413,
"pty_writes": 13,
"rss_kb": 230864,
"pss_kb": 187378,
"private_dirty_kb": 170884,
"vmhwm_kb": 230864,
"utime_ticks": 81,
"stime_ticks": 6,
"cg_current": 3186503680,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 2140,
"msgs": 501,
"events": 1792,
"pty_bytes": 33737,
"pty_writes": 15,
"rss_kb": 233388,
"pss_kb": 189764,
"private_dirty_kb": 173220,
"vmhwm_kb": 233388,
"utime_ticks": 93,
"stime_ticks": 7,
"cg_current": 3185446912,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 2317,
"msgs": 601,
"events": 2154,
"pty_bytes": 35582,
"pty_writes": 16,
"rss_kb": 274096,
"pss_kb": 230408,
"private_dirty_kb": 213800,
"vmhwm_kb": 274152,
"utime_ticks": 120,
"stime_ticks": 8,
"cg_current": 3185287168,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 2542,
"msgs": 701,
"events": 2496,
"pty_bytes": 41569,
"pty_writes": 19,
"rss_kb": 319456,
"pss_kb": 275645,
"private_dirty_kb": 259036,
"vmhwm_kb": 336796,
"utime_ticks": 166,
"stime_ticks": 13,
"cg_current": 3184562176,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 2793,
"msgs": 801,
"events": 2857,
"pty_bytes": 46542,
"pty_writes": 21,
"rss_kb": 350128,
"pss_kb": 306317,
"private_dirty_kb": 289708,
"vmhwm_kb": 350128,
"utime_ticks": 195,
"stime_ticks": 14,
"cg_current": 3184861184,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 3044,
"msgs": null,
"events": null,
"pty_bytes": 47578,
"pty_writes": 22,
"rss_kb": 383596,
"pss_kb": 339785,
"private_dirty_kb": 323176,
"vmhwm_kb": 383596,
"utime_ticks": 224,
"stime_ticks": 15,
"cg_current": 3184898048,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 3095,
"msgs": 901,
"events": 3245,
"pty_bytes": 48697,
"pty_writes": 23,
"rss_kb": 384708,
"pss_kb": 340892,
"private_dirty_kb": 324288,
"vmhwm_kb": 384708,
"utime_ticks": 229,
"stime_ticks": 15,
"cg_current": 3184648192,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 3195,
"msgs": 1001,
"events": 3588,
"pty_bytes": 50416,
"pty_writes": 24,
"rss_kb": 387812,
"pss_kb": 343996,
"private_dirty_kb": 327392,
"vmhwm_kb": 387840,
"utime_ticks": 241,
"stime_ticks": 15,
"cg_current": 3184648192,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 3524,
"msgs": 1101,
"events": 3928,
"pty_bytes": 54393,
"pty_writes": 27,
"rss_kb": 416784,
"pss_kb": 372968,
"private_dirty_kb": 356364,
"vmhwm_kb": 417092,
"utime_ticks": 276,
"stime_ticks": 17,
"cg_current": 3185565696,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 3925,
"msgs": 1201,
"events": 4298,
"pty_bytes": 57944,
"pty_writes": 29,
"rss_kb": 449756,
"pss_kb": 405940,
"private_dirty_kb": 389336,
"vmhwm_kb": 449776,
"utime_ticks": 321,
"stime_ticks": 19,
"cg_current": 3184865280,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 4050,
"msgs": null,
"events": null,
"pty_bytes": 57944,
"pty_writes": 29,
"rss_kb": 472700,
"pss_kb": 428884,
"private_dirty_kb": 412280,
"vmhwm_kb": 472764,
"utime_ticks": 335,
"stime_ticks": 20,
"cg_current": 3184816128,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 4199,
"msgs": 1300,
"events": 4659,
"pty_bytes": 59534,
"pty_writes": 30,
"rss_kb": 477008,
"pss_kb": 433192,
"private_dirty_kb": 416588,
"vmhwm_kb": 477008,
"utime_ticks": 350,
"stime_ticks": 20,
"cg_current": 3184738304,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 4227,
"msgs": 1400,
"events": 5011,
"pty_bytes": 60792,
"pty_writes": 31,
"rss_kb": 478412,
"pss_kb": 434596,
"private_dirty_kb": 417992,
"vmhwm_kb": 478460,
"utime_ticks": 355,
"stime_ticks": 20,
"cg_current": 3184717824,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 4231,
"msgs": 1500,
"events": 5384,
"pty_bytes": 60792,
"pty_writes": 31,
"rss_kb": 478732,
"pss_kb": 434916,
"private_dirty_kb": 418312,
"vmhwm_kb": 479492,
"utime_ticks": 356,
"stime_ticks": 20,
"cg_current": 3184459776,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 4234,
"msgs": 1600,
"events": 5730,
"pty_bytes": 60792,
"pty_writes": 31,
"rss_kb": 479576,
"pss_kb": 435760,
"private_dirty_kb": 419156,
"vmhwm_kb": 479668,
"utime_ticks": 356,
"stime_ticks": 20,
"cg_current": 3184439296,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 4237,
"msgs": 1700,
"events": 6100,
"pty_bytes": 60792,
"pty_writes": 31,
"rss_kb": 479844,
"pss_kb": 436028,
"private_dirty_kb": 419424,
"vmhwm_kb": 479952,
"utime_ticks": 356,
"stime_ticks": 20,
"cg_current": 3184439296,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 4240,
"msgs": 1800,
"events": 6455,
"pty_bytes": 60792,
"pty_writes": 31,
"rss_kb": 480112,
"pss_kb": 436296,
"private_dirty_kb": 419692,
"vmhwm_kb": 480184,
"utime_ticks": 356,
"stime_ticks": 20,
"cg_current": 3184439296,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 4242,
"msgs": 1900,
"events": 6838,
"pty_bytes": 60792,
"pty_writes": 31,
"rss_kb": 480300,
"pss_kb": 436484,
"private_dirty_kb": 419880,
"vmhwm_kb": 480364,
"utime_ticks": 357,
"stime_ticks": 20,
"cg_current": 3184439296,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 4244,
"msgs": 2000,
"events": 7151,
"pty_bytes": 60792,
"pty_writes": 31,
"rss_kb": 480524,
"pss_kb": 436708,
"private_dirty_kb": 420104,
"vmhwm_kb": 480576,
"utime_ticks": 357,
"stime_ticks": 20,
"cg_current": 3184439296,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 5055,
"msgs": null,
"events": null,
"pty_bytes": 60792,
"pty_writes": 31,
"rss_kb": 664920,
"pss_kb": 621104,
"private_dirty_kb": 604500,
"vmhwm_kb": 664944,
"utime_ticks": 473,
"stime_ticks": 26,
"cg_current": 3184832512,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 5383,
"msgs": 2100,
"events": 7532,
"pty_bytes": 64936,
"pty_writes": 33,
"rss_kb": 669004,
"pss_kb": 625188,
"private_dirty_kb": 608584,
"vmhwm_kb": 669028,
"utime_ticks": 506,
"stime_ticks": 28,
"cg_current": 3184775168,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 5809,
"msgs": 2201,
"events": 7892,
"pty_bytes": 66459,
"pty_writes": 34,
"rss_kb": 698060,
"pss_kb": 654244,
"private_dirty_kb": 637640,
"vmhwm_kb": 698060,
"utime_ticks": 551,
"stime_ticks": 30,
"cg_current": 3184918528,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 6061,
"msgs": null,
"events": null,
"pty_bytes": 68410,
"pty_writes": 35,
"rss_kb": 715944,
"pss_kb": 672128,
"private_dirty_kb": 655524,
"vmhwm_kb": 716072,
"utime_ticks": 579,
"stime_ticks": 31,
"cg_current": 3184750592,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 6512,
"msgs": 2301,
"events": 8254,
"pty_bytes": 70180,
"pty_writes": 36,
"rss_kb": 729140,
"pss_kb": 685313,
"private_dirty_kb": 668720,
"vmhwm_kb": 729140,
"utime_ticks": 623,
"stime_ticks": 33,
"cg_current": 3184795648,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 7069,
"msgs": null,
"events": null,
"pty_bytes": 71849,
"pty_writes": 37,
"rss_kb": 759640,
"pss_kb": 715813,
"private_dirty_kb": 699220,
"vmhwm_kb": 759640,
"utime_ticks": 682,
"stime_ticks": 35,
"cg_current": 3184627712,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 7243,
"msgs": 2401,
"events": 8596,
"pty_bytes": 73318,
"pty_writes": 38,
"rss_kb": 760072,
"pss_kb": 716245,
"private_dirty_kb": 699652,
"vmhwm_kb": 760072,
"utime_ticks": 700,
"stime_ticks": 35,
"cg_current": 3185369088,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 7974,
"msgs": 2501,
"events": 8957,
"pty_bytes": 76046,
"pty_writes": 40,
"rss_kb": 790560,
"pss_kb": 746733,
"private_dirty_kb": 730140,
"vmhwm_kb": 790560,
"utime_ticks": 775,
"stime_ticks": 38,
"cg_current": 3185258496,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 8074,
"msgs": null,
"events": null,
"pty_bytes": 76046,
"pty_writes": 40,
"rss_kb": 790560,
"pss_kb": 746733,
"private_dirty_kb": 730140,
"vmhwm_kb": 790560,
"utime_ticks": 785,
"stime_ticks": 38,
"cg_current": 3185254400,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 8803,
"msgs": 2601,
"events": 9345,
"pty_bytes": 80929,
"pty_writes": 42,
"rss_kb": 821056,
"pss_kb": 777198,
"private_dirty_kb": 760636,
"vmhwm_kb": 821056,
"utime_ticks": 860,
"stime_ticks": 40,
"cg_current": 3186028544,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 9056,
"msgs": 2701,
"events": 9688,
"pty_bytes": 82978,
"pty_writes": 43,
"rss_kb": 821896,
"pss_kb": 778038,
"private_dirty_kb": 761476,
"vmhwm_kb": 821904,
"utime_ticks": 885,
"stime_ticks": 40,
"cg_current": 3185565696,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 9079,
"msgs": null,
"events": null,
"pty_bytes": 82978,
"pty_writes": 43,
"rss_kb": 823668,
"pss_kb": 779810,
"private_dirty_kb": 763248,
"vmhwm_kb": 823688,
"utime_ticks": 887,
"stime_ticks": 40,
"cg_current": 3185565696,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 9909,
"msgs": 2801,
"events": 10028,
"pty_bytes": 86325,
"pty_writes": 45,
"rss_kb": 852852,
"pss_kb": 808994,
"private_dirty_kb": 792432,
"vmhwm_kb": 852868,
"utime_ticks": 972,
"stime_ticks": 42,
"cg_current": 3186216960,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 10086,
"msgs": null,
"events": null,
"pty_bytes": 86325,
"pty_writes": 45,
"rss_kb": 878688,
"pss_kb": 834830,
"private_dirty_kb": 818268,
"vmhwm_kb": 878920,
"utime_ticks": 989,
"stime_ticks": 45,
"cg_current": 3186737152,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 10811,
"msgs": 2901,
"events": 10398,
"pty_bytes": 91739,
"pty_writes": 47,
"rss_kb": 885784,
"pss_kb": 841926,
"private_dirty_kb": 825364,
"vmhwm_kb": 885800,
"utime_ticks": 1063,
"stime_ticks": 45,
"cg_current": 3188797440,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "periodic",
"t_ms": 11086,
"msgs": null,
"events": null,
"pty_bytes": 91739,
"pty_writes": 47,
"rss_kb": 911912,
"pss_kb": 868054,
"private_dirty_kb": 851492,
"vmhwm_kb": 911912,
"utime_ticks": 1091,
"stime_ticks": 47,
"cg_current": 3191078912,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "boundary",
"t_ms": 11439,
"msgs": 3000,
"events": 10759,
"pty_bytes": 94867,
"pty_writes": 48,
"rss_kb": 913956,
"pss_kb": 870098,
"private_dirty_kb": 853536,
"vmhwm_kb": 913948,
"utime_ticks": 1125,
"stime_ticks": 48,
"cg_current": 3189575680,
"cg_peak": 6536753152,
"cg_oom_kill": 0
},
{
"kind": "done",
"t_ms": 11446,
"msgs": 3000,
"events": 10759,
"pty_bytes": 94867,
"pty_writes": 48,
"rss_kb": 913948,
"pss_kb": 870090,
"private_dirty_kb": 853528,
"vmhwm_kb": 913948,
"utime_ticks": 1125,
"stime_ticks": 48,
"cg_current": 3189575680,
"cg_peak": 6536753152,
"cg_oom_kill": 0
}
],
"events": [
{
"kind": "rpc",
"method": "session.create",
"t_ms": 177
},
{
"kind": "rpc",
"method": "startup.catalog",
"t_ms": 177
},
{
"kind": "rpc",
"method": "model.options",
"t_ms": 177
}
],
"summary": {
"result": "crashed_after_stream",
"cap_hit": false,
"cap_hit_basis": null,
"at_messages": null,
"exit": {
"exitCode": 7,
"signal": 0,
"t": 11857
},
"stream_done": true,
"msgs_streamed": 3000,
"events_streamed": 10759,
"pty_bytes_total": 98672,
"pty_data_callbacks": 51,
"first_byte_ms": 142,
"session_create_ms": 177,
"stream_start_ms": 1687,
"vmhwm_kb": 913948,
"cg_peak": 6536753152,
"drain_max_loop_lag_ms": 19,
"drain_lag_violations": 2,
"drain_ok": false,
"digest": null
},
"pty_tail": "at consequat. Voluptate ullamco labore sit sunt esse aliquip aliqua consectetur officia. - Elit mollit pariatur aute quis.- Occaecat reprehenderit exercitation incididunt dolor proident velit.- Laboris magna amet culpa.Cillum commodo enim adipiscing deserunt nulla duis. Magna amet culpa cillum commodo enim adipiscing deserunt.◦edit_file Aliquip aliqua consectetur officia. · 2.9s (7 lines)⚡grep Elitmollit pratur aut. · 0s ⧉ copy ⚕ ◦edit_file Sit sunt esse aliquip. · 0.9s (7 lines) ◦ grep Fugiat consequat minim elit. · 1.0s (2 lines) ●web_search Quis eiusmod lorem occaecat. · 1.1s (18 lines)◆write_file Dolor proident velit laboris. · 1.2s (7 lines)$terminal Cillum commodo enim adipiscing. · 1.3s (2 lines) ◇ read_file Veniam sed anim excepteur. · 1.4s (18 lines) Ipsum cupidatt volupae ullamo. · 1.5s (7 lines) ◦ grepssealiquip lqua consctetur. · 1.6s (2 lines)⧉ copy⚕Sit sunt esse aliquip aliqua consectetur officia fugiat consequat minim elit mollit pariatur aute. Voluptate ullamco labore sit sunt esse. Tempor ipsum cupidatat voluptate ullamco labore sit. - Fugiat consequat minim elit mollit.- Quis eiusmod lorem occaecat reprehenderit exercitation incididunt.- Dolor proident velit laboris.Magna amet culpa cillum commodo enim adipiscing deserunt nulla duis veniam sed anim. Proident velit laboris magna amet culpa cillum commodo enim adipiscing deserunt nulla duis veniam.◦edit_file Sit sunt esse aliquip. · 0.9s (7 lines)grep Fugiat consequat minim elit. · 1.0s (2 lines)●web_search Quis ismod lorem occaecat ·1.1s (18 lines)◆rite_fileDolor proident vlit lboris. · 1.2s (7$terminal Cillum commoo enim adipiscing32◇rad_fie Veniam sed anim excepteur. · 1.4s (18lines) ◦editIpsum cupidtatvoluate ullamco.· 1.5s (7 lines)grep Essealiquip aliqu conseceur. · 1.6s (2 lines) ⚡web_search Mnim elit mollit pariatur. · 0s 10s │ …/lively-thrush/hermes-agent ⧉ copy ◦ edit_file Voluptate ullamco labore sit. · 2.9s (7 lines) ◦grep Aliqua consectetur officia fugiat. · 3.0s (2 lines) ●web_search Mollit pariatur aute quis. · 3.1s (18 lines) ◆write_file Reprehenderit exercitation incididunt dolor. · 3.2s (7 lines) $terminal Magna amet culpa cillum. · 3.3s (2 lines) ◇read_file Deserunt nulla duis veniam. · 3.4s (18 lines) ◦edit_file Irure nostrud tempor ipsum. · 3.5s (7 lines) ◦grep Labore sit sunt esse. · 3.6s (2 lines) ●web_search Officia fugiat consequat minim. · 3.7s (18 lines) ⧉ copy ●- - const x3 = 8function f3() { return x}⧉ copy ◦ edit_file Voluptate ullamco labore sit. · 2.9s (7 lines)grep Aliqua consectetur officia fugia302●web_search Mollit pariatur auteqis. ·3.1s (18 lines) ◆rite_fileReprehenderi exercitatonincididunt door. · 3.2s (7 lines)$terminal Magna amt culpa cillum. · 3.3s (2 lines) ◇rad_fie Deserunt nula dus veniam. · 3.4s (18 lines)◦editIrure ostrud tempor ipsu57 lines) grep Labore sitsun esse. · 3.6 (2lines) ●web_search Officia fugiat consequatminim. · 3.7s (18 lines) ⧉ copy⚕▍ ◐file:///home/daimon/github/worktrees/hermes-agent/lively-thrush/hermes-agent/ui-opentui/node_modules/@opentui/core/index-59t85rvq.js:14915\n throw new Error(`Failed to create optimized buffer: ${width}x${height}`);\n ^\nError: Failed to create optimized buffer: 120x12\n at FFIRenderLib.createOptimizedBuffer (file:///home/daimon/github/worktrees/hermes-agent/lively-thrush/hermes-agent/ui-opentui/node_modules/@opentui/core/index-59t85rvq.js:14915:13)\n at OptimizedBuffer.create (file:///home/daimon/github/worktrees/hermes-agent/lively-thrush/hermes-agent/ui-opentui/node_modules/@opentui/core/index-59t85rvq.js:11918:24)\n at TerminalConsole.show (file:///home/daimon/github/worktrees/hermes-agent/lively-thrush/hermes-agent/ui-opentui/node_modules/@opentui/core/index-59t85rvq.js:21058:44)\n at CliRenderer.<anonymous> (file:///home/daimon/github/worktrees/hermes-agent/lively-thrush/hermes-agent/ui-opentui/node_modules/@opentui/core/index-59t85rvq.js:23222:20)\n at process.emit (node:events:509:20)\n at process._fatalException (node:internal/process/execution:190:32)\nNode.js v26.3.0"
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,162 @@
{
"meta": {
"cell": "startup",
"ui": "ink",
"config": "ink",
"mode": "startup",
"rep": 0,
"run_id": "mq8k2tye-1xyi",
"utc": "2026-06-10T21:03:24.711Z",
"sha": "197d49948",
"node": "/home/daimon/.local/share/fnm/node-versions/v26.3.0/installation/bin/node",
"node_version": "v26.3.0",
"pty": {
"cols": 120,
"rows": 40,
"term": "xterm-256color"
},
"heap_mb": 8192,
"memory_max": null,
"container_cap": false,
"container_memory": null,
"opentui_cap": null,
"fixture": {
"path": "",
"msgs": 0,
"sha256": ""
},
"sample_every": 100,
"mode_params": {},
"ui_pid": 2373306,
"gw_pid": 2373314,
"cgroup": null,
"load_avg_at_start": [
0.43,
0.55,
0.48
],
"instrumented": false
},
"samples": [
{
"kind": "periodic",
"t_ms": 27,
"msgs": null,
"events": null,
"pty_bytes": 0,
"pty_writes": 0,
"rss_kb": 55524,
"pss_kb": 29228,
"private_dirty_kb": 18060,
"vmhwm_kb": 55556,
"utime_ticks": 1,
"stime_ticks": 0
},
{
"kind": "final",
"t_ms": 933,
"msgs": 0,
"events": 0,
"pty_bytes": 6604,
"pty_writes": 10,
"rss_kb": 108996,
"pss_kb": 69637,
"private_dirty_kb": 49764,
"vmhwm_kb": 108996,
"utime_ticks": 24,
"stime_ticks": 3
}
],
"events": [
{
"kind": "rpc",
"method": "config.get",
"t_ms": 176
},
{
"kind": "rpc",
"method": "commands.catalog",
"t_ms": 201
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 201
},
{
"kind": "rpc",
"method": "setup.status",
"t_ms": 201
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 201
},
{
"kind": "rpc",
"method": "session.create",
"t_ms": 227
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 227
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 227
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 227
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 227
},
{
"kind": "rpc",
"method": "session.active_list",
"t_ms": 227
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 227
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 227
}
],
"summary": {
"result": "completed",
"cap_hit": false,
"cap_hit_basis": null,
"at_messages": null,
"exit": {
"exitCode": 0,
"signal": 0,
"t": 943
},
"stream_done": false,
"msgs_streamed": 0,
"events_streamed": null,
"pty_bytes_total": 6785,
"pty_data_callbacks": 18,
"first_byte_ms": 71,
"session_create_ms": 227,
"stream_start_ms": null,
"vmhwm_kb": 108996,
"cg_peak": null,
"drain_max_loop_lag_ms": 1,
"drain_lag_violations": 0,
"drain_ok": true,
"digest": null
}
}

View File

@@ -0,0 +1,157 @@
{
"meta": {
"cell": "startup",
"ui": "ink",
"config": "ink",
"mode": "startup",
"rep": 1,
"run_id": "mq8k3k0d-8lb5",
"utc": "2026-06-10T21:03:58.478Z",
"sha": "197d49948",
"node": "/home/daimon/.local/share/fnm/node-versions/v26.3.0/installation/bin/node",
"node_version": "v26.3.0",
"pty": {
"cols": 120,
"rows": 40,
"term": "xterm-256color"
},
"heap_mb": 8192,
"memory_max": null,
"container_cap": false,
"container_memory": null,
"opentui_cap": null,
"fixture": {
"path": "",
"msgs": 0,
"sha256": ""
},
"sample_every": 100,
"mode_params": {},
"ui_pid": 2374052,
"gw_pid": 2374068,
"cgroup": null,
"load_avg_at_start": [
0.24,
0.49,
0.46
],
"instrumented": false
},
"samples": [
{
"kind": "periodic",
"t_ms": 26,
"msgs": null,
"events": null,
"pty_bytes": 0,
"pty_writes": 0,
"rss_kb": 56656,
"pss_kb": 30387,
"private_dirty_kb": 19220,
"vmhwm_kb": 56664,
"utime_ticks": 1,
"stime_ticks": 0
},
{
"kind": "final",
"t_ms": 933,
"msgs": 0,
"events": 0,
"pty_bytes": 6604,
"pty_writes": 10,
"rss_kb": 107996,
"pss_kb": 68555,
"private_dirty_kb": 48740,
"vmhwm_kb": 107996,
"utime_ticks": 24,
"stime_ticks": 2
}
],
"events": [
{
"kind": "rpc",
"method": "config.get",
"t_ms": 176
},
{
"kind": "rpc",
"method": "commands.catalog",
"t_ms": 202
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 202
},
{
"kind": "rpc",
"method": "setup.status",
"t_ms": 202
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 202
},
{
"kind": "rpc",
"method": "session.create",
"t_ms": 202
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 202
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 226
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 226
},
{
"kind": "rpc",
"method": "session.active_list",
"t_ms": 226
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 226
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 226
}
],
"summary": {
"result": "completed",
"cap_hit": false,
"cap_hit_basis": null,
"at_messages": null,
"exit": {
"exitCode": 0,
"signal": 0,
"t": 945
},
"stream_done": false,
"msgs_streamed": 0,
"events_streamed": null,
"pty_bytes_total": 6785,
"pty_data_callbacks": 26,
"first_byte_ms": 67,
"session_create_ms": 202,
"stream_start_ms": null,
"vmhwm_kb": 107996,
"cg_peak": null,
"drain_max_loop_lag_ms": 2,
"drain_lag_violations": 0,
"drain_ok": true,
"digest": null
}
}

View File

@@ -0,0 +1,126 @@
{
"meta": {
"cell": "startup",
"ui": "opentui",
"config": "otui-capped",
"mode": "startup",
"rep": 0,
"run_id": "mq8k32ir-kfs0",
"utc": "2026-06-10T21:03:35.811Z",
"sha": "197d49948",
"node": "/home/daimon/.local/share/fnm/node-versions/v26.3.0/installation/bin/node",
"node_version": "v26.3.0",
"pty": {
"cols": 120,
"rows": 40,
"term": "xterm-256color"
},
"heap_mb": 8192,
"memory_max": null,
"container_cap": false,
"container_memory": null,
"opentui_cap": 3000,
"fixture": {
"path": "",
"msgs": 0,
"sha256": ""
},
"sample_every": 100,
"mode_params": {},
"ui_pid": 2373543,
"gw_pid": 2373552,
"cgroup": null,
"load_avg_at_start": [
0.37,
0.54,
0.47
],
"instrumented": false
},
"samples": [
{
"kind": "periodic",
"t_ms": 26,
"msgs": null,
"events": null,
"pty_bytes": 0,
"pty_writes": 0,
"rss_kb": 54272,
"pss_kb": 27947,
"private_dirty_kb": 16840,
"vmhwm_kb": 54312,
"utime_ticks": 1,
"stime_ticks": 0
},
{
"kind": "periodic",
"t_ms": 1032,
"msgs": null,
"events": null,
"pty_bytes": 28911,
"pty_writes": 19,
"rss_kb": 104972,
"pss_kb": 64818,
"private_dirty_kb": 48264,
"vmhwm_kb": 107336,
"utime_ticks": 17,
"stime_ticks": 2
},
{
"kind": "final",
"t_ms": 1139,
"msgs": 0,
"events": 0,
"pty_bytes": 28911,
"pty_writes": 19,
"rss_kb": 104972,
"pss_kb": 64818,
"private_dirty_kb": 48264,
"vmhwm_kb": 107336,
"utime_ticks": 17,
"stime_ticks": 2
}
],
"events": [
{
"kind": "rpc",
"method": "session.create",
"t_ms": 176
},
{
"kind": "rpc",
"method": "startup.catalog",
"t_ms": 176
},
{
"kind": "rpc",
"method": "model.options",
"t_ms": 176
}
],
"summary": {
"result": "completed",
"cap_hit": false,
"cap_hit_basis": null,
"at_messages": null,
"exit": {
"exitCode": 0,
"signal": 0,
"t": 1322
},
"stream_done": false,
"msgs_streamed": 0,
"events_streamed": null,
"pty_bytes_total": 29189,
"pty_data_callbacks": 23,
"first_byte_ms": 126,
"session_create_ms": 176,
"stream_start_ms": null,
"vmhwm_kb": 107336,
"cg_peak": null,
"drain_max_loop_lag_ms": 3,
"drain_lag_violations": 0,
"drain_ok": true,
"digest": null
}
}

View File

@@ -0,0 +1,126 @@
{
"meta": {
"cell": "startup",
"ui": "opentui",
"config": "otui-capped",
"mode": "startup",
"rep": 1,
"run_id": "mq8k3b9i-cl30",
"utc": "2026-06-10T21:03:47.143Z",
"sha": "197d49948",
"node": "/home/daimon/.local/share/fnm/node-versions/v26.3.0/installation/bin/node",
"node_version": "v26.3.0",
"pty": {
"cols": 120,
"rows": 40,
"term": "xterm-256color"
},
"heap_mb": 8192,
"memory_max": null,
"container_cap": false,
"container_memory": null,
"opentui_cap": 3000,
"fixture": {
"path": "",
"msgs": 0,
"sha256": ""
},
"sample_every": 100,
"mode_params": {},
"ui_pid": 2373796,
"gw_pid": 2373805,
"cgroup": null,
"load_avg_at_start": [
0.28,
0.51,
0.46
],
"instrumented": false
},
"samples": [
{
"kind": "periodic",
"t_ms": 25,
"msgs": null,
"events": null,
"pty_bytes": 0,
"pty_writes": 0,
"rss_kb": 53764,
"pss_kb": 27358,
"private_dirty_kb": 16248,
"vmhwm_kb": 53916,
"utime_ticks": 1,
"stime_ticks": 1
},
{
"kind": "periodic",
"t_ms": 1036,
"msgs": null,
"events": null,
"pty_bytes": 28911,
"pty_writes": 14,
"rss_kb": 108848,
"pss_kb": 68551,
"private_dirty_kb": 51992,
"vmhwm_kb": 108848,
"utime_ticks": 17,
"stime_ticks": 3
},
{
"kind": "final",
"t_ms": 1142,
"msgs": 0,
"events": 0,
"pty_bytes": 28911,
"pty_writes": 14,
"rss_kb": 108848,
"pss_kb": 68551,
"private_dirty_kb": 51992,
"vmhwm_kb": 108848,
"utime_ticks": 17,
"stime_ticks": 3
}
],
"events": [
{
"kind": "rpc",
"method": "session.create",
"t_ms": 175
},
{
"kind": "rpc",
"method": "startup.catalog",
"t_ms": 175
},
{
"kind": "rpc",
"method": "model.options",
"t_ms": 175
}
],
"summary": {
"result": "completed",
"cap_hit": false,
"cap_hit_basis": null,
"at_messages": null,
"exit": {
"exitCode": 0,
"signal": 0,
"t": 1324
},
"stream_done": false,
"msgs_streamed": 0,
"events_streamed": null,
"pty_bytes_total": 29189,
"pty_data_callbacks": 18,
"first_byte_ms": 128,
"session_create_ms": 175,
"stream_start_ms": null,
"vmhwm_kb": 108848,
"cg_peak": null,
"drain_max_loop_lag_ms": 2,
"drain_lag_violations": 0,
"drain_ok": true,
"digest": null
}
}

View File

@@ -0,0 +1,157 @@
{
"meta": {
"cell": "startup",
"ui": "ink",
"config": "ink",
"mode": "startup",
"rep": 2,
"run_id": "mq8k3ski-br6m",
"utc": "2026-06-10T21:04:09.570Z",
"sha": "197d49948",
"node": "/home/daimon/.local/share/fnm/node-versions/v26.3.0/installation/bin/node",
"node_version": "v26.3.0",
"pty": {
"cols": 120,
"rows": 40,
"term": "xterm-256color"
},
"heap_mb": 8192,
"memory_max": null,
"container_cap": false,
"container_memory": null,
"opentui_cap": null,
"fixture": {
"path": "",
"msgs": 0,
"sha256": ""
},
"sample_every": 100,
"mode_params": {},
"ui_pid": 2374304,
"gw_pid": 2374312,
"cgroup": null,
"load_avg_at_start": [
0.36,
0.51,
0.46
],
"instrumented": false
},
"samples": [
{
"kind": "periodic",
"t_ms": 27,
"msgs": null,
"events": null,
"pty_bytes": 0,
"pty_writes": 0,
"rss_kb": 56620,
"pss_kb": 30323,
"private_dirty_kb": 19156,
"vmhwm_kb": 56628,
"utime_ticks": 1,
"stime_ticks": 0
},
{
"kind": "final",
"t_ms": 933,
"msgs": 0,
"events": 0,
"pty_bytes": 6607,
"pty_writes": 12,
"rss_kb": 109548,
"pss_kb": 70130,
"private_dirty_kb": 50316,
"vmhwm_kb": 109548,
"utime_ticks": 23,
"stime_ticks": 3
}
],
"events": [
{
"kind": "rpc",
"method": "config.get",
"t_ms": 177
},
{
"kind": "rpc",
"method": "commands.catalog",
"t_ms": 202
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 202
},
{
"kind": "rpc",
"method": "setup.status",
"t_ms": 202
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 202
},
{
"kind": "rpc",
"method": "session.create",
"t_ms": 202
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 202
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 227
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 227
},
{
"kind": "rpc",
"method": "session.active_list",
"t_ms": 227
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 227
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 227
}
],
"summary": {
"result": "completed",
"cap_hit": false,
"cap_hit_basis": null,
"at_messages": null,
"exit": {
"exitCode": 0,
"signal": 0,
"t": 941
},
"stream_done": false,
"msgs_streamed": 0,
"events_streamed": null,
"pty_bytes_total": 6788,
"pty_data_callbacks": 14,
"first_byte_ms": 65,
"session_create_ms": 202,
"stream_start_ms": null,
"vmhwm_kb": 109548,
"cg_peak": null,
"drain_max_loop_lag_ms": 2,
"drain_lag_violations": 0,
"drain_ok": true,
"digest": null
}
}

View File

@@ -0,0 +1,157 @@
{
"meta": {
"cell": "startup",
"ui": "ink",
"config": "ink",
"mode": "startup",
"rep": 3,
"run_id": "mq8k49vp-a57g",
"utc": "2026-06-10T21:04:32.005Z",
"sha": "197d49948",
"node": "/home/daimon/.local/share/fnm/node-versions/v26.3.0/installation/bin/node",
"node_version": "v26.3.0",
"pty": {
"cols": 120,
"rows": 40,
"term": "xterm-256color"
},
"heap_mb": 8192,
"memory_max": null,
"container_cap": false,
"container_memory": null,
"opentui_cap": null,
"fixture": {
"path": "",
"msgs": 0,
"sha256": ""
},
"sample_every": 100,
"mode_params": {},
"ui_pid": 2374791,
"gw_pid": 2374799,
"cgroup": null,
"load_avg_at_start": [
0.24,
0.47,
0.45
],
"instrumented": false
},
"samples": [
{
"kind": "periodic",
"t_ms": 27,
"msgs": null,
"events": null,
"pty_bytes": 0,
"pty_writes": 0,
"rss_kb": 56660,
"pss_kb": 30363,
"private_dirty_kb": 19196,
"vmhwm_kb": 56660,
"utime_ticks": 1,
"stime_ticks": 1
},
{
"kind": "final",
"t_ms": 931,
"msgs": 0,
"events": 0,
"pty_bytes": 6607,
"pty_writes": 11,
"rss_kb": 108056,
"pss_kb": 68668,
"private_dirty_kb": 48884,
"vmhwm_kb": 108056,
"utime_ticks": 23,
"stime_ticks": 3
}
],
"events": [
{
"kind": "rpc",
"method": "config.get",
"t_ms": 176
},
{
"kind": "rpc",
"method": "commands.catalog",
"t_ms": 201
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 201
},
{
"kind": "rpc",
"method": "setup.status",
"t_ms": 201
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 201
},
{
"kind": "rpc",
"method": "session.create",
"t_ms": 201
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 201
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 226
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 226
},
{
"kind": "rpc",
"method": "session.active_list",
"t_ms": 226
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 226
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 226
}
],
"summary": {
"result": "completed",
"cap_hit": false,
"cap_hit_basis": null,
"at_messages": null,
"exit": {
"exitCode": 0,
"signal": 0,
"t": 941
},
"stream_done": false,
"msgs_streamed": 0,
"events_streamed": null,
"pty_bytes_total": 6788,
"pty_data_callbacks": 20,
"first_byte_ms": 65,
"session_create_ms": 201,
"stream_start_ms": null,
"vmhwm_kb": 108056,
"cg_peak": null,
"drain_max_loop_lag_ms": 1,
"drain_lag_violations": 0,
"drain_ok": true,
"digest": null
}
}

View File

@@ -0,0 +1,157 @@
{
"meta": {
"cell": "startup",
"ui": "ink",
"config": "ink",
"mode": "startup",
"rep": 4,
"run_id": "mq8k4r6t-uvp2",
"utc": "2026-06-10T21:04:54.437Z",
"sha": "197d49948",
"node": "/home/daimon/.local/share/fnm/node-versions/v26.3.0/installation/bin/node",
"node_version": "v26.3.0",
"pty": {
"cols": 120,
"rows": 40,
"term": "xterm-256color"
},
"heap_mb": 8192,
"memory_max": null,
"container_cap": false,
"container_memory": null,
"opentui_cap": null,
"fixture": {
"path": "",
"msgs": 0,
"sha256": ""
},
"sample_every": 100,
"mode_params": {},
"ui_pid": 2375404,
"gw_pid": 2375412,
"cgroup": null,
"load_avg_at_start": [
0.24,
0.45,
0.45
],
"instrumented": false
},
"samples": [
{
"kind": "periodic",
"t_ms": 26,
"msgs": null,
"events": null,
"pty_bytes": 0,
"pty_writes": 0,
"rss_kb": 56380,
"pss_kb": 30111,
"private_dirty_kb": 18944,
"vmhwm_kb": 56460,
"utime_ticks": 2,
"stime_ticks": 0
},
{
"kind": "final",
"t_ms": 928,
"msgs": 0,
"events": 0,
"pty_bytes": 6604,
"pty_writes": 12,
"rss_kb": 110684,
"pss_kb": 71346,
"private_dirty_kb": 51564,
"vmhwm_kb": 110684,
"utime_ticks": 24,
"stime_ticks": 2
}
],
"events": [
{
"kind": "rpc",
"method": "config.get",
"t_ms": 177
},
{
"kind": "rpc",
"method": "commands.catalog",
"t_ms": 202
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 202
},
{
"kind": "rpc",
"method": "setup.status",
"t_ms": 202
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 202
},
{
"kind": "rpc",
"method": "session.create",
"t_ms": 202
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 202
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 228
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 228
},
{
"kind": "rpc",
"method": "session.active_list",
"t_ms": 228
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 228
},
{
"kind": "rpc",
"method": "config.get",
"t_ms": 228
}
],
"summary": {
"result": "completed",
"cap_hit": false,
"cap_hit_basis": null,
"at_messages": null,
"exit": {
"exitCode": 0,
"signal": 0,
"t": 939
},
"stream_done": false,
"msgs_streamed": 0,
"events_streamed": null,
"pty_bytes_total": 6785,
"pty_data_callbacks": 25,
"first_byte_ms": 68,
"session_create_ms": 202,
"stream_start_ms": null,
"vmhwm_kb": 110684,
"cg_peak": null,
"drain_max_loop_lag_ms": 2,
"drain_lag_violations": 0,
"drain_ok": true,
"digest": null
}
}

View File

@@ -0,0 +1,126 @@
{
"meta": {
"cell": "startup",
"ui": "opentui",
"config": "otui-capped",
"mode": "startup",
"rep": 2,
"run_id": "mq8k414m-e67a",
"utc": "2026-06-10T21:04:20.662Z",
"sha": "197d49948",
"node": "/home/daimon/.local/share/fnm/node-versions/v26.3.0/installation/bin/node",
"node_version": "v26.3.0",
"pty": {
"cols": 120,
"rows": 40,
"term": "xterm-256color"
},
"heap_mb": 8192,
"memory_max": null,
"container_cap": false,
"container_memory": null,
"opentui_cap": 3000,
"fixture": {
"path": "",
"msgs": 0,
"sha256": ""
},
"sample_every": 100,
"mode_params": {},
"ui_pid": 2374523,
"gw_pid": 2374532,
"cgroup": null,
"load_avg_at_start": [
0.31,
0.49,
0.46
],
"instrumented": false
},
"samples": [
{
"kind": "periodic",
"t_ms": 26,
"msgs": null,
"events": null,
"pty_bytes": 0,
"pty_writes": 0,
"rss_kb": 55320,
"pss_kb": 28903,
"private_dirty_kb": 17792,
"vmhwm_kb": 55372,
"utime_ticks": 2,
"stime_ticks": 0
},
{
"kind": "periodic",
"t_ms": 1030,
"msgs": null,
"events": null,
"pty_bytes": 28911,
"pty_writes": 13,
"rss_kb": 105108,
"pss_kb": 64892,
"private_dirty_kb": 48364,
"vmhwm_kb": 107848,
"utime_ticks": 18,
"stime_ticks": 2
},
{
"kind": "final",
"t_ms": 1150,
"msgs": 0,
"events": 0,
"pty_bytes": 28911,
"pty_writes": 13,
"rss_kb": 105108,
"pss_kb": 64892,
"private_dirty_kb": 48364,
"vmhwm_kb": 107848,
"utime_ticks": 18,
"stime_ticks": 2
}
],
"events": [
{
"kind": "rpc",
"method": "session.create",
"t_ms": 176
},
{
"kind": "rpc",
"method": "startup.catalog",
"t_ms": 176
},
{
"kind": "rpc",
"method": "model.options",
"t_ms": 176
}
],
"summary": {
"result": "completed",
"cap_hit": false,
"cap_hit_basis": null,
"at_messages": null,
"exit": {
"exitCode": 0,
"signal": 0,
"t": 1332
},
"stream_done": false,
"msgs_streamed": 0,
"events_streamed": null,
"pty_bytes_total": 29189,
"pty_data_callbacks": 17,
"first_byte_ms": 135,
"session_create_ms": 176,
"stream_start_ms": null,
"vmhwm_kb": 107848,
"cg_peak": null,
"drain_max_loop_lag_ms": 2,
"drain_lag_violations": 0,
"drain_ok": true,
"digest": null
}
}

View File

@@ -0,0 +1,126 @@
{
"meta": {
"cell": "startup",
"ui": "opentui",
"config": "otui-capped",
"mode": "startup",
"rep": 3,
"run_id": "mq8k4ifv-j1w7",
"utc": "2026-06-10T21:04:43.099Z",
"sha": "197d49948",
"node": "/home/daimon/.local/share/fnm/node-versions/v26.3.0/installation/bin/node",
"node_version": "v26.3.0",
"pty": {
"cols": 120,
"rows": 40,
"term": "xterm-256color"
},
"heap_mb": 8192,
"memory_max": null,
"container_cap": false,
"container_memory": null,
"opentui_cap": 3000,
"fixture": {
"path": "",
"msgs": 0,
"sha256": ""
},
"sample_every": 100,
"mode_params": {},
"ui_pid": 2375091,
"gw_pid": 2375153,
"cgroup": null,
"load_avg_at_start": [
0.28,
0.47,
0.45
],
"instrumented": false
},
"samples": [
{
"kind": "periodic",
"t_ms": 27,
"msgs": null,
"events": null,
"pty_bytes": 0,
"pty_writes": 0,
"rss_kb": 54772,
"pss_kb": 28432,
"private_dirty_kb": 17324,
"vmhwm_kb": 54784,
"utime_ticks": 2,
"stime_ticks": 0
},
{
"kind": "periodic",
"t_ms": 1032,
"msgs": null,
"events": null,
"pty_bytes": 28911,
"pty_writes": 13,
"rss_kb": 105188,
"pss_kb": 65019,
"private_dirty_kb": 48464,
"vmhwm_kb": 107864,
"utime_ticks": 18,
"stime_ticks": 2
},
{
"kind": "final",
"t_ms": 1147,
"msgs": 0,
"events": 0,
"pty_bytes": 28911,
"pty_writes": 13,
"rss_kb": 105188,
"pss_kb": 65019,
"private_dirty_kb": 48464,
"vmhwm_kb": 107864,
"utime_ticks": 18,
"stime_ticks": 2
}
],
"events": [
{
"kind": "rpc",
"method": "session.create",
"t_ms": 176
},
{
"kind": "rpc",
"method": "startup.catalog",
"t_ms": 176
},
{
"kind": "rpc",
"method": "model.options",
"t_ms": 176
}
],
"summary": {
"result": "completed",
"cap_hit": false,
"cap_hit_basis": null,
"at_messages": null,
"exit": {
"exitCode": 0,
"signal": 0,
"t": 1329
},
"stream_done": false,
"msgs_streamed": 0,
"events_streamed": null,
"pty_bytes_total": 29189,
"pty_data_callbacks": 17,
"first_byte_ms": 132,
"session_create_ms": 176,
"stream_start_ms": null,
"vmhwm_kb": 107864,
"cg_peak": null,
"drain_max_loop_lag_ms": 2,
"drain_lag_violations": 0,
"drain_ok": true,
"digest": null
}
}

Some files were not shown because too many files have changed in this diff Show More