Compare commits

...

227 Commits

Author SHA1 Message Date
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
Teknium
e45b745835 fix(file-tools): reject sentinel TERMINAL_CWD; anchor worktree edits before live cwd exists (#41861)
Completes the worktree-misroute fix from #35399, which made misroutes
visible (resolved_path) but did not prevent them: its divergence warning
only fired once a terminal command had populated the live cwd registry.
A fresh worktree session (registry still empty) with a stale TERMINAL_CWD='.'
got neither a worktree anchor nor a warning, so a relative write_file/patch
silently landed in the MAIN checkout.

Two changes in tools/file_tools.py:
- Treat sentinel TERMINAL_CWD values ('', '.', './', 'auto', 'cwd') and any
  relative value as UNSET rather than a literal anchor. Previously '.' was
  joined onto the process cwd, silently routing edits to wherever the process
  happened to be (the main repo, in a worktree session). The gateway already
  sanitizes the same set at import time; the file-tool layer now matches.
- New _authoritative_workspace_root(): prefers the live terminal cwd, else a
  sentinel-free absolute TERMINAL_CWD (the worktree path cli.py/main.py set
  for -w). _resolve_base_dir() and _path_resolution_warning() both use it, so
  a worktree session resolves into — and warns about escaping — the worktree
  from the very first write, before any cd has run.

Validation: 11 new/parametrized tests (sentinel handling, empty-registry
anchoring, early divergence warning, live-cwd precedence). 32/32 pass under
scripts/run_tests.sh. Live E2E: relative write in an empty-registry worktree
session lands in the worktree, main untouched.
2026-06-07 23:58:47 -07:00
LeonSGP43
e02f4c03c3 fix(gateway): abort --replace when old PID survives SIGKILL
When --replace force-kills an unresponsive old gateway, SIGKILL can fail
to reap it (uninterruptible sleep, zombie-reaping parent, etc.). The old
code unconditionally cleared the PID file and scoped locks and started a
fresh instance anyway, leaving two live gateways fighting over the same
bot token — a duplicate-gateway failure mode of #19471.

Re-verify the process is actually gone (via the Windows-safe _pid_exists
helper) after the force-kill; if it still appears alive, clear the
takeover marker and abort the replacement instead of duplicating.

Co-authored-by: Hermes <noreply@nousresearch.com>
2026-06-07 23:57:32 -07:00
konsisumer
3714caa1b9 fix(session): follow compression continuations for transcript reads 2026-06-07 23:57:20 -07:00
teknium1
329c33dac3 fix(terminal): read cwd overrides under raw task_id after container collapse
PR #41822 collapsed CWD-only overrides to the shared 'default' container
via _resolve_container_task_id, but three call sites kept routing the
*env/override lookup* through that collapsed id:

  - the foreground exec path read _task_env_overrides[effective_task_id],
    yet register_task_env_overrides writes under the raw task_id, so a
    CWD-only override's cwd was silently dropped (env spun up at the wrong
    root, exit 126);
  - the get-or-create env lookup keyed solely on effective_task_id, so an
    env cached under the raw task_id was missed and duplicated;
  - register_task_env_overrides synced the new cwd onto the env under the
    collapsed id, missing a live env cached under the raw task_id.

Container *identity* still collapses to 'default' (sharing preserved);
only the per-session env/override *lookup* now prefers the raw task_id and
falls back to the collapsed id. Fixes the 3 regressions in
test_terminal_task_cwd.py left red by #41822.
2026-06-07 23:44:04 -07:00
teknium1
d759c13c09 chore(salvage): lint fix + AUTHOR_MAP for desktop source-folders PR #40272
eslint --fix (import sort + padding-line-between-statements) on sidebar/index.tsx
after cherry-picking @dangelo352's commits; add release.py AUTHOR_MAP entry so
CI doesn't block on the unmapped author email.
2026-06-07 23:44:04 -07:00
D'Angelo Rodriguez
694adec635 Smooth desktop sidebar drag sorting 2026-06-07 23:44:04 -07:00
D'Angelo Rodriguez
f0fcaa1e54 Preserve dragged order inside source folders 2026-06-07 23:44:04 -07:00
D'Angelo Rodriguez
0f500fc41d Render grouped sessions when local list is empty 2026-06-07 23:44:04 -07:00
D'Angelo Rodriguez
3fc67b7333 Persist desktop sidebar drag order 2026-06-07 23:44:04 -07:00
D'Angelo Rodriguez
ede4f5a4a3 Show messaging source folders in desktop sessions 2026-06-07 23:44:04 -07:00
D'Angelo Rodriguez
9d6992ee8a Show platform sources in desktop sessions 2026-06-07 23:44:04 -07:00
teknium1
1c68f6f81f refactor(gateway): extract kanban watcher loops into GatewayKanbanWatchersMixin (god-file Phase 3)
gateway/run.py is the largest god file (20k LOC, GatewayRunner with 220
methods). This lifts the cohesive kanban-watcher cluster — _kanban_notifier_watcher,
_kanban_dispatcher_watcher, _kanban_advance/unsub/rewind, _deliver_kanban_artifacts
(~1,035 LOC, 6 methods) — into gateway/kanban_watchers.py as a mixin that
GatewayRunner inherits.

Mixin (not free functions) because the methods use only self state: inheriting
keeps every self._kanban_* call site working unchanged via the MRO, making this
a behavior-neutral move. The methods' lazy imports (_kb, _decomp, _load_config,
Platform) travel with them; the mixin needs only stdlib + a matching
logging.getLogger('gateway.run').

run.py 20187 -> 19157 LOC; GatewayRunner direct methods 220 -> 214.

Behavior-neutral: gateway test suite 6582 passed / 0 failed; start() still wires
both watchers via self._kanban_*; MRO resolves all 6 to the mixin. One test
(corrupt-board quarantine retry) keyed its time-travel mock on the caller's
filename being gateway/run.py — updated to also accept gateway/kanban_watchers.py.

Establishes the mixin-extraction pattern for further GatewayRunner decomposition
(the 2406-LOC _run_agent and 1164-LOC _handle_message remain, but their callback
closures need a context-object redesign — deferred).
2026-06-07 23:14:18 -07:00
liuhao1024
6459b3d991 fix(terminal): collapse CWD-only overrides to shared container
When register_task_env_overrides is called with only a 'cwd' key
(ACP adapter workspace tracking), the task_id should collapse to
'default' so all interactive surfaces (TUI, gateway, dashboard)
share one long-lived container.

Previously, any override registration — even CWD-only — caused
_resolve_container_task_id to return the session key unchanged,
spinning up a separate container per session. This made it
impossible to authenticate into external services once and have
that auth available across all surfaces.

Now only overrides containing isolation keys (docker_image,
modal_image, singularity_image, daytona_image, env_type) trigger
per-task container isolation.

Fixes #37361
2026-06-07 23:04:54 -07:00
teknium1
1a626470ca refactor(cli): promote 9 closure handlers to top-level + extract their parsers (god-file Phase 2 follow-up)
Subcommands whose handler was a closure defined inside main() — memory, acp,
tools, insights, skills, pairing, plugins, mcp, claw — have their handler
promoted to a top-level function and their parser block extracted into
hermes_cli/subcommands/<name>.py (build_<name>_parser, injected handler).

These 9 had zero closure-over-main-locals, so promotion is a pure relocation.
acp/mcp parser blocks use the shared add_accept_hooks_flag helper.

main() 1798 -> 954 LOC (71% below the 3297 Phase-2 starting point);
add_parser calls in main.py 89 -> 28.

Deferred: sessions, computer-use, secrets handlers reference <name>_parser
(for a no-subcommand print_help fallback) — left in place to avoid the
_self_parser indirection; minority, low value.

Behavior-neutral: all 9 subcommands' --help (incl nested subactions) byte-
identical to pre-extraction (diff-verified). tests/hermes_cli/ 6519 passed /
0 failed; new test_subcommands_followup.py covers the 9 builders.
2026-06-07 22:56:23 -07:00
teknium1
524453dab5 refactor(agent): consolidate inner-retry-loop recovery flags into TurnRetryState (god-file Phase 1b)
run_conversation's inner retry loop tracked recovery state in ~15 scattered
bare booleans (per-provider OAuth refresh guards, format-recovery guards,
restart signals). They are now fields on a single TurnRetryState dataclass the
loop mutates in place (_retry.<flag>), giving the recovery bookkeeping a named,
testable home.

Loop-control vars (retry_count, max_retries, max_compression_attempts) stay as
plain locals — they're while-mechanics, not recovery bookkeeping.

Behavior-neutral: pure local→attribute rewrite of 42 references; kwarg NAMES
preserved (e.g. has_retried_429=_retry.has_retried_429). Live simple + tool
turns OK.

Validation: tests/run_agent/ 1615 passed / 0 failed under per-file process
isolation; new test_turn_retry_state.py pins the field contract.
2026-06-07 22:42:05 -07:00
teknium1
4d926f248d chore(release): add AUTHOR_MAP entry for rodboev 2026-06-07 22:39:51 -07:00
Rod Boev
648706936d test(gateway): add compression session_id rotation integration tests (#34089) 2026-06-07 22:39:51 -07:00
teknium1
39c4ac3af1 chore(release): add AUTHOR_MAP entry for JimStenstrom 2026-06-07 22:30:02 -07:00
JimStenstrom
cb5c24e37d fix(agent): sync logging session context on compaction id rotation
When context compaction rotates agent.session_id, it updates the gateway/tools
session context (set_current_session_id -> HERMES_SESSION_ID env + ContextVar)
but never updates the separate logging session context. The [session_id] tag on
log lines comes from hermes_logging._session_context (set once per turn in
conversation_loop.py), so post-compaction log lines in the same turn carry the
STALE old id while the message/DB/gateway state carry the new one — breaking log
correlation exactly at the compaction boundary.

Call hermes_logging.set_session_context(agent.session_id) alongside the existing
set_current_session_id, guarded so a logging failure can't regress the routing
update. Logs-only; no runtime or caching impact.

Refs #34089
2026-06-07 22:30:02 -07:00
Teknium
8e223b36ed fix(curator): protect load-bearing built-in skills from archival/consolidation (#41817)
The curator's idle-archival path (apply_automatic_transitions under
prune_builtins) could archive the bundled `plan` skill, killing the
/plan slash command silently — typing /plan then returned 'Unknown
command' with no signal that a skill had vanished. The archived skill's
hash stays in .bundled_manifest, so 'hermes update' wouldn't re-seed it.

Add PROTECTED_BUILTIN_SKILLS ({plan}) enforced at the master gate
is_curation_eligible() (covers archive_skill + the transition walk) and
in the candidate enumerator (so the LLM consolidation pass never sees
them). Immune to prune_builtins, pin state, and LLM judgment.
2026-06-07 22:23:29 -07:00
Teknium
777dc9da62 feat(acp): emit session provenance metadata for compression rotation (#41724)
Closes #33617. Adds additive _meta.hermes.sessionProvenance to ACP session
surfaces so clients can detect compression-driven internal session rotation
without parsing status text, guessing from token drops, or reading state.db.

Derived on demand from the existing compression chain (parent_session_id /
end_reason) — no new persisted state, no schema change, no ACP protocol change.
ACP session_id stays the stable client handle.

- acp_adapter/provenance.py: derive provenance from SessionDB
- server.py: attach _meta to new/load/resume responses; emit a
  session_info_update when the internal head rotates during a prompt
2026-06-07 22:22:21 -07:00
teknium1
240c5d4543 chore: map martin.alca@gmail.com -> draix in AUTHOR_MAP
Salvage follow-up for PR #33221 — the cherry-picked commit is authored
under martin.alca@gmail.com (not the draixagent@gmail.com already mapped),
which would fail the CI author-attribution gate.
2026-06-07 22:22:01 -07:00
Martín Alcalá Rubí
132d6fe6d6 fix(volcengine): strip XML attribute fragments from tool_use.name (#33007)
VolcEngine's api/plan endpoint occasionally leaks raw XML attribute
fragments into tool_use.name when its protocol-translation layer
converts the model's native XML-style tool emission to Anthropic
Messages tool_use blocks, producing names like:

  terminal" parameter="command" string="true
  execute_code" parameter="code" string="true
  session_search" parameter="session_id" string="true

The corruption happens server-side at the provider, but it breaks
every tool call for affected users — no normalization rule in
repair_tool_call can rescue them, so each request runs through three
retries and then aborts as partial.

Add an early sanitizer in agent_runtime_helpers.repair_tool_call that
trims at the first ' " ', " ' ", '<', or '>' character (idx > 0
only) so the rest of the existing repair pipeline (lowercase /
snake_case / fuzzy match) can resolve the cleaned name normally.

Whitespace is deliberately NOT a separator — the legitimate
"write file" -> write_file repair path (covered by
test_space_to_underscore) must keep working.

Tests: 11 new regression cases in TestVolcEngineXmlPollution
covering all three observed polluted names, CamelCase + pollution
mix, single-quote variants, angle-bracket variants, clean-name
passthrough, and the whitespace-preservation guard. All 18 pre-
existing repair tests still pass (29 total in the file).
2026-06-07 22:22:01 -07:00
teknium1
f5bd09af4b refactor(acp): share interrupt-sentinel prefix, simplify guard
Replace the ACP-local prefix/suffix matcher + helper with a single
startswith() check against INTERRUPT_WAITING_FOR_MODEL_PREFIX, now
defined once in conversation_loop.py where the sentinel is produced.
Keeps the source of truth in one place so the guard cannot drift if
the status string changes. Net -17 LOC in server.py.

Also add lsaether to release.py AUTHOR_MAP.
2026-06-07 22:20:43 -07:00
lsaether
9b631e4ae1 fix(acp): suppress cancel interrupt sentinel 2026-06-07 22:20:43 -07:00
Teknium
2789bf4e25 fix(auxiliary): route Codex Responses path through shared converter (#5709)
The auxiliary Codex adapter maintained its own chat->Responses conversion
loop that forwarded every non-system message's role verbatim into
Responses input[]. When flush_memories()/compression replayed session
history containing assistant tool_calls + role=tool results, those tool
messages leaked into the request and the Responses API rejected them with
HTTP 400: Invalid value: 'tool'.

Route _CodexCompletionsAdapter.create() through the same shared converter
the main agent transport uses (_chat_messages_to_responses_input), so tool
calls become function_call items and tool results become function_call_output
items with a valid call_id. Single conversion path means no future drift.

Also remove the now-dead _convert_content_for_responses() helper — its only
caller was the private conversion loop this change deletes.

Co-authored-by: ProgramCaiCai <techxacm@gmail.com>
2026-06-07 22:18:31 -07:00
teknium1
568e127612 refactor(cli): extract 25 more subcommand parsers into hermes_cli/subcommands/
Batch extraction of every remaining subcommand whose handler is top-level and
whose parser block is pure argparse: model, setup, postinstall, whatsapp, slack,
login, logout, auth, status, webhook, hooks, doctor, security, dump, debug,
backup, import, config, version, update, uninstall, dashboard, gui, logs,
prompt-size.

Each becomes hermes_cli/subcommands/<name>.py with build_<name>_parser() and an
injected handler (no main import). dashboard also injects cmd_dashboard_register
for its nested 'register' action.

Behavior-neutral: all 25 subcommands' --help output (and nested subaction help)
diff-verified byte-identical to pre-extraction. Two RawDescriptionHelpFormatter
epilogs (debug, logs) needed their multi-line string interiors preserved at
column 0 — caught by the --help diff, not compile.

main() 3297 -> 1798 LOC across this PR; add_parser calls in main.py 179 -> 89.

Validation: tests/hermes_cli/ 6476 passed / 0 failed under per-file process
isolation; new test_subcommands_batch.py smoke-tests all 25 builders + the
dashboard two-handler case.
2026-06-07 22:18:14 -07:00
teknium1
4da45e8727 refactor(cli): extract profile + gateway/proxy parsers into hermes_cli/subcommands/
Follow-on to the cron extraction in the same Phase 2 PR. Same pattern:
per-group build_<name>_parser() functions with injected handlers, no main
import.

- subcommands/profile.py: build_profile_parser (190-line block out of main()).
- subcommands/gateway.py: build_gateway_parser (gateway + proxy, 238-line block;
  they shared one inline section). Imports argparse for SUPPRESS defaults.
- main(): two more inline blocks become single builder calls.

Behavior-neutral: 'profile [sub] --help' and 'gateway/proxy [sub] --help'
byte-identical to pre-extraction (diff-verified).

main() now 2723 LOC (was 3297 at Phase 2 start); add_parser calls in main.py
179 -> 141.

Validation: tests/hermes_cli/ 6476 passed / 0 failed under per-file process
isolation; new builder unit tests cover subactions, aliases, dispatch, flags.
2026-06-07 22:18:14 -07:00
teknium1
b2e6053243 refactor(cli): extract hermes cron parser into hermes_cli/subcommands/ (god-file Phase 2)
Phase 2 of the god-file decomposition plan. main()'s argparse tree is 179
inline add_parser calls in one 3,297-line function. This establishes the
hermes_cli/subcommands/ package and extracts the first group (cron) as the
proof-of-pattern:

- hermes_cli/subcommands/_shared.py: shared parser helpers (add_accept_hooks_flag),
  re-exported from main.py for backwards compat.
- hermes_cli/subcommands/cron.py: build_cron_parser(subparsers, cmd_cron=...).
  Handler injected so the module never imports main (cycle avoidance).
- main()'s ~155-line inline cron block becomes one build_cron_parser() call.

Behavior-neutral: 'hermes cron create --help' output is byte-identical to
origin/main. main() 3297 -> 3143 LOC.

Validation: tests/hermes_cli/ 6466 passed / 0 failed under per-file process
isolation; new test_subcommands_cron.py covers subactions, aliases, options,
no-agent tristate, injected dispatch, and --accept-hooks.
2026-06-07 22:18:14 -07:00
teknium1
54870847cb refactor(agent): extract run_conversation prologue into agent/turn_context.py
Phase 1 of the god-file decomposition plan. run_conversation's ~470-line
once-per-turn setup block (stdio guarding, retry-counter resets, user-message
sanitization, todo/nudge hydration, system-prompt restore-or-build,
crash-resilience persistence, preflight compression, the pre_llm_call hook, and
external-memory prefetch) is moved verbatim into build_turn_context(), which
returns a TurnContext dataclass the loop unpacks.

Behavior-neutral move-and-name refactor: the builder mutates `agent` exactly as
the inline code did; only the locals the loop reads back are returned.

- run_conversation: 4602 -> 4217 LOC (-385)
- agent/conversation_loop.py: 4965 -> ~4580 LOC
- new agent/turn_context.py: focused, dependency-injected, unit-tested in isolation

Tests: tests/run_agent/ 1570 passed / 0 failed under per-file process isolation.
Relocation follow-ups: 413_compression mocks now patch both module references;
nudge/on_turn_start source-inspection guards point at the extracted module.
2026-06-07 22:17:35 -07:00
Teknium
86c537d209 fix(memory): instruct in-turn consolidation + retry on overflow (#41755)
* fix(memory): make overflow errors instruct in-turn consolidation + retry

When bounded memory is full, the add/replace overflow errors now explicitly
tell the model to consolidate (merge/remove/shorten) and retry the write in
the same turn, matching the documented behavior. The replace-overflow path
now also echoes current_entries + usage for parity with add-overflow, so the
model has the same context to act on.

Closes #23378 (working-as-documented; this sharpens runtime to match docs).

* fix(memory): broaden overflow remediation hint beyond 'stale'

Say 'stale or less important' — entries don't have to be stale to be the
right ones to drop when making room.
2026-06-07 22:16:28 -07:00
teknium1
2a10da3a16 fix(gateway): keep /model + /reasoning overrides on topic recovery & compression splits
Session-scoped /model and /reasoning overrides were silently lost on
Telegram DM/forum topics and after compression session splits (#30479).

Root cause: _handle_message_with_agent rewrites source.thread_id via
_recover_telegram_topic_thread_id (lobby/stripped reply -> the user's
bound topic) before deriving the session key. The /model and /reasoning
handlers derived their override key from the raw inbound event.source,
skipping that recovery, so the override was stored under one key and the
next message turn read a different key.

Fix: add _normalize_source_for_session_key (applies the same recovery a
message turn does) and use it in both handlers before deriving the key.
session_id rotation on compression was never the cause — overrides are
keyed by the durable session_key; the split path preserves it.

Author: teknium1 <127238744+teknium1@users.noreply.github.com>
2026-06-07 22:10:32 -07:00
Hariharan Ayappane
b8469a81e3 fix(weixin): add rate-limit circuit breaker 2026-06-07 22:10:17 -07:00
Teknium
2e62862784 fix(telegram): use get_running_loop in polling-conflict retry reschedule (#41716)
The conflict-retry path called asyncio.get_event_loop() to reschedule
itself when a retry's start_polling raised. On Python 3.11+ (our floor)
that raises 'RuntimeError: There is no current event loop in thread
MainThread' when no loop is attached to the thread, which is what
happens when PTB dispatches this error callback. The retry never gets
scheduled, the adapter goes silent-but-alive, and gateway --replace
keeps spawning fresh instances that hit the same wall — the crash loop
reported in #19471 (worse under multi-profile, where two bots hold the
same conflict open).

We are inside a coroutine here, so asyncio.get_running_loop() is the
correct, guaranteed-valid replacement. Only get_event_loop() call in
any platform adapter, so no sibling sites.

Fixes #19471
2026-06-07 22:10:03 -07:00
teknium1
b5f7a1f299 chore(release): add basilalshukaili to AUTHOR_MAP 2026-06-07 22:09:45 -07:00
dusterbloom
cca3b77a4b fix(compression): clear _previous_summary on session end (defense-in-depth)
ContextCompressor inherited a no-op on_session_end() from ContextEngine, so
per-session iterative-summary state (_previous_summary) survived a real session
boundary on a reused compressor instance. Override it to clear the summary the
moment the owning session ends, complementing the point-of-use guard in
compress(). Closes the cross-session contamination path in #38788.

Co-authored-by: dusterbloom <32869278+dusterbloom@users.noreply.github.com>
2026-06-07 22:09:45 -07:00
Basil Al Shukaili
8513a6aec7 fix(compression): guard against cross-session stale _previous_summary contamination
When a cron or background session compacts, it sets _previous_summary for
iterative updates. If that session ends without /new or /reset (which calls
on_session_reset()), the stale summary survives on the ContextCompressor
instance. A subsequent live messaging session's compaction then injects it as
'PREVIOUS SUMMARY:' into the summarizer prompt — contaminating the live
session with unrelated content from the prior session.

Add an else guard in compress(): when no handoff summary is found in the
current messages but _previous_summary is non-empty, discard it so
_generate_summary() starts fresh instead of iteratively updating a stale
cross-session summary.

Fixes #38788
2026-06-07 22:09:45 -07:00
Teknium
ad8e57793d fix(hermes_time): implement reset_cache() referenced in docstrings (#41728)
The module docstring and get_timezone()/cache comments documented a
reset_cache() helper for forcing tz re-resolution after config changes,
but the function was never defined — doc-followers calling it hit
AttributeError. Adds the helper to clear the cached tz state.

Surfaced in #32043.
2026-06-07 22:08:01 -07:00
Teknium
5408013369 fix(gateway): isolate DM sessions on user_id when chat_id is absent (#41764)
build_session_key collapsed every DM that arrived without a chat_id into
one shared 'agent:main:<platform>:dm' key. A single cached AIAgent then
served multiple users' conversations, bleeding history across senders.

DMs now fall back to the sender's user_id_alt/user_id (mirroring the
group-path participant precedence and the telegram auth-path fallback)
before the bare per-platform sink. Telegram's normal event path always
sets chat_id, so this hardens the synthetic-source / non-standard-adapter
paths that don't.
2026-06-07 22:07:07 -07:00
Teknium
a77bc2c08d fix(compression): disable compression on background-review fork to prevent cross-turn stale-parent fork (#41708)
The per-session compression lock prevents same-window concurrent forks but
not cross-turn ones: the background-review fork shares the parent's
session_id, so if it won a compression race its new child session was never
adopted by the gateway (the fork is single-lifecycle). The next foreground
turn then started from the stale parent and compressed it again, leaving the
same parent with two sibling children.

Set review_agent.compression_enabled = False so the fork never triggers
compression. Both trigger sites in conversation_loop.py gate on
compression_enabled before calling _compress_context, so the fork can never
rotate the shared parent. Review needs full context anyway — compressing
would degrade the memory/skill summary.

The per-session lock is kept as defense-in-depth for any future shared-session
path. Adds a regression test that fails without the flag and passes with it.

Closes #38727
2026-06-07 22:06:48 -07:00
Teknium
48ae8029aa fix(delegate): resolve custom-endpoint subagent pools by endpoint identity (#41730)
Subagents delegated to a custom endpoint were misrouted when the parent
ran on a different custom endpoint. Both runtimes collapse to
provider="custom", so _resolve_child_credential_pool() treated them as
interchangeable and handed the child the parent's pool. Leasing from it
then overwrote the child's delegated base_url with the parent's endpoint
via _swap_credential() — the child sent the delegated model name to the
wrong endpoint.

Custom runtimes now resolve by endpoint identity (the custom:<name> pool
key derived from base_url). The parent pool is reused only when both
parent and child resolve to the same custom endpoint; unregistered raw
endpoints return None so the child keeps its fixed delegated credential.
Non-custom provider paths are unchanged.

Fixes #7833.
2026-06-07 22:05:14 -07:00
Teknium
bddc5fd087 fix(desktop): fail loudly instead of blank-paging when the renderer bundle is missing (#41729)
A packaged desktop app launches to a blank page with a bare
ERR_FILE_NOT_FOUND when dist/index.html isn't in the bundle (#39484).
This happens when the build step fails (e.g. a stale checkout that
fails typecheck) but electron-builder packages anyway, shipping an
empty dist/.

- build-time: scripts/assert-dist-built.cjs runs at the tail of the
  `build` script and aborts before electron-builder if dist/index.html
  or the vite JS bundle is missing/empty. Every packaging path
  (pack, dist*) inherits it via `npm run build &&`.
- runtime: resolveRendererIndex() now logs a clear 'packaged without a
  renderer bundle — rebuild with hermes desktop --force-build' message
  when no index.html exists, instead of silently loading a missing path.
- runtime: resolveWebDist() logs when it falls back to an asar-internal
  dist that isn't a real directory (the dashboard 404 class, #41327/#39472),
  rather than returning an unservable path silently.

Adds scripts/assert-dist-built.test.cjs (node:test) covering the guard.
2026-06-07 22:04:39 -07:00
liuhao1024
53a2ac8f2d fix(desktop): unpack dist/ from asar so dashboard static files are servable
The dashboard backend serves HTTP 404 on all static routes (/, /assets,
/health) in packaged builds because resolveWebDist() points at
app.asar.unpacked/dist/, but dist/** was not listed in asarUnpack.

Add dist/** to the asarUnpack glob list so electron-builder extracts the
built frontend assets alongside the asar archive, making them accessible
to the Express static file server at runtime.

Fixes #41327
2026-06-07 22:04:36 -07:00
Teknium
ace4b722dc feat(skills): add simplify-code skill — parallel 3-agent code review and cleanup (#41691)
Inspired by Claude Code's /simplify. A bundled skill that captures recent
changes via git diff, fans out three focused reviewers (reuse, quality,
efficiency) via delegate_task batch mode, then aggregates findings and
applies the fixes worth applying.

Zero core changes — orchestrates existing tools (terminal/git, search_files,
delegate_task). Supports focus, dry-run, and scoped-diff modifiers.

Closes #379.
2026-06-07 22:02:41 -07:00
teknium1
0c67d4015f chore(release): map islam666 for as-is salvage batch 2026-06-07 21:50:57 -07:00
islam666
78e2101cd2 fix: reap zombie subprocesses in web_server action status and meet_bot cleanup
- web_server.py: after proc.poll() returns a non-None exit code, call
  proc.wait() to reap the child and move the entry from _ACTION_PROCS
  to _ACTION_RESULTS. Previously .poll() alone left <defunct> zombies.
- meet_bot.py: terminate and wait on the pcm_pump subprocess (paplay/
  ffmpeg) during the finally-block teardown. Previously leaked on every
  normal bot exit.
- tests: add test_action_status_reaps_completed_process and
  test_action_status_ignores_wait_failure covering both the happy path
  and the wait()-raises-OSError edge case.

Closes #38032
2026-06-07 21:50:57 -07:00
islam666
e53b74c394 fix(dist): stop USER_OWNED_EXCLUDE from filtering nested directories
The copytree ignore lambda in _copy_dist_payload applied USER_OWNED_EXCLUDE
recursively at every directory depth. This caused nested directories whose
names matched exclude entries (bin, logs, cache, etc.) to be silently dropped
during distribution install/update.

Fix: only apply USER_OWNED_EXCLUDE filtering at the root of the staged tree,
matching the two-tier pattern used by _clone_all_copytree_ignore and
_default_export_ignore in profiles.py.

Add 5 tests covering nested bin/logs/cache preservation and top-level
filtering still working.

Fixes #37954
2026-06-07 21:50:57 -07:00
islam666
09a5548628 fix(weixin): refresh typing ticket on expiry to prevent stuck indicator (#38085)
The WeChat iLink typing ticket has a 600-second TTL. When a long-running
session exceeds that window, the cached ticket evicts from TypingTicketCache.
Both send_typing and stop_typing silently returned early when the ticket was
None, meaning the TYPING_STOP=2 signal was never sent to iLink. The WeChat
client then showed the typing indicator indefinitely.

Fix: add _ensure_typing_ticket() that transparently refreshes the ticket
via getConfig when the cached one has expired or is missing. Both send_typing
and stop_typing now call this method instead of silently no-oping.

Fixes #38085
2026-06-07 21:50:57 -07:00
islam666
2e61de0638 fix(model_metadata): consult DEFAULT_CONTEXT_LENGTHS before 256K fallback on custom endpoints
Problem: get_model_context_length() had an early return at the end of the
custom-endpoint probe branch (step 3) that returned DEFAULT_FALLBACK_CONTEXT
(256K) without ever consulting the hardcoded DEFAULT_CONTEXT_LENGTHS catalog
(step 8). Models served through a custom/proxied gateway (e.g. corporate
Anthropic proxy) that didn't expose Ollama or local-server endpoints would
hit this path and get capped at 256K, even when the model name clearly
matched a known entry in the catalog (e.g. claude-opus-4-8 → 1M).

Changes:
- agent/model_metadata.py: Before returning DEFAULT_FALLBACK_CONTEXT at the
  end of the custom-endpoint branch, consult DEFAULT_CONTEXT_LENGTHS using
  the same longest-key-first fuzzy matching as step 8. Only fall through
  to 256K if no catalog entry matches.
- tests/agent/test_model_metadata.py: Updated existing test and added new
  test covering the custom-endpoint → catalog fallback behavior.

Fixes #38865
2026-06-07 21:50:57 -07:00
islam666
f1d3afb151 fix(profiles): skip 'default' in named profiles scan to prevent duplicates
When ~/.hermes/profiles/default/ exists as a directory, list_profiles()
returns 'default' twice: once as the built-in default profile (~/.hermes)
and once from the directory scan (~/.hermes/profiles/default).

This causes the cron dashboard API (profile=all) to read the same
jobs.json twice, showing every default-profile job duplicated in the UI.

Fix: skip name=='default' in the named profiles loop, since it's already
added as the built-in default at the top of the function.

Fixes #39346
2026-06-07 21:50:57 -07:00
islam666
9513793ad7 fix(vision): proactive downgrade for providers rejecting list-type tool content (#41072)
Xiaomi MiMo (and potentially other providers) support multimodal user
messages but reject list-type tool message content with 400 'text is not
set'. Previously this was handled reactively — the API call would fail,
images would be stripped, and the request retried, losing visual info.

Fix: add supports_vision_tool_messages field to ProviderProfile (default
True). Xiaomi sets it to False. _tool_result_content_for_active_model
now checks this field proactively and returns a text summary instead of
list content, avoiding the round-trip failure entirely.
2026-06-07 21:50:57 -07:00
islam666
41f0714287 fix(vision): honor custom_providers per-model supports_vision (#41036)
_supports_vision_override() in image_routing.py checked model.supports_vision
and providers.<name>.models, but not the legacy list-style custom_providers
config. A custom provider entry like:

  custom_providers:
    - name: my-provider
      models:
        my-model:
          supports_vision: true

was ignored, causing image_input_mode=auto to route through the auxiliary
vision_analyze path instead of natively attaching images.

Fix: added a lookup step for custom_providers list entries, matching by
provider name (including 'custom:<name>' variants at runtime).
providers.<name>.models still takes precedence over custom_providers.

13 new tests covering: true/false override, custom: prefix matching,
no-match fallback, non-dict entries, empty lists, models key missing.
2026-06-07 21:50:57 -07:00
islam666
18c085b1a4 fix(gateway): normalize optional systemd directives in stale-check (#41119)
On older systemd versions that don't support RestartMaxDelaySec /
RestartSteps, the installed unit file has those directives silently
dropped. systemd_unit_is_current() did a strict text comparison, so
the unit was perpetually flagged as outdated.

Fix: _strip_optional_systemd_directives() removes RestartMaxDelaySec
and RestartSteps from both the installed and expected text before
comparison. Units that differ only by these optional directives are
now correctly considered current.
2026-06-07 21:50:57 -07:00
islam666
b18490b890 fix(compaction): prevent infinite loop when transcript fits in tail budget
When summary_target_ratio is large (e.g. 0.45) and the context_length is
moderate (e.g. 96000), the soft_ceiling (token_budget * 1.5) can exceed
the total transcript size.  _find_tail_cut_by_tokens walks the entire
transcript without breaking early, and the resulting compress window is
either empty (compress_start >= compress_end) or a single message whose
summary-of-one overhead saves ~0 tokens.

Both outcomes cause a no-op compression that does not increment
_ineffective_compression_count, so should_compress() returns True on
every subsequent turn and the loop repeats endlessly.

Fix (two layers):
1. _find_tail_cut_by_tokens: when the backward walk consumed the entire
   transcript without breaking (cut_idx <= head_end and accumulated <=
   soft_ceiling), re-walk with the raw (non-inflated) token budget to
   find a meaningful cut that gives the summarizer a useful middle window.
2. compress(): when compress_start >= compress_end, increment
   _ineffective_compression_count and log a warning so the existing
   anti-thrashing guard in should_compress() can break the loop.

Fixes #40803
2026-06-07 21:50:57 -07:00
teknium1
38d1a414a1 chore: add islam666 to AUTHOR_MAP for salvaged PR #39624 2026-06-07 21:50:25 -07:00
islam666
09ec26c66a fix(ollama): set default_max_tokens for custom/Ollama provider
The custom/Ollama provider profile had no default_max_tokens, so no
max_tokens was sent on requests and Ollama fell back to its internal
num_predict=128 — truncating responses after a few tokens with
finish_reason='length' (#39281, e.g. gemma4).

max_tokens resolution is ephemeral > user model.max_tokens > profile
default, so this is only a floor used when the user hasn't set their own
cap. Set it to 65536 (matching the qwen-oauth tier) rather than a
conservative value, since users can always override per-model.

Fixes #39281
2026-06-07 21:50:25 -07:00
Brian D. Evans
ab0a6270c3 fix(slack): align thread_ts check with is_thread_reply invariant (Copilot #15464)
Two findings from Copilot's review on #15464, both addressed:

1. ``event.get("thread_ts")`` truthy vs
   ``event_thread_ts != ts``: the new channel branch treated ANY
   truthy ``thread_ts`` as a real thread reply, but three lines below
   ``is_thread_reply`` is defined with the stricter
   ``event_thread_ts and event_thread_ts != ts`` invariant.  If Slack
   ever ships a payload where ``thread_ts == ts`` on a thread root,
   the stricter check would treat it as a top-level message for the
   ``is_thread_reply`` path but as a thread reply for session keying
   — divergent behaviour.  Aligned this branch to the same
   ``and event_thread_ts_raw != ts`` invariant.

2. ``test_top_level_reply_to_id_stays_none_when_shared`` docstring
   had the ternary logic backwards ("None != ts → reply_to_message_id
   IS set").  The code reads
   ``reply_to_message_id = thread_ts if thread_ts != ts else None`` —
   with ``thread_ts = None``, the condition is True so the expression
   evaluates to ``thread_ts`` itself (None), meaning the reply stays
   un-threaded.  The test asserted the correct end-state; only the
   explanatory docstring was wrong.  Rewrote the docstring to match
   the actual code flow, with the note that Copilot caught the
   reversal.

7/7 tests still pass.  No behaviour change for the existing
test_thread_reply_scopes_by_thread_even_when_shared case because
``event_thread_ts_raw = "1700000000.000000"`` and ``ts =
"1700000000.000005"`` are distinct — the new
``!= ts`` guard is a no-op there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 21:19:59 -07:00
Brian D. Evans
133e0271e2 fix(slack): scope top-level channel messages by channel-only when reply_in_thread=false (#15421)
Top-level Slack channel messages previously fell back to the message's
own ``ts`` as a synthetic ``thread_ts``:

    thread_ts = event.get("thread_ts") or ts  # ts fallback for channels

That value flows into ``build_source(thread_id=thread_ts)`` at
line 1247.  The gateway session store keys sessions by
``(platform, channel_id, thread_id)``, so every top-level channel
message ended up on a unique session.  Operators who set
``reply_in_thread: false`` in ``config.yaml`` expected all top-level
channel messages to share one session (the whole point of that flag)
— instead each one spawned a fresh conversation with no context
carry-over.

### Fix

Three explicit cases in the channel branch:

| event.thread_ts | reply_in_thread | thread_ts for session keying |
|---|---|---|
| non-null (real thread reply) | either | event.thread_ts |
| null (top-level) | true (default) | ts (legacy: own-thread sessions) |
| null (top-level) | false | **None** (shared channel session) |

The outbound-reply gate at line 1264 (``reply_to_message_id =
thread_ts if thread_ts != ts else None``) still works correctly in
all three cases without further changes: ``None != ts`` is True, so
shared-channel top-level messages don't get their reply threaded
either — matching the operator's ``reply_in_thread=false`` intent
end-to-end.

Genuine thread replies still scope per-thread under both modes so
multi-person threaded conversations can't collide with unrelated
channel chatter.

### Tests (7 new in ``tests/gateway/test_slack_channel_session_scope.py``)

All drive the real ``SlackAdapter._handle_slack_message`` code path
(not a re-implementation) via the standard pytest fixture pattern
used by ``tests/gateway/test_slack.py``.  Messages @mention the bot
so the mention gate doesn't drop them — the tests are specifically
about what happens once the handler decides to emit a ``MessageEvent``.

* ``TestChannelSessionScopeDefault`` (2 cases):
  - Explicit ``reply_in_thread: true`` keeps ``thread_id = ts``
    (legacy behaviour — regression guard)
  - Unset config behaves like ``reply_in_thread: true`` (pins the
    default)
* ``TestChannelSessionScopeShared`` (3 cases):
  - ``reply_in_thread: false`` + top-level → ``thread_id is None``
    (the #15421 bug 1 fix)
  - ``reply_to_message_id is None`` in the same case (no threaded
    outbound reply)
  - Genuine thread reply still scopes per-thread when shared mode is
    on — only TOP-LEVEL messages collapse to the channel session
* ``TestThreadReplyAlwaysScopesByThread`` (2 parametrised cases):
  - Thread replies get ``thread_id = event.thread_ts`` regardless of
    ``reply_in_thread`` — critical invariant for multi-thread
    channels; a regression here would leak per-thread context across
    threads

**Regression guard verified**: reverted the else-branch to the legacy
``thread_ts = event.get("thread_ts") or ts`` one-liner;
``test_top_level_maps_to_none_when_reply_in_thread_false`` correctly
failed (asserts ``thread_id is None`` but got ``"1700000000.000003"``).
Restored → 182 slack tests pass (175 existing + 7 new).

Scope: this fixes #15421 bug 1 only.  Bug 2 (sessions.json not
persisting across compression) lives elsewhere in the session
manager and is left for a separate diff.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 21:19:59 -07:00
brooklyn!
b5a457c033 fix(desktop): persist zoom level via renderer localStorage (#41747)
Desktop zoom shortcuts (Cmd/Ctrl +/-/0) and the View menu only called
webContents.setZoomLevel(), which mutates the live renderer but persists
nothing. On reload, renderer crash/restart, or page recreation the app
snapped back to the default zoom, so the shortcuts felt broken for users
who need larger text.

Persist the selected zoom in the renderer's own localStorage rather than a
main-process JSON file. localStorage is per-origin and survives the
renderer lifecycle automatically, so there's no atomic-write/userData file
machinery to maintain. The main process still owns setZoomLevel: every
zoom change is mirrored into localStorage via executeJavaScript, and the
value is read back and re-applied on did-finish-load (covering reloads and
crash recovery). Clamping to Electron's [-9, 9] range now happens once in
setAndPersistZoomLevel instead of at each call site.
2026-06-07 22:43:09 -05:00
brooklyn!
d65b513f23 feat(desktop): hover-reveal collapsed sidebars as fixed overlays (#41670)
* feat(desktop): hover-reveal collapsed chat sidebar as a fixed overlay

When the sessions sidebar is collapsed, hovering the left edge now floats
it back in as a fixed overlay over the main content instead of just being
hidden. The collapsed grid track stays at 0px so the panel never reserves
space — it slides over whatever's underneath and retracts on pointer-leave.

- PaneShell: new hoverReveal prop. When a pane is collapsed + hoverReveal,
  render an edge hot-zone + a side-anchored floating panel (absolute, full
  height, honors any persisted resize width) that slides in on hover/focus.
- ChatSidebar: force the (otherwise opacity-0 when collapsed) sidebar fully
  visible + interactive while the overlay is revealed, via an
  in-data-[pane-hover-reveal=open] variant.
- desktop-controller: opt the chat-sidebar pane into hoverReveal.

* feat(desktop): lower window minWidth 900→400

Lets the window shrink to a narrow rail (e.g. for the collapsed
hover-reveal sidebar) instead of being floored at 900px.

* fix(desktop): render full sidebar content in hover-reveal overlay

The hover-reveal overlay showed only the nav rail — session rows, search,
pinned/recents were gated behind `sidebarOpen` (false while collapsed), so
they never mounted in the floated panel.

Add a $sidebarRevealed store the PaneShell overlay drives via a new
onHoverRevealChange callback, and gate ChatSidebar's content on
`sidebarOpen || sidebarRevealed` (contentVisible) instead of raw open
state. The overlay now shows the complete sidebar.

* fix(desktop): drop shadow on hover-reveal sidebar overlay

* feat(desktop): hover-reveal the file-browser sidebar too

The reveal mechanism already lives in the shared Pane primitive — the
right rail just opts in with hoverReveal. Its content renders
unconditionally, so (unlike the chat sidebar) it needs no extra
content-visibility gating.

* clean(desktop): tighten hover-reveal pane code

KISS pass — flatten the translate ternary, derive a single `revealed`,
inline the edge style, drop the redundant set-guard, and trim comments to
the house one-liner style. No behavior change.

* fix(desktop): stop hiding sidebar nav labels on narrow windows

The nav labels (New session, Skills, …) and the ⌘N hint were gated on a
viewport breakpoint (max-[46.25rem]:hidden), so shrinking the window hid
them even when the sidebar itself was wide — including in the hover-reveal
overlay. Drop the gate; the label already truncates (min-w-0 flex-1) so it
ellipsizes gracefully in a narrow rail, and contentVisible already hides it
when collapsed to the icon rail.

* feat(desktop): auto-collapse both sidebars below 600px into hover-reveal

Add a Pane `forceCollapsed` prop — collapses the track without writing to
the store (so the saved open state restores when the window widens) while
keeping hoverReveal alive (unlike `disabled`, which suppresses it).

desktop-controller watches (max-width: 600px) and force-collapses the chat
sidebar + file browser, so on a narrow window both rails get out of the way
and the hover-reveal overlay becomes the way in.

* feat(desktop): hover-intent + refined easing for sidebar reveal

- Gate the reveal on pointer velocity: the full-height edge hot-zone now
  only arms on a slow, deliberate pass (<=0.55 px/ms). Fast sweeps toward
  the titlebar/statusbar — or off the window — blow past the threshold and
  never trigger, so the wide hit area stops being a nuisance.
- Swap the slide easing to cubic-bezier(0.32,0.72,0,1) at 260ms (snappy-out,
  soft-land) for a more serious-app feel.

* fix(desktop): don't reveal sidebar during window resize

Resizing the window parks the cursor on the screen edge and fires slow
pointermoves over the hot-zone, reading as deliberate intent. Guard the
reveal on (a) e.buttons !== 0 — any button-held drag, incl. edge-resize —
and (b) a 250ms cooldown after any window resize event.

* feat(desktop): hoverIntent-style poll gate + inert contents during slide

Replace the single-sample velocity check (too eager — fired on any one slow
move, incl. resize drift) with a port of Brian Cherne's hoverIntent: poll
the pointer every 90ms and only arm once it has *settled* (moved <5px between
two consecutive polls inside the edge zone). Fly-bys, pass-throughs, and
resize drift never produce two close samples in a row, so they don't trigger.

Also keep the revealed panel's CONTENTS pointer-events-none until the slide-in
transition finishes (onTransitionEnd → settled), so you can't misclick a
session row mid-animation. Resets on retract.

* fix(desktop): no cursor/hit-test leak before reveal settles

The edge hot-zone showed cursor:pointer the instant the pointer touched it —
before the panel was armed or in view. And contents were inert but the panel
itself still hit-tested, so the cursor could flip mid-slide. Fix: hot-zone is
cursor-default (it's invisible), and the whole panel is pointer-events-none
until revealed && settled, so the cursor never changes or lands on a row
before the slide-in finishes.

* fix(desktop): geometry-driven close so revealed panel always retracts

The revealed panel relied on its own onPointerLeave to close — but a panel
that slid in under a still cursor (or whose contents were inert during the
slide) never fires enter/leave, so it got stuck open (esp. the file browser).
onTransitionEnd also bubbled from the file-tree's own row transitions,
tripping the settled flag wrongly.

Replace with a document-level pointermove watcher that closes once the cursor
leaves the panel's bounding rect + a 24px grace — independent of pointer-events
state or what the contents do. Gate interactivity on a simple slide-duration
timer (interactive) instead of the fragile transitionEnd, so the cursor still
can't flip or land on a row before the panel is in view.

* feat(desktop): make sidebar toggle shortcuts reveal when force-collapsed

mod+b / mod+j were no-ops on a narrow (force-collapsed) window — they
flipped the store but the pane ignores it. Now the toggle handlers also
dispatch PANE_TOGGLE_REVEAL_EVENT; a force-collapsed Pane listens (only while
overlayActive) and flips its hover-reveal, so the shortcut floats the rail in
(and back out) at this responsive breakpoint.

* refactor(desktop): name the 600px sidebar collapse breakpoint

Hoist the inline '(max-width: 600px)' literal into
SIDEBAR_COLLAPSE_BREAKPOINT_PX + SIDEBAR_COLLAPSE_MEDIA_QUERY in
layout-constants, so the responsive collapse point is a single named source
of truth instead of a magic string in the controller.

* tweak(desktop): sidebar auto-collapse breakpoint 600px -> 768px

768 is the standard md breakpoint and a more honest 'no room to dock' point.

* tweak(desktop): halve sidebar reveal slide duration 260ms -> 130ms

* Revert "tweak(desktop): halve sidebar reveal slide duration 260ms -> 130ms"

This reverts commit 6009a13200.

* perf(desktop): pre-mount hover-reveal contents to kill slide-in stall

The reveal mounted the (heavy, virtualized) sidebar contents in the same
frame the slide started, so the browser stalled painting the transform until
the mount finished — a ~100-200ms beat before the panel moved, very visible
on the instant keyboard toggle (hover masked it via the 90ms intent poll).

Report overlayActive (collapsed-overlay mode) rather than the live reveal
state to the mount consumer, so contents stay mounted off-screen while
collapsed and reveal is a pure transform. Visibility is still driven
separately by the data-pane-hover-reveal attr + the slide transform.

* fix(desktop): make reveal hotkey spammable

Two throttles on the reveal toggle:
- The handler fired both the reveal event AND toggleSidebarOpen() per press;
  the store write hits localStorage synchronously every keystroke + recomputes
  the grid, janking rapid presses. When collapsed, only dispatch the reveal
  event (the store toggle was a no-op anyway).
- The geometry close-watcher slammed a keyboard-opened panel shut on the first
  stray pointermove (trackpad jitter), fighting hotkey spam. Keyboard reveals
  now ignore geometry until the cursor actually enters the panel, then the
  mouse takes over.

* fix(desktop): inset reveal hot-zone past the OS window-resize gutter

The hot-zone sat flush at the window edge (left-0/right-0), overlapping the
OS resize grab strip — reaching to drag-resize naturally slows the cursor
there, which hoverIntent reads as settled and reveals before the resize drag
even starts. Inset the hot-zone 8px so the outermost edge stays a pure
resize/drag region and only an intentful move just inside it arms a reveal.

* fix(desktop): keep reveal hot-zone at edge, gate arming past resize gutter

Insetting the hot-zone made it unreachable when moving fast. Instead, anchor
the zone flush at the edge (w-4, always captures the pointer) but only ARM the
reveal when the cursor settles >=8px in from the edge — so a resize-reach that
parks on the outermost OS grab strip never triggers, while a deliberate move
into the zone still does. Keeps polling while in the gutter so moving inward
still arms.

* refactor(desktop): rebuild hover-reveal as pure CSS, delete the JS state machine

The hand-rolled pointer state machine (hoverIntent poll, refs, timers, document
pointermove geometry-close, interactive gate, resize cooldowns, keyboard-held
suppression) was fragile and side/instance-specific — hover broke on the right
rail, keyboard toggles triggered phantom animations, resize popped it open.

Replace all of it with the native primitive: CSS group-hover drives the slide
transform; a transition-delay on enter (instant on leave) is the hover-intent
gate (a fast pass-by doesn't dwell long enough to open); a thin edge trigger
inset past the OS resize grab strip arms it; and a single `forced` bool
(data-forced, toggled by the keyboard event) pins it open. Side-agnostic by
construction — group-hover doesn't care which edge or which pane.

Net: ~200 lines of imperative pointer logic → ~40 lines of declarative CSS.

* fix(desktop): don't animate hover-reveal panel across viewport on side flip

Flipping panes changed the off-screen transform from -translateX (off the
left) to +translateX (off the right). transition-transform interpolated
between them, passing through translate-x-0 (fully on-screen) mid-way — so the
hidden panel visibly slid across the window to reach its new hiding spot.
Key the panel on side so it remounts off-screen on the new edge with no
transition to play.

* clean(desktop): tighten hover-reveal markup

KISS pass on the CSS-driven reveal: reuse the existing `side` instead of a
local `left`, move the static duration/ease to inline style (drop two
single-use CSS vars + their arbitrary-value classes, keep only the
state-dependent enter-delay var), and trim comments to the house one-liner
density. No behavior change.

* fix(desktop): inset titlebar past traffic lights when sidebar is force-collapsed

The titlebar content inset (clearing the macOS traffic lights) keyed off the
stored sidebarOpen/fileBrowserOpen, but below the collapse breakpoint both
rails are force-collapsed so the left edge is uncovered while the store still
says open — content (the intro wordmark) overflowed under the lights. Gate
leftEdgePaneOpen on !narrowViewport using the shared SIDEBAR_COLLAPSE_MEDIA_QUERY.

Also rename the now-misleading reveal plumbing to match what it actually does:
onHoverRevealChange -> onOverlayActiveChange, $sidebarRevealed ->
$sidebarOverlayMounted (+ setter/consumer). It reports/stores collapsed-overlay
mode (mount gate), not live reveal state.

* feat(desktop): small --nous-shadow lift on revealed hover-reveal panels

Add a --nous-shadow token (white-based on light, black-based on dark) and apply
it to the floating sidebar panel only while revealed (group-hover / data-forced)
so it reads as lifted off the surface. No shadow on the off-screen panel.

* feat(desktop): shadow-reveal lift on revealed hover-reveal panels

Mirror the --shadow-nous layered falloff into a new --shadow-reveal token whose
drop color flips per mode (white on light, black on dark) via --shadow-reveal-raw
set in :root / :root.dark. Apply the generated shadow-reveal utility to the
floated panel only while revealed (group-hover / data-forced). Leaves the shared
--shadow-nous untouched.

* feat(desktop): use tuned reveal shadow, drop per-mode token

Replace the --shadow-reveal token machinery with Brooklyn's tuned literal
(0 -18px 18px -5px #0000003b) inline per-panel via --reveal-shadow, y-offset
sign flipped for the right side. Same color both modes. Reverts styles.css to
pristine (token removed).

* fix(desktop): use the reveal shadow verbatim, don't invert it per side

Flipping the y-offset sign for the right side inverted the shadow's direction
(cast-up -> cast-down), making it read heavier — not a mirror. The mirror axis
for a left/right panel is offset-x, which is 0 here, so both sides take the
tuned value as-is: 0 -18px 18px -5px #0000003b.

* clean(desktop): hoist reveal shadow to a named const

Move the inline reveal-shadow literal to HOVER_REVEAL_SHADOW alongside the
other HOVER_REVEAL_* tuning consts; drop the now-stale per-side comment.

* fix(desktop): truncate titlebar title before the right tool cluster

The session title used a hardcoded max-w-[52vw] that's blind to where the
right-side tools start, so it ran under them at narrow widths / with pane
tools present. Bound the title container by the same vars the titlebar drag
region uses (--titlebar-content-inset + --titlebar-tools-right +
--titlebar-tools-width) so it truncates exactly at the cluster's left edge.

* fix(desktop): responsive markdown tables — floor width + nowrap headers

The wrapper had overflow-x-auto but the table was w-full with auto layout, so
instead of scrolling it crushed columns until even header words broke mid-word
(Tim/e, Nig/ht). Add a min-w-[18rem] floor so it scrolls horizontally when the
column is narrower than readable, and whitespace-nowrap on th so headers never
break mid-word. Above the floor it still wraps cells naturally.

* fix intro
2026-06-07 22:41:21 -05:00
Shannon Sands
86e5efb0ae Preserve Telegram onboarding fallback errors 2026-06-07 19:48:09 -07:00
Shannon Sands
ba29010902 Use httpx for Telegram onboarding worker calls 2026-06-07 19:48:09 -07:00
Teknium
e3b8b6d32c feat(hooks): expose thread_id and chat_type in agent:start/end context (#41672)
Adds thread_id and chat_type to the agent:start/end plugin hook context
(via getattr with safe defaults; both are real `source` attrs already used
in gateway/run.py). agent:end inherits them via **hook_ctx. Purely additive
— no prompt/history mutation. Documents the full ctx dict in hooks.py.

Co-authored-by: SNooZyy2 <SNooZyy2@users.noreply.github.com>
2026-06-07 19:16:36 -07:00
brooklyn!
fa42ac094d feat(desktop): Shift+click the status-bar zap to toggle YOLO globally (#41666)
The status-bar zap currently toggles per-session approval bypass (the same
scope as the TUI's Shift+Tab). This adds a global escape hatch: Shift+clicking
the zap flips the persistent approvals.mode in config.yaml between "off"
(bypass on) and "manual" (bypass off), affecting every session, the CLI, the
TUI, and cron — and it survives restarts.

- statusbar-controls: thread the click's shiftKey through onSelect via a new
  StatusbarSelectModifiers arg.
- yolo-session: add setGlobalYolo() that calls config.set with scope="global".
- use-statusbar-items: branch toggleYolo on modifiers.shiftKey; plain click
  stays per-session, Shift+click goes global.
- tui_gateway config.set "yolo" key: add scope="global" that reads/writes
  approvals.mode through the gateway's own (mtime-cached) config view, honors
  an explicit value, and re-emits session.info to every live session so each
  window's zap reflects the flip immediately.
- i18n: tooltip copy in en/ja/zh/zh-hant notes Shift+click toggles globally.

Tests: two new tui_gateway tests cover the global toggle and explicit-value
paths; existing session/process-scope yolo tests still pass.
2026-06-07 20:57:08 -05:00
Teknium
30c7913617 fix(api_server): report hermes version on /health and /health/detailed (#40620)
Salvaged from #40479; re-verified on main, tightened, tested.

Co-authored-by: tfournet <tfournet@users.noreply.github.com>
2026-06-07 18:38:54 -07:00
Teknium
d3b670e63e docs(codex): document --sandbox danger-full-access for gateway bubblewrap failures (#40619)
Salvaged from #40435; re-verified on main, tightened, tested.

Co-authored-by: ziwon <ziwon@users.noreply.github.com>
2026-06-07 18:36:18 -07:00
Teknium
b97cd81c78 refactor(insights): drop dead pricing/duration wrappers, call usage_pricing directly (#40618)
Salvaged from #40527; re-verified on main, tightened, tested.

Co-authored-by: HeLLGURD <HeLLGURD@users.noreply.github.com>
2026-06-07 18:33:20 -07:00
Teknium
ad399b9229 docs(update): document updates.* config keys (pre_update_backup, backup_keep, non_interactive_local_changes) (#40617)
Salvaged from #40540; re-verified on main, tightened, tested.

Co-authored-by: jiangkoumo <jiangkoumo@users.noreply.github.com>
2026-06-07 18:29:56 -07:00
Teknium
2aa316ec9c docs(windows): fix Get-Command PATH guidance to venv\Scripts\hermes.exe (#40613)
Closes #40464.

Salvaged from #40488; re-verified on main, tightened, tested.

Co-authored-by: gauravsaxena1997 <gauravsaxena1997@users.noreply.github.com>
2026-06-07 18:28:23 -07:00
Teknium
4ce9caed04 fix(tui): type execFileNoThrow stdio/ChildProcess and make memoryMonitor critical test heap-independent (#40612)
Salvaged from #40415; re-verified on main, tightened, tested.

Co-authored-by: psionic73 <psionic73@users.noreply.github.com>
2026-06-07 18:23:42 -07:00
Teknium
6bdc4c0231 test: skip curses tests on Windows where _curses is unavailable (#40611)
Salvaged from #40447; re-verified on main, tightened, tested.

Co-authored-by: Ganesh0690 <Ganesh0690@users.noreply.github.com>
2026-06-07 18:21:03 -07:00
Teknium
628780b4f3 fix(desktop): pin empty PostCSS config so Vite stops walking up the home tree (#40609)
Salvaged from #40526; re-verified on main, tightened, tested.

Co-authored-by: xxxigm <xxxigm@users.noreply.github.com>
2026-06-07 18:10:32 -07:00
xxxigm
c50fb560ef Merge pull request #40433 from xxxigm/fix/desktop-chat-autoscroll
fix(desktop): stop chat transcript from jumping/flickering while reading (#37549)
2026-06-07 20:09:55 -05:00
Teknium
69a293b419 hardening(todo): bound TodoStore item content length and count
The todo list is re-injected into the model's context after every
context-compression event (TodoStore.format_for_injection), so an oversized
todo item or an unbounded number of items defeats the compression it is meant
to ride through. TodoStore.write/_validate previously enforced no size or count
bounds, so a single 50KB item produced a ~50KB re-injection block on every
subsequent turn.

Add two caps:
- MAX_TODO_CONTENT_CHARS (4000): per-item content is truncated with a marker.
  Routed through a shared _cap_content() so the merge-update path (which writes
  content directly, bypassing _validate) is capped too.
- MAX_TODO_ITEMS (256): total list length is bounded, keeping the
  highest-priority head (list order is priority).

Both caps are generous relative to real plans — a todo item is a short task
description and active lists are a handful of items.

NOT a security fix. Raised externally via GHSA-5g4g-6jrg-mw3g, which framed a
caller-supplied conversation_history on the authenticated API server replaying
into _hydrate_todo_store as a DoS. That path is authenticated (the API server
refuses to start without API_SERVER_KEY) and self-scoped (the caller supplies
their own entire history and can only inflate their own response chain — forged
role=tool entries are never persisted to the session DB), so it is out of scope
as a vulnerability under SECURITY.md 3.2. These bounds are footgun containment
that also applies to the trusted agent path, where the model itself authors the
todos. Credit to the reporter for the observation.

Co-authored-by: YLChen-007 <30854794+YLChen-007@users.noreply.github.com>
2026-06-07 18:06:27 -07:00
teknium1
9c5d1afbe9 chore: add giladbau to AUTHOR_MAP for salvaged PR #20182 2026-06-07 18:05:58 -07:00
Gilad Bauman
ae82eed2b1 fix(gateway): use OGG for Telegram auto TTS 2026-06-07 18:05:58 -07:00
Teknium
cb83149dc6 fix(yuanbao): bound ws.close() so an idle server can't stall shutdown ~5s (#40607)
Salvaged from #40421; re-verified on main, tightened, tested.

Co-authored-by: maxmilian <maxmilian@users.noreply.github.com>
2026-06-07 17:49:38 -07:00
AMIK
2b119baac1 docs: add Urdu translation of README (#40578)
Co-authored-by: AMIK-coorporations <info@amik.co>
2026-06-08 06:15:27 +05:30
Teknium
09d66037f8 fix(hindsight): send only new-turn delta on append retains instead of whole session (#40605)
Closes #40503.

Salvaged from #40519; re-verified on main, tightened, tested.

Co-authored-by: skylarbpayne <skylarbpayne@users.noreply.github.com>
2026-06-07 17:41:10 -07:00
Teknium
dde9c0d19d feat(gateway): render terminal tool calls as native bash code blocks on markdown platforms (#41215)
Tool-progress now shows a terminal command in a ```bash fenced block —
full command, no surrounding quotes, no label, no 40-char truncation —
instead of the noisy `terminal: "cmd…"` line, on every platform that
renders markdown code blocks (Telegram, Slack, Matrix, WhatsApp, Feishu,
Weixin, Discord). Plain-text platforms keep the compact preview line.

Gated on a new `BasePlatformAdapter.supports_code_blocks` capability
(default False) rather than a hardcoded platform list, so plugin adapters
(Discord lives in plugins/platforms/) opt in by setting the flag. Applies
to both all/new and verbose progress modes, with a safe fallback when the
command arg is missing or blank.
2026-06-07 17:29:55 -07:00
Teknium
e029b7597b feat(desktop): stop the chat viewport from following streaming output (#41414)
The desktop chat GUI pinned the viewport to the bottom on every content
growth while a turn streamed, so the window chased tokens as they arrived.
Remove that follow behavior: once a turn is running the viewport stays
exactly where the user left it.

- Delete the streaming ResizeObserver re-pin loop in useThreadScrollAnchor.
- Delete the post-run bottom lock (kept pinning ~1.2s after completion).
- Keep the one-time jump-to-bottom on user submit / new turn / session
  change so a freshly submitted message still lands in view.
- Update streaming.test.tsx to assert the viewport no longer follows
  streaming growth or snaps down on final code-highlight remeasure.
2026-06-07 17:29:32 -07:00
teknium1
1c7ae46f0e chore(release): map AlchemistChaos co-author email for #40135 salvage 2026-06-07 17:29:12 -07:00
teknium1
cadb74adad fix(desktop): recover chat after sleep/wake by revalidating a stale remote backend
After sleep/wake, a remote (global-remote) primary backend can become
unreachable, but it has no child process whose 'exit' clears the main
process's cached connectionPromise. The renderer then re-dials the same
dead remote forever and the composer stays stuck on "Starting Hermes…";
only a quit+reopen recovered.

Fix: the renderer's existing backoff-paced reconnect loop now asks the
main process to revalidate the cached connection before re-dialing. The
main process liveness-probes the cached REMOTE backend's public
/api/status and, if unreachable, drops the cache (resetHermesConnection
only nulls connectionPromise for a remote — no child to SIGTERM) so the
next getConnection() rebuilds a reachable descriptor. Local backends are
never touched here; they self-heal via the child 'exit' handler. The
renderer's loop already provides retry pacing and rides out transient
blips, so no streak/episode bookkeeping is needed in the main process.

The boot hook dismisses the boot-progress overlay on the post-rebuild
'open' so an in-place rebuild can't leave it stuck at ~94%.

Reimplements #40135 by @AlchemistChaos on a smaller, more interpretable
path (63 added lines vs 555): no extracted helper module, no
failure-streak / episode-window state, the renderer's backoff loop is
the retry mechanism. Original diagnosis and fix by @AlchemistChaos.

Co-authored-by: AlchemistChaos <alchemistchaos@protonmail.com>
2026-06-07 17:29:12 -07:00
480 changed files with 217268 additions and 4753 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
@@ -174,8 +176,15 @@ RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra
COPY --chown=hermes:hermes . .
# Build browser dashboard and terminal UI assets.
# 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 now ships. (CI must verify the full image build on Node 26.)
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
# ---------- Permissions ----------
# Make install dir world-readable so any HERMES_UID can read it at runtime.

View File

@@ -10,6 +10,7 @@
<a href="https://github.com/NousResearch/hermes-agent/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-MIT-green?style=for-the-badge" alt="License: MIT"></a>
<a href="https://nousresearch.com"><img src="https://img.shields.io/badge/Built%20by-Nous%20Research-blueviolet?style=for-the-badge" alt="Built by Nous Research"></a>
<a href="README.zh-CN.md"><img src="https://img.shields.io/badge/Lang-中文-red?style=for-the-badge" alt="中文"></a>
<a href="README.ur-pk.md"><img src="https://img.shields.io/badge/Lang-اردو-green?style=for-the-badge" alt="اردو"></a>
</p>
**The self-improving AI agent built by [Nous Research](https://nousresearch.com).** It's the only agent with a built-in learning loop — it creates skills from experience, improves them during use, nudges itself to persist knowledge, searches its own past conversations, and builds a deepening model of who you are across sessions. Run it on a $5 VPS, a GPU cluster, or serverless infrastructure that costs nearly nothing when idle. It's not tied to your laptop — talk to it from Telegram while it works on a cloud VM.
@@ -104,6 +105,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 |

261
README.ur-pk.md Normal file
View File

@@ -0,0 +1,261 @@
<div dir="rtl">
<p align="center">
<img src="assets/banner.png" alt="Hermes Agent" width="100%">
</p>
# ہرمیس ایجنٹ ☤ (Hermes Agent)
<p align="center">
<a href="https://hermes-agent.nousresearch.com/docs/"><img src="https://img.shields.io/badge/Docs-hermes--agent.nousresearch.com-FFD700?style=for-the-badge" alt="Documentation"></a>
<a href="https://discord.gg/NousResearch"><img src="https://img.shields.io/badge/Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord"></a>
<a href="https://github.com/NousResearch/hermes-agent/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-MIT-green?style=for-the-badge" alt="License: MIT"></a>
<a href="https://nousresearch.com"><img src="https://img.shields.io/badge/Built%20by-Nous%20Research-blueviolet?style=for-the-badge" alt="Built by Nous Research"></a>
<a href="README.md"><img src="https://img.shields.io/badge/Lang-English-lightgrey?style=for-the-badge" alt="English"></a>
<a href="README.zh-CN.md"><img src="https://img.shields.io/badge/Lang-中文-red?style=for-the-badge" alt="中文"></a>
</p>
**[نوس ریسرچ (Nous Research)](https://nousresearch.com) کا تیار کردہ خود کو بہتر بنانے والا اے آئی (AI) ایجنٹ۔** یہ واحد ایجنٹ ہے جس میں سیکھنے کا عمل (learning loop) پہلے سے موجود ہے — یہ اپنے تجربات سے نئی مہارتیں (skills) بناتا ہے، استعمال کے دوران ان کو بہتر کرتا ہے، معلومات کو محفوظ رکھنے کے لیے خود کو یاد دہانی کرواتا ہے، اپنی پرانی بات چیت کو تلاش کر سکتا ہے، اور مختلف سیشنز کے دوران آپ کے بارے میں ایک گہری سمجھ پیدا کرتا ہے۔ اسے $5 والے VPS پر چلائیں، GPU کلسٹر پر، یا سرور لیس (serverless) انفراسٹرکچر پر جس کی قیمت استعمال نہ ہونے پر تقریباً صفر ہے۔ یہ آپ کے لیپ ٹاپ تک محدود نہیں ہے — آپ ٹیلی گرام (Telegram) سے اس کے ساتھ بات چیت کر سکتے ہیں جبکہ یہ کلاؤڈ VM پر کام کر رہا ہو۔
آپ اپنی مرضی کا کوئی بھی ماڈل استعمال کر سکتے ہیں — [Nous Portal](https://portal.nousresearch.com)، [OpenRouter](https://openrouter.ai) (200 سے زائد ماڈلز)، [NovitaAI](https://novita.ai) (ماڈل API، ایجنٹ سینڈ باکس، اور GPU کلاؤڈ کے لیے اے آئی مقامی کلاؤڈ)، [NVIDIA NIM](https://build.nvidia.com) (Nemotron)، [Xiaomi MiMo](https://platform.xiaomimimo.com)، [z.ai/GLM](https://z.ai)، [Kimi/Moonshot](https://platform.moonshot.ai)، [MiniMax](https://www.minimax.io)، [Hugging Face](https://huggingface.co)، OpenAI، یا اپنا حسب ضرورت اینڈ پوائنٹ (endpoint) استعمال کریں۔ ماڈل تبدیل کرنے کے لیے صرف `hermes model` استعمال کریں — کسی کوڈ کو تبدیل کرنے کی ضرورت نہیں، کوئی پابندی نہیں۔
<table>
<tr><td><b>حقیقی ٹرمینل انٹرفیس</b></td><td>مکمل TUI جس میں ملٹی لائن ایڈیٹنگ، سلیش-کمانڈ آٹو کمپلیٹ، بات چیت کی ہسٹری، انٹرپٹ اور ری ڈائریکٹ، اور سٹریمنگ ٹول آؤٹ پٹ شامل ہے۔</td></tr>
<tr><td><b>یہ وہاں موجود ہے جہاں آپ ہیں</b></td><td>ٹیلی گرام، ڈسکارڈ (Discord)، سلیک (Slack)، واٹس ایپ (WhatsApp)، سگنل (Signal)، اور CLI — سب ایک ہی گیٹ وے پروسیس سے کام کرتے ہیں۔ وائس میمو (Voice memo) ٹرانسکرپشن، کراس پلیٹ فارم بات چیت کا تسلسل۔</td></tr>
<tr><td><b>سیکھنے کا ایک مکمل عمل</b></td><td>ایجنٹ کی اپنی ترتیب دی گئی میموری، جس میں وہ خود کو وقتاً فوقتاً یاد دہانی کرواتا ہے۔ پیچیدہ کاموں کے بعد خود کار طریقے سے مہارت (skill) کی تخلیق۔ استعمال کے دوران مہارتوں میں بہتری۔ LLM سمرائزیشن کے ساتھ FTS5 سیشن سرچ تاکہ پرانے سیشنز کی یاددہانی کی جا سکے۔ <a href="https://github.com/plastic-labs/honcho">Honcho</a> کے ذریعے صارف کی ماڈلنگ۔ <a href="https://agentskills.io">agentskills.io</a> اوپن سٹینڈرڈ کے ساتھ مکمل مطابقت۔</td></tr>
<tr><td><b>شیڈول کی گئی خودکار کارروائیاں</b></td><td>بلٹ ان (Built-in) کرون (cron) شیڈیولر جو کسی بھی پلیٹ فارم پر ڈیلیوری کے لیے استعمال ہو سکتا ہے۔ روزانہ کی رپورٹس، رات کے بیک اپس، ہفتہ وار آڈٹس — یہ سب کچھ قدرتی زبان (natural language) میں اور بغیر کسی نگرانی کے کام کرتا ہے۔</td></tr>
<tr><td><b>کام کی تقسیم اور متوازی عمل</b></td><td>متوازی (parallel) کاموں کے لیے الگ سے ذیلی ایجنٹس (subagents) بنائیں۔ پائتھون (Python) سکرپٹس لکھیں جو RPC کے ذریعے ٹولز کو استعمال کریں، تاکہ کئی مراحل پر مشتمل کاموں کو بغیر کسی سیاق و سباق (context) کے خرچ کے، ایک ہی باری میں انجام دیا جا سکے۔</td></tr>
<tr><td><b>کہیں بھی چلائیں، صرف اپنے لیپ ٹاپ پر نہیں</b></td><td>چھ (Six) ٹرمینل بیک اینڈز — لوکل، Docker، SSH، Singularity، Modal، اور Daytona۔ ڈیٹونا (Daytona) اور موڈل (Modal) سرور لیس (serverless) فعالیت پیش کرتے ہیں — جب آپ کا ایجنٹ فارغ ہوتا ہے تو اس کا ماحول سلیپ (hibernate) ہو جاتا ہے اور ضرورت پڑنے پر خود بخود جاگ جاتا ہے، جس کی وجہ سے سیشنز کے درمیان لاگت تقریباً صفر رہتی ہے۔ اسے $5 والے VPS یا GPU کلسٹر پر چلائیں۔</td></tr>
<tr><td><b>تحقیق کے لیے تیار</b></td><td>بیچ (Batch) ٹریجیکٹری (trajectory) جنریشن، اگلی نسل کے ٹول کالنگ ماڈلز کی تربیت کے لیے ٹریجیکٹری کمپریشن۔</td></tr>
</table>
---
## فوری انسٹالیشن (Quick Install)
### لینکس (Linux)، میک او ایس (macOS)، ڈبلیو ایس ایل ٹو (WSL2)، ٹرمکس (Termux)
<div dir="ltr">
```bash
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
```
</div>
### ونڈوز (نیٹو، پاور شیل)
> **توجہ فرمائیں:** مقامی ونڈوز (Native Windows) پر ہرمیس بغیر WSL کے چلتا ہے — CLI، گیٹ وے، TUI، اور ٹولز سب مقامی طور پر کام کرتے ہیں۔ اگر آپ WSL2 استعمال کرنا پسند کرتے ہیں، تو اوپر دی گئی لینکس/میک او ایس کی کمانڈ وہاں بھی کام کرے گی۔ کوئی مسئلہ نظر آیا؟ براہ کرم [مسائل (issues) درج کریں](https://github.com/NousResearch/hermes-agent/issues)۔
اسے پاور شیل (PowerShell) میں چلائیں:
<div dir="ltr">
```powershell
iex (irm https://hermes-agent.nousresearch.com/install.ps1)
```
</div>
انسٹالر سب کچھ خود سنبھالتا ہے: uv، Python 3.11، Node.js، ripgrep، ffmpeg، **اور ایک پورٹ ایبل (portable) گٹ بیش (Git Bash)** (یعنی MinGit، جو `%LOCALAPPDATA%\hermes\git` میں ان پیک ہوتا ہے — اس کے لیے ایڈمن کی اجازت درکار نہیں، اور یہ سسٹم کے کسی بھی گٹ انسٹال سے بالکل الگ ہے)۔ ہرمیس اس بنڈل شدہ گٹ بیش کو شیل کمانڈز چلانے کے لیے استعمال کرتا ہے۔
اگر آپ کے پاس پہلے سے گٹ (Git) انسٹال ہے، تو انسٹالر اسے شناخت کر لیتا ہے اور اسے ہی استعمال کرتا ہے۔ بصورت دیگر آپ کو صرف ~45MB کے MinGit ڈاؤنلوڈ کی ضرورت ہوگی — یہ آپ کے سسٹم کے گٹ پر کوئی اثر نہیں ڈالے گا۔
> **اینڈرائیڈ (Android) / ٹرمکس (Termux):** ٹیسٹ کیا گیا مینوئل طریقہ [Termux گائیڈ](https://hermes-agent.nousresearch.com/docs/getting-started/termux) میں موجود ہے۔ ٹرمکس پر ہرمیس ایک مخصوص `.[termux]` ایکسٹرا انسٹال کرتا ہے کیونکہ مکمل `.[all]` ایکسٹرا میں ایسی وائس ڈیپینڈینسیز شامل ہیں جو اینڈرائیڈ کے ساتھ مطابقت نہیں رکھتیں۔
>
> **ونڈوز (Windows):** مقامی ونڈوز کی مکمل سپورٹ موجود ہے — اوپر دی گئی پاور شیل کی کمانڈ سب کچھ انسٹال کر دیتی ہے۔ اگر آپ WSL2 استعمال کرنا چاہتے ہیں، تو لینکس کی کمانڈ وہاں کام کرتی ہے۔ مقامی ونڈوز میں انسٹالیشن `%LOCALAPPDATA%\hermes` میں ہوتی ہے؛ جبکہ WSL2 میں لینکس کی طرح `~/.hermes` میں ہوتی ہے۔ ہرمیس کا وہ واحد فیچر جسے فی الحال خاص طور پر WSL2 کی ضرورت ہے وہ براؤزر پر مبنی ڈیش بورڈ چیٹ پین ہے (یہ POSIX PTY استعمال کرتا ہے — کلاسک CLI اور گیٹ وے دونوں مقامی طور پر چلتے ہیں)۔
انسٹالیشن کے بعد:
<div dir="ltr">
```bash
source ~/.bashrc # شیل کو ری لوڈ کریں (یا: source ~/.zshrc)
hermes # بات چیت شروع کریں!
```
</div>
---
## آغاز کریں (Getting Started)
<div dir="ltr">
```bash
hermes # انٹرایکٹو CLI — بات چیت شروع کریں
hermes model # اپنا LLM پرووائیڈر اور ماڈل منتخب کریں
hermes tools # کنفیگر کریں کہ کون سے ٹولز ایکٹو ہیں
hermes config set # انفرادی کنفگ (config) ویلیوز سیٹ کریں
hermes gateway # میسجنگ گیٹ وے شروع کریں (ٹیلی گرام، ڈسکارڈ، وغیرہ)
hermes setup # مکمل سیٹ اپ وزرڈ چلائیں (یہ سب کچھ ایک ساتھ کنفیگر کر دے گا)
hermes claw migrate # OpenClaw سے مائیگریٹ کریں (اگر آپ OpenClaw سے آ رہے ہیں)
hermes update # لیٹسٹ ورژن پر اپ ڈیٹ کریں
hermes doctor # کسی بھی مسئلے کی تشخیص کریں
```
</div>
📖 **[مکمل دستاویزات →](https://hermes-agent.nousresearch.com/docs/)**
---
## API-کیز اکٹھی کرنے سے بچیں — Nous Portal
ہرمیس آپ کے پسندیدہ پرووائیڈر کے ساتھ کام کرتا ہے — یہ چیز تبدیل نہیں ہو رہی۔ لیکن اگر آپ ماڈل، ویب سرچ، امیج جنریشن، TTS، اور کلاؤڈ براؤزر کے لیے پانچ الگ الگ API کیز جمع نہیں کرنا چاہتے، تو **[Nous Portal](https://portal.nousresearch.com)** ان سب کو ایک ہی سبسکرپشن کے تحت کور کرتا ہے:
- **300+ ماڈلز** — ان میں سے کوئی بھی ماڈل `/model <name>` کے ذریعے منتخب کریں
- **ٹول گیٹ وے (Tool Gateway)** — ویب سرچ (Firecrawl)، امیج جنریشن (FAL)، ٹیکسٹ ٹو سپیچ (OpenAI)، کلاؤڈ براؤزر (Browser Use)، یہ سب آپ کی سبسکرپشن کے ذریعے چلتے ہیں۔ کسی اضافی اکاؤنٹ کی ضرورت نہیں۔
نئی انسٹالیشن کے بعد بس ایک کمانڈ کی ضرورت ہے:
<div dir="ltr">
```bash
hermes setup --portal
```
</div>
یہ آپ کو OAuth کے ذریعے لاگ ان کرواتا ہے، Nous کو آپ کا پرووائیڈر مقرر کرتا ہے، اور ٹول گیٹ وے کو آن کر دیتا ہے۔ `hermes portal info` کمانڈ استعمال کر کے آپ کسی بھی وقت چیک کر سکتے ہیں کہ کون کون سی سروسز منسلک ہیں۔ مکمل تفصیلات [Tool Gateway دستاویزات کے صفحے](https://hermes-agent.nousresearch.com/docs/user-guide/features/tool-gateway) پر موجود ہیں۔
آپ اب بھی کسی بھی ٹول کے لیے اپنی مرضی کی API کیز استعمال کر سکتے ہیں — گیٹ وے ہر سروس کے لیے الگ الگ کام کرتا ہے، ایسا نہیں کہ یا تو سب کچھ استعمال کریں یا کچھ بھی نہیں۔
---
## CLI بمقابلہ میسجنگ فوری حوالہ
ہرمیس کے دو بنیادی انٹر فیس ہیں: آپ ٹرمینل UI کو `hermes` کے ساتھ شروع کریں، یا گیٹ وے چلا کر اس کے ساتھ ٹیلی گرام، ڈسکارڈ، سلیک، واٹس ایپ، سگنل، یا ای میل کے ذریعے بات کریں۔ جب آپ کسی بات چیت میں ہوتے ہیں، تو بہت سی سلیش (slash) کمانڈز دونوں انٹرفیسز میں ایک جیسی ہوتی ہیں۔
<div dir="ltr">
| کارروائی (Action) | سی ایل آئی (CLI) | میسجنگ پلیٹ فارمز (Messaging platforms) |
| --------------------------------------- | --------------------------------------------- | -------------------------------------------------------------------------------- |
| بات چیت شروع کریں | `hermes` | `hermes gateway setup` اور `hermes gateway start` چلائیں، پھر بوٹ کو میسج بھیجیں |
| نئی بات چیت شروع کریں | `/new` یا `/reset` | `/new` یا `/reset` |
| ماڈل تبدیل کریں | `/model [provider:model]` | `/model [provider:model]` |
| پرسنلٹی (Personality) سیٹ کریں | `/personality [name]` | `/personality [name]` |
| پچھلی باری کو دوبارہ یا منسوخ (undo) کریں | `/retry`، `/undo` | `/retry`، `/undo` |
| کانٹیکسٹ (context) کمپریس کریں / استعمال چیک کریں | `/compress`، `/usage`، `/insights [--days N]` | `/compress`، `/usage`، `/insights [days]` |
| مہارتیں (Skills) براؤز کریں | `/skills` یا `/<skill-name>` | `/<skill-name>` |
| موجودہ کام کو روکیں | `Ctrl+C` دبائیں یا نیا میسج بھیجیں | `/stop` یا نیا میسج بھیجیں |
| پلیٹ فارم کے لحاظ سے سٹیٹس | `/platforms` | `/status`، `/sethome` |
</div>
مکمل کمانڈ لسٹ کے لیے، [CLI گائیڈ](https://hermes-agent.nousresearch.com/docs/user-guide/cli) اور [میسجنگ گیٹ وے گائیڈ](https://hermes-agent.nousresearch.com/docs/user-guide/messaging) دیکھیں۔
---
## دستاویزات (Documentation)
تمام دستاویزات **[hermes-agent.nousresearch.com/docs](https://hermes-agent.nousresearch.com/docs/)** پر موجود ہیں:
<div dir="ltr">
| سیکشن (Section) | تفصیل (What's Covered) |
| --------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
| [فوری آغاز (Quickstart)](https://hermes-agent.nousresearch.com/docs/getting-started/quickstart) | انسٹالیشن → سیٹ اپ → 2 منٹ میں پہلی بات چیت شروع کریں |
| [CLI کا استعمال](https://hermes-agent.nousresearch.com/docs/user-guide/cli) | کمانڈز، کی بائنڈنگز (keybindings)، پرسنلٹیز (personalities)، سیشنز |
| [کنفیگریشن (Configuration)](https://hermes-agent.nousresearch.com/docs/user-guide/configuration) | کنفگ فائل، پرووائیڈرز، ماڈلز، اور تمام آپشنز |
| [میسجنگ گیٹ وے](https://hermes-agent.nousresearch.com/docs/user-guide/messaging) | ٹیلی گرام، ڈسکارڈ، سلیک، واٹس ایپ، سگنل، ہوم اسسٹنٹ |
| [سیکیورٹی (Security)](https://hermes-agent.nousresearch.com/docs/user-guide/security) | کمانڈ کی منظوری، DM پیئرنگ (pairing)، کنٹینر آئسولیشن |
| [ٹولز اور ٹول سیٹس](https://hermes-agent.nousresearch.com/docs/user-guide/features/tools) | 40 سے زائد ٹولز، ٹول سیٹ سسٹم، ٹرمینل بیک اینڈز |
| [مہارتوں کا سسٹم (Skills System)](https://hermes-agent.nousresearch.com/docs/user-guide/features/skills)| پروسیجرل (Procedural) میموری، سکلز ہب، نئی مہارتیں بنانا |
| [میموری (Memory)](https://hermes-agent.nousresearch.com/docs/user-guide/features/memory) | مستقل میموری، یوزر پروفائلز، بہترین طریقہ کار |
| [MCP انضمام (Integration)](https://hermes-agent.nousresearch.com/docs/user-guide/features/mcp) | صلاحیتوں کو بڑھانے کے لیے کسی بھی MCP سرور کو جوڑیں |
| [کرون (Cron) شیڈیولنگ](https://hermes-agent.nousresearch.com/docs/user-guide/features/cron) | پلیٹ فارم ڈیلیوری کے ساتھ شیڈول کیے گئے کام |
| [کانٹیکسٹ (Context) فائلز](https://hermes-agent.nousresearch.com/docs/user-guide/features/context-files)| پروجیکٹ کا سیاق و سباق (context) جو ہر بات چیت پر اثر انداز ہوتا ہے |
| [آرکیٹیکچر (Architecture)](https://hermes-agent.nousresearch.com/docs/developer-guide/architecture) | پروجیکٹ کا ڈھانچہ، ایجنٹ لوپ، اہم کلاسز |
| [تعاون (Contributing)](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) | ڈیویلپمنٹ سیٹ اپ، PR کا طریقہ کار، کوڈنگ کا انداز |
| [CLI حوالہ جات (Reference)](https://hermes-agent.nousresearch.com/docs/reference/cli-commands) | تمام کمانڈز اور فلیگز (flags) |
| [انوائرمنٹ ویری ایبلز](https://hermes-agent.nousresearch.com/docs/reference/environment-variables) | مکمل انوائرمنٹ ویری ایبل حوالہ جات |
</div>
---
## OpenClaw سے منتقلی
اگر آپ OpenClaw سے منتقل ہو رہے ہیں، تو ہرمیس آپ کی سیٹنگز، یادیں (memories)، مہارتیں (skills)، اور API کیز کو خود بخود امپورٹ کر سکتا ہے۔
**پہلی بار سیٹ اپ کے دوران:** سیٹ اپ وزرڈ (`hermes setup`) خود بخود `~/.openclaw` کو پہچان لیتا ہے اور کنفیگریشن شروع ہونے سے پہلے مائیگریٹ (migrate) کرنے کا آپشن دیتا ہے۔
**انسٹالیشن کے بعد کسی بھی وقت:**
<div dir="ltr">
```bash
hermes claw migrate # انٹرایکٹو مائیگریشن (مکمل پری سیٹ)
hermes claw migrate --dry-run # جائزہ لیں کہ کیا کیا مائیگریٹ ہوگا
hermes claw migrate --preset user-data # حساس معلومات (secrets) کے بغیر مائیگریٹ کریں
hermes claw migrate --overwrite # موجودہ متصادم فائلوں کو اوور رائٹ کریں
```
</div>
جو چیزیں امپورٹ ہوتی ہیں:
- **SOUL.md** — پرسونا (persona) فائل
- **میموریز (Memories)** — MEMORY.md اور USER.md کی اندراجات
- **مہارتیں (Skills)** — صارف کی بنائی گئی مہارتیں → `~/.hermes/skills/openclaw-imports/`
- **کمانڈ الاؤ لسٹ (allowlist)** — منظوری کے پیٹرنز (approval patterns)
- **میسجنگ سیٹنگز** — پلیٹ فارم کنفیگریشنز، اجازت یافتہ صارفین، ورکنگ ڈائریکٹری
- **API کیز** — الاؤ لسٹ شدہ حساس معلومات (ٹیلی گرام، OpenRouter، OpenAI، Anthropic، ElevenLabs)
- **TTS اثاثے** — ورک اسپیس کی آڈیو فائلیں
- **ورک اسپیس کی ہدایات** — AGENTS.md (`--workspace-target` کے ساتھ)
تمام آپشنز دیکھنے کے لیے `hermes claw migrate --help` استعمال کریں، یا انٹرایکٹو ایجنٹ کی مدد سے مائیگریٹ کرنے کے لیے `openclaw-migration` سکل کا استعمال کریں (جس میں ڈرائی رن (dry-run) پریویوز شامل ہیں)۔
---
## تعاون کریں (Contributing)
ہم آپ کے تعاون کا خیرمقدم کرتے ہیں! ڈیویلپمنٹ سیٹ اپ، کوڈ کے انداز اور PR کے طریقہ کار کے لیے براہ کرم ہماری [Contributing گائیڈ](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) دیکھیں۔
معاونین (contributors) کے لیے فوری آغاز — کلون (clone) کریں اور `setup-hermes.sh` چلائیں:
<div dir="ltr">
```bash
git clone https://github.com/NousResearch/hermes-agent.git
cd hermes-agent
./setup-hermes.sh # uv کو انسٹال کرتا ہے، venv بناتا ہے، .[all] کو انسٹال کرتا ہے، اور ~/.local/bin/hermes کا سیم لنک (symlink) بناتا ہے
./hermes # خود بخود venv کی شناخت کرتا ہے، پہلے `source` کرنے کی ضرورت نہیں
```
</div>
مینوئل طریقہ (اوپر والے طریقے کے مساوی):
<div dir="ltr">
```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
uv venv .venv --python 3.11
source .venv/bin/activate
uv pip install -e ".[all,dev]"
scripts/run_tests.sh
```
</div>
---
## کمیونٹی (Community)
- 💬 [ڈسکارڈ (Discord)](https://discord.gg/NousResearch)
- 📚 [سکلز ہب (Skills Hub)](https://agentskills.io)
- 🐛 [مسائل (Issues)](https://github.com/NousResearch/hermes-agent/issues)
- 🔌 [computer-use-linux](https://github.com/avifenesh/computer-use-linux) — ہرمیس اور دیگر MCP ہوسٹس کے لیے لینکس (Linux) ڈیسک ٹاپ کنٹرول MCP سرور، جس میں AT-SPI ایکسیسیبلٹی ٹریز، Wayland/X11 ان پٹ، سکرین شاٹس، اور کمپوزیٹر ونڈو ٹارگیٹنگ شامل ہے۔
- 🔌 [HermesClaw](https://github.com/AaronWong1999/hermesclaw) — کمیونٹی وی چیٹ (WeChat) برج: ہرمیس ایجنٹ اور OpenClaw کو ایک ہی وی چیٹ اکاؤنٹ پر چلائیں۔
---
## لائسنس (License)
MIT — تفصیلات کے لیے [LICENSE](LICENSE) دیکھیں۔
[نوس ریسرچ (Nous Research)](https://nousresearch.com) کی جانب سے تیار کردہ۔
</div>

View File

@@ -10,6 +10,7 @@
<a href="https://github.com/NousResearch/hermes-agent/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-MIT-green?style=for-the-badge" alt="License: MIT"></a>
<a href="https://nousresearch.com"><img src="https://img.shields.io/badge/Built%20by-Nous%20Research-blueviolet?style=for-the-badge" alt="Built by Nous Research"></a>
<a href="README.md"><img src="https://img.shields.io/badge/Lang-English-lightgrey?style=for-the-badge" alt="English"></a>
<a href="README.ur-pk.md"><img src="https://img.shields.io/badge/Lang-اردو-green?style=for-the-badge" alt="اردو"></a>
</p>
**由 [Nous Research](https://nousresearch.com) 构建的自进化 AI 代理。** 它是唯一内置学习闭环的智能代理——从经验中创建技能,在使用中改进技能,主动持久化知识,搜索过往对话,并在跨会话中逐步构建对你的深度理解。可以在 $5 的 VPS 上运行,也可以在 GPU 集群上运行,或者使用几乎零成本的 Serverless 基础设施。它不绑定你的笔记本——你可以在 Telegram 上与它对话,而它在云端 VM 上工作。

127
acp_adapter/provenance.py Normal file
View File

@@ -0,0 +1,127 @@
"""Derive ACP session-provenance metadata from the existing compression chain.
This is an additive Hermes extension surfaced under ACP ``_meta.hermes`` so
existing ACP clients ignore it. It carries no new persisted state: everything
is derived on demand from the ``sessions`` table (``parent_session_id`` /
``end_reason``), which already models compression-continuation chains.
The ACP/editor ``session_id`` stays the stable public handle. When context
compression rotates the internal Hermes head, ``build_session_provenance`` lets
a client see the previous/current internal ids and the lineage root without
parsing status text, guessing from token drops, or reading ``state.db``.
"""
from __future__ import annotations
from typing import Any, Dict, Optional
# Bound defensive walks; compression chains this deep are pathological.
_MAX_WALK = 100
def build_session_provenance(
db: Any,
acp_session_id: str,
current_hermes_session_id: str,
*,
previous_hermes_session_id: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""Build ``_meta.hermes.sessionProvenance`` for an ACP session.
Args:
db: A ``SessionDB`` (must expose ``get_session``).
acp_session_id: The stable ACP/editor-facing session handle.
current_hermes_session_id: The live internal Hermes DB session id
(``state.agent.session_id``).
previous_hermes_session_id: The internal id from before the most recent
turn, when known. Supplied by ``prompt()`` to flag a rotation.
Returns:
A dict suitable for ``{"hermes": {"sessionProvenance": <dict>}}`` under
ACP ``_meta``, or ``None`` if the session can't be read.
"""
try:
row = db.get_session(current_hermes_session_id)
except Exception:
return None
if not row:
return None
parent_id = row.get("parent_session_id")
end_reason = row.get("end_reason")
# Walk parents to the lineage root and count compression depth. Only
# compression-split parents (parent.end_reason == 'compression') count
# toward depth — delegate/branch children share the parent_session_id
# column but are not compaction boundaries.
root_id = current_hermes_session_id
compression_depth = 0
cursor_parent = parent_id
seen = {current_hermes_session_id}
for _ in range(_MAX_WALK):
if not cursor_parent or cursor_parent in seen:
break
seen.add(cursor_parent)
try:
prow = db.get_session(cursor_parent)
except Exception:
prow = None
if not prow:
break
root_id = cursor_parent
if prow.get("end_reason") == "compression":
compression_depth += 1
cursor_parent = prow.get("parent_session_id")
# A session is a compression continuation when its parent was ended with
# end_reason='compression'. Determine that from the immediate parent.
is_continuation = False
if parent_id:
try:
immediate_parent = db.get_session(parent_id)
except Exception:
immediate_parent = None
if immediate_parent and immediate_parent.get("end_reason") == "compression":
is_continuation = True
rotated = bool(
previous_hermes_session_id
and previous_hermes_session_id != current_hermes_session_id
)
provenance: Dict[str, Any] = {
"acpSessionId": acp_session_id,
"currentHermesSessionId": current_hermes_session_id,
"rootHermesSessionId": root_id,
"parentHermesSessionId": parent_id,
"sessionKind": "continuation" if is_continuation else "root",
"compressionDepth": compression_depth,
}
if previous_hermes_session_id:
provenance["previousHermesSessionId"] = previous_hermes_session_id
if rotated:
# The head moved during the last turn. The only mechanism that rotates
# the internal id mid-turn is compression-driven session splitting.
provenance["reason"] = "compression"
provenance["creatorKind"] = "compression"
return provenance
def session_provenance_meta(
db: Any,
acp_session_id: str,
current_hermes_session_id: str,
*,
previous_hermes_session_id: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""Return a ready ``_meta`` payload: ``{"hermes": {"sessionProvenance": ...}}``."""
prov = build_session_provenance(
db,
acp_session_id,
current_hermes_session_id,
previous_hermes_session_id=previous_hermes_session_id,
)
if prov is None:
return None
return {"hermes": {"sessionProvenance": prov}}

View File

@@ -71,6 +71,7 @@ from acp_adapter.events import (
make_tool_progress_cb,
)
from acp_adapter.permissions import make_approval_callback
from acp_adapter.provenance import session_provenance_meta
from acp_adapter.session import SessionManager, SessionState, _expand_acp_enabled_toolsets
from acp_adapter.tools import build_tool_complete, build_tool_start
@@ -709,8 +710,39 @@ class HermesACPAgent(acp.Agent):
exc_info=True,
)
async def _send_session_info_update(self, session_id: str) -> None:
"""Send ACP native session metadata after Hermes changes it."""
def _provenance_meta(
self,
acp_session_id: str,
current_hermes_session_id: str,
previous_hermes_session_id: Optional[str] = None,
) -> Optional[dict]:
"""Best-effort ``_meta.hermes.sessionProvenance`` for an ACP session."""
try:
return session_provenance_meta(
self.session_manager._get_db(),
acp_session_id,
current_hermes_session_id,
previous_hermes_session_id=previous_hermes_session_id,
)
except Exception:
logger.debug(
"Could not build ACP session provenance for %s", acp_session_id, exc_info=True
)
return None
async def _send_session_info_update(
self,
session_id: str,
*,
current_hermes_session_id: Optional[str] = None,
previous_hermes_session_id: Optional[str] = None,
) -> None:
"""Send ACP native session metadata after Hermes changes it.
When the internal Hermes head rotated (e.g. compression-driven session
split during a turn), pass ``previous_hermes_session_id`` so the
attached ``_meta.hermes.sessionProvenance`` flags the rotation reason.
"""
if not self._conn:
return
try:
@@ -727,10 +759,16 @@ class HermesACPAgent(acp.Agent):
# the updated_at since we're emitting this notification precisely
# because the title was just refreshed.
updated_at = datetime.now(timezone.utc).isoformat()
meta = self._provenance_meta(
session_id,
current_hermes_session_id or session_id,
previous_hermes_session_id,
)
update = SessionInfoUpdate(
session_update="session_info_update",
title=title if isinstance(title, str) and title.strip() else None,
updated_at=updated_at,
field_meta=meta,
)
try:
await self._conn.session_update(
@@ -1081,6 +1119,9 @@ class HermesACPAgent(acp.Agent):
session_id=state.session_id,
models=self._build_model_state(state),
modes=self._session_modes(state),
field_meta=self._provenance_meta(
state.session_id, getattr(state.agent, "session_id", state.session_id)
),
)
async def load_session(
@@ -1125,6 +1166,9 @@ class HermesACPAgent(acp.Agent):
return LoadSessionResponse(
models=self._build_model_state(state),
modes=self._session_modes(state),
field_meta=self._provenance_meta(
session_id, getattr(state.agent, "session_id", session_id)
),
)
async def resume_session(
@@ -1157,6 +1201,9 @@ class HermesACPAgent(acp.Agent):
return ResumeSessionResponse(
models=self._build_model_state(state),
modes=self._session_modes(state),
field_meta=self._provenance_meta(
state.session_id, getattr(state.agent, "session_id", state.session_id)
),
)
async def cancel(self, session_id: str, **kwargs: Any) -> None:
@@ -1494,6 +1541,11 @@ class HermesACPAgent(acp.Agent):
logger.debug("Could not clear ACP session context", exc_info=True)
try:
# Snapshot the internal Hermes DB session id before the turn so we
# can detect a compression-driven session rotation afterwards. The
# ACP `session_id` stays the stable client handle; agent.session_id
# is the live internal head that compression may rotate.
pre_turn_hermes_id = getattr(state.agent, "session_id", None)
# Wrap the executor call in a fresh copy of the current context so
# concurrent ACP sessions on the shared ThreadPoolExecutor don't
# stomp on each other's ContextVar writes (HERMES_SESSION_KEY in
@@ -1512,8 +1564,41 @@ class HermesACPAgent(acp.Agent):
# Persist updated history so sessions survive process restarts.
self.session_manager.save_session(session_id)
# Detect a compression-driven internal session rotation. If the agent's
# DB head moved during the turn, emit a session_info_update carrying
# _meta.hermes.sessionProvenance so ACP clients can render the boundary
# and keep old/new ids in lineage. The ACP session_id is unchanged.
post_turn_hermes_id = getattr(state.agent, "session_id", None)
if (
conn
and post_turn_hermes_id
and pre_turn_hermes_id
and post_turn_hermes_id != pre_turn_hermes_id
):
try:
await self._send_session_info_update(
session_id,
current_hermes_session_id=post_turn_hermes_id,
previous_hermes_session_id=pre_turn_hermes_id,
)
except Exception:
logger.debug(
"Could not emit ACP provenance update after rotation for %s",
session_id,
exc_info=True,
)
final_response = result.get("final_response", "")
if final_response:
cancelled = bool(state.cancel_event and state.cancel_event.is_set())
interrupted = bool(result.get("interrupted")) or cancelled
# Hermes' local "waiting for model response" interrupt status is metadata,
# not assistant prose — clients get cancellation from stop_reason instead.
from agent.conversation_loop import INTERRUPT_WAITING_FOR_MODEL_PREFIX
suppress_interrupt_response = interrupted and final_response.startswith(
INTERRUPT_WAITING_FOR_MODEL_PREFIX
)
if final_response and not suppress_interrupt_response:
try:
from agent.title_generator import maybe_auto_title
@@ -1534,7 +1619,12 @@ class HermesACPAgent(acp.Agent):
)
except Exception:
logger.debug("Failed to auto-title ACP session %s", session_id, exc_info=True)
if final_response and conn and (not streamed_message or result.get("response_transformed")):
if (
final_response
and conn
and not suppress_interrupt_response
and (not streamed_message or result.get("response_transformed"))
):
# Deliver the final response when streaming did not already send it,
# or when a plugin hook transformed the response after streaming
# finished (e.g. transform_llm_output) — otherwise the appended /
@@ -1576,7 +1666,7 @@ class HermesACPAgent(acp.Agent):
await self._send_usage_update(state)
stop_reason = "cancelled" if state.cancel_event and state.cancel_event.is_set() else "end_turn"
stop_reason = "cancelled" if cancelled else "end_turn"
return PromptResponse(stop_reason=stop_reason, usage=usage)
# ---- Slash commands (headless) -------------------------------------------

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

@@ -1620,6 +1620,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

@@ -1846,6 +1846,27 @@ def repair_tool_call(agent, tool_name: str) -> str | None:
if not tool_name:
return None
# VolcEngine api/plan workaround (issue #33007): the endpoint's
# protocol-translation layer occasionally leaks raw XML attribute
# fragments into tool_use.name, e.g.
# `terminal" parameter="command" string="true`
# `execute_code" parameter="code" string="true`
# `session_search" parameter="session_id" string="true`
# We trim at the first unambiguous XML/quote character so the rest
# of the repair pipeline (lowercase / snake_case / fuzzy match)
# can resolve the cleaned name to a real tool.
#
# Crucially we DO NOT split on whitespace: legitimate inputs like
# "write file" must keep flowing through ``_norm`` -> ``write_file``
# (covered by test_space_to_underscore in
# tests/run_agent/test_repair_tool_call_name.py).
for _xml_sep in ('"', "'", "<", ">"):
_idx = tool_name.find(_xml_sep)
if _idx > 0:
tool_name = tool_name[:_idx]
if not tool_name:
return None
def _norm(s: str) -> str:
return s.lower().replace("-", "_").replace(" ", "_")

View File

@@ -637,54 +637,6 @@ def _pool_runtime_base_url(entry: Any, fallback: str = "") -> str:
# calls to the Codex Responses API so callers don't need any changes.
def _convert_content_for_responses(content: Any) -> Any:
"""Convert chat.completions content to Responses API format.
chat.completions uses:
{"type": "text", "text": "..."}
{"type": "image_url", "image_url": {"url": "data:image/png;base64,..."}}
Responses API uses:
{"type": "input_text", "text": "..."}
{"type": "input_image", "image_url": "data:image/png;base64,..."}
If content is a plain string, it's returned as-is (the Responses API
accepts strings directly for text-only messages).
"""
if isinstance(content, str):
return content
if not isinstance(content, list):
return str(content) if content else ""
converted: List[Dict[str, Any]] = []
for part in content:
if not isinstance(part, dict):
continue
ptype = part.get("type", "")
if ptype == "text":
converted.append({"type": "input_text", "text": part.get("text", "")})
elif ptype == "image_url":
# chat.completions nests the URL: {"image_url": {"url": "..."}}
image_data = part.get("image_url", {})
url = image_data.get("url", "") if isinstance(image_data, dict) else str(image_data)
entry: Dict[str, Any] = {"type": "input_image", "image_url": url}
# Preserve detail if specified
detail = image_data.get("detail") if isinstance(image_data, dict) else None
if detail:
entry["detail"] = detail
converted.append(entry)
elif ptype in {"input_text", "input_image"}:
# Already in Responses format — pass through
converted.append(part)
else:
# Unknown content type — try to preserve as text
text = part.get("text", "")
if text:
converted.append({"type": "input_text", "text": text})
return converted or ""
class _CodexCompletionsAdapter:
"""Drop-in shim that accepts chat.completions.create() kwargs and
routes them through the Codex Responses streaming API."""
@@ -697,26 +649,37 @@ class _CodexCompletionsAdapter:
messages = kwargs.get("messages", [])
model = kwargs.get("model", self._model)
# Separate system/instructions from conversation messages.
# Convert chat.completions multimodal content blocks to Responses
# API format (input_text / input_image instead of text / image_url).
# Separate system/instructions from replayable conversation messages,
# then route the rest through the SINGLE shared chat->Responses
# converter used by the main agent transport
# (agent/transports/codex.py). Maintaining a private conversion loop
# here let chat-style messages with role="tool" leak straight into
# Responses input[] — which the Responses API rejects with
# "Invalid value: 'tool'. Supported values are: 'assistant', 'system',
# 'developer', and 'user'." (issue #5709, hit hard by flush_memories()
# / compression replaying real session history that includes assistant
# tool_calls + role="tool" results). The shared converter encodes
# assistant tool calls as `function_call` items and tool results as
# `function_call_output` items with a valid call_id, so every
# Responses path normalizes tool history identically and cannot drift.
from agent.codex_responses_adapter import _chat_messages_to_responses_input
instructions = "You are a helpful assistant."
input_msgs: List[Dict[str, Any]] = []
replay_messages: List[Dict[str, Any]] = []
for msg in messages:
role = msg.get("role", "user")
content = msg.get("content") or ""
if role == "system":
instructions = content if isinstance(content, str) else str(content)
else:
input_msgs.append({
"role": role,
"content": _convert_content_for_responses(content),
})
replay_messages.append(msg)
input_items = _chat_messages_to_responses_input(replay_messages)
resp_kwargs: Dict[str, Any] = {
"model": model,
"instructions": instructions,
"input": input_msgs or [{"role": "user", "content": ""}],
"input": input_items or [{"role": "user", "content": ""}],
"store": False,
}

View File

@@ -449,6 +449,17 @@ def _run_review_in_thread(
# if a future code path bypasses the cache.
review_agent.session_start = agent.session_start
review_agent.session_id = agent.session_id
# Never let the review fork compress. It shares the parent's
# session_id, so if it won a compression race it would rotate the
# parent into a NEW child that the gateway never adopts (the fork
# is single-lifecycle and dies right after this run_conversation).
# The foreground turn would then start from the stale parent and
# compress it again, leaving the same parent with two sibling
# children (issue #38727). Review also needs full context to
# produce a good memory/skill summary — compressing would strip
# detail. Both compression triggers in conversation_loop.py gate on
# agent.compression_enabled, so this short-circuits both paths.
review_agent.compression_enabled = False
from model_tools import get_tool_definitions
from hermes_cli.plugins import (

View File

@@ -553,6 +553,22 @@ class ContextCompressor(ContextEngine):
self.last_rough_tokens_when_real_prompt_fit = 0
self.awaiting_real_usage_after_compression = False
def on_session_end(self, session_id: str, messages: List[Dict[str, Any]]) -> None:
"""Clear per-session compaction state at a real session boundary.
``_previous_summary`` is per-session iterative-summary state. It is
cleared on ``on_session_reset()`` (/new, /reset), but session *end*
(CLI exit, gateway expiry, session-id rotation) goes through
``on_session_end()`` instead — which inherited a no-op from
``ContextEngine``. Without clearing here, a cron/background session's
summary could survive on a reused compressor instance and leak into the
next live session via the ``_generate_summary()`` iterative-update path
(#38788). ``compress()`` already guards the leak at the point of use;
this is defense-in-depth that drops the stale summary the moment the
owning session ends.
"""
self._previous_summary = None
def update_model(
self,
model: str,
@@ -1818,6 +1834,41 @@ The user has requested that this compaction PRIORITISE preserving all informatio
accumulated += msg_tokens
cut_idx = i
# If the backward walk never broke early because the entire transcript
# fits within soft_ceiling, accumulated now holds the total transcript
# size. Without intervention _ensure_last_user_message_in_tail pushes
# cut_idx forward to include the last user message, and the caller's
# compress_start >= compress_end guard either returns unchanged (no-op)
# or compresses a single message — both of which trigger the infinite
# compaction loop described in #40803.
#
# Fix: when the whole transcript fits in soft_ceiling, compute a
# meaningful cut point using the raw (non-inflated) budget so that
# compression actually summarizes a worthwhile middle section.
if cut_idx <= head_end and accumulated <= soft_ceiling and accumulated > 0:
# The entire compressable region fits in the soft ceiling.
# Re-walk with the raw budget (no 1.5x multiplier) to find a
# split that gives the summarizer something useful.
raw_budget = token_budget
raw_accumulated = 0
for j in range(n - 1, head_end - 1, -1):
raw_msg = messages[j]
raw_content = raw_msg.get("content") or ""
raw_len = _content_length_for_budget(raw_content)
raw_tok = raw_len // _CHARS_PER_TOKEN + 10
for tc in raw_msg.get("tool_calls") or []:
if isinstance(tc, dict):
args = tc.get("function", {}).get("arguments", "")
raw_tok += len(args) // _CHARS_PER_TOKEN
if raw_accumulated + raw_tok > raw_budget and (n - j) >= min_tail:
cut_idx = j
break
raw_accumulated += raw_tok
cut_idx = j
# If the raw-budget walk also consumed everything (very small
# transcript), fall through — the existing fallback logic below
# will still force a minimal cut after head_end.
# Ensure we protect at least min_tail messages
fallback_cut = n - min_tail
cut_idx = min(cut_idx, fallback_cut)
@@ -1920,6 +1971,21 @@ The user has requested that this compaction PRIORITISE preserving all informatio
compress_end = self._find_tail_cut_by_tokens(messages, compress_start)
if compress_start >= compress_end:
# No compressable window — the entire transcript fits within
# the tail budget (soft_ceiling). Without recording this as
# an ineffective compression the anti-thrashing guard in
# should_compress() never fires and every subsequent turn
# re-triggers a no-op compression loop. (#40803)
self._ineffective_compression_count += 1
self._last_compression_savings_pct = 0.0
if not self.quiet_mode:
logger.warning(
"Compression skipped: compress_start (%d) >= compress_end (%d) "
"— transcript fits within tail budget, nothing to compress. "
"ineffective_compression_count=%d",
compress_start, compress_end,
self._ineffective_compression_count,
)
return messages
turns_to_summarize = messages[compress_start:compress_end]
@@ -1940,6 +2006,13 @@ The user has requested that this compaction PRIORITISE preserving all informatio
if summary_body and not self._previous_summary:
self._previous_summary = summary_body
turns_to_summarize = messages[max(compress_start, summary_idx + 1):compress_end]
elif self._previous_summary:
# No handoff summary found in the current messages, but
# _previous_summary is non-empty — it was set by a different
# (now-ended) session (e.g., a cron job, a prior /new). Discard
# it so _generate_summary() does not inject cross-session content
# into the summarizer prompt via the iterative-update path.
self._previous_summary = None
if not self.quiet_mode:
logger.info(

View File

@@ -507,12 +507,29 @@ def compress_context(
agent._session_db.end_session(agent.session_id, "compression")
old_session_id = agent.session_id
agent.session_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}"
# Ordering contract: the agent thread updates the contextvar here;
# the gateway propagates to SessionEntry after run_in_executor returns.
try:
from gateway.session_context import set_current_session_id
set_current_session_id(agent.session_id)
except Exception:
os.environ["HERMES_SESSION_ID"] = agent.session_id
# The gateway/tools session context (ContextVar + env) and the
# logging session context are SEPARATE mechanisms. The call above
# moves the former; the ``[session_id]`` tag on log lines comes
# from ``hermes_logging._session_context`` (set once per turn in
# conversation_loop.py). Without this, post-rotation log lines in
# the same turn keep the STALE old id while the message/DB/gateway
# state carry the new one — breaking log correlation exactly at the
# compaction boundary (see #34089). Guarded separately so a logging
# failure can never regress the routing update above.
try:
from hermes_logging import set_session_context
set_session_context(agent.session_id)
except Exception:
pass
agent._session_db_created = False
agent._session_db.create_session(
session_id=agent.session_id,

View File

@@ -31,6 +31,8 @@ from agent.codex_responses_adapter import _summarize_user_message_for_log
from agent.display import KawaiiSpinner
from agent.error_classifier import FailoverReason, classify_api_error
from agent.iteration_budget import IterationBudget
from agent.turn_context import build_turn_context
from agent.turn_retry_state import TurnRetryState
from agent.memory_manager import build_memory_context_block
from agent.message_sanitization import (
_repair_tool_call_arguments,
@@ -55,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
@@ -63,6 +69,11 @@ from utils import base_url_host_matches, env_var_enabled
logger = logging.getLogger(__name__)
# Stable prefix of the local interrupt status string emitted when a turn is
# cancelled while waiting on the provider. Surfaces (ACP, TUI) match on this
# to treat it as cancellation metadata rather than assistant prose.
INTERRUPT_WAITING_FOR_MODEL_PREFIX = "Operation interrupted: waiting for model response ("
def _ollama_context_limit_error(agent: Any, request_tokens: int) -> Optional[str]:
"""Return a user-facing error when Ollama is loaded with too little context."""
@@ -389,376 +400,43 @@ def run_conversation(
Returns:
Dict: Complete conversation result with final response and message history
"""
# Guard stdio against OSError from broken pipes (systemd/headless/daemon).
# Installed once, transparent when streams are healthy, prevents crash on write.
_install_safe_stdio()
agent._ensure_db_session()
# Tell auxiliary_client what the live main provider/model are for
# this turn. Used by tools whose behaviour depends on the active
# main model (e.g. vision_analyze's native fast path) so they see
# the CLI/gateway override instead of the stale config.yaml
# default. Idempotent — fine to call every turn.
try:
from agent.auxiliary_client import set_runtime_main
set_runtime_main(
getattr(agent, "provider", "") or "",
getattr(agent, "model", "") or "",
base_url=getattr(agent, "base_url", "") or "",
api_key=getattr(agent, "api_key", "") or "",
api_mode=getattr(agent, "api_mode", "") or "",
)
except Exception:
pass
# Tag all log records on this thread with the session ID so
# ``hermes logs --session <id>`` can filter a single conversation.
set_session_context(agent.session_id)
# Bind the skill write-origin ContextVar for this thread so tool
# handlers (e.g. skill_manage create) can tell whether they are
# running inside the background agent-improvement review fork vs.
# a foreground user-directed turn. Set at the top of each call;
# the review fork runs on its own thread with a fresh context,
# so the foreground value here does not leak into it.
set_current_write_origin(getattr(agent, "_memory_write_origin", "assistant_tool"))
# If the previous turn activated fallback, restore the primary
# runtime so this turn gets a fresh attempt with the preferred model.
# No-op when _fallback_activated is False (gateway, first turn, etc.).
agent._restore_primary_runtime()
# Sanitize surrogate characters from user input. Clipboard paste from
# rich-text editors (Google Docs, Word, etc.) can inject lone surrogates
# that are invalid UTF-8 and crash JSON serialization in the OpenAI SDK.
if isinstance(user_message, str):
user_message = _sanitize_surrogates(user_message)
if isinstance(persist_user_message, str):
persist_user_message = _sanitize_surrogates(persist_user_message)
# Store stream callback for _interruptible_api_call to pick up
agent._stream_callback = stream_callback
agent._persist_user_message_idx = None
agent._persist_user_message_override = persist_user_message
# Generate unique task_id if not provided to isolate VMs between concurrent tasks
effective_task_id = task_id or str(uuid.uuid4())
# Expose the active task_id so tools running mid-turn (e.g. delegate_task
# in delegate_tool.py) can identify this agent for the cross-agent file
# state registry. Set BEFORE any tool dispatch so snapshots taken at
# child-launch time see the parent's real id, not None.
agent._current_task_id = effective_task_id
turn_id = f"{agent.session_id or 'session'}:{effective_task_id}:{uuid.uuid4().hex[:8]}"
agent._current_turn_id = turn_id
agent._current_api_request_id = ""
# Reset retry counters and iteration budget at the start of each turn
# so subagent usage from a previous turn doesn't eat into the next one.
agent._invalid_tool_retries = 0
agent._invalid_json_retries = 0
agent._empty_content_retries = 0
agent._incomplete_scratchpad_retries = 0
agent._codex_incomplete_retries = 0
agent._thinking_prefill_retries = 0
agent._post_tool_empty_retried = False
agent._last_content_with_tools = None
agent._last_content_tools_all_housekeeping = False
agent._mute_post_response = False
agent._unicode_sanitization_passes = 0
agent._tool_guardrails.reset_for_turn()
agent._tool_guardrail_halt_decision = None
# True until the server rejects an image_url content part with an error
# like "Only 'text' content type is supported." Set to False on first
# rejection and kept False for the rest of the session so we never re-send
# images to a text-only endpoint. Scoped per `_run()` call, not per instance.
agent._vision_supported = True
# Pre-turn connection health check: detect and clean up dead TCP
# connections left over from provider outages or dropped streams.
# This prevents the next API call from hanging on a zombie socket.
if agent.api_mode != "anthropic_messages":
try:
if agent._cleanup_dead_connections():
agent._emit_status(
"🔌 Detected stale connections from a previous provider "
"issue — cleaned up automatically. Proceeding with fresh "
"connection."
)
except Exception:
pass
# Replay compression warning through status_callback for gateway
# platforms (the callback was not wired during __init__).
if agent._compression_warning:
agent._replay_compression_warning()
agent._compression_warning = None # send once
# NOTE: _turns_since_memory and _iters_since_skill are NOT reset here.
# They are initialized in __init__ and must persist across run_conversation
# calls so that nudge logic accumulates correctly in CLI mode.
agent.iteration_budget = IterationBudget(agent.max_iterations)
# Log conversation turn start for debugging/observability
_preview_text = _summarize_user_message_for_log(user_message)
_msg_preview = (_preview_text[:80] + "...") if len(_preview_text) > 80 else _preview_text
_msg_preview = _msg_preview.replace("\n", " ")
logger.info(
"conversation turn: session=%s model=%s provider=%s platform=%s history=%d msg=%r",
agent.session_id or "none", agent.model, agent.provider or "unknown",
agent.platform or "unknown", len(conversation_history or []),
_msg_preview,
# ── Per-turn setup (the prologue) ──
# All once-per-turn setup — stdio guarding, retry-counter resets, user
# message sanitization, todo/nudge hydration, system-prompt restore-or-
# build, crash-resilience persistence, preflight compression, the
# ``pre_llm_call`` plugin hook, and external-memory prefetch — lives in
# ``build_turn_context``. It mutates ``agent`` exactly as the inline code
# did and returns the locals the loop below reads back. See
# ``agent/turn_context.py``.
_ctx = build_turn_context(
agent,
user_message,
system_message,
conversation_history,
task_id,
stream_callback,
persist_user_message,
restore_or_build_system_prompt=_restore_or_build_system_prompt,
install_safe_stdio=_install_safe_stdio,
sanitize_surrogates=_sanitize_surrogates,
summarize_user_message_for_log=_summarize_user_message_for_log,
set_session_context=set_session_context,
set_current_write_origin=set_current_write_origin,
ra=_ra,
)
user_message = _ctx.user_message
original_user_message = _ctx.original_user_message
messages = _ctx.messages
conversation_history = _ctx.conversation_history
active_system_prompt = _ctx.active_system_prompt
effective_task_id = _ctx.effective_task_id
turn_id = _ctx.turn_id
current_turn_user_idx = _ctx.current_turn_user_idx
_should_review_memory = _ctx.should_review_memory
_plugin_user_context = _ctx.plugin_user_context
_ext_prefetch_cache = _ctx.ext_prefetch_cache
# Initialize conversation (copy to avoid mutating the caller's list)
messages = list(conversation_history) if conversation_history else []
# Hydrate todo store from conversation history (gateway creates a fresh
# AIAgent per message, so the in-memory store is empty -- we need to
# recover the todo state from the most recent todo tool response in history)
if conversation_history and not agent._todo_store.has_items():
agent._hydrate_todo_store(conversation_history)
# Hydrate per-session nudge counters from persisted history.
# Gateway creates a fresh AIAgent per inbound message (cache miss /
# 1h idle eviction / config-signature mismatch / process restart), so
# _turns_since_memory and _user_turn_count start at 0 every turn and
# the memory.nudge_interval trigger may never be reached. Reconstruct
# an effective count from prior user turns in conversation_history.
# Idempotent: a cached agent that already accumulated counters keeps
# them; only a freshly-built agent with empty in-memory state hydrates.
# See issue #22357.
if conversation_history and agent._user_turn_count == 0:
prior_user_turns = sum(
1 for m in conversation_history if m.get("role") == "user"
)
if prior_user_turns > 0:
agent._user_turn_count = prior_user_turns
if agent._memory_nudge_interval > 0 and agent._turns_since_memory == 0:
# % preserves original 1-in-N cadence rather than firing a
# review immediately on resume (which would surprise users
# whose session happened to land just past a multiple of N).
agent._turns_since_memory = prior_user_turns % agent._memory_nudge_interval
# Prefill messages (few-shot priming) are injected at API-call time only,
# never stored in the messages list. This keeps them ephemeral: they won't
# be saved to session DB, session logs, or batch trajectories, but they're
# automatically re-applied on every API call (including session continuations).
# Track user turns for memory flush and periodic nudge logic
agent._user_turn_count += 1
# Reset the streaming context scrubber at the top of each turn so a
# hung span from a prior interrupted stream can't taint this turn's
# output.
scrubber = getattr(agent, "_stream_context_scrubber", None)
if scrubber is not None:
scrubber.reset()
# Reset the think scrubber for the same reason — an interrupted
# prior stream may have left us inside an unterminated block.
think_scrubber = getattr(agent, "_stream_think_scrubber", None)
if think_scrubber is not None:
think_scrubber.reset()
# Preserve the original user message (no nudge injection).
original_user_message = persist_user_message if persist_user_message is not None else user_message
# Track memory nudge trigger (turn-based, checked here).
# Skill trigger is checked AFTER the agent loop completes, based on
# how many tool iterations THIS turn used.
_should_review_memory = False
if (agent._memory_nudge_interval > 0
and "memory" in agent.valid_tool_names
and agent._memory_store):
agent._turns_since_memory += 1
if agent._turns_since_memory >= agent._memory_nudge_interval:
_should_review_memory = True
agent._turns_since_memory = 0
# Add user message
user_msg = {"role": "user", "content": user_message}
messages.append(user_msg)
current_turn_user_idx = len(messages) - 1
agent._persist_user_message_idx = current_turn_user_idx
if not agent.quiet_mode:
_print_preview = _summarize_user_message_for_log(user_message)
agent._safe_print(f"💬 Starting conversation: '{_print_preview[:60]}{'...' if len(_print_preview) > 60 else ''}'")
# ── System prompt (cached per session for prefix caching) ──
# Built once on first call, reused for all subsequent calls.
# Only rebuilt after context compression events (which invalidate
# the cache and reload memory from disk).
#
# For continuing sessions (gateway creates a fresh AIAgent per
# message), we load the stored system prompt from the session DB
# instead of rebuilding. Rebuilding would pick up memory changes
# from disk that the model already knows about (it wrote them!),
# producing a different system prompt and breaking the Anthropic
# prefix cache.
if agent._cached_system_prompt is None:
_restore_or_build_system_prompt(agent, system_message, conversation_history)
active_system_prompt = agent._cached_system_prompt
# Crash-resilience: persist the inbound user turn as soon as the session row
# has a valid system prompt, before any provider call or tool execution can
# hang/kill the process. The normal end-of-turn persist still runs later;
# _last_flushed_db_idx makes this idempotent and prevents duplicate rows.
try:
agent._persist_session(messages, conversation_history)
except Exception:
logger.warning(
"Early turn-start session persistence failed for session=%s",
agent.session_id or "none",
exc_info=True,
)
# ── Preflight context compression ──
# Before entering the main loop, check if the loaded conversation
# history already exceeds the model's context threshold. This handles
# cases where a user switches to a model with a smaller context window
# while having a large existing session — compress proactively rather
# than waiting for an API error (which might be caught as a non-retryable
# 4xx and abort the request entirely).
if (
agent.compression_enabled
and len(messages) > agent.context_compressor.protect_first_n
+ agent.context_compressor.protect_last_n + 1
):
# Include tool schema tokens — with many tools these can add
# 20-30K+ tokens that the old sys+msg estimate missed entirely.
_preflight_tokens = estimate_request_tokens_rough(
messages,
system_prompt=active_system_prompt or "",
tools=agent.tools or None,
)
_compressor = agent.context_compressor
_defer_preflight = getattr(
_compressor,
"should_defer_preflight_to_real_usage",
lambda _tokens: False,
)
_preflight_deferred = _defer_preflight(_preflight_tokens)
if not _preflight_deferred:
# Keep the CLI/ACP context display in sync with what preflight
# actually measured. The status bar reads
# ``compressor.last_prompt_tokens``, which otherwise only updates
# from a *successful* API response. When the conversation has grown
# since the last successful call — or when compression then fails
# (e.g. the auxiliary summary model times out) and no fresh usage
# arrives — the bar stays stuck at the old, smaller value while
# preflight reports a much larger number, looking out of sync.
# Seed it with the fresh estimate (only ever revising upward; a real
# ``update_from_response`` will correct it after the next API call).
# Skipped when deferring — a deferred estimate is known to over-count
# vs the last real provider prompt, so trusting it for the display
# would re-introduce the very desync we're avoiding.
_last = _compressor.last_prompt_tokens
# Do NOT overwrite the -1 sentinel. compress_context() sets
# last_prompt_tokens=-1 right after compression to mark "no real API
# usage yet". `(x or 0)` evaluates to -1 (truthy) for the sentinel,
# so the old comparison was always True and clobbered the sentinel
# with a schema-inflated rough estimate — re-triggering compression
# on the next turn (#36718). Treat any negative value as "no data".
if _last >= 0 and _preflight_tokens > _last:
_compressor.last_prompt_tokens = _preflight_tokens
if _preflight_deferred:
logger.info(
"Skipping preflight compression: rough estimate ~%s >= %s, "
"but last real provider prompt was %s after compression",
f"{_preflight_tokens:,}",
f"{_compressor.threshold_tokens:,}",
f"{_compressor.last_real_prompt_tokens:,}",
)
elif _compressor.should_compress(_preflight_tokens):
logger.info(
"Preflight compression: ~%s tokens >= %s threshold (model %s, ctx %s)",
f"{_preflight_tokens:,}",
f"{_compressor.threshold_tokens:,}",
agent.model,
f"{_compressor.context_length:,}",
)
agent._emit_status(
f"📦 Preflight compression: ~{_preflight_tokens:,} tokens "
f">= {_compressor.threshold_tokens:,} threshold. "
"This may take a moment."
)
# May need multiple passes for very large sessions with small
# context windows (each pass summarises the middle N turns).
for _pass in range(3):
_orig_len = len(messages)
messages, active_system_prompt = agent._compress_context(
messages, system_message, approx_tokens=_preflight_tokens,
task_id=effective_task_id,
)
if len(messages) >= _orig_len:
break # Cannot compress further
# Compression created a new session — clear the history
# reference so _flush_messages_to_session_db writes ALL
# compressed messages to the new session's SQLite, not
# skipping them because conversation_history is still the
# pre-compression length.
conversation_history = None
# Fix: reset retry counters after compression so the model
# gets a fresh budget on the compressed context. Without
# this, pre-compression retries carry over and the model
# hits "(empty)" immediately after compression-induced
# context loss.
agent._empty_content_retries = 0
agent._thinking_prefill_retries = 0
agent._last_content_with_tools = None
agent._last_content_tools_all_housekeeping = False
agent._mute_post_response = False
# Re-estimate after compression
_preflight_tokens = estimate_request_tokens_rough(
messages,
system_prompt=active_system_prompt or "",
tools=agent.tools or None,
)
if not _compressor.should_compress(_preflight_tokens):
break # Under threshold or anti-thrash guard stopped it
# Plugin hook: pre_llm_call
# Fired once per turn before the tool-calling loop. Plugins can
# return a dict with a ``context`` key (or a plain string) whose
# value is appended to the current turn's user message.
#
# Context is ALWAYS injected into the user message, never the
# system prompt. This preserves the prompt cache prefix — the
# system prompt stays identical across turns so cached tokens
# are reused. The system prompt is Hermes's territory; plugins
# contribute context alongside the user's input.
#
# All injected context is ephemeral (not persisted to session DB).
_plugin_user_context = ""
try:
from hermes_cli.plugins import invoke_hook as _invoke_hook
_pre_results = _invoke_hook(
"pre_llm_call",
session_id=agent.session_id,
task_id=effective_task_id,
turn_id=turn_id,
user_message=original_user_message,
conversation_history=list(messages),
is_first_turn=(not bool(conversation_history)),
model=agent.model,
platform=getattr(agent, "platform", None) or "",
sender_id=getattr(agent, "_user_id", None) or "",
)
_ctx_parts: list[str] = []
for r in _pre_results:
if isinstance(r, dict) and r.get("context"):
_ctx_parts.append(str(r["context"]))
elif isinstance(r, str) and r.strip():
_ctx_parts.append(r)
if _ctx_parts:
_plugin_user_context = "\n\n".join(_ctx_parts)
except Exception as exc:
logger.warning("pre_llm_call hook failed: %s", exc)
# Main conversation loop
# Main conversation loop counters (pure locals consumed by the loop below).
api_call_count = 0
final_response = None
interrupted = False
@@ -770,53 +448,6 @@ def run_conversation(
compression_attempts = 0
_turn_exit_reason = "unknown" # Diagnostic: why the loop ended
# Per-turn file-mutation verifier state. Keyed by resolved path;
# each failed ``write_file`` / ``patch`` call records the error
# preview. Later successful writes to the same path remove the
# entry (the model recovered). At end-of-turn, any entries still
# present are surfaced in an advisory footer so the model cannot
# over-claim success while the file is actually unchanged on disk.
agent._turn_failed_file_mutations: Dict[str, Dict[str, Any]] = {}
# Record the execution thread so interrupt()/clear_interrupt() can
# scope the tool-level interrupt signal to THIS agent's thread only.
# Must be set before any thread-scoped interrupt syncing.
agent._execution_thread_id = threading.current_thread().ident
# Always clear stale per-thread state from a previous turn. If an
# interrupt arrived before startup finished, preserve it and bind it
# to this execution thread now instead of dropping it on the floor.
_ra()._set_interrupt(False, agent._execution_thread_id)
if agent._interrupt_requested:
_ra()._set_interrupt(True, agent._execution_thread_id)
agent._interrupt_thread_signal_pending = False
else:
agent._interrupt_message = None
agent._interrupt_thread_signal_pending = False
# Notify memory providers of the new turn so cadence tracking works.
# Must happen BEFORE prefetch_all() so providers know which turn it is
# and can gate context/dialectic refresh via contextCadence/dialecticCadence.
if agent._memory_manager:
try:
_turn_msg = original_user_message if isinstance(original_user_message, str) else ""
agent._memory_manager.on_turn_start(agent._user_turn_count, _turn_msg)
except Exception:
pass
# External memory provider: prefetch once before the tool loop.
# Reuse the cached result on every iteration to avoid re-calling
# prefetch_all() on each tool call (10 tool calls = 10x latency + cost).
# Use original_user_message (clean input) — user_message may contain
# injected skill content that bloats / breaks provider queries.
_ext_prefetch_cache = ""
if agent._memory_manager:
try:
_query = original_user_message if isinstance(original_user_message, str) else ""
_ext_prefetch_cache = agent._memory_manager.prefetch_all(_query) or ""
except Exception:
pass
# Optional opt-in runtime: if api_mode == codex_app_server, hand the
# turn to the codex app-server subprocess (terminal/file ops/patching
# all run inside Codex). Default Hermes path is bypassed entirely.
@@ -1172,22 +803,8 @@ def run_conversation(
api_start_time = time.time()
retry_count = 0
max_retries = agent._api_max_retries
primary_recovery_attempted = False
_retry = TurnRetryState()
max_compression_attempts = 3
codex_auth_retry_attempted=False
anthropic_auth_retry_attempted=False
nous_auth_retry_attempted=False
nous_paid_entitlement_refresh_attempted=False
copilot_auth_retry_attempted=False
thinking_sig_retry_attempted = False
invalid_encrypted_content_retry_attempted = False
image_shrink_retry_attempted = False
multimodal_tool_content_retry_attempted = False
oauth_1m_beta_retry_attempted = False
llama_cpp_grammar_retry_attempted = False
has_retried_429 = False
restart_with_compressed_messages = False
restart_with_length_continuation = False
finish_reason = "stop"
response = None # Guard against UnboundLocalError if all retries fail
@@ -1220,7 +837,7 @@ def run_conversation(
if agent._try_activate_fallback():
retry_count = 0
compression_attempts = 0
primary_recovery_attempted = False
_retry.primary_recovery_attempted = False
continue
# No fallback available — surface buffered context
# so user sees the rate-limit message that led here.
@@ -1545,7 +1162,7 @@ def run_conversation(
if agent._try_activate_fallback():
retry_count = 0
compression_attempts = 0
primary_recovery_attempted = False
_retry.primary_recovery_attempted = False
continue
# Check for error field in response (some providers include this)
@@ -1616,7 +1233,7 @@ def run_conversation(
if agent._try_activate_fallback():
retry_count = 0
compression_attempts = 0
primary_recovery_attempted = False
_retry.primary_recovery_attempted = False
continue
# Terminal — flush buffered retry trace so user sees what happened.
agent._flush_status_buffer()
@@ -1840,7 +1457,7 @@ def run_conversation(
}
messages.append(continue_msg)
agent._session_messages = messages
restart_with_length_continuation = True
_retry.restart_with_length_continuation = True
break
partial_response = agent._strip_think_blocks("".join(truncated_response_parts)).strip()
@@ -2020,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
@@ -2046,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"
@@ -2089,7 +1743,7 @@ def run_conversation(
f"({hit_pct:.0f}% hit, {written:,} written)"
)
has_retried_429 = False # Reset on success
_retry.has_retried_429 = False # Reset on success
# Note: don't clear the retry buffer here — an "API call
# success" only means we got bytes back, not that we got
# usable content. Empty responses still loop through the
@@ -2117,7 +1771,7 @@ def run_conversation(
agent._vprint(f"{agent.log_prefix}⚡ Interrupted during API call.", force=True)
agent._persist_session(messages, conversation_history)
interrupted = True
final_response = f"Operation interrupted: waiting for model response ({api_elapsed:.1f}s elapsed)."
final_response = f"{INTERRUPT_WAITING_FOR_MODEL_PREFIX}{api_elapsed:.1f}s elapsed)."
break
except Exception as api_error:
@@ -2419,9 +2073,9 @@ def run_conversation(
getattr(agent, "provider", "") or "",
getattr(agent, "base_url", "") or "",
)
and not nous_paid_entitlement_refresh_attempted
and not _retry.nous_paid_entitlement_refresh_attempted
):
nous_paid_entitlement_refresh_attempted = True
_retry.nous_paid_entitlement_refresh_attempted = True
if _try_refresh_nous_paid_entitlement_credentials(agent):
agent._vprint(
f"{agent.log_prefix}🔐 Nous paid access verified — "
@@ -2430,9 +2084,9 @@ def run_conversation(
)
continue
recovered_with_pool, has_retried_429 = agent._recover_with_credential_pool(
recovered_with_pool, _retry.has_retried_429 = agent._recover_with_credential_pool(
status_code=status_code,
has_retried_429=has_retried_429,
has_retried_429=_retry.has_retried_429,
classified_reason=classified.reason,
error_context=error_context,
)
@@ -2447,9 +2101,9 @@ def run_conversation(
# fails, fall through to normal error handling.
if (
classified.reason == FailoverReason.image_too_large
and not image_shrink_retry_attempted
and not _retry.image_shrink_retry_attempted
):
image_shrink_retry_attempted = True
_retry.image_shrink_retry_attempted = True
if agent._try_shrink_image_parts_in_messages(api_messages):
agent._vprint(
f"{agent.log_prefix}📐 Image(s) exceeded provider size limit — "
@@ -2472,9 +2126,9 @@ def run_conversation(
# downgrade, and retry once. See issue #27344.
if (
classified.reason == FailoverReason.multimodal_tool_content_unsupported
and not multimodal_tool_content_retry_attempted
and not _retry.multimodal_tool_content_retry_attempted
):
multimodal_tool_content_retry_attempted = True
_retry.multimodal_tool_content_retry_attempted = True
if agent._try_strip_image_parts_from_tool_messages(api_messages):
agent._vprint(
f"{agent.log_prefix}📐 Provider rejected list-type tool content — "
@@ -2501,9 +2155,9 @@ def run_conversation(
classified.reason == FailoverReason.oauth_long_context_beta_forbidden
and agent.api_mode == "anthropic_messages"
and agent._is_anthropic_oauth
and not oauth_1m_beta_retry_attempted
and not _retry.oauth_1m_beta_retry_attempted
):
oauth_1m_beta_retry_attempted = True
_retry.oauth_1m_beta_retry_attempted = True
if not getattr(agent, "_oauth_1m_beta_disabled", False):
agent._oauth_1m_beta_disabled = True
try:
@@ -2522,9 +2176,9 @@ def run_conversation(
agent.api_mode == "codex_responses"
and agent.provider in {"openai-codex", "xai-oauth"}
and status_code == 401
and not codex_auth_retry_attempted
and not _retry.codex_auth_retry_attempted
):
codex_auth_retry_attempted = True
_retry.codex_auth_retry_attempted = True
if agent._try_refresh_codex_client_credentials(force=True):
_label = "xAI OAuth" if agent.provider == "xai-oauth" else "Codex"
agent._buffer_vprint(f"🔐 {_label} auth refreshed after 401. Retrying request...")
@@ -2533,9 +2187,9 @@ def run_conversation(
agent.api_mode == "chat_completions"
and agent.provider == "nous"
and status_code == 401
and not nous_auth_retry_attempted
and not _retry.nous_auth_retry_attempted
):
nous_auth_retry_attempted = True
_retry.nous_auth_retry_attempted = True
if agent._try_refresh_nous_client_credentials(force=True):
print(f"{agent.log_prefix}🔐 Nous agent key refreshed after 401. Retrying request...")
continue
@@ -2564,9 +2218,9 @@ def run_conversation(
if (
agent.provider == "copilot"
and status_code == 401
and not copilot_auth_retry_attempted
and not _retry.copilot_auth_retry_attempted
):
copilot_auth_retry_attempted = True
_retry.copilot_auth_retry_attempted = True
if agent._try_refresh_copilot_client_credentials():
agent._buffer_vprint(f"🔐 Copilot credentials refreshed after 401. Retrying request...")
continue
@@ -2574,9 +2228,9 @@ def run_conversation(
agent.api_mode == "anthropic_messages"
and status_code == 401
and hasattr(agent, '_anthropic_api_key')
and not anthropic_auth_retry_attempted
and not _retry.anthropic_auth_retry_attempted
):
anthropic_auth_retry_attempted = True
_retry.anthropic_auth_retry_attempted = True
from agent.anthropic_adapter import _is_oauth_token
from agent.azure_identity_adapter import is_token_provider
if agent._try_refresh_anthropic_client_credentials():
@@ -2617,9 +2271,9 @@ def run_conversation(
# blocks at all. One-shot — don't retry infinitely.
if (
classified.reason == FailoverReason.thinking_signature
and not thinking_sig_retry_attempted
and not _retry.thinking_sig_retry_attempted
):
thinking_sig_retry_attempted = True
_retry.thinking_sig_retry_attempted = True
for _m in messages:
if isinstance(_m, dict):
_m.pop("reasoning_details", None)
@@ -2651,7 +2305,7 @@ def run_conversation(
# handles it (the provider is rejecting something else).
if (
classified.reason == FailoverReason.invalid_encrypted_content
and not invalid_encrypted_content_retry_attempted
and not _retry.invalid_encrypted_content_retry_attempted
and agent.api_mode == "codex_responses"
and bool(getattr(agent, "_codex_reasoning_replay_enabled", True))
and any(
@@ -2662,7 +2316,7 @@ def run_conversation(
for _m in messages
)
):
invalid_encrypted_content_retry_attempted = True
_retry.invalid_encrypted_content_retry_attempted = True
replay_stats = agent._disable_codex_reasoning_replay(messages)
agent._vprint(
f"{agent.log_prefix}⚠️ Encrypted reasoning replay was rejected by the provider — "
@@ -2689,9 +2343,9 @@ def run_conversation(
# fires only for users on llama.cpp's OAI server.
if (
classified.reason == FailoverReason.llama_cpp_grammar_pattern
and not llama_cpp_grammar_retry_attempted
and not _retry.llama_cpp_grammar_retry_attempted
):
llama_cpp_grammar_retry_attempted = True
_retry.llama_cpp_grammar_retry_attempted = True
try:
from tools.schema_sanitizer import strip_pattern_and_format
_, _stripped = strip_pattern_and_format(agent.tools)
@@ -2902,7 +2556,7 @@ def run_conversation(
f"(was {old_ctx:,}), retrying..."
)
time.sleep(2)
restart_with_compressed_messages = True
_retry.restart_with_compressed_messages = True
break
# Fall through to normal error handling if compression
# is exhausted or didn't help.
@@ -2935,7 +2589,7 @@ def run_conversation(
if agent._try_activate_fallback(reason=classified.reason):
retry_count = 0
compression_attempts = 0
primary_recovery_attempted = False
_retry.primary_recovery_attempted = False
continue
# ── Nous Portal: record rate limit & skip retries ─────
@@ -3073,7 +2727,7 @@ def run_conversation(
if len(messages) < original_len:
agent._buffer_status(f"🗜️ Compressed {original_len}{len(messages)} messages, retrying...")
time.sleep(2) # Brief pause between compression retries
restart_with_compressed_messages = True
_retry.restart_with_compressed_messages = True
break
else:
# Terminal — surface buffered context so the user
@@ -3145,7 +2799,7 @@ def run_conversation(
"failed": True,
"compression_exhausted": True,
}
restart_with_compressed_messages = True
_retry.restart_with_compressed_messages = True
break
# Error is about the INPUT being too large. Only reduce
@@ -3230,7 +2884,7 @@ def run_conversation(
if len(messages) < original_len:
agent._buffer_status(f"🗜️ Compressed {original_len}{len(messages)} messages, retrying...")
time.sleep(2) # Brief pause between compression retries
restart_with_compressed_messages = True
_retry.restart_with_compressed_messages = True
break
else:
# Can't compress further and already at minimum tier
@@ -3335,7 +2989,7 @@ def run_conversation(
if agent._try_activate_fallback():
retry_count = 0
compression_attempts = 0
primary_recovery_attempted = False
_retry.primary_recovery_attempted = False
continue
if api_kwargs is not None:
agent._dump_api_request_debug(
@@ -3467,10 +3121,10 @@ def run_conversation(
# client once for transient transport errors (stale
# connection pool, TCP reset). Only attempted once
# per API call block.
if not primary_recovery_attempted and agent._try_recover_primary_transport(
if not _retry.primary_recovery_attempted and agent._try_recover_primary_transport(
api_error, retry_count=retry_count, max_retries=max_retries,
):
primary_recovery_attempted = True
_retry.primary_recovery_attempted = True
retry_count = 0
continue
# Try fallback before giving up entirely
@@ -3479,7 +3133,7 @@ def run_conversation(
if agent._try_activate_fallback():
retry_count = 0
compression_attempts = 0
primary_recovery_attempted = False
_retry.primary_recovery_attempted = False
continue
# Terminal — flush buffered retry/fallback trace.
agent._flush_status_buffer()
@@ -3630,17 +3284,17 @@ def run_conversation(
_turn_exit_reason = "interrupted_during_api_call"
break
if restart_with_compressed_messages:
if _retry.restart_with_compressed_messages:
api_call_count -= 1
agent.iteration_budget.refund()
# Count compression restarts toward the retry limit to prevent
# infinite loops when compression reduces messages but not enough
# to fit the context window.
retry_count += 1
restart_with_compressed_messages = False
_retry.restart_with_compressed_messages = False
continue
if restart_with_length_continuation:
if _retry.restart_with_length_continuation:
# Progressively boost the output token budget on each retry.
# Retry 1 → 2× base, retry 2 → 3× base, capped at 32 768.
# Applies to all providers via _ephemeral_max_output_tokens.

View File

@@ -374,7 +374,7 @@ def _iter_custom_providers(config: Optional[dict] = None):
yield _normalize_custom_pool_name(name), entry
def get_custom_provider_pool_key(base_url: str, provider_name: Optional[str] = None) -> Optional[str]:
def get_custom_provider_pool_key(base_url: Optional[str], provider_name: Optional[str] = None) -> Optional[str]:
"""Look up the custom_providers list in config.yaml and return 'custom:<name>' for a matching base_url.
When provider_name is given, prefer matching by name first (solving the case where

View File

@@ -375,6 +375,11 @@ CURATOR_REVIEW_PROMPT = (
"into ~/.hermes/skills/.archive/) is the maximum destructive action. "
"Archives are recoverable; deletion is not.\n"
"3. DO NOT touch skills shown as pinned=yes. Skip them entirely.\n"
"3b. DO NOT archive, delete, consolidate, move, or otherwise modify any "
"skill named in the protected built-ins list (currently: plan). These "
"back load-bearing UX (slash-command entry points referenced in docs and "
"tips) and are filtered out of the candidate list below — never resurrect "
"one as an archive or absorb target.\n"
"4. DO NOT use usage counters as a reason to skip consolidation. The "
"counters are new and often mostly zero. Judge overlap on CONTENT, "
"not on use_count. 'use=0' is not evidence a skill is valuable; it's "

View File

@@ -219,6 +219,35 @@ def _supports_vision_override(
coerced = _coerce_capability_bool(per_model.get("supports_vision"))
if coerced is not None:
return coerced
# 2b. Legacy list-style custom_providers. Entries are dicts with a
# "name" key and a nested "models" dict. Match by provider name (which
# may appear as the raw name or "custom:<name>" at runtime).
custom_providers = cfg.get("custom_providers")
if isinstance(custom_providers, list):
# Build candidate names: the provider value and the config provider
# value, both raw and with "custom:" prefix stripped/added.
candidate_names: set = set()
for p in filter(None, (provider, config_provider)):
candidate_names.add(p)
if p.startswith("custom:"):
candidate_names.add(p[len("custom:"):])
else:
candidate_names.add(f"custom:{p}")
for entry_raw in custom_providers:
if not isinstance(entry_raw, dict):
continue
entry_name = str(entry_raw.get("name") or "").strip()
if entry_name not in candidate_names:
continue
models_raw = entry_raw.get("models")
models_cfg = models_raw if isinstance(models_raw, dict) else {}
per_model_raw = models_cfg.get(model)
per_model = per_model_raw if isinstance(per_model_raw, dict) else {}
coerced = _coerce_capability_bool(per_model.get("supports_vision"))
if coerced is not None:
return coerced
return None

View File

@@ -20,23 +20,17 @@ import json
import time
from collections import Counter, defaultdict
from datetime import datetime
from typing import Any, Dict, List
from typing import Any, Dict, List, Optional
from agent.usage_pricing import (
CanonicalUsage,
DEFAULT_PRICING,
estimate_usage_cost,
format_duration_compact,
has_known_pricing,
)
_DEFAULT_PRICING = DEFAULT_PRICING
def _has_known_pricing(model_name: str, provider: str = None, base_url: str = None) -> bool:
"""Check if a model has known pricing (vs unknown/custom endpoint)."""
return has_known_pricing(model_name, provider=provider, base_url=base_url)
def _estimate_cost(
session_or_model: Dict[str, Any] | str,
@@ -45,8 +39,8 @@ def _estimate_cost(
*,
cache_read_tokens: int = 0,
cache_write_tokens: int = 0,
provider: str = None,
base_url: str = None,
provider: Optional[str] = None,
base_url: Optional[str] = None,
) -> tuple[float, str]:
"""Estimate the USD cost for a session row or a model/token tuple."""
if isinstance(session_or_model, dict):
@@ -77,9 +71,6 @@ def _estimate_cost(
return float(result.amount_usd or 0.0), result.status
def _format_duration(seconds: float) -> str:
"""Format seconds into a human-readable duration string."""
return format_duration_compact(seconds)
def _bar_chart(values: List[int], max_width: int = 20) -> List[str]:
@@ -435,7 +426,7 @@ class InsightsEngine:
included_cost_sessions += 1
elif status == "unknown":
unknown_cost_sessions += 1
if _has_known_pricing(model, s.get("billing_provider"), s.get("billing_base_url")):
if has_known_pricing(model, s.get("billing_provider"), s.get("billing_base_url")):
models_with_pricing.add(display)
else:
models_without_pricing.add(display)
@@ -508,7 +499,7 @@ class InsightsEngine:
d["tool_calls"] += s.get("tool_call_count") or 0
estimate, status = _estimate_cost(s)
d["cost"] += estimate
d["has_pricing"] = _has_known_pricing(model, s.get("billing_provider"), s.get("billing_base_url"))
d["has_pricing"] = has_known_pricing(model, s.get("billing_provider"), s.get("billing_base_url"))
d["cost_status"] = status
result = [
@@ -679,7 +670,7 @@ class InsightsEngine:
top.append({
"label": "Longest session",
"session_id": longest["id"][:16],
"value": _format_duration(dur),
"value": format_duration_compact(dur),
"date": datetime.fromtimestamp(longest["started_at"]).strftime("%b %d"),
})
@@ -764,7 +755,7 @@ class InsightsEngine:
lines.append(f" Input tokens: {o['total_input_tokens']:<12,} Output tokens: {o['total_output_tokens']:,}")
lines.append(f" Total tokens: {o['total_tokens']:,}")
if o["total_hours"] > 0:
lines.append(f" Active time: ~{_format_duration(o['total_hours'] * 3600):<11} Avg session: ~{_format_duration(o['avg_session_duration'])}")
lines.append(f" Active time: ~{format_duration_compact(o['total_hours'] * 3600):<11} Avg session: ~{format_duration_compact(o['avg_session_duration'])}")
lines.append(f" Avg msgs/session: {o['avg_messages_per_session']:.1f}")
lines.append("")
@@ -879,7 +870,7 @@ class InsightsEngine:
lines.append(f"**Sessions:** {o['total_sessions']} | **Messages:** {o['total_messages']:,} | **Tool calls:** {o['total_tool_calls']:,}")
lines.append(f"**Tokens:** {o['total_tokens']:,} (in: {o['total_input_tokens']:,} / out: {o['total_output_tokens']:,})")
if o["total_hours"] > 0:
lines.append(f"**Active time:** ~{_format_duration(o['total_hours'] * 3600)} | **Avg session:** ~{_format_duration(o['avg_session_duration'])}")
lines.append(f"**Active time:** ~{format_duration_compact(o['total_hours'] * 3600)} | **Avg session:** ~{format_duration_compact(o['avg_session_duration'])}")
lines.append("")
# Models (top 5)

View File

@@ -1684,6 +1684,26 @@ def get_model_context_length(
"in config.yaml to override.",
model, base_url, f"{DEFAULT_FALLBACK_CONTEXT:,}",
)
# 3b. Before falling back to the hard 256K default, consult the
# hardcoded catalog as a last resort. A proxied/custom Anthropic
# gateway (e.g. corporate proxy) fails the Ollama/local probes
# above, but the model name may still match an entry in
# DEFAULT_CONTEXT_LENGTHS (e.g. "claude-opus-4-8" → 1M).
# Without this, the early return here short-circuits the catalog
# lookup at step 8 and silently caps context at 256K.
model_lower = model.lower()
for default_model, length in sorted(
DEFAULT_CONTEXT_LENGTHS.items(),
key=lambda x: len(x[0]),
reverse=True,
):
if default_model in model_lower:
logger.info(
"Using hardcoded context length %s for model %r "
"(custom endpoint, catalog match on %r)",
f"{length:,}", model, default_model,
)
return length
return DEFAULT_FALLBACK_CONTEXT
# 4. Anthropic /v1/models API (only for regular API keys, not OAuth)

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.

388
agent/turn_context.py Normal file
View File

@@ -0,0 +1,388 @@
"""Per-turn setup for ``run_conversation`` (the turn prologue).
``run_conversation`` opened with ~470 lines of straight-line setup before the
tool-calling loop ever started: stdio guarding, runtime-main wiring, retry-counter
resets, user-message sanitization, todo/nudge-counter hydration, system-prompt
restore-or-build, crash-resilience persistence, preflight context compression, the
``pre_llm_call`` plugin hook, and external-memory prefetch.
All of that is *prologue* — it runs once per turn, has no back-references into the
loop, and produces a fixed set of values the loop then consumes. ``TurnContext``
captures those produced values; ``build_turn_context`` performs the setup work and
returns one. ``run_conversation`` is left to unpack the context and run the loop,
shrinking the orchestrator by the full prologue.
The builder still mutates ``agent`` heavily (counters, thread id, cached prompt,
session DB) exactly as the inline code did — those side effects are the point. The
``TurnContext`` it returns carries only the *locals* the loop reads back.
Behavior is identical to the original inline prologue; this is a pure
move-and-name refactor with no semantic change.
"""
from __future__ import annotations
import logging
import threading
import uuid
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
from agent.iteration_budget import IterationBudget
from agent.model_metadata import estimate_request_tokens_rough
logger = logging.getLogger(__name__)
@dataclass
class TurnContext:
"""Values produced by the turn prologue and consumed by the turn loop."""
# Sanitized inbound message (surrogates stripped).
user_message: str
# Clean message preserved for transcripts / memory queries (no nudge injection).
original_user_message: Any
# Working message list for this turn (loop appends to it).
messages: List[Dict[str, Any]]
# May be reset to None by preflight compression (new session created).
conversation_history: Optional[List[Dict[str, Any]]]
# Cached system prompt active for this turn (may be rebuilt by compression).
active_system_prompt: Optional[str]
# Task / turn identifiers.
effective_task_id: str
turn_id: str
# Index of the current user turn within ``messages``.
current_turn_user_idx: int
# Whether the post-turn memory review should fire.
should_review_memory: bool = False
# Context contributed by ``pre_llm_call`` plugins (appended to user message).
plugin_user_context: str = ""
# External-memory prefetch result, reused across loop iterations.
ext_prefetch_cache: str = ""
def build_turn_context(
agent,
user_message: str,
system_message: Optional[str],
conversation_history: Optional[List[Dict[str, Any]]],
task_id: Optional[str],
stream_callback,
persist_user_message: Optional[str],
*,
restore_or_build_system_prompt,
install_safe_stdio,
sanitize_surrogates,
summarize_user_message_for_log,
set_session_context,
set_current_write_origin,
ra,
) -> TurnContext:
"""Run the once-per-turn setup and return the loop's input context.
The callables/helpers the original prologue referenced from the
``conversation_loop`` module are passed in explicitly to keep this module
free of an import cycle with ``agent.conversation_loop``.
"""
# Guard stdio against OSError from broken pipes (systemd/headless/daemon).
install_safe_stdio()
agent._ensure_db_session()
# Tell auxiliary_client what the live main provider/model are for this turn.
try:
from agent.auxiliary_client import set_runtime_main
set_runtime_main(
getattr(agent, "provider", "") or "",
getattr(agent, "model", "") or "",
base_url=getattr(agent, "base_url", "") or "",
api_key=getattr(agent, "api_key", "") or "",
api_mode=getattr(agent, "api_mode", "") or "",
)
except Exception:
pass
# Tag log records on this thread with the session ID for ``hermes logs``.
set_session_context(agent.session_id)
# Bind the skill write-origin ContextVar for this thread.
set_current_write_origin(getattr(agent, "_memory_write_origin", "assistant_tool"))
# Restore the primary runtime if the previous turn activated fallback.
agent._restore_primary_runtime()
# Sanitize surrogate characters from user input.
if isinstance(user_message, str):
user_message = sanitize_surrogates(user_message)
if isinstance(persist_user_message, str):
persist_user_message = sanitize_surrogates(persist_user_message)
# Store stream callback for _interruptible_api_call to pick up.
agent._stream_callback = stream_callback
agent._persist_user_message_idx = None
agent._persist_user_message_override = persist_user_message
# Generate unique task_id if not provided to isolate VMs between tasks.
effective_task_id = task_id or str(uuid.uuid4())
agent._current_task_id = effective_task_id
turn_id = f"{agent.session_id or 'session'}:{effective_task_id}:{uuid.uuid4().hex[:8]}"
agent._current_turn_id = turn_id
agent._current_api_request_id = ""
# Reset retry counters and iteration budget at the start of each turn.
agent._invalid_tool_retries = 0
agent._invalid_json_retries = 0
agent._empty_content_retries = 0
agent._incomplete_scratchpad_retries = 0
agent._codex_incomplete_retries = 0
agent._thinking_prefill_retries = 0
agent._post_tool_empty_retried = False
agent._last_content_with_tools = None
agent._last_content_tools_all_housekeeping = False
agent._mute_post_response = False
agent._unicode_sanitization_passes = 0
agent._tool_guardrails.reset_for_turn()
agent._tool_guardrail_halt_decision = None
agent._vision_supported = True
# Pre-turn connection health check: clean up dead TCP connections.
if agent.api_mode != "anthropic_messages":
try:
if agent._cleanup_dead_connections():
agent._emit_status(
"🔌 Detected stale connections from a previous provider "
"issue — cleaned up automatically. Proceeding with fresh "
"connection."
)
except Exception:
pass
# Replay compression warning through status_callback for gateway platforms.
if agent._compression_warning:
agent._replay_compression_warning()
agent._compression_warning = None # send once
# NOTE: _turns_since_memory and _iters_since_skill are NOT reset here.
agent.iteration_budget = IterationBudget(agent.max_iterations)
# Log conversation turn start for debugging/observability.
_preview_text = summarize_user_message_for_log(user_message)
_msg_preview = (_preview_text[:80] + "...") if len(_preview_text) > 80 else _preview_text
_msg_preview = _msg_preview.replace("\n", " ")
logger.info(
"conversation turn: session=%s model=%s provider=%s platform=%s history=%d msg=%r",
agent.session_id or "none", agent.model, agent.provider or "unknown",
agent.platform or "unknown", len(conversation_history or []),
_msg_preview,
)
# Initialize conversation (copy to avoid mutating the caller's list).
messages = list(conversation_history) if conversation_history else []
# Hydrate todo store from conversation history.
if conversation_history and not agent._todo_store.has_items():
agent._hydrate_todo_store(conversation_history)
# Hydrate per-session nudge counters from persisted history (issue #22357).
if conversation_history and agent._user_turn_count == 0:
prior_user_turns = sum(
1 for m in conversation_history if m.get("role") == "user"
)
if prior_user_turns > 0:
agent._user_turn_count = prior_user_turns
if agent._memory_nudge_interval > 0 and agent._turns_since_memory == 0:
agent._turns_since_memory = prior_user_turns % agent._memory_nudge_interval
# Track user turns for memory flush and periodic nudge logic.
agent._user_turn_count += 1
# Reset the streaming context scrubber at the top of each turn.
scrubber = getattr(agent, "_stream_context_scrubber", None)
if scrubber is not None:
scrubber.reset()
# Reset the think scrubber for the same reason.
think_scrubber = getattr(agent, "_stream_think_scrubber", None)
if think_scrubber is not None:
think_scrubber.reset()
# Preserve the original user message (no nudge injection).
original_user_message = persist_user_message if persist_user_message is not None else user_message
# Track memory nudge trigger (turn-based, checked here).
should_review_memory = False
if (agent._memory_nudge_interval > 0
and "memory" in agent.valid_tool_names
and agent._memory_store):
agent._turns_since_memory += 1
if agent._turns_since_memory >= agent._memory_nudge_interval:
should_review_memory = True
agent._turns_since_memory = 0
# Add user message.
user_msg = {"role": "user", "content": user_message}
messages.append(user_msg)
current_turn_user_idx = len(messages) - 1
agent._persist_user_message_idx = current_turn_user_idx
if not agent.quiet_mode:
_print_preview = summarize_user_message_for_log(user_message)
agent._safe_print(
f"💬 Starting conversation: '{_print_preview[:60]}"
f"{'...' if len(_print_preview) > 60 else ''}'"
)
# ── System prompt (cached per session for prefix caching) ──
if agent._cached_system_prompt is None:
restore_or_build_system_prompt(agent, system_message, conversation_history)
active_system_prompt = agent._cached_system_prompt
# Crash-resilience: persist the inbound user turn as soon as the session row exists.
try:
agent._persist_session(messages, conversation_history)
except Exception:
logger.warning(
"Early turn-start session persistence failed for session=%s",
agent.session_id or "none",
exc_info=True,
)
# ── Preflight context compression ──
if (
agent.compression_enabled
and len(messages) > agent.context_compressor.protect_first_n
+ agent.context_compressor.protect_last_n + 1
):
_preflight_tokens = estimate_request_tokens_rough(
messages,
system_prompt=active_system_prompt or "",
tools=agent.tools or None,
)
_compressor = agent.context_compressor
_defer_preflight = getattr(
_compressor,
"should_defer_preflight_to_real_usage",
lambda _tokens: False,
)
_preflight_deferred = _defer_preflight(_preflight_tokens)
if not _preflight_deferred:
_last = _compressor.last_prompt_tokens
# Do NOT overwrite the -1 sentinel (#36718).
if _last >= 0 and _preflight_tokens > _last:
_compressor.last_prompt_tokens = _preflight_tokens
if _preflight_deferred:
logger.info(
"Skipping preflight compression: rough estimate ~%s >= %s, "
"but last real provider prompt was %s after compression",
f"{_preflight_tokens:,}",
f"{_compressor.threshold_tokens:,}",
f"{_compressor.last_real_prompt_tokens:,}",
)
elif _compressor.should_compress(_preflight_tokens):
logger.info(
"Preflight compression: ~%s tokens >= %s threshold (model %s, ctx %s)",
f"{_preflight_tokens:,}",
f"{_compressor.threshold_tokens:,}",
agent.model,
f"{_compressor.context_length:,}",
)
agent._emit_status(
f"📦 Preflight compression: ~{_preflight_tokens:,} tokens "
f">= {_compressor.threshold_tokens:,} threshold. "
"This may take a moment."
)
for _pass in range(3):
_orig_len = len(messages)
messages, active_system_prompt = agent._compress_context(
messages, system_message, approx_tokens=_preflight_tokens,
task_id=effective_task_id,
)
if len(messages) >= _orig_len:
break # Cannot compress further
conversation_history = None
agent._empty_content_retries = 0
agent._thinking_prefill_retries = 0
agent._last_content_with_tools = None
agent._last_content_tools_all_housekeeping = False
agent._mute_post_response = False
_preflight_tokens = estimate_request_tokens_rough(
messages,
system_prompt=active_system_prompt or "",
tools=agent.tools or None,
)
if not _compressor.should_compress(_preflight_tokens):
break
# Plugin hook: pre_llm_call (context injected into user message, not system prompt).
plugin_user_context = ""
try:
from hermes_cli.plugins import invoke_hook as _invoke_hook
_pre_results = _invoke_hook(
"pre_llm_call",
session_id=agent.session_id,
task_id=effective_task_id,
turn_id=turn_id,
user_message=original_user_message,
conversation_history=list(messages),
is_first_turn=(not bool(conversation_history)),
model=agent.model,
platform=getattr(agent, "platform", None) or "",
sender_id=getattr(agent, "_user_id", None) or "",
)
_ctx_parts: list[str] = []
for r in _pre_results:
if isinstance(r, dict) and r.get("context"):
_ctx_parts.append(str(r["context"]))
elif isinstance(r, str) and r.strip():
_ctx_parts.append(r)
if _ctx_parts:
plugin_user_context = "\n\n".join(_ctx_parts)
except Exception as exc:
logger.warning("pre_llm_call hook failed: %s", exc)
# Per-turn file-mutation verifier state.
agent._turn_failed_file_mutations = {}
# Record the execution thread so interrupt()/clear_interrupt() can scope
# the tool-level interrupt signal to THIS agent's thread only.
agent._execution_thread_id = threading.current_thread().ident
# Clear stale per-thread interrupt state, preserving a pending interrupt.
ra()._set_interrupt(False, agent._execution_thread_id)
if agent._interrupt_requested:
ra()._set_interrupt(True, agent._execution_thread_id)
agent._interrupt_thread_signal_pending = False
else:
agent._interrupt_message = None
agent._interrupt_thread_signal_pending = False
# Notify memory providers of the new turn (BEFORE prefetch_all).
if agent._memory_manager:
try:
_turn_msg = original_user_message if isinstance(original_user_message, str) else ""
agent._memory_manager.on_turn_start(agent._user_turn_count, _turn_msg)
except Exception:
pass
# External memory provider: prefetch once before the tool loop.
ext_prefetch_cache = ""
if agent._memory_manager:
try:
_query = original_user_message if isinstance(original_user_message, str) else ""
ext_prefetch_cache = agent._memory_manager.prefetch_all(_query) or ""
except Exception:
pass
return TurnContext(
user_message=user_message,
original_user_message=original_user_message,
messages=messages,
conversation_history=conversation_history,
active_system_prompt=active_system_prompt,
effective_task_id=effective_task_id,
turn_id=turn_id,
current_turn_user_idx=current_turn_user_idx,
should_review_memory=should_review_memory,
plugin_user_context=plugin_user_context,
ext_prefetch_cache=ext_prefetch_cache,
)

68
agent/turn_retry_state.py Normal file
View File

@@ -0,0 +1,68 @@
"""Per-attempt recovery bookkeeping for the conversation turn loop.
The inner retry loop in ``run_conversation`` (``while retry_count <
max_retries``) makes several distinct recovery attempts on a single model API
call: a credential-pool 429 retry, a per-provider OAuth refresh (codex,
anthropic, nous, copilot), a long-context compression restart, a length-
continuation restart, and a handful of format-recovery branches (thinking-
signature stripping, multimodal-tool-content stripping, llama.cpp grammar
fallback, image shrink, invalid-encrypted-content, 1M-beta header).
Each of those branches is guarded by a one-shot boolean so it fires at most
once per attempt. They used to be ~16 bare ``*_attempted`` / ``has_retried_*``
/ ``restart_with_*`` locals declared inline before the loop and threaded
through its 2,400-line body. ``TurnRetryState`` collapses them into one object
the loop mutates in place (``state.codex_auth_retry_attempted = True``), giving
the recovery bookkeeping a single named, testable home.
Loop-control variables (``retry_count``, ``max_retries``,
``max_compression_attempts``) intentionally stay as plain locals — they are the
``while`` mechanics, not recovery bookkeeping, and putting them on the object
would add indirection without clarifying anything.
This module is dependency-free so it can be unit-tested in isolation and
imported by the turn loop without an import cycle.
"""
from __future__ import annotations
from dataclasses import dataclass, fields
@dataclass
class TurnRetryState:
"""One-shot recovery guards + restart signals for a single API-call attempt.
A fresh instance is created for each iteration of the outer turn loop
(once per ``api_call_count``). Each guard fires its recovery branch at most
once; the ``restart_with_*`` signals are read by the loop after the attempt
to decide whether to rebuild the request and retry.
"""
# ── Per-provider OAuth / credential refresh guards ───────────────────
codex_auth_retry_attempted: bool = False
anthropic_auth_retry_attempted: bool = False
nous_auth_retry_attempted: bool = False
nous_paid_entitlement_refresh_attempted: bool = False
copilot_auth_retry_attempted: bool = False
# ── Format / payload recovery guards ─────────────────────────────────
thinking_sig_retry_attempted: bool = False
invalid_encrypted_content_retry_attempted: bool = False
image_shrink_retry_attempted: bool = False
multimodal_tool_content_retry_attempted: bool = False
oauth_1m_beta_retry_attempted: bool = False
llama_cpp_grammar_retry_attempted: bool = False
# ── Transport / rate-limit recovery ──────────────────────────────────
primary_recovery_attempted: bool = False
has_retried_429: bool = False
# ── Restart signals (read by the outer loop after the attempt) ───────
restart_with_compressed_messages: bool = False
restart_with_length_continuation: bool = False
def __iter__(self):
# Convenience for debugging / tests: iterate (name, value) pairs.
for f in fields(self):
yield f.name, getattr(self, f.name)

View File

@@ -849,6 +849,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

@@ -1902,12 +1902,36 @@ function resolveWebDist() {
const unpackedDist = path.join(unpackedPathFor(APP_ROOT), 'dist')
if (directoryExists(unpackedDist)) return unpackedDist
return path.join(APP_ROOT, 'dist')
// Final fallback: APP_ROOT/dist. When packaged with asar:true this lives
// INSIDE app.asar — not a servable filesystem directory — so the embedded
// dashboard backend 404s on static routes (see #41327, #39472). The durable
// fix is unpacking dist/ (PR #41411 adds dist/** to asarUnpack so the tier-2
// unpackedDist above resolves). If we still land here while packaged, log it
// so the cause isn't silent.
const fallback = path.join(APP_ROOT, 'dist')
if (IS_PACKAGED && /app\.asar(?=$|[\\/])/.test(fallback) && !directoryExists(fallback)) {
rememberLog(
`[web-dist] dashboard frontend dir resolved to an asar-internal path that ` +
`is not a real directory: ${fallback}. Static routes will 404. ` +
`Ensure dist/** is unpacked (asarUnpack) or set HERMES_DESKTOP_WEB_DIST.`
)
}
return fallback
}
function resolveRendererIndex() {
const candidates = [path.join(APP_ROOT, 'dist', 'index.html'), path.join(resolveWebDist(), 'index.html')]
return candidates.find(fileExists) || candidates[0]
const found = candidates.find(fileExists)
if (found) return found
// Nothing on disk. A packaged build with no renderer bundle blank-pages with
// a bare ERR_FILE_NOT_FOUND and no clue why (see #39484). Surface the cause
// and the fix before Electron loads the missing file.
rememberLog(
`[renderer] index.html not found — the desktop app was packaged without a ` +
`renderer bundle. Tried: ${candidates.join(', ')}. ` +
`Rebuild with: hermes desktop --force-build`
)
return candidates[0]
}
function resolveHermesCwd() {
@@ -3137,7 +3161,7 @@ function buildApplicationMenu() {
label: 'Actual Size',
accelerator: 'CommandOrControl+0',
click: () => {
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.setZoomLevel(0)
setAndPersistZoomLevel(mainWindow, 0)
}
},
{
@@ -3145,8 +3169,7 @@ function buildApplicationMenu() {
accelerator: 'CommandOrControl+Plus',
click: () => {
if (mainWindow && !mainWindow.isDestroyed()) {
const next = Math.min(mainWindow.webContents.getZoomLevel() + 0.1, 9)
mainWindow.webContents.setZoomLevel(next)
setAndPersistZoomLevel(mainWindow, mainWindow.webContents.getZoomLevel() + 0.1)
}
}
},
@@ -3155,8 +3178,7 @@ function buildApplicationMenu() {
accelerator: 'CommandOrControl+-',
click: () => {
if (mainWindow && !mainWindow.isDestroyed()) {
const next = Math.max(mainWindow.webContents.getZoomLevel() - 0.1, -9)
mainWindow.webContents.setZoomLevel(next)
setAndPersistZoomLevel(mainWindow, mainWindow.webContents.getZoomLevel() - 0.1)
}
}
},
@@ -3218,6 +3240,38 @@ function installPreviewShortcut(window) {
})
}
// Zoom level is persisted in the renderer's own localStorage (per-origin,
// survives reloads/restarts) rather than a main-process JSON file. The main
// process owns setZoomLevel, so we mirror each change into localStorage and
// read it back on did-finish-load to re-apply after reloads or crash recovery.
const ZOOM_STORAGE_KEY = 'hermes:desktop:zoomLevel'
function clampZoomLevel(value) {
if (!Number.isFinite(value)) return 0
return Math.min(Math.max(value, -9), 9)
}
function setAndPersistZoomLevel(window, zoomLevel) {
if (!window || window.isDestroyed()) return
const next = clampZoomLevel(zoomLevel)
window.webContents.setZoomLevel(next)
window.webContents
.executeJavaScript(`try { localStorage.setItem(${JSON.stringify(ZOOM_STORAGE_KEY)}, ${JSON.stringify(String(next))}) } catch {}`)
.catch(error => rememberLog(`[zoom] persist failed: ${error?.message || error}`))
}
function restorePersistedZoomLevel(window) {
if (!window || window.isDestroyed()) return
window.webContents
.executeJavaScript(`(() => { try { return localStorage.getItem(${JSON.stringify(ZOOM_STORAGE_KEY)}) } catch { return null } })()`)
.then(stored => {
if (stored == null || !window || window.isDestroyed()) return
const level = clampZoomLevel(Number(stored))
window.webContents.setZoomLevel(level)
})
.catch(error => rememberLog(`[zoom] restore failed: ${error?.message || error}`))
}
function installZoomShortcuts(window) {
// Override Ctrl/Cmd + +/-/0 with half the default zoom step (0.1 vs 0.2).
// The menu items handle this on macOS (where the menu is always present),
@@ -3231,15 +3285,13 @@ function installZoomShortcuts(window) {
const key = input.key
if (key === '0') {
event.preventDefault()
window.webContents.setZoomLevel(0)
setAndPersistZoomLevel(window, 0)
} else if (key === '=' || key === '+') {
event.preventDefault()
const next = Math.min(window.webContents.getZoomLevel() + ZOOM_STEP, 9)
window.webContents.setZoomLevel(next)
setAndPersistZoomLevel(window, window.webContents.getZoomLevel() + ZOOM_STEP)
} else if (key === '-') {
event.preventDefault()
const next = Math.max(window.webContents.getZoomLevel() - ZOOM_STEP, -9)
window.webContents.setZoomLevel(next)
setAndPersistZoomLevel(window, window.webContents.getZoomLevel() - ZOOM_STEP)
}
})
}
@@ -4614,7 +4666,7 @@ function createWindow() {
mainWindow = new BrowserWindow({
width: 1220,
height: 800,
minWidth: 900,
minWidth: 400,
minHeight: 620,
title: 'Hermes',
// Frameless title bar on every platform so the renderer can paint the
@@ -4730,6 +4782,7 @@ function createWindow() {
}
mainWindow.webContents.once('did-finish-load', () => {
restorePersistedZoomLevel(mainWindow)
broadcastBootProgress()
sendWindowStateChanged()
startHermes().catch(error => rememberLog(error.stack || error.message))
@@ -4737,6 +4790,45 @@ function createWindow() {
}
ipcMain.handle('hermes:connection', async (_event, profile) => ensureBackend(profile))
// Reconnect-after-wake recovery. A REMOTE primary backend has no child process,
// so the 'exit'/'error' handlers that would clear a dead connectionPromise never
// fire — once the remote becomes unreachable across a sleep/wake the renderer
// re-dials the same dead descriptor forever and the composer stays stuck on
// "Starting Hermes…". Before the renderer's backoff loop reconnects, it asks us
// to confirm the cached PRIMARY backend is still reachable; if a remote one is
// not, we drop the cache so the next getConnection() rebuilds it. Local backends
// self-heal via their child 'exit' handler, so we never touch them here.
ipcMain.handle('hermes:connection:revalidate', async () => {
if (!connectionPromise) {
return { ok: true, rebuilt: false }
}
let conn = null
try {
conn = await connectionPromise
} catch {
// The cached boot already rejected (its own catch nulls connectionPromise);
// nothing to revalidate — the next getConnection() builds fresh.
return { ok: true, rebuilt: false }
}
if (!conn || conn.mode !== 'remote' || !conn.baseUrl) {
return { ok: true, rebuilt: false }
}
const base = conn.baseUrl.replace(/\/+$/, '')
try {
await fetchPublicJson(`${base}/api/status`, { timeoutMs: 2_500 })
return { ok: true, rebuilt: false }
} catch {
// Unreachable remote: drop the stale cache so the renderer's next reconnect
// tick rebuilds a fresh, reachable descriptor. resetHermesConnection only
// nulls connectionPromise for a remote (no child to SIGTERM).
rememberLog('Cached remote Hermes backend failed liveness probe; dropping stale connection.')
resetHermesConnection()
return { ok: true, rebuilt: true }
}
})
ipcMain.handle('hermes:backend:touch', async (_event, profile) => {
touchPoolBackend(profile)
return { ok: true }

View File

@@ -2,6 +2,7 @@ const { contextBridge, ipcRenderer, webUtils } = require('electron')
contextBridge.exposeInMainWorld('hermesDesktop', {
getConnection: profile => ipcRenderer.invoke('hermes:connection', profile),
revalidateConnection: () => ipcRenderer.invoke('hermes:connection:revalidate'),
touchBackend: profile => ipcRenderer.invoke('hermes:backend:touch', profile),
getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile),
getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'),

View File

@@ -18,7 +18,7 @@
"profile:main": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .",
"profile:main:cpu": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
"start": "npm run build && electron .",
"build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build",
"build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && node scripts/assert-dist-built.cjs",
"builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 electron-builder",
"pack": "npm run build && npm run builder -- --dir",
"dist": "npm run build && npm run builder",
@@ -166,7 +166,8 @@
"afterSign": "scripts/notarize.cjs",
"asarUnpack": [
"**/*.node",
"**/prebuilds/**"
"**/prebuilds/**",
"dist/**"
],
"mac": {
"category": "public.app-category.developer-tools",

Binary file not shown.

After

Width:  |  Height:  |  Size: 770 KiB

View File

@@ -0,0 +1,70 @@
"use strict"
// Build-time guard: refuse to hand a half-built renderer to electron-builder.
//
// `npm run pack` / `npm run dist*` are `npm run build && npm run builder`.
// If the `build` step (tsc -b && vite build) fails but packaging proceeds
// anyway — a stale checkout that fails typecheck, an interrupted vite build,
// or npm not short-circuiting `&&` in some shells — electron-builder happily
// packages an app with an empty or missing `dist/`. The result launches but
// blank-pages with `ERR_FILE_NOT_FOUND` for dist/index.html, with no clue why.
//
// This runs at the tail of `build`, after vite build, so any packaging path
// inherits it. It fails loud and early instead of shipping a broken bundle.
// See issues #39484 (renderer blank page) and #41327 / #39472 (dashboard 404).
const fs = require("fs")
const path = require("path")
// Pure check — returns { ok: true } or { ok: false, error: "..." }.
// Kept side-effect-free so it can be unit tested without spawning a process.
function checkDistBuilt(distDir) {
if (!fs.existsSync(distDir) || !fs.statSync(distDir).isDirectory()) {
return { ok: false, error: `no dist directory at ${distDir}` }
}
const indexHtml = path.join(distDir, "index.html")
if (!fs.existsSync(indexHtml) || !fs.statSync(indexHtml).isFile()) {
return { ok: false, error: `dist/index.html is missing at ${indexHtml}` }
}
if (fs.statSync(indexHtml).size === 0) {
return { ok: false, error: `dist/index.html is empty at ${indexHtml}` }
}
// index.html alone isn't enough — vite emits hashed JS into dist/assets.
// An index.html with no script bundle still blank-pages.
const assetsDir = path.join(distDir, "assets")
const hasAssets =
fs.existsSync(assetsDir) &&
fs.statSync(assetsDir).isDirectory() &&
fs.readdirSync(assetsDir).some(name => name.endsWith(".js"))
if (!hasAssets) {
return { ok: false, error: `dist/assets has no built JS bundle (expected vite output under ${assetsDir})` }
}
return { ok: true }
}
function main() {
const desktopRoot = path.resolve(__dirname, "..")
const distDir = path.join(desktopRoot, "dist")
const result = checkDistBuilt(distDir)
if (!result.ok) {
console.error(`\n✗ assert-dist-built: ${result.error}`)
console.error(" The renderer bundle is missing or incomplete, so packaging")
console.error(" would produce an app that launches to a blank page.")
console.error(" Re-run the build and check the tsc/vite output above for the")
console.error(" real failure, then package again:")
console.error(` cd ${desktopRoot} && npm run build\n`)
process.exit(1)
}
console.log("✓ assert-dist-built: dist/index.html + assets present")
}
if (require.main === module) {
main()
}
module.exports = { checkDistBuilt }

View File

@@ -0,0 +1,84 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const test = require('node:test')
const { checkDistBuilt } = require('../scripts/assert-dist-built.cjs')
function makeDist(extra) {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-assert-dist-'))
const distDir = path.join(tempRoot, 'dist')
fs.mkdirSync(distDir, { recursive: true })
if (extra) extra(distDir)
return { tempRoot, distDir }
}
test('checkDistBuilt passes when index.html + an assets JS bundle exist', () => {
const { tempRoot, distDir } = makeDist(d => {
fs.writeFileSync(path.join(d, 'index.html'), '<!doctype html><div id=root></div>', 'utf8')
fs.mkdirSync(path.join(d, 'assets'))
fs.writeFileSync(path.join(d, 'assets', 'index-abc123.js'), 'console.log(1)', 'utf8')
})
try {
assert.deepEqual(checkDistBuilt(distDir), { ok: true })
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
test('checkDistBuilt fails when the dist directory is absent', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-assert-dist-'))
try {
const result = checkDistBuilt(path.join(tempRoot, 'dist'))
assert.equal(result.ok, false)
assert.match(result.error, /no dist directory/)
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
test('checkDistBuilt fails when index.html is missing', () => {
const { tempRoot, distDir } = makeDist(d => {
fs.mkdirSync(path.join(d, 'assets'))
fs.writeFileSync(path.join(d, 'assets', 'index-abc123.js'), 'console.log(1)', 'utf8')
})
try {
const result = checkDistBuilt(distDir)
assert.equal(result.ok, false)
assert.match(result.error, /index\.html is missing/)
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
test('checkDistBuilt fails when index.html is empty', () => {
const { tempRoot, distDir } = makeDist(d => {
fs.writeFileSync(path.join(d, 'index.html'), '', 'utf8')
fs.mkdirSync(path.join(d, 'assets'))
fs.writeFileSync(path.join(d, 'assets', 'index-abc123.js'), 'console.log(1)', 'utf8')
})
try {
const result = checkDistBuilt(distDir)
assert.equal(result.ok, false)
assert.match(result.error, /index\.html is empty/)
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
test('checkDistBuilt fails when assets/ has no JS bundle', () => {
const { tempRoot, distDir } = makeDist(d => {
fs.writeFileSync(path.join(d, 'index.html'), '<!doctype html>', 'utf8')
fs.mkdirSync(path.join(d, 'assets'))
// CSS only, no JS — still a blank page at runtime.
fs.writeFileSync(path.join(d, 'assets', 'index-abc123.css'), 'body{}', 'utf8')
})
try {
const result = checkDistBuilt(distDir)
assert.equal(result.ok, false)
assert.match(result.error, /no built JS bundle/)
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})

View File

@@ -124,7 +124,10 @@ function ChatHeader({
return (
<header className={cn(titlebarHeaderBaseClass, isRoutedSessionView && titlebarHeaderShadowClass)}>
<div className="min-w-0 flex-1">
<div
className="min-w-0 flex-1"
style={{ maxWidth: 'calc(100vw - var(--titlebar-content-inset,0px) - var(--titlebar-tools-right) - var(--titlebar-tools-width) - 1.5rem)' }}
>
<SessionActionsMenu
align="start"
onDelete={selectedSessionId ? onDeleteSelectedSession : undefined}
@@ -135,11 +138,11 @@ function ChatHeader({
title={title}
>
<Button
className="pointer-events-auto h-6 min-w-0 gap-1 border border-transparent bg-transparent px-2 py-0 text-(--ui-text-secondary) hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground data-[state=open]:border-(--ui-stroke-tertiary) data-[state=open]:bg-(--ui-control-active-background) [-webkit-app-region:no-drag]"
className="pointer-events-auto flex h-6 min-w-0 max-w-full gap-1 border border-transparent bg-transparent px-2 py-0 text-(--ui-text-secondary) hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground data-[state=open]:border-(--ui-stroke-tertiary) data-[state=open]:bg-(--ui-control-active-background) [-webkit-app-region:no-drag]"
type="button"
variant="ghost"
>
<h2 className="max-w-[52vw] truncate text-[0.75rem] font-medium leading-none">{title}</h2>
<h2 className="min-w-0 flex-1 truncate text-[0.75rem] font-medium leading-none">{title}</h2>
<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="chevron-down" size="0.8125rem" />
</Button>
</SessionActionsMenu>

View File

@@ -19,6 +19,7 @@ import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { PlatformAvatar } from '@/app/messaging/platform-icon'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
@@ -39,6 +40,7 @@ import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/he
import { useI18n } from '@/i18n'
import { profileColor } from '@/lib/profile-color'
import { sessionMatchesSearch } from '@/lib/session-search'
import { normalizeSessionSource, sessionSourceLabel } from '@/lib/session-source'
import { cn } from '@/lib/utils'
import { $cronJobs } from '@/store/cron'
import {
@@ -47,8 +49,11 @@ import {
$sidebarAgentsGrouped,
$sidebarCronOpen,
$sidebarOpen,
$sidebarOverlayMounted,
$sidebarPinsOpen,
$sidebarRecentsOpen,
$sidebarSessionOrderIds,
$sidebarWorkspaceOrderIds,
pinSession,
reorderPinnedSession,
SESSION_SEARCH_FOCUS_EVENT,
@@ -56,6 +61,8 @@ import {
setSidebarCronOpen,
setSidebarPinsOpen,
setSidebarRecentsOpen,
setSidebarSessionOrderIds,
setSidebarWorkspaceOrderIds,
SIDEBAR_SESSIONS_PAGE_SIZE,
unpinSession
} from '@/store/layout'
@@ -116,10 +123,14 @@ const WORKSPACE_PAGE = 5
// ALL-profiles view: show only the latest N per profile up front to keep the
// unified list scannable, then reveal/fetch more in N-sized steps on demand.
const PROFILE_INITIAL_PAGE = 5
const WS_ID_PREFIX = 'workspace:'
const GROUP_DND_ID_PREFIX = 'group:'
const LOCAL_SESSION_SOURCES = new Set(['cli', 'desktop', 'local', 'tui'])
const groupDndId = (id: string) => `${GROUP_DND_ID_PREFIX}${id}`
const parseGroupDndId = (id: string) =>
id.startsWith(GROUP_DND_ID_PREFIX) ? id.slice(GROUP_DND_ID_PREFIX.length) : null
const wsId = (id: string) => `${WS_ID_PREFIX}${id}`
const parseWsId = (id: string) => (id.startsWith(WS_ID_PREFIX) ? id.slice(WS_ID_PREFIX.length) : null)
const countLabel = (loaded: number, total: number) => (total > loaded ? `${loaded}/${total}` : String(loaded))
const sessionTime = (s: SessionInfo) => s.last_active || s.started_at || 0
@@ -150,6 +161,33 @@ function orderByIds<T>(items: T[], getId: (item: T) => string, orderIds: string[
return out
}
function reconcileOrderIds(currentIds: string[], orderIds: string[]): string[] {
if (!currentIds.length) {
return []
}
if (!orderIds.length) {
return currentIds
}
const current = new Set(currentIds)
const next = orderIds.filter(id => current.has(id))
const known = new Set(next)
for (const id of currentIds) {
if (!known.has(id)) {
next.push(id)
known.add(id)
}
}
return next
}
function sameIds(left: string[], right: string[]) {
return left.length === right.length && left.every((item, index) => item === right[index])
}
const baseName = (path: string) =>
path
.replace(/[/\\]+$/, '')
@@ -183,7 +221,11 @@ function searchResultToSession(result: SessionSearchResult): SessionInfo {
}
}
function workspaceGroupsFor(sessions: SessionInfo[], noWorkspaceLabel: string): SidebarSessionGroup[] {
function workspaceGroupsFor(
sessions: SessionInfo[],
noWorkspaceLabel: string,
options: { preserveSessionOrder?: boolean } = {}
): SidebarSessionGroup[] {
const groups = new Map<string, SidebarSessionGroup>()
for (const session of sessions) {
@@ -196,17 +238,56 @@ function workspaceGroupsFor(sessions: SessionInfo[], noWorkspaceLabel: string):
groups.set(id, group)
}
// Groups keep recency order (Map insertion = first-seen in the recency-sorted
// input, so an active project floats up), but rows *within* a group sort by
// creation time so they don't reshuffle every time a message lands — keeps
// muscle memory intact.
for (const group of groups.values()) {
group.sessions.sort((a, b) => b.started_at - a.started_at)
if (!options.preserveSessionOrder) {
// Groups keep recency order (Map insertion = first-seen in the recency-sorted
// input, so an active project floats up), but rows *within* a group sort by
// creation time so they don't reshuffle every time a message lands — keeps
// muscle memory intact.
for (const group of groups.values()) {
group.sessions.sort((a, b) => b.started_at - a.started_at)
}
}
return [...groups.values()]
}
function sourceSessionGroupsFor(sessions: SessionInfo[]): {
localSessions: SessionInfo[]
sourceGroups: SidebarSessionGroup[]
} {
const groups = new Map<string, SidebarSessionGroup>()
const localSessions: SessionInfo[] = []
for (const session of sessions) {
const sourceId = normalizeSessionSource(session.source)
if (!sourceId || LOCAL_SESSION_SOURCES.has(sourceId)) {
localSessions.push(session)
continue
}
const label = sessionSourceLabel(sourceId) ?? sourceId
const group = groups.get(sourceId) ?? {
id: `source:${sourceId}`,
label,
mode: 'source',
path: null,
sessions: [],
sourceId
}
group.sessions.push(session)
groups.set(sourceId, group)
}
return {
localSessions,
sourceGroups: [...groups.values()].sort((a, b) => sessionTime(b.sessions[0]) - sessionTime(a.sessions[0]))
}
}
function useSortableBindings(id: string) {
const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({ id })
@@ -215,7 +296,11 @@ function useSortableBindings(id: string) {
dragHandleProps: { ...attributes, ...listeners },
ref: setNodeRef,
reorderable: true as const,
style: { transform: CSS.Transform.toString(transform), transition }
style: {
transform: CSS.Transform.toString(transform),
transition: isDragging ? undefined : transition,
willChange: isDragging ? 'transform' : undefined
}
}
}
@@ -247,6 +332,9 @@ export function ChatSidebar({
const { t } = useI18n()
const s = t.sidebar
const sidebarOpen = useStore($sidebarOpen)
// Collapsed-but-overlay-mounted → render the full sidebar, not just the nav rail.
const overlayMounted = useStore($sidebarOverlayMounted)
const contentVisible = sidebarOpen || overlayMounted
const panesFlipped = useStore($panesFlipped)
const agentsGrouped = useStore($sidebarAgentsGrouped)
const pinnedSessionIds = useStore($pinnedSessionIds)
@@ -270,8 +358,8 @@ export function ChatSidebar({
// profile while scope is still ALL (persisted), the rail is hidden and they'd
// otherwise be stuck in the grouped view with no way out.
const showAllProfiles = multiProfile && profileScope === ALL_PROFILES
const [agentOrderIds, setAgentOrderIds] = useState<string[]>([])
const [workspaceOrderIds, setWorkspaceOrderIds] = useState<string[]>([])
const agentOrderIds = useStore($sidebarSessionOrderIds)
const workspaceOrderIds = useStore($sidebarWorkspaceOrderIds)
const [searchQuery, setSearchQuery] = useState('')
const [serverMatches, setServerMatches] = useState<SessionSearchResult[]>([])
const [newSessionKbdFlash, setNewSessionKbdFlash] = useState(false)
@@ -425,14 +513,40 @@ export function ChatSidebar({
[sortedSessions, pinnedRealIdSet]
)
useEffect(() => {
const next = reconcileOrderIds(
unpinnedAgentSessions.map(s => s.id),
agentOrderIds
)
if (!sameIds(next, agentOrderIds)) {
setSidebarSessionOrderIds(next)
}
}, [agentOrderIds, unpinnedAgentSessions])
const agentSessions = useMemo(
() => orderByIds(unpinnedAgentSessions, s => s.id, agentOrderIds),
[unpinnedAgentSessions, agentOrderIds]
)
const { localSessions: localAgentSessions, sourceGroups } = useMemo(
() => sourceSessionGroupsFor(agentSessions),
[agentSessions]
)
const orderedSourceGroups = useMemo(
() => orderByIds(sourceGroups, g => g.id, workspaceOrderIds),
[sourceGroups, workspaceOrderIds]
)
const agentGroups = useMemo(
() => orderByIds(workspaceGroupsFor(agentSessions, s.noWorkspace), g => g.id, workspaceOrderIds),
[agentSessions, s.noWorkspace, workspaceOrderIds]
() =>
orderByIds(
workspaceGroupsFor(localAgentSessions, s.noWorkspace, { preserveSessionOrder: sourceGroups.length > 0 }),
g => g.id,
workspaceOrderIds
),
[localAgentSessions, s.noWorkspace, sourceGroups.length, workspaceOrderIds]
)
const loadMoreForProfileGroup = useCallback(
@@ -445,9 +559,7 @@ export function ChatSidebar({
void Promise.resolve(onLoadMoreProfileSessions(profile))
.catch(() => undefined)
.finally(() =>
setProfileLoadMorePending(({ [profile]: _done, ...rest }) => rest)
)
.finally(() => setProfileLoadMorePending(({ [profile]: _done, ...rest }) => rest))
},
[onLoadMoreProfileSessions]
)
@@ -478,15 +590,17 @@ export function ChatSidebar({
groups.set(key, group)
}
return [...groups.values()]
.map(group => ({
...group,
loadingMore: Boolean(profileLoadMorePending[group.id]),
onLoadMore: onLoadMoreProfileSessions ? () => loadMoreForProfileGroup(group.id) : undefined,
totalCount: Math.max(group.sessions.length, sessionProfileTotals[group.id] ?? 0)
}))
// default (root) first, then the rest alphabetically.
.sort((a, b) => (a.id === 'default' ? -1 : b.id === 'default' ? 1 : a.label.localeCompare(b.label)))
return (
[...groups.values()]
.map(group => ({
...group,
loadingMore: Boolean(profileLoadMorePending[group.id]),
onLoadMore: onLoadMoreProfileSessions ? () => loadMoreForProfileGroup(group.id) : undefined,
totalCount: Math.max(group.sessions.length, sessionProfileTotals[group.id] ?? 0)
}))
// default (root) first, then the rest alphabetically.
.sort((a, b) => (a.id === 'default' ? -1 : b.id === 'default' ? 1 : a.label.localeCompare(b.label)))
)
}, [
showAllProfiles,
agentSessions,
@@ -496,6 +610,53 @@ export function ChatSidebar({
sessionProfileTotals
])
const displayAgentSessions = sourceGroups.length ? localAgentSessions : agentSessions
const displayAgentGroups = useMemo(() => {
if (orderedSourceGroups.length) {
const localGroups = agentsGrouped
? agentGroups
: localAgentSessions.length
? [
{
id: 'local-sessions',
label: 'Local',
mode: 'workspace' as const,
path: null,
sessions: localAgentSessions
}
]
: []
return orderByIds([...orderedSourceGroups, ...localGroups], g => g.id, workspaceOrderIds)
}
return showAllProfiles ? profileGroups : agentsGrouped ? agentGroups : undefined
}, [
agentGroups,
agentsGrouped,
localAgentSessions,
orderedSourceGroups,
profileGroups,
showAllProfiles,
workspaceOrderIds
])
useEffect(() => {
if (!displayAgentGroups?.length || showAllProfiles) {
return
}
const next = reconcileOrderIds(
displayAgentGroups.map(g => g.id),
workspaceOrderIds
)
if (!sameIds(next, workspaceOrderIds)) {
setSidebarWorkspaceOrderIds(next)
}
}, [displayAgentGroups, showAllProfiles, workspaceOrderIds])
const showSessionSkeletons = sessionsLoading && sortedSessions.length === 0
const showSessionSections = showSessionSkeletons || sortedSessions.length > 0
@@ -543,23 +704,24 @@ export function ChatSidebar({
const activeId = String(active.id)
const overId = String(over.id)
const activeWs = parseWsId(activeId)
const overWs = parseWsId(overId)
const activeGroup = parseGroupDndId(activeId)
const overGroup = parseGroupDndId(overId)
if (activeWs && overWs) {
const oldIdx = agentGroups.findIndex(g => g.id === activeWs)
const newIdx = agentGroups.findIndex(g => g.id === overWs)
if (activeGroup && overGroup) {
const groups = displayAgentGroups ?? []
const oldIdx = groups.findIndex(g => g.id === activeGroup)
const newIdx = groups.findIndex(g => g.id === overGroup)
if (oldIdx < 0 || newIdx < 0) {
return
}
setWorkspaceOrderIds(arrayMove(agentGroups, oldIdx, newIdx).map(g => g.id))
setSidebarWorkspaceOrderIds(arrayMove(groups, oldIdx, newIdx).map(g => g.id))
return
}
if (activeWs || overWs) {
if (activeGroup || overGroup) {
return
}
@@ -570,7 +732,7 @@ export function ChatSidebar({
return
}
setAgentOrderIds(arrayMove(agentSessions, oldIdx, newIdx).map(s => s.id))
setSidebarSessionOrderIds(arrayMove(agentSessions, oldIdx, newIdx).map(s => s.id))
}
return (
@@ -580,7 +742,11 @@ export function ChatSidebar({
panesFlipped ? 'border-l border-r-0' : 'border-r border-l-0',
sidebarOpen
? 'border-(--sidebar-edge-border) bg-(--ui-sidebar-surface-background) opacity-100'
: 'pointer-events-none border-transparent bg-transparent opacity-0'
: 'pointer-events-none border-transparent bg-transparent opacity-0',
// While floated by PaneShell's hover-reveal, force visible + interactive
// — on hover (group-hover/reveal) or when keyboard-pinned (data-forced).
'in-data-[pane-hover-reveal=open]:pointer-events-auto in-data-[pane-hover-reveal=open]:border-(--sidebar-edge-border) in-data-[pane-hover-reveal=open]:bg-(--ui-sidebar-surface-background) in-data-[pane-hover-reveal=open]:opacity-100',
'group-hover/reveal:pointer-events-auto group-hover/reveal:border-(--sidebar-edge-border) group-hover/reveal:bg-(--ui-sidebar-surface-background) group-hover/reveal:opacity-100'
)}
collapsible="none"
>
@@ -624,14 +790,14 @@ export function ChatSidebar({
type="button"
>
<item.icon className="size-4 shrink-0 text-[color-mix(in_srgb,currentColor_72%,transparent)]" />
{sidebarOpen && (
{contentVisible && (
<>
<span className="min-w-0 flex-1 truncate max-[46.25rem]:hidden">
<span className="min-w-0 flex-1 truncate">
{s.nav[item.id] ?? item.label}
</span>
{isNewSession && (
<KbdGroup
className={cn('ml-auto max-[46.25rem]:hidden', newSessionKbdFlash && 'opacity-100!')}
className={cn('ml-auto', newSessionKbdFlash && 'opacity-100!')}
keys={[...NEW_SESSION_KBD]}
/>
)}
@@ -645,7 +811,7 @@ export function ChatSidebar({
</SidebarGroupContent>
</SidebarGroup>
{sidebarOpen && showSessionSections && (
{contentVisible && showSessionSections && (
<div className="shrink-0 px-2 pb-1 pt-1">
<SearchField
aria-label={s.searchAria}
@@ -657,7 +823,7 @@ export function ChatSidebar({
</div>
)}
{sidebarOpen && showSessionSections && trimmedQuery && (
{contentVisible && showSessionSections && trimmedQuery && (
<SidebarSessionsSection
activeSessionId={activeSidebarSessionId}
contentClassName="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75"
@@ -681,7 +847,7 @@ export function ChatSidebar({
/>
)}
{sidebarOpen && showSessionSections && !trimmedQuery && (
{contentVisible && showSessionSections && !trimmedQuery && (
<SidebarSessionsSection
activeSessionId={activeSidebarSessionId}
contentClassName="flex min-h-10 shrink-0 flex-col gap-px rounded-lg pb-2 pt-1"
@@ -703,7 +869,7 @@ export function ChatSidebar({
/>
)}
{sidebarOpen && showSessionSections && !trimmedQuery && (
{contentVisible && showSessionSections && !trimmedQuery && (
<SidebarSessionsSection
activeSessionId={activeSidebarSessionId}
contentClassName={cn(
@@ -727,7 +893,7 @@ export function ChatSidebar({
) : null
}
forceEmptyState={showSessionSkeletons}
groups={showAllProfiles ? profileGroups : agentsGrouped ? agentGroups : undefined}
groups={displayAgentGroups}
headerAction={
// Always reserve the icon-xs (size-6) slot so the header keeps the
// same height whether or not the toggle renders — otherwise the
@@ -736,7 +902,7 @@ export function ChatSidebar({
// the toggle does nothing, and it's irrelevant in the ALL-profiles
// view (always grouped by profile), so hide the button (not the slot).
<div className="grid size-6 shrink-0 place-items-center">
{!showAllProfiles && agentSessions.length > 0 ? (
{!showAllProfiles && localAgentSessions.length > 0 ? (
<Tip label={agentsGrouped ? s.groupTitleGrouped : s.groupTitleUngrouped}>
<Button
aria-label={agentsGrouped ? s.groupAriaGrouped : s.groupAriaUngrouped}
@@ -770,13 +936,13 @@ export function ChatSidebar({
open={agentsOpen}
pinned={false}
rootClassName="min-h-0 flex-1 p-0"
sessions={agentSessions}
sessions={displayAgentSessions}
sortable={!showAllProfiles && agentSessions.length > 1}
workingSessionIdSet={workingSessionIdSet}
/>
)}
{sidebarOpen && !trimmedQuery && cronJobs.length > 0 && (
{contentVisible && !trimmedQuery && cronJobs.length > 0 && (
<SidebarCronJobsSection
jobs={cronJobs}
label={s.cronJobs}
@@ -788,9 +954,9 @@ export function ChatSidebar({
/>
)}
{sidebarOpen && !showSessionSections && <div className="min-h-0 flex-1" />}
{contentVisible && !showSessionSections && <div className="min-h-0 flex-1" />}
{sidebarOpen && (
{contentVisible && (
<div className="shrink-0 px-0.5 pb-1 pt-0.5">
<ProfileRail />
</div>
@@ -872,8 +1038,9 @@ interface SidebarSessionGroup {
// Profile color for the ALL-profiles view; absent for workspace groups.
color?: null | string
loadingMore?: boolean
mode?: 'profile' | 'workspace'
mode?: 'profile' | 'source' | 'workspace'
onLoadMore?: () => void
sourceId?: string
totalCount?: number
}
@@ -928,7 +1095,8 @@ function SidebarSessionsSection({
onReorder,
dndSensors
}: SidebarSessionsSectionProps) {
const showEmptyState = forceEmptyState || sessions.length === 0
const hasGroupedSessions = Boolean(groups?.some(group => group.sessions.length > 0))
const showEmptyState = forceEmptyState || (!hasGroupedSessions && sessions.length === 0)
const dndActive = sortable && !!onReorder
const renderRow = (session: SessionInfo) => {
@@ -961,12 +1129,25 @@ function SidebarSessionsSection({
renderRows(items)
)
const renderNestedSessionList = (items: SessionInfo[]) =>
dndActive ? (
<DndContext collisionDetection={closestCenter} onDragEnd={onReorder} sensors={dndSensors}>
<SortableContext items={items.map(s => s.id)} strategy={verticalListSortingStrategy}>
{renderRows(items)}
</SortableContext>
</DndContext>
) : (
renderRows(items)
)
const flatVirtualized = !showEmptyState && !groups?.length && sessions.length >= VIRTUALIZE_THRESHOLD
let inner: React.ReactNode
let bodyOwnsDndContext = dndActive && !showEmptyState
if (showEmptyState) {
inner = emptyState
bodyOwnsDndContext = false
} else if (groups?.length) {
const groupNodes = groups.map(group =>
dndActive ? (
@@ -974,7 +1155,7 @@ function SidebarSessionsSection({
group={group}
key={group.id}
onNewSession={onNewSessionInWorkspace}
renderRows={renderSessionList}
renderRows={renderNestedSessionList}
/>
) : (
<SidebarWorkspaceGroup
@@ -987,12 +1168,15 @@ function SidebarSessionsSection({
)
inner = dndActive ? (
<SortableContext items={groups.map(g => wsId(g.id))} strategy={verticalListSortingStrategy}>
{groupNodes}
</SortableContext>
<DndContext collisionDetection={closestCenter} onDragEnd={onReorder} sensors={dndSensors}>
<SortableContext items={groups.map(g => groupDndId(g.id))} strategy={verticalListSortingStrategy}>
{groupNodes}
</SortableContext>
</DndContext>
) : (
groupNodes
)
bodyOwnsDndContext = false
} else if (flatVirtualized) {
inner = (
<VirtualSessionList
@@ -1011,14 +1195,13 @@ function SidebarSessionsSection({
inner = renderSessionList(sessions)
}
const body =
dndActive && !showEmptyState ? (
<DndContext collisionDetection={closestCenter} onDragEnd={onReorder} sensors={dndSensors}>
{inner}
</DndContext>
) : (
inner
)
const body = bodyOwnsDndContext ? (
<DndContext collisionDetection={closestCenter} onDragEnd={onReorder} sensors={dndSensors}>
{inner}
</DndContext>
) : (
inner
)
// The virtualizer owns its own scroller, so suppress the wrapper's overflow
// to avoid a double scroll container.
@@ -1061,6 +1244,7 @@ function SidebarWorkspaceGroup({
const { t } = useI18n()
const s = t.sidebar
const isProfileGroup = group.mode === 'profile'
const isSourceGroup = group.mode === 'source'
const pageStep = isProfileGroup ? PROFILE_INITIAL_PAGE : WORKSPACE_PAGE
const [open, setOpen] = useState(true)
const [visibleCount, setVisibleCount] = useState(pageStep)
@@ -1086,7 +1270,16 @@ function SidebarWorkspaceGroup({
}
return (
<div className={cn('grid gap-px', dragging && 'z-10 opacity-60', className)} ref={ref} style={style} {...rest}>
<div
className={cn(
'grid gap-px data-[dragging=true]:z-10 data-[dragging=true]:opacity-70 data-[dragging=true]:will-change-transform',
className
)}
data-dragging={dragging ? 'true' : undefined}
ref={ref}
style={style}
{...rest}
>
<div className="group/workspace flex min-h-6 items-center gap-1 px-2 pt-1 text-[0.6875rem] font-medium text-(--ui-text-tertiary)">
<button
className="flex min-w-0 items-center gap-1.5 bg-transparent text-left hover:text-(--ui-text-secondary)"
@@ -1094,7 +1287,18 @@ function SidebarWorkspaceGroup({
type="button"
>
{group.color ? (
<span aria-hidden="true" className="size-2 shrink-0 rounded-full" style={{ backgroundColor: group.color }} />
<span
aria-hidden="true"
className="size-2 shrink-0 rounded-full"
style={{ backgroundColor: group.color }}
/>
) : null}
{isSourceGroup && group.sourceId ? (
<PlatformAvatar
className="size-4 rounded-[4px] text-[0.5625rem] [&_svg]:size-3"
platformId={group.sourceId}
platformName={group.label}
/>
) : null}
<span className="truncate">{group.label}</span>
<SidebarCount>
@@ -1143,7 +1347,11 @@ function SidebarWorkspaceGroup({
{renderRows(visibleSessions)}
{hiddenCount > 0 &&
(isProfileGroup ? (
<SidebarLoadMoreRow loading={Boolean(group.loadingMore)} onClick={handleProfileLoadMore} step={nextCount} />
<SidebarLoadMoreRow
loading={Boolean(group.loadingMore)}
onClick={handleProfileLoadMore}
step={nextCount}
/>
) : (
<Tip label={s.showMoreIn(nextCount, group.label)}>
<button
@@ -1169,7 +1377,7 @@ interface SortableWorkspaceProps {
}
function SortableSidebarWorkspaceGroup(props: SortableWorkspaceProps) {
return <SidebarWorkspaceGroup {...props} {...useSortableBindings(wsId(props.group.id))} />
return <SidebarWorkspaceGroup {...props} {...useSortableBindings(groupDndId(props.group.id))} />
}
function SidebarCount({ children }: { children: React.ReactNode }) {

View File

@@ -176,8 +176,8 @@ export function SidebarSessionRow({
needsInput ? 'overflow-visible' : 'overflow-hidden'
)}
>
<SidebarRowDot isWorking={isWorking} needsInput={needsInput} />
</span>
<SidebarRowDot isWorking={isWorking} needsInput={needsInput} />
</span>
)}
<span className="min-w-0 flex-1 truncate text-[0.8125rem] font-normal text-(--ui-text-secondary) group-hover:text-foreground group-data-[working=true]:text-foreground/90">
{title}

View File

@@ -8,6 +8,7 @@ import { DesktopInstallOverlay } from '@/components/desktop-install-overlay'
import { DesktopOnboardingOverlay } from '@/components/desktop-onboarding-overlay'
import { GatewayConnectingOverlay } from '@/components/gateway-connecting-overlay'
import { Pane, PaneMain } from '@/components/pane-shell'
import { useMediaQuery } from '@/hooks/use-media-query'
import { useSkinCommand } from '@/themes/use-skin-command'
import { formatRefValue } from '../components/assistant-ui/directive-text'
@@ -23,6 +24,7 @@ import {
FILE_BROWSER_MAX_WIDTH,
FILE_BROWSER_MIN_WIDTH,
pinSession,
setSidebarOverlayMounted,
SIDEBAR_DEFAULT_WIDTH,
SIDEBAR_MAX_WIDTH,
SIDEBAR_SESSIONS_PAGE_SIZE,
@@ -76,6 +78,7 @@ import { CommandPalette } from './command-palette'
import { useGatewayBoot } from './gateway/hooks/use-gateway-boot'
import { useGatewayRequest } from './gateway/hooks/use-gateway-request'
import { useKeybinds } from './hooks/use-keybinds'
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from './layout-constants'
import { ModelPickerOverlay } from './model-picker-overlay'
import { ModelVisibilityOverlay } from './model-visibility-overlay'
import { RightSidebarPane } from './right-sidebar'
@@ -165,6 +168,10 @@ export function DesktopController() {
const terminalTakeover = useStore($terminalTakeover)
const panesFlipped = useStore($panesFlipped)
const profileScope = useStore($profileScope)
// Below SIDEBAR_COLLAPSE_BREAKPOINT_PX there's no room for a docked rail —
// collapse both sidebars (without touching their stored open state) so the
// hover-reveal overlay becomes the way in. Restores once it's wide again.
const narrowViewport = useMediaQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY)
const routedSessionId = routeSessionId(location.pathname)
const routeToken = `${location.pathname}:${location.search}:${location.hash}`
@@ -300,6 +307,7 @@ export function DesktopController() {
// with few recent sessions isn't windowed out of the cross-profile
// recency page — the empty-history-on-profile-switch bug.
const sessionProfile = profileScope === ALL_PROFILES ? 'all' : profileScope
const result = await listAllProfileSessions(limit, 1, 'exclude', 'recent', sessionProfile, {
excludeSources: ['cron']
})
@@ -846,6 +854,8 @@ export function DesktopController() {
<Pane
defaultOpen={false}
disabled={!chatOpen}
forceCollapsed={narrowViewport}
hoverReveal
id="file-browser"
key="file-browser"
maxWidth={FILE_BROWSER_MAX_WIDTH}
@@ -873,9 +883,12 @@ export function DesktopController() {
>
<Pane
disabled={terminalTakeoverActive}
forceCollapsed={narrowViewport}
hoverReveal
id="chat-sidebar"
maxWidth={SIDEBAR_MAX_WIDTH}
minWidth={SIDEBAR_DEFAULT_WIDTH}
onOverlayActiveChange={setSidebarOverlayMounted}
resizable
side={sidebarSide}
width={`${SIDEBAR_DEFAULT_WIDTH}px`}

View File

@@ -120,6 +120,13 @@ export function useGatewayBoot({
reconnecting = true
try {
// Drop a stale REMOTE backend cache before re-dialing. After sleep/wake a
// remote backend can become unreachable, but it has no child process
// whose 'exit' would clear the main process's cached descriptor — without
// this the renderer re-dials the same dead endpoint forever and stays on
// "Starting Hermes…". The probe is a no-op for a healthy or local backend.
await desktop.revalidateConnection?.().catch(() => undefined)
const conn = await desktop.getConnection($activeGatewayProfile.get())
if (cancelled) {
@@ -218,6 +225,15 @@ export function useGatewayBoot({
reconnectAttempt = 0
reauthNotified = false
clearReconnectTimer()
// A revalidate-driven reconnect can rebuild the backend in place when the
// cached remote was found dead, which re-drives the boot-progress overlay.
// Unlike the initial boot, nothing calls completeDesktopBoot() afterwards,
// so dismiss it here once we're open again — otherwise the overlay sticks
// at ~94%. A no-op on a normal (non-rebuild) reconnect.
if (bootCompleted) {
completeDesktopBoot()
}
} else if (bootCompleted && (st === 'closed' || st === 'error')) {
// The socket dropped after a healthy boot (typically sleep/wake). Try
// to bring it back instead of leaving the composer stuck disabled.

View File

@@ -2,11 +2,15 @@ import { useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { setRightSidebarTab } from '@/app/right-sidebar/store'
import { PANE_TOGGLE_REVEAL_EVENT } from '@/components/pane-shell'
import { matchesQuery } from '@/hooks/use-media-query'
import { PROFILE_SLOT_COUNT } from '@/lib/keybinds/actions'
import { comboAllowedInInput, comboFromEvent, isEditableTarget } from '@/lib/keybinds/combo'
import { toggleCommandPalette } from '@/store/command-palette'
import { $capture, $comboIndex, endCapture, setBinding, toggleKeybindPanel } from '@/store/keybinds'
import {
CHAT_SIDEBAR_PANE_ID,
FILE_BROWSER_PANE_ID,
requestSessionSearchFocus,
setFileBrowserOpen,
toggleFileBrowserOpen,
@@ -24,6 +28,7 @@ import { $activeSessionId, $sessions, setModelPickerOpen } from '@/store/session
import { useTheme } from '@/themes/context'
import { requestComposerFocus } from '../chat/composer/focus'
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '../layout-constants'
import {
AGENTS_ROUTE,
ARTIFACTS_ROUTE,
@@ -109,8 +114,20 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
'session.focusSearch': requestSessionSearchFocus,
'session.togglePin': deps.toggleSelectedPin,
'view.toggleSidebar': toggleSidebarOpen,
'view.toggleRightSidebar': toggleFileBrowserOpen,
'view.toggleSidebar': () => {
if (matchesQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY)) {
window.dispatchEvent(new CustomEvent(PANE_TOGGLE_REVEAL_EVENT, { detail: { id: CHAT_SIDEBAR_PANE_ID } }))
} else {
toggleSidebarOpen()
}
},
'view.toggleRightSidebar': () => {
if (matchesQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY)) {
window.dispatchEvent(new CustomEvent(PANE_TOGGLE_REVEAL_EVENT, { detail: { id: FILE_BROWSER_PANE_ID } }))
} else {
toggleFileBrowserOpen()
}
},
'view.showFiles': () => showRightSidebarTab('files'),
'view.showTerminal': () => showRightSidebarTab('terminal'),
'view.flipPanes': togglePanesFlipped,

View File

@@ -11,3 +11,9 @@ export const PAGE_INSET_X = 'px-[clamp(1.25rem,4vw,4rem)]'
// Matching negative inline-margin to bleed an element (e.g. a sticky header bar)
// out to the gutter edges before re-applying PAGE_INSET_X.
export const PAGE_INSET_NEG_X = '-mx-[clamp(1.25rem,4vw,4rem)]'
// Below this viewport width a docked sidebar leaves no room for content, so both
// rails auto-collapse into the hover-reveal overlay. Single source of truth for
// the responsive collapse point.
export const SIDEBAR_COLLAPSE_BREAKPOINT_PX = 768
export const SIDEBAR_COLLAPSE_MEDIA_QUERY = `(max-width: ${SIDEBAR_COLLAPSE_BREAKPOINT_PX}px)`

View File

@@ -28,15 +28,17 @@ import { cn } from '@/lib/utils'
type IconKind = 'brand' | 'generic'
interface PlatformIconSpec {
Icon: ComponentType<SVGProps<SVGSVGElement>>
Icon?: ComponentType<SVGProps<SVGSVGElement>>
color: string
kind: IconKind
monogram?: string
}
const PLATFORM_ICONS: Record<string, PlatformIconSpec> = {
telegram: { Icon: SiTelegram, color: '#26A5E4', kind: 'brand' },
discord: { Icon: SiDiscord, color: '#5865F2', kind: 'brand' },
// Slack removed from Simple Icons by Salesforce request — letter monogram.
slack: { color: '#4A154B', kind: 'brand', monogram: 'S' },
mattermost: { Icon: SiMattermost, color: '#0058CC', kind: 'brand' },
matrix: { Icon: SiMatrix, color: '#000000', kind: 'brand' },
signal: { Icon: SiSignal, color: '#3A76F0', kind: 'brand' },
@@ -87,7 +89,7 @@ export function PlatformAvatar({ className, platformId, platformName }: Platform
color
}}
>
<Icon className="size-3.5" />
{Icon ? <Icon className="size-3.5" /> : spec.monogram || platformName.charAt(0).toUpperCase()}
</span>
)
}

View File

@@ -9,6 +9,28 @@ import { $busy, $messages, noteSessionActivity, setSessionAttention, setSessionW
import type { ClientSessionState } from '../../types'
// Shallow per-message identity check. When a flush carries no transcript
// changes, `preserveLocalAssistantErrors` returns the same message objects in
// the same order, so reference equality per slot is enough to detect "nothing
// to publish" and avoid a needless `$messages` churn.
function sameMessageList(a: ChatMessage[], b: ChatMessage[]): boolean {
if (a === b) {
return true
}
if (a.length !== b.length) {
return false
}
for (let index = 0; index < a.length; index += 1) {
if (a[index] !== b[index]) {
return false
}
}
return true
}
interface SessionStateCacheOptions {
activeSessionId: string | null
busyRef: MutableRefObject<boolean>
@@ -88,7 +110,20 @@ export function useSessionStateCache({
return
}
setMessages(preserveLocalAssistantErrors(pending.state.messages, $messages.get()))
// `preserveLocalAssistantErrors` always returns a fresh array, so publishing
// it unconditionally puts a new `$messages` reference on the store every
// flush — including the periodic `session.info` heartbeats that don't touch
// the transcript. That churns ChatView → runtimeMessageRepository → the
// assistant-ui runtime → the virtualizer, which re-measures and visibly
// jerks the scroll position while the user is reading. Skip the publish when
// the merged result is content-identical to what's already on screen.
const currentMessages = $messages.get()
const nextMessages = preserveLocalAssistantErrors(pending.state.messages, currentMessages)
if (!sameMessageList(nextMessages, currentMessages)) {
setMessages(nextMessages)
}
setBusy(pending.state.busy)
setMutableRef(busyRef, pending.state.busy)
setAwaitingResponse(pending.state.awaitingResponse)

View File

@@ -5,6 +5,7 @@ import { useSyncExternalStore } from 'react'
import { NotificationStack } from '@/components/notifications'
import { PaneShell } from '@/components/pane-shell'
import { SidebarProvider } from '@/components/ui/sidebar'
import { useMediaQuery } from '@/hooks/use-media-query'
import {
$fileBrowserOpen,
$panesFlipped,
@@ -16,6 +17,8 @@ import {
import { $paneWidthOverride } from '@/store/panes'
import { $connection } from '@/store/session'
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '../layout-constants'
import { KeybindPanel } from './keybind-panel'
import { StatusbarControls, type StatusbarItem } from './statusbar-controls'
import { TITLEBAR_HEIGHT, titlebarControlsPosition } from './titlebar'
@@ -58,6 +61,7 @@ export function AppShell({
const sidebarOpen = useStore($sidebarOpen)
const fileBrowserOpen = useStore($fileBrowserOpen)
const panesFlipped = useStore($panesFlipped)
const narrowViewport = useMediaQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY)
const fileBrowserWidthOverride = useStore($paneWidthOverride(FILE_BROWSER_PANE_ID))
const connection = useStore($connection)
const viewportFullscreen = useSyncExternalStore(subscribeWindowSize, viewportIsFullscreen, () => false)
@@ -71,8 +75,10 @@ export function AppShell({
// The inset clears the top-left titlebar buttons when nothing covers the
// window's left edge. Default layout: the sessions sidebar sits there.
// Flipped layout: the file browser does instead.
const leftEdgePaneOpen = panesFlipped ? fileBrowserOpen : sidebarOpen
// Flipped layout: the file browser does instead. Below the collapse
// breakpoint both rails are force-collapsed (hover-reveal overlay), so the
// edge is uncovered regardless of their stored open state.
const leftEdgePaneOpen = !narrowViewport && (panesFlipped ? fileBrowserOpen : sidebarOpen)
const titlebarContentInset = leftEdgePaneOpen
? 0

View File

@@ -4,6 +4,7 @@ import { useCallback, useMemo } from 'react'
import type { CommandCenterSection } from '@/app/command-center'
import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel'
import { useI18n } from '@/i18n'
import {
Activity,
AlertCircle,
@@ -16,12 +17,11 @@ import {
Zap,
ZapFilled
} from '@/lib/icons'
import { useI18n } from '@/i18n'
import { formatModelStatusLabel } from '@/lib/model-status-label'
import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar'
import { cn } from '@/lib/utils'
import { setSessionYolo } from '@/lib/yolo-session'
import { setGlobalYolo, setSessionYolo } from '@/lib/yolo-session'
import { $desktopActionTasks } from '@/store/activity'
import { $previewServerRestartStatus } from '@/store/preview'
import {
@@ -44,7 +44,7 @@ import { $desktopVersion, $updateApply, $updateStatus, setUpdateOverlayOpen } fr
import type { StatusResponse } from '@/types/hermes'
import { CRON_ROUTE } from '../../routes'
import type { StatusbarItem } from '../statusbar-controls'
import type { StatusbarItem, StatusbarSelectModifiers } from '../statusbar-controls'
interface StatusbarItemsOptions {
agentsOpen: boolean
@@ -105,22 +105,39 @@ export function useStatusbarItems({
// Per-session approval bypass (same scope as the TUI's Shift+Tab). On a
// new-chat draft (no runtime session yet) we arm locally; the session-create
// path applies it once the backend session exists.
const toggleYolo = useCallback(async () => {
const next = !$yoloActive.get()
const sid = $activeSessionId.get()
//
// Shift+click flips the GLOBAL approvals.mode instead — a persistent,
// all-sessions/CLI/TUI/cron bypass that survives restarts.
const toggleYolo = useCallback(
async (modifiers?: StatusbarSelectModifiers) => {
const next = !$yoloActive.get()
setYoloActive(next)
setYoloActive(next)
if (!sid) {
return
}
if (modifiers?.shiftKey) {
try {
await setGlobalYolo(requestGateway, next)
} catch {
setYoloActive(!next)
}
try {
await setSessionYolo(requestGateway, sid, next)
} catch {
setYoloActive(!next)
}
}, [requestGateway])
return
}
const sid = $activeSessionId.get()
if (!sid) {
return
}
try {
await setSessionYolo(requestGateway, sid, next)
} catch {
setYoloActive(!next)
}
},
[requestGateway]
)
const showYoloToggle = gatewayState === 'open' && (!!activeSessionId || freshDraftReady)
@@ -333,7 +350,7 @@ export function useStatusbarItems({
<Zap className="size-3.5 shrink-0 opacity-70" />
),
id: 'yolo',
onSelect: () => void toggleYolo(),
onSelect: modifiers => void toggleYolo(modifiers),
title: yoloActive ? copy.yoloOn : copy.yoloOff,
variant: 'action'
},

View File

@@ -35,12 +35,16 @@ export interface StatusbarItem {
menuClassName?: string
menuContent?: ReactNode
menuItems?: readonly StatusbarMenuItem[]
onSelect?: () => void
onSelect?: (modifiers: StatusbarSelectModifiers) => void
title?: string
to?: string
variant?: 'action' | 'link' | 'menu' | 'text'
}
export interface StatusbarSelectModifiers {
shiftKey: boolean
}
export type StatusbarItemSide = 'left' | 'right'
export type SetStatusbarItemGroup = (id: string, items: readonly StatusbarItem[], side?: StatusbarItemSide) => void
@@ -170,12 +174,12 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
<button
className={cn(STATUSBAR_ACTION_CLASS, item.className)}
disabled={item.disabled}
onClick={() => {
onClick={event => {
if (item.to) {
navigate(item.to)
}
item.onSelect?.()
item.onSelect?.({ shiftKey: event.shiftKey })
}}
type="button"
>

View File

@@ -425,7 +425,7 @@ function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTex
<div className="aui-md-table my-2 max-w-full overflow-x-auto rounded-[0.375rem] border border-border">
<table
className={cn(
'm-0 w-full border-collapse text-[0.8125rem] [&_tr]:border-b [&_tr]:border-border last:[&_tr]:border-0',
'm-0 w-full min-w-[18rem] border-collapse text-[0.8125rem] [&_tr]:border-b [&_tr]:border-border last:[&_tr]:border-0',
className
)}
{...props}
@@ -438,7 +438,7 @@ function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTex
th: ({ className, ...props }: ComponentProps<'th'>) => (
<th
className={cn(
'px-2.5 py-1.5 text-left align-middle text-[0.75rem] font-medium text-muted-foreground',
'whitespace-nowrap px-2.5 py-1.5 text-left align-middle text-[0.75rem] font-medium text-muted-foreground',
className
)}
{...props}

View File

@@ -489,7 +489,7 @@ describe('assistant-ui streaming renderer', () => {
expect(viewport.scrollTop).toBe(420)
})
it('keeps sticky-bottom armed through viewport height changes during streaming', async () => {
it('does not follow streaming content growth even while parked at the bottom', async () => {
const { container } = render(<StreamingHarness />)
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
@@ -508,6 +508,7 @@ describe('assistant-ui streaming renderer', () => {
await wait(80)
// Park the user at the bottom of the current content.
await act(async () => {
viewport.scrollTop = 800
fireEvent.scroll(viewport)
@@ -520,6 +521,9 @@ describe('assistant-ui streaming renderer', () => {
fireEvent.scroll(viewport)
})
// Content grows as tokens stream in. Streaming auto-follow is removed, so
// the viewport must NOT chase the new bottom — it stays where the user
// last left it.
scrollHeight = 1_200
await act(async () => {
@@ -529,7 +533,7 @@ describe('assistant-ui streaming renderer', () => {
})
await wait(0)
expect(viewport.scrollTop).toBe(1_200)
expect(viewport.scrollTop).toBe(760)
})
it('honors the first upward wheel scroll even when a programmatic bottom-pin scroll event is still pending', async () => {
@@ -566,7 +570,7 @@ describe('assistant-ui streaming renderer', () => {
expect(viewport.scrollTop).toBe(420)
})
it('keeps following final code-highlight growth when a run completes at bottom', async () => {
it('does not snap to the bottom on final code-highlight growth after a run completes', async () => {
const { container } = render(<StreamingHarness />)
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
@@ -588,10 +592,13 @@ describe('assistant-ui streaming renderer', () => {
await wait(650)
// Completion re-measures (Shiki highlight) and grows the content. The
// post-run bottom lock is removed, so the viewport stays put instead of
// snapping to the new bottom.
scrollHeight = 1_700
await wait(0)
expect(viewport.scrollTop).toBe(1_700)
expect(viewport.scrollTop).toBe(800)
})
it('does not restart bottom-follow after completion when the user scrolled up', async () => {

View File

@@ -19,7 +19,6 @@ import { setThreadScrolledUp } from '@/store/thread-scroll'
const ESTIMATED_ITEM_HEIGHT = 220
const OVERSCAN = 4
const AT_BOTTOM_THRESHOLD = 4
const POST_RUN_BOTTOM_LOCK_MS = 1_200
type ThreadMessageComponents = ComponentProps<typeof ThreadPrimitive.MessageByIndex>['components']
@@ -265,8 +264,27 @@ function useThreadScrollAnchor({
return
}
// Already parked at the bottom: writing `scrollTop` is a no-op and the
// browser fires NO scroll event, so arming the programmatic gate here would
// leave it permanently set. Repeated pins (streaming heartbeats, the
// post-run lock loop) then accumulate the gate, and the next genuine user
// scroll-up is misread as one of our programmatic scrolls — re-arming
// sticky-bottom and yanking the viewport back down. Refresh trackers, bail.
const distFromBottom = el.scrollHeight - (el.scrollTop + el.clientHeight)
if (distFromBottom <= AT_BOTTOM_THRESHOLD) {
lastTopRef.current = el.scrollTop
lastHeightRef.current = el.scrollHeight
lastClientHeightRef.current = el.clientHeight
return
}
// Hold the disarm gate across the scroll event the next line will fire.
programmaticScrollPendingRef.current += 1
// Set to 1 rather than incrementing: coalesced writes within a frame fire a
// single scroll event, so a counter > 1 can never drain and would swallow a
// later real user scroll.
programmaticScrollPendingRef.current = 1
scrollElementToBottom(el)
lastTopRef.current = el.scrollTop
lastHeightRef.current = el.scrollHeight
@@ -369,51 +387,15 @@ function useThreadScrollAnchor({
}
}, [scrollerRef, stickyBottomRef])
// Follow content growth (streaming, item measurements, loading indicator)
// while armed. During fast streaming the ResizeObserver can fire many
// times per frame as Streamdown re-tokenizes; coalesce to one pin per
// animation frame so we don't run the scroll-event/re-pin chain
// (~20+ ms self in `Virtualizer.getMaxScrollOffset`) several times per
// token.
useEffect(() => {
if (!enabled || !isRunning) {
return undefined
}
const el = scrollerRef.current
if (!el) {
return undefined
}
let pinRafScheduled = false
const schedulePin = () => {
if (pinRafScheduled || !stickyBottomRef.current) {
return
}
pinRafScheduled = true
requestAnimationFrame(() => {
pinRafScheduled = false
if (stickyBottomRef.current) {
pinToBottom()
}
})
}
const observer = new ResizeObserver(schedulePin)
// Observe ONLY the content (firstElementChild), not the scroller `el`
// itself. Resizes of the viewport/scroller (window resize, devtools
// panel toggle) shouldn't trigger a pin — only content growth should.
if (el.firstElementChild) {
observer.observe(el.firstElementChild)
}
return () => observer.disconnect()
}, [enabled, isRunning, pinToBottom, scrollerRef, stickyBottomRef])
// Intentionally NO streaming auto-follow. Earlier builds ran a
// ResizeObserver here that re-pinned the viewport to the bottom on every
// content growth while a turn was running, so the chat tracked tokens as
// they streamed. That behavior is removed by request: once a turn is in
// flight the viewport stays exactly where the user left it. The viewport
// is still moved to the bottom ONCE per user submit / new turn / session
// change (see the layout effect and the session-change effect below) so a
// freshly submitted message lands in view — but it does not chase the
// stream afterward.
// Jump to bottom on session change OR when an empty thread first gets
// content. Both share the same intent and the same effect.
@@ -429,22 +411,21 @@ function useThreadScrollAnchor({
}
}, [enabled, groupCount, jumpToBottom, sessionKey])
// Pre-paint pin: when groupCount increases while armed (optimistic user
// message insert, streaming assistant turn arriving, etc.), pin BEFORE
// the browser commits the layout to screen. Using useLayoutEffect rather
// than useEffect so this runs synchronously after React commits the DOM
// mutation but before the browser paints. Without this, there's a ~50ms
// visual window where the new message sits below the fold while we wait
// for the ResizeObserver / scroll event chain to fire and re-pin.
// Pre-paint pin: when groupCount increases while armed (a new turn arriving
// from the user submit or assistant turn start), pin BEFORE the browser
// commits the layout to screen. Using useLayoutEffect rather than useEffect
// so this runs synchronously after React commits the DOM mutation but before
// the browser paints. Without this, there's a ~50ms visual window where the
// new message sits below the fold.
//
// We pin TWICE in this critical path — once synchronously, then once on
// the next rAF. The second pin catches the case where React mounts the
// new message in the second commit (after our layout effect ran), which
// grows scrollHeight again; without the rAF pin the user briefly sees a
// ~15 px gap below the new message until the RO catches up. Streaming
// tokens use the rate-limited RO path only; only the group-count change
// (which fires once per user submit / new turn arrival) pays for the
// extra pin.
// ~15 px gap below the new message. This fires once per user submit / new
// turn arrival — it is NOT streaming-token follow (that path is removed
// above), so a turn that streams a long response after this initial jump
// will not chase the bottom.
const prevGroupCountForLayoutRef = useRef(groupCount)
useLayoutEffect(() => {
if (!enabled) {
@@ -468,45 +449,17 @@ function useThreadScrollAnchor({
prevGroupCountForLayoutRef.current = groupCount
}, [enabled, groupCount, pinToBottom, stickyBottomRef])
// Completion swaps streaming placeholders/plain code for final rendered DOM
// (notably Shiki-highlighted code). Keep following the bottom briefly after
// `isRunning` flips false so that final measurement pass cannot strand the
// viewport near the top of a large code block.
// Intentionally NO post-run bottom lock. Earlier builds kept pinning to
// the bottom for POST_RUN_BOTTOM_LOCK_MS after `isRunning` flipped false to
// chase final Shiki re-highlight measurement. With streaming follow gone,
// re-pinning at completion would yank the viewport back to the bottom even
// though the user is reading earlier content — the opposite of what's
// wanted. The one-time submit / new-turn jump already covers landing a
// fresh message in view.
const prevIsRunningForLayoutRef = useRef(isRunning)
useLayoutEffect(() => {
const finishedRun = prevIsRunningForLayoutRef.current && !isRunning
prevIsRunningForLayoutRef.current = isRunning
if (!enabled || !finishedRun || !stickyBottomRef.current) {
return undefined
}
const lockUntil = performance.now() + POST_RUN_BOTTOM_LOCK_MS
let lockRaf: number | null = null
const lockFrame = () => {
lockRaf = null
if (!stickyBottomRef.current) {
return
}
pinToBottom()
if (performance.now() < lockUntil) {
lockRaf = requestAnimationFrame(lockFrame)
}
}
pinToBottom()
lockRaf = requestAnimationFrame(lockFrame)
return () => {
if (lockRaf !== null) {
cancelAnimationFrame(lockRaf)
}
}
}, [enabled, isRunning, pinToBottom, stickyBottomRef])
}, [isRunning])
useAuiEvent('thread.runStart', jumpToBottom)
}

View File

@@ -150,10 +150,7 @@ export const Thread: FC<{
)
const emptyPlaceholder = intro ? (
<div
className="flex min-h-0 w-full flex-col items-center justify-center"
style={{ paddingBottom: 'var(--composer-measured-height)' }}
>
<div className="flex min-h-0 w-full flex-col items-center justify-center pt-[var(--composer-measured-height)]">
<Intro {...intro} />
</div>
) : undefined
@@ -470,9 +467,7 @@ const ReasoningAccordionGroup: FC<{ children?: ReactNode; endIndex: number; star
s =>
s.thread.isRunning &&
s.message.status?.type === 'running' &&
s.message.parts
.slice(Math.max(0, startIndex))
.some(p => p?.type === 'reasoning' && p.status?.type !== 'complete')
s.message.parts.slice(Math.max(0, startIndex)).some(p => p?.type === 'reasoning' && p.status?.type !== 'complete')
)
// A reasoning group with no actual text is pure noise — drop the whole

View File

@@ -160,14 +160,14 @@ export function Intro({ personality, seed }: IntroProps) {
return (
<div
className="pointer-events-none flex w-full min-w-0 flex-col items-center justify-center px-3 py-6 text-center text-muted-foreground sm:px-6 lg:px-8"
className="pointer-events-none flex w-full min-w-0 flex-col items-center justify-center px-0.5 py-6 text-center text-muted-foreground sm:px-6 lg:px-8"
data-slot="aui_intro"
>
<div className="w-full min-w-0">
<p
aria-label={WORDMARK}
className="fit-text mx-auto mb-3 w-[88%] font-['Collapse'] font-bold uppercase leading-[0.9] tracking-[0.08em] text-midground mix-blend-plus-lighter dark:text-foreground/90"
style={{ '--fit-text-line-height': '0.9', '--fit-text-min': '2.75rem' } as CSSProperties}
className="fit-text mx-auto mb-1 w-[calc(100%-1rem)] font-['Collapse'] font-bold uppercase leading-[0.9] tracking-[0.08em] text-midground mix-blend-plus-lighter dark:text-foreground/90"
style={{ '--fit-min': '2.75rem' } as CSSProperties}
>
<span>
<span>{WORDMARK}</span>

View File

@@ -1,4 +1,4 @@
export type { PaneShellContextValue, PaneSlot } from './context'
export { PaneShellContext } from './context'
export { Pane, PaneMain, PaneShell } from './pane-shell'
export { Pane, PANE_TOGGLE_REVEAL_EVENT, PaneMain, PaneShell } from './pane-shell'
export type { PaneMainProps, PaneProps, PaneShellProps } from './pane-shell'

View File

@@ -10,7 +10,8 @@ import {
useContext,
useEffect,
useMemo,
useRef
useRef,
useState
} from 'react'
import { cn } from '@/lib/utils'
@@ -31,6 +32,12 @@ export interface PaneProps {
defaultOpen?: boolean
/** Forces the pane closed (track→0, aria-hidden) without writing to the store — for transient route gates. */
disabled?: boolean
/** Like disabled, but keeps hoverReveal alive — collapses the track without writing to the store (e.g. narrow window). */
forceCollapsed?: boolean
/** When collapsed, float the contents over the main column on hover/focus instead of hiding them (track stays 0px). */
hoverReveal?: boolean
/** Called with true while the pane is a collapsed hover-reveal overlay, so the consumer can keep contents mounted (ready to slide). */
onOverlayActiveChange?: (overlayActive: boolean) => void
id: string
maxWidth?: WidthValue
minWidth?: WidthValue
@@ -53,6 +60,7 @@ export interface PaneShellProps {
interface CollectedPane {
defaultOpen: boolean
disabled: boolean
forceCollapsed: boolean
id: string
resizable: boolean
side: PaneSide
@@ -62,6 +70,22 @@ interface CollectedPane {
const DEFAULT_WIDTH = '16rem'
const DEFAULT_RESIZE_MIN_WIDTH = 160
// Hover-reveal slide. The enter delay is a pure-CSS hover-intent gate: a fast
// pass-by doesn't dwell on the trigger long enough for the delay to elapse.
const HOVER_REVEAL_SLIDE_MS = 220
const HOVER_REVEAL_ENTER_DELAY_MS = 130
const HOVER_REVEAL_EASE = 'cubic-bezier(0.32,0.72,0,1)'
// Offset shadow lifting the revealed panel off the content (same both sides;
// the mirror axis is offset-x, which is 0). Same color on light + dark.
const HOVER_REVEAL_SHADOW = '0px -18px 18px -5px #00000012'
// Edge trigger strip, inset past the OS window-resize grab area.
const HOVER_REVEAL_TRIGGER_WIDTH = 14
const HOVER_REVEAL_EDGE_GUTTER = 6
// Fired (window CustomEvent<{ id }>) to toggle a force-collapsed pane's reveal
// from the keyboard, since its store-open toggle is a no-op while collapsed.
export const PANE_TOGGLE_REVEAL_EVENT = 'hermes:pane-toggle-reveal'
const widthToCss = (value: WidthValue | undefined, fallback: string) =>
value === undefined ? fallback : typeof value === 'number' ? `${value}px` : value
@@ -110,6 +134,7 @@ function collectPanes(children: ReactNode) {
const entry: CollectedPane = {
defaultOpen: props.defaultOpen ?? true,
disabled: props.disabled ?? false,
forceCollapsed: props.forceCollapsed ?? false,
id: props.id,
resizable: props.resizable ?? false,
side: props.side,
@@ -124,7 +149,7 @@ function collectPanes(children: ReactNode) {
function trackForPane(pane: CollectedPane, states: Record<string, { open: boolean; widthOverride?: number }>) {
const stateOpen = states[pane.id]?.open ?? pane.defaultOpen
const open = !pane.disabled && stateOpen
const open = !pane.disabled && !pane.forceCollapsed && stateOpen
if (!open) {
return { open: false, track: '0px' }
@@ -193,14 +218,29 @@ export function Pane({
className,
defaultOpen = true,
disabled = false,
hoverReveal = false,
id,
maxWidth,
minWidth,
resizable = false
onOverlayActiveChange,
resizable = false,
width
}: PaneProps) {
const ctx = useContext(PaneShellContext)
const paneStates = useStore($paneStates)
const registered = useRef(false)
const paneRef = useRef<HTMLDivElement | null>(null)
// Keyboard (mod+b / mod+j) pins the reveal open while collapsed; hover is CSS.
const [forced, setForced] = useState(false)
const slot = ctx?.paneById.get(id)
const open = Boolean(slot?.open && !disabled)
const side = slot?.side ?? 'left'
// Collapsed + hoverReveal: float the pane contents over the main column on
// hover/focus instead of hiding them. Honors any persisted resize width.
const overlayActive = !open && hoverReveal && !disabled
const override = resizable ? paneStates[id]?.widthOverride : undefined
const overlayWidth = override !== undefined ? `${override}px` : widthToCss(width, DEFAULT_WIDTH)
useEffect(() => {
if (registered.current) {
@@ -211,12 +251,34 @@ export function Pane({
ensurePaneRegistered(id, { open: defaultOpen })
}, [defaultOpen, id])
const slot = ctx?.paneById.get(id)
const open = Boolean(slot?.open && !disabled)
// Keyboard toggle pins/unpins the reveal while collapsed; clear when no longer
// a collapsed overlay (reopened / widened).
useEffect(() => {
if (typeof window === 'undefined' || !overlayActive) {
setForced(false)
return
}
const onToggle = (e: Event) => {
if ((e as CustomEvent<{ id: string }>).detail?.id === id) {
setForced(v => !v)
}
}
window.addEventListener(PANE_TOGGLE_REVEAL_EVENT, onToggle)
return () => window.removeEventListener(PANE_TOGGLE_REVEAL_EVENT, onToggle)
}, [id, overlayActive])
// Keep contents mounted while collapsed so reveal is a pure CSS transform.
useEffect(() => {
onOverlayActiveChange?.(overlayActive)
}, [onOverlayActiveChange, overlayActive])
const canResize = open && resizable
const lo = widthToPx(minWidth) ?? DEFAULT_RESIZE_MIN_WIDTH
const hi = widthToPx(maxWidth) ?? Number.POSITIVE_INFINITY
const side = slot?.side ?? 'left'
const startResize = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
@@ -273,6 +335,58 @@ export function Pane({
return null
}
// Collapsed hover-reveal track: a 0px, pointer-transparent grid cell holding a
// thin edge trigger + the floating panel (both absolute, escaping the zero
// box). group-hover (or data-forced from the keyboard) drives the slide; the
// enter-delay is the hover-intent gate. No JS pointer math.
if (overlayActive) {
const edge = side === 'left' ? 'left' : 'right'
const offscreen = side === 'left' ? '-translate-x-[calc(100%+1rem)]' : 'translate-x-[calc(100%+1rem)]'
return (
<div
className={cn('group/reveal pointer-events-none relative row-start-1 min-w-0', className)}
data-forced={forced ? '' : undefined}
data-pane-hover-reveal={forced ? 'open' : 'closed'}
data-pane-id={id}
data-pane-open="false"
data-pane-side={side}
ref={paneRef}
style={{ gridColumn: `${slot.column} / ${slot.column + 1}` }}
>
<div
aria-hidden="true"
className="pointer-events-auto absolute inset-y-0 z-30 [-webkit-app-region:no-drag]"
style={{ [edge]: HOVER_REVEAL_EDGE_GUTTER, width: HOVER_REVEAL_TRIGGER_WIDTH }}
/>
{/* Keyed on side so flipping panes remounts off-screen on the new edge
instead of transitioning the transform across the viewport. */}
<div
className={cn(
'pointer-events-none absolute inset-y-0 z-30 overflow-hidden transition-transform delay-0',
offscreen,
'group-hover/reveal:pointer-events-auto group-hover/reveal:translate-x-0 group-hover/reveal:delay-[var(--reveal-enter-delay)] group-hover/reveal:shadow-[var(--reveal-shadow)]',
'group-data-[forced]/reveal:pointer-events-auto group-data-[forced]/reveal:translate-x-0 group-data-[forced]/reveal:delay-0 group-data-[forced]/reveal:shadow-[var(--reveal-shadow)]'
)}
key={edge}
style={
{
[edge]: 0,
width: overlayWidth,
'--reveal-shadow': HOVER_REVEAL_SHADOW,
transitionDuration: `${HOVER_REVEAL_SLIDE_MS}ms`,
transitionTimingFunction: HOVER_REVEAL_EASE,
'--reveal-enter-delay': `${HOVER_REVEAL_ENTER_DELAY_MS}ms`
} as CSSProperties
}
>
<div className="flex h-full w-full flex-col">{children}</div>
</div>
</div>
)
}
return (
<div
aria-hidden={!open}

View File

@@ -7,6 +7,13 @@ declare global {
// the window's backend; pass a named profile to lazily spawn/reuse that
// profile's backend from the pool.
getConnection: (profile?: string | null) => Promise<HermesConnection>
// Reconnect-after-wake recovery: liveness-probe the cached PRIMARY backend
// and drop it if a remote one has gone unreachable, so the next
// getConnection() rebuilds a reachable descriptor instead of the renderer
// re-dialing a dead remote forever. No-op for local backends (they
// self-heal via the child 'exit' handler). `rebuilt` is true when a stale
// remote cache was dropped.
revalidateConnection: () => Promise<{ ok: boolean; rebuilt: boolean }>
// Keepalive: mark a pool profile backend as recently used so the idle
// reaper spares it while its chat is active.
touchBackend: (profile?: string | null) => Promise<{ ok: boolean }>

View File

@@ -1463,8 +1463,8 @@ export const en: Translations = {
contextUsage: 'Context usage',
session: 'Session',
runtimeSessionElapsed: 'Runtime session elapsed',
yoloOn: 'YOLO on — auto-approving dangerous commands. Click to turn off.',
yoloOff: 'YOLO off — click to auto-approve dangerous commands.',
yoloOn: 'YOLO on — auto-approving dangerous commands. Click to turn off. Shift+click toggles it globally.',
yoloOff: 'YOLO off — click to auto-approve dangerous commands. Shift+click toggles it globally.',
modelNone: 'none',
noModel: 'no model',
switchModel: 'Switch model',

View File

@@ -1606,8 +1606,8 @@ export const ja = defineLocale({
contextUsage: 'コンテキスト使用状況',
session: 'セッション',
runtimeSessionElapsed: 'ランタイムセッション経過時間',
yoloOn: 'YOLO オン — 危険なコマンドを自動承認中。クリックでオフに。',
yoloOff: 'YOLO オフ — クリックで危険なコマンドを自動承認。',
yoloOn: 'YOLO オン — 危険なコマンドを自動承認中。クリックでオフに。Shift+クリックで全体に切り替え。',
yoloOff: 'YOLO オフ — クリックで危険なコマンドを自動承認。Shift+クリックで全体に切り替え。',
modelNone: 'なし',
noModel: 'モデルなし',
switchModel: 'モデルを切り替え',

View File

@@ -1567,8 +1567,8 @@ export const zhHant = defineLocale({
contextUsage: '上下文使用量',
session: '工作階段',
runtimeSessionElapsed: '執行時工作階段已用時間',
yoloOn: 'YOLO 已開啟 — 自動核准危險指令。點擊關閉。',
yoloOff: 'YOLO 已關閉 — 點擊自動核准危險指令。',
yoloOn: 'YOLO 已開啟 — 自動核准危險指令。點擊關閉。Shift+點擊可全域切換。',
yoloOff: 'YOLO 已關閉 — 點擊自動核准危險指令。Shift+點擊可全域切換。',
modelNone: '無',
noModel: '無模型',
switchModel: '切換模型',

View File

@@ -1644,8 +1644,8 @@ export const zh: Translations = {
contextUsage: '上下文用量',
session: '会话',
runtimeSessionElapsed: '运行时会话已用时间',
yoloOn: 'YOLO 已开启 - 自动批准危险命令。点击关闭。',
yoloOff: 'YOLO 已关闭 - 点击自动批准危险命令。',
yoloOn: 'YOLO 已开启 - 自动批准危险命令。点击关闭。Shift+点击可全局切换。',
yoloOff: 'YOLO 已关闭 - 点击自动批准危险命令。Shift+点击可全局切换。',
modelNone: '无',
noModel: '无模型',
switchModel: '切换模型',

View File

@@ -52,6 +52,14 @@ describe('sessionMatchesSearch', () => {
expect(sessionMatchesSearch(session, 'hermes-agent')).toBe(true)
})
it('matches sessions by source platform and aliases', () => {
expect(sessionMatchesSearch(makeSession({ source: 'telegram' }), 'Telegram')).toBe(true)
expect(sessionMatchesSearch(makeSession({ source: 'whatsapp' }), 'WhatsApp')).toBe(true)
expect(sessionMatchesSearch(makeSession({ source: 'whatsapp' }), 'wa')).toBe(true)
expect(sessionMatchesSearch(makeSession({ source: 'slack' }), 'slack')).toBe(true)
expect(sessionMatchesSearch(makeSession({ source: 'bluebubbles' }), 'imessage')).toBe(true)
})
it('does not match unrelated queries', () => {
expect(sessionMatchesSearch(makeSession(), 'totally-unrelated')).toBe(false)
})

View File

@@ -1,6 +1,7 @@
import type { SessionInfo } from '@/types/hermes'
import { sessionTitle } from './chat-runtime'
import { sessionSourceSearchTerms } from './session-source'
export function sessionMatchesSearch(session: SessionInfo, query: string): boolean {
const needle = query.trim().toLowerCase()
@@ -14,6 +15,7 @@ export function sessionMatchesSearch(session: SessionInfo, query: string): boole
session._lineage_root_id ?? '',
sessionTitle(session),
session.preview ?? '',
session.cwd ?? ''
session.cwd ?? '',
...sessionSourceSearchTerms(session.source)
].some(value => value.toLowerCase().includes(needle))
}

View File

@@ -0,0 +1,62 @@
const SOURCE_LABELS: Record<string, string> = {
api_server: 'API',
bluebubbles: 'iMessage',
cli: 'CLI',
codex: 'Codex',
desktop: 'Desktop',
discord: 'Discord',
email: 'Email',
gateway: 'Gateway',
local: 'Local',
matrix: 'Matrix',
mattermost: 'Mattermost',
qqbot: 'QQ',
signal: 'Signal',
slack: 'Slack',
sms: 'SMS',
telegram: 'Telegram',
tui: 'TUI',
webhook: 'Webhook',
weixin: 'WeChat',
whatsapp: 'WhatsApp',
yuanbao: 'Yuanbao'
}
const SOURCE_ALIASES: Record<string, string[]> = {
bluebubbles: ['apple messages', 'imessage'],
cli: ['terminal'],
desktop: ['app', 'gui'],
local: ['machine'],
qqbot: ['qq'],
telegram: ['tg'],
tui: ['terminal'],
weixin: ['wechat'],
whatsapp: ['wa']
}
export function normalizeSessionSource(source: null | string | undefined): string | null {
const id = source?.trim().toLowerCase()
return id || null
}
export function sessionSourceLabel(source: null | string | undefined): string | null {
const id = normalizeSessionSource(source)
if (!id) {
return null
}
return SOURCE_LABELS[id] || id.replace(/[_-]+/g, ' ').replace(/\b\w/g, char => char.toUpperCase())
}
export function sessionSourceSearchTerms(source: null | string | undefined): string[] {
const id = normalizeSessionSource(source)
const label = sessionSourceLabel(id)
if (!id) {
return []
}
return [id, label ?? '', ...(SOURCE_ALIASES[id] ?? [])].filter(Boolean)
}

View File

@@ -24,3 +24,27 @@ export async function setSessionYolo(
return active
}
/**
* Toggle GLOBAL YOLO (approval bypass) via gateway `config.set` with
* `scope: 'global'`. This flips the persistent `approvals.mode` in config.yaml
* between `off` (bypass on) and `manual` (bypass off), affecting every session,
* the CLI, the TUI, and cron — and it survives restarts. Triggered by
* Shift+clicking the status-bar zap.
*/
export async function setGlobalYolo(
requestGateway: GatewayRequester,
enabled: boolean
): Promise<boolean> {
const result = await requestGateway<{ value?: string }>('config.set', {
key: 'yolo',
scope: 'global',
value: enabled ? '1' : '0'
})
const active = result?.value === '1'
setYoloActive(active)
return active
}

View File

@@ -23,6 +23,8 @@ export const SIDEBAR_SESSIONS_PAGE_SIZE = 50
const SIDEBAR_PINNED_STORAGE_KEY = 'hermes.desktop.pinnedSessions'
const SIDEBAR_AGENTS_GROUPED_STORAGE_KEY = 'hermes.desktop.agentsGroupedByWorkspace'
const SIDEBAR_CRON_OPEN_STORAGE_KEY = 'hermes.desktop.sidebarCronOpen'
const SIDEBAR_SESSION_ORDER_STORAGE_KEY = 'hermes.desktop.sessionOrder'
const SIDEBAR_WORKSPACE_ORDER_STORAGE_KEY = 'hermes.desktop.workspaceOrder'
const PANES_FLIPPED_STORAGE_KEY = 'hermes.desktop.panesFlipped'
export const CHAT_SIDEBAR_PANE_ID = 'chat-sidebar'
@@ -53,7 +55,14 @@ export const $sidebarWidth: ReadableAtom<number> = computed($paneStates, states
})
export const $pinnedSessionIds = atom(storedStringArray(SIDEBAR_PINNED_STORAGE_KEY))
export const $sidebarSessionOrderIds = atom(storedStringArray(SIDEBAR_SESSION_ORDER_STORAGE_KEY))
export const $sidebarWorkspaceOrderIds = atom(storedStringArray(SIDEBAR_WORKSPACE_ORDER_STORAGE_KEY))
export const $sidebarPinsOpen = atom(true)
// Set by the PaneShell hover-reveal overlay while the sidebar is collapsed; kept
// true the whole time it's a floating overlay (not just while shown) so the
// consumer mounts contents off-screen, ready to slide. ChatSidebar mounts its
// rows on `sidebarOpen || this`.
export const $sidebarOverlayMounted = atom(false)
export const $sidebarRecentsOpen = atom(true)
// Cron-job sessions live in their own section below recents, collapsed by
// default (it only renders at all when cron sessions exist) so the
@@ -68,6 +77,8 @@ export const $sessionsLimit = atom(SIDEBAR_SESSIONS_PAGE_SIZE)
$pinnedSessionIds.subscribe(ids => persistStringArray(SIDEBAR_PINNED_STORAGE_KEY, [...ids]))
$sidebarCronOpen.subscribe(open => persistBoolean(SIDEBAR_CRON_OPEN_STORAGE_KEY, open))
$sidebarSessionOrderIds.subscribe(ids => persistStringArray(SIDEBAR_SESSION_ORDER_STORAGE_KEY, [...ids]))
$sidebarWorkspaceOrderIds.subscribe(ids => persistStringArray(SIDEBAR_WORKSPACE_ORDER_STORAGE_KEY, [...ids]))
$sidebarAgentsGrouped.subscribe(grouped => persistBoolean(SIDEBAR_AGENTS_GROUPED_STORAGE_KEY, grouped))
$panesFlipped.subscribe(flipped => persistBoolean(PANES_FLIPPED_STORAGE_KEY, flipped))
@@ -116,6 +127,10 @@ export function setSidebarPinsOpen(open: boolean) {
$sidebarPinsOpen.set(open)
}
export function setSidebarOverlayMounted(mounted: boolean) {
$sidebarOverlayMounted.set(mounted)
}
export function setSidebarRecentsOpen(open: boolean) {
$sidebarRecentsOpen.set(open)
}
@@ -128,6 +143,18 @@ export function setSidebarAgentsGrouped(grouped: boolean) {
$sidebarAgentsGrouped.set(grouped)
}
export function setSidebarSessionOrderIds(ids: string[]) {
if (!arraysEqual($sidebarSessionOrderIds.get(), ids)) {
$sidebarSessionOrderIds.set(ids)
}
}
export function setSidebarWorkspaceOrderIds(ids: string[]) {
if (!arraysEqual($sidebarWorkspaceOrderIds.get(), ids)) {
$sidebarWorkspaceOrderIds.set(ids)
}
}
export function setSidebarResizing(resizing: boolean) {
$isSidebarResizing.set(resizing)
}

View File

@@ -888,52 +888,42 @@ canvas {
}
.fit-text {
--fit-captured-length: initial;
--fit-support-sentinel: var(--fit-captured-length, 9999px);
display: flex;
font-size: var(--fit-text-min, 1rem);
container-type: inline-size;
--captured-length: initial;
--support-sentinel: var(--captured-length, 9999px);
}
.fit-text > [aria-hidden='true'] {
.fit-text > [aria-hidden] {
visibility: hidden;
}
.fit-text > :not([aria-hidden='true']) {
.fit-text > :not([aria-hidden]) {
flex-grow: 1;
container-type: inline-size;
--captured-length: 100cqi;
--available-space: var(--captured-length);
--fit-captured-length: 100cqi;
--fit-available-space: var(--fit-captured-length);
}
.fit-text > :not([aria-hidden='true']) > * {
.fit-text > :not([aria-hidden]) > * {
--fit-support-sentinel: inherit;
--fit-captured-length: 100cqi;
--fit-ratio: tan(atan2(var(--fit-available-space), var(--fit-available-space) - var(--fit-captured-length)));
display: block;
inline-size: var(--available-space);
line-height: var(--fit-text-line-height, 1);
--support-sentinel: inherit;
--captured-length: 100cqi;
--ratio: tan(atan2(var(--available-space), var(--available-space) - var(--captured-length)));
--font-size: clamp(
var(--fit-text-min, 1em),
1em * var(--ratio),
var(--fit-text-max, infinity * 1px) - var(--support-sentinel)
);
font-size: var(--font-size);
inline-size: var(--fit-available-space);
font-size: clamp(var(--fit-min, 1em), 1em * var(--fit-ratio), var(--fit-max, infinity * 1px) - var(--fit-support-sentinel));
}
@container (inline-size > 0) {
.fit-text > :not([aria-hidden='true']) > * {
.fit-text > :not([aria-hidden]) > * {
white-space: nowrap;
}
}
@property --captured-length {
syntax: '<length>';
initial-value: 0px;
inherits: true;
}
@property --captured-length2 {
@property --fit-captured-length {
syntax: '<length>';
initial-value: 0px;
inherits: true;

View File

@@ -6,6 +6,19 @@ import path from 'path'
export default defineConfig({
base: './',
plugins: [react(), tailwindcss()],
css: {
// Pin an explicit (empty) PostCSS config. Tailwind is handled entirely by
// `@tailwindcss/vite`, so the renderer needs no PostCSS plugins — and
// without this, Vite's `postcss-load-config` walks UP the filesystem
// looking for a stray `postcss.config.*` / `tailwind.config.*`. The desktop
// build runs from inside the user's home tree (e.g.
// `C:\Users\<name>\AppData\Local\hermes\hermes-agent\apps\desktop`), so an
// unrelated Tailwind v3 config higher up the tree gets picked up and
// reprocesses our v4 stylesheet, failing the build with
// "`@layer base` is used but no matching `@tailwind base` directive is
// present." Pinning the config makes the build hermetic.
postcss: { plugins: [] }
},
build: {
// Keep desktop packaging stable: Shiki ships many dynamic chunks by
// default, and electron-builder can OOM scanning thousands of files.

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

1301
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`)

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
}
}

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