Commit graph

11592 commits

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

# Conflicts:
#	ui-opentui/src/entry/main.tsx
2026-06-13 18:05:11 +05:30
alt-glitch
3d1691c48b fix(tui): opentui launches when fnm default is older than 26.3; chrome bar reads the real cwd
Two reasons the local TUI stopped running OpenTUI / showed the wrong directory:

1. Node resolution. OpenTUI needs Node >= 26.3 (node:ffi floor), but
   _node26_bin_or_none only checked HERMES_NODE + `which node`. When fnm's
   default flips to an older line (e.g. v25.9) the active node fails the gate
   and the engine silently falls back to Ink even though a usable v26.3 sits
   installed. _fnm_node26_candidates now discovers fnm's installed versions
   (FNM_DIR / XDG_DATA_HOME/fnm / ~/.local/share/fnm / macOS Library path),
   newest first, version-probed — so the engine launches without the user
   re-aliasing their global default.

2. Launch cwd. The launcher runs the engine with cwd=<engine package dir> so
   its build/resolution works; the gateway it spawns then auto-detected THAT
   dir as the workspace (chrome bar showed 'ui-opentui (feat/opentui-native-
   engine)' regardless of where you ran hermes). TERMINAL_CWD — the gateway's
   canonical launch-dir channel — was only exported in worktree mode; now it's
   set to the real cwd for every launch (worktree mode still overrides to the
   worktree path). The TUI's session.create no longer sends process.cwd() (the
   engine dir) — a new launchCwd() reads the launcher's HERMES_CWD/TERMINAL_CWD,
   falling back to process.cwd() only for standalone smokes.

Together: session cwd, chrome bar, terminal-tool cwd, and /sessions grouping
all anchor to where you actually ran hermes. Verified live — chrome bar shows
'/tmp/cwd-probe (my-feature)' launched from there with fnm default on v25.9.

8 new tests (fnm discovery order/precedence/empty-safety; launchCwd env
precedence).
2026-06-13 13:24:54 +05:30
alt-glitch
27a455d301 opentui(v6): /sessions groups this directory's sessions first + TUI persists its cwd
The resume picker never had cwd grouping — deliberately deferred in the v1
spec because TUI session rows had no cwd to group by: the TUI's
session.create sent only {cols}, so explicit_cwd stayed false and
_ensure_session_db_row skipped cwd stamping by design (the desktop's launch
dir is meaningless — 'No workspace' grouping is its desired default).

In a terminal the launch directory IS the workspace choice, so the entry now
passes cwd: process.cwd() at session.create — the existing explicit-workspace
machinery persists it to the session row on first message (covered by
test_ensure_session_db_row_persists_explicit_cwd; zero gateway changes).

Picker: while browsing (no search), sessions whose cwd matches the TUI's
current directory order first under a '▾ this directory (N)' caption, the
rest under '▾ other directories' — one flat reordered list, so selection/
windowing/load-more math is untouched, and captions are pure render
decoration keyed off hereCount. During search the fuzzy score keeps owning
the order. Trailing-slash-normalized comparison, no fs calls.

Old sessions can't be backfilled (their cwd was never recorded); coverage
accumulates from here. 6 new tests (pure ordering edges + grouped frames,
search-drops-grouping, no-cwd passthrough).
2026-06-12 23:50:59 +05:30
alt-glitch
7d5fe2c39f opentui(v6): fleet memory self-sampling — HERMES_TUI_MEMLOG + memwatch-report aggregator
Instead of an external watcher chasing 5-10 concurrent session pids,
every TUI samples ITSELF at 1Hz (rss/heap/external + windowing
mounted/peak-mounted counters) into ~/.hermes/logs/memwatch/, gated by
HERMES_TUI_MEMLOG (defaults to the HERMES_TUI_DIAGNOSTICS master
switch) — one shell-rc export covers every session a dev ever starts.
Unref'd timer, every failure path silently disables, 14-day retention.
bench/memwatch-report.mjs aggregates the fleet: per-session
baseline/peak/last, steady-state MB/h slope, peak mounted rows, and
SLOPE/PEAK/MOUNTED anomaly flags. Verified live: two fake-gateway
smoke sessions logged and aggregated (102MB base, mounted ≤60).
2026-06-12 19:24:00 +05:30
alt-glitch
2402e7777c opentui(v6): syntax highlighting for 10 more languages (vendored tree-sitter grammars)
@opentui/core@0.4.0 bundles only 5 grammars (ts/js/markdown/markdown_inline/
zig) and Hermes registered none of its own — Python/Rust/Go/bash/JSON/C/HTML/
CSS/YAML/TOML tool bodies and fences rendered plain text (never a regression:
no addDefaultParsers existed anywhere in branch history).

Now: parsers/manifest.json curates the 10 grammars (cpp deliberately dropped —
3.28MB alone); scripts/update-parsers.mjs vendors wasm+highlights.scm with
magic/content validation (plain Node fetch — core's update-assets generator is
Bun-flavored and its import-module won't bundle under esbuild, so registration
skips it and points at the vendored files by runtime-resolved path instead);
boundary/parsers.ts registers via the public addDefaultParsers() at entry
module load, before the first <code>/<markdown> mount initializes the global
tree-sitter client. ~4MB vendored, committed (build inputs, offline-safe).

Markdown fence injections need no infoStringMap: fence labels resolve as
filetype ids and core's ext maps already normalize py→python, zsh→bash, h→c.
Live-smoked in a real renderer: python tool body draws 6 distinct token
colors; ```python and ```yaml fences inside markdown highlight too. 6 new
tests pin the wiring (vendored assets valid, registration set, filetype
routing); visuals stay live-smoke territory per codeBlock.tsx.
2026-06-12 13:26:17 +05:30
alt-glitch
a14d44482e bench: post-merge gate verification results (gate/mem2000/scroll2000 @ f0ec24a) 2026-06-12 13:09:23 +05:30
alt-glitch
e9fe618fce opentui(v6): terminal window title (OSC 0/2) + waiting-on-you notifications (OSC 9/99/777)
Window title: a render-nothing <TerminalChrome> tracks session.info — the
native renderer.setTerminalTitle (frame-safe, zig-side OSC emit) shows
'{session title} — Hermes' once the session is titled, 'Hermes Agent' until
then. The user's previous title is bracketed with the XTWINOPS title stack
(save on boot, best-effort restore on quit). Gateway: _session_info now
carries the live title (DB row, pending_title fallback) and a session.info
refresh follows every title change — pending-title application, the
auto-title worker landing (via maybe_auto_title's title_callback), and
session.title renames — so the window retitles without waiting for the
next turn.

Notifications: when the TUI starts waiting on the user — any blocking
prompt (clarify/approval/sudo/secret/confirm) or turn completion — three
dialect sequences go out through renderer.writeOut: OSC 9 (iTerm2/wezterm),
OSC 99 (kitty), OSC 777 (urxvt/foot); terminals ignore what they don't
speak. Suppressed while the terminal reports focused (core's mode-1004
focus/blur events; until a first blur proves reporting works, notify
unconditionally). HERMES_TUI_NOTIFY=0/false/off kills notifications; the
title is not gated. All text is OSC-sanitized (control chars stripped,
777's semicolon fields spliced-proof, length-capped).

13 new TUI tests (pure shaping/sequences/env gate + store-edge wiring via
an injected seam) and 2 gateway tests (title resolution order, thread-safe
refresh emitter). Live-smoked: tmux pane_title shows 'Hermes Agent' from
the native title path.
2026-06-12 13:09:23 +05:30
alt-glitch
fc0774b7f2 Merge feat/opentui-native-engine (origin/main @ 24f74eb88 merged in) into feat/opentui-memory-window
Brings the memory-window branch up to current main + the multi-click
selection feature, via the base PR branch's merge commits (history
preserved, no rewrites). No conflicts: the 13 windowing/diagnostics
commits touch ui-opentui/bench/docs only.
2026-06-12 10:37:23 +05:30
alt-glitch
3d7a64c383 Merge origin/main (follow-up delta) into feat/opentui-native-engine 2026-06-12 10:35:38 +05:30
alt-glitch
8356b10afa tests: pin ink engine in _make_tui_argv npm-bootstrap tests (post-merge semantic fix)
Main's rewritten test_tui_npm_install.py tests call _make_tui_argv expecting
the Ink/npm flow unconditionally; with the dual-engine dispatch merged in,
_resolve_tui_engine() auto-selects opentui whenever ui-opentui/dist is built
in the repo, routing the call away from the path under test (first subprocess
became 'node --version' instead of 'npm run build'). Pin the engine to ink
via an autouse fixture, mirroring the existing pinning precedent in
test_tui_resume_flow.py.
2026-06-12 10:32:40 +05:30
brooklyn!
24f74eb888
fix(desktop): make file-preview source + markdown selectable (#44648)
body sets user-select:none for native feel and opts text back in only via
[data-selectable-text='true']; the preview's source and rendered-markdown
panes never set it, so code couldn't be selected or copied. Tag the Shiki
code column and the markdown root. The attribute stays off the SourceView
grid root so the gutter keeps its select-none and line numbers don't bleed
into copied text.
2026-06-12 04:15:06 +00:00
brooklyn!
6e41ca956b
fix(desktop): bundle JetBrains Mono for the terminal pane (#44642)
The terminal listed JetBrains Mono only as a late fallback and shipped no
webfont, so on machines without SF Mono/Menlo xterm measured the grid on the
regular system face while styled SGR spans fell back to a font with different
advances — glyphs squeezed and overlapped.

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

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

Verified during resolution: cli.py worktree prune/cleanup lock fix survived
auto-merge intact and main's new prune call-site (hermes_cli/main.py:2139)
inherits its clean/locked/dirty/unpushed skip semantics; HERMES_TUI_ENGINE
selection in hermes_cli/main.py intact.
2026-06-12 09:35:30 +05:30
alt-glitch
ed2cffd450 opentui(v6): diagnostics-gate review nits — completion-mechanism precision + client-only design note 2026-06-12 09:28:15 +05:30
alt-glitch
50059ea403 opentui(v6): HERMES_TUI_DIAGNOSTICS master switch — gate /mem, /heapdump + window-stats default
Regular users get zero diagnostic surface by default: /mem and /heapdump
disappear from /help and completion, and invoking them prints the
one-line enable hint (relaunch with HERMES_TUI_DIAGNOSTICS=1) instead of
executing — an enable switch, not a secret. With the switch on, the
commands work as before and HERMES_TUI_WINDOW_STATS defaults on (still
individually settable either way). Full env-flag ledger (master switch /
user config / dev tuning / internal plumbing) in docs/opentui-env-flags.md.
672 tests exit 0.
2026-06-12 09:17:25 +05:30
brooklyn!
6db65e687c
Merge pull request #44627 from NousResearch/bb/desktop-tool-row-copy-affordance
fix(desktop): move tool-row copy control into expanded body
2026-06-11 22:32:52 -05:00
Brooklyn Nicholson
09bcf5a937 fix(desktop): move tool-row copy control into expanded body
The per-row copy control lived in the header's trailing slot as a 24px
button that depended on a `group-hover/tool-row` group that exists nowhere
in the tree. It therefore stayed `opacity-0` yet remained clickable — an
invisible hit-target straddling the disclosure caret and duration, making
the caret hard to click without firing a copy.

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

Tradeoff: copy is only reachable once a row is expanded; rows with no
expandable body no longer surface a copy control.
2026-06-11 22:27:39 -05:00
alt-glitch
22434f4d07 bench: rebased-branch verification — digest unchanged, mem2000 289MB 2026-06-12 08:42:19 +05:30
alt-glitch
dc57ad98db opentui(v6): post-rebase fixups — dedup probe mouse, .demo lint ignore, cap tests to windowing-aware contract
Rebasing onto fcf49f313 (multi-click selection) collided in the test
probe (both sides added 'mouse' — deduped) and surfaced two test debts
the cap-restore commit (3cc56517a) had shipped masked by a piped exit
code: store cap tests still asserted the 1000 default (now: 3000
windowed, 1000 with HERMES_TUI_WINDOWING=0, both covered), and the
burst-interplay test relied on the old cap trimming a 1500-row burst
(now pins HERMES_TUI_MAX_MESSAGES=1000 explicitly for both stores).
Also: .demo/ build artifacts excluded from typed linting. 669 tests
exit 0 (verified unpiped). Multi-click selections flow through the
same renderer selection seam, so windowing's drag-freeze + row-pinning
covers them with no changes.
2026-06-12 08:40:53 +05:30
alt-glitch
44c896a5e2 docs: upstream alignment playbook — forkless invariant, shim ledger, upgrade contract
Maintainer signals (native yoga next release, 2x layout, opencode's
100-cap was a legacy perf workaround): what changes for us (WASM ratchet
dies), what doesn't (the 65k handle table still makes windowing
load-bearing at 3000 rows), the boundary/ shim ledger with
delete-on-upstream-fix criteria, and the per-release upgrade playbook
that uses the bench suite as the acceptance contract.
2026-06-12 08:29:24 +05:30
alt-glitch
6f5f7457fe docs: the OpenTUI memory story — ELI5 walkthrough of the 686MB→300MB campaign
Shareable explainer: every primitive at play (native handle table, Yoga
WASM grow-only memory, renderables, Solid surgical unmount, V8 GC
laziness, scrollbox draw-only culling) and every decision (windowing vs
store-cull, exact-heights-at-unmount vs Ink's estimate-correct, the
correctionIsLegal zero-jank law, append-time adjudication, never-window
rules, windowing-aware cap restore, heap right-sizing), with the
measured scoreboard and honest open items.
2026-06-12 08:29:24 +05:30
alt-glitch
dc3c7dc405 opentui(v6): restore scrollback cap 1000 → 3000 under windowing (#27 payoff)
With transcript windowing (S1+S2) the mounted set no longer scales with
the store (peak 31 rows over a 1500-row burst), so the handle-table
clamp that forced 1000 rows is unnecessary when windowing is on. The
ceiling is now windowing-aware: 3000 rows (the originally-shipped
default, regression documented in opentui-fixes-audit.md §2) with
windowing, 1000 with HERMES_TUI_WINDOWING=0 (every row mounts again).

Measured at the restored cap (full 3000-msg store): mem3000 360MB peak
styled end-to-end (pre-campaign: ~870MB + unstyled past ~1,400 rows;
before that: crash). scroll3000 p50=2 p90=3 p99=8 max=17ms (Ink same
workload: p90=35 p99=96). Gate digest unchanged.
2026-06-12 08:29:24 +05:30
alt-glitch
360388f627 opentui: Node 26 onboarding — scoped .node-version, engines floor, README setup guide
Pins Node 26.3 to ui-opentui/ only (fnm/mise auto-switch on cd; leaving
the directory restores whatever the dev had — no global default change).
engines.node >= 26.3 makes a wrong-Node npm ci warn. README covers
install paths (fnm/mise/nvm/absolute-binary), the ABI-locked
node_modules gotcha, and build/run commands.
2026-06-12 08:29:24 +05:30
alt-glitch
375899f89c opentui(v6): windowing S2 — pin selected rows instead of freezing on a lingering highlight
Adversarial-review follow-up to the S2 slice. The S1 rule froze ALL window
recomputes while renderer.getSelection()?.isActive — but a finished mouse
selection persists by design (boundary/renderer.ts keeps the highlight so
Ctrl+C can re-copy), so a long streaming turn behind a lingering highlight
ballooned the mounted set exactly like pre-windowing (and permanently
ratcheted the Yoga-WASM high-water).

Refinement:
- full freeze only while selection.isDragging (the native walk touches the
  live tree on every drag update — destroying a row mid-walk corrupts the
  highlight; unchanged from S1 where it matters),
- a finished highlight instead PINS the rows containing
  selection.selectedRenderables (parent-climb to the row wrapper via a
  WeakMap) as neverWindow — the highlight and a later Ctrl+C copy stay
  byte-exact while everything else keeps windowing,
- an active highlight counts as activity (no idle measure churn under it).

Test (headless mock-mouse drag): finished selection persists (isActive,
!isDragging) → 300-row burst keeps peakMounted < 120 AND
getSelectedText() returns the identical text afterward, the selected rows
having been pinned while long scrolled past the margin.

Verified on this build: gate digest otui-capped d5e9558583159eac… (2/2),
mem2000 otui-capped windowing-ON vmhwm 312MB (target ≤ 350), scroll2000
otui-capped p50 2.0ms / p99 6.0ms (gate ≤ 17ms). check exit 0 (648 tests).

Review verdicts on the remaining findings (verified against core source):
- "scrollTop compensation race": rejected — scrollTop is an imperative
  scrollbar property (no signal staleness); records fire in document order,
  each compensation immediately visible to the next.
- "heights map leak on /new": rejected — the countChanged cleanup prunes
  every per-key map against the live key set (test-verified).
- remount-in-viewport estimate shift: only reachable when one frame jumps
  past the margin (> 1 viewport); the design's accepted "remounted for
  view" path — documented in the header.
- expanded tool/reasoning re-collapse on far-remount: S1-accepted,
  deferred (component-local state; out of S2 file ownership).
2026-06-12 08:29:24 +05:30
alt-glitch
fd956d3189 bench: S2 controller verification — mem2000 307/373MB, scroll p99 6ms, digest unchanged 2026-06-12 08:29:24 +05:30
alt-glitch
fcbe525a63 opentui(v6): transcript windowing S2 — append-time adjudication + windowed resume + edge measure
S2 of docs/plans/opentui-transcript-windowing.md (#27), behind
HERMES_TUI_WINDOWING (OFF path renders the byte-identical legacy tree).

Append-time adjudication: the window now recomputes on transcript GROWTH,
not just scroll — a createComputed on messages.length re-windows
synchronously per append, and while pinned at the bottom computeWindow
anchors to the cumulative content BOTTOM (pinnedBottom) instead of the
stale pre-layout scrollTop, so burst-appended rows are spacer-swapped the
moment they pass the margin. The frame driver additionally treats a
≥ ¼-viewport scrollHeight change (streaming growth) like scroll movement.
Unseen-row default changed from "always mounted" to "mounted iff created
streaming or within the bottom-30" — live rows still paint instantly with
zero added latency; a bulk commitSnapshot (resume) mounts ONLY the bottom
window and everything above starts as line-count-estimate spacers (chip-
and-spacing-aware estimateMessageHeight).

Spacer corrections (zero-jank rule): when a measure lands a height
different from what the spacer occupied, the wrapper's onSizeChange fires
inside the layout traversal, pre-paint. Pinned at bottom the scrollbox's
own sticky re-pin (content onSizeChange runs before the row wrappers')
already compensated — verified by test; otherwise scrollTop is compensated
same-frame for rows fully above the viewport (correctionIsLegal). Frames
stay byte-stable across corrections in both pinned and mid-history tests.

Lazy exact-measure (design §4 — the simple choice, documented): no true
offscreen layout exists in @opentui/core, so an idle pulse (no appends,
no scroll, no turn, no selection for HERMES_TUI_WINDOW_IDLE_MS≈1s) mounts
MEASURE_BATCH_ROWS=10 never-measured rows nearest the bottom window edge
(edgeMeasureBatch), records exact heights (incl. a direct post-layout pull
for rows whose mount changed nothing — no onSizeChange fires), and the
next recompute swaps them back to now-exact spacers. Scrolling itself
still measures the margin band.

DEV counter: windowRowStats (current/peak simultaneously-mounted rows),
exposed on globalThis behind HERMES_TUI_WINDOW_STATS; tests assert it.

Measured (this build, 39f9f433e+S2):
- check: exit 0 (647 tests / 39 files; +11 pure window cases, +4 headless)
- peak mounted: 31 rows over a 1500-row burst; 30 rows on a 600-row
  resume snapshot (bound asserted < 120)
- gate digest: otui-capped d5e9558583159eac… — byte-identical, 2/2 reps
- mem2000 (otui-capped, windowing ON, 8GB heap): vmhwm 300MB
  (S1 same-heap 518MB; S1 right-sized-heap 427MB; Ink 229-239MB;
  target ≤ 350MB)
- scroll2000 otui-capped: p50 2.0ms / p99 5.0ms / max 18ms
  (gate ≤ 17ms p99; S1 baseline p99 15ms)

Known S2 limits (deferred to S3, design §5): /compact·/details toggles and
width resizes leave out-of-window spacer heights stale until remount or
the idle march; expanded-body state above the window may re-collapse on
remount (S1-accepted).
2026-06-12 08:29:24 +05:30
alt-glitch
f7381800f7 bench: windowing A/B knobs + S1 results — windowing −170MB, GC laziness −90MB at 2k msgs
composeEnv passes HERMES_TUI_WINDOWING through (clean env stripped it);
--heap N overrides the mem-cell V8 cap. Same-build A/B at 2000 msgs:
OFF 686MB peak / ON 518MB / ON+512MB-heap 427MB; scroll p99 16ms vs
17ms baseline (no jank regression), determinism digest byte-identical.
Residual slope ~108MB/1k (Ink ~37) — burst-time mounted peak is the S2
target.
2026-06-12 08:29:24 +05:30
alt-glitch
411334b3d0 opentui(v6): transcript windowing S1 — exact-height spacers behind HERMES_TUI_WINDOWING
Core machinery of docs/plans/opentui-transcript-windowing.md (#27): rows
outside [scrollTop − viewport, scrollTop + 2·viewport) swap to an exact-height
empty <box> (1 yoga node, no text buffers / native handles), so the mounted
set stays ~3 viewports regardless of transcript length.

Flag: HERMES_TUI_WINDOWING — unset → ON; 0/false/no/off → OFF (envFlag
semantics, the bench A/B + one-env escape hatch). OFF renders the exact
legacy tree (no wrapper boxes).

Pieces:
- logic/window.ts (pure, table-tested): computeWindow (viewport ± 1-viewport
  margin intersection over cumulative exact heights; null heights fall back
  to a per-row line-count estimate), hysteresisFor/shouldRecompute (≥ ¼
  viewport between recomputes), correctionIsLegal (the jank rule: corrections
  only fully above the viewport with same-frame scrollTop compensation, or
  fully below it), estimateMessageHeight (line-count estimate; wrong values
  are fixed by remount only — S1 never corrects a spacer in place).
- view/transcript.tsx: per-row measuring wrapper records exact heights via
  onSizeChange (only while the real row is mounted); window driver is a
  renderer frame callback (setFrameCallback — scroll always renders, so no
  extra timer) publishing the mounted set through one signal + createSelector
  so only flipped rows re-render. Stable row keys via WeakMap<Message, n>
  (messages have no id; store proxies are reference-stable). Solid <Show>
  unmount destroys the row's renderables (@opentui/solid _removeNode →
  destroyRecursively).

Never-window rules:
- streaming rows (remount would restart native markdown streaming),
- the last row while a turn is running (deltas land there),
- the bottom 30 rows (fixed K — sticky-bottom region; rows under
  viewport+margin are mounted by the window calc anyway),
- rows the window has never adjudicated default to MOUNTED (new live rows
  paint instantly),
- the whole window FREEZES while a mouse selection is active
  (renderer.getSelection()?.isActive — a swap would destroy highlighted
  renderables under the native selection walk).

Tests: 30 pure window.test.ts cases + 2 headless integration cases
(transcriptWindow.test.tsx) pinning the zero-jank invariant (scrollHeight
identical ON vs OFF), the renderable shedding, and remount-on-scroll-back.
2026-06-12 08:29:24 +05:30
brooklyn!
4d67ac6172
Merge pull request #44596 from NousResearch/bb/desktop-rtl-bidi
feat(desktop): auto-detect RTL/bidi text direction in chat
2026-06-11 21:44:13 -05:00
Brooklyn Nicholson
6c00077d38 feat(desktop): auto-detect RTL/bidi text direction in chat
Arabic/Hebrew/Persian/Urdu chat text rendered left-to-right and
left-aligned, and mixed RTL/English technical messages (the common case)
read backwards. Resolve each chat block's base direction from its own
first strong character (UAX#9) with pure CSS, scoped to the chat
surfaces only:

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

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

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

Fixes #44150

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

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

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

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

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

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

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

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

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

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

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

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

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

Supersedes #37856
Closes #37812

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

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

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

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

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

Discriminate on child liveness: the desktop pins
HERMES_DASHBOARD_SESSION_TOKEN on every spawn, so a live child always
serves our token. Served-token mismatch + dead child = foreign backend;
fail the boot loudly instead of connecting. Mismatch + live child keeps
the adopt-served-token salvage from #43720.
2026-06-11 18:18:22 -05:00
Evis
7a2d498b9d fix(desktop): route profile session reads
(cherry picked from commit 64aaf58f5e)
2026-06-11 18:09:24 -05:00
Jeff
e96fe06e49 fix(desktop): use served dashboard token for websocket auth
(cherry picked from commit f8209f91d3)
(cherry picked from commit 72290f0809)
2026-06-11 18:07:19 -05:00