Compare commits

...
Author SHA1 Message Date
Ben 44ef0150ab fix(dashboard): sanction plugin WS/upload auth via SDK helpers (gated mode)
Dashboard plugins (kanban, hermes-achievements) read
window.__HERMES_SESSION_TOKEN__ directly and hand-assembled WebSocket
URLs with ?token=. That works in loopback/--insecure mode but is
rejected on OAuth-gated deployments, where the session token is absent
and _ws_auth_ok only accepts single-use ?ticket= auth. The result was
401s on plugin REST calls and 1008/403 on the kanban live-events WS
whenever the dashboard ran behind OAuth (e.g. hosted Fly agents).

Make the plugin SDK the single sanctioned auth surface:

- web/src/lib/api.ts: add authedFetch() (raw Response for FormData
  uploads / blob downloads, token-or-cookie auth, no throw / no 401
  redirect) and buildWsUrl() (assembles a ws(s):// URL with the correct
  auth param for the active mode — fresh single-use ticket in gated
  mode, token in loopback).
- web/src/plugins/registry.ts: expose authedFetch, buildWsUrl,
  buildWsAuthParam, and sdkVersion on window.__HERMES_PLUGIN_SDK__;
  add SDK_CONTRACT_VERSION.
- web/src/plugins/sdk.d.ts: hand-authored typed contract for the
  plugin SDK + registry globals (single source of truth for the
  Window declarations).
- plugins/kanban + hermes-achievements dist bundles: stop reading the
  session token directly; route uploads/downloads through
  SDK.authedFetch and the live-events WS through SDK.buildWsUrl.
- plugins/kanban plugin_api.py: _ws_upgrade_authorized() delegates the
  /events WS upgrade to the canonical web_server._ws_auth_ok gate, so
  it transparently accepts loopback token / gated ticket / internal
  credential and can never drift from core auth again.
- tests: guard test asserting no plugin dist reads
  __HERMES_SESSION_TOKEN__ directly; kanban gated-ticket WS test.

Verified live on a gated staging Fly agent: kanban /events upgrades
101 with a minted ticket (ticket_len=43, ws_auth_ok=True) where the
old code got 403.
2026-06-04 09:17:59 +10:00
kshitij f019a9c491 Merge pull request #37975 from kshitijk4poor/fix/desktop-session-view-bleed
fix(desktop): stop background session messages bleeding into the active transcript
2026-06-03 01:03:50 -07:00
kshitij 46ea0a184d Merge pull request #37999 from kshitijk4poor/desktop-slash-nav-dom-regression-test
fix(desktop): slash/@ menu keyboard nav — cycle all items + Esc dismiss
2026-06-03 00:51:54 -07:00
kshitijk4poor 49f1b9e4b4 fix(desktop): stop Esc reopening the slash/@ menu; harden keyup guard
Follow-up to #37937. That fix guarded the composer's keyup with
`shouldSkipTriggerRefreshOnKeyUp(key, trigger !== null)`. The `trigger !== null`
check is timing-fragile for Escape: Escape's *keydown* sets `trigger = null`
and closes the menu, but in a real browser the *keyup* fires after a re-render,
so the handler closure sees `trigger === null`, the guard returns false,
`refreshTrigger` runs, re-detects the still-present `/` in the input, and
instantly reopens the menu. (jsdom batches state synchronously so a unit test
could not observe this -- only the running app does.)

Replace the value-based guard with a `triggerKeyConsumedRef` set synchronously
in keydown whenever the open popover consumes a nav/control key
(Arrow/Enter/Tab/Escape). keyup consults and clears that ref, so it is immune
to the keydown->re-render->keyup timing. Applied to both the main composer
(chat/composer/index.tsx) and the message-edit composer
(assistant-ui/thread.tsx).

Removes the now-unused `shouldSkipTriggerRefreshOnKeyUp` helper and its unit
test. The real-DOM regression test now fires keydown+keyup pairs through the
ref-based handlers and asserts Esc closes and stays closed.

Verified by running a production renderer build (Vite v8) under Electron
against a local backend: ArrowDown/ArrowUp cycle the full list and Esc
dismisses the menu without reopening.
2026-06-03 13:15:08 +05:30
kshitijk4poor c77c470d27 test(desktop): real-DOM regression for slash/@ menu keyboard nav
The existing slash-menu fix (PR #37937) shipped a unit test that drove the
keydown reducer directly. It did not exercise the actual DOM event path —
specifically the keyup-driven `refreshTrigger` that was the root cause — so
it would not have caught a regression in that path.

This adds a faithful @testing-library reproduction that mounts the real
`useLiveCompletionAdapter` plus the index.tsx trigger wiring and fires real
`keyDown` + `keyUp` event pairs on a contentEditable. It asserts:

- ArrowDown cycles through ALL items (0,1,2,3,4,0,1), not just the first two
- Escape closes the menu and keyup does not reopen it

Reverting the fix (always-refresh keyup + unconditional setTriggerActive(0))
makes this test fail with the highlight stuck at the top — confirming it
guards the real bug.
2026-06-03 12:46:14 +05:30
kshitijk4poor e114b31eda test(dashboard): direct unit coverage for internal WS credential + docstring fix
Follow-up to Ben's PR #37892. Adds a TestInternalCredential block to
test_dashboard_auth_ws_tickets.py exercising the mint-once stability,
multi-use, unminted-rejection, empty-value, wrong-value, reset-and-remint,
and ticket-store-independence branches directly (previously only covered
indirectly via _ws_auth_ok, which left the unminted and empty-value
branches unexercised).

Also corrects the consume_internal_credential docstring: the returned
identity dict is discarded by the current _ws_auth_ok caller (which only
needs the boolean outcome), so the prior 'carry it into its session log'
wording over-promised.
2026-06-02 23:43:27 -07:00
Ben fd1ec8033d fix(dashboard): authenticate server-spawned PTY child WS with a process-internal credential
The embedded-TUI PTY child attaches to two server-internal WebSockets:
/api/ws (its primary JSON-RPC gateway backend) and /api/pub (the event
sidecar). Both URLs are built server-side in web_server.py and handed to
the child via its environment.

In OAuth-gated mode (auth_required=true, every hosted Fly agent), _ws_auth_ok
unconditionally rejects the legacy ?token=<_SESSION_TOKEN> path — a leaked
session token must not grant WS access once the gate is engaged. But
_build_gateway_ws_url() still only emitted ?token=, with no gated-mode
branch (its sibling _build_sidecar_url had been given a ticket branch; the
gateway-url builder was missed). So the TUI child's /api/ws upgrade was
rejected 4401 -> 'gateway websocket connection failed' -> 'gateway startup
timeout', leaving the embedded chat unusable on every gated deployment.

A single-use 30s browser ticket is the wrong shape for this link: the child
reads its attach URL once at startup and reuses it on every reconnect, and
on a slow cold boot it may not dial within the TTL. (_build_sidecar_url's
own docstring already flagged this fragility.)

Fix: add a process-lifetime, multi-use internal credential to
dashboard_auth.ws_tickets (internal_ws_credential / consume_internal_credential),
minted once per process and NEVER injected into the SPA — it only leaves the
process via a spawned child's env, so browser-side XSS can't read it, and a
leak grants no more than a ticket already does. _ws_auth_ok accepts it via
?internal= in gated mode only. Both _build_gateway_ws_url and
_build_sidecar_url now use it, so the child can reconnect both sockets.

Loopback / --insecure behavior is unchanged (still ?token=).

Needs review: touches _ws_auth_ok + dashboard_auth (core auth surface).
2026-06-02 23:43:27 -07:00
kshitijk4poor 28f1590b7a fix(desktop): stop background session messages bleeding into the active transcript
A still-busy background session (one the user toggled away from) keeps
emitting updateSessionState() heartbeats — stream deltas, and especially
the 'session busy' prompt-rejection errors from auto-drained queued turns.
Each call invoked syncSessionStateToView() unconditionally, staging that
session's messages into the shared $messages view.

flushPendingViewState() guarded against the wrong session reaching the
view, but only one requestAnimationFrame is scheduled per frame and
pendingViewStateRef holds just the latest writer. So within a single
frame a background write could overwrite an already-pending foreground
write, and the stale background transcript (e.g. the red 'session busy'
rows) would render on top of whatever session the user switched to —
appearing to 'bleed' into every session.

Guard at the staging site: a session may only stage into the view when
it is the currently-active session. Background sessions still update
their own cache entry; they just never touch $messages. Pure render
fix, no behavior change to queuing, interrupt, or drain.
2026-06-03 12:09:18 +05:30
kshitij ada04573a9 Merge pull request #37948 from kshitijk4poor/fix/desktop-stop-button-interrupt
fix(desktop): make Stop button actually interrupt when a turn is queued
2026-06-02 23:20:30 -07:00
kshitijk4poor a23728dfcc fix(desktop): make Stop button actually interrupt when a turn is queued
When a follow-up message is queued during a busy turn, the composer
clears and the primary button switches back to the Stop affordance. But
clicking Stop ran interruptAndSendNextQueued(), which cancelled the turn
and *immediately* re-sent the head of the queue. The auto-drain effect
(busy true to false) compounded this: any explicit cancel flipped busy
false and re-fired the queue. The net effect was that Stop appeared to
never interrupt -- the agent kept running on the queued prompt.

Fix:
- Stop button (busy + empty composer) now always performs a pure
  interrupt via onCancel(); it no longer hijacks the queue.
- An explicit interrupt latches userInterruptedRef so the busy to false
  auto-drain skips exactly one drain. Queued turns are preserved and the
  user resumes them deliberately (Cmd/Ctrl+K, Enter, or the per-row
  send-now arrow), matching the documented Esc=cancel / Cmd+K=send-next
  affordances.
- Extracted the settle decision into shouldAutoDrainOnSettle() with unit
  tests covering natural completion vs. explicit interrupt.
2026-06-03 11:46:02 +05:30
kshitij 9b43ab8de5 Merge pull request #37937 from kshitijk4poor/fix/desktop-slash-menu-keyup-nav
fix(desktop): keep slash/@ completion menu navigable and Esc-dismissable
2026-06-02 22:54:05 -07:00
kshitijk4poor 188e52db91 fix(desktop): keep slash/@ completion menu navigable and Esc-dismissable
The desktop composer's `onKeyUp` handler unconditionally re-ran
`refreshTrigger` on every keyup, including the Arrow/Enter/Tab/Escape keys
the open-trigger `onKeyDown` branch had already fully handled. Because
`refreshTrigger` re-detects the trigger and resets the active index to 0,
this produced two bugs in the `/` (and `@`) completion popover:

- ArrowDown/ArrowUp moved the highlight on keydown, then keyup snapped it
  straight back to the top — so the user could never cycle past the first
  couple of items.
- Escape closed the menu on keydown, then keyup re-detected the still-present
  `/` and immediately reopened it — so Esc appeared to do nothing.

Fix: skip the keyup-driven refresh for the navigation/control keys while a
trigger menu is open (they never edit text, so refreshing is pointless), and
only reset the highlight in `refreshTrigger` when the detected trigger query
actually changed. Applied to both the main composer (chat/composer/index.tsx)
and the message-edit composer (assistant-ui/thread.tsx), which shared the
same bug. New `shouldSkipTriggerRefreshOnKeyUp` helper is unit-tested.
2026-06-03 11:19:07 +05:30
brooklyn! 5005b79bc3 Merge pull request #37932 from NousResearch/bb/desktop-remote-flicker
fix(desktop): disable GPU acceleration on remote displays to stop flicker
2026-06-03 00:43:37 -05:00
Brooklyn Nicholson d0ea4caf7f fix(desktop): don't treat WSLg as a remote display
WSLg renders Linux GUIs locally through a vGPU surface rather than
shipping frames over the wire, so it doesn't show the remote-compositor
flicker — confirmed by a WSL user seeing zero flickering. Drop the WSL
branch from detectRemoteDisplay so WSLg keeps hardware acceleration;
detection now covers only genuinely-remote displays (SSH X11 forwarding,
VNC, RDP). The HERMES_DESKTOP_DISABLE_GPU override still works for anyone
who does hit it.
2026-06-03 00:42:05 -05:00
Brooklyn Nicholson 6a2909fe5a fix(desktop): disable GPU acceleration on remote displays to stop flicker
Users on remote/forwarded displays (SSH X11 forwarding, VNC, RDP, WSLg)
reported the window flickering during scroll/streaming; nobody on native
Windows/macOS ever saw it.

Root cause: the app shipped with Chromium's default GPU hardware
acceleration and no remote-display handling. Over a remote connection the
GPU compositor can't present accelerated layers cleanly across the wire,
so the surface flashes on repaint. Local sessions composite on the GPU
and never hit it.

Detect a remote display before app `ready` (detectRemoteDisplay in
bootstrap-platform.cjs) and fall back to software rendering via
app.disableHardwareAcceleration() + --disable-gpu-compositing. Software
compositing is rock-steady over the wire and the CPU cost is negligible
next to the connection's latency. HERMES_DESKTOP_DISABLE_GPU overrides
detection both ways for VNC/screen-sharing setups we can't sniff or
remote hosts that do have working acceleration.
2026-06-03 00:36:59 -05:00
Ben Barclay 9272e4019a fix(docker): point TUI launcher at prebuilt bundle via HERMES_TUI_DIR (#37923)
The embedded dashboard Chat tab dies on hosted images with a 502 /
"[session ended]": the PTY child's `hermes --tui` spawn runs a runtime
`npm install` that fails.

Root cause: the root package-lock.json describes the WHOLE npm monorepo
workspace set (root + web + ui-tui + apps/*), but the image only installs
root/web/ui-tui — apps/* (the desktop app) is never `npm install`ed here, and
its deps hoist into the shared root node_modules. So the actualized
node_modules permanently disagrees with the canonical lock,
`_tui_need_npm_install()` returns True on every launch, and the runtime
`npm install` it triggers (a) can never converge against the partial monorepo
and (b) races itself across concurrent /api/pty connections -> ENOTEMPTY ->
the launcher `sys.exit(1)`s, the slow install blows past Fly's WS-upgrade
window -> 502 -> the browser shows "[session ended]".

Fix: set `ENV HERMES_TUI_DIR=/opt/hermes/ui-tui` so `_make_tui_argv` takes the
prebuilt-bundle fast path (`node --expose-gc /opt/hermes/ui-tui/dist/entry.js`)
and never reaches the install check — exactly the nix/packaged-release path
the launcher was designed for. The bundle is already built at Layer 8
(`ui-tui && npm run build`); this just tells the launcher to use it.

Verified on a freshly-built image: HERMES_TUI_DIR is set, the prebuilt
dist/entry.js is present, `_make_tui_argv` resolves to the prebuilt node
invocation (no npm), and `docker run ... --tui` no longer prints
"npm install failed". New regression guard: tests/docker/test_tui_prebuilt_bundle.py.

A separate launcher hardening (make _tui_need_npm_install tolerant of
partial-monorepo installs) is tracked independently; this Docker-side fix
resolves the hosted-chat symptom on its own.

Area: docker (Dockerfile + tests/docker).
2026-06-03 15:30:45 +10:00
brooklyn! feb50eee70 Merge pull request #37908 from NousResearch/bb/desktop-concurrent-session-loss
fix(desktop): keep in-flight new chats from vanishing on refresh
2026-06-03 00:29:13 -05:00
Brooklyn Nicholson e0a999aa8a fix(desktop): label in-flight new chats with the first message
The send path created the optimistic sidebar row with a null preview, so
a new chat read "Untitled session" until its turn persisted and auto-title
ran. With concurrent new chats now preserved across refreshes, several
"Untitled session" rows could show at once.

Seed the optimistic preview with the user's first message (the branch path
already does this) so each in-flight row is labeled immediately. The
server's own preview/title supersedes it once the turn persists.
2026-06-03 00:25:19 -05:00
Brooklyn Nicholson 55a76ec669 fix(desktop): keep in-flight new chats from vanishing on refresh
Creating several sessions in a row (Ctrl-N, type, send, repeat) and
waiting for one to finish made the other still-running chats disappear
from the sidebar.

Root cause: a new session's first user message isn't flushed to the
SessionDB until its turn is persisted, so the row's message_count stays
0 mid-response. `refreshSessions()` lists with min_messages=1 and then
hard-replaces $sessions. Because every message.complete triggers a
refresh, the moment one session finished, the others (still at
message_count 0) were filtered out of the server page and dropped from
the list.

Fix: merge instead of replace. `mergeWorkingSessions()` preserves any
session that is still in $workingSessionIds but absent from the server
page, so concurrent new chats stay visible until their own turn persists.
Optimistic deletes/archives already remove the row from the previous
list, so a removed session can't be resurrected by the merge.
2026-06-03 00:21:05 -05:00
Ben Barclay d9f7e7ac81 fix(docker): seed gateway_state.json from HERMES_GATEWAY_BOOTSTRAP_STATE on first boot (#37896)
On a fresh volume there is no gateway_state.json, so the boot reconciler
(cont-init.d/02-reconcile-profiles) registers the gateway-default s6 slot
but leaves it down — it only auto-starts when the last recorded state was
"running". A freshly-provisioned container therefore comes up with the
gateway down until something starts it (e.g. the dashboard's start button).

Add a generic, first-boot-only env-seed in stage2-hook.sh (which runs
before 02-reconcile-profiles): when HERMES_GATEWAY_BOOTSTRAP_STATE=running
and no gateway_state.json exists yet, seed {"gateway_state":"running"} so
the reconciler brings the supervised slot up on the very first boot.

This mirrors the existing HERMES_AUTH_JSON_BOOTSTRAP pattern: it seeds the
same state file the reconciler already consults, guarded by [ ! -f ] so
persisted runtime state always wins on later boots (a deliberately-stopped
gateway stays stopped across restarts). Only the literal "running" is
honoured (the sole value in the reconciler's _AUTOSTART_STATES).

Generic container contract — no host-specific code. Useful to any
orchestrator that provisions a blank volume and wants the gateway up from
first boot (the supervised gateway/dashboard already work on such hosts;
only the first-boot autostart was missing because the CLI lifecycle
commands can't drive the s6 layer when container self-detection misses).

Adds a shell-level contract test and documents the env var.
2026-06-03 15:11:15 +10:00
ethernet e618cbee44 feat(desktop): custom zoom shortcuts at half default step
Replace Electron's built-in zoomIn/zoomOut/resetZoom menu roles with
custom implementations that use a 0.1 zoom-level step instead of
Chromium's default 0.2. This makes Ctrl/Cmd + +/-0 zoom feel more
granular and less jumpy.

Also adds installZoomShortcuts() which intercepts the keyboard shortcuts
via before-input-event. This is necessary on Linux/Windows where the
application menu is set to null, so Chromium's default handler would
otherwise apply the full 0.2 step.
2026-06-03 01:07:44 -04:00
brooklyn! 2f0ee66467 Merge pull request #37877 from NousResearch/bb/desktop-sticky-msg-clamp
feat(desktop): clamp sticky human messages to ~2 lines until hover/focus
2026-06-02 23:45:13 -05:00
Brooklyn Nicholson cbc1d901ba chore: uptick 2026-06-02 23:44:51 -05:00
Brooklyn Nicholson 84eb5f1f89 fix(desktop): restore sticky human clamp transition at 0.75s 2026-06-02 23:44:06 -05:00
Brooklyn Nicholson e5472da584 fix(desktop): drop sticky human clamp max-height transition 2026-06-02 23:43:52 -05:00
Brooklyn Nicholson 3ab783a7bb chore: uptick 2026-06-02 23:43:25 -05:00
Brooklyn Nicholson 06aa140fa1 fix(desktop): inset sticky human messages with --sticky-human-top
Pin user bubbles 0.75rem below the scroll top via a single token instead of
flush top-0, so the sticky header doesn't sit hard against the thread edge.
2026-06-02 23:42:38 -05:00
Ben Barclay dd28f2ac9c fix(dashboard): trust non-web WS origins on OAuth-gated binds after ticket auth (#37870)
Generalises #37747. The WS Origin guard (_ws_host_origin_is_allowed) only
trusted the packaged Electron app's non-web origin (file:// / null / app://)
when the bind was NOT OAuth-gated. The packaged Hermes Desktop renderer loads
over file://, so when it drives a remote OAuth-gated gateway its /api/ws
upgrade was rejected with HTTP 403 even though _ws_auth_ok had already
validated the single-use ?ticket= one line earlier.

This guard runs only AFTER _ws_auth_ok has accepted the WS credential, which
is the real auth boundary in every mode:
  * loopback bind          -> legacy dashboard session token
  * non-loopback --insecure -> legacy session token (Tailscale / LAN, #37747)
  * OAuth-gated public bind -> single-use, 30s-TTL, identity-bound ?ticket=
A non-web origin can only come from a native client; a DNS-rebinding attack
always arrives from an http(s) origin and is still match-checked against the
bound host. So once the upstream credential check has passed, the Origin guard
adds nothing for a non-web origin. Collapsed the loopback/non-gated special
cases to 'return True' for non-web origins.

http(s) origins keep the strict same-host check, so browser DNS-rebinding
defence is unchanged.

Tests: gated file:///null/app:// now asserted ALLOWED; cross-site http(s)
still rejected on gated and loopback binds; #37747's loopback and
non-loopback-insecure cases retained. 37/37 test_dashboard_auth_ws_auth +
test_web_server_host_header pass.
2026-06-03 14:32:53 +10:00
Brooklyn Nicholson 9bdf01852a feat(desktop): clamp sticky human messages to ~2 lines until hover/focus
Long user prompts stick to the top of the thread while the response streams
beneath them, so a multi-line prompt could eat most of the viewport. Clamp the
read-only human bubble's text to ~2 lines with a soft bottom fade; the clamp
lifts on hover or keyboard focus, and clicking the bubble still opens the edit
composer (which shows the full text). Short messages are untouched — no clamp,
no fade.

Overflow is measured on an unclamped inner wrapper so the ResizeObserver only
fires on real content/width changes, not every frame while the outer
max-height animates open; the measured height feeds --human-msg-full so
expand/collapse animate to the true height instead of overshooting the cap.
2026-06-02 23:29:05 -05:00
brooklyn! a92cbcac45 Merge pull request #37866 from NousResearch/bb/desktop-scroll-anchor
fix(desktop): stop chat scroll jumping by disabling native scroll anchoring
2026-06-02 23:19:32 -05:00
Brooklyn Nicholson e67ab2e042 fix(desktop): stop chat scroll jumping by disabling native scroll anchoring
The thread renders virtualized turns in natural document flow with padding
spacers, and @tanstack/react-virtual already adjusts scrollTop itself when an
off-screen turn is measured and its real height differs from the 220px
estimate. With the browser default `overflow-anchor: auto`, native scroll
anchoring corrects that SAME size delta too, so the two double-correct and the
view lurches — most visibly with Windows mouse wheels, whose coarse notches
mount/measure several under-estimated turns per tick (Mac trackpads scroll
~1-3px/frame, keeping it sub-perceptual).

Set `overflow-anchor: none` on the thread viewport so only the virtualizer
compensates. Also adds `diag-scroll-reset.mjs`, a CDP wheel-up repro that A/B
tests the anchor behavior at runtime to confirm the fix.
2026-06-02 23:08:01 -05:00
brooklyn! b6da66c5be Merge pull request #37786 from NousResearch/bb/tui-rightclick-and-boundaries
fix(tui): clear selection on right-click copy + clearer block boundaries
2026-06-02 22:43:48 -05:00
Brooklyn Nicholson dfba3f3e51 fix(tui): clear selection on right-click copy + group transcript blocks
Two TUI polish fixes.

(1) Right-click copy now clears the highlight.
The right-click handler copied an active selection via onCopySelectionNoClear
(the copy-on-select variant that keeps the highlight during a drag) and never
cleared it, so after right-click-to-copy the selection stayed lit with no
confirmation and a follow-up right-click re-copied the stale range instead of
pasting. A successful right-click copy now clears the selection and notifies;
if the copy fails (no clipboard path) the highlight survives and we fall back
to the right-click paste handler, exactly as before.

(2) Group transcript blocks so boundaries read clearly.
Model replies, reasoning/tool trails, and system/error notes rendered with no
vertical separation, so distinct block types butted together and were hard to
scan. Group adjacent blocks by kind: one blank line opens only where the visual
group changes (model prose <-> reasoning/tool trails <-> notes), while a run of
same-kind blocks renders flush. The rule lives in domain/blockLayout.ts
(messageGroup + hasLeadGap) and is applied intrinsically in MessageLine via a
`prev` prop, which fixes the things ad-hoc per-block margins kept breaking:

  - Streaming stability: the gap is derived from the stable predecessor, never
    the live block's own changing text, so the actively-streaming reply computes
    the same gap while it streams as the settled segment does once it flushes.
    No reflow/jump.
  - Transparent empty trails: a trail hidden by /details, or one carrying only a
    token tally (the finalDetails segment message.complete appends), renders
    nothing and is transparent to grouping (prevRenderedMsg skips it), so there
    are no floating gaps, no doubled gap after a prompt, and no padded space
    above the final reply. In the default/collapsed modes content-bearing trails
    always render, so the grouping is a no-op there.

The virtual-height estimator counts the group-boundary line so scroll math
stays accurate before Yoga remeasures.

ui-tui/src/domain/blockLayout.ts (new), components/messageLine.tsx,
components/streamingAssistant.tsx, components/appLayout.tsx,
lib/virtualHeights.ts, app/useMainApp.ts.

Tests: blockLayout.test.ts (grouping + hidden/empty-trail visibility),
virtualHeights leadGap, app-mouse.test.ts copy behavior. Full ui-tui suite
green apart from 3 pre-existing local/env failures (cursorDrift, ink-resize,
virtualHeights user-prompt-width) unchanged from main.
2026-06-02 22:03:38 -05:00
Teknium b28dd3417d fix(setup): default browser/TTS picker to free local backend, not paid Nous (#37800)
The Browser Automation and Text-to-Speech provider pickers listed the paid
"Nous Subscription" gateway row first, so on a fresh install the menu cursor
defaulted to index 0 (Nous). Pressing Enter selected it and ran the inline
Nous Portal device-code login — walking users into a paid offering they
never chose.

Reorder both provider lists so the free, no-key local backend is index 0
(Local Browser / Microsoft Edge TTS). Users who already configured Nous are
unaffected: _detect_active_provider_index still resolves their active row
first, so the cursor lands on Nous (now index 1) for them.

Reported by Javier via Kujila.
2026-06-02 19:49:10 -07:00
brooklyn! 918aef267b Merge pull request #37782 from NousResearch/bb/configurable-default-interface
feat(cli): configurable default interface (cli vs tui) + --cli flag
2026-06-02 21:16:19 -05:00
Teknium 205ed71ba0 fix(deps): refresh lockfile to clear 6 npm audit findings (#37752)
* fix(deps): refresh lockfile to clear 6 npm audit findings

Plain `npm audit fix` (no --force, no overrides) — every patched
version was already in-range, so a lockfile refresh clears all
findings without permanent override pins.

Cleared:
- tmp 0.2.5 -> 0.2.7 (path traversal, HIGH — GHSA-ph9p-34f9-6g65)
- brace-expansion 5.0.5 -> 5.0.6 (DoS — GHSA-jxxr-4gwj-5jf2)
- mermaid 11.14.0 -> 11.15.0 (4 advisories: GHSA-6m6c-36f7-fhxh,
  GHSA-xcj9-5m2h-648r, GHSA-87f9-hvmw-gh4p, GHSA-ghcm-xqfw-q4vr)

npm audit: 6 vulnerabilities -> 0. package.json untouched.

* fix(nix): bump npmDepsHash for refreshed lockfile

Uses the hash fetchNpmDeps (the actual build fetcher) produces, which
diverges from prefetch-npm-deps / nix run .#fix-lockfiles output for
this lockfile.
2026-06-02 18:51:23 -07:00
Brooklyn Nicholson d6b0c23f87 feat(cli): configurable default interface (cli vs tui)
Add `display.interface` config key so users can make the modern TUI the
default for bare `hermes` / `hermes chat` without exporting HERMES_TUI=1 in
every shell. Default stays "cli" to preserve current behavior.

Add a `--cli` flag (mirrors `--tui`) so an explicit invocation can force the
classic prompt_toolkit REPL even when `display.interface: tui` is configured.

Precedence (highest first): `--cli` > `--tui`/`HERMES_TUI=1` > config
`display.interface` > classic REPL. Two resolvers enforce it:

  * `_resolve_use_tui(args)` — the args-aware resolver used by `cmd_chat`
    and the Termux fast-TUI path (uses full load_config()).
  * `_wants_tui_early(argv)` — a dependency-free early resolver used by
    mouse-residue suppression and the Termux fast paths, which run before
    argparse / hermes_cli.config are importable (minimal cached YAML read).

Both `--cli` and `--tui` are registered via `_inherited_flag`, so they are
carried across self-relaunch automatically.

- config: add display.interface ("cli" default), bump _config_version 25->26.
  The generic missing-field migration + load_config() deep-merge seed the key
  for existing configs; no bespoke migration block needed.
- docs: document --cli flag and display.interface in cli-commands.md and
  the TUI user guide.
- tests: new test_default_interface_resolution.py covering resolver
  precedence at every layer, early resolver edge cases (missing/garbage
  config), parser flags, and relaunch inheritance.
2026-06-02 20:49:44 -05:00
brooklyn! 7d0246ab57 Merge pull request #37745 from xxxigm/fix/macos-mic-entitlement-inherit
fix(desktop): inherit microphone entitlement for macOS helpers (#37718)
2026-06-02 20:43:05 -05:00
Vinoth ae5b2de2fa fix: expand skill bundles in cron jobs 2026-06-02 18:39:28 -07:00
teknium1 1e047677a5 chore: add leonardsellem to AUTHOR_MAP for PR #37405 2026-06-02 18:29:08 -07:00
Leonard Sellem 6ed9a2de8f fix(dashboard): allow desktop websocket origins on remote binds 2026-06-02 18:29:08 -07:00
brooklyn! 54343bcade Merge pull request #37738 from NousResearch/bb/statusbar-model-menu
feat(desktop): inline model picker in the status bar
2026-06-02 20:00:39 -05:00
Brooklyn Nicholson b6945ce772 fix(desktop): switch model on keyboard activation of picker rows
The model row is a Radix sub-trigger (no onSelect), so switching was
pointer-only. Wire Enter/Space alongside onClick so keyboard users can switch
models too.
2026-06-02 19:50:55 -05:00
brooklyn! 591c329f15 Merge pull request #37739 from NousResearch/bb/desktop-macos-install-forward
fix(desktop): adopt existing macOS install + auto-place app
2026-06-02 19:49:05 -05:00
Brooklyn Nicholson afec339e96 docs(desktop): sync marker schema comment + default dock note arg
Address Copilot review: document the `adopted` flag and nullable `pinnedCommit`
in the marker schema comment, and default `done(note = {})` so the dock-pinned
marker write is unambiguous (object spread of undefined was already a no-op, but
explicit is clearer).
2026-06-02 19:42:59 -05:00
Brooklyn Nicholson d704df2d6e fix(desktop): roll back optimistic model switch on failure
selectModel snapshots the prior model/provider and restores the store +
query cache when the backend switch fails, so the UI never shows a model the
backend didn't actually select.
2026-06-02 19:40:42 -05:00
xxxigm 39933f758b test(desktop): assert macOS device entitlements are inherited
Pin #37718: the inherit plist must grant audio-input, every device.*
entitlement on the main app must also be inherited by the Helper/Setup
processes, and both entitlement files must stay valid plists.
2026-06-03 07:32:00 +07:00
xxxigm 21e172b94a fix(desktop): inherit microphone entitlement for macOS helpers
Add com.apple.security.device.audio-input to entitlements.mac.inherit.plist.
Under hardenedRuntime the Electron Helper/Setup processes inherit this file,
and the missing entitlement made macOS TCC deny the microphone with no prompt,
breaking voice chat.

Fixes #37718
2026-06-03 07:32:00 +07:00
ethernet 46e513ef51 fix(desktop): configure Linux Electron sandbox helper
Electron's chrome-sandbox helper must be root:root 4755 on Linux or the
sandboxed renderer aborts before the desktop app starts. The existing
installer only searched for macOS .app bundles, so a successful Linux
build was reported as missing.

Changes:
- Add _desktop_linux_sandbox_fixup() to hermes_cli/main.py, called
  before launching a packaged desktop app on Linux.
- Use lstat() + S_ISREG check to reject symlinks — chown/chmod on a
  symlink target would set SUID on an arbitrary path.
- Update install.sh to recognize Linux unpacked artifacts and configure
  chrome-sandbox with proper error handling (the original PR silently
  ignored chown/chmod failures).
- Add regression tests: normal fixup flow, symlink rejection, and
  already-configured skip path.

Closes #37529 (rebased, merge conflicts resolved, copilot review
feedback addressed).
2026-06-02 20:30:13 -04:00
Brooklyn Nicholson 1daecfa4b0 fix(desktop): write Dock tile as a file-reference URL
The Dock stores persistent-apps as type-15 file:// URLs; the type-0/raw-path
tile we wrote was silently dropped on the next Dock restart (so the pin never
took, yet we'd stamped the marker and never retried). Use pathToFileURL + type
15 and flush prefs through cfprefsd before `killall Dock`. Verified end-to-end
on a packaged build: move -> adopt -> Dock tile lands as
file:///Applications/Hermes.app/.
2026-06-02 19:30:06 -05:00
ethernet 4a626ed187 fix(tests): add _patch_managed_uv autouse fixture to uv-dependent test files
Production code now uses ensure_uv()/update_managed_uv() from
managed_uv.py instead of shutil.which("uv") directly. Tests that
patched shutil.which to control uv availability no longer controlled
the actual code path, causing CI failures.

Add an autouse _patch_managed_uv fixture to test_update_autostash.py
and test_uv_tool_update.py (matching the existing fixture in
test_cmd_update.py). The fixture makes managed_uv functions delegate
to shutil.which so existing test patches flow through naturally.
2026-06-02 20:29:54 -04:00
ethernet 4df280d511 refactor(uv): single managed-uv path, delete fts5 installer escalation
Replace the multi-path UV resolution chain (PATH probing, conda guards,
5-location trust ordering, temp-dir fallback installs) with a single
managed uv binary at $HERMES_HOME/bin/uv. Every code path that needs
uv resolves it from that one location; if missing, ensure_uv()
bootstraps it via the official standalone installer.

Key changes:

- New hermes_cli/managed_uv.py: managed_uv_path(), resolve_uv(),
  ensure_uv() (returns (path, freshly_bootstrapped) tuple),
  update_managed_uv(), rebuild_venv(), installer internals.
- hermes_cli/main.py: replace all shutil.which('uv') with ensure_uv(),
  add venv rebuild on first-time managed uv bootstrap, update_managed_uv
  before dep install on all 3 update paths.
- scripts/install.sh: install_uv() always installs to
  $HERMES_HOME/bin/uv; delete ensure_fts5, _python_has_fts5,
  _reinstall_python_with_fts5, _warn_no_fts5 (61 lines).
  Managed uv always installs current Python with FTS5.
- scripts/install.ps1: Install-Uv always installs to
  $HermesHome\bin\uv.exe; Resolve-UvCmd checks managed location first.
- hermes_state.py: simplified FTS5 warning now suggests 'hermes update'
  as the fix instead of blaming install method.
- tests: 15 tests in test_managed_uv.py, autouse _patch_managed_uv
  fixture in test_cmd_update.py.

Closes #37605, Closes #37622
2026-06-02 20:29:54 -04:00
ethernet a51a7b9b92 fix(node/nix): consolidate workspace lockfile + update all consumers
Consolidate per-package package-lock.json files into a single root-level
workspace lockfile.  Update all consumers:

- Nix: shared src/npmDeps/npmDepsHash in lib.nix; devshell hook stamps
  package.json paths then runs npm ci from root; individual .nix files
  use mkNpmPassthru attrs instead of per-package fetchNpmDeps.
- Python CLI: new _workspace_root() helper so _tui_need_npm_install,
  _make_tui_argv, _build_web_ui resolve lockfile/node_modules from the
  workspace root.
- Desktop: replace --force-build/mtime heuristic with content-hash build
  stamp (_compute_desktop_content_hash via pathspec).  Remove --force-build
  flag.
- Dockerfile: single root npm install; no per-directory lockfile copies.
- CI: nix-lockfile-fix and osv-scanner reference root package-lock.json;
  apps/dashboard → apps/desktop.
- Tests: new test_tui_npm_install.py; desktop stamp tests in
  test_gui_command.py; updated assertions in test_cmd_update.py,
  test_web_ui_build.py, test_dockerfile_pid1_reaping.py.
- Docs: remove --force-build from desktop flag table.

Deleted: apps/desktop/package-lock.json, ui-tui/package-lock.json,
ui-tui/packages/hermes-ink/package-lock.json, web/package-lock.json.
2026-06-02 20:28:18 -04:00
Brooklyn Nicholson 115671ae6b fix(desktop): address Copilot review on model picker
- selectModel reports success; edits bail (and roll back) instead of landing
  on the previously active model when a switch fails
- Fast toggle stays available to turn off a carried-over speed param even when
  the new model has no native fast mechanism
- active row's "Fast" label derives from the same fastControl as the submenu
  toggle, so it's consistent and handles standalone `-fast` model ids
2026-06-02 19:28:11 -05:00
Fearvox 01eaba7061 polish(gateway): address Copilot review comments on fd-leak fix
Seven Copilot inline review comments on #37679, four worth landing
in a polish pass before merge:

1. _dispose_unused_adapter signature: 'BasePlatformAdapter' ->
   'BasePlatformAdapter | None'. The function explicitly handles
   None and the reconnect watcher calls it with None in the
   except arm, so the annotation now matches the actual contract.

2. (duplicate of #1 on a different line) — same fix.

3. except Exception in _dispose_unused_adapter — the reviewer
   asked about asyncio.CancelledError swallowing. On Python 3.8+
   (Hermes requires 3.13, see pyproject.toml), CancelledError
   inherits from BaseException, NOT Exception, so the existing
   'except Exception' does NOT swallow task cancellation. Added
   an explicit comment explaining the contract so future readers
   don't repeat the analysis. We don't re-raise because the
   watcher loop intentionally treats dispose failures as
   best-effort: a failed dispose on an unowned adapter should not
   take down the watcher that's keeping the gateway alive.

4. _response_store = None after close in api_server.py — the
   reviewer flagged this for idempotency. Decided to keep the
   non-None state intentionally: setting it to None cascades
   to ~9 callers that access self._response_store without a
   None check, and 'close() is idempotent on a closed sqlite3
   Connection' means the current code is already safe. The
   type stays stable; LSP doesn't flag a cascade of
   reportOptionalMemberAccess errors. (This matches the
   pre-existing pattern in the codebase — e.g.
   _mark_disconnected doesn't reset state to None either.)

5. _build_adapter_with_store: reviewer worried about
   disconnect() failing on the self.name property if
   __init__ wasn't called. Already handled: we set
   'adapter.platform = Platform.API_SERVER' so the
   'self.platform.value.title()' property returns
   'Api_Server' without raising. The exception-swallowing
   branch in disconnect() does call self.name via the
   logger.debug format, so this is a real path that needs
   the platform attribute, and we have it.

6. test_disconnect_closes_response_store: bare 'pytest.raises(Exception)'
   -> 'pytest.raises(sqlite3.ProgrammingError)'. The bare
   Exception matcher would silently accept AttributeError,
   OperationalError, env-related issues, etc. The specific
   exception type ('Cannot operate on a closed database') is
   the actual signal we want — proves the SQLite conn is
   closed, not just that *something* raised.

7. test_nonretryable_failure_disposes_unowned_adapter:
   assertion tightened from '>= 1' to '== 1' on
   adapter._disconnect_calls. The docstring said 'exactly once',
   the assertion now matches. Catches the hypothetical
   'watcher disposes the same adapter twice' regression that
   '>=' would have missed.
2026-06-02 17:27:44 -07:00
Fearvox 7982560845 fix(release): add fearvox1015@gmail.com -> Fearvox to AUTHOR_MAP
The check-attribution CI job on #37679 failed because the commit
author email nolan@0xvox.com (a local git config mistake on this
machine) is not in scripts/release.py AUTHOR_MAP. The commit
itself is now re-authored to fearvox1015@gmail.com, and this
follow-up adds the entry to AUTHOR_MAP so any future commits
authored from this email also pass the check.
2026-06-02 17:27:44 -07:00
Fearvox 4b06c98fe4 fix(gateway): close ResponseStore + dispose unowned adapter on reconnect failure
Three separate code paths in the gateway's platform reconnect loop
leaked file descriptors every retry, exhausting the default 2560-fd
ulimit in ~12 hours of continuous failure and turning the gateway
into a zombie that raises OSError: [Errno 24] on every open() (#37011).

Root cause:
  * APIServerAdapter.__init__ opens a ResponseStore SQLite connection
    that holds 2 fds (db file + WAL sidecar).
  * APIServerAdapter.disconnect() previously only stopped the aiohttp
    web server — the ResponseStore connection was never closed.
  * The reconnect watcher in _platform_reconnect_watcher constructs a
    fresh adapter on every retry attempt. When the connect call fails
    (3 paths: non-retryable error, retryable error, exception during
    connect) the adapter is dropped without ever being installed on
    self.adapters, so nothing else calls its disconnect(). Result: the
    2 ResponseStore fds stay open until GC sweeps the unreachable
    object, which Python's cyclic GC does not do promptly for
    asyncio-bound native handles.

  2 fds × 1 retry × (3600s / 300s backoff cap) ≈ 12 fds/hour.
  2560 fds / 12 fds/hr ≈ 12h to ulimit exhaustion.

Fix:

  * APIServerAdapter.disconnect() now also calls
    self._response_store.close() (with a try/except so a SQLite
    close failure doesn't abort the aiohttp teardown).
  * New module-level helper _dispose_unused_adapter(adapter) in
    gateway/run.py that calls adapter.disconnect() and swallows
    any exception (so half-constructed adapters whose __init__
    crashed don't kill the watcher loop).
  * _platform_reconnect_watcher calls _dispose_unused_adapter() in
    all three failure paths: non-retryable, retryable, and the
    except Exception arm. adapter = None is initialized
    before the try so the except arm can see the partial
    construction.

Tests:

  * New file tests/gateway/test_platform_reconnect_fd_leak.py with
    7 regression tests covering all three failure paths, the
    _dispose_unused_adapter helper (None + raising-disconnect cases),
    and the APIServerAdapter ResponseStore close behavior (success +
    close-exception cases). The _CountingAdapter fixture tracks
    disconnect() invocations and an _open_fds counter that is
    decremented on dispose, so the assertion is the literal
    observable behavior of the leak.

Refs:
  - Closes #37011 (the original fd-leak report)
  - Supersedes #37018, #37110, #37238, #37260, #37394 (7 competing
    open PRs all addressing the same root cause from different angles;
    none of them rebased cleanly against current main, and none
    covered all three failure paths in one fix with regression tests
    for both the watcher and the platform-level close behavior)
2026-06-02 17:27:44 -07:00
Teknium ab2472e692 fix(aux): self-heal Nous-routed calls when a pinned model leaves the catalog (#37732)
A long-lived process (gateway, watcher) caches the Nous Portal's
recommended-models payload and can pin a model for its whole lifetime.
When that model is later dropped from the Nous -> OpenRouter catalog,
every auxiliary call 404s with 'model does not exist in our
configuration or OpenRouter catalog' until the process restarts.

Now such a 404 force-refreshes the Portal recommendation and retries
once with the current pick (or the gemini-3-flash-preview default).
Scoped to Nous-routed calls only.

- _is_model_not_found_error(): 404/400 'not found / does not exist /
  not a valid model' predicate, excludes billing keywords so it never
  overlaps _is_payment_error.
- _refresh_nous_recommended_model(): force-refresh fetch, returns a
  model distinct from the one that failed, else the known-good default.
- Wired into both call_llm and async_call_llm error chains.
2026-06-02 17:14:36 -07:00
Brooklyn Nicholson 7466182179 fix(desktop): adopt existing macOS install + auto-place app
First-launch "already installed?" hinged solely on a marker that only the
desktop's own bootstrap writes, so a runtime from `install.sh --include-desktop`
(or a DMG launch over a prior CLI install) was runnable yet markerless and got
the WHOLE installer re-run on top of it. Detect a runnable ACTIVE_HERMES_ROOT
(valid source + venv), adopt it (stamp the marker, recording HEAD), and forward
straight to the app. Repair keeps forcing a real re-bootstrap.

Also: on first packaged macOS launch relocate the bundle into /Applications
(Electron relaunches from there) and pin the canonical copy to the Dock once,
so users stop re-opening the installer from Downloads/the DMG.
2026-06-02 19:11:05 -05:00
Brooklyn Nicholson ea4fe15631 feat(desktop): inline model picker in the status bar
Replace the status-bar model chip's modal with a Cursor-style dropdown:
- providers grouped by name in a stable order (no recency reshuffle on select)
- per-model hover-Edit submenu for reasoning effort + fast, gated by per-model
  capabilities now surfaced in the model.options payload
- unified Fast toggle: flips the speed=fast param where supported, else swaps
  to the model's `-fast` variant (base and variant collapse into one row)
- localStorage-backed "Edit Models" dialog to choose which models appear

Adds reusable dropdown primitives (DropdownMenuSearch, shared row/label
tokens, portaled + collision-aware submenus) and reads session state from
nanostores rather than prop-drilling, so editing options doesn't rebuild and
close the menu.
2026-06-02 19:09:41 -05:00
Teknium bb1c8b6f1a test(honcho): de-flake prewarm smoke test's thread wait (#37614)
TestDialecticLifecycleSmoke._await_thread did a single join(timeout=3.0) and
then proceeded regardless of whether the background dialectic thread had
finished. On a loaded CI runner (6 parallel test slices) the prewarm thread's
completion can slip past that 3s window, so the join times out silently and the
test reads _prefetch_result before the worker wrote it — the intermittent
'session-start prewarm must land in _prefetch_result' failure.

Join in a loop up to a 30s ceiling and assert the thread is actually dead, so a
genuine hang surfaces as a clear failure instead of a timing race. Reproduced
the old failure deterministically (5/5 fails with a 3.5s prewarm delay) and
confirmed the fix (0/8) before/after.
2026-06-02 17:00:04 -07:00
082025abcd fix(gateway): route /background result media by type
Background-task (/background, /btw) result media now routes to the
type-specific sender — TTS clip → voice bubble, video → send_video,
image → send_image_file — instead of forcing everything through
send_document. Mirrors the streaming + kanban delivery paths and
reuses base.should_send_media_as_audio for the Telegram OGG nuance.

Co-authored-by: LJ Li <liliangjya@gmail.com>
Co-authored-by: Kolektori <256073454+Kolektori@users.noreply.github.com>
2026-06-02 16:55:25 -07:00
brooklyn! 30a7a94120 Merge pull request #37697 from NousResearch/bb/grok-provider-desktop
feat(desktop): make xAI Grok a first-class OAuth provider in the launcher
2026-06-02 18:43:31 -05:00
Brooklyn Nicholson 123b945731 Merge remote-tracking branch 'origin/main' into bb/grok-provider-desktop 2026-06-02 18:41:32 -05:00
ethernet cbc82511ea fix(web-server): move event channel state from module globals to app.state (#37683)
Module-level asyncio.Lock() binds to whatever event loop was active at
import time.  When the same web_server module is reused across multiple
TestClient instances (or across uvicorn reloads), the old lock still
references a defunct loop, causing 'attached to a different loop' errors
and flaky subscriber-registration races in CI.

Replace the module-level _event_channels dict + _event_lock with:
  - _lifespan() async context manager that creates both on the running
    event loop during FastAPI startup (guaranteed correct loop binding)
  - _get_event_state() lazy accessor that initialises on app.state when
    TestClient is used without a `with` block (preserves backward compat)

All call sites (_broadcast_event, /api/pub, /api/events) now receive the
app reference and read state via _get_event_state(app) instead of the
module globals.  The test polling loop is updated to check
app.state.event_channels rather than the removed module attribute.
2026-06-02 18:40:12 -05:00
Brooklyn Nicholson a13db76eaa fix(desktop): signal loopback worker to stop on cancel
Shutting down the callback server stopped the serve thread but left the
worker spinning in _xai_wait_for_callback (which polls callback_result)
until the timeout. Flag callback_result as cancelled on DELETE so the
wait returns promptly and the daemon thread exits — avoids thread
buildup on repeated cancel/retry.
2026-06-02 18:28:24 -05:00
Brooklyn Nicholson 33807e2b14 fix(desktop): use auth-store path as xAI OAuth source_label
source_label is meant to be a human-readable origin (file path / source),
not the internal auth_mode string ("oauth_pkce"). Surface the auth-store
path, then the source slug, then a generic label.
2026-06-02 18:21:17 -05:00
ethernet a429a2a0bf ci(nix): fold package+devShell builds into flake check
Add build-package and build-devshell as cross-platform check
derivations so nix flake check verifies the default package and
devShell build on every platform (including darwin, which previously
only did eval-only checks).

This lets us drop the separate nix build step from the CI workflow
and removes the macOS-only eval fallback — a single nix flake check
now covers builds + runtime checks on all runners.
2026-06-02 19:14:18 -04:00
Brooklyn Nicholson d963ad56c1 fix(desktop): address second Copilot pass on xAI loopback flow
- onboarding: openSignInUrl now falls back to window.open when the desktop
  bridge's openExternal throws/rejects (OS handler missing, user denied),
  not just when the bridge is absent
- web_server: cancelling a loopback session shuts down the 127.0.0.1
  callback server + joins its thread immediately, freeing the port instead
  of holding it until the wait times out (+ regression test)
- web_server: document the new "loopback" flow in the /api/providers/oauth
  enum, the poll-endpoint docstring, and the Phase 2 flow comment block
2026-06-02 18:14:00 -05:00
Brooklyn Nicholson 3be9fb7317 fix(desktop): address Copilot review on xAI loopback flow
- web_server: join the callback-server thread in the start error path so a
  failed discovery/URL build doesn't leave a daemon thread running
- web_server: loopback worker now bails if the session was cancelled while
  waiting for the callback or exchanging the code, instead of persisting
  tokens the user no longer wants (+ regression test)
- onboarding: fall back to window.open when the desktop bridge's
  openExternal is unavailable, so the flow never silently stalls
2026-06-02 17:55:22 -05:00
Brooklyn Nicholson 63e824831c fix(desktop): order xAI Grok after MiniMax in the OAuth catalog 2026-06-02 17:36:39 -05:00
Brooklyn Nicholson dd5e97bd7f feat(desktop): make xAI Grok a first-class OAuth provider in the launcher
xAI Grok was only reachable via the "I have an API key" form. xAI's
OAuth (SuperGrok / Premium+) flow already exists in the backend
(`hermes auth add xai-oauth`) but was never surfaced in the desktop
onboarding launcher.

Add a loopback PKCE flow: the local backend binds the 127.0.0.1
callback listener, the client opens the browser, and the redirect lands
back automatically — no code to copy/paste. Reuses the existing xAI
OAuth helpers (discovery, callback server, token exchange, persist)
rather than duplicating them.

- web_server: catalog entry (flow: loopback) + status dispatch +
  _start_xai_loopback_flow + background worker + route branch
- desktop: 'loopback' flow type, awaiting_browser status, xAI Grok card
  (PROVIDER_DISPLAY / FLOW_SUBTITLES / FlowPanel waiting render)
- tests: catalog listing, start authorize-url, worker persist, state
  mismatch rejection
2026-06-02 17:34:00 -05:00
113 changed files with 9721 additions and 37845 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
watch_file pyproject.toml uv.lock
watch_file ui-tui/package-lock.json ui-tui/package.json
watch_file package-lock.json package.json web/package.json ui-tui/package.json website/package.json apps/shared/package.json apps/desktop/package.json ui-tui/packages/hermes-ink/package.json
watch_file flake.nix flake.lock nix/devShell.nix nix/tui.nix nix/package.nix nix/python.nix
use flake
+8 -8
View File
@@ -4,10 +4,10 @@ on:
push:
branches: [main]
paths:
- 'ui-tui/package-lock.json'
- 'package-lock.json'
- 'package.json'
- 'ui-tui/package.json'
- 'apps/dashboard/package-lock.json'
- 'apps/dashboard/package.json'
- 'apps/desktop/package.json'
workflow_dispatch:
inputs:
pr_number:
@@ -27,9 +27,9 @@ concurrency:
jobs:
# ── Auto-fix on main ───────────────────────────────────────────────
# Fires when a push to main touches package.json or package-lock.json
# in ui-tui/ or apps/dashboard/. Runs fix-lockfiles and pushes the hash
# update commit directly to main so Nix builds never stay broken.
# Fires when a push to main touches package.json or package-lock.json.
# Runs fix-lockfiles and pushes the hash update commit directly to main
# so Nix builds never stay broken.
#
# Safety invariants:
# 1. The fix commit only touches nix/*.nix files, which are NOT in
@@ -109,8 +109,8 @@ jobs:
# our computed hashes are stale. Abort and let the next triggered
# run recompute from the correct package-lock state.
pkg_changed="$(git diff --name-only "$BASE_SHA"..origin/main -- \
'ui-tui/package-lock.json' 'ui-tui/package.json' \
'apps/dashboard/package-lock.json' 'apps/dashboard/package.json' || true)"
'package-lock.json' 'package.json' \
'ui-tui/package.json' 'apps/desktop/package.json' || true)"
if [ -n "$pkg_changed" ]; then
echo "::warning::Package files changed since hash computation — aborting; a fresh run will recompute"
exit 0
+8 -20
View File
@@ -37,23 +37,16 @@ jobs:
- name: Check flake
id: flake
if: runner.os == 'Linux'
continue-on-error: true
run: nix flake check --print-build-logs
- name: Build package
id: build
if: runner.os == 'Linux'
continue-on-error: true
run: nix build --print-build-logs
# When the real Nix build fails, run a targeted diagnostic to see if
# When the flake check fails, run a targeted diagnostic to see if
# the failure is specifically a stale npm lockfile hash in one of the
# known npm subpackages (tui / web). This avoids surfacing a generic
# "build failed" message when the fix is a single known command.
- name: Diagnose npm lockfile hashes
id: hash_check
if: (steps.flake.outcome == 'failure' || steps.build.outcome == 'failure') && runner.os == 'Linux'
if: steps.flake.outcome == 'failure' && runner.os == 'Linux'
continue-on-error: true
env:
LINK_SHA: ${{ steps.sha.outputs.full }}
@@ -88,30 +81,25 @@ jobs:
- Or [run the Nix Lockfile Fix workflow](${{ github.server_url }}/${{ github.repository }}/actions/workflows/nix-lockfile-fix.yml) manually (pass PR `#${{ github.event.pull_request.number }}`)
- Or locally: `nix run .#fix-lockfiles` and commit the diff
# Clear the sticky comment when either the build passed outright (no
# Clear the sticky comment when either the flake check passed outright (no
# hash check needed) or the hash check explicitly returned stale=false
# (build failed for a non-hash reason).
# (check failed for a non-hash reason).
- name: Clear sticky PR comment (resolved)
if: |
github.event_name == 'pull_request' &&
runner.os == 'Linux' &&
(steps.hash_check.outputs.stale == 'false' ||
(steps.flake.outcome == 'success' && steps.build.outcome == 'success'))
steps.flake.outcome == 'success')
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
with:
header: nix-lockfile-check
delete: true
- name: Final fail if build or flake failed
if: steps.flake.outcome == 'failure' || steps.build.outcome == 'failure'
- name: Final fail if flake check failed
if: steps.flake.outcome == 'failure'
run: |
if [ "${{ steps.hash_check.outputs.stale }}" == "true" ]; then
echo "::error::Nix build failed due to stale npm lockfile hash. Run: nix run .#fix-lockfiles"
else
echo "::error::Nix build/flake check failed. See logs above."
echo "::error::Nix flake check failed. See logs above."
fi
exit 1
- name: Evaluate flake (macOS)
if: runner.os == 'macOS'
run: nix flake show --json > /dev/null
+1 -3
View File
@@ -28,7 +28,6 @@ on:
- 'package.json'
- 'package-lock.json'
- 'ui-tui/package.json'
- 'ui-tui/package-lock.json'
- 'website/package.json'
- 'website/package-lock.json'
- '.github/workflows/osv-scanner.yml'
@@ -39,7 +38,6 @@ on:
- 'pyproject.toml'
- 'package.json'
- 'package-lock.json'
- 'ui-tui/package-lock.json'
- 'website/package-lock.json'
schedule:
# Weekly scan against main — catches CVEs published after merge for
@@ -62,6 +60,6 @@ jobs:
# the three sources of truth and skip vendored / test / worktree dirs.
scan-args: |-
--lockfile=uv.lock
--lockfile=ui-tui/package-lock.json
--lockfile=package-lock.json
--lockfile=website/package-lock.json
fail-on-vuln: false
+19 -4
View File
@@ -113,8 +113,8 @@ WORKDIR /opt/hermes
# ui-tui/package.json. Copying the tree up front lets npm resolve the
# workspace to real content instead of stopping at a bare package.json.
COPY package.json package-lock.json ./
COPY web/package.json web/package-lock.json web/
COPY ui-tui/package.json ui-tui/package-lock.json ui-tui/
COPY web/package.json web/
COPY ui-tui/package.json ui-tui/
COPY ui-tui/packages/hermes-ink/ ui-tui/packages/hermes-ink/
# `npm_config_install_links=false` forces npm to install `file:` deps as
@@ -131,8 +131,6 @@ ENV npm_config_install_links=false
RUN npm install --prefer-offline --no-audit && \
npx playwright install --with-deps chromium --only-shell && \
(cd web && npm install --prefer-offline --no-audit) && \
(cd ui-tui && npm install --prefer-offline --no-audit) && \
npm cache clean --force
# ---------- Layer-cached Python dependency install ----------
@@ -245,6 +243,23 @@ COPY --chmod=0755 docker/cont-init.d/02-reconcile-profiles /etc/cont-init.d/02-r
# ---------- Runtime ----------
ENV HERMES_WEB_DIST=/opt/hermes/hermes_cli/web_dist
# Point the TUI launcher at the prebuilt bundle baked at build time (Layer 8:
# `ui-tui && npm run build`). This makes _make_tui_argv take the prebuilt-bundle
# fast path (`node --expose-gc /opt/hermes/ui-tui/dist/entry.js`) and skip the
# _tui_need_npm_install / runtime `npm install` branch entirely — exactly the
# nix/packaged-release path the launcher was designed for.
#
# Why this is required (not just an optimization): the root package-lock.json
# describes the WHOLE monorepo workspace set (root + web + ui-tui + apps/*),
# but the image only installs root/web/ui-tui (apps/* — the desktop app — is
# never `npm install`ed here). So the actualized node_modules permanently
# disagrees with the canonical lock, _tui_need_npm_install() returns True on
# every launch, and the runtime `npm install` it triggers (a) can never
# converge against the partial monorepo and (b) races itself across concurrent
# embedded-chat (/api/pty) connections → ENOTEMPTY → the chat tab dies with a
# 502 / "[session ended]". Pointing at the prebuilt bundle sidesteps the whole
# check. (A separate launcher hardening is tracked independently.)
ENV HERMES_TUI_DIR=/opt/hermes/ui-tui
ENV HERMES_HOME=/opt/data
# `docker exec` privilege-drop shim. When operators run
+132
View File
@@ -1621,6 +1621,47 @@ def _try_nous(vision: bool = False) -> Tuple[Optional[OpenAI], Optional[str]]:
)
def _refresh_nous_recommended_model(
*, vision: bool, stale_model: Optional[str]
) -> Optional[str]:
"""Re-fetch the Nous Portal's recommended model after a stale-model 404.
Long-lived processes (gateway, watchers) cache the Portal's
``recommended-models`` payload for 10 minutes and, in practice, can pin a
model for the whole process lifetime. When that model is later dropped from
the Nous → OpenRouter catalog, every auxiliary call 404s with
"model does not exist". This forces a fresh Portal fetch and returns a
model name to retry with:
* the Portal's current recommendation for the task, if it differs from
the model that just failed; otherwise
* ``_NOUS_MODEL`` (google/gemini-3-flash-preview), the known-good default,
if it too differs from the failed model.
Returns ``None`` when no usable alternative is available (e.g. the Portal
still recommends the exact model that just 404'd and the default also
matches it) — callers should then let the original error propagate.
"""
stale = (stale_model or "").strip().lower()
fresh: Optional[str] = None
try:
from hermes_cli.models import get_nous_recommended_aux_model
fresh = get_nous_recommended_aux_model(vision=vision, force_refresh=True)
except Exception as exc:
logger.debug(
"Nous recommended-model refresh failed (%s); using default %s",
exc, _NOUS_MODEL,
)
if fresh and fresh.strip().lower() != stale:
return fresh
# Portal recommendation unchanged or unavailable — fall back to the
# hardcoded known-good default, but only if it's actually different.
if _NOUS_MODEL.strip().lower() != stale:
return _NOUS_MODEL
return None
def _read_main_model() -> str:
"""Read the user's configured main model from config.yaml.
@@ -2451,6 +2492,46 @@ def _is_unsupported_temperature_error(exc: Exception) -> bool:
return _is_unsupported_parameter_error(exc, "temperature")
def _is_model_not_found_error(exc: Exception) -> bool:
"""Detect "the requested model doesn't exist" errors (404 / invalid model).
This fires when a resolved model name is no longer served by the endpoint
— most commonly when a long-lived process pinned a Portal-recommended model
that has since been dropped from the Nous → OpenRouter catalog. The Nous
proxy returns 404 with a body like::
Model 'gpt-5.4-mini' not found. The requested model does not exist
in our configuration or OpenRouter catalog.
Distinct from :func:`_is_payment_error` (which also matches some 404s for
free-tier/credit language) — this one keys on "does not exist / not found /
not a valid model" phrasing, and explicitly excludes the billing keywords
that the payment path already owns so the two predicates don't overlap.
"""
status = getattr(exc, "status_code", None)
err_lower = str(exc).lower()
# Billing/quota 404s belong to _is_payment_error — don't claim them here.
if any(kw in err_lower for kw in (
"credits", "insufficient funds", "billing", "out of funds",
"balance_depleted", "no usable credits", "free tier", "free-tier",
"not available on the free tier",
)):
return False
if status not in {404, 400, None}:
return False
return any(kw in err_lower for kw in (
"model does not exist",
"does not exist in our configuration",
"openrouter catalog",
"is not a valid model",
"no such model",
"model not found",
"the model `", # OpenAI-style: "The model `X` does not exist"
"model_not_found",
"unknown model",
))
def _evict_cached_clients(provider: str) -> None:
"""Drop cached auxiliary clients for a provider so fresh creds are used."""
normalized = _normalize_aux_provider(provider)
@@ -5027,6 +5108,32 @@ def call_llm(
raise
first_err = retry_err
# ── Stale-model self-heal (Nous Portal recommendation drift) ───
# A long-lived process can pin a Portal-recommended model that has
# since been dropped from the Nous → OpenRouter catalog, so every
# auxiliary call 404s with "model does not exist". Force a fresh
# Portal fetch and retry once with the current recommendation (or the
# known-good default). Only applies to Nous-routed calls.
_heal_is_nous = (
resolved_provider == "nous"
or base_url_host_matches(_base_info, "inference-api.nousresearch.com")
)
if _is_model_not_found_error(first_err) and _heal_is_nous:
healed_model = _refresh_nous_recommended_model(
vision=(task == "vision"), stale_model=kwargs.get("model"))
if healed_model and healed_model != kwargs.get("model"):
logger.warning(
"Auxiliary %s: model %r no longer in Nous catalog; "
"retrying with refreshed recommendation %r",
task or "call", kwargs.get("model"), healed_model,
)
kwargs["model"] = healed_model
try:
return _validate_llm_response(
client.chat.completions.create(**kwargs), task)
except Exception as retry_err:
first_err = retry_err
# ── Nous auth refresh parity with main agent ──────────────────
client_is_nous = (
resolved_provider == "nous"
@@ -5464,6 +5571,31 @@ async def async_call_llm(
raise
first_err = retry_err
# ── Stale-model self-heal (Nous Portal recommendation drift) ───
# See the sync call_llm() path for the rationale: a long-lived process
# can pin a Portal-recommended model that has since been dropped from
# the Nous → OpenRouter catalog, 404'ing every auxiliary call. Force a
# fresh Portal fetch and retry once with the current recommendation.
_heal_is_nous = (
resolved_provider == "nous"
or base_url_host_matches(_client_base, "inference-api.nousresearch.com")
)
if _is_model_not_found_error(first_err) and _heal_is_nous:
healed_model = _refresh_nous_recommended_model(
vision=(task == "vision"), stale_model=kwargs.get("model"))
if healed_model and healed_model != kwargs.get("model"):
logger.warning(
"Auxiliary %s (async): model %r no longer in Nous catalog; "
"retrying with refreshed recommendation %r",
task or "call", kwargs.get("model"), healed_model,
)
kwargs["model"] = healed_model
try:
return _validate_llm_response(
await client.chat.completions.create(**kwargs), task)
except Exception as retry_err:
first_err = retry_err
# ── Nous auth refresh parity with main agent ──────────────────
client_is_nous = (
resolved_provider == "nous"
@@ -32,8 +32,58 @@ function bundledRuntimeImportCheck(platform = process.platform) {
return platform === 'win32' ? 'import fastapi, uvicorn, winpty' : 'import fastapi, uvicorn, ptyprocess'
}
const GPU_OVERRIDE_ON = new Set(['1', 'true', 'yes', 'on'])
const GPU_OVERRIDE_OFF = new Set(['0', 'false', 'no', 'off'])
/**
* Decide whether the app is being shown over a remote/forwarded display, where
* Chromium's GPU compositor produces an unstable, flickering surface (it can't
* present accelerated layers cleanly over the wire). Native local Windows/macOS
* sessions composite locally and never hit this, so we only fall back to
* software rendering when a remote display is detected.
*
* Returns a short reason string when GPU acceleration should be disabled, or
* null to keep it enabled. `HERMES_DESKTOP_DISABLE_GPU` overrides detection
* both ways (1/true/yes/on → always disable, 0/false/no/off → never disable).
*
* Pure + dependency-free so it can be unit-tested and called before app ready.
*/
function detectRemoteDisplay(options = {}) {
const env = options.env ?? process.env
const platform = options.platform ?? process.platform
const override = String(env.HERMES_DESKTOP_DISABLE_GPU || '').trim().toLowerCase()
if (GPU_OVERRIDE_ON.has(override)) return 'override (HERMES_DESKTOP_DISABLE_GPU)'
if (GPU_OVERRIDE_OFF.has(override)) return null
// Launched from an SSH session → the display is X11-forwarded or otherwise
// remote. Covers the common `ssh user@box` + GUI-forwarding case.
if (env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY) return 'ssh-session'
if (platform === 'linux') {
// X11 forwarding sets DISPLAY to "<host>:N" (e.g. "localhost:10.0"); a
// local X server is ":0"/":1" with no host part before the colon.
// NB: WSLg deliberately isn't treated as remote — it reports
// GPU-accelerated vGPU surfaces locally and doesn't show the flicker.
const display = String(env.DISPLAY || '')
if (display.includes(':') && display.split(':')[0]) {
return `x11-forwarding (DISPLAY=${display})`
}
}
if (platform === 'win32') {
// RDP sessions report SESSIONNAME like "RDP-Tcp#7"; the local console is
// "Console".
const sessionName = String(env.SESSIONNAME || '')
if (/^rdp-/i.test(sessionName)) return `rdp (SESSIONNAME=${sessionName})`
}
return null
}
module.exports = {
bundledRuntimeImportCheck,
detectRemoteDisplay,
isWindowsBinaryPathInWsl,
isWslEnvironment
}
@@ -3,7 +3,12 @@ const fs = require('node:fs')
const path = require('node:path')
const test = require('node:test')
const { bundledRuntimeImportCheck, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
const {
bundledRuntimeImportCheck,
detectRemoteDisplay,
isWindowsBinaryPathInWsl,
isWslEnvironment
} = require('./bootstrap-platform.cjs')
test('isWslEnvironment detects WSL2 env vars on linux', () => {
assert.equal(isWslEnvironment({ WSL_DISTRO_NAME: 'Ubuntu' }, 'linux'), true)
@@ -28,6 +33,53 @@ test('bundledRuntimeImportCheck selects platform-specific import checks', () =>
assert.equal(bundledRuntimeImportCheck('linux'), 'import fastapi, uvicorn, ptyprocess')
})
test('detectRemoteDisplay keeps GPU on for local sessions', () => {
// Plain local X11, Wayland, native Windows, native macOS — no remote signal.
assert.equal(detectRemoteDisplay({ env: { DISPLAY: ':0' }, platform: 'linux' }), null)
assert.equal(detectRemoteDisplay({ env: { WAYLAND_DISPLAY: 'wayland-0' }, platform: 'linux' }), null)
assert.equal(detectRemoteDisplay({ env: { SESSIONNAME: 'Console' }, platform: 'win32' }), null)
assert.equal(detectRemoteDisplay({ env: {}, platform: 'darwin' }), null)
})
test('detectRemoteDisplay does not treat WSLg as remote', () => {
// WSLg renders locally via vGPU and doesn't show the flicker, so a WSL
// session with a local DISPLAY keeps hardware acceleration on.
assert.equal(detectRemoteDisplay({ env: { WSL_DISTRO_NAME: 'Ubuntu', DISPLAY: ':0' }, platform: 'linux' }), null)
assert.equal(detectRemoteDisplay({ env: { WSL_INTEROP: '/run/WSL/1_interop', DISPLAY: ':0' }, platform: 'linux' }), null)
})
test('detectRemoteDisplay flags SSH sessions on any platform', () => {
assert.equal(detectRemoteDisplay({ env: { SSH_CONNECTION: '1.2.3.4 5 6.7.8.9 22' }, platform: 'linux' }), 'ssh-session')
assert.equal(detectRemoteDisplay({ env: { SSH_CLIENT: '1.2.3.4 5 22' }, platform: 'darwin' }), 'ssh-session')
assert.equal(detectRemoteDisplay({ env: { SSH_TTY: '/dev/pts/0' }, platform: 'win32' }), 'ssh-session')
})
test('detectRemoteDisplay flags forwarded X11 displays but not local ones', () => {
assert.match(String(detectRemoteDisplay({ env: { DISPLAY: 'localhost:10.0' }, platform: 'linux' })), /x11-forwarding/)
assert.match(String(detectRemoteDisplay({ env: { DISPLAY: '192.168.1.5:0' }, platform: 'linux' })), /x11-forwarding/)
assert.equal(detectRemoteDisplay({ env: { DISPLAY: ':1' }, platform: 'linux' }), null)
})
test('detectRemoteDisplay flags RDP sessions', () => {
assert.match(String(detectRemoteDisplay({ env: { SESSIONNAME: 'RDP-Tcp#7' }, platform: 'win32' })), /^rdp/)
})
test('detectRemoteDisplay honors the HERMES_DESKTOP_DISABLE_GPU override both ways', () => {
// Force-on even on a local display.
assert.match(
String(detectRemoteDisplay({ env: { HERMES_DESKTOP_DISABLE_GPU: '1', DISPLAY: ':0' }, platform: 'linux' })),
/override/
)
// Force-off even over SSH (escape hatch when a remote display has working accel).
assert.equal(
detectRemoteDisplay({
env: { HERMES_DESKTOP_DISABLE_GPU: 'false', SSH_CONNECTION: '1.2.3.4 5 6.7.8.9 22' },
platform: 'linux'
}),
null
)
})
test('packaged electron entrypoints do not require unpackaged npm modules', () => {
const electronDir = __dirname
const entrypoints = ['main.cjs', 'preload.cjs', 'bootstrap-platform.cjs']
@@ -8,5 +8,7 @@
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>
+231 -7
View File
@@ -23,7 +23,7 @@ const net = require('node:net')
const path = require('node:path')
const { fileURLToPath, pathToFileURL } = require('node:url')
const { execFileSync, spawn } = require('node:child_process')
const { isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
const { runBootstrap } = require('./bootstrap-runner.cjs')
const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
const {
@@ -73,6 +73,26 @@ const IS_MAC = process.platform === 'darwin'
const IS_WINDOWS = process.platform === 'win32'
const IS_WSL = isWslEnvironment()
const APP_ROOT = app.getAppPath()
// Remote displays (SSH X11 forwarding, VNC, RDP) make Chromium's GPU
// compositor flicker — accelerated layers can't be presented cleanly over the
// wire, so the window flashes during scroll/streaming/animation. Local
// Windows/macOS (and WSLg, which renders locally via vGPU) composite on the
// GPU and never see it. Fall back to software rendering when a remote display
// is detected; it's rock-steady over the wire and the CPU cost is negligible
// next to the connection's latency. Must run before app `ready` — these
// switches only apply pre-launch. Override with HERMES_DESKTOP_DISABLE_GPU
// (1/true → always disable, 0/false → keep GPU on).
const REMOTE_DISPLAY_REASON = detectRemoteDisplay()
if (REMOTE_DISPLAY_REASON) {
app.disableHardwareAcceleration()
// Belt-and-suspenders for X11/VNC, where the Viz compositor can still glitch
// with only --disable-gpu: force compositing onto the CPU too.
app.commandLine.appendSwitch('disable-gpu-compositing')
console.log(
`[hermes] remote display detected (${REMOTE_DISPLAY_REASON}); disabling GPU hardware acceleration to prevent flicker`
)
}
const SOURCE_REPO_ROOT = path.resolve(APP_ROOT, '../..')
// Build-time install stamp -- the git ref this .exe was built against.
@@ -445,6 +465,10 @@ let bootstrapFailure = null
// Active first-launch install, so the renderer's Cancel button (and app quit)
// can abort the in-flight install.sh/ps1 instead of leaving it running.
let bootstrapAbortController = null
// Set by the renderer's "Repair install" IPC. While true, resolution skips the
// existing-install adopt branch (3b) so repair re-drives the installer instead
// of re-adopting the install we're repairing. Cleared once a bootstrap runs.
let forceBootstrapRepair = false
let connectionConfigCache = null
const hermesLog = []
const previewWatchers = new Map()
@@ -1506,8 +1530,12 @@ function readJson(filePath) {
// Marker schema (version 1):
// {
// schemaVersion: 1,
// pinnedCommit: "<40-char SHA>", // what install.ps1 was driven against
// pinnedCommit: "<40-char SHA>" | null, // what install.ps1 was driven against;
// // may be null for adopted installs
// pinnedBranch: "<branch name>" | null,
// adopted: <bool>, // true when we adopted a pre-existing
// // install rather than bootstrapping it;
// // treated as authoritative even sans commit
// completedAt: "<ISO 8601>",
// desktopVersion: "<app.getVersion()>" // for forensics
// }
@@ -1515,11 +1543,25 @@ function readBootstrapMarker() {
return readJson(BOOTSTRAP_COMPLETE_MARKER)
}
// Marker-independent: is the canonical install at ACTIVE_HERMES_ROOT actually
// runnable right now? A complete CLI install (`install.sh --include-desktop`)
// or a DMG launch over a prior CLI install satisfies this WITHOUT the desktop
// ever having written the bootstrap marker -- so we must be able to recognise
// "already installed" off the filesystem alone, not just the marker.
function isActiveRuntimeUsable() {
return isHermesSourceRoot(ACTIVE_HERMES_ROOT) && fileExists(getVenvPython(VENV_ROOT))
}
function isBootstrapComplete() {
const marker = readBootstrapMarker()
if (!marker || typeof marker !== 'object') return false
if (marker.schemaVersion !== BOOTSTRAP_MARKER_SCHEMA_VERSION) return false
if (typeof marker.pinnedCommit !== 'string' || marker.pinnedCommit.length < 7) return false
if (typeof marker.pinnedCommit !== 'string' || marker.pinnedCommit.length < 7) {
// Adopted markers (an existing install we detected and took ownership of,
// possibly without a resolvable commit) are still authoritative -- they
// attest a runnable install we deliberately decided to forward to.
if (marker.adopted !== true) return false
}
// We DELIBERATELY do NOT verify that the checkout is currently at the
// pinned commit -- users update via the in-app update path or `hermes
// update`, which moves HEAD legitimately. The marker just attests "we
@@ -1527,7 +1569,22 @@ function isBootstrapComplete() {
// a runnable venv: an interrupted or split-home install can leave the marker
// + checkout without a venv, and trusting that spawns a dead backend
// ("gateway offline") instead of re-running bootstrap to repair it.
return isHermesSourceRoot(ACTIVE_HERMES_ROOT) && fileExists(getVenvPython(VENV_ROOT))
return isActiveRuntimeUsable()
}
// HEAD commit of ACTIVE_HERMES_ROOT so an adopted marker carries the same
// provenance a freshly-bootstrapped one would. null when git is unavailable or
// the root isn't a checkout -- the marker stays valid via its `adopted` flag.
function readActiveHeadCommit() {
try {
const sha = execFileSync(resolveGitBinary(), ['-C', ACTIVE_HERMES_ROOT, 'rev-parse', 'HEAD'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore']
}).trim()
return /^[0-9a-f]{7,40}$/i.test(sha) ? sha : null
} catch {
return null
}
}
function writeBootstrapMarker(payload) {
@@ -1536,6 +1593,7 @@ function writeBootstrapMarker(payload) {
schemaVersion: BOOTSTRAP_MARKER_SCHEMA_VERSION,
pinnedCommit: payload.pinnedCommit || null,
pinnedBranch: payload.pinnedBranch || null,
adopted: Boolean(payload.adopted),
completedAt: new Date().toISOString(),
desktopVersion: app.getVersion()
}
@@ -1693,6 +1751,24 @@ function resolveHermesBackend(dashboardArgs) {
return createActiveBackend(dashboardArgs)
}
// 3b. Existing-but-unmarked install at ACTIVE_HERMES_ROOT. The marker is
// written only by OUR bootstrap, so a runtime from `install.sh
// --include-desktop` (or a DMG launch over a prior CLI install) is
// runnable yet markerless -- without this we'd fall to step 6 and re-run
// the WHOLE install on top of a working one. ACTIVE_HERMES_ROOT is our
// canonical location (unlike a random `hermes` on PATH), so adopt it:
// stamp the marker once and forward straight to the app. Repair skips
// this so a broken-but-present venv still gets rebuilt.
if (!forceBootstrapRepair && isActiveRuntimeUsable()) {
rememberLog(`[bootstrap] adopting existing install at ${ACTIVE_HERMES_ROOT}; skipping first-launch setup`)
try {
writeBootstrapMarker({ pinnedCommit: readActiveHeadCommit(), pinnedBranch: null, adopted: true })
} catch (err) {
rememberLog(`[bootstrap] could not stamp adopted marker: ${err.message}`)
}
return createActiveBackend(dashboardArgs)
}
// 4. Existing `hermes` on PATH -- installed via install.ps1 / install.sh from
// a previous tool-only setup, or pip-installed system-wide. Use it but
// do NOT write a bootstrap marker; the user did this themselves and we
@@ -1883,6 +1959,9 @@ async function ensureRuntime(backend) {
}
rememberLog('[bootstrap] bootstrap complete; marker written. Re-resolving backend.')
// A repair (if any) has now re-run, so clear the gate -- the re-resolution
// below SHOULD land on the fresh marker fast-path rather than skip it.
forceBootstrapRepair = false
// Re-resolve now that the install exists. The new resolution lands in
// step 3 (bootstrap-complete marker) and we recurse to wire venvPython.
return ensureRuntime(resolveHermesBackend(backend.args))
@@ -2672,9 +2751,31 @@ function buildApplicationMenu() {
{ role: 'forceReload' },
{ role: 'toggleDevTools' },
{ type: 'separator' },
{ role: 'resetZoom' },
{ role: 'zoomIn' },
{ role: 'zoomOut' },
{
label: 'Actual Size',
accelerator: 'CommandOrControl+0',
click: () => { if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.setZoomLevel(0) }
},
{
label: 'Zoom In',
accelerator: 'CommandOrControl+Plus',
click: () => {
if (mainWindow && !mainWindow.isDestroyed()) {
const next = Math.min(mainWindow.webContents.getZoomLevel() + 0.1, 9)
mainWindow.webContents.setZoomLevel(next)
}
}
},
{
label: 'Zoom Out',
accelerator: 'CommandOrControl+-',
click: () => {
if (mainWindow && !mainWindow.isDestroyed()) {
const next = Math.max(mainWindow.webContents.getZoomLevel() - 0.1, -9)
mainWindow.webContents.setZoomLevel(next)
}
}
},
{ type: 'separator' },
{ role: 'togglefullscreen' }
]
@@ -2733,6 +2834,32 @@ function installPreviewShortcut(window) {
})
}
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),
// but on Linux/Windows the menu is null and Chromium's default handler
// would use the full 0.2 step, so we intercept here for consistency.
const ZOOM_STEP = 0.1
window.webContents.on('before-input-event', (event, input) => {
const mod = IS_MAC ? input.meta : input.control
if (!mod || input.alt || input.shift) return
const key = input.key
if (key === '0') {
event.preventDefault()
window.webContents.setZoomLevel(0)
} else if (key === '=' || key === '+') {
event.preventDefault()
const next = Math.min(window.webContents.getZoomLevel() + ZOOM_STEP, 9)
window.webContents.setZoomLevel(next)
} else if (key === '-') {
event.preventDefault()
const next = Math.max(window.webContents.getZoomLevel() - ZOOM_STEP, -9)
window.webContents.setZoomLevel(next)
}
})
}
function installContextMenu(window) {
window.webContents.on('context-menu', (_event, params) => {
const template = []
@@ -3319,6 +3446,7 @@ function createWindow() {
installPreviewShortcut(mainWindow)
installDevToolsShortcut(mainWindow)
installZoomShortcuts(mainWindow)
installContextMenu(mainWindow)
mainWindow.webContents.setWindowOpenHandler(details => {
openExternalUrl(details.url)
@@ -3399,6 +3527,7 @@ ipcMain.handle('hermes:bootstrap:reset', async () => {
// full backend flow (including a fresh runBootstrap pass).
rememberLog('[bootstrap] reset requested by renderer; clearing latched failure')
bootstrapFailure = null
forceBootstrapRepair = false
connectionPromise = null
bootstrapState = {
active: false,
@@ -3426,6 +3555,9 @@ ipcMain.handle('hermes:bootstrap:repair', async () => {
rememberLog(`[bootstrap] failed to remove marker during repair: ${error.message}`)
}
bootstrapFailure = null
// Force the next resolution past both the marker fast-path and the adopt
// branch so the installer actually re-runs (the whole point of repair).
forceBootstrapRepair = true
resetHermesConnection()
return { ok: true }
})
@@ -3941,7 +4073,99 @@ ipcMain.handle('hermes:version', async () => ({
hermesRoot: resolveUpdateRoot()
}))
// ---------------------------------------------------------------------------
// macOS first-launch placement: move into /Applications and pin to the Dock
// ---------------------------------------------------------------------------
//
// The DMG and CLI-built apps launch from wherever the user left them (a DMG
// mount, ~/Downloads, ~/.hermes/...) -- which means Gatekeeper translocation,
// no Dock tile, and "which icon do I click?" confusion. On first packaged
// launch we relocate into /Applications (Electron relaunches from there) and,
// once we're that canonical copy, pin to the Dock. Both macOS-only,
// packaged-only, best-effort, run at most once.
// Move the bundle into /Applications and relaunch. Returns true when a relaunch
// is underway (caller must stop init). No-op in dev, off macOS, or already in
// /Applications. `existsAndRunning` -> another copy owns the slot; don't fight
// it. `exists` -> stale copy; replace it so there's exactly one current app.
function maybeRelocateToApplications() {
if (!IS_MAC || !IS_PACKAGED || process.env.HERMES_DESKTOP_NO_AUTO_MOVE === '1') return false
try {
if (app.isInApplicationsFolder()) return false
const moved = app.moveToApplicationsFolder({ conflictHandler: type => type !== 'existsAndRunning' })
if (moved) rememberLog('[install] relocated into /Applications; relaunching')
return moved
} catch (err) {
rememberLog(`[install] move to /Applications skipped: ${err.message}`)
return false
}
}
const DOCK_PINNED_MARKER = 'dock-pinned.json'
// Pin the /Applications copy to the Dock once. macOS has no Electron API for
// this, so we append to com.apple.dock's persistent-apps and restart the Dock.
// Guarded by a userData marker + membership check so we never duplicate the tile.
function maybePinToDock() {
if (!IS_MAC || !IS_PACKAGED || process.env.HERMES_DESKTOP_NO_DOCK_PIN === '1') return
const marker = path.join(app.getPath('userData'), DOCK_PINNED_MARKER)
if (fileExists(marker)) return
let bundle
try {
if (!app.isInApplicationsFolder()) return // don't pin a soon-to-be-stale path
bundle = runningAppBundle()
} catch {
return
}
if (!bundle) return
// The Dock stores tiles as file-reference URLs (type 15), e.g.
// file:///Applications/Hermes.app/ -- NOT a raw POSIX path. A type-0/raw-path
// tile is silently dropped when the Dock rewrites persistent-apps on restart.
const url = pathToFileURL(bundle.endsWith('/') ? bundle : `${bundle}/`).href
const done = (note = {}) => {
try {
fs.writeFileSync(marker, JSON.stringify({ bundle, pinnedAt: new Date().toISOString(), ...note }) + '\n')
} catch {
// best-effort; we re-check next launch (membership guard dedupes)
}
}
try {
const apps = execFileSync('defaults', ['read', 'com.apple.dock', 'persistent-apps'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore']
})
if (apps.includes(url)) return done({ alreadyPresent: true })
} catch {
// persistent-apps may not exist yet; -array-add creates it
}
const tile =
'<dict><key>tile-data</key><dict><key>file-data</key><dict>' +
`<key>_CFURLString</key><string>${url}</string><key>_CFURLStringType</key><integer>15</integer>` +
'</dict></dict></dict>'
try {
execFileSync('defaults', ['write', 'com.apple.dock', 'persistent-apps', '-array-add', tile], { stdio: 'ignore' })
// Flush the write through cfprefsd before restarting the Dock, otherwise the
// Dock reloads stale prefs and our tile is lost in the race.
execFileSync('defaults', ['read', 'com.apple.dock', 'persistent-apps'], { stdio: 'ignore' })
execFileSync('killall', ['Dock'], { stdio: 'ignore' })
done()
rememberLog(`[install] pinned to Dock: ${url}`)
} catch (err) {
rememberLog(`[install] Dock pin skipped: ${err.message}`)
}
}
app.whenReady().then(() => {
// macOS: relocate into /Applications before anything else so setup + state
// land in the final location; on success this relaunches, so bail here.
if (maybeRelocateToApplications()) return
maybePinToDock()
if (IS_MAC) {
Menu.setApplicationMenu(buildApplicationMenu())
} else {
-18363
View File
File diff suppressed because it is too large Load Diff
+229
View File
@@ -0,0 +1,229 @@
// Reproduce + diagnose the "scroll wheel resets position while reading" bug.
//
// The complaint (Windows, mouse wheel): scrolling UP through a chat to re-read
// older content randomly yanks the view to a different position, so you have to
// fight the scrollbar. Mac users on trackpads don't see it.
//
// Hypothesis: the thread scroller has the browser default `overflow-anchor:
// auto`, and the thread renders items in natural document flow (padding
// spacers, NOT transforms). When an item above the viewport is measured by
// @tanstack/react-virtual (its real height differs a lot from the 220px
// estimate) — or when Shiki/images/fonts reflow it — TWO mechanisms both
// adjust scrollTop for the same delta: TanStack's measurement compensation AND
// the browser's native scroll anchoring. The double-correction lurches the
// view. A mouse wheel's coarse, discrete notches mount/measure several
// under-estimated turns per tick, so the over-correction is large and visible;
// a trackpad's ~1-3px/frame keeps it sub-perceptual.
//
// This script drives synthetic mouse-wheel-UP scrolling on a long thread and
// measures how much a tracked on-screen turn jumps, first with
// `overflow-anchor: auto` (reproduce) then `overflow-anchor: none` (the fix).
// If the fix run shows dramatically fewer/smaller jumps, the hypothesis holds.
//
// Prereq: a running desktop app with remote debugging on 9222, on a thread
// with enough history to scroll (the longer / more code+tool blocks, the
// better the repro). Then: node apps/desktop/scripts/diag-scroll-reset.mjs
const NOTCHES = 14 // wheel-up ticks per sweep
const NOTCH_PX = 120 // Windows wheel notch ≈ 120px
const NOTCH_GAP_MS = 130 // let each smooth-scroll animation settle
const REVERSE_JUMP_PX = 6 // tracked turn moving UP while scrolling up = wrong way
const LURCH_PX = 60 // single-frame on-screen jump that reads as a "reset"
const list = await (await fetch('http://127.0.0.1:9222/json/list')).json()
const tgt = list.find(t => t.type === 'page' && t.url.startsWith('http'))
if (!tgt) {
console.error('No page target on :9222. Is the desktop app running with --remote-debugging-port=9222?')
process.exit(1)
}
const ws = new WebSocket(tgt.webSocketDebuggerUrl)
let id = 0
const pending = new Map()
ws.addEventListener('message', ev => {
const m = JSON.parse(ev.data)
if (m.id != null && pending.has(m.id)) {
pending.get(m.id)(m)
pending.delete(m.id)
}
})
await new Promise(r => ws.addEventListener('open', r))
const send = (m, p = {}) =>
new Promise(r => {
const i = ++id
pending.set(i, r)
ws.send(JSON.stringify({ id: i, method: m, params: p }))
})
const evalP = async expr => {
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true })
if (r.result?.exceptionDetails) throw new Error(r.result.exceptionDetails.text)
return r.result.result.value
}
const sleep = ms => new Promise(r => setTimeout(r, ms))
// Install per-sweep instrumentation. `mode` is the overflow-anchor value to
// force inline so we A/B the exact same thread regardless of any CSS fix.
// Starts from ~45% down the thread so there's room to scroll up into
// not-yet-measured turns, tags the turn nearest viewport-center as the anchor,
// then records (per rAF) scrollTop + that turn's on-screen top, plus every
// scrollTop *setter* write (TanStack compensation) and ResizeObserver hit.
async function arm(mode) {
await evalP(`(() => {
const v = document.querySelector('[data-slot="aui_thread-viewport"]')
if (!v) throw new Error('thread viewport not found')
// Force the overflow-anchor behavior under test (inline beats CSS).
v.style.overflowAnchor = ${JSON.stringify(mode)}
// Park ~45% down so a wheel-up sweep climbs into estimated-but-unmeasured
// turns above the fold (where the measurement correction fires).
v.scrollTop = Math.round(v.scrollHeight * 0.45)
// Tag the turn closest to viewport center; we track its on-screen top.
const vr = v.getBoundingClientRect()
const center = vr.top + v.clientHeight / 2
let best = null, bestD = Infinity
for (const el of v.querySelectorAll('[data-index]')) {
const r = el.getBoundingClientRect()
const d = Math.abs((r.top + r.height / 2) - center)
if (d < bestD) { bestD = d; best = el }
}
document.querySelectorAll('[data-se-anchor]').forEach(e => e.removeAttribute('data-se-anchor'))
if (best) best.setAttribute('data-se-anchor', '1')
const anchorIndex = best ? best.getAttribute('data-index') : null
const samples = []
const writes = []
const ros = []
const t0 = performance.now()
// Intercept scrollTop writes → these are JS (TanStack) corrections.
// Native browser scroll anchoring does NOT go through this setter, so a
// scrollTop change with no write in the same frame is a native adjust.
const desc = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollTop')
Object.defineProperty(v, 'scrollTop', {
configurable: true,
get() { return desc.get.call(this) },
set(val) {
writes.push({ t: performance.now() - t0, val, sh: this.scrollHeight })
desc.set.call(this, val)
}
})
window.__restoreScrollTop = () => Object.defineProperty(v, 'scrollTop', desc)
const ro = new ResizeObserver(entries => {
for (const e of entries) {
ros.push({ t: performance.now() - t0, slot: e.target.getAttribute?.('data-slot') || e.target.tagName, h: Math.round(e.contentRect.height) })
}
})
ro.observe(v)
if (v.firstElementChild) ro.observe(v.firstElementChild)
let running = true
const tick = () => {
if (!running) return
const a = v.querySelector('[data-se-anchor]')
const ar = a ? a.getBoundingClientRect() : null
samples.push({
t: performance.now() - t0,
st: Math.round(v.scrollTop * 100) / 100,
sh: v.scrollHeight,
ch: v.clientHeight,
atop: ar ? Math.round(ar.top * 100) / 100 : null,
aconn: !!a
})
requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
window.__se = { samples, writes, ros, anchorIndex, dpr: window.devicePixelRatio, stop() { running = false; ro.disconnect(); window.__restoreScrollTop?.() } }
return true
})()`)
}
async function wheelUpSweep() {
const { x, y } = await evalP(`(() => {
const v = document.querySelector('[data-slot="aui_thread-viewport"]')
const r = v.getBoundingClientRect()
return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) }
})()`)
for (let i = 0; i < NOTCHES; i++) {
await send('Input.dispatchMouseEvent', { type: 'mouseWheel', x, y, deltaX: 0, deltaY: -NOTCH_PX })
await sleep(NOTCH_GAP_MS)
}
await sleep(400)
}
async function collect() {
const data = JSON.parse(await evalP(`(() => { window.__se.stop(); return JSON.stringify(window.__se) })()`))
return data
}
function analyze(label, data) {
const { samples, writes, ros, anchorIndex, dpr } = data
let reverseJumps = 0
let reverseSum = 0
let lurches = 0
let maxJump = 0
let nativeMoves = 0
let prev = null
for (const s of samples) {
if (prev && prev.aconn && s.aconn && prev.atop != null && s.atop != null) {
const dTop = s.atop - prev.atop // wheel-up should move content DOWN → dTop >= 0
const dSt = s.st - prev.st
// Native (browser-anchoring) move: scrollTop changed with no setter write in this frame window.
const wroteThisFrame = writes.some(w => w.t > prev.t && w.t <= s.t)
if (Math.abs(dSt) > 0.5 && !wroteThisFrame) nativeMoves++
if (dTop < -REVERSE_JUMP_PX) {
reverseJumps++
reverseSum += -dTop
}
if (Math.abs(dTop) > LURCH_PX) lurches++
if (Math.abs(dTop) > maxJump) maxJump = Math.abs(dTop)
}
prev = s
}
console.log(`\n── ${label} ──`)
console.log(` devicePixelRatio: ${dpr}${Number.isInteger(dpr) ? '' : ' (fractional — Windows scaling, worsens rounding jitter)'}`)
console.log(` tracked turn index: ${anchorIndex}`)
console.log(` rAF frames: ${samples.length}`)
console.log(` scrollTop writes: ${writes.length} (TanStack measurement corrections)`)
console.log(` ResizeObserver hits: ${ros.length}`)
console.log(` native scroll moves: ${nativeMoves} (scrollTop moved with NO JS write = browser anchoring)`)
console.log(` reverse jumps: ${reverseJumps} (tracked turn yanked UP while scrolling up; total ${reverseSum.toFixed(0)}px)`)
console.log(` big lurches (>${LURCH_PX}px): ${lurches}`)
console.log(` max single-frame jump: ${maxJump.toFixed(0)}px`)
return { reverseJumps, reverseSum, lurches, maxJump, nativeMoves }
}
console.log(`Wheel-up repro: ${NOTCHES} notches × ${NOTCH_PX}px, anchored mid-thread.\n`)
await arm('auto')
await sleep(150)
await wheelUpSweep()
const a = analyze('overflow-anchor: auto (current / repro)', await collect())
await sleep(300)
await arm('none')
await sleep(150)
await wheelUpSweep()
const b = analyze('overflow-anchor: none (proposed fix)', await collect())
// Clean up our tag.
await evalP(`document.querySelectorAll('[data-se-anchor]').forEach(e => e.removeAttribute('data-se-anchor'))`)
console.log('\n══ verdict ══')
const drop = (x, y) => (x === 0 ? (y === 0 ? '0' : 'n/a') : `${Math.round((1 - y / x) * 100)}% fewer`)
console.log(` reverse jumps: auto=${a.reverseJumps} none=${b.reverseJumps} (${drop(a.reverseJumps, b.reverseJumps)})`)
console.log(` big lurches: auto=${a.lurches} none=${b.lurches} (${drop(a.lurches, b.lurches)})`)
console.log(` max jump: auto=${a.maxJump.toFixed(0)}px none=${b.maxJump.toFixed(0)}px`)
console.log(` native moves: auto=${a.nativeMoves} none=${b.nativeMoves} (browser anchoring should ~vanish at none)`)
if (a.reverseJumps + a.lurches > 0 && b.reverseJumps + b.lurches < a.reverseJumps + a.lurches) {
console.log('\n → Jumps drop sharply with overflow-anchor:none → root cause confirmed.')
} else if (a.reverseJumps + a.lurches === 0) {
console.log('\n → No jumps captured this run. Use a longer thread (many code/tool blocks),')
console.log(' raise NOTCHES, and ensure you start scrolled up from the bottom.')
}
ws.close()
+74 -16
View File
@@ -31,6 +31,7 @@ import {
enqueueQueuedPrompt,
type QueuedPromptEntry,
removeQueuedPrompt,
shouldAutoDrainOnSettle,
updateQueuedPrompt
} from '@/store/composer-queue'
import { $messages } from '@/store/session'
@@ -124,6 +125,12 @@ export function ChatBar({
const draftRef = useRef(draft)
const previousBusyRef = useRef(busy)
const drainingQueueRef = useRef(false)
// Set when the user explicitly interrupts the running turn via the Stop
// button (busy + empty composer). It suppresses the next busy→false
// auto-drain so an explicit Stop actually halts instead of immediately
// firing the head of the queue. The queue is preserved; the user resumes
// it deliberately via Cmd/Ctrl+K, Enter, or the per-row "send now" arrow.
const userInterruptedRef = useRef(false)
const urlInputRef = useRef<HTMLInputElement | null>(null)
const [urlOpen, setUrlOpen] = useState(false)
@@ -414,6 +421,14 @@ export function ChatBar({
const [trigger, setTrigger] = useState<TriggerState | null>(null)
const [triggerActive, setTriggerActive] = useState(0)
const [triggerItems, setTriggerItems] = useState<readonly Unstable_TriggerItem[]>([])
// Set synchronously in keydown when the open trigger popover consumes a
// navigation/control key (Arrow/Enter/Tab/Escape). The subsequent keyup must
// NOT run refreshTrigger for that keypress: it never edits text, and for
// Escape the keydown has already set trigger=null, so a keyup refresh would
// re-detect the still-present `/` and instantly reopen the menu. A ref is
// used instead of reading `trigger` in keyup because by keyup time React has
// re-rendered and the handler closure sees the post-keydown state.
const triggerKeyConsumedRef = useRef(false)
const refreshTrigger = useCallback(() => {
const editor = editorRef.current
@@ -442,7 +457,14 @@ export function ChatBar({
const detected = detectTrigger(before ?? composerPlainText(editor))
setTrigger(detected)
setTriggerActive(0)
// Only reset the highlight when the trigger actually changed (opened, or
// the query/kind differs). Re-detecting the *same* trigger — e.g. on a
// caret move (mouseup) or a stray refresh — must preserve the user's
// current selection instead of snapping back to the first item.
if (detected?.kind !== trigger?.kind || detected?.query !== trigger?.query) {
setTriggerActive(0)
}
}, [trigger])
const handleEditorInput = (event: FormEvent<HTMLDivElement>) => {
@@ -558,6 +580,7 @@ export function ChatBar({
if (trigger && triggerItems.length > 0) {
if (event.key === 'ArrowDown') {
event.preventDefault()
triggerKeyConsumedRef.current = true
setTriggerActive(idx => (idx + 1) % triggerItems.length)
return
@@ -565,6 +588,7 @@ export function ChatBar({
if (event.key === 'ArrowUp') {
event.preventDefault()
triggerKeyConsumedRef.current = true
setTriggerActive(idx => (idx - 1 + triggerItems.length) % triggerItems.length)
return
@@ -572,6 +596,7 @@ export function ChatBar({
if (event.key === 'Enter' || event.key === 'Tab') {
event.preventDefault()
triggerKeyConsumedRef.current = true
const item = triggerItems[triggerActive]
if (item) {
@@ -583,6 +608,7 @@ export function ChatBar({
if (event.key === 'Escape') {
event.preventDefault()
triggerKeyConsumedRef.current = true
closeTrigger()
return
@@ -603,6 +629,18 @@ export function ChatBar({
}
const handleEditorKeyUp = () => {
// If this keyup belongs to a key the open trigger popover already consumed
// in keydown (Arrow/Enter/Tab/Escape), skip the refresh. Those keys never
// edit text, and for Escape the keydown already closed the menu — a refresh
// here would re-detect the still-present `/` and instantly reopen it. We
// read a ref set during keydown rather than `trigger`, because by keyup
// time React has re-rendered and `trigger` may already be null.
if (triggerKeyConsumedRef.current) {
triggerKeyConsumedRef.current = false
return
}
window.setTimeout(refreshTrigger, 0)
}
@@ -844,26 +882,42 @@ export function ChatBar({
[queueEdit, runDrain]
)
const interruptAndSendNextQueued = useCallback(async () => {
if (queuedPrompts.length === 0) {
return false
}
await Promise.resolve(onCancel())
return drainNextQueued()
}, [drainNextQueued, onCancel, queuedPrompts.length])
// Auto-drain on busy → false (turn settled).
// Auto-drain on busy → false (turn settled). An explicit user interrupt
// (Stop button) sets userInterruptedRef so we skip exactly one auto-drain:
// the user asked to halt, so we must not immediately re-send the queue.
// The queued turns stay intact and the user resumes them on demand.
useEffect(() => {
const wasBusy = previousBusyRef.current
previousBusyRef.current = busy
if (busy || !wasBusy || queuedPrompts.length === 0) {
// Clear the interrupt latch when a new turn starts (false → true). This
// guards the sub-frame race where a Stop click lands after busy already
// flipped false (button not yet unmounted): the stale latch can no longer
// survive into the next turn and wrongly suppress its natural auto-drain.
if (busy && !wasBusy) {
userInterruptedRef.current = false
return
}
void drainNextQueued()
const interrupted = userInterruptedRef.current
// Consume the interrupt latch on any settle so a later natural completion
// is not wrongly suppressed.
if (!busy && wasBusy && interrupted) {
userInterruptedRef.current = false
}
if (
shouldAutoDrainOnSettle({
isBusy: busy,
queueLength: queuedPrompts.length,
userInterrupted: interrupted,
wasBusy
})
) {
void drainNextQueued()
}
}, [busy, drainNextQueued, queuedPrompts.length])
// Clean up queue edit when its target disappears (session swap or external delete).
@@ -886,9 +940,13 @@ export function ChatBar({
} else if (busy) {
if (hasComposerPayload) {
queueCurrentDraft()
} else if (queuedPrompts.length > 0) {
void interruptAndSendNextQueued()
} else {
// Stop button: an explicit interrupt must actually halt the running
// turn. Mark the interrupt so the busy→false auto-drain effect skips
// re-sending the queue — otherwise a queued follow-up would fire the
// instant we cancel and Stop would appear to "never work". Queued
// turns are preserved; the user sends them on demand.
userInterruptedRef.current = true
triggerHaptic('cancel')
void Promise.resolve(onCancel())
}
@@ -0,0 +1,183 @@
import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
import { act, fireEvent, render } from '@testing-library/react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { useLiveCompletionAdapter } from './hooks/use-live-completion-adapter'
import { detectTrigger, type TriggerState } from './text-utils'
// Faithful mirror of index.tsx's trigger wiring, driven through REAL DOM
// keydown+keyup events on a contentEditable. Exercises the parts a direct
// reducer-call repro misses: the keyup -> refreshTrigger path, the
// keydown-set "consumed" ref that guards it, and per-press keydown+keyup
// ordering (critical for Escape, whose keydown nulls `trigger` before keyup).
function Harness({
onState
}: {
onState: (s: { active: number; items: readonly Unstable_TriggerItem[]; open: boolean }) => void
}) {
const editorRef = useRef<HTMLDivElement>(null)
const triggerKeyConsumedRef = useRef(false)
const [trigger, setTrigger] = useState<TriggerState | null>(null)
const [triggerActive, setTriggerActive] = useState(0)
const [triggerItems, setTriggerItems] = useState<readonly Unstable_TriggerItem[]>([])
const { adapter } = useLiveCompletionAdapter({
enabled: true,
debounceMs: 0,
fetcher: async (query: string) => ({
query,
items: Array.from({ length: 5 }, (_, i) => ({ text: `/cmd${i}`, display: `/cmd${i}`, meta: '' }))
}),
toItem: (entry, index) => ({ id: `${entry.text}|${index}`, type: 'slash', label: entry.text.slice(1) })
})
const triggerAdapter: Unstable_TriggerAdapter | null = trigger?.kind === '/' ? adapter : null
const refreshTrigger = useCallback(() => {
const editor = editorRef.current
if (!editor) {return}
const raw = editor.textContent ?? ''
if (!raw.includes('@') && !raw.includes('/')) {
if (trigger) {
setTrigger(null)
setTriggerActive(0)
}
return
}
const detected = detectTrigger(raw)
setTrigger(detected)
if (detected?.kind !== trigger?.kind || detected?.query !== trigger?.query) {
setTriggerActive(0)
}
}, [trigger])
useEffect(() => {
if (!trigger || !triggerAdapter?.search) {
setTriggerItems([])
return
}
setTriggerItems(triggerAdapter.search(trigger.query))
}, [trigger, triggerAdapter])
useEffect(() => {
setTriggerActive(idx => Math.min(idx, Math.max(0, triggerItems.length - 1)))
}, [triggerItems.length])
onState({ active: triggerActive, items: triggerItems, open: trigger !== null })
const closeTrigger = () => {
setTrigger(null)
setTriggerItems([])
setTriggerActive(0)
}
// Exact copies of index.tsx handlers, including the keydown-set "consumed"
// ref that the keyup consults.
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (trigger && triggerItems.length > 0) {
if (event.key === 'ArrowDown') {
event.preventDefault()
triggerKeyConsumedRef.current = true
setTriggerActive(idx => (idx + 1) % triggerItems.length)
return
}
if (event.key === 'ArrowUp') {
event.preventDefault()
triggerKeyConsumedRef.current = true
setTriggerActive(idx => (idx - 1 + triggerItems.length) % triggerItems.length)
return
}
if (event.key === 'Escape') {
event.preventDefault()
triggerKeyConsumedRef.current = true
closeTrigger()
return
}
}
}
const handleKeyUp = () => {
if (triggerKeyConsumedRef.current) {
triggerKeyConsumedRef.current = false
return
}
// index.tsx defers via setTimeout(refreshTrigger, 0); call synchronously
// here so the test deterministically observes the keyup-driven refresh.
refreshTrigger()
}
return (
<div
contentEditable
data-testid="editor"
onInput={() => refreshTrigger()}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
ref={editorRef}
suppressContentEditableWarning
/>
)
}
async function flush() {
await act(async () => {
await new Promise(r => setTimeout(r, 20))
})
}
describe('slash menu navigation — real DOM keydown+keyup', () => {
it('cycles through ALL items and Esc closes (and stays closed)', async () => {
vi.useRealTimers()
let latest = { active: 0, items: [] as readonly Unstable_TriggerItem[], open: false }
const { getByTestId } = render(<Harness onState={s => (latest = s)} />)
const editor = getByTestId('editor')
// Simulate typing '/'.
await act(async () => {
editor.textContent = '/'
fireEvent.input(editor)
})
await flush()
expect(latest.open).toBe(true)
expect(latest.items.length).toBe(5)
// ArrowDown 6x with REAL keydown+keyup pairs. Bug = stuck [0,1,0,1,...].
const seen: number[] = [latest.active]
for (let i = 0; i < 6; i++) {
await act(async () => {
fireEvent.keyDown(editor, { key: 'ArrowDown' })
fireEvent.keyUp(editor, { key: 'ArrowDown' })
await Promise.resolve()
})
seen.push(latest.active)
}
expect(seen).toEqual([0, 1, 2, 3, 4, 0, 1])
// Escape: keydown closes; keyup must NOT reopen (the '/' is still in text).
await act(async () => {
fireEvent.keyDown(editor, { key: 'Escape' })
fireEvent.keyUp(editor, { key: 'Escape' })
await Promise.resolve()
})
await flush()
expect(latest.open).toBe(false)
})
})
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { detectTrigger } from './text-utils'
describe('detectTrigger', () => {
it('detects a bare slash trigger with an empty query', () => {
expect(detectTrigger('/')).toEqual({ kind: '/', query: '', tokenLength: 1 })
})
it('detects a slash command query', () => {
expect(detectTrigger('/skill')).toEqual({ kind: '/', query: 'skill', tokenLength: 6 })
})
it('detects a bare at-mention trigger with an empty query', () => {
expect(detectTrigger('@')).toEqual({ kind: '@', query: '', tokenLength: 1 })
})
it('detects an at-mention query', () => {
expect(detectTrigger('@file')).toEqual({ kind: '@', query: 'file', tokenLength: 5 })
})
it('returns null for plain text', () => {
expect(detectTrigger('hello there')).toBeNull()
})
})
+30 -3
View File
@@ -1,6 +1,6 @@
import { useStore } from '@nanostores/react'
import { useQueryClient } from '@tanstack/react-query'
import { lazy, Suspense, useCallback, useEffect, useRef } from 'react'
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef } from 'react'
import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from 'react-router-dom'
import { BootFailureOverlay } from '@/components/boot-failure-overlay'
@@ -33,6 +33,8 @@ import {
$gatewayState,
$selectedStoredSessionId,
$sessions,
$workingSessionIds,
mergeWorkingSessions,
sessionPinId,
setAwaitingResponse,
setBusy,
@@ -59,10 +61,11 @@ import { ChatSidebar } from './chat/sidebar'
import { useGatewayBoot } from './gateway/hooks/use-gateway-boot'
import { useGatewayRequest } from './gateway/hooks/use-gateway-request'
import { ModelPickerOverlay } from './model-picker-overlay'
import { ModelVisibilityOverlay } from './model-visibility-overlay'
import { RightSidebarPane } from './right-sidebar'
import { $terminalTakeover } from './right-sidebar/store'
import { PersistentTerminal, TerminalSlot } from './right-sidebar/terminal/persistent'
import { NEW_CHAT_ROUTE, routeSessionId, sessionRoute } from './routes'
import { NEW_CHAT_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE } from './routes'
import { useContextSuggestions } from './session/hooks/use-context-suggestions'
import { useCwdActions } from './session/hooks/use-cwd-actions'
import { useHermesConfig } from './session/hooks/use-hermes-config'
@@ -77,6 +80,7 @@ import { AppShell } from './shell/app-shell'
import { useOverlayRouting } from './shell/hooks/use-overlay-routing'
import { useStatusSnapshot } from './shell/hooks/use-status-snapshot'
import { useStatusbarItems } from './shell/hooks/use-statusbar-items'
import { ModelMenuPanel } from './shell/model-menu-panel'
import type { StatusbarItem } from './shell/statusbar-controls'
import type { TitlebarTool } from './shell/titlebar-controls'
import { useGroupRegistry } from './shell/use-group-registry'
@@ -204,7 +208,12 @@ export function DesktopController() {
const result = await listSessions(limit, 1)
if (refreshSessionsRequestRef.current === requestId) {
setSessions(result.sessions)
// Don't hard-replace: a session whose first turn is still in flight has
// message_count 0 in the DB, so min_messages=1 omits it. Since every
// message.complete refreshes the list, a plain replace would drop the
// other still-running new chats the moment one of them finishes. Keep
// any working session the server hasn't surfaced yet.
setSessions(prev => mergeWorkingSessions(prev, result.sessions, $workingSessionIds.get()))
setSessionsTotal(typeof result.total === 'number' ? result.total : result.sessions.length)
}
} finally {
@@ -274,6 +283,22 @@ export function DesktopController() {
requestGateway
})
const openProviderSettings = useCallback(() => {
navigate(`${SETTINGS_ROUTE}?tab=keys`)
}, [navigate])
const modelMenuContent = useMemo(
() =>
gatewayState === 'open' ? (
<ModelMenuPanel
gateway={gatewayRef.current || undefined}
onSelectModel={selectModel}
requestGateway={requestGateway}
/>
) : null,
[gatewayRef, gatewayState, requestGateway, selectModel]
)
useContextSuggestions({
activeSessionId,
activeSessionIdRef,
@@ -497,6 +522,7 @@ export function DesktopController() {
gatewayLogLines,
gatewayState,
inferenceStatus,
modelMenuContent,
openAgents,
openCommandCenterSection,
statusSnapshot,
@@ -531,6 +557,7 @@ export function DesktopController() {
requestGateway={requestGateway}
/>
<ModelPickerOverlay gateway={gatewayRef.current || undefined} onSelect={selectModel} />
<ModelVisibilityOverlay gateway={gatewayRef.current || undefined} onOpenProviders={openProviderSettings} />
<UpdatesOverlay />
<GatewayConnectingOverlay />
<BootFailureOverlay />
@@ -0,0 +1,31 @@
import { useStore } from '@nanostores/react'
import { ModelVisibilityDialog } from '@/components/model-visibility-dialog'
import type { HermesGateway } from '@/hermes'
import { $modelVisibilityOpen, setModelVisibilityOpen } from '@/store/model-visibility'
import { $activeSessionId, $gatewayState } from '@/store/session'
interface ModelVisibilityOverlayProps {
gateway?: HermesGateway
onOpenProviders: () => void
}
export function ModelVisibilityOverlay({ gateway, onOpenProviders }: ModelVisibilityOverlayProps) {
const activeSessionId = useStore($activeSessionId)
const gatewayOpen = useStore($gatewayState) === 'open'
const open = useStore($modelVisibilityOpen)
if (!gatewayOpen) {
return null
}
return (
<ModelVisibilityDialog
gw={gateway}
onOpenChange={setModelVisibilityOpen}
onOpenProviders={onOpenProviders}
open={open}
sessionId={activeSessionId}
/>
)
}
@@ -3,7 +3,7 @@ import { useCallback } from 'react'
import { getGlobalModelInfo, setGlobalModel } from '@/hermes'
import { notifyError } from '@/store/notifications'
import { setCurrentModel, setCurrentProvider } from '@/store/session'
import { $currentModel, $currentProvider, setCurrentModel, setCurrentProvider } from '@/store/session'
import type { ModelOptionsResponse } from '@/types/hermes'
interface ModelSelection {
@@ -48,38 +48,53 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
}
}, [])
// Returns whether the switch succeeded so callers can await it before
// applying follow-up changes (e.g. editing a model's reasoning/fast must land
// on the right active model — bail rather than write to the previous one).
const selectModel = useCallback(
(selection: ModelSelection) => {
async (selection: ModelSelection): Promise<boolean> => {
const includeGlobal = selection.persistGlobal || !activeSessionId
// Snapshot for rollback: the switch is applied optimistically, so a
// failure must restore the prior model/provider (store + query cache)
// rather than leave the UI showing a model the backend never selected.
const prevModel = $currentModel.get()
const prevProvider = $currentProvider.get()
setCurrentModel(selection.model)
setCurrentProvider(selection.provider)
updateModelOptionsCache(selection.provider, selection.model, selection.persistGlobal || !activeSessionId)
updateModelOptionsCache(selection.provider, selection.model, includeGlobal)
void (async () => {
try {
if (activeSessionId) {
await requestGateway('slash.exec', {
session_id: activeSessionId,
command: `/model ${selection.model} --provider ${selection.provider}${selection.persistGlobal ? ' --global' : ''}`
})
try {
if (activeSessionId) {
await requestGateway('slash.exec', {
session_id: activeSessionId,
command: `/model ${selection.model} --provider ${selection.provider}${selection.persistGlobal ? ' --global' : ''}`
})
if (selection.persistGlobal) {
void refreshCurrentModel()
}
void queryClient.invalidateQueries({
queryKey: selection.persistGlobal ? ['model-options'] : ['model-options', activeSessionId]
})
return
if (selection.persistGlobal) {
void refreshCurrentModel()
}
await setGlobalModel(selection.provider, selection.model)
void refreshCurrentModel()
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
} catch (err) {
notifyError(err, 'Model switch failed')
void queryClient.invalidateQueries({
queryKey: selection.persistGlobal ? ['model-options'] : ['model-options', activeSessionId]
})
return true
}
})()
await setGlobalModel(selection.provider, selection.model)
void refreshCurrentModel()
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
return true
} catch (err) {
setCurrentModel(prevModel)
setCurrentProvider(prevProvider)
updateModelOptionsCache(prevProvider, prevModel, includeGlobal)
notifyError(err, 'Model switch failed')
return false
}
},
[activeSessionId, queryClient, refreshCurrentModel, requestGateway, updateModelOptionsCache]
)
@@ -65,7 +65,7 @@ interface PromptActionsOptions {
activeSessionIdRef: MutableRefObject<string | null>
busyRef: MutableRefObject<boolean>
branchCurrentSession: () => Promise<boolean>
createBackendSessionForSend: () => Promise<string | null>
createBackendSessionForSend: (preview?: string | null) => Promise<string | null>
handleSkinCommand: (arg: string) => string
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
selectedStoredSessionIdRef: MutableRefObject<string | null>
@@ -296,7 +296,7 @@ export function usePromptActions({
if (!sessionId) {
try {
sessionId = await createBackendSessionForSend()
sessionId = await createBackendSessionForSend(visibleText)
} catch (err) {
dropOptimistic(null)
releaseBusy()
@@ -303,7 +303,7 @@ export function useSessionActions({
[activeSessionIdRef, busyRef, navigate, selectedStoredSessionIdRef]
)
const createBackendSessionForSend = useCallback(async (): Promise<string | null> => {
const createBackendSessionForSend = useCallback(async (preview: string | null = null): Promise<string | null> => {
const startingActiveSessionId = activeSessionIdRef.current
const startingStoredSessionId = selectedStoredSessionIdRef.current
const startingRouteToken = getRouteToken()
@@ -330,7 +330,11 @@ export function useSessionActions({
ensureSessionState(created.session_id, stored)
if (stored) {
upsertOptimisticSession(created, stored)
// Seed the sidebar preview with the user's first message so the row
// reads meaningfully while the turn is in flight, instead of flashing
// "Untitled session" until the turn persists and auto-title runs. The
// server later returns its own preview/title and supersedes this.
upsertOptimisticSession(created, stored, null, preview?.trim() || null)
navigate(sessionRoute(stored), { replace: true })
}
@@ -95,6 +95,19 @@ export function useSessionStateCache({
const syncSessionStateToView = useCallback(
(sessionId: string, state: ClientSessionState) => {
// Only the currently-viewed session may stage into the shared `$messages`
// view. A background session (e.g. one still busy and emitting stream /
// error updates after the user toggled away) must update its own cache
// entry but never the view — otherwise its messages clobber the
// foreground transcript and appear to "bleed" into every other session.
// The flush below also re-checks the active id, but staging here is what
// prevents a background write from overwriting an already-pending
// foreground write within the same animation frame (only one RAF is
// scheduled, so the last `pendingViewStateRef` writer would otherwise win).
if (sessionId !== activeSessionIdRef.current) {
return
}
pendingViewStateRef.current = { sessionId, state }
if (viewSyncRafRef.current !== null) {
@@ -1,9 +1,11 @@
import { useStore } from '@nanostores/react'
import type { ReactNode } from 'react'
import { useMemo } from 'react'
import type { CommandCenterSection } from '@/app/command-center'
import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel'
import { Activity, AlertCircle, Clock, Command, Cpu, Hash, Loader2, Sparkles } from '@/lib/icons'
import { Activity, AlertCircle, ChevronDown, Clock, Command, Hash, Loader2, Sparkles } from '@/lib/icons'
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'
@@ -11,8 +13,10 @@ import { $desktopActionTasks } from '@/store/activity'
import { $previewServerRestartStatus } from '@/store/preview'
import {
$busy,
$currentFastMode,
$currentModel,
$currentProvider,
$currentReasoningEffort,
$currentUsage,
$sessionStartedAt,
$turnStartedAt,
@@ -34,6 +38,7 @@ interface StatusbarItemsOptions {
gatewayLogLines: readonly string[]
gatewayState: string
inferenceStatus: RuntimeReadinessResult | null
modelMenuContent?: ReactNode
openAgents: () => void
openCommandCenterSection: (section: CommandCenterSection) => void
statusSnapshot: StatusResponse | null
@@ -48,14 +53,17 @@ export function useStatusbarItems({
gatewayLogLines,
gatewayState,
inferenceStatus,
modelMenuContent,
openAgents,
openCommandCenterSection,
statusSnapshot,
toggleCommandCenter
}: StatusbarItemsOptions) {
const busy = useStore($busy)
const currentFastMode = useStore($currentFastMode)
const currentModel = useStore($currentModel)
const currentProvider = useStore($currentProvider)
const currentReasoningEffort = useStore($currentReasoningEffort)
const currentUsage = useStore($currentUsage)
const desktopActionTasks = useStore($desktopActionTasks)
const previewServerRestartStatus = useStore($previewServerRestartStatus)
@@ -269,17 +277,51 @@ export function useStatusbarItems({
variant: 'text'
},
{
detail: currentProvider || '',
icon: <Cpu className="size-3" />,
id: 'model-summary',
label: currentModel || 'No model selected',
onSelect: () => setModelPickerOpen(true),
title: currentProvider ? `Switch model · ${currentProvider}: ${currentModel || ''}` : 'Open model picker',
variant: 'action'
label: (
<span className="inline-flex min-w-0 items-center gap-0.5">
<span className="truncate">
{formatModelStatusLabel(currentModel, {
fastMode: currentFastMode,
reasoningEffort: currentReasoningEffort
})}
</span>
<ChevronDown className="size-2.5 shrink-0 opacity-50" />
</span>
),
...(modelMenuContent
? {
menuAlign: 'end' as const,
menuClassName: 'w-64',
menuContent: modelMenuContent,
title: currentProvider
? `Model · ${currentProvider}: ${currentModel || 'none'}`
: 'Switch model',
variant: 'menu' as const
}
: {
onSelect: () => setModelPickerOpen(true),
title: currentProvider
? `${currentProvider} · ${currentModel || 'no model'}`
: 'Open model picker',
variant: 'action' as const
})
},
versionItem
],
[busy, contextBar, contextUsage, currentModel, currentProvider, sessionStartedAt, turnStartedAt, versionItem]
[
busy,
contextBar,
contextUsage,
currentFastMode,
currentModel,
currentProvider,
currentReasoningEffort,
modelMenuContent,
sessionStartedAt,
turnStartedAt,
versionItem
]
)
const leftStatusbarItems = useMemo(
@@ -0,0 +1,248 @@
import { useStore } from '@nanostores/react'
import {
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
dropdownMenuRow,
dropdownMenuSectionLabel,
DropdownMenuSeparator,
DropdownMenuSubContent
} from '@/components/ui/dropdown-menu'
import { Switch } from '@/components/ui/switch'
import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
import {
$activeSessionId,
$currentReasoningEffort,
setCurrentFastMode,
setCurrentReasoningEffort
} from '@/store/session'
// Hermes' real reasoning levels (see VALID_REASONING_EFFORTS); `none` is owned
// by the Thinking toggle, not the radio.
const EFFORT_OPTIONS = [
{ value: 'minimal', label: 'Minimal' },
{ value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' },
{ value: 'xhigh', label: 'Max' }
] as const
/** How "fast" is achieved for a given model two different mechanisms:
* - `param`: the Anthropic/OpenAI `speed=fast` request parameter.
* - `variant`: a separate `…-fast` sibling model selected via the model field.
*/
export type FastControl =
| { kind: 'none' }
| { kind: 'param'; on: boolean }
| { kind: 'variant'; baseId: string; fastId: string; on: boolean }
/** Resolve the fast mechanism for a model: prefer the speed=fast parameter
* when the backend supports it, else fall back to a `…-fast` sibling model. */
export function resolveFastControl(
model: string,
providerModels: readonly string[],
paramSupported: boolean,
currentFastMode: boolean
): FastControl {
if (paramSupported) {
return { kind: 'param', on: currentFastMode }
}
if (/-fast$/i.test(model)) {
const baseId = model.replace(/-fast$/i, '')
// Only a toggle if there's a base to switch back to; otherwise it's a
// standalone fast model with no "off" state.
return providerModels.includes(baseId)
? { kind: 'variant', baseId, fastId: model, on: true }
: { kind: 'none' }
}
const fastId = `${model}-fast`
if (providerModels.includes(fastId)) {
return { kind: 'variant', baseId: model, fastId, on: false }
}
// Fast isn't natively offered here, but if the session still has the speed
// param on (carried over from a previous model), expose the toggle so it can
// be turned off rather than stranded.
if (currentFastMode) {
return { kind: 'param', on: true }
}
return { kind: 'none' }
}
interface ModelEditSubmenuProps {
/** How fast mode is offered for this model (param toggle vs. variant swap). */
fastControl: FastControl
/** Whether this row's model is the active one. */
isActive: boolean
/** Switch to this model (resolves false on failure). Awaited before applying
* edits when not active so a failed switch doesn't write to the old model. */
onActivate: () => Promise<boolean> | void
/** Switch to a specific model id (used to swap base ⇄ -fast variant). */
onSelectModel: (model: string) => Promise<boolean> | void
/** Whether this model supports reasoning effort. */
reasoning: boolean
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
}
export function ModelEditSubmenu({
fastControl,
isActive,
onActivate,
onSelectModel,
reasoning,
requestGateway
}: ModelEditSubmenuProps) {
// Reactive session state comes straight from the stores rather than being
// drilled through the panel, so editing it re-renders only this submenu.
const activeSessionId = useStore($activeSessionId)
const currentReasoningEffort = useStore($currentReasoningEffort)
const effort = normalizeEffort(currentReasoningEffort)
const thinkingOn = isThinkingEnabled(currentReasoningEffort)
// Reasoning/fast are session-scoped (they apply to the active model), so
// editing a non-active model first switches to it. Returns false if the
// switch failed, so callers skip applying to the wrong (previous) model.
const ensureActive = async (): Promise<boolean> => {
if (isActive) {
return true
}
return (await onActivate()) !== false
}
const patchReasoning = async (next: string, rollback: string) => {
setCurrentReasoningEffort(next)
try {
if (!(await ensureActive())) {
setCurrentReasoningEffort(rollback)
return
}
await requestGateway('config.set', {
key: 'reasoning',
session_id: activeSessionId ?? '',
value: next
})
} catch (err) {
setCurrentReasoningEffort(rollback)
notifyError(err, 'Model option update failed')
}
}
const toggleFast = (enabled: boolean) => {
if (fastControl.kind === 'variant') {
// Fast is a separate model id — swap to it (or back to the base).
void onSelectModel(enabled ? fastControl.fastId : fastControl.baseId)
return
}
if (fastControl.kind === 'param') {
setCurrentFastMode(enabled)
void (async () => {
try {
if (!(await ensureActive())) {
setCurrentFastMode(!enabled)
return
}
await requestGateway('config.set', {
key: 'fast',
session_id: activeSessionId ?? '',
value: enabled ? 'fast' : 'normal'
})
} catch (err) {
setCurrentFastMode(!enabled)
notifyError(err, 'Fast mode update failed')
}
})()
}
}
const hasFast = fastControl.kind !== 'none'
const fastOn = fastControl.kind === 'none' ? false : fastControl.on
return (
<DropdownMenuSubContent className="w-52 p-0" sideOffset={4}>
{!hasFast && !reasoning ? (
<div className="px-2.5 py-3 text-xs text-(--ui-text-tertiary)">No options for this model</div>
) : (
<>
<DropdownMenuLabel className={dropdownMenuSectionLabel}>Options</DropdownMenuLabel>
{reasoning ? (
<DropdownMenuItem
className={cn(dropdownMenuRow, 'cursor-pointer')}
onSelect={event => event.preventDefault()}
>
Thinking
<Switch
checked={thinkingOn}
className="ml-auto cursor-pointer"
onCheckedChange={checked => void patchReasoning(checked ? effort || 'medium' : 'none', currentReasoningEffort)}
/>
</DropdownMenuItem>
) : null}
{hasFast ? (
<DropdownMenuItem
className={cn(dropdownMenuRow, 'cursor-pointer')}
onSelect={event => event.preventDefault()}
>
Fast
<Switch checked={fastOn} className="ml-auto cursor-pointer" onCheckedChange={toggleFast} />
</DropdownMenuItem>
) : null}
{reasoning ? (
<>
<DropdownMenuSeparator className="mx-0" />
<DropdownMenuLabel className={dropdownMenuSectionLabel}>Effort</DropdownMenuLabel>
<DropdownMenuRadioGroup
onValueChange={value => void patchReasoning(value, currentReasoningEffort)}
value={effort}
>
{EFFORT_OPTIONS.map(option => (
<DropdownMenuRadioItem
className={cn(dropdownMenuRow, 'cursor-pointer')}
key={option.value}
onSelect={event => event.preventDefault()}
value={option.value}
>
{option.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</>
) : null}
</>
)}
</DropdownMenuSubContent>
)
}
function isThinkingEnabled(effort: string): boolean {
// Empty = Hermes default (medium) = on; only an explicit "none" is off.
return (effort || 'medium').trim().toLowerCase() !== 'none'
}
function normalizeEffort(effort: string): string {
const value = (effort || 'medium').trim().toLowerCase()
// Thinking off → no effort selected in the radio group.
if (value === 'none') {
return ''
}
return EFFORT_OPTIONS.some(option => option.value === value) ? value : 'medium'
}
@@ -0,0 +1,289 @@
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { Codicon } from '@/components/ui/codicon'
import {
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
dropdownMenuRow,
DropdownMenuSearch,
dropdownMenuSectionLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubTrigger
} from '@/components/ui/dropdown-menu'
import { Skeleton } from '@/components/ui/skeleton'
import type { HermesGateway } from '@/hermes'
import { getGlobalModelOptions } from '@/hermes'
import { displayModelName, modelDisplayParts, reasoningEffortLabel } from '@/lib/model-status-label'
import { cn } from '@/lib/utils'
import {
$visibleModels,
collapseModelFamilies,
DEFAULT_VISIBLE_PER_PROVIDER,
type ModelFamily,
modelVisibilityKey,
setModelVisibilityOpen
} from '@/store/model-visibility'
import {
$activeSessionId,
$currentFastMode,
$currentModel,
$currentProvider,
$currentReasoningEffort
} from '@/store/session'
import type { ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes'
import { ModelEditSubmenu, resolveFastControl } from './model-edit-submenu'
interface ModelMenuPanelProps {
gateway?: HermesGateway
onSelectModel: (selection: { model: string; persistGlobal: boolean; provider: string }) => Promise<boolean> | void
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
}
interface ProviderGroup {
families: ModelFamily[]
provider: ModelOptionProvider
}
export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: ModelMenuPanelProps) {
const [search, setSearch] = useState('')
// Reactive session state is read from the stores here (not drilled in), so
// toggling effort/fast/model re-renders this panel in place without forcing
// the parent to rebuild the menu content (which would close the dropdown).
const activeSessionId = useStore($activeSessionId)
const currentFastMode = useStore($currentFastMode)
const currentModel = useStore($currentModel)
const currentProvider = useStore($currentProvider)
const currentReasoningEffort = useStore($currentReasoningEffort)
const visibleModels = useStore($visibleModels)
const modelOptions = useQuery({
queryKey: ['model-options', activeSessionId || 'global'],
queryFn: (): Promise<ModelOptionsResponse> => {
if (gateway && activeSessionId) {
return gateway.request<ModelOptionsResponse>('model.options', { session_id: activeSessionId })
}
return getGlobalModelOptions()
}
})
const optionsModel = String(modelOptions.data?.model ?? currentModel ?? '')
const optionsProvider = String(modelOptions.data?.provider ?? currentProvider ?? '')
const loading = modelOptions.isPending && !modelOptions.data
const error = modelOptions.error
? modelOptions.error instanceof Error
? modelOptions.error.message
: String(modelOptions.error)
: null
const providers = modelOptions.data?.providers
const switchTo = (model: string, provider: string) =>
onSelectModel({ model, persistGlobal: !activeSessionId, provider })
const groups = useMemo(
() => groupModels(providers ?? [], search, { model: optionsModel, provider: optionsProvider }, visibleModels),
[providers, search, optionsModel, optionsProvider, visibleModels]
)
return (
<>
<DropdownMenuSearch
aria-label="Search models"
onValueChange={setSearch}
placeholder="Search models"
value={search}
/>
<DropdownMenuSeparator className="mx-0" />
{loading ? (
<DropdownMenuGroup className="py-1">
{Array.from({ length: 4 }, (_, index) => (
<DropdownMenuItem
className={dropdownMenuRow}
disabled
key={index}
onSelect={event => event.preventDefault()}
>
<Skeleton className="h-4 w-full" />
</DropdownMenuItem>
))}
</DropdownMenuGroup>
) : error ? (
<DropdownMenuItem className={dropdownMenuRow} disabled>
{error}
</DropdownMenuItem>
) : groups.length === 0 ? (
<DropdownMenuItem className={dropdownMenuRow} disabled>
No models found
</DropdownMenuItem>
) : (
<div className="max-h-80 overflow-y-auto py-0.5">
{groups.map(group => (
<DropdownMenuGroup className="py-0.5" key={group.provider.slug}>
<DropdownMenuLabel className={dropdownMenuSectionLabel}>{group.provider.name}</DropdownMenuLabel>
{group.families.map(family => {
// The active id may be the base or its -fast sibling; either
// way this one family row represents both.
const activeId =
group.provider.slug === optionsProvider &&
(optionsModel === family.id || optionsModel === family.fastId)
? optionsModel
: null
const isCurrent = activeId !== null
const name = modelDisplayParts(family.id).name
// Capabilities are looked up against the active/base id; the
// -fast variant carries the same param support as its base.
const caps = group.provider.capabilities?.[family.id]
// Single source of truth for the active row's fast state — keeps
// the row label in lock-step with the submenu's Fast toggle and
// handles the standalone `-fast` id case.
const fastControl = resolveFastControl(
activeId ?? family.id,
group.provider.models ?? [],
caps?.fast ?? false,
currentFastMode
)
// Grayed text: active row shows live state (Fast + effort);
// others show a fast-capability hint.
const meta = isCurrent
? [fastControl.kind !== 'none' && fastControl.on ? 'Fast' : null, reasoningEffortLabel(currentReasoningEffort) || 'Med']
.filter(Boolean)
.join(' ')
: caps?.fast || family.fastId
? 'Fast'
: ''
// Every row is a hover-Edit submenu trigger. Activating it
// (pointer or keyboard) switches to the family's base model;
// the Fast toggle inside swaps to the -fast sibling (or flips
// the speed param). The sub-trigger has no `onSelect`, so wire
// both click and Enter/Space for keyboard parity.
const activate = () => {
if (!isCurrent) {
void switchTo(family.id, group.provider.slug)
}
}
return (
<DropdownMenuSub key={`${group.provider.slug}:${family.id}`}>
<DropdownMenuSubTrigger
className={cn(dropdownMenuRow, 'cursor-pointer')}
hideChevron
onClick={activate}
onKeyDown={event => {
if (event.key === 'Enter' || event.key === ' ') {
activate()
}
}}
>
<span className="min-w-0 flex-1 truncate">
{name}
{meta ? <span className="text-(--ui-text-tertiary)"> {meta}</span> : null}
</span>
{isCurrent ? <Codicon className="ml-auto text-foreground" name="check" size="0.75rem" /> : null}
</DropdownMenuSubTrigger>
<ModelEditSubmenu
fastControl={fastControl}
isActive={isCurrent}
onActivate={() => switchTo(family.id, group.provider.slug)}
onSelectModel={nextModel => switchTo(nextModel, group.provider.slug)}
reasoning={caps?.reasoning ?? true}
requestGateway={requestGateway}
/>
</DropdownMenuSub>
)
})}
</DropdownMenuGroup>
))}
</div>
)}
<DropdownMenuSeparator className="mx-0" />
<DropdownMenuItem
className={cn(dropdownMenuRow, 'cursor-pointer text-(--ui-text-tertiary)')}
onSelect={() => setModelVisibilityOpen(true)}
>
Edit Models
</DropdownMenuItem>
</>
)
}
// Collapsed we show the user's chosen models (or the curated default); typing
// spans every available model so anything is reachable past the cut.
const PER_PROVIDER_SEARCH = 12
function groupModels(
providers: ModelOptionProvider[],
search: string,
current: { model: string; provider: string },
visible: Set<string> | null
): ProviderGroup[] {
const q = search.trim().toLowerCase()
const groups: ProviderGroup[] = []
for (const provider of providers) {
const allFamilies = collapseModelFamilies(provider.models ?? [])
if (allFamilies.length === 0) {
continue
}
const matches = (family: ModelFamily) =>
`${family.id} ${family.fastId ?? ''} ${provider.name} ${provider.slug} ${displayModelName(family.id)}`
.toLowerCase()
.includes(q)
// Which model ids to show (the active one is always added on top of this).
let shown: Set<string>
if (q) {
// Search spans every family, regardless of visibility.
shown = new Set(allFamilies.filter(matches).map(family => family.id))
} else if (visible) {
// User has customized which models show — honor their selection exactly.
shown = new Set(
allFamilies.filter(family => visible.has(modelVisibilityKey(provider.slug, family.id))).map(family => family.id)
)
} else {
// Default: curated top-N families per provider.
shown = new Set(allFamilies.slice(0, DEFAULT_VISIBLE_PER_PROVIDER).map(family => family.id))
}
// Always include the active model — but keep every row in the provider's
// stable curated order (filter `allFamilies`, never reorder), so selecting
// a model can't shuffle the list.
const activeId =
provider.slug === current.provider && current.model
? allFamilies.find(family => family.id === current.model || family.fastId === current.model)?.id
: undefined
let families = allFamilies.filter(family => shown.has(family.id) || family.id === activeId)
if (q) {
families = families.slice(0, PER_PROVIDER_SEARCH)
}
if (families.length > 0) {
groups.push({ families, provider })
}
}
// Stable, logical group order: alphabetical by provider name. (The backend
// floats the current provider first, which would reshuffle on every switch.)
groups.sort((a, b) => a.provider.name.localeCompare(b.provider.name))
return groups
}
@@ -26,6 +26,7 @@ export interface StatusbarItem {
disabled?: boolean
hidden?: boolean
href?: string
menuAlign?: 'center' | 'end' | 'start'
menuClassName?: string
menuContent?: ReactNode
menuItems?: readonly StatusbarMenuItem[]
@@ -104,7 +105,7 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
align={item.menuAlign ?? 'start'}
className={cn('w-56', item.menuContent && 'p-0', item.menuClassName)}
side="top"
sideOffset={8}
@@ -74,6 +74,7 @@ import {
} from '@/components/ui/dropdown-menu'
import { Loader } from '@/components/ui/loader'
import type { HermesGateway } from '@/hermes'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
import { triggerHaptic } from '@/lib/haptics'
import { GitBranchIcon, Loader2Icon, Volume2Icon, VolumeXIcon } from '@/lib/icons'
@@ -637,7 +638,7 @@ function messageAttachmentRefs(value: unknown): string[] {
function StickyHumanMessageContainer({ children }: { children: ReactNode }) {
return (
<div
className="group/user-message sticky top-0 z-40 -mx-4 flex w-[calc(100%+2rem)] min-w-0 max-w-none flex-col items-stretch gap-0 self-end overflow-visible bg-(--ui-chat-surface-background) px-4 pb-(--conversation-turn-gap) pt-2"
className="group/user-message sticky z-40 -mx-4 flex w-[calc(100%+2rem)] min-w-0 max-w-none flex-col items-stretch gap-0 self-end overflow-visible bg-(--ui-chat-surface-background) px-4 pb-(--conversation-turn-gap) pt-2"
data-role="user"
data-slot="aui_user-message-root"
>
@@ -685,6 +686,32 @@ const UserMessage: FC<{
return messageAttachmentRefs(custom.attachmentRefs)
})
// Sticky human bubbles clamp to ~2 lines with a soft fade so a long prompt
// doesn't dominate the viewport while the response streams underneath; the
// clamp lifts on hover / focus (see styles.css). We measure the *unclamped*
// inner wrapper so the ResizeObserver only fires on real content / width
// changes, not on every frame while the outer max-height animates open.
const clampInnerRef = useRef<HTMLDivElement | null>(null)
const [bodyClamped, setBodyClamped] = useState(false)
const measureClamp = useCallback(() => {
const inner = clampInnerRef.current
const outer = inner?.parentElement
if (!inner || !outer) {
return
}
const styles = getComputedStyle(inner)
const lineHeight = parseFloat(styles.lineHeight) || 1.5 * parseFloat(styles.fontSize) || 20
const fullHeight = inner.scrollHeight
outer.style.setProperty('--human-msg-full', `${fullHeight}px`)
setBodyClamped(fullHeight > lineHeight * 2 + 1)
}, [])
useResizeObserver(measureClamp, clampInnerRef)
const hasBody = messageText.trim().length > 0
const isLatestUser = messageId === latestUserId
const showStop = isLatestUser && threadRunning && Boolean(onCancel)
@@ -707,7 +734,11 @@ const UserMessage: FC<{
// Render the user's text through a minimal markdown pipeline:
// backtick `code` and ``` fenced ``` blocks, with directive chips
// (`@file:` etc.) still resolved inside the plain-text spans.
<UserMessageText className="wrap-anywhere" text={messageText} />
<div className="sticky-human-clamp" data-clamped={bodyClamped ? 'true' : undefined}>
<div ref={clampInnerRef}>
<UserMessageText className="wrap-anywhere" text={messageText} />
</div>
</div>
)}
</>
)
@@ -842,6 +873,10 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
const [trigger, setTrigger] = useState<TriggerState | null>(null)
const [triggerActive, setTriggerActive] = useState(0)
const [triggerItems, setTriggerItems] = useState<readonly Unstable_TriggerItem[]>([])
// See index.tsx: set in keydown when the open popover consumes a nav/control
// key so the matching keyup skips refreshTrigger (timing-immune vs reading
// `trigger`, which keyup sees as already-null after Escape).
const triggerKeyConsumedRef = useRef(false)
const [triggerPlacement, setTriggerPlacement] = useState<'bottom' | 'top'>('top')
const [focusRequestId, setFocusRequestId] = useState(0)
const [submitting, setSubmitting] = useState(false)
@@ -966,8 +1001,15 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
}
setTrigger(detected)
setTriggerActive(0)
}, [])
// Only reset the highlight when the trigger actually changed (opened, or
// the query/kind differs). Re-detecting the *same* trigger — e.g. on a
// caret move (mouseup) or a stray refresh — must preserve the user's
// current selection instead of snapping back to the first item.
if (detected?.kind !== trigger?.kind || detected?.query !== trigger?.query) {
setTriggerActive(0)
}
}, [trigger])
const closeTrigger = useCallback(() => {
setTrigger(null)
@@ -1200,6 +1242,7 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
if (trigger && triggerItems.length > 0) {
if (event.key === 'ArrowDown') {
event.preventDefault()
triggerKeyConsumedRef.current = true
setTriggerActive(idx => (idx + 1) % triggerItems.length)
return
@@ -1207,6 +1250,7 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
if (event.key === 'ArrowUp') {
event.preventDefault()
triggerKeyConsumedRef.current = true
setTriggerActive(idx => (idx - 1 + triggerItems.length) % triggerItems.length)
return
@@ -1214,6 +1258,7 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
if (event.key === 'Enter' || event.key === 'Tab') {
event.preventDefault()
triggerKeyConsumedRef.current = true
const item = triggerItems[triggerActive]
if (item) {
@@ -1225,6 +1270,7 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
if (event.key === 'Escape') {
event.preventDefault()
triggerKeyConsumedRef.current = true
closeTrigger()
return
@@ -1244,6 +1290,22 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
}
}
const handleKeyUp = () => {
// If this keyup belongs to a key the open trigger popover already consumed
// in keydown (Arrow/Enter/Tab/Escape), skip the refresh. Those keys never
// edit text, and for Escape the keydown already closed the menu — a refresh
// here would re-detect the still-present `/` and instantly reopen it. We
// read a ref set during keydown rather than `trigger`, because by keyup
// time React has re-rendered and `trigger` may already be null.
if (triggerKeyConsumedRef.current) {
triggerKeyConsumedRef.current = false
return
}
window.setTimeout(refreshTrigger, 0)
}
return (
<ComposerPrimitive.Root className="contents" data-slot="aui_edit-composer-root">
<StickyHumanMessageContainer>
@@ -1294,7 +1356,7 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
onFocus={() => markActiveComposer('edit')}
onInput={handleInput}
onKeyDown={handleKeyDown}
onKeyUp={() => window.setTimeout(refreshTrigger, 0)}
onKeyUp={handleKeyUp}
onMouseUp={refreshTrigger}
onPaste={handlePaste}
ref={editorRef}
@@ -107,8 +107,9 @@ const PROVIDER_DISPLAY: Record<string, { order: number; title: string }> = {
anthropic: { order: 1, title: 'Anthropic Claude' },
'openai-codex': { order: 2, title: 'OpenAI Codex / ChatGPT' },
'minimax-oauth': { order: 3, title: 'MiniMax' },
'claude-code': { order: 4, title: 'Claude Code' },
'qwen-oauth': { order: 5, title: 'Qwen Code' }
'xai-oauth': { order: 4, title: 'xAI Grok' },
'claude-code': { order: 5, title: 'Claude Code' },
'qwen-oauth': { order: 6, title: 'Qwen Code' }
}
const assetPath = (path: string) => `${import.meta.env.BASE_URL}${path.replace(/^\/+/, '')}`
@@ -116,6 +117,7 @@ const assetPath = (path: string) => `${import.meta.env.BASE_URL}${path.replace(/
const FLOW_SUBTITLES: Record<OAuthProvider['flow'], string> = {
pkce: 'Opens your browser to sign in, then continues here',
device_code: 'Opens a verification page in your browser — Hermes connects automatically',
loopback: 'Opens your browser to sign in — Hermes connects automatically',
external: 'Sign in once in your terminal, then come back to chat'
}
@@ -565,6 +567,24 @@ function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow
)
}
if (flow.status === 'awaiting_browser') {
return (
<Step title={`Sign in with ${title}`}>
<p className="text-sm text-muted-foreground">
We opened {title} in your browser. Authorize Hermes there and you'll be connected
automatically nothing to copy or paste.
</p>
<FlowFooter left={<DocsLink href={flow.start.auth_url}>Re-open sign-in page</DocsLink>}>
<span className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="size-3 animate-spin" />
Waiting for you to authorize...
</span>
<CancelBtn size="sm" />
</FlowFooter>
</Step>
)
}
if (flow.status === 'external_pending') {
return (
<Step title={`Sign in with ${title}`}>
@@ -0,0 +1,148 @@
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Switch } from '@/components/ui/switch'
import type { HermesGateway } from '@/hermes'
import { getGlobalModelOptions } from '@/hermes'
import { displayModelName, modelDisplayParts } from '@/lib/model-status-label'
import {
$visibleModels,
collapseModelFamilies,
effectiveVisibleKeys,
modelVisibilityKey,
setVisibleModels
} from '@/store/model-visibility'
import type { ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes'
interface ModelVisibilityDialogProps {
gw?: HermesGateway
onOpenChange: (open: boolean) => void
onOpenProviders: () => void
open: boolean
sessionId?: string | null
}
export function ModelVisibilityDialog({ gw, onOpenChange, onOpenProviders, open, sessionId }: ModelVisibilityDialogProps) {
const [search, setSearch] = useState('')
const stored = useStore($visibleModels)
const modelOptions = useQuery({
queryKey: ['model-options', sessionId || 'global'],
queryFn: (): Promise<ModelOptionsResponse> => {
if (gw && sessionId) {
return gw.request<ModelOptionsResponse>('model.options', { session_id: sessionId })
}
return getGlobalModelOptions()
},
enabled: open
})
const providers = useMemo(
() => (modelOptions.data?.providers ?? []).filter(provider => (provider.models ?? []).length > 0),
[modelOptions.data]
)
const visible = effectiveVisibleKeys(stored, providers)
const toggle = (provider: ModelOptionProvider, model: string) => {
const next = new Set(effectiveVisibleKeys($visibleModels.get(), providers))
const key = modelVisibilityKey(provider.slug, model)
if (next.has(key)) {
next.delete(key)
} else {
next.add(key)
}
setVisibleModels(next)
}
const q = search.trim().toLowerCase()
const matches = (provider: ModelOptionProvider, model: string) =>
!q || `${model} ${provider.name} ${provider.slug} ${displayModelName(model)}`.toLowerCase().includes(q)
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-xs gap-0 overflow-hidden p-0">
<DialogHeader className="px-3 pb-1 pt-3">
<DialogTitle className="text-[0.8125rem]">Models</DialogTitle>
</DialogHeader>
<div className="px-3 py-1.5">
<input
autoFocus
className="h-5 w-full bg-transparent text-xs text-foreground placeholder:text-(--ui-text-tertiary) focus:outline-none"
onChange={event => setSearch(event.target.value)}
placeholder="Search models"
type="text"
value={search}
/>
</div>
<div className="max-h-[55vh] overflow-y-auto pb-1">
{providers.length === 0 ? (
<div className="px-3 py-5 text-center text-xs text-muted-foreground">
{modelOptions.isPending ? 'Loading…' : 'No authenticated providers.'}
</div>
) : (
providers.map(provider => {
const models = collapseModelFamilies(provider.models ?? []).filter(family =>
matches(provider, family.id)
)
if (models.length === 0) {
return null
}
return (
<div className="py-0.5" key={provider.slug}>
<div className="px-3 pb-0.5 pt-1 text-[0.625rem] font-medium uppercase tracking-wide text-(--ui-text-tertiary)">
{provider.name}
</div>
{models.map(family => {
const { name, tag } = modelDisplayParts(family.id)
const key = modelVisibilityKey(provider.slug, family.id)
return (
<label
className="flex cursor-pointer items-center gap-2 px-3 py-1 text-xs hover:bg-accent/50"
key={key}
>
<span className="min-w-0 flex-1 truncate">
{name}
{tag ? <span className="text-(--ui-text-tertiary)"> {tag}</span> : null}
</span>
<Switch
checked={visible.has(key)}
className="cursor-pointer"
onCheckedChange={() => toggle(provider, family.id)}
/>
</label>
)
})}
</div>
)
})
)}
</div>
<div className="px-3 py-2">
<button
className="text-xs text-(--ui-text-tertiary) transition-colors hover:text-foreground"
onClick={() => {
onOpenChange(false)
onOpenProviders()
}}
type="button"
>
Add provider
</button>
</div>
</DialogContent>
</Dialog>
)
}
@@ -4,6 +4,17 @@ import * as React from 'react'
import { Codicon } from '@/components/ui/codicon'
import { cn } from '@/lib/utils'
// Shared class tokens for edge-to-edge menus (use with `p-0` content): rows go
// full-width, square, and compact so the highlight spans the whole surface.
// Reuse these instead of re-deriving per menu so every searchable/compact menu
// reads identically.
export const dropdownMenuRow = 'gap-2 rounded-none px-2.5 py-1 text-xs'
export const dropdownMenuSectionLabel = 'px-2.5 pt-1 pb-0.5 text-[0.625rem] font-medium uppercase tracking-wide'
// Keys that must reach Radix's menu handler (navigation/close). Everything else
// is a filter keystroke and is stopped so the menu's typeahead doesn't hijack it.
const DROPDOWN_NAV_KEYS = new Set(['ArrowDown', 'ArrowUp', 'Enter', 'Escape', 'Tab'])
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
@@ -16,8 +27,49 @@ function DropdownMenuTrigger({ ...props }: React.ComponentProps<typeof DropdownM
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
}
/**
* Borderless filter input for a searchable dropdown. Autofocuses, keeps the
* menu's typeahead from eating keystrokes, and still lets arrow/enter/escape
* drive the list. Drop it in as the first child of a `DropdownMenuContent`.
*/
function DropdownMenuSearch({
className,
onChange,
onKeyDown,
onValueChange,
...props
}: Omit<React.ComponentProps<'input'>, 'type'> & {
onValueChange?: (value: string) => void
}) {
return (
<div className="px-2.5 py-1.5" data-slot="dropdown-menu-search">
<input
autoFocus
className={cn(
'h-4 w-full bg-transparent text-xs leading-none text-foreground placeholder:text-(--ui-text-tertiary) focus:outline-none',
className
)}
onChange={event => {
onChange?.(event)
onValueChange?.(event.target.value)
}}
onKeyDown={event => {
if (!DROPDOWN_NAV_KEYS.has(event.key)) {
event.stopPropagation()
}
onKeyDown?.(event)
}}
type="text"
{...props}
/>
</div>
)
}
function DropdownMenuContent({
className,
collisionPadding = 8,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
@@ -31,6 +83,9 @@ function DropdownMenuContent({
'dt-portal-scrollbar z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-36 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg border border-(--ui-stroke-secondary) bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)] p-1 text-[length:var(--conversation-text-font-size)] text-popover-foreground shadow-md backdrop-blur-md data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
className
)}
// Keep the menu inside the viewport: Radix flips/shifts away from edges
// (avoidCollisions defaults on); the padding stops it kissing the edge.
collisionPadding={collisionPadding}
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
{...props}
@@ -76,18 +131,16 @@ function DropdownMenuCheckboxItem({
<DropdownMenuPrimitive.CheckboxItem
checked={checked}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-md py-1 pr-2 pl-7 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
"relative flex cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
className
)}
data-slot="dropdown-menu-checkbox-item"
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Codicon name="check" size="1rem" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
<DropdownMenuPrimitive.ItemIndicator className="ml-auto flex items-center pl-2 text-foreground">
<Codicon name="check" size="0.75rem" />
</DropdownMenuPrimitive.ItemIndicator>
</DropdownMenuPrimitive.CheckboxItem>
)
}
@@ -104,18 +157,16 @@ function DropdownMenuRadioItem({
return (
<DropdownMenuPrimitive.RadioItem
className={cn(
"relative flex cursor-default items-center gap-2 rounded-md py-1 pr-2 pl-7 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
"relative flex cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
className
)}
data-slot="dropdown-menu-radio-item"
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Codicon name="primitive-dot" size="0.5rem" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
<DropdownMenuPrimitive.ItemIndicator className="ml-auto flex items-center pl-2 text-foreground">
<Codicon name="check" size="0.75rem" />
</DropdownMenuPrimitive.ItemIndicator>
</DropdownMenuPrimitive.RadioItem>
)
}
@@ -164,10 +215,13 @@ function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuP
function DropdownMenuSubTrigger({
className,
inset,
hideChevron = false,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
/** Suppress the trailing caret — for triggers that own their right-side affordance. */
hideChevron?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
@@ -180,29 +234,40 @@ function DropdownMenuSubTrigger({
{...props}
>
{children}
<Codicon className="ml-auto text-(--ui-text-tertiary)" name="chevron-right" size="1rem" />
{!hideChevron && <Codicon className="ml-auto text-(--ui-text-tertiary)" name="chevron-right" size="1rem" />}
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
collisionPadding = 8,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
// SubContent inherits the same portal/scrollbar issue as Content (Radix
// renders it under document.body), so apply `dt-portal-scrollbar`. Use
// a fixed `max-h-80` rather than the Radix available-height variable:
// that variable is only published on Content, NOT SubContent — using
// it here collapses the submenu to 0px height.
className={cn(
'dt-portal-scrollbar z-50 max-h-80 min-w-36 origin-(--radix-dropdown-menu-content-transform-origin) overflow-y-auto rounded-lg border border-(--ui-stroke-secondary) bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)] p-1 text-[length:var(--conversation-text-font-size)] text-popover-foreground shadow-md backdrop-blur-md data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
className
)}
data-slot="dropdown-menu-sub-content"
{...props}
/>
// Portal the submenu out of the parent Content so it escapes that Content's
// `overflow` clip. Without this, a submenu opening from a scrollable menu
// gets visually cut off at the parent's edges. Radix Popper still anchors
// it to the SubTrigger and handles collision/flip, so portaling is safe.
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.SubContent
// `dt-portal-scrollbar` reproduces the themed scrollbar for portaled
// overlays (rendered under document.body). Use a fixed `max-h-80`
// rather than the Radix available-height variable: that variable is
// only published on Content, NOT SubContent — using it here collapses
// the submenu to 0px height.
className={cn(
'dt-portal-scrollbar z-50 max-h-80 min-w-36 origin-(--radix-dropdown-menu-content-transform-origin) overflow-y-auto rounded-lg border border-(--ui-stroke-secondary) bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)] p-1 text-[length:var(--conversation-text-font-size)] text-popover-foreground shadow-md backdrop-blur-md data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
className
)}
// Flip to the other side / shift vertically when near a viewport edge
// (e.g. the status bar menu opening from the bottom-right corner) so
// the submenu never gets clipped.
collisionPadding={collisionPadding}
data-slot="dropdown-menu-sub-content"
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
@@ -216,6 +281,7 @@ export {
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSearch,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import { displayModelName, formatModelStatusLabel, reasoningEffortLabel } from './model-status-label'
describe('model-status-label', () => {
it('formats display names consistently', () => {
expect(displayModelName('anthropic/claude-opus-4.8-fast')).toBe('Opus 4.8')
expect(displayModelName('openai/gpt-5.5')).toBe('GPT-5.5')
})
it('maps reasoning effort to compact labels', () => {
expect(reasoningEffortLabel('high')).toBe('High')
expect(reasoningEffortLabel('xhigh')).toBe('Max')
expect(reasoningEffortLabel('')).toBe('')
})
it('appends fast + effort session state to the status label', () => {
expect(formatModelStatusLabel('openai/gpt-5.5', { fastMode: true, reasoningEffort: 'high' })).toBe(
'GPT-5.5 · Fast High'
)
})
it('always surfaces the effort (default medium) so the level is visible', () => {
expect(formatModelStatusLabel('openai/gpt-5.5', { reasoningEffort: 'medium' })).toBe('GPT-5.5 · Med')
expect(formatModelStatusLabel('openai/gpt-5.5')).toBe('GPT-5.5 · Med')
})
it('returns just the placeholder name when there is no model', () => {
expect(formatModelStatusLabel('')).toBe('No model')
})
})
+103
View File
@@ -0,0 +1,103 @@
const REASONING_LABELS: Record<string, string> = {
none: 'Off',
minimal: 'Min',
low: 'Low',
medium: 'Med',
high: 'High',
xhigh: 'Max'
}
export function reasoningEffortLabel(effort: string): string {
const key = effort.trim().toLowerCase()
if (!key) {
return ''
}
return REASONING_LABELS[key] ?? effort
}
/** Strip provider prefix and normalize for display. */
export function modelBaseId(model: string): string {
const trimmed = model.trim()
const slash = trimmed.lastIndexOf('/')
return slash >= 0 ? trimmed.slice(slash + 1) : trimmed
}
// Trailing model-id variants that should render as a grayed tag beside the
// name (e.g. "Opus 4.8" + "Fast") rather than collapsing two distinct ids to
// the same display name.
const VARIANT_TAGS: ReadonlyArray<readonly [RegExp, string]> = [
[/-fast$/i, 'Fast'],
[/-thinking$/i, 'Thinking'],
[/-preview$/i, 'Preview'],
[/-latest$/i, 'Latest']
]
const titleCase = (text: string): string => text.replace(/\b\w/g, char => char.toUpperCase()).trim()
function prettifyBase(base: string): string {
if (/^claude-/i.test(base)) {
return titleCase(base.replace(/^claude-/i, '').replace(/-/g, ' '))
}
if (/^gpt-/i.test(base)) {
return base.replace(/^gpt-/i, 'GPT-')
}
if (/^gemini-/i.test(base)) {
return base.replace(/^gemini-/i, 'Gemini ').replace(/-/g, ' ')
}
return titleCase(base.replace(/-/g, ' '))
}
/** Split a model id into a clean display name plus an optional grayed variant
* tag, so distinct ids (e.g. `…-4.8` vs `…-4.8-fast`) don't collapse. */
export function modelDisplayParts(model: string): { name: string; tag: string } {
let base = modelBaseId(model)
let tag = ''
for (const [pattern, label] of VARIANT_TAGS) {
if (pattern.test(base)) {
tag = label
base = base.replace(pattern, '')
break
}
}
return { name: prettifyBase(base) || model.trim() || 'No model', tag }
}
/** Friendly one-line model name for menus and the status bar. */
export function displayModelName(model: string): string {
return modelDisplayParts(model).name
}
/** Status bar trigger label — model name plus the live session state (effort/fast). */
export function formatModelStatusLabel(
model: string,
options?: { fastMode?: boolean; reasoningEffort?: string }
): string {
const name = displayModelName(model)
if (!model.trim()) {
return name
}
const parts: string[] = []
// Fast is shown when the speed=fast param is on (options.fastMode) OR the
// active model is a `…-fast` variant (fast via a separate model id).
if (options?.fastMode || /-fast$/i.test(modelBaseId(model))) {
parts.push('Fast')
}
// Always surface the effort (empty = Hermes default of medium) so the
// current reasoning level is visible at a glance, not just when non-default.
parts.push(reasoningEffortLabel(options?.reasoningEffort ?? '') || 'Med')
return `${name} · ${parts.join(' ')}`
}
@@ -8,6 +8,7 @@ import {
enqueueQueuedPrompt,
getQueuedPrompts,
removeQueuedPrompt,
shouldAutoDrainOnSettle,
updateQueuedPrompt,
updateQueuedPromptText
} from './composer-queue'
@@ -100,3 +101,37 @@ describe('composer queue store', () => {
expect(parsed[SESSION_KEY]?.[0]?.text).toBe('persist me')
})
})
describe('shouldAutoDrainOnSettle', () => {
const base = { isBusy: false, queueLength: 1, userInterrupted: false, wasBusy: true }
it('drains the next queued prompt when a turn completes naturally', () => {
expect(shouldAutoDrainOnSettle(base)).toBe(true)
})
it('does NOT drain when the user explicitly interrupted (Stop button)', () => {
// Regression: previously the Stop button "never worked" because cancelling
// a turn flipped busy → false and the queue immediately re-fired its head.
expect(shouldAutoDrainOnSettle({ ...base, userInterrupted: true })).toBe(false)
})
it('does not drain when the queue is empty', () => {
expect(shouldAutoDrainOnSettle({ ...base, queueLength: 0 })).toBe(false)
})
it('does not drain when interrupted even if the queue is also empty', () => {
expect(shouldAutoDrainOnSettle({ ...base, queueLength: 0, userInterrupted: true })).toBe(false)
})
it('ignores steady busy state (no true → false transition)', () => {
expect(shouldAutoDrainOnSettle({ ...base, isBusy: true })).toBe(false)
})
it('ignores busy entry (false → true, not a settle)', () => {
expect(shouldAutoDrainOnSettle({ ...base, isBusy: true, wasBusy: false })).toBe(false)
})
it('ignores steady idle state (was not busy)', () => {
expect(shouldAutoDrainOnSettle({ ...base, wasBusy: false })).toBe(false)
})
})
+36
View File
@@ -188,3 +188,39 @@ export const clearQueuedPrompts = (key: string | null | undefined) => {
writeSession(sid, [])
}
/** Inputs to {@link shouldAutoDrainOnSettle}, captured at a `busy` transition. */
export interface AutoDrainSettleInput {
wasBusy: boolean
isBusy: boolean
queueLength: number
userInterrupted: boolean
}
/**
* Decide whether the composer should auto-drain the next queued prompt when a
* turn settles (busy transitions true false).
*
* The queue auto-advances when a turn *completes naturally*, but must NOT
* advance when the user *explicitly interrupted* the turn via the Stop button.
* Conflating the two made the Stop button appear to "never work": cancelling a
* turn flipped busy false, the queue immediately re-fired its head, and the
* agent kept running. An explicit interrupt means stop the queued turns are
* preserved and the user resumes them deliberately (Cmd/Ctrl+K, Enter, or the
* per-row "send now" arrow).
*/
export const shouldAutoDrainOnSettle = (params: AutoDrainSettleInput): boolean => {
const { isBusy, queueLength, userInterrupted, wasBusy } = params
// Only react to a true → false transition; ignore steady state and entry.
if (isBusy || !wasBusy) {
return false
}
// An explicit Stop suppresses exactly one auto-drain.
if (userInterrupted) {
return false
}
return queueLength > 0
}
+108
View File
@@ -0,0 +1,108 @@
import { atom } from 'nanostores'
import { persistString, storedString } from '@/lib/storage'
import type { ModelOptionProvider } from '@/types/hermes'
const STORAGE_KEY = 'hermes.desktop.visible-models'
/** Models shown per provider in the status-bar dropdown before the user has
* customized the list. Backend `models` are already relevance-ordered. */
export const DEFAULT_VISIBLE_PER_PROVIDER = 5
/** Stable key for a provider/model pair (`::` avoids colliding with model ids
* that contain a single colon, e.g. `model:tag`). */
export const modelVisibilityKey = (provider: string, model: string): string => `${provider}::${model}`
/** A model and its optional `-fast` sibling, collapsed into one logical row.
* `id` is the canonical (base) model; `fastId` is the fast variant if present. */
export interface ModelFamily {
fastId: string | null
id: string
}
/** Collapse a provider's model list so a base model and its `-fast` variant
* become a single family (one row, one toggle). Order is preserved by the
* base model's position. A `…-fast` model with no base stands on its own. */
export function collapseModelFamilies(models: readonly string[]): ModelFamily[] {
const present = new Set(models)
const families: ModelFamily[] = []
const consumed = new Set<string>()
for (const model of models) {
if (consumed.has(model)) {
continue
}
if (/-fast$/i.test(model) && present.has(model.replace(/-fast$/i, ''))) {
// Represented by its base entry — the base attaches it as `fastId`.
continue
}
const fastId = `${model}-fast`
const hasFast = present.has(fastId)
families.push({ fastId: hasFast ? fastId : null, id: model })
consumed.add(model)
if (hasFast) {
consumed.add(fastId)
}
}
return families
}
function loadVisible(): Set<string> | null {
const raw = storedString(STORAGE_KEY)
if (!raw) {
return null
}
try {
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? new Set(parsed.filter((x): x is string => typeof x === 'string')) : null
} catch {
return null
}
}
/** Explicit set of visible `provider::model` keys, or null when the user
* hasn't customized in which case the curated default applies. */
export const $visibleModels = atom<Set<string> | null>(loadVisible())
export const $modelVisibilityOpen = atom(false)
export function setVisibleModels(keys: Set<string>): void {
$visibleModels.set(new Set(keys))
persistString(STORAGE_KEY, JSON.stringify([...keys]))
}
export function setModelVisibilityOpen(open: boolean): void {
$modelVisibilityOpen.set(open)
}
/** The default-visible key set: the curated top-N per provider. Used both as
* the dropdown fallback and to seed the Edit Models dialog. */
export function defaultVisibleKeys(providers: readonly ModelOptionProvider[]): Set<string> {
const keys = new Set<string>()
for (const provider of providers) {
const families = collapseModelFamilies(provider.models ?? [])
for (const family of families.slice(0, DEFAULT_VISIBLE_PER_PROVIDER)) {
keys.add(modelVisibilityKey(provider.slug, family.id))
}
}
return keys
}
/** Resolve which keys are currently visible: the user's explicit set when
* configured, otherwise the curated default for the given providers. */
export function effectiveVisibleKeys(
stored: Set<string> | null,
providers: readonly ModelOptionProvider[]
): Set<string> {
return stored ?? defaultVisibleKeys(providers)
}
+41 -3
View File
@@ -18,6 +18,7 @@ import type { ModelOptionProvider, OAuthProvider, OAuthStartResponse } from '@/t
type PkceStart = Extract<OAuthStartResponse, { flow: 'pkce' }>
type DeviceStart = Extract<OAuthStartResponse, { flow: 'device_code' }>
type LoopbackStart = Extract<OAuthStartResponse, { flow: 'loopback' }>
export type OnboardingMode = 'apikey' | 'oauth'
@@ -26,6 +27,10 @@ export type OnboardingFlow =
| { provider: OAuthProvider; status: 'starting' }
| { code: string; provider: OAuthProvider; start: PkceStart; status: 'awaiting_user' }
| { copied: boolean; provider: OAuthProvider; start: DeviceStart; status: 'polling' }
// Loopback PKCE (xAI Grok): browser opens, the local backend's 127.0.0.1
// listener catches the redirect, and we poll until the worker finishes.
// No code to paste and no user_code to show — just a waiting state.
| { provider: OAuthProvider; start: LoopbackStart; status: 'awaiting_browser' }
| { provider: OAuthProvider; start: OAuthStartResponse; status: 'submitting' }
| { copied: boolean; provider: OAuthProvider; status: 'external_pending' }
| { provider: OAuthProvider; status: 'success' }
@@ -406,6 +411,26 @@ export async function refreshOnboarding(ctx: OnboardingContext) {
return false
}
// Open a sign-in URL via the desktop bridge, falling back to window.open
// when the bridge isn't present (e.g. the web dashboard / dev preview) so
// the flow never silently stalls in a waiting state. Mirrors the pattern in
// apps/desktop/src/app/artifacts/index.tsx.
async function openSignInUrl(url: string) {
if (window.hermesDesktop?.openExternal) {
try {
await window.hermesDesktop.openExternal(url)
return
} catch {
// Bridge present but failed (no OS handler, user denied, etc.). Fall
// through to window.open so the sign-in URL still opens and the flow
// doesn't strand a pending OAuth session in a waiting state.
}
}
window.open(url, '_blank', 'noopener,noreferrer')
}
export async function startProviderOAuth(provider: OAuthProvider, ctx: OnboardingContext) {
clearPoll()
@@ -419,7 +444,8 @@ export async function startProviderOAuth(provider: OAuthProvider, ctx: Onboardin
try {
const start = await startOAuthLogin(provider.id)
await window.hermesDesktop?.openExternal(start.flow === 'pkce' ? start.auth_url : start.verification_url)
const browserUrl = start.flow === 'device_code' ? start.verification_url : start.auth_url
await openSignInUrl(browserUrl)
if (start.flow === 'pkce') {
setFlow({ status: 'awaiting_user', provider, start, code: '' })
@@ -427,14 +453,26 @@ export async function startProviderOAuth(provider: OAuthProvider, ctx: Onboardin
return
}
if (start.flow === 'loopback') {
// No code to paste: the redirect lands on the backend's loopback
// listener. Just wait and poll the session until the worker finishes.
setFlow({ status: 'awaiting_browser', provider, start })
pollTimer = window.setInterval(() => void pollSession(provider, start, ctx), POLL_MS)
return
}
setFlow({ status: 'polling', provider, start, copied: false })
pollTimer = window.setInterval(() => void pollDevice(provider, start, ctx), POLL_MS)
pollTimer = window.setInterval(() => void pollSession(provider, start, ctx), POLL_MS)
} catch (error) {
setFlow({ status: 'error', provider, message: `Could not start sign-in: ${errMessage(error)}` })
}
}
async function pollDevice(provider: OAuthProvider, start: DeviceStart, ctx: OnboardingContext) {
// Poll a session-backed flow (device_code or loopback) until it resolves.
// Both shapes only need the session_id to poll; the start is threaded
// through to the error flow so the user can retry from the same context.
async function pollSession(provider: OAuthProvider, start: DeviceStart | LoopbackStart, ctx: OnboardingContext) {
try {
const { error_message, status } = await pollOAuthSession(provider.id, start.session_id)
+44 -1
View File
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import type { SessionInfo } from '@/types/hermes'
import { sessionPinId } from './session'
import { mergeWorkingSessions, sessionPinId } from './session'
const session = (over: Partial<SessionInfo>): SessionInfo => ({
archived: false,
@@ -34,3 +34,46 @@ describe('sessionPinId', () => {
expect(sessionPinId(session({ id: 'tip', _lineage_root_id: 'root' }))).toBe('root')
})
})
describe('mergeWorkingSessions', () => {
it('returns the server page untouched when nothing is working', () => {
const previous = [session({ id: 'a' }), session({ id: 'b' })]
const incoming = [session({ id: 'a' })]
expect(mergeWorkingSessions(previous, incoming, [])).toBe(incoming)
})
it('keeps a still-working session the server omitted', () => {
// Repro of the disappearing-sessions bug: A finished and is returned by the
// server, but B and C are mid-first-response (message_count 0 in the DB) so
// listSessions(min_messages=1) skips them. They must survive the refresh.
const previous = [session({ id: 'c' }), session({ id: 'b' }), session({ id: 'a' })]
const incoming = [session({ id: 'a', message_count: 2 })]
const merged = mergeWorkingSessions(previous, incoming, ['b', 'c'])
expect(merged.map(s => s.id)).toEqual(['c', 'b', 'a'])
// The finished session comes from the fresh server payload, not the stale
// optimistic copy.
expect(merged.find(s => s.id === 'a')?.message_count).toBe(2)
})
it('does not duplicate a working session the server already returned', () => {
const previous = [session({ id: 'b' }), session({ id: 'a' })]
const incoming = [session({ id: 'b', message_count: 4 }), session({ id: 'a' })]
const merged = mergeWorkingSessions(previous, incoming, ['b'])
expect(merged.map(s => s.id)).toEqual(['b', 'a'])
expect(merged.find(s => s.id === 'b')?.message_count).toBe(4)
})
it('never resurrects a non-working session the server dropped', () => {
// A deleted/archived session is removed from `previous` optimistically and
// is not in the working set, so it must stay gone after a refresh.
const previous = [session({ id: 'b' }), session({ id: 'gone' })]
const incoming = [session({ id: 'b' })]
expect(mergeWorkingSessions(previous, incoming, ['b']).map(s => s.id)).toEqual(['b'])
})
})
+27
View File
@@ -27,6 +27,33 @@ function updateAtom<T>(store: AppAtom<T>, next: Updater<T>) {
export const sessionPinId = (session: Pick<SessionInfo, '_lineage_root_id' | 'id'>): string =>
session._lineage_root_id ?? session.id
/** Merge a fresh server session page into the in-memory list, keeping any
* still-"working" session the server omitted.
*
* A brand-new session's first user message isn't flushed to the SessionDB
* until its turn is persisted, so `listSessions(min_messages=1)` skips
* sessions that are mid-first-response. Because every `message.complete`
* triggers a full refresh, a hard replace makes concurrent new chats vanish
* the instant any one of them finishes. Preserving the working-but-absent
* rows keeps them visible until their own turn persists and the server
* starts returning them. Optimistic deletes/archives already drop the row
* from `previous`, so a removed session can't be resurrected here. */
export function mergeWorkingSessions(
previous: SessionInfo[],
incoming: SessionInfo[],
workingIds: readonly string[]
): SessionInfo[] {
if (workingIds.length === 0) {
return incoming
}
const working = new Set(workingIds)
const incomingIds = new Set(incoming.map(session => session.id))
const survivors = previous.filter(session => working.has(session.id) && !incomingIds.has(session.id))
return survivors.length ? [...survivors, ...incoming] : incoming
}
export const $connection = atom<HermesConnection | null>(null)
export const $gatewayState = atom('idle')
export const $sessions = atom<SessionInfo[]>([])
+67 -19
View File
@@ -76,8 +76,7 @@
--shadow-header:
0 0.0625rem 0 color-mix(in srgb, var(--dt-foreground) 7%, transparent),
0 0.625rem 1.5rem -1.25rem color-mix(in srgb, #000 16%, transparent);
--shadow-composer:
0 0.0625rem 0.125rem color-mix(in srgb, #000 5%, transparent);
--shadow-composer: 0 0.0625rem 0.125rem color-mix(in srgb, #000 5%, transparent);
--shadow-composer-focus:
0 0 0 0.125rem color-mix(in srgb, var(--dt-composer-ring) calc(10% * var(--composer-ring-strength)), transparent),
0 0 0 0.0625rem color-mix(in srgb, var(--dt-composer-ring) calc(22% * var(--composer-ring-strength)), transparent),
@@ -133,15 +132,23 @@
--ui-cyan: #4c7f8c;
--ui-blue: #0053fd;
--ui-purple: #9e94d5;
--ui-bg-chrome: color-mix(in srgb, var(--theme-background-seed) var(--theme-mix-chrome), var(--theme-neutral-chrome));
--ui-bg-sidebar: color-mix(in srgb, var(--theme-sidebar-seed) var(--theme-mix-sidebar), var(--theme-neutral-sidebar));
--ui-bg-editor: color-mix(in srgb, var(--theme-card-seed) var(--theme-mix-card), var(--theme-neutral-card));
--ui-bg-elevated: color-mix(in srgb, var(--theme-elevated-seed) var(--theme-mix-elevated), var(--theme-neutral-card));
--ui-bg-card: color-mix(
--ui-bg-chrome: color-mix(
in srgb,
var(--ui-accent) 4%,
color-mix(in srgb, var(--ui-base) 4%, transparent)
var(--theme-background-seed) var(--theme-mix-chrome),
var(--theme-neutral-chrome)
);
--ui-bg-sidebar: color-mix(
in srgb,
var(--theme-sidebar-seed) var(--theme-mix-sidebar),
var(--theme-neutral-sidebar)
);
--ui-bg-editor: color-mix(in srgb, var(--theme-card-seed) var(--theme-mix-card), var(--theme-neutral-card));
--ui-bg-elevated: color-mix(
in srgb,
var(--theme-elevated-seed) var(--theme-mix-elevated),
var(--theme-neutral-card)
);
--ui-bg-card: color-mix(in srgb, var(--ui-accent) 4%, color-mix(in srgb, var(--ui-base) 4%, transparent));
--ui-bg-input: #fcfcfc;
--ui-bg-primary: color-mix(
in srgb,
@@ -218,7 +225,11 @@
--ui-sidebar-surface-background: var(--ui-bg-sidebar);
--ui-chat-surface-background: var(--ui-bg-chrome);
--ui-editor-surface-background: var(--ui-bg-chrome);
--ui-chat-bubble-background: color-mix(in srgb, var(--theme-bubble-seed) var(--theme-mix-bubble), var(--theme-neutral-card));
--ui-chat-bubble-background: color-mix(
in srgb,
var(--theme-bubble-seed) var(--theme-mix-bubble),
var(--theme-neutral-card)
);
--ui-chat-bubble-opaque-background: var(--ui-bg-editor);
--ui-inline-code-background: color-mix(in srgb, #141414 5%, transparent);
--ui-inline-code-border: color-mix(in srgb, #141414 8%, transparent);
@@ -272,6 +283,7 @@
--conversation-line-height: 1.125rem;
--conversation-caption-line-height: 1rem;
--conversation-turn-gap: 0.375rem;
--sticky-human-top: 0.23rem;
--file-tree-row-height: 1.375rem;
--composer-width: 48.75rem;
@@ -626,7 +638,7 @@ canvas {
.scrollbar-dt::-webkit-scrollbar-thumb,
.scrollbar-dt *::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--dt-midground) 18%, transparent);
border-radius: 9999rem;
border-radius: 9999rem;
border: 0.125rem solid transparent;
background-clip: padding-box;
}
@@ -704,11 +716,52 @@ canvas {
padding-inline-start: var(--md-text-indent, 0.5rem);
}
[data-slot='aui_user-message-root'] {
top: var(--sticky-human-top);
}
[data-slot='aui_user-message-root'],
[data-slot='aui_edit-composer-root'] {
font-size: var(--conversation-text-font-size);
}
/* Sticky human bubbles clamp to ~2 lines with a soft bottom fade so a long
prompt doesn't dominate the viewport while you read the response stuck
beneath it. The clamp lifts on hover / focus (clicking the bubble opens the
edit composer, which already shows the full text). --human-msg-full is the
measured content height (set in UserMessage) so expand/collapse animates to
the real height instead of overshooting the cap. */
.sticky-human-clamp {
max-height: calc(2 * var(--dt-line-height) * var(--conversation-text-font-size) + 0.15rem);
overflow: hidden;
transition: max-height 0.08s cubic-bezier(0.4, 0, 0.2, 1);
}
.sticky-human-clamp[data-clamped='true'] {
-webkit-mask-image: linear-gradient(to bottom, #000 55%, transparent);
mask-image: linear-gradient(to bottom, #000 55%, transparent);
}
.composer-human-message:hover .sticky-human-clamp,
.composer-human-message:focus-within .sticky-human-clamp {
max-height: min(var(--human-msg-full, 24rem), 24rem);
overflow-y: auto;
-webkit-mask-image: none;
mask-image: none;
}
/* The thread renders items in natural document flow (padding spacers, not
transforms) and @tanstack/react-virtual already adjusts scrollTop itself
when an off-screen turn is measured and its real height differs from the
220px estimate. The browser's native scroll anchoring (overflow-anchor:
auto) would adjust scrollTop for that SAME size delta, so the two
double-correct and the view lurches most visibly on Windows mouse wheels,
whose coarse notches mount/measure several under-estimated turns per tick.
Opt out of native anchoring so only the virtualizer compensates. */
[data-slot='aui_thread-viewport'] {
overflow-anchor: none;
}
[data-slot='aui_thread-content'] {
max-width: var(--composer-width);
padding-inline: 1.5rem;
@@ -897,8 +950,7 @@ canvas {
background: transparent !important;
}
[data-slot='aui_assistant-message-content']
> :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure']) {
[data-slot='aui_assistant-message-content'] > :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure']) {
opacity: 0.67;
transition: opacity 120ms ease-out;
}
@@ -929,12 +981,8 @@ canvas {
margin-top: 1rem;
}
[data-slot='aui_assistant-message-content']
[data-slot='aui_thinking-disclosure']
+ [data-slot='tool-block'],
[data-slot='aui_assistant-message-content']
[data-slot='tool-block']
+ [data-slot='aui_thinking-disclosure'] {
[data-slot='aui_assistant-message-content'] [data-slot='aui_thinking-disclosure'] + [data-slot='tool-block'],
[data-slot='aui_assistant-message-content'] [data-slot='tool-block'] + [data-slot='aui_thinking-disclosure'] {
margin-top: 0.75rem;
}
+15 -1
View File
@@ -48,7 +48,7 @@ export interface OAuthProviderStatus {
export interface OAuthProvider {
cli_command: string
docs_url: string
flow: 'device_code' | 'external' | 'pkce'
flow: 'device_code' | 'external' | 'loopback' | 'pkce'
id: string
name: string
status: OAuthProviderStatus
@@ -73,6 +73,12 @@ export type OAuthStartResponse =
user_code: string
verification_url: string
}
| {
auth_url: string
expires_in: number
flow: 'loopback'
session_id: string
}
export interface OAuthSubmitResponse {
message?: string
@@ -210,6 +216,14 @@ export interface ModelOptionProvider {
free_tier?: boolean
/** Nous only: paid models a free-tier user cannot select (shown disabled). */
unavailable_models?: string[]
/** Per-model option support, keyed by model id (present when the picker
* requested capabilities). Lets the UI gate fast/reasoning controls. */
capabilities?: Record<string, ModelCapabilities>
}
export interface ModelCapabilities {
fast: boolean
reasoning: boolean
}
export interface ModelOptionsResponse {
+26
View File
@@ -1115,10 +1115,36 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:
from tools.skills_tool import skill_view
from tools.skill_usage import bump_use
from agent.skill_bundles import build_bundle_invocation_message, resolve_bundle_command_key
parts = []
skipped: list[str] = []
for skill_name in skill_names:
# Cron jobs historically accepted only skill names here, but the CLI/gateway
# slash-command path lets bundles shadow skills with the same slug. Mirror
# that behavior so `skills: ["my-bundle"]` expands bundle members instead
# of being treated as a missing skill.
bundle_key = resolve_bundle_command_key(skill_name.lstrip("/"))
if bundle_key:
bundle_payload = build_bundle_invocation_message(
bundle_key,
user_instruction="",
task_id=str(job.get("id") or "") or None,
)
if bundle_payload:
bundle_message, _loaded_bundle_skills, _missing_bundle_skills = bundle_payload
if parts:
parts.append("")
parts.append(bundle_message)
continue
logger.warning(
"Cron job '%s': bundle '%s' could not load any skills, skipping",
job.get("name", job.get("id")),
skill_name,
)
skipped.append(skill_name)
continue
try:
loaded = json.loads(skill_view(skill_name))
except (json.JSONDecodeError, TypeError):
+32
View File
@@ -278,6 +278,38 @@ if [ ! -f "$HERMES_HOME/auth.json" ] && [ -n "${HERMES_AUTH_JSON_BOOTSTRAP:-}" ]
chmod 600 "$HERMES_HOME/auth.json"
fi
# gateway_state.json: declare the gateway's INITIAL supervised state on a
# fresh volume. Same first-boot-only env-seed pattern as auth.json above.
#
# On a blank volume there is no gateway_state.json, so the boot reconciler
# (cont-init.d/02-reconcile-profiles → container_boot.reconcile_profile_gateways)
# registers the gateway-default s6 slot but leaves it DOWN — it only
# auto-starts when the last recorded state was "running". That means a
# freshly-provisioned container comes up with the gateway down until
# someone starts it (e.g. from the dashboard). An orchestrator that
# provisions a fresh volume and wants the gateway running from first boot
# can set HERMES_GATEWAY_BOOTSTRAP_STATE=running; we seed the state file
# here, BEFORE 02-reconcile-profiles runs (cont-init.d scripts run in
# lexicographic order), so the reconciler sees prior_state=running and
# brings the supervised slot up on the very first boot.
#
# This is a generic container contract, not specific to any host: it seeds
# the SAME gateway_state.json the reconciler already consults, exactly as
# HERMES_AUTH_JSON_BOOTSTRAP seeds auth.json. The [ ! -f ] guard is the
# load-bearing part — on every subsequent boot the persisted state wins,
# so a gateway the operator deliberately stopped stays stopped across
# restarts and we never clobber real runtime state.
#
# Only a literal "running" is honoured (the sole value in the reconciler's
# _AUTOSTART_STATES); any other value is ignored so a typo can't write a
# bogus state the reconciler would treat as "no prior state" anyway.
if [ ! -f "$HERMES_HOME/gateway_state.json" ] && \
[ "${HERMES_GATEWAY_BOOTSTRAP_STATE:-}" = "running" ]; then
printf '{"gateway_state":"running"}\n' > "$HERMES_HOME/gateway_state.json"
chown hermes:hermes "$HERMES_HOME/gateway_state.json" 2>/dev/null || true
chmod 644 "$HERMES_HOME/gateway_state.json"
fi
# --- Sync bundled skills ---
# Invoke the venv's python by absolute path so we don't need a `sh -c`
# wrapper to source the activate script. This is safe because
+18 -1
View File
@@ -4195,8 +4195,25 @@ class APIServerAdapter(BasePlatformAdapter):
return False
async def disconnect(self) -> None:
"""Stop the aiohttp web server."""
"""Stop the aiohttp web server and release all owned resources.
Closes the ResponseStore SQLite connection in addition to stopping
the aiohttp web server. Without this, every adapter instance leaks
2 file descriptors (the database file and its WAL sidecar) the
reconnect loop in ``gateway.run`` constructs a fresh adapter on
every retry, so 2 fds/retry × 300s backoff cap 12 fds/hour, which
exhausts the default 2560 fd limit after ~12h of failed reconnects
and turns the whole gateway into a zombie
(OSError: [Errno 24] Too many open files, #37011).
"""
self._mark_disconnected()
if self._response_store is not None:
try:
self._response_store.close()
except Exception:
logger.debug(
"Failed to close response store for %s", self.name, exc_info=True,
)
if self._site:
await self._site.stop()
self._site = None
+113 -6
View File
@@ -1755,6 +1755,60 @@ def _preserve_queued_followup_history_offset(
return merged
async def _dispose_unused_adapter(adapter: "BasePlatformAdapter | None") -> None:
"""Best-effort dispose for an adapter that never made it onto ``self.adapters``.
The reconnect watcher in ``GatewayRunner._platform_reconnect_watcher``
constructs a fresh adapter on every retry attempt. When the connect
call fails for any of the three reasons (non-retryable error,
retryable error, exception during connect) the adapter is dropped
without ever being installed, so nothing else will call its
``disconnect()``. Any resources the adapter opened in ``__init__``
(e.g. ``APIServerAdapter`` opens a SQLite ``ResponseStore`` that
holds 2 fds the db file and its WAL sidecar) stay open until
garbage collection sweeps the unreachable object, which Python's
cyclic GC does not do promptly for asyncio-bound objects with
native handles. The cumulative leak is 2 fds × every retry at the
300s backoff cap 12 fds/hour, and the default 2560-fd ulimit
is exhausted in ~12h of continuous failure, after which every
open() call on the gateway raises ``OSError: [Errno 24] Too many
open files`` and the gateway becomes a zombie (#37011).
This helper centralises the dispose-with-suppression so the three
failure paths in the reconnect watcher can all call it without
each one having to know that ``disconnect()`` may itself raise
on a half-constructed adapter.
``adapter`` may be ``None``: the reconnect watcher initialises
``adapter = None`` before the ``try`` so the ``except Exception``
arm can dispose a half-constructed object, and also early-returns
here when ``_create_adapter()`` returned ``None``.
"""
if adapter is None:
return
try:
await adapter.disconnect()
except Exception:
# Half-constructed adapters (e.g. APIServerAdapter that
# crashed during aiohttp app setup) can raise from
# disconnect() on objects that never finished initializing.
# We must not let that escape and abort the watcher loop.
#
# On Python 3.8+, ``asyncio.CancelledError`` inherits from
# ``BaseException`` (not ``Exception``), so this ``except
# Exception`` does not swallow task cancellation. We don't
# re-raise explicitly because the watcher loop intentionally
# treats dispose failures as best-effort: a failed ``disconnect``
# call should not take down the reconnect watcher that
# itself is what's keeping the gateway alive during a partial
# outage.
logger.debug(
"Adapter dispose raised on unowned adapter %r",
getattr(adapter, "name", type(adapter).__name__),
exc_info=True,
)
class GatewayRunner:
"""
Main gateway controller.
@@ -6175,6 +6229,7 @@ class GatewayRunner:
platform.value, attempt,
)
adapter = None
try:
adapter = self._create_adapter(platform, platform_config)
if not adapter:
@@ -6224,6 +6279,15 @@ class GatewayRunner:
"Reconnect %s: non-retryable error (%s), removing from retry queue",
platform.value, adapter.fatal_error_message,
)
# The adapter is about to be dropped from the queue
# without ever being installed on self.adapters, so
# nothing else will call disconnect() on it. We must
# dispose it here, otherwise the resource owners it
# constructed in __init__ (ResponseStore for
# APIServerAdapter, etc.) leak 2 fds each. The
# gateway hits the 2560-fd limit after ~12h of
# failed reconnects at the 300s backoff cap (#37011).
await _dispose_unused_adapter(adapter)
del self._failed_platforms[platform]
else:
self._update_platform_runtime_status(
@@ -6239,6 +6303,14 @@ class GatewayRunner:
"Reconnect %s failed, next retry in %ds",
platform.value, backoff,
)
# Same fd-leak concern as the non-retryable branch
# above: the adapter failed to connect and is being
# thrown away. Without an explicit dispose call, the
# resources it opened in __init__ stay open until
# the next GC pass — and aiohttp/SQLite handles
# don't get GC'd promptly, so 2 fds/retry leak at
# 300s backoff cap = ~12 fds/hour (#37011).
await _dispose_unused_adapter(adapter)
# Retryable failures (network/DNS blips) keep retrying
# at the backoff cap indefinitely — they self-heal once
# connectivity returns. We do NOT auto-pause them: a
@@ -6248,6 +6320,14 @@ class GatewayRunner:
# `not fatal_error_retryable` branch above, so anything
# reaching here is by definition retryable.
except Exception as e:
if adapter is not None:
# An exception escaping the connect call path
# (DNS timeout, aiohttp server.start() crash, etc.)
# leaves the adapter in the same unowned state as
# the two branches above. Dispose so __init__
# resources don't accumulate while the watcher
# keeps retrying.
await _dispose_unused_adapter(adapter)
self._update_platform_runtime_status(
platform.value,
platform_state="retrying",
@@ -12508,14 +12588,41 @@ class GatewayRunner:
except Exception:
pass
# Send media files
# Send media files, routing each by type so a TTS clip
# arrives as a voice bubble / a clip as a video rather than
# a generic document. Mirrors the streaming + kanban paths.
from gateway.platforms.base import (
should_send_media_as_audio as _should_send_media_as_audio,
)
_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp"}
_VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".3gp"}
for media_path, _is_voice in (media_files or []):
_ext = os.path.splitext(media_path)[1].lower()
try:
await adapter.send_document(
chat_id=source.chat_id,
file_path=media_path,
metadata=_thread_metadata,
)
if _should_send_media_as_audio(source.platform, _ext, _is_voice):
await adapter.send_voice(
chat_id=source.chat_id,
audio_path=media_path,
metadata=_thread_metadata,
)
elif _ext in _VIDEO_EXTS:
await adapter.send_video(
chat_id=source.chat_id,
video_path=media_path,
metadata=_thread_metadata,
)
elif _ext in _IMAGE_EXTS:
await adapter.send_image_file(
chat_id=source.chat_id,
image_path=media_path,
metadata=_thread_metadata,
)
else:
await adapter.send_document(
chat_id=source.chat_id,
file_path=media_path,
metadata=_thread_metadata,
)
except Exception:
pass
else:
+16
View File
@@ -41,6 +41,8 @@ _EPILOGUE = """
Examples:
hermes Start interactive chat
hermes chat -q "Hello" Single query mode
hermes --tui Launch the modern TUI (or set display.interface: tui)
hermes --cli Force the classic REPL (overrides display.interface: tui)
hermes -c Resume the most recent session
hermes -c "my project" Resume a session by name (latest in lineage)
hermes --resume <session_id> Resume a specific session by ID
@@ -218,6 +220,13 @@ def build_top_level_parser():
default=False,
help="Launch the modern TUI instead of the classic REPL",
)
_inherited_flag(
parser,
"--cli",
action="store_true",
default=False,
help="Force the classic prompt_toolkit REPL (overrides display.interface=tui)",
)
_inherited_flag(
parser,
"--dev",
@@ -369,6 +378,13 @@ def build_top_level_parser():
default=False,
help="Launch the modern TUI instead of the classic REPL",
)
_inherited_flag(
chat_parser,
"--cli",
action="store_true",
default=False,
help="Force the classic prompt_toolkit REPL (overrides display.interface=tui)",
)
_inherited_flag(
chat_parser,
"--dev",
+7 -1
View File
@@ -1283,6 +1283,12 @@ DEFAULT_CONFIG = {
# behavior of showing tool-call summaries inline.
"resume_skip_tool_only": True,
"busy_input_mode": "interrupt", # interrupt | queue | steer
# Which interface bare `hermes` (and `hermes chat`) launches by default:
# "cli" — the classic prompt_toolkit REPL (default, preserves prior behavior)
# "tui" — the modern Ink TUI (same as passing `--tui`)
# Explicit flags always win over this setting: `--cli` forces the classic
# REPL and `--tui` (or HERMES_TUI=1) forces the TUI regardless of config.
"interface": "cli",
# When true, `hermes --tui` auto-resumes the most recent human-
# facing session on launch instead of forging a fresh one.
# Mirrors `hermes -c` muscle memory. Default off so existing
@@ -2284,7 +2290,7 @@ DEFAULT_CONFIG = {
# Config schema version - bump this when adding new required fields
"_config_version": 25,
"_config_version": 26,
}
# =============================================================================
+84 -10
View File
@@ -1,16 +1,32 @@
"""Short-lived single-use tickets for WS-upgrade auth in gated mode.
"""WS-upgrade auth credentials for gated mode.
Browsers cannot set ``Authorization`` on a WebSocket upgrade. In loopback
mode the legacy ``?token=<_SESSION_TOKEN>`` query param works because the
token is injected into the SPA bundle. In gated mode there is no injected
token the SPA gets a fresh ticket via the authenticated REST endpoint
``POST /api/auth/ws-ticket`` and passes that as ``?ticket=`` on the
WS upgrade.
token so this module provides two credential shapes:
Tickets are single-use, TTL = 30 seconds. In-memory; the dashboard is a
single process so no distributed coordination is needed. The module
exposes a small functional API rather than a class so tests can patch
``time.time`` cleanly.
1. **Single-use browser tickets** (``mint_ticket`` / ``consume_ticket``).
The SPA gets a fresh ticket via the authenticated REST endpoint
``POST /api/auth/ws-ticket`` and passes it as ``?ticket=`` on the WS
upgrade. Single-use, TTL = 30 seconds a leaked ticket is uninteresting.
2. **A process-lifetime internal credential** (``internal_ws_credential`` /
``consume_internal_credential``). This authenticates *server-spawned*
WS clients specifically the embedded-TUI PTY child, which attaches to
``/api/ws`` (JSON-RPC gateway) and ``/api/pub`` (event sidecar) over
loopback. A single-use 30s ticket is the wrong shape for that link: the
child reads its attach URL once at startup and **reuses it on every
reconnect**, and on a slow cold boot the child may not dial within 30s.
The internal credential is minted once per process, never expires, is
multi-use, and critically is **never injected into any HTML/SPA**:
it only ever leaves the process via the spawned child's environment, so
browser-side XSS cannot read it. A leaked internal credential grants no
more than a single-use ticket already does (the same two internal WS
endpoints), and the same Origin / host guards still apply downstream.
In-memory; the dashboard is a single process so no distributed coordination
is needed. The module exposes a small functional API rather than a class so
tests can patch ``time.time`` cleanly.
"""
from __future__ import annotations
@@ -18,7 +34,7 @@ from __future__ import annotations
import secrets
import threading
import time
from typing import Any, Dict, Tuple
from typing import Any, Dict, Optional, Tuple
#: Time-to-live for newly-minted tickets in seconds. 30 s is long enough
#: that the SPA can call ``getWsTicket()`` and immediately open the WS,
@@ -28,6 +44,16 @@ TTL_SECONDS = 30
_lock = threading.Lock()
_tickets: Dict[str, Tuple[int, Dict[str, Any]]] = {} # ticket -> (expires_at, info)
#: The process-lifetime internal credential (see module docstring). Lazily
#: minted on first ``internal_ws_credential()`` call and stable for the life
#: of the process. Guarded by ``_lock``.
_internal_credential: Optional[str] = None
#: Identity recorded for connections that authenticate via the internal
#: credential, so audit logs distinguish them from browser-initiated tickets.
INTERNAL_USER_ID = "server-internal"
INTERNAL_PROVIDER = "server-internal"
class TicketInvalid(Exception):
"""Ticket missing, expired, or already consumed."""
@@ -81,7 +107,55 @@ def _gc_expired_locked() -> None:
_tickets.pop(t, None)
def internal_ws_credential() -> str:
"""Return the process-lifetime internal WS credential, minting it once.
Used by the server to authenticate WS clients it spawns itself (the
embedded-TUI PTY child). The value is stable for the life of the process,
multi-use, and never expires so a server-spawned child can reconnect
its ``/api/ws`` / ``/api/pub`` sockets indefinitely without re-minting.
The credential is never injected into the SPA HTML or returned over any
REST endpoint; it is only ever passed to a child process via its
environment. See the module docstring for the threat-model rationale.
"""
global _internal_credential
with _lock:
if _internal_credential is None:
_internal_credential = secrets.token_urlsafe(32)
return _internal_credential
def consume_internal_credential(value: str) -> Dict[str, Any]:
"""Validate an internal credential. Raises :class:`TicketInvalid` on mismatch.
Unlike :func:`consume_ticket` this is **not** single-use the value is
not removed on success, so a server-spawned child can present it on every
(re)connect. Returns the fixed server-internal identity ``info`` dict
(``{user_id, provider}``), mirroring the ``info`` shape ``consume_ticket``
returns, so a caller that wants to record the connecting identity can; the
current ``_ws_auth_ok`` caller validates for the boolean outcome only and
discards the dict.
A constant-time compare against the (lazily-minted) credential avoids
leaking length / prefix information on mismatch. If no internal
credential has been minted yet, any value is rejected.
"""
with _lock:
expected = _internal_credential
if not value or expected is None:
raise TicketInvalid("no internal credential")
if not secrets.compare_digest(value.encode(), expected.encode()):
raise TicketInvalid("internal credential mismatch")
return {
"user_id": INTERNAL_USER_ID,
"provider": INTERNAL_PROVIDER,
}
def _reset_for_tests() -> None:
"""Test-only: drop all tickets."""
"""Test-only: drop all tickets and the internal credential."""
global _internal_credential
with _lock:
_tickets.clear()
_internal_credential = None
+45
View File
@@ -115,6 +115,7 @@ def build_models_payload(
picker_hints: bool = False,
canonical_order: bool = False,
pricing: bool = False,
capabilities: bool = False,
max_models: int = 50,
) -> dict:
"""Build the ``{providers, model, provider}`` shape every consumer
@@ -134,6 +135,10 @@ def build_models_payload(
show $/Mtok columns and gate paid models on free accounts
mirroring the ``hermes model`` CLI picker. Adds network calls
(pricing fetch + Nous tier check); only set for interactive pickers.
- ``capabilities``: add a per-row ``capabilities`` map
``{model: {fast, reasoning}}`` so pickers can gate the model-options
controls (fast toggle / reasoning) to what each model actually
supports, instead of offering knobs the backend would reject.
"""
from hermes_cli.model_switch import list_authenticated_providers
@@ -154,6 +159,8 @@ def build_models_payload(
rows = _reorder_canonical(rows)
if pricing:
_apply_pricing(rows)
if capabilities:
_apply_capabilities(rows)
return {
"providers": rows,
@@ -162,6 +169,44 @@ def build_models_payload(
}
def _apply_capabilities(rows: list[dict]) -> None:
"""Attach a ``{model: {fast, reasoning}}`` map to each provider row.
`fast` mirrors ``model_supports_fast_mode`` (the same gate the runtime
enforces). `reasoning` comes from the models.dev catalog when known and
defaults to True otherwise the effort dial is broadly accepted and a
no-op on models that ignore it, whereas hiding it from a capable-but-
uncatalogued model is the worse failure.
"""
from hermes_cli.models import model_supports_fast_mode
try:
from agent.models_dev import get_model_capabilities
except Exception:
get_model_capabilities = None # type: ignore[assignment]
for row in rows:
slug = row.get("slug") or ""
caps: dict[str, dict[str, bool]] = {}
for model in row.get("models") or []:
reasoning = True
if get_model_capabilities is not None and slug:
try:
meta = get_model_capabilities(slug, model)
if meta is not None:
reasoning = bool(meta.supports_reasoning)
except Exception:
reasoning = True
caps[model] = {
"fast": bool(model_supports_fast_mode(model)),
"reasoning": reasoning,
}
row["capabilities"] = caps
# ─── Internal: row post-processing ──────────────────────────────────────
+268 -56
View File
@@ -105,6 +105,58 @@ def _set_process_title() -> None:
pass
# Cheap, dependency-free read of `display.interface` from config.yaml for the
# earliest hot-path decisions (mouse-residue suppression, Termux fast launch)
# that run *before* hermes_cli.config is importable. Mirrors the explicit
# precedence used everywhere else: `--cli` always wins, then `--tui`/env, then
# this config value. Cached so the multiple early callers don't re-parse YAML.
_EARLY_INTERFACE_CACHE: "list | None" = None
def _config_default_interface_early() -> str:
"""Return the configured default interface ("cli"/"tui") via a minimal
YAML read. Best-effort: any error falls back to "cli" (legacy behavior)."""
global _EARLY_INTERFACE_CACHE
if _EARLY_INTERFACE_CACHE is not None:
return _EARLY_INTERFACE_CACHE[0]
value = "cli"
try:
home = os.environ.get("HERMES_HOME")
if home:
cfg_path = os.path.join(home, "config.yaml")
else:
cfg_path = os.path.join(os.path.expanduser("~"), ".hermes", "config.yaml")
if os.path.exists(cfg_path):
import yaml as _yaml_iface
with open(cfg_path, encoding="utf-8") as _f:
raw = _yaml_iface.safe_load(_f) or {}
disp = raw.get("display", {})
if isinstance(disp, dict):
iface = disp.get("interface")
if isinstance(iface, str) and iface.strip().lower() == "tui":
value = "tui"
except Exception:
value = "cli" # best-effort — default to classic REPL on any error
_EARLY_INTERFACE_CACHE = [value]
return value
def _wants_tui_early(argv: "list[str] | None" = None) -> bool:
"""Earliest TUI decision, usable before argparse/config imports.
Precedence: explicit ``--cli`` wins (forces classic REPL), then
``--tui``/``HERMES_TUI=1``, then ``display.interface`` in config.
"""
if argv is None:
argv = sys.argv[1:]
if "--cli" in argv:
return False
if os.environ.get("HERMES_TUI") == "1" or "--tui" in argv:
return True
return _config_default_interface_early() == "tui"
# Mouse-tracking residue suppression — runs BEFORE every other import on the
# TUI hot path so the terminal stops emitting SGR/X10 mouse reports while the
# Python launcher is still doing imports (≈100300ms in cooked + echo mode,
@@ -116,7 +168,7 @@ def _set_process_title() -> None:
def _suppress_mouse_residue_early() -> None:
if os.environ.get("HERMES_TUI_NO_EARLY_DISABLE") == "1":
return
if not (os.environ.get("HERMES_TUI") == "1" or "--tui" in sys.argv[1:]):
if not _wants_tui_early():
return
try:
# Skip when stdout is redirected (`hermes --tui … >log`, CI capture):
@@ -204,6 +256,7 @@ import argparse
import hashlib
import json
import shutil
import stat
import subprocess
from pathlib import Path
from typing import Optional
@@ -1184,6 +1237,33 @@ to avoid false-positive reinstalls on every launch.
"""
def _workspace_root(dir: Path) -> Path:
"""Return the npm workspace root for *dir*.
In a workspace checkout the single ``package-lock.json`` and hoisted
``node_modules/`` live at the workspace root (the parent of the
sub-package directory). Heuristic: if *dir* has a ``package.json``
but **no** ``package-lock.json``, and its **parent** has a
``package-lock.json``, the parent is the workspace root.
Otherwise *dir* itself is the root (standalone project or
prebuilt-bundle layout).
Used by ``_tui_need_npm_install``, ``_make_tui_argv``, and
``_build_web_ui`` so that lockfile/node_modules resolution and
``npm install`` cwd stay consistent a single helper prevents
the checks from diverging if someone accidentally creates a
sub-package lockfile (e.g. running ``npm install`` in the wrong
directory).
"""
if (
(dir / "package.json").is_file()
and not (dir / "package-lock.json").is_file()
and (dir.parent / "package-lock.json").is_file()
):
return dir.parent
return dir
def _tui_need_npm_install(root: Path) -> bool:
"""True when @hermes/ink is missing or node_modules is behind package-lock.json.
@@ -1192,6 +1272,12 @@ def _tui_need_npm_install(root: Path) -> bool:
``package.json``), skip reinstall entirely the bundle is self-contained
and there is nothing to install.
With npm workspaces the single ``package-lock.json`` and the hoisted
``node_modules/`` live at the workspace root (the parent of the
``ui-tui/`` directory). The lockfile / ink / marker checks use that
workspace root; only the prebuilt-bundle sentinel stays relative to
*root* (``ui-tui/dist/entry.js``).
Compares ``package-lock.json`` against ``node_modules/.package-lock.json``
(npm's hidden lockfile) by **content**, not mtime: git checkouts and npm
rewrites can bump the root lockfile's timestamp even when installed deps
@@ -1209,19 +1295,21 @@ def _tui_need_npm_install(root: Path) -> bool:
we'd rather not force a reinstall for them. Falls back to mtime
comparison if either lockfile is unparseable.
"""
lock = root / "package-lock.json"
entry = root / "dist" / "entry.js"
# Prebuilt self-contained bundle (nix / packaged release): no lockfile
# shipped, dist/entry.js is the single runtime artefact.
entry = root / "dist" / "entry.js"
# With npm workspaces the lockfile lives at the workspace root.
ws_root = _workspace_root(root)
lock = ws_root / "package-lock.json"
if entry.is_file() and not lock.is_file():
return False
ink = root / "node_modules" / "@hermes" / "ink" / "package.json"
ink = ws_root / "node_modules" / "@hermes" / "ink" / "package.json"
if not ink.is_file():
return True
if not lock.is_file():
return False
marker = root / "node_modules" / ".package-lock.json"
marker = ws_root / "node_modules" / ".package-lock.json"
if not marker.is_file():
return True
@@ -1270,7 +1358,6 @@ _TUI_BUILD_INPUT_FILES = (
"babel.compiler.config.cjs",
"scripts/build.mjs",
"packages/hermes-ink/package.json",
"packages/hermes-ink/package-lock.json",
"packages/hermes-ink/index.js",
"packages/hermes-ink/text-input.js",
)
@@ -1437,6 +1524,8 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
# 2. Normal flow: npm install if needed, always esbuild, then node dist/entry.js.
# --dev flow: npm install if needed, then tsx src/entry.tsx.
# npm install runs from the workspace root (where package-lock.json lives);
# npm workspaces resolves ui-tui deps automatically.
did_install = False
if _tui_need_npm_install(tui_dir):
npm = _node_bin("npm")
@@ -1444,7 +1533,7 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
print("Installing TUI dependencies…")
result = subprocess.run(
[npm, "install", "--silent", "--no-fund", "--no-audit", "--progress=false"],
cwd=str(tui_dir),
cwd=str(_workspace_root(tui_dir)),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
@@ -1731,9 +1820,34 @@ def _sync_bundled_skills_quietly() -> None:
pass
def _resolve_use_tui(args) -> bool:
"""Decide whether to launch the TUI for a chat/bare invocation.
Precedence (highest first):
1. ``--cli`` flag always classic REPL
2. ``--tui`` flag / ``HERMES_TUI=1`` always TUI
3. ``display.interface`` config value ("cli" | "tui")
4. default classic REPL
Explicit flags always win over config so muscle memory and scripts keep
working regardless of the configured default.
"""
if getattr(args, "cli", False):
return False
if getattr(args, "tui", False) or os.environ.get("HERMES_TUI") == "1":
return True
try:
from hermes_cli.config import load_config
iface = (load_config().get("display", {}) or {}).get("interface", "cli")
return isinstance(iface, str) and iface.strip().lower() == "tui"
except Exception:
return False
def cmd_chat(args):
"""Run interactive chat CLI."""
use_tui = getattr(args, "tui", False) or os.environ.get("HERMES_TUI") == "1"
use_tui = _resolve_use_tui(args)
# Resolve --continue into --resume with the latest session or by name
continue_val = getattr(args, "continue_last", None)
@@ -6604,7 +6718,6 @@ def _web_ui_build_needed(web_dir: Path) -> bool:
return True
for meta in (
"package.json",
"package-lock.json",
"yarn.lock",
"pnpm-lock.yaml",
"vite.config.ts",
@@ -6613,6 +6726,10 @@ def _web_ui_build_needed(web_dir: Path) -> bool:
mp = web_dir / meta
if mp.exists() and mp.stat().st_mtime > dist_mtime:
return True
# Workspace root lockfile (single package-lock.json covers all workspaces).
root_lock = project_root / "package-lock.json"
if root_lock.exists() and root_lock.stat().st_mtime > dist_mtime:
return True
return False
@@ -6809,7 +6926,11 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
if text:
_say(text)
r1 = _run_npm_install_deterministic(npm, web_dir, extra_args=("--silent",))
r1 = _run_npm_install_deterministic(
npm,
_workspace_root(web_dir),
extra_args=("--silent",),
)
if r1.returncode != 0:
_say(
f" {'' if fatal else ''} Web UI npm install failed"
@@ -7070,6 +7191,45 @@ def _desktop_macos_relaunchable_fixup(desktop_dir: Path) -> None:
except Exception as exc:
print(f" (warning: macOS relaunch fixup skipped: {exc})")
def _desktop_linux_sandbox_fixup(packaged_executable: Path) -> bool:
"""Configure Electron's Linux SUID sandbox helper when required."""
if sys.platform != "linux":
return True
sandbox = packaged_executable.parent / "chrome-sandbox"
if not sandbox.exists():
print(f"✗ Hermes Desktop is missing Electron's Linux sandbox helper: {sandbox}")
return False
# Reject symlinks — chown/chmod must not follow an attacker-controlled
# link to an arbitrary path. Use lstat() so we inspect the link itself
# rather than the target, and require a regular file.
try:
sandbox_lstat = sandbox.lstat()
except OSError:
print(f"✗ Cannot stat Electron's Linux sandbox helper: {sandbox}")
return False
if not stat.S_ISREG(sandbox_lstat.st_mode):
print(f"✗ Electron's Linux sandbox helper is not a regular file: {sandbox}")
return False
if sandbox_lstat.st_uid == 0 and stat.S_IMODE(sandbox_lstat.st_mode) == 0o4755:
return True
sudo = shutil.which("sudo")
if not sudo:
print("✗ Hermes Desktop requires sudo to configure Electron's Linux sandbox helper.")
return False
print("→ Configuring Electron Linux sandbox helper (sudo required)...")
for command in ([sudo, "chown", "root:root", str(sandbox)], [sudo, "chmod", "4755", str(sandbox)]):
if subprocess.run(command, check=False).returncode != 0:
print(f"✗ Failed to configure Electron's Linux sandbox helper: {sandbox}")
return False
return True
def cmd_gui(args: argparse.Namespace):
"""Build and launch the native Electron desktop GUI."""
desktop_dir = PROJECT_ROOT / "apps" / "desktop"
@@ -7195,6 +7355,9 @@ def cmd_gui(args: argparse.Namespace):
print(" Expected an unpacked Electron app for the current OS.")
sys.exit(1)
if not _desktop_linux_sandbox_fixup(packaged_executable):
sys.exit(1)
print(f"→ Launching packaged Hermes Desktop: {packaged_executable}")
launch_result = subprocess.run([str(packaged_executable)], cwd=desktop_dir, env=env, check=False)
sys.exit(launch_result.returncode)
@@ -7636,8 +7799,21 @@ def _update_via_zip(args):
# individually so update does not silently strip working capabilities.
print("→ Updating Python dependencies...")
from hermes_cli.managed_uv import ensure_uv, rebuild_venv, update_managed_uv
# Keep managed uv current — runs `uv self update` if we already have one.
update_managed_uv()
uv_bin, fresh_bootstrap = ensure_uv()
# First-time managed uv install on an existing checkout: the old venv
# may point to a Python without FTS5. Rebuild it so the new managed
# uv provides a fresh interpreter with FTS5 guaranteed.
if fresh_bootstrap and uv_bin:
rebuild_venv(uv_bin, PROJECT_ROOT / "venv")
pip_cmd = [sys.executable, "-m", "pip"]
uv_bin = shutil.which("uv") or _ensure_uv_for_termux(pip_cmd)
if not uv_bin:
uv_bin = _ensure_uv_for_termux(pip_cmd)
if uv_bin:
uv_env = {**os.environ, "VIRTUAL_ENV": str(PROJECT_ROOT / "venv")}
if _is_termux_env(uv_env):
@@ -8737,16 +8913,27 @@ def _install_psutil_android_compat(
def _ensure_uv_for_termux(pip_cmd: list[str]) -> str | None:
"""Best-effort uv bootstrap on Termux for faster update installs."""
uv_bin = shutil.which("uv")
if uv_bin or not _is_termux_env():
return uv_bin
"""Best-effort uv bootstrap on Termux for faster update installs.
The normal path (``ensure_uv()`` in managed_uv) installs the managed
standalone uv into ``$HERMES_HOME/bin/uv``, but on Termux the official
installer may not work (glibc vs bionic). Fall back to ``pip install uv``
which gets a Termux-compatible binary.
"""
from hermes_cli.managed_uv import resolve_uv
existing = resolve_uv()
if existing:
return existing
if not _is_termux_env():
return None
try:
print(" → Termux detected: trying to install uv for faster dependency updates...")
subprocess.run(pip_cmd + ["install", "uv"], cwd=PROJECT_ROOT, check=False)
except Exception:
pass
return shutil.which("uv")
# After pip install, check managed path first, then PATH
return resolve_uv() or shutil.which("uv")
def _update_node_dependencies() -> None:
@@ -8754,45 +8941,48 @@ def _update_node_dependencies() -> None:
if not npm:
return
paths = (
("repo root", PROJECT_ROOT),
("ui-tui", PROJECT_ROOT / "ui-tui"),
)
if not any((path / "package.json").exists() for _, path in paths):
if not (PROJECT_ROOT / "package.json").exists():
return
# With a single workspace lockfile the root install would cover ALL
# workspaces — but apps/desktop pulls in Electron as a devDependency,
# and its postinstall downloads a ~200MB binary. Most users don't
# need desktop during `hermes update`, so we install root-only first
# then add just the workspaces the CLI/TUI/web build actually requires.
# Desktop deps are installed on demand by the desktop launcher
# (see _desktop_build_needed).
print("→ Updating Node.js dependencies...")
for label, path in paths:
if not (path / "package.json").exists():
continue
extra_args = ["--no-fund", "--no-audit", "--progress=false"]
# Stream npm output (no `--silent`, no `capture_output`) so any
# optional dependency postinstall scripts (e.g. `agent-browser`'s
# Chromium fetch on first install) print progress instead of
# appearing to hang silently for minutes (#18840). The
# `_UpdateOutputStream` wrapper installed by the updater mirrors
# streamed output to ``~/.hermes/logs/update.log`` so nothing is lost.
#
# The repo root install also passes `--workspaces=false` so npm
# does not recursively install every `apps/*` workspace (dashboard,
# desktop, shared) — those are installed/built on demand via
# `_build_web_ui()` and the desktop launchers.
extra_args = ["--no-fund", "--no-audit", "--progress=false"]
if path == PROJECT_ROOT:
extra_args.append("--workspaces=false")
# Step 1: root install (no workspace recursion).
root_args = [*extra_args, "--workspaces=false"]
root_result = _run_npm_install_deterministic(
npm,
PROJECT_ROOT,
extra_args=tuple(root_args),
capture_output=False,
)
if root_result.returncode != 0:
print(" ⚠ npm install failed in repo root")
stderr = (root_result.stderr or "").strip() if root_result.stderr else ""
if stderr:
print(f" {stderr.splitlines()[-1]}")
return
result = _run_npm_install_deterministic(
npm,
path,
extra_args=tuple(extra_args),
capture_output=False,
)
if result.returncode == 0:
print(f"{label}")
continue
print(f" ⚠ npm install failed in {label}")
stderr = (result.stderr or "").strip() if result.stderr else ""
# Step 2: install only the workspaces update needs (ui-tui, web).
# --workspace selects specific workspaces; the rest (desktop) are skipped.
ws_args = [*extra_args, "--workspace", "ui-tui", "--workspace", "web"]
ws_result = _run_npm_install_deterministic(
npm,
PROJECT_ROOT,
extra_args=tuple(ws_args),
capture_output=False,
)
if ws_result.returncode == 0:
print(" ✓ repo root + ui-tui, web workspaces (desktop skipped)")
else:
print(" ⚠ npm workspace install failed")
stderr = (ws_result.stderr or "").strip() if ws_result.stderr else ""
if stderr:
print(f" {stderr.splitlines()[-1]}")
@@ -9394,7 +9584,12 @@ def _cmd_update_pip(args):
print(f"→ Current version: {__version__}")
print("→ Checking PyPI for updates...")
uv = shutil.which("uv")
from hermes_cli.managed_uv import ensure_uv, update_managed_uv
# Keep managed uv current before using it.
update_managed_uv()
uv, _fresh_bootstrap = ensure_uv()
in_venv = sys.prefix != sys.base_prefix
# pipx-managed installs live under .../pipx/venvs/<name>/...
pipx_managed = "pipx" in sys.prefix.split(os.sep)
@@ -9409,7 +9604,8 @@ def _cmd_update_pip(args):
if is_uv_tool_install():
if not uv:
print("✗ Detected a uv-tool install but `uv` is not on PATH; install uv and retry.")
print("✗ Detected a uv-tool install but managed uv install failed.")
print(" Install uv manually: https://docs.astral.sh/uv/getting-started/installation/")
sys.exit(1)
cmd = [uv, "tool", "upgrade", "hermes-agent"]
elif pipx_managed and pipx:
@@ -9805,8 +10001,21 @@ def _cmd_update_impl(args, gateway_mode: bool):
# breaks on this machine, keep base deps and reinstall the remaining extras
# individually so update does not silently strip working capabilities.
print("→ Updating Python dependencies...")
from hermes_cli.managed_uv import ensure_uv, rebuild_venv, update_managed_uv
# Keep managed uv current — runs `uv self update` if we already have one.
update_managed_uv()
uv_bin, fresh_bootstrap = ensure_uv()
# First-time managed uv install on an existing checkout: the old venv
# may point to a Python without FTS5. Rebuild it so the new managed
# uv provides a fresh interpreter with FTS5 guaranteed.
if fresh_bootstrap and uv_bin:
rebuild_venv(uv_bin, PROJECT_ROOT / "venv")
pip_cmd = [sys.executable, "-m", "pip"]
uv_bin = shutil.which("uv") or _ensure_uv_for_termux(pip_cmd)
if not uv_bin:
uv_bin = _ensure_uv_for_termux(pip_cmd)
install_group = "all"
if uv_bin:
@@ -11852,7 +12061,10 @@ def _try_termux_fast_cli_launch() -> bool:
argv = sys.argv[1:]
if "-h" in argv or "--help" in argv:
return False
if os.environ.get("HERMES_TUI") == "1" or "--tui" in argv:
# Let the TUI fast path (or full dispatch) handle anything that resolves to
# the TUI — explicit --tui/env or display.interface=tui. `--cli` forces this
# to stay False so the classic fast path still runs.
if _wants_tui_early(argv):
return False
if _is_termux_fast_version_argv(argv):
@@ -11927,7 +12139,7 @@ def _try_termux_fast_tui_launch() -> bool:
if "-h" in sys.argv[1:] or "--help" in sys.argv[1:]:
return False
wants_tui = os.environ.get("HERMES_TUI") == "1" or "--tui" in sys.argv[1:]
wants_tui = _wants_tui_early(sys.argv[1:])
if not wants_tui:
return False
@@ -11946,7 +12158,7 @@ def _try_termux_fast_tui_launch() -> bool:
return False
if getattr(args, "command", None) not in {None, "chat"}:
return False
if not (getattr(args, "tui", False) or os.environ.get("HERMES_TUI") == "1"):
if not _resolve_use_tui(args):
return False
cmd_chat(args)
+228
View File
@@ -0,0 +1,228 @@
"""Managed uv — one path, no guessing.
Hermes owns its own uv binary at ``$HERMES_HOME/bin/uv`` (or ``uv.exe`` on
Windows). Every code path that needs uv resolves it from that single location.
If the binary is missing, ``ensure_uv()`` bootstraps it via the official
standalone installer with ``UV_UNMANAGED_INSTALL`` / ``UV_INSTALL_DIR`` pointed
at ``$HERMES_HOME/bin`` so the installer writes directly there no PATH
probing, no conda guards, no multi-location resolution chains.
When ``ensure_uv()`` bootstraps uv for the first time (i.e. there was no
managed uv before), it returns ``(path, True)`` instead of just ``path``.
Callers in the update path use that signal to nuke and recreate the venv
with the now-current managed uv, guaranteeing a Python with FTS5.
"""
from __future__ import annotations
import logging
import os
import platform
import shutil
import subprocess
import tempfile
from pathlib import Path
from typing import Optional, Tuple
from hermes_constants import get_hermes_home
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Public helpers
# ---------------------------------------------------------------------------
def managed_uv_path() -> Path:
"""Return the path where Hermes keeps *its* uv binary.
``$HERMES_HOME/bin/uv`` on POSIX, ``$HERMES_HOME\\bin\\uv.exe`` on
Windows. The directory may not exist yet callers should use
``ensure_uv()`` to bootstrap it.
"""
home = get_hermes_home()
if platform.system() == "Windows":
return home / "bin" / "uv.exe"
return home / "bin" / "uv"
def resolve_uv() -> Optional[str]:
"""Return the managed uv path if it exists, else ``None``.
No side effects pure lookup.
"""
p = managed_uv_path()
if p.is_file() and os.access(p, os.X_OK):
return str(p)
return None
def ensure_uv() -> Tuple[Optional[str], bool]:
"""Return the managed uv path, installing it first if necessary.
Returns ``(path, freshly_bootstrapped)`` where *freshly_bootstrapped* is
``True`` when we just installed managed uv for the first time (there was
no managed uv before this call). Callers can use that signal to rebuild
the venv so Python is guaranteed to have FTS5.
On failure returns ``(None, False)`` (never raises) so callers can fall
back to pip gracefully.
"""
existing = resolve_uv()
if existing:
return (existing, False)
target = managed_uv_path()
target.parent.mkdir(parents=True, exist_ok=True)
print(f" → Installing managed uv into {target.parent} ...")
try:
_install_uv(target)
except Exception as exc:
logger.warning("Managed uv install failed: %s", exc)
print(f" ✗ Failed to install managed uv: {exc}")
return (None, False)
# Verify
result = resolve_uv()
if result:
version = subprocess.run(
[result, "--version"],
capture_output=True,
text=True,
check=False,
).stdout.strip()
print(f" ✓ Managed uv installed ({version})")
else:
print(" ✗ Managed uv install appeared to succeed but binary not found")
return (result, result is not None)
def rebuild_venv(uv_bin: str, venv_dir: Path, python_version: str = "3.11") -> bool:
"""Nuke and recreate the venv with managed uv.
Called when managed uv is first bootstrapped on an existing install the
old venv may point to a Python without FTS5, so we rebuild it with a
fresh interpreter from the current managed uv. Returns ``True`` on
success.
"""
if venv_dir.exists():
print(f" → Rebuilding venv (old Python may lack FTS5)...")
shutil.rmtree(venv_dir, ignore_errors=True)
result = subprocess.run(
[uv_bin, "venv", str(venv_dir), "--python", python_version],
capture_output=True,
text=True,
check=False,
)
if result.returncode == 0:
venv_python = venv_dir / ("Scripts" if platform.system() == "Windows" else "bin") / "python"
py_ver = subprocess.run(
[str(venv_python), "--version"],
capture_output=True,
text=True,
check=False,
).stdout.strip()
print(f" ✓ venv rebuilt ({py_ver})")
return True
else:
logger.warning("venv rebuild failed: %s", result.stderr)
print(f" ✗ venv rebuild failed: {result.stderr.strip()}")
return False
def update_managed_uv() -> Optional[str]:
"""Run ``uv self update`` on the managed uv binary.
Call this during ``hermes update`` so the managed copy stays current.
Returns the managed path on success, ``None`` if uv isn't available or
the self-update fails (non-fatal the old version still works).
"""
existing = resolve_uv()
if not existing:
# Not installed yet — ensure_uv() will handle that elsewhere.
return None
result = subprocess.run(
[existing, "self", "update"],
capture_output=True,
text=True,
check=False,
)
if result.returncode == 0:
version = subprocess.run(
[existing, "--version"],
capture_output=True,
text=True,
check=False,
).stdout.strip()
print(f" ✓ Managed uv updated ({version})")
else:
# Non-fatal — old uv still works fine.
logger.debug("uv self update failed (rc=%d): %s", result.returncode, result.stderr)
return existing
# ---------------------------------------------------------------------------
# Installer internals
# ---------------------------------------------------------------------------
def _install_uv(target: Path) -> None:
"""Bootstrap uv into *target* using the official standalone installer.
Uses ``UV_UNMANAGED_INSTALL`` (POSIX) or ``UV_INSTALL_DIR`` (Windows)
so the astral installer writes the binary directly into
``$HERMES_HOME/bin/`` instead of ``~/.local/bin/``.
"""
system = platform.system()
env = {
**os.environ,
# Tell the astral installer to drop the binary in our dir, not
# ~/.local/bin. UV_UNMANAGED_INSTALL is the POSIX env var; Windows
# uses UV_INSTALL_DIR.
"UV_UNMANAGED_INSTALL": str(target.parent),
"UV_INSTALL_DIR": str(target.parent),
}
if system == "Windows":
_install_uv_windows(env)
else:
_install_uv_posix(env)
def _install_uv_posix(env: dict[str, str]) -> None:
"""Download + sh the POSIX installer (two-stage to avoid curl|sh pitfalls)."""
with tempfile.NamedTemporaryFile(suffix=".sh", delete=False) as f:
installer_path = f.name
try:
subprocess.run(
["curl", "-LsSf", "https://astral.sh/uv/install.sh", "-o", installer_path],
check=True,
capture_output=True,
)
subprocess.run(
["sh", installer_path],
env=env,
check=True,
capture_output=True,
)
finally:
try:
os.unlink(installer_path)
except OSError:
pass
def _install_uv_windows(env: dict[str, str]) -> None:
"""Invoke the PowerShell installer."""
cmd = (
'irm https://astral.sh/uv/install.ps1 | iex'
)
subprocess.run(
["powershell", "-ExecutionPolicy", "Bypass", "-c", cmd],
env=env,
check=True,
capture_output=True,
)
+9 -7
View File
@@ -1868,19 +1868,21 @@ def model_supports_fast_mode(model_id: Optional[str]) -> bool:
def _is_anthropic_fast_model(model_id: Optional[str]) -> bool:
"""Return True if the model is a Claude model eligible for Anthropic Fast Mode.
"""Return True if the model accepts the Anthropic Fast Mode ``speed`` param.
Fast mode is currently supported on Claude Opus 4.6 only. Per Anthropic's
docs (https://platform.claude.com/docs/en/build-with-claude/fast-mode):
"Fast mode is currently supported on Opus 4.6 only. Sending speed: fast
with an unsupported model returns an error." Opus 4.7 explicitly rejects
the ``speed`` parameter with HTTP 400.
This gates the *speed=fast request parameter*, which Anthropic supports on
Opus 4.6 only (Opus 4.7 explicitly 400s). It is deliberately NOT a general
"is this a fast model" check: for Opus 4.8 the fast offering is a SEPARATE
model id (``-opus-4.8-fast``) selected via the model field, not the speed
parameter see ``agent.anthropic_adapter._supports_fast_mode`` and its
test. Keep this in lock-step with that adapter gate so the UI never shows a
Fast toggle that the runtime would silently drop.
"""
raw = _strip_vendor_prefix(str(model_id or ""))
base = raw.split(":")[0]
if not base.startswith("claude-"):
return False
# Only Opus 4.6 supports fast mode at present.
# Only Opus 4.6 supports the speed=fast parameter at present.
return "opus-4-6" in base or "opus-4.6" in base
+20 -17
View File
@@ -202,6 +202,13 @@ TOOL_CATEGORIES = {
"name": "Text-to-Speech",
"icon": "🔊",
"providers": [
{
"name": "Microsoft Edge TTS",
"badge": "★ recommended · free",
"tag": "Good quality, no API key needed",
"env_vars": [],
"tts_provider": "edge",
},
{
"name": "Nous Subscription",
"badge": "subscription",
@@ -212,13 +219,6 @@ TOOL_CATEGORIES = {
"managed_nous_feature": "tts",
"override_env_vars": ["VOICE_TOOLS_OPENAI_KEY", "OPENAI_API_KEY"],
},
{
"name": "Microsoft Edge TTS",
"badge": "★ recommended · free",
"tag": "Good quality, no API key needed",
"env_vars": [],
"tts_provider": "edge",
},
{
"name": "OpenAI TTS",
"badge": "paid",
@@ -406,15 +406,26 @@ TOOL_CATEGORIES = {
# Per-provider rows for Browserbase, Browser Use, and Firecrawl are
# injected at runtime from plugins.browser.<vendor>.provider via
# _plugin_browser_providers() in _visible_providers(). Only
# non-provider UX setup-flow rows remain here:
# non-provider UX setup-flow rows remain here. "Local Browser" is
# listed FIRST so it is the default-highlighted (index 0) choice on a
# fresh install — pressing Enter must land on the free, no-key local
# backend, never on the paid Nous Subscription gateway row:
# - "Local Browser" — non-cloud option, no CloudBrowserProvider.
# - "Nous Subscription (Browser Use cloud)" — managed Browser Use
# billed via Nous subscription (requires_nous_auth +
# override_env_vars). Uses the browser-use plugin as the
# underlying backend but has a distinct setup UX.
# - "Local Browser" — non-cloud option, no CloudBrowserProvider.
# - "Camofox" — anti-detection local Firefox; short-circuits the
# cloud-provider dispatch path via _is_camofox_mode().
"providers": [
{
"name": "Local Browser",
"badge": "★ recommended · free",
"tag": "Headless Chromium, no API key needed",
"env_vars": [],
"browser_provider": "local",
"post_setup": "agent_browser",
},
{
"name": "Nous Subscription (Browser Use cloud)",
"badge": "subscription",
@@ -426,14 +437,6 @@ TOOL_CATEGORIES = {
"override_env_vars": ["BROWSER_USE_API_KEY"],
"post_setup": "agent_browser",
},
{
"name": "Local Browser",
"badge": "★ recommended · free",
"tag": "Headless Chromium, no API key needed",
"env_vars": [],
"browser_provider": "local",
"post_setup": "agent_browser",
},
{
"name": "Camofox",
"badge": "free · local",
+433 -45
View File
@@ -9,6 +9,8 @@ Usage:
python -m hermes_cli.main web --port 8080
"""
from contextlib import asynccontextmanager
import asyncio
import base64
import binascii
@@ -84,7 +86,43 @@ except ImportError:
WEB_DIST = Path(os.environ["HERMES_WEB_DIST"]) if "HERMES_WEB_DIST" in os.environ else Path(__file__).parent / "web_dist"
_log = logging.getLogger(__name__)
app = FastAPI(title="Hermes Agent", version=__version__)
# ---------------------------------------------------------------------------
# Per-channel subscriber registry used by /api/pub (PTY-side gateway → dashboard)
# and /api/events (dashboard → browser sidebar). Keyed by an opaque channel id
# the chat tab generates on mount; entries auto-evict when the last subscriber
# drops AND the publisher has disconnected.
#
# State lives on app.state (not module-level globals) so that asyncio.Lock is
# created on the running event loop during lifespan startup. A module-level
# asyncio.Lock() binds to whatever loop was active at import time, which breaks
# when the same module is used across TestClient instances or uvicorn reloads.
# ---------------------------------------------------------------------------
@asynccontextmanager
async def _lifespan(app: "FastAPI"):
app.state.event_channels = {} # dict[str, set]
app.state.event_lock = asyncio.Lock()
yield
def _get_event_state(app: "FastAPI"):
"""Return (event_channels, event_lock) from app.state.
Lazily initialises the state if the lifespan hasn't run (e.g. when
TestClient is constructed without a ``with`` block). The lifespan
path is preferred because it guarantees the Lock is created on the
correct event loop, but the lazy path lets existing non-``with``
TestClient usages keep working.
"""
try:
return app.state.event_channels, app.state.event_lock
except AttributeError:
app.state.event_channels = {}
app.state.event_lock = asyncio.Lock()
return app.state.event_channels, app.state.event_lock
app = FastAPI(title="Hermes Agent", version=__version__, lifespan=_lifespan)
# ---------------------------------------------------------------------------
# Session token for protecting sensitive endpoints (reveal).
@@ -1627,7 +1665,9 @@ def get_model_options():
try:
from hermes_cli.inventory import build_models_payload, load_picker_context
return build_models_payload(load_picker_context(), max_models=50, pricing=True)
return build_models_payload(
load_picker_context(), max_models=50, pricing=True, capabilities=True
)
except Exception:
_log.exception("GET /api/model/options failed")
raise HTTPException(status_code=500, detail="Failed to list model options")
@@ -2935,6 +2975,17 @@ _OAUTH_PROVIDER_CATALOG: tuple[Dict[str, Any], ...] = (
"docs_url": "https://www.minimax.io",
"status_fn": None, # dispatched via auth.get_minimax_oauth_auth_status
},
{
"id": "xai-oauth",
"name": "xAI Grok OAuth (SuperGrok / Premium+)",
# Loopback PKCE: the desktop's local backend binds a 127.0.0.1
# callback server, the client opens the browser, and the redirect
# lands back on the loopback listener — no code to copy/paste.
"flow": "loopback",
"cli_command": "hermes auth add xai-oauth",
"docs_url": "https://hermes-agent.nousresearch.com/docs/guides/xai-grok-oauth",
"status_fn": None, # dispatched via auth.get_xai_oauth_auth_status
},
)
@@ -2988,6 +3039,20 @@ def _resolve_provider_status(provider_id: str, status_fn) -> Dict[str, Any]:
"expires_at": raw.get("expires_at"),
"has_refresh_token": True,
}
if provider_id == "xai-oauth":
raw = hauth.get_xai_oauth_auth_status()
# source_label is meant to be a human-readable origin (auth-store
# path / credential source), not the internal auth_mode string
# ("oauth_pkce"). Prefer the store path, then the source slug.
return {
"logged_in": bool(raw.get("logged_in")),
"source": raw.get("source") or "xai_oauth",
"source_label": raw.get("auth_store") or raw.get("source") or "xAI Grok OAuth",
"token_preview": _truncate_token(raw.get("api_key")),
"expires_at": None,
"has_refresh_token": True,
"last_refresh": raw.get("last_refresh"),
}
except Exception as e:
return {"logged_in": False, "error": str(e)}
return {"logged_in": False}
@@ -3000,7 +3065,7 @@ async def list_oauth_providers():
Response shape (per provider):
id stable identifier (used in DELETE path)
name human label
flow "pkce" | "device_code" | "external"
flow "pkce" | "device_code" | "external" | "loopback"
cli_command fallback CLI command for users to run manually
docs_url external docs/portal link for the "Learn more" link
status:
@@ -3100,6 +3165,19 @@ async def disconnect_oauth_provider(provider_id: str, request: Request):
# 4. On "approved" the background thread has already saved creds; UI
# refreshes the providers list.
#
# Loopback PKCE (xAI Grok):
# 1. POST /api/providers/oauth/xai-oauth/start
# → server binds a 127.0.0.1 callback listener, builds the xAI
# authorize URL, spawns a background worker waiting on the redirect
# → returns { session_id, flow: "loopback", auth_url, expires_in }
# 2. UI opens auth_url in the browser. There is NO user_code/code to
# paste — the redirect lands back on the loopback listener.
# 3. UI polls GET /api/providers/oauth/{provider}/poll/{session_id}
# (same endpoint as device_code) until status != "pending".
# 4. The worker exchanges the code, persists creds, sets "approved".
# DELETE /sessions/{id} cancels: the worker bails before persisting
# and the callback server is shut down to free the port immediately.
#
# Sessions are kept in-memory only (single-process FastAPI) and time out
# after 15 minutes. A periodic cleanup runs on each /start call to GC
# expired sessions so the dict doesn't grow without bound.
@@ -3483,6 +3561,220 @@ async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]:
raise HTTPException(status_code=400, detail=f"Provider {provider_id} does not support device-code flow")
# xAI Grok OAuth uses a loopback-redirect PKCE flow (RFC 8252). Unlike the
# device-code providers there is no user_code to display: the local backend
# binds a 127.0.0.1 callback server, the client opens the authorize URL in
# the browser, and the redirect lands back on the loopback listener. The
# background worker waits for that callback, exchanges the code, and persists
# the tokens exactly like `hermes auth add xai-oauth`.
_XAI_LOOPBACK_TIMEOUT_SECONDS = 300.0
def _start_xai_loopback_flow() -> Dict[str, Any]:
"""Begin the xAI loopback PKCE flow.
Binds the local callback server, builds the authorize URL, and spawns a
background worker that waits for the redirect and finishes the exchange.
Returns the authorize URL for the client to open in the browser.
"""
from hermes_cli import auth as hauth
discovery = hauth._xai_oauth_discovery()
server, thread, callback_result, redirect_uri = hauth._xai_start_callback_server()
try:
hauth._xai_validate_loopback_redirect_uri(redirect_uri)
verifier = hauth._oauth_pkce_code_verifier()
challenge = hauth._oauth_pkce_code_challenge(verifier)
state = secrets.token_hex(16)
nonce = secrets.token_hex(16)
authorize_url = hauth._xai_oauth_build_authorize_url(
authorization_endpoint=discovery["authorization_endpoint"],
redirect_uri=redirect_uri,
code_challenge=challenge,
state=state,
nonce=nonce,
)
except Exception:
# Binding succeeded but URL construction failed — release the socket
# and join the serving thread so we don't leak a listener (or a
# lingering daemon thread) on the loopback port.
try:
server.shutdown()
server.server_close()
except Exception:
pass
try:
thread.join(timeout=1.0)
except Exception:
pass
raise
sid, sess = _new_oauth_session("xai-oauth", "loopback")
sess["server"] = server
sess["thread"] = thread
sess["callback_result"] = callback_result
sess["redirect_uri"] = redirect_uri
sess["verifier"] = verifier
sess["challenge"] = challenge
sess["state"] = state
sess["token_endpoint"] = discovery["token_endpoint"]
sess["discovery"] = discovery
sess["expires_at"] = time.time() + _XAI_LOOPBACK_TIMEOUT_SECONDS
threading.Thread(
target=_xai_loopback_worker, args=(sid,), daemon=True,
name=f"oauth-xai-{sid[:6]}",
).start()
return {
"session_id": sid,
"flow": "loopback",
"auth_url": authorize_url,
"expires_in": int(_XAI_LOOPBACK_TIMEOUT_SECONDS),
}
def _xai_loopback_worker(session_id: str) -> None:
"""Wait for the xAI loopback callback, exchange the code, persist tokens."""
from datetime import datetime, timezone
from hermes_cli import auth as hauth
with _oauth_sessions_lock:
sess = _oauth_sessions.get(session_id)
if not sess:
return
def _fail(message: str) -> None:
with _oauth_sessions_lock:
s = _oauth_sessions.get(session_id)
if s is not None:
s["status"] = "error"
s["error_message"] = message
def _cancelled() -> bool:
# The session is removed from the registry when the user cancels
# (DELETE /sessions/{id}). If that happened while we were blocked on
# the callback or token exchange, abort instead of persisting tokens
# the user no longer wants.
with _oauth_sessions_lock:
return session_id not in _oauth_sessions
try:
callback = hauth._xai_wait_for_callback(
sess["server"],
sess["thread"],
sess["callback_result"],
timeout_seconds=_XAI_LOOPBACK_TIMEOUT_SECONDS,
)
except Exception as exc:
_fail(f"xAI authorization timed out: {exc}")
return
if _cancelled():
return
if callback.get("error"):
detail = callback.get("error_description") or callback["error"]
_fail(f"xAI authorization failed: {detail}")
return
if callback.get("state") != sess["state"]:
_fail("xAI authorization failed: state mismatch.")
return
code = str(callback.get("code") or "").strip()
if not code:
_fail("xAI authorization failed: missing authorization code.")
return
try:
payload = hauth._xai_oauth_exchange_code_for_tokens(
token_endpoint=sess["token_endpoint"],
code=code,
redirect_uri=sess["redirect_uri"],
code_verifier=sess["verifier"],
code_challenge=sess["challenge"],
)
access_token = str(payload.get("access_token", "") or "").strip()
refresh_token = str(payload.get("refresh_token", "") or "").strip()
if not access_token or not refresh_token:
_fail("xAI token exchange did not return the expected tokens.")
return
base_url = hauth._xai_validate_inference_base_url(
os.getenv("HERMES_XAI_BASE_URL", "").strip().rstrip("/")
or os.getenv("XAI_BASE_URL", "").strip().rstrip("/"),
fallback=hauth.DEFAULT_XAI_OAUTH_BASE_URL,
)
last_refresh = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
tokens = {
"access_token": access_token,
"refresh_token": refresh_token,
"id_token": str(payload.get("id_token", "") or "").strip(),
"expires_in": payload.get("expires_in"),
"token_type": str(payload.get("token_type") or "Bearer").strip() or "Bearer",
}
if _cancelled():
return
hauth._save_xai_oauth_tokens(
tokens,
discovery=sess.get("discovery"),
redirect_uri=sess["redirect_uri"],
last_refresh=last_refresh,
)
_add_xai_oauth_pool_entry(access_token, refresh_token, base_url, last_refresh)
except Exception as exc:
_fail(f"xAI token exchange failed: {exc}")
return
with _oauth_sessions_lock:
s = _oauth_sessions.get(session_id)
if s is not None:
s["status"] = "approved"
_log.info("oauth/loopback: xai-oauth login completed (session=%s)", session_id)
def _add_xai_oauth_pool_entry(
access_token: str, refresh_token: str, base_url: str, last_refresh: str
) -> None:
"""Mirror `hermes auth add xai-oauth`'s credential-pool insert.
Best-effort: the auth-store write in _save_xai_oauth_tokens is the source
of truth for runtime resolution; the pool entry only matters for the
rotation strategy.
"""
try:
import uuid
from agent.credential_pool import (
PooledCredential,
load_pool,
AUTH_TYPE_OAUTH,
SOURCE_MANUAL,
)
pool = load_pool("xai-oauth")
existing = [
e for e in pool.entries()
if getattr(e, "source", "").startswith(f"{SOURCE_MANUAL}:dashboard_xai_pkce")
]
for e in existing:
try:
pool.remove_entry(getattr(e, "id", ""))
except Exception:
pass
entry = PooledCredential(
provider="xai-oauth",
id=uuid.uuid4().hex[:6],
label="dashboard PKCE",
auth_type=AUTH_TYPE_OAUTH,
priority=0,
source=f"{SOURCE_MANUAL}:dashboard_xai_pkce",
access_token=access_token,
refresh_token=refresh_token,
base_url=base_url,
last_refresh=last_refresh,
)
pool.add_entry(entry)
except Exception as e:
_log.warning("xai-oauth pool add (dashboard) failed: %s", e)
def _nous_poller(session_id: str) -> None:
"""Background poller that drives a Nous device-code flow to completion."""
from hermes_cli.auth import (
@@ -3772,6 +4064,10 @@ async def start_oauth_login(provider_id: str, request: Request):
return _start_anthropic_pkce()
if catalog_entry["flow"] == "device_code":
return await _start_device_code_flow(provider_id)
if catalog_entry["flow"] == "loopback" and provider_id == "xai-oauth":
return await asyncio.get_running_loop().run_in_executor(
None, _start_xai_loopback_flow
)
except HTTPException:
raise
except Exception as e:
@@ -3798,7 +4094,13 @@ async def submit_oauth_code(provider_id: str, body: OAuthSubmitBody, request: Re
@app.get("/api/providers/oauth/{provider_id}/poll/{session_id}")
async def poll_oauth_session(provider_id: str, session_id: str):
"""Poll a device-code session's status (no auth — read-only state)."""
"""Poll a session's status (no auth — read-only state).
Shared by the device-code flows (Nous, OpenAI Codex, MiniMax) and the
loopback flow (xAI Grok). Both surface progress through the same
background-worker-updated ``status`` field, so a single poll endpoint
serves them all.
"""
with _oauth_sessions_lock:
sess = _oauth_sessions.get(session_id)
if not sess:
@@ -3821,6 +4123,33 @@ async def cancel_oauth_session(session_id: str, request: Request):
sess = _oauth_sessions.pop(session_id, None)
if sess is None:
return {"ok": False, "message": "session not found"}
# Loopback sessions own a bound 127.0.0.1 callback server. Without an
# explicit shutdown the worker would keep that port held until
# _xai_wait_for_callback times out (up to 5 min). Free it immediately so
# an orphaned listener can't block a subsequent sign-in attempt.
if sess.get("flow") == "loopback":
# The worker is blocked in _xai_wait_for_callback, which polls
# callback_result rather than the server state. Flag the result as
# cancelled so that loop returns on its next tick instead of spinning
# until the timeout — otherwise repeated cancel/retry piles up daemon
# threads. (_cancelled() in the worker then short-circuits before any
# persist.)
result = sess.get("callback_result")
if isinstance(result, dict):
result["error"] = result.get("error") or "cancelled"
server = sess.get("server")
thread = sess.get("thread")
try:
if server is not None:
server.shutdown()
server.server_close()
except Exception:
pass
try:
if thread is not None:
thread.join(timeout=1.0)
except Exception:
pass
return {"ok": True, "session_id": session_id}
@@ -6276,13 +6605,26 @@ def _ws_host_origin_is_allowed(ws: "WebSocket") -> bool:
parsed = urllib.parse.urlparse(origin)
if parsed.scheme not in {"http", "https"}:
# Packaged Electron loads the desktop renderer over file://, so its
# WebSocket handshake carries a non-web Origin such as file:// or null.
# DNS-rebinding attacks originate from an http(s) site; they cannot
# forge a file:// origin and still hold the loopback session token.
# Public/gated binds have no legitimate non-web client, so keep
# rejecting these origins there.
return bound_host.lower() in _LOOPBACK_HOST_VALUES
# Packaged Electron loads the desktop renderer over a non-web origin
# such as file://, null, or a custom app:// scheme. This helper is
# called only AFTER _ws_auth_ok has already accepted the WS credential,
# which is the real auth boundary in every mode:
# * loopback bind → legacy dashboard session token
# * non-loopback --insecure → legacy session token (Tailscale / LAN)
# * OAuth-gated public bind → single-use, 30s-TTL, identity-bound
# ?ticket= minted at the cookie-authed POST /api/auth/ws-ticket
# A non-web origin can only be produced by a native client (the desktop
# shell); a DNS-rebinding attack always arrives from an http(s) origin
# and is still match-checked against the bound host below. So once the
# credential check upstream has passed, the Origin guard adds nothing
# for a non-web origin — trust it in every mode.
#
# (Earlier revisions restricted this to loopback, then to non-gated
# binds; both excluded the packaged desktop talking to a remote
# OAuth-gated gateway, whose file:// renderer origin then got rejected
# at the WS upgrade even with a valid ticket. The ticket is the gate,
# not the origin.)
return True
if not parsed.netloc:
return False
@@ -6301,10 +6643,21 @@ def _ws_auth_ok(ws: "WebSocket") -> bool:
Loopback / ``--insecure``: legacy ``?token=<_SESSION_TOKEN>`` query
parameter, constant-time compared.
Gated (public bind, no ``--insecure``): ``?ticket=<single-use>`` query
parameter consumed against the dashboard-auth ticket store. The legacy
token path is unconditionally rejected in this mode (the SPA bundle
isn't carrying the token any longer).
Gated (public bind, no ``--insecure``): one of two credentials
* ``?ticket=<single-use>`` a browser-minted, single-use, 30s-TTL ticket
consumed against the dashboard-auth ticket store. This is what the SPA
(and native clients) use.
* ``?internal=<process-credential>`` the process-lifetime internal
credential, used only by WS clients the server spawns itself (the
embedded-TUI PTY child attaching to ``/api/ws`` and ``/api/pub``). It
is multi-use and never expires so the child can reconnect, and is never
injected into the SPA see ``dashboard_auth.ws_tickets`` for the
threat model.
The legacy ``?token=`` path is unconditionally rejected in gated mode
(the SPA bundle isn't carrying the token any longer, and a leaked
``_SESSION_TOKEN`` must not grant WS access once the gate is engaged).
Returns True if the WS should be accepted; callers close with the
appropriate WS code (4401) on False. Audit-logs the rejection so
@@ -6312,17 +6665,36 @@ def _ws_auth_ok(ws: "WebSocket") -> bool:
"""
auth_required = bool(getattr(app.state, "auth_required", False))
if auth_required:
ticket = ws.query_params.get("ticket", "")
if not ticket:
return False
# Lazy import — keeps this function importable in test harnesses
# that don't bring in the dashboard_auth layer.
from hermes_cli.dashboard_auth.audit import AuditEvent, audit_log
from hermes_cli.dashboard_auth.ws_tickets import (
TicketInvalid,
consume_internal_credential,
consume_ticket,
)
# Server-spawned children (PTY child → /api/ws, /api/pub) present the
# multi-use internal credential rather than a single-use ticket, so
# they survive reconnects and slow cold boots.
internal = ws.query_params.get("internal", "")
if internal:
try:
consume_internal_credential(internal)
return True
except TicketInvalid as exc:
audit_log(
AuditEvent.WS_TICKET_REJECTED,
reason=f"internal: {exc}",
ip=(ws.client.host if ws.client else ""),
path=ws.url.path,
)
return False
ticket = ws.query_params.get("ticket", "")
if not ticket:
return False
try:
consume_ticket(ticket)
return True
@@ -6342,8 +6714,7 @@ def _ws_auth_ok(ws: "WebSocket") -> bool:
# and /api/events (dashboard → browser sidebar). Keyed by an opaque channel id
# the chat tab generates on mount; entries auto-evict when the last subscriber
# drops AND the publisher has disconnected.
_event_channels: dict[str, set] = {}
_event_lock = asyncio.Lock()
# (State is initialised in _lifespan on app startup — see above.)
def _resolve_chat_argv(
@@ -6399,7 +6770,16 @@ def _resolve_chat_argv(
def _build_gateway_ws_url() -> Optional[str]:
"""ws:// URL the PTY child should attach to for JSON-RPC gateway traffic."""
"""ws:// URL the PTY child should attach to for JSON-RPC gateway traffic.
Loopback / ``--insecure``: ``?token=<_SESSION_TOKEN>``.
Gated mode: the legacy token path is rejected by ``_ws_auth_ok``, so the
server-spawned PTY child authenticates with the process-lifetime internal
credential (``?internal=``). It must NOT use a single-use browser ticket:
the child reads this URL once at startup and reuses it on every reconnect,
and a 30s-TTL ticket can expire before a slow cold boot even dials.
"""
host = getattr(app.state, "bound_host", None)
port = getattr(app.state, "bound_port", None)
@@ -6411,7 +6791,13 @@ def _build_gateway_ws_url() -> Optional[str]:
if ":" in host and not host.startswith("[")
else f"{host}:{port}"
)
qs = urllib.parse.urlencode({"token": _SESSION_TOKEN})
if getattr(app.state, "auth_required", False):
from hermes_cli.dashboard_auth.ws_tickets import internal_ws_credential
qs = urllib.parse.urlencode({"internal": internal_ws_credential()})
else:
qs = urllib.parse.urlencode({"token": _SESSION_TOKEN})
return f"ws://{netloc}/api/ws?{qs}"
@@ -6421,16 +6807,14 @@ def _build_sidecar_url(channel: str) -> Optional[str]:
Loopback / ``--insecure``: uses ``?token=<_SESSION_TOKEN>``.
Gated mode: mints a single-use ticket via the dashboard-auth ticket
store (server-side mint, no HTTP round trip the PTY child is a
server-spawned process and we trust it). The ticket binds to the
pseudo-user ``"pty-sidecar"`` so audit logs can distinguish these from
browser-initiated tickets.
The single-use lifetime means the PTY child cannot reconnect without a
new sidecar URL. PTY children open ``/api/pub`` once at startup; if
reconnect semantics ever become important, this should be upgraded to
a long-lived process-scoped token.
Gated mode: authenticates with the process-lifetime internal credential
(``?internal=``), the same one ``_build_gateway_ws_url`` uses. The PTY
child is a server-spawned process we trust; the credential is multi-use
and never expires, so the child can reconnect ``/api/pub`` without a new
URL. (This previously minted a single-use 30s ticket, which meant the
child could not reconnect and could miss the window on a slow cold boot.)
Connections authenticated this way are recorded under the
``server-internal`` identity in the audit log.
"""
host = getattr(app.state, "bound_host", None)
port = getattr(app.state, "bound_port", None)
@@ -6441,21 +6825,24 @@ def _build_sidecar_url(channel: str) -> Optional[str]:
netloc = f"[{host}]:{port}" if ":" in host and not host.startswith("[") else f"{host}:{port}"
if getattr(app.state, "auth_required", False):
# Gated mode — mint a ticket so the WS upgrade survives _ws_auth_ok.
from hermes_cli.dashboard_auth.ws_tickets import mint_ticket
# Gated mode — use the internal credential so the WS upgrade survives
# _ws_auth_ok and the child can reconnect.
from hermes_cli.dashboard_auth.ws_tickets import internal_ws_credential
ticket = mint_ticket(user_id="pty-sidecar", provider="server-internal")
qs = urllib.parse.urlencode({"ticket": ticket, "channel": channel})
qs = urllib.parse.urlencode(
{"internal": internal_ws_credential(), "channel": channel}
)
else:
qs = urllib.parse.urlencode({"token": _SESSION_TOKEN, "channel": channel})
return f"ws://{netloc}/api/pub?{qs}"
async def _broadcast_event(channel: str, payload: str) -> None:
async def _broadcast_event(app: Any, channel: str, payload: str) -> None:
"""Fan out one publisher frame to every subscriber on `channel`."""
async with _event_lock:
subs = list(_event_channels.get(channel, ()))
event_channels, event_lock = _get_event_state(app)
async with event_lock:
subs = list(event_channels.get(channel, ()))
for sub in subs:
try:
@@ -6646,7 +7033,7 @@ async def pub_ws(ws: WebSocket) -> None:
try:
while True:
await _broadcast_event(channel, await ws.receive_text())
await _broadcast_event(ws.app, channel, await ws.receive_text())
except WebSocketDisconnect:
pass
@@ -6672,8 +7059,9 @@ async def events_ws(ws: WebSocket) -> None:
await ws.accept()
async with _event_lock:
_event_channels.setdefault(channel, set()).add(ws)
event_channels, event_lock = _get_event_state(ws.app)
async with event_lock:
event_channels.setdefault(channel, set()).add(ws)
try:
while True:
@@ -6684,14 +7072,14 @@ async def events_ws(ws: WebSocket) -> None:
except WebSocketDisconnect:
pass
finally:
async with _event_lock:
subs = _event_channels.get(channel)
async with event_lock:
subs = event_channels.get(channel)
if subs is not None:
subs.discard(ws)
if not subs:
_event_channels.pop(channel, None)
event_channels.pop(channel, None)
def _normalise_prefix(raw: Optional[str]) -> str:
+3 -6
View File
@@ -452,12 +452,9 @@ class SessionDB:
self._fts_unavailable_warned = True
logger.warning(
"SQLite FTS5 unavailable for %s; full-text session search "
"disabled. This usually means Hermes is running on an "
"unsupported install (e.g. a pip-installed or pip-managed "
"Python whose bundled SQLite lacks FTS5) rather than a "
"mainline install. Some features may be missing or behave "
"differently. Install the supported way: "
"https://hermes-agent.nousresearch.com (underlying error: %s)",
"disabled. Run `hermes update` to rebuild the venv with a "
"current Python (managed uv guarantees FTS5). "
"(underlying error: %s)",
self.db_path,
exc,
)
+16
View File
@@ -58,6 +58,22 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2)
echo "ok" > $out/result
''
);
# Verify the default package builds successfully (cross-platform).
# On Linux the runtime checks below already depend on the package,
# but this ensures darwin builders also build it during flake check.
build-package = pkgs.runCommand "hermes-build-package" { } ''
echo "PASS: package built at ${hermes-agent}"
mkdir -p $out
echo "ok" > $out/result
'';
# Verify the devShell builds successfully (cross-platform).
build-devshell = pkgs.runCommand "hermes-build-devshell" { } ''
echo "PASS: devShell built at ${self'.devShells.default}"
mkdir -p $out
echo "ok" > $out/result
'';
} // lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux {
# Verify binaries exist and are executable
package-contents = pkgs.runCommand "hermes-package-contents" { } ''
+26 -33
View File
@@ -8,37 +8,20 @@
# No reimplementation of the agent resolution in this wrapper.
{ pkgs, lib, stdenv, makeWrapper, hermesNpmLib, electron, hermesAgent, ... }:
let
src = ../apps;
npmDeps = pkgs.fetchNpmDeps {
src = ../apps/desktop;
# buildNpmPackage uses `npm ci` which is strict — peer deps not in the
# lockfile cause network fetch attempts. Fetcher v2 stages the full
# cache (including peer-only deps) so `npm ci` can resolve them offline.
fetcherVersion = 2;
hash = "sha256-7W9ObYz08yDMtybY8+RkUXkKVsJXINLl0qBUB91hpao=";
};
npm = hermesNpmLib.mkNpmPassthru { folder = "apps/desktop"; attr = "desktop"; pname = "hermes-desktop"; };
packageJson = builtins.fromJSON (builtins.readFile (src + "/desktop/package.json"));
packageJson = builtins.fromJSON (builtins.readFile (npm.src + "/apps/desktop/package.json"));
version = packageJson.version;
# Build the renderer (dist/ + electron/ + package.json).
renderer = pkgs.buildNpmPackage (npm // {
pname = "hermes-desktop-renderer";
inherit src npmDeps version;
sourceRoot = "apps/desktop";
inherit version;
doCheck = false;
# buildNpmPackage uses `npm ci` which fails on peer deps not in the
# lockfile. npmDepsFetcherVersion=2 stages the full cache (peer deps
# included) so the offline `npm ci` resolves them.
npmDepsFetcherVersion = 2;
# `--ignore-scripts` skips the electron prebuild download (we use nixpkgs
# electron instead). `--legacy-peer-deps` matches the dev workflow —
# apps/desktop has conflicting peer deps (zod, @testing-library) that
# the package.json relies on npm 7+ to relax.
npmFlags = [ "--ignore-scripts" "--legacy-peer-deps" ];
# The workspace lockfile resolves all peer deps
# correctly so --legacy-peer-deps is not needed.
# --ignore-scripts comes from mkNpmPassthru (shared).
makeCacheWritable = true;
buildPhase = ''
@@ -47,21 +30,23 @@ let
# write-build-stamp.cjs replacement. Packaged Electron reads this
# at first-launch to pin the install.ps1 git ref; informational in
# nix builds (the backend comes from the derivation directly).
mkdir -p build
echo '{"schemaVersion":1,"commit":"nix","branch":"nix","dirty":false,"source":"nix"}' > build/install-stamp.json
mkdir -p apps/desktop/build
echo '{"schemaVersion":1,"commit":"nix","branch":"nix","dirty":false,"source":"nix"}' > apps/desktop/build/install-stamp.json
# The vite config aliases react/react-dom to ../../node_modules/react
# (workspace root, where npm dedups them in dev). In the standalone
# nix build there is no workspace root, so the deps are installed
# locally — rewrite the aliases to point at the local copy.
substituteInPlace vite.config.ts \
--replace-quiet '../../node_modules/' './node_modules/'
# Build from apps/desktop/ so vite.config.ts resolves correctly.
# The workspace root's node_modules/ is accessible as ../../node_modules/.
cd apps/desktop
# vite handles TS transpilation via esbuild — no type-checking.
# We skip `tsc -b` to avoid type errors in test files that don't
# ship in the bundle (real upstream peer-dep version mismatches
# in @testing-library/react v16 — not blocking the build).
npx vite build --outDir dist
# Call vite directly from root node_modules to avoid npx resolving
# through unpatched workspace symlinks.
node ../../node_modules/vite/bin/vite.js build --outDir dist
# Return to source root so installPhase paths are correct.
cd ../..
runHook postBuild
'';
@@ -69,8 +54,12 @@ let
installPhase = ''
runHook preInstall
mkdir -p $out
cp -r dist electron build $out/
cp package.json $out/
# vite writes to apps/desktop/dist/ (we cd'd there in buildPhase).
# apps/desktop/build was created before the cd. electron/ is source.
cp -r apps/desktop/dist $out/
cp -r apps/desktop/electron $out/
cp -r apps/desktop/build $out/
cp apps/desktop/package.json $out/
runHook postInstall
'';
});
@@ -106,6 +95,10 @@ stdenv.mkDerivation {
runHook postInstall
'';
passthru = {
inherit (renderer.passthru) packageJsonPath;
};
meta = with lib; {
description = "Native Electron desktop shell for Hermes Agent";
homepage = "https://github.com/NousResearch/hermes-agent";
+23 -12
View File
@@ -1,13 +1,28 @@
# nix/devShell.nix — Dev shell that delegates setup to each package
#
# Each package in inputsFrom might expose passthru.devShellHook — a bash snippet
# with stamp-checked setup logic. This file collects and runs them all.
# Each npm workspace package exposes passthru.packageJsonPath (e.g.
# "ui-tui/package.json"). This file collects them all and passes the
# list to mkNpmDevShellHook, which stamps all package.jsons at once,
# then runs a single `npm i --package-lock-only` if any changed and
# `npm ci` if the lockfile changed.
{ ... }:
{
perSystem =
{ pkgs, self', ... }:
let
packages = builtins.attrValues self'.packages;
hermesNpmLib = self'.packages.default.passthru.hermesNpmLib;
fixLockfilesExe = pkgs.lib.getExe self'.packages.fix-lockfiles;
# Collect all packageJsonPath values from npm workspace packages.
npmPackageJsonPaths = builtins.filter (p: p != null) (
map (p: p.passthru.packageJsonPath or null) packages
);
# Non-npm packages may have their own devShellHook (e.g. hermes-agent
# stamps pyproject.toml + uv.lock for Python venv setup).
nonNpmHooks = map (p: p.passthru.devShellHook or "") packages;
combinedNonNpm = pkgs.lib.concatStringsSep "\n" (builtins.filter (h: h != "") nonNpmHooks);
in
{
devShells.default = pkgs.mkShell {
@@ -15,16 +30,12 @@
packages = with pkgs; [
uv
];
shellHook =
let
hooks = map (p: p.passthru.devShellHook or "") packages;
combined = pkgs.lib.concatStringsSep "\n" (builtins.filter (h: h != "") hooks);
in
''
echo "Hermes Agent dev shell"
${combined}
echo "Ready. Run 'hermes' to start."
'';
shellHook = ''
echo "Hermes Agent dev shell"
${combinedNonNpm}
${hermesNpmLib.mkNpmDevShellHook npmPackageJsonPaths fixLockfilesExe}
echo "Ready. Run 'hermes' to start."
'';
};
};
}
+175 -121
View File
@@ -1,15 +1,40 @@
# nix/lib.nix — Shared helpers for nix stuff
#
# All npm packages in this repo are workspace members sharing a single
# root package-lock.json. mkNpmPassthru provides the shared src, npmDeps,
# npmRoot, and npmDepsFetcherVersion so individual .nix files don't
# duplicate them. One hash to rule them all.
#
# mkNpmPassthru returns packageJsonPath (e.g. "ui-tui/package.json")
# instead of a per-package devShellHook. The root devshell hook
# (mkNpmDevShellHook) collects all package.json paths, stamps them,
# and if any changed, runs a single `npm i --package-lock-only` from
# root to update the lockfile, then `npm ci` if the lockfile changed.
{
pkgs,
npm-lockfile-fix,
nodejs,
}:
let
# The workspace root — where the single package-lock.json lives.
src = ../.;
# Single npm deps fetch from the workspace root lockfile.
# All workspace packages share this derivation.
npmDepsHash = "sha256-WudVthIvvyqaKDr3SwRAswd8csvByzUb+T8jCqeai6g=";
npmDeps = pkgs.fetchNpmDeps {
inherit src;
fetcherVersion = 2;
hash = npmDepsHash;
};
in
{
# Returns a buildNpmPackage-compatible attrs set that provides:
# patchPhase — ensures lockfile has exactly one trailing newline
# src, npmDeps, npmRoot, npmDepsFetcherVersion
# patchPhase — ensures root lockfile has exactly one trailing newline
# nativeBuildInputs — [ updateLockfileScript ] (list, prepend with ++ for more)
# passthru.devShellHook — stamp-checked npm install + hash auto-update
# passthru.npmLockfile — metadata for mkFixLockfiles
# passthru.packageJsonPath — relative path to this workspace's package.json
# nodejs — fixed nodejs version for all packages we use in the repo
#
# NOTE: npmConfigHook runs `diff` between the source lockfile and the
@@ -19,22 +44,38 @@
#
# Usage:
# npm = hermesNpmLib.mkNpmPassthru { folder = "ui-tui"; attr = "tui"; pname = "hermes-tui"; };
# pkgs.buildNpmPackage (npm // { ... } # or:
# pkgs.buildNpmPackage ({ ... } // npm)
# pkgs.buildNpmPackage (npm // {
# sourceRoot = "ui-tui";
# buildPhase = '' ... '';
# installPhase = '' ... '';
# })
mkNpmPassthru =
{
folder, # repo-relative folder with package.json, e.g. "ui-tui"
attr, # flake package attr, e.g. "tui"
pname, # e.g. "hermes-tui"
nixFile ? "nix/${attr}.nix", # defaults to nix/<attr>.nix
}:
let
# No sourceRoot — the workspace root (with the single package-lock.json)
# is auto-detected as sourceRoot by nix. npmRoot stays at "."
# so npmConfigHook finds the lockfile there.
in
{
inherit nodejs;
inherit src npmDeps nodejs;
npmRoot = ".";
npmDepsFetcherVersion = 2;
# --ignore-scripts: the workspace includes electron (apps/desktop)
# which has a postinstall that tries to download from github.com.
# nix builds are offline, so all scripts must be skipped. Each
# package sets up its own build commands in buildPhase instead.
npmFlags = [ "--ignore-scripts" ];
patchPhase = ''
runHook prePatch
# Normalize trailing newlines so source and npm-deps always match,
# regardless of what fetchNpmDeps preserves.
sed -i -z 's/\n*$/\n/' package-lock.json
# Normalize trailing newlines on the root lockfile so source and
# npm-deps always match, regardless of what fetchNpmDeps preserves.
sed -i -z 's/\\n*$/\\n/' package-lock.json
# Make npmConfigHook's byte-for-byte diff newline-agnostic by
# replacing its hardcoded /nix/store/.../diff with a wrapper that
@@ -42,11 +83,11 @@
mkdir -p "$TMPDIR/bin"
cat > "$TMPDIR/bin/diff" << DIFFWRAP
#!/bin/sh
f1=\$(mktemp) && sed -z 's/\n*$/\n/' "\$1" > "\$f1"
f2=\$(mktemp) && sed -z 's/\n*$/\n/' "\$2" > "\$f2"
${pkgs.diffutils}/bin/diff "\$f1" "\$f2" && rc=0 || rc=\$?
rm -f "\$f1" "\$f2"
exit \$rc
f1=\\$(mktemp) && sed -z 's/\\n*$/\\n/' "\\$1" > "\\$f1"
f2=\\$(mktemp) && sed -z 's/\\n*$/\\n/' "\\$2" > "\\$f2"
${pkgs.diffutils}/bin/diff "\\$f1" "\\$f2" && rc=0 || rc=\\$?
rm -f "\\$f1" "\\$f2"
exit \\$rc
DIFFWRAP
chmod +x "$TMPDIR/bin/diff"
export PATH="$TMPDIR/bin:$PATH"
@@ -60,62 +101,71 @@
REPO_ROOT=$(git rev-parse --show-toplevel)
cd "$REPO_ROOT/${folder}"
# All workspace packages share the root lockfile.
cd "$REPO_ROOT"
rm -rf node_modules/
${pkgs.lib.getExe' nodejs "npm"} cache clean --force
CI=true ${pkgs.lib.getExe' nodejs "npm"} install
CI=true ${pkgs.lib.getExe' nodejs "npm"} install --workspaces
${pkgs.lib.getExe npm-lockfile-fix} ./package-lock.json
NIX_FILE="$REPO_ROOT/${nixFile}"
sed -i "s/hash = \"[^\"]*\";/hash = \"\";/" $NIX_FILE
NIX_OUTPUT=$(nix build .#${attr} 2>&1 || true)
NEW_HASH=$(echo "$NIX_OUTPUT" | grep 'got:' | awk '{print $2}')
echo got new hash $NEW_HASH
sed -i "s|hash = \"[^\"]*\";|hash = \"$NEW_HASH\";|" $NIX_FILE
# Hash lives in lib.nix — just rebuild to verify.
nix build .#${attr}
echo "Updated npm hash in $NIX_FILE to $NEW_HASH"
echo "Lockfile updated and build verified for .#${attr}"
'')
];
passthru = {
devShellHook = pkgs.writeShellScript "npm-dev-hook-${pname}" ''
REPO_ROOT=$(git rev-parse --show-toplevel)
_hermes_npm_stamp() {
sha256sum "${folder}/package.json" "${folder}/package-lock.json" \
2>/dev/null | sha256sum | awk '{print $1}'
}
STAMP=".nix-stamps/${pname}"
STAMP_VALUE="$(_hermes_npm_stamp)"
if [ ! -f "$STAMP" ] || [ "$(cat "$STAMP")" != "$STAMP_VALUE" ]; then
echo "${pname}: installing npm dependencies..."
( cd ${folder} && CI=true ${pkgs.lib.getExe' nodejs "npm"} install --silent --no-fund --no-audit 2>/dev/null )
# Auto-update the nix hash so it stays in sync with the lockfile
echo "${pname}: prefetching npm deps..."
NIX_FILE="$REPO_ROOT/${nixFile}"
if NEW_HASH=$(${pkgs.lib.getExe pkgs.prefetch-npm-deps} "${folder}/package-lock.json" 2>/dev/null); then
sed -i "s|hash = \"sha256-[A-Za-z0-9+/=]+\"|hash = \"$NEW_HASH\";|" "$NIX_FILE"
echo "${pname}: updated hash to $NEW_HASH"
else
echo "${pname}: warning: prefetch failed, run 'nix run .#fix-lockfiles' manually" >&2
fi
mkdir -p .nix-stamps
_hermes_npm_stamp > "$STAMP"
fi
unset -f _hermes_npm_stamp
'';
npmLockfile = {
inherit attr folder nixFile;
};
packageJsonPath = "${folder}/package.json";
};
};
# Aggregate `fix-lockfiles` bin from a list of packages carrying
# passthru.npmLockfile = { attr; folder; nixFile; };
# Invocations:
# Single devshell hook for all npm workspace packages.
#
# Takes a list of package.json relative paths (from mkNpmPassthru .passthru.packageJsonPath),
# stamps all of them, and if any changed:
# 1. Runs `npm i --package-lock-only` from root to update the lockfile
# 2. If the lockfile changed, runs `npm ci` + fix-lockfiles
#
# fixLockfilesExe: absolute path to the fix-lockfiles binary
# (from pkgs.lib.getExe self'.packages.fix-lockfiles in devShell.nix).
mkNpmDevShellHook =
packageJsonPaths: fixLockfilesExe:
pkgs.writeShellScript "npm-dev-hook" ''
REPO_ROOT=$(git rev-parse --show-toplevel)
# Stamp all workspace package.jsons into one file.
STAMP_DIR=".nix-stamps"
STAMP="$STAMP_DIR/npm-package-jsons"
STAMP_VALUE=$(
${pkgs.coreutils}/bin/sha256sum ${
pkgs.lib.concatMapStringsSep " " (p: "\"$REPO_ROOT/${p}\"") packageJsonPaths
} 2>/dev/null | ${pkgs.coreutils}/bin/sort | ${pkgs.coreutils}/bin/sha256sum | awk '{print $1}'
)
PKG_CHANGED=false
if [ ! -f "$STAMP" ] || [ "$(cat "$STAMP")" != "$STAMP_VALUE" ]; then
PKG_CHANGED=true
echo "npm: package.json changed, updating lockfile..."
( cd "$REPO_ROOT" && ${pkgs.lib.getExe' nodejs "npm"} i --package-lock-only --silent --no-fund --no-audit 2>/dev/null )
mkdir -p "$STAMP_DIR"
echo "$STAMP_VALUE" > "$STAMP"
fi
# Check if lockfile changed (either from the npm i above or from an
# external edit). Runs npm ci + fix-lockfiles if so.
LOCK_STAMP="$STAMP_DIR/root-lockfile"
LOCK_STAMP_VALUE=$(sha256sum "$REPO_ROOT/package-lock.json" 2>/dev/null | awk '{print $1}')
if [ ! -f "$LOCK_STAMP" ] || [ "$(cat "$LOCK_STAMP")" != "$LOCK_STAMP_VALUE" ]; then
echo "npm: package-lock.json changed, running npm ci..."
( cd "$REPO_ROOT" && CI=true ${pkgs.lib.getExe' nodejs "npm"} ci --silent --no-fund --no-audit 2>/dev/null )
echo "npm: updating nix hash..."
${fixLockfilesExe} || echo "npm: warning: fix-lockfiles failed, run it manually" >&2
mkdir -p "$STAMP_DIR"
echo "$LOCK_STAMP_VALUE" > "$LOCK_STAMP"
fi
'';
# Build `fix-lockfiles` bin that checks/updates the single npmDepsHash
# fix-lockfiles --check # exit 1 if any hash is stale
# fix-lockfiles --apply # rewrite stale hashes in place
# fix-lockfiles # alias of --apply
@@ -123,12 +173,8 @@
# when set, so CI workflows can post a sticky PR comment directly.
mkFixLockfiles =
{
packages, # list of packages with passthru.npmLockfile
attr, # flake package attr for fallback verification build, e.g. "tui"
}:
let
entries = map (p: p.passthru.npmLockfile) packages;
entryArgs = pkgs.lib.concatMapStringsSep " " (e: "\"${e.attr}:${e.folder}:${e.nixFile}\"") entries;
in
pkgs.writeShellScriptBin "fix-lockfiles" ''
set -uox pipefail
MODE="''${1:---apply}"
@@ -142,8 +188,6 @@
exit 2 ;;
esac
ENTRIES=(${entryArgs})
REPO_ROOT="$(git rev-parse --show-toplevel)"
cd "$REPO_ROOT"
@@ -160,66 +204,76 @@
FIXED=0
REPORT=""
for entry in "''${ENTRIES[@]}"; do
IFS=":" read -r ATTR FOLDER NIX_FILE <<< "$entry"
echo "==> .#$ATTR ($FOLDER -> $NIX_FILE)"
# Compute the actual hash from the lockfile directly using
# prefetch-npm-deps. This avoids false "ok" from nix build when
# an old derivation is cached in a substituter (cachix/cache.nixos.org).
LOCK_FILE="$FOLDER/package-lock.json"
NEW_HASH=$(${pkgs.lib.getExe pkgs.prefetch-npm-deps} "$LOCK_FILE" 2>/dev/null)
# All workspace packages share the root package-lock.json, so
# we only need to check the hash once.
LOCK_FILE="package-lock.json"
LIB_FILE="nix/lib.nix"
NEW_HASH=$(${pkgs.lib.getExe pkgs.prefetch-npm-deps} "$LOCK_FILE" 2>/dev/null)
if [ -z "$NEW_HASH" ]; then
echo "prefetch-npm-deps failed, falling back to nix build" >&2
OUTPUT=$(nix build ".#${attr}.npmDeps" --no-link --print-build-logs 2>&1)
STATUS=$?
if [ "$STATUS" -eq 0 ]; then
echo "ok (via nix build)"
exit 0
fi
NEW_HASH=$(echo "$OUTPUT" | awk '/got:/ {print $2; exit}')
if [ -z "$NEW_HASH" ]; then
echo " prefetch-npm-deps failed, falling back to nix build" >&2
OUTPUT=$(nix build ".#$ATTR.npmDeps" --no-link --print-build-logs 2>&1)
STATUS=$?
if [ "$STATUS" -eq 0 ]; then
echo " ok (via nix build)"
continue
if echo "$OUTPUT" | grep -qE "throttled|HTTP error 418|substituter .* is disabled|some outputs of .* are not valid"; then
echo "skipped (transient cache failure see primary nix build for real status)" >&2
echo "$OUTPUT" | tail -8 >&2
exit 0
fi
NEW_HASH=$(echo "$OUTPUT" | awk '/got:/ {print $2; exit}')
if [ -z "$NEW_HASH" ]; then
if echo "$OUTPUT" | grep -qE "throttled|HTTP error 418|substituter .* is disabled|some outputs of .* are not valid"; then
echo " skipped (transient cache failure see primary nix build for real status)" >&2
echo "$OUTPUT" | tail -8 >&2
continue
echo "build failed with no hash mismatch:" >&2
echo "$OUTPUT" | tail -40 >&2
exit 1
fi
fi
OLD_HASH=$(grep -oE 'npmDepsHash = "sha256-[^"]+"' "$LIB_FILE" | head -1 \
| sed -E 's/npmDepsHash = "(.*)"/\1/')
if [ "$NEW_HASH" = "$OLD_HASH" ]; then
echo "ok"
exit 0
fi
HASH_LINE=$(grep -n 'npmDepsHash = "sha256-' "$LIB_FILE" | head -1 | cut -d: -f1)
echo "stale: $LIB_FILE:$HASH_LINE $OLD_HASH -> $NEW_HASH"
STALE=1
if [ -n "$LINK_REPO" ] && [ -n "$LINK_SHA" ]; then
LIB_URL="$LINK_SERVER/$LINK_REPO/blob/$LINK_SHA/$LIB_FILE#L$HASH_LINE"
LOCK_URL="$LINK_SERVER/$LINK_REPO/blob/$LINK_SHA/$LOCK_FILE"
REPORT="- [\`$LIB_FILE:$HASH_LINE\`]($LIB_URL): \`$OLD_HASH\` \`$NEW_HASH\` lockfile: [\`$LOCK_FILE\`]($LOCK_URL)"$'\\n'
else
REPORT="- \`$LIB_FILE:$HASH_LINE\`: \`$OLD_HASH\` \`$NEW_HASH\`"$'\\n'
fi
if [ "$MODE" = "--apply" ]; then
sed -i -E "s|npmDepsHash = \"sha256-[^\"]+\";|npmDepsHash = \"$NEW_HASH\";|" "$LIB_FILE"
if ! nix build ".#${attr}.npmDeps" --no-link --print-build-logs 2>/dev/null; then
# prefetch-npm-deps may disagree with fetchNpmDeps (it hashes
# the lockfile contents, not the full source tree). Extract the
# correct hash from the nix build error and retry.
RETRY_OUTPUT=$(nix build ".#${attr}.npmDeps" --no-link --print-build-logs 2>&1)
CORRECT_HASH=$(echo "$RETRY_OUTPUT" | awk '/got:/ {print $2; exit}')
if [ -n "$CORRECT_HASH" ]; then
echo "prefetch-npm-deps gave $NEW_HASH but nix wants $CORRECT_HASH retrying" >&2
sed -i -E "s|npmDepsHash = \"sha256-[^\"]+\";|npmDepsHash = \"$CORRECT_HASH\";|" "$LIB_FILE"
if ! nix build ".#${attr}.npmDeps" --no-link --print-build-logs; then
echo "verification build failed after hash retry" >&2
exit 1
fi
echo " build failed with no hash mismatch:" >&2
echo "$OUTPUT" | tail -40 >&2
NEW_HASH="$CORRECT_HASH"
else
echo "verification build failed after hash update" >&2
exit 1
fi
fi
OLD_HASH=$(grep -oE 'hash = "sha256-[^"]+"' "$NIX_FILE" | head -1 \
| sed -E 's/hash = "(.*)"/\1/')
if [ "$NEW_HASH" = "$OLD_HASH" ]; then
echo " ok"
continue
fi
HASH_LINE=$(grep -n 'hash = "sha256-' "$NIX_FILE" | head -1 | cut -d: -f1)
echo " stale: $NIX_FILE:$HASH_LINE $OLD_HASH -> $NEW_HASH"
STALE=1
if [ -n "$LINK_REPO" ] && [ -n "$LINK_SHA" ]; then
NIX_URL="$LINK_SERVER/$LINK_REPO/blob/$LINK_SHA/$NIX_FILE#L$HASH_LINE"
LOCK_URL="$LINK_SERVER/$LINK_REPO/blob/$LINK_SHA/$LOCK_FILE"
REPORT+="- [\`$NIX_FILE:$HASH_LINE\`]($NIX_URL) (\`.#$ATTR\`): \`$OLD_HASH\` \`$NEW_HASH\` lockfile: [\`$LOCK_FILE\`]($LOCK_URL)"$'\n'
else
REPORT+="- \`$NIX_FILE:$HASH_LINE\` (\`.#$ATTR\`): \`$OLD_HASH\` \`$NEW_HASH\`"$'\n'
fi
if [ "$MODE" = "--apply" ]; then
sed -i "s|hash = \"sha256-[^\"]*\";|hash = \"$NEW_HASH\";|" "$NIX_FILE"
if ! nix build ".#$ATTR.npmDeps" --no-link --print-build-logs; then
echo " verification build failed after hash update" >&2
exit 1
fi
FIXED=1
echo " fixed"
fi
done
FIXED=1
echo "fixed"
fi
if [ -n "''${GITHUB_OUTPUT:-}" ]; then
{
@@ -235,7 +289,7 @@
if [ "$STALE" -eq 1 ] && [ "$MODE" = "--check" ]; then
echo
echo "Stale lockfile hashes detected. Run:"
echo "Stale lockfile hash detected. Run:"
echo " nix run .#fix-lockfiles"
exit 1
fi
+1 -3
View File
@@ -51,9 +51,7 @@
web = hermesAgent.hermesWeb;
desktop = hermesAgent.hermesDesktop;
fix-lockfiles = hermesAgent.hermesNpmLib.mkFixLockfiles {
packages = [ hermesAgent.hermesTui hermesAgent.hermesWeb hermesAgent.hermesDesktop ];
};
fix-lockfiles = hermesAgent.hermesNpmLib.mkFixLockfiles { attr = "tui"; };
};
};
}
+11 -13
View File
@@ -1,34 +1,32 @@
# nix/tui.nix — Hermes TUI (Ink/React) compiled with tsc and bundled
{ pkgs, hermesNpmLib, ... }:
let
src = ../ui-tui;
npmDeps = pkgs.fetchNpmDeps {
inherit src;
hash = "sha256-F6/MzZOWc0zhW9mIfnaY+PrllPvJcsA/OdFdEM+NpLY=";
};
npm = hermesNpmLib.mkNpmPassthru { folder = "ui-tui"; attr = "tui"; pname = "hermes-tui"; };
packageJson = builtins.fromJSON (builtins.readFile (src + "/package.json"));
packageJson = builtins.fromJSON (builtins.readFile (npm.src + "/ui-tui/package.json"));
version = packageJson.version;
in
pkgs.buildNpmPackage (npm // {
pname = "hermes-tui";
inherit src npmDeps version;
inherit version;
doCheck = false;
npmFlags = [ "--legacy-peer-deps" ];
buildPhase = ''
# esbuild bundles everything — no need for tsc or vite.
# Run from the workspace root where node_modules/ lives.
node ui-tui/scripts/build.mjs
'';
installPhase = ''
runHook preInstall
mkdir -p $out/lib/hermes-tui
# Single self-contained bundle built by scripts/build.mjs (esbuild).
cp -r dist $out/lib/hermes-tui/dist
# esbuild writes to ui-tui/dist/ from the source root (no cd).
cp -r ui-tui/dist $out/lib/hermes-tui/dist
# package.json kept for "type": "module" resolution on `node dist/entry.js`.
cp package.json $out/lib/hermes-tui/
cp ui-tui/package.json $out/lib/hermes-tui/
runHook postInstall
'';
+14 -11
View File
@@ -1,31 +1,34 @@
# nix/web.nix — Hermes Web Dashboard (Vite/React) frontend build
{ pkgs, hermesNpmLib, ... }:
let
src = ../web;
npmDeps = pkgs.fetchNpmDeps {
inherit src;
hash = "sha256-HV0aISBVjwbGqDj8qQynSxGFrrZDzuYAW3D3lB/x3zo=";
};
npm = hermesNpmLib.mkNpmPassthru { folder = "web"; attr = "web"; pname = "hermes-web"; };
packageJson = builtins.fromJSON (builtins.readFile (src + "/package.json"));
packageJson = builtins.fromJSON (builtins.readFile (npm.src + "/web/package.json"));
version = packageJson.version;
in
pkgs.buildNpmPackage (npm // {
pname = "hermes-web";
inherit src npmDeps version;
inherit version;
doCheck = false;
buildPhase = ''
npx tsc -b
npx vite build --outDir dist
# Build from web/ so vite.config.ts and tsconfig resolve correctly.
# The workspace root's node_modules/ is at ../node_modules/.
cd web
node ../node_modules/typescript/bin/tsc -b
# outDir in vite.config.ts points to ../hermes_cli/web_dist for the
# monorepo layout. Override with --outDir dist for the nix build.
node ../node_modules/vite/bin/vite.js build --outDir dist
# Return to source root so installPhase paths are correct.
cd ..
'';
installPhase = ''
runHook preInstall
cp -r dist $out
# vite writes to web/dist/ (we cd'd there, overrode outDir, then cd'd back).
cp -r web/dist $out
runHook postInstall
'';
})
+2275 -904
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -4,7 +4,10 @@
"description": "An AI agent with advanced tool-calling capabilities, featuring a flexible toolsets system for organizing and managing tools.",
"private": true,
"workspaces": [
"apps/*"
"apps/*",
"ui-tui",
"ui-tui/packages/*",
"web"
],
"scripts": {
"postinstall": "echo '✅ Browser tools ready. Run: python run_agent.py --help'"
+9 -15
View File
@@ -48,22 +48,16 @@
return tier ? "ha-tier-" + tier.toLowerCase() : "ha-tier-pending";
};
async function api(path, options) {
function api(path, options) {
// Delegate to the host SDK's fetchJSON so auth is handled correctly in
// BOTH dashboard modes: loopback (X-Hermes-Session-Token header) and
// gated OAuth (hermes_session_at cookie via credentials:'include').
// Hand-rolling fetch + reading window.__HERMES_SESSION_TOKEN__ directly
// 401s in gated mode (the token isn't injected there). fetchJSON throws
// Error("<status>: <body>") on non-2xx — the call sites' .catch() relies
// on that to surface errors, so we let it propagate (don't swallow).
const url = "/api/plugins/hermes-achievements" + path;
const token = window.__HERMES_SESSION_TOKEN__ || "";
const headers = { ...((options && options.headers) || {}) };
if (token) headers["X-Hermes-Session-Token"] = token;
const res = await fetch(url, { ...(options || {}), headers });
if (!res.ok) {
const text = await res.text().catch(function () { return res.statusText; });
throw new Error(res.status + ": " + text);
}
const text = await res.text();
try {
return JSON.parse(text);
} catch (_) {
return null;
}
return SDK.fetchJSON(url, options);
}
function AchievementIcon({ icon }) {
+58 -45
View File
@@ -588,52 +588,62 @@
wsClosedRef.current = false;
function openWs() {
if (wsClosedRef.current) return;
const token = window.__HERMES_SESSION_TOKEN__ || "";
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
const qsParams = {
since: String(cursorRef.current || 0),
token: token,
};
// Build the WS URL via the host SDK so the correct auth param is used
// in BOTH modes: single-use ?ticket= in gated OAuth mode, ?token= in
// loopback. Reading window.__HERMES_SESSION_TOKEN__ directly (the old
// path) sends an empty token and is rejected in gated mode. buildWsUrl
// also applies the dashboard base-path prefix for reverse-proxied
// deployments, which the old inline URL did not. It's async (gated
// mode mints a fresh ticket per connect), so resolve then open.
const wsParams = { since: String(cursorRef.current || 0) };
// Pin the WS stream to the currently-selected board so events
// from other boards don't bleed in. Includes "default" so the
// dashboard's own board pin always wins over the server-side
// ``current`` file — same rationale as ``withBoard()`` above.
// Regression: #20879.
if (board) qsParams.board = board;
const qs = new URLSearchParams(qsParams);
const url = `${proto}//${window.location.host}${API}/events?${qs}`;
let ws;
try { ws = new WebSocket(url); } catch (_e) { return; }
wsRef.current = ws;
ws.onopen = function () { wsBackoffRef.current = 1000; };
ws.onmessage = function (ev) {
try {
const msg = JSON.parse(ev.data);
if (msg && Array.isArray(msg.events) && msg.events.length > 0) {
cursorRef.current = msg.cursor || cursorRef.current;
// Stamp per-task signal so the TaskDrawer can reload itself.
setTaskEventTick(function (prev) {
const next = Object.assign({}, prev);
for (const e of msg.events) {
if (e && e.task_id) next[e.task_id] = (next[e.task_id] || 0) + 1;
}
return next;
});
scheduleReload();
if (board) wsParams.board = board;
SDK.buildWsUrl(`${API}/events`, wsParams).then(function (url) {
if (wsClosedRef.current) return;
let ws;
try { ws = new WebSocket(url); } catch (_e) { return; }
wsRef.current = ws;
ws.onopen = function () { wsBackoffRef.current = 1000; };
ws.onmessage = function (ev) {
try {
const msg = JSON.parse(ev.data);
if (msg && Array.isArray(msg.events) && msg.events.length > 0) {
cursorRef.current = msg.cursor || cursorRef.current;
// Stamp per-task signal so the TaskDrawer can reload itself.
setTaskEventTick(function (prev) {
const next = Object.assign({}, prev);
for (const e of msg.events) {
if (e && e.task_id) next[e.task_id] = (next[e.task_id] || 0) + 1;
}
return next;
});
scheduleReload();
}
} catch (_e) { /* ignore */ }
};
ws.onclose = function (ev) {
if (wsClosedRef.current) return;
if (ev && ev.code === 1008) {
setError(tx(t, "wsAuthFailed",
"WebSocket auth failed — reload the page to refresh the session token."));
return;
}
} catch (_e) { /* ignore */ }
};
ws.onclose = function (ev) {
const delay = Math.min(wsBackoffRef.current, 30000);
wsBackoffRef.current = Math.min(wsBackoffRef.current * 2, 30000);
setTimeout(openWs, delay);
};
}).catch(function () {
// Ticket mint / URL build failed (e.g. session expired). Back off
// and retry; a hard auth failure surfaces via the 1008 close path.
if (wsClosedRef.current) return;
if (ev && ev.code === 1008) {
setError(tx(t, "wsAuthFailed",
"WebSocket auth failed — reload the page to refresh the session token."));
return;
}
const delay = Math.min(wsBackoffRef.current, 30000);
wsBackoffRef.current = Math.min(wsBackoffRef.current * 2, 30000);
setTimeout(openWs, delay);
};
});
}
openWs();
return function () {
@@ -2837,8 +2847,6 @@
if (!files.length) return;
setUploadBusy(true);
setUploadErr(null);
const token = window.__HERMES_SESSION_TOKEN__ || "";
const headers = token ? { Authorization: "Bearer " + token } : {};
const url = withBoard(`${API}/tasks/${encodeURIComponent(props.taskId)}/attachments`, boardSlug);
// Upload sequentially so a partial failure leaves a clear state.
let chain = Promise.resolve();
@@ -2846,7 +2854,11 @@
chain = chain.then(function () {
const fd = new FormData();
fd.append("file", f, f.name);
return fetch(url, { method: "POST", headers: headers, credentials: "same-origin", body: fd })
// SDK.authedFetch handles auth in BOTH modes (loopback token header /
// gated cookie) and applies the dashboard base-path prefix. The old
// hand-rolled Authorization:Bearer + credentials:'same-origin' sent
// an empty token and 401'd in gated mode.
return SDK.authedFetch(url, { method: "POST", body: fd })
.then(function (resp) {
if (!resp.ok) {
return resp.text().then(function (txt) {
@@ -3073,15 +3085,16 @@
const fileRef = useRef(null);
const [dlErr, setDlErr] = useState(null);
// Download via authenticated fetch → blob → synthetic anchor click.
// A plain <a href> can't carry the session header/bearer the dashboard
// auth middleware requires in loopback mode, so fetch with the token
// and hand the browser a blob URL instead.
// A plain <a href> can't carry the auth the dashboard middleware requires,
// so fetch authenticated and hand the browser a blob URL instead.
function downloadAttachment(a) {
const token = window.__HERMES_SESSION_TOKEN__ || "";
const headers = token ? { Authorization: "Bearer " + token } : {};
// SDK.authedFetch handles auth in BOTH modes (loopback token header /
// gated cookie) and applies the dashboard base-path prefix. The old
// hand-rolled Authorization:Bearer + credentials:'same-origin' sent an
// empty token and 401'd in gated mode.
const url = withBoard(`${API}/attachments/${a.id}`, props.boardSlug);
setDlErr(null);
fetch(url, { headers: headers, credentials: "same-origin" })
SDK.authedFetch(url)
.then(function (resp) {
if (!resp.ok) {
return resp.text().then(function (txt) {
+27 -16
View File
@@ -36,7 +36,6 @@ the port.
from __future__ import annotations
import asyncio
import hmac
import json
import logging
import os
@@ -63,15 +62,29 @@ router = APIRouter()
# existing plugin-bypass; this is documented above).
# ---------------------------------------------------------------------------
def _check_ws_token(provided: Optional[str]) -> bool:
"""Constant-time compare against the dashboard session token.
def _ws_upgrade_authorized(ws: "WebSocket") -> bool:
"""Authorize a WebSocket upgrade by delegating to the dashboard's canonical
WS auth gate (``hermes_cli.web_server._ws_auth_ok``).
Delegating (rather than re-implementing a ``_SESSION_TOKEN``-only check)
means this endpoint transparently accepts whatever the core gate accepts
in each mode:
* loopback / ``--insecure``: legacy ``?token=<_SESSION_TOKEN>``
* gated OAuth: single-use ``?ticket=`` (the browser SDK's
``buildWsUrl`` mints one per connect)
* server-internal: the process-lifetime ``?internal=`` credential
The previous bespoke check only understood ``_SESSION_TOKEN``, so the
kanban live-events WS was rejected on every OAuth-gated deployment even
though the rest of the dashboard worked. Routing through the shared gate
also means this can never drift from core auth again.
Imported lazily so the plugin still loads in test contexts where the
dashboard web_server module isn't importable (e.g. the bare-FastAPI
test harness).
dashboard ``web_server`` module isn't importable (e.g. the bare-FastAPI
test harness); there we accept so the tail loop stays testable, matching
the prior behaviour.
"""
if not provided:
return False
try:
from hermes_cli import web_server as _ws
except Exception:
@@ -79,10 +92,7 @@ def _check_ws_token(provided: Optional[str]) -> bool:
# testable; in production the dashboard module always imports
# cleanly because it's the caller.
return True
expected = getattr(_ws, "_SESSION_TOKEN", None)
if not expected:
return True
return hmac.compare_digest(str(provided), str(expected))
return bool(_ws._ws_auth_ok(ws))
def _resolve_board(board: Optional[str]) -> Optional[str]:
@@ -2375,11 +2385,12 @@ def set_orchestration_settings(payload: OrchestrationSettingsBody):
@router.websocket("/events")
async def stream_events(ws: WebSocket):
# Enforce the dashboard session token as a query param — browsers can't
# set Authorization on a WS upgrade. This matches how the PTY bridge
# authenticates in hermes_cli/web_server.py.
token = ws.query_params.get("token")
if not _check_ws_token(token):
# Authorize the upgrade via the dashboard's canonical WS gate so the
# correct credential is accepted in every mode (loopback token / gated
# single-use ticket / server-internal credential). Browsers can't set
# Authorization on a WS upgrade, so the credential rides in the query
# string — the browser SDK's buildWsUrl() assembles it.
if not _ws_upgrade_authorized(ws):
await ws.close(code=http_status.WS_1008_POLICY_VIOLATION)
return
await ws.accept()
+37 -78
View File
@@ -289,78 +289,42 @@ function Install-AgentBrowser {
# ============================================================================
function Install-Uv {
Write-Info "Checking for uv package manager..."
# Check if uv is already available
if (Get-Command uv -ErrorAction SilentlyContinue) {
$version = uv --version
$script:UvCmd = "uv"
Write-Success "uv found ($version)"
# Hermes owns its own uv at $HermesHome\bin\uv.exe. Always install there —
# no PATH probing, no conda guards, no multi-location resolution chains.
# The runtime update path (hermes_cli/managed_uv.py) looks in the same
# place, so install.ps1 and `hermes update` stay in sync.
$managedUv = Join-Path $HermesHome "bin\uv.exe"
if (Test-Path $managedUv) {
$script:UvCmd = $managedUv
$version = & $managedUv --version
Write-Success "Managed uv found ($version)"
return $true
}
# Check common install locations
$uvPaths = @(
"$env:USERPROFILE\.local\bin\uv.exe",
"$env:USERPROFILE\.cargo\bin\uv.exe"
)
foreach ($uvPath in $uvPaths) {
if (Test-Path $uvPath) {
$script:UvCmd = $uvPath
$version = & $uvPath --version
Write-Success "uv found at $uvPath ($version)"
return $true
}
}
# Install uv
Write-Info "Installing uv (fast Python package manager)..."
# Capture EAP outside the try block so the catch's restore call always
# has a meaningful value -- if the assignment lived inside try and the
# try body threw before reaching it, the catch would see $prevEAP
# unset and leave EAP at whatever the previous protected call set.
Write-Info "Installing managed uv into $HermesHome\bin ..."
New-Item -ItemType Directory -Path (Join-Path $HermesHome "bin") -Force | Out-Null
# UV_INSTALL_DIR tells the astral installer to place the binary
# directly into $HermesHome\bin instead of ~/.local/bin.
$prevEAP = $ErrorActionPreference
try {
# Relax ErrorActionPreference around the nested astral installer.
# The astral installer (a separate `powershell -c "irm ... | iex"`)
# writes download progress to stderr. With $ErrorActionPreference
# = "Stop" set at the top of this script, PowerShell wraps stderr
# lines from native commands (which `powershell -c` is, from our
# perspective) as ErrorRecord objects when captured via 2>&1, then
# throws a terminating exception on the first one -- even though
# uv installs successfully and the child exits 0. Same fix
# pattern Test-Python uses for `uv python install`; verify success
# via Test-Path on the expected binary afterwards, which is more
# reliable than exit-code/stderr signal anyway.
$ErrorActionPreference = "Continue"
$env:UV_INSTALL_DIR = Join-Path $HermesHome "bin"
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" 2>&1 | Out-Null
$ErrorActionPreference = $prevEAP
# Find the installed binary
$uvExe = "$env:USERPROFILE\.local\bin\uv.exe"
if (-not (Test-Path $uvExe)) {
$uvExe = "$env:USERPROFILE\.cargo\bin\uv.exe"
}
if (-not (Test-Path $uvExe)) {
# Refresh PATH and try again
$env:Path = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine")
if (Get-Command uv -ErrorAction SilentlyContinue) {
$uvExe = (Get-Command uv).Source
}
}
if (Test-Path $uvExe) {
$script:UvCmd = $uvExe
$version = & $uvExe --version
Write-Success "uv installed ($version)"
if (Test-Path $managedUv) {
$script:UvCmd = $managedUv
$version = & $managedUv --version
Write-Success "Managed uv installed ($version)"
return $true
}
Write-Err "uv installed but not found on PATH"
Write-Info "Try restarting your terminal and re-running"
Write-Err "uv installed but not found at $managedUv"
Write-Info "Install manually: https://docs.astral.sh/uv/getting-started/installation/"
return $false
} catch {
# Restore EAP in case the try block threw before the assignment
if ($prevEAP) { $ErrorActionPreference = $prevEAP }
Write-Err "Failed to install uv: $_"
Write-Info "Install manually: https://docs.astral.sh/uv/getting-started/installation/"
@@ -385,11 +349,9 @@ function Sync-EnvPath {
# in a fresh powershell process, so $script:UvCmd set by Install-Uv in a
# prior process is not visible here. Later stages (Test-Python,
# Install-Venv, Install-Dependencies, Install-PlatformSdks) call this
# at the top to populate $script:UvCmd from PATH or known install paths.
# Throws if uv is not findable -- the caller's stage then surfaces a
# clean error via the stage-driver's try/catch. Fast path is a single
# Get-Command call when uv is on PATH (the common case after Stage-Uv
# ran path-modifying installs in a sibling process).
# at the top to populate $script:UvCmd from the managed location.
# Throws if uv is not findable the caller's stage then surfaces a
# clean error via the stage-driver's try/catch.
function Resolve-UvCmd {
# Already resolved (default invocation path: Install-Uv ran earlier
# in the same process and set $script:UvCmd).
@@ -404,9 +366,15 @@ function Resolve-UvCmd {
# Stale; fall through to re-discover.
}
# Try PATH first (covers `winget install astral.uv`, manual installs,
# and the post-Install-Uv state where uv.exe lives in
# %USERPROFILE%\.local\bin which the installer added to PATH).
# Check the managed location first — this is where Install-Uv puts it.
$managedUv = Join-Path $HermesHome "bin\uv.exe"
if (Test-Path $managedUv) {
$script:UvCmd = $managedUv
return
}
# Fall back to PATH (covers edge cases where the installer ran in a
# sibling process and HERMES_HOME wasn't propagated).
if (Get-Command uv -ErrorAction SilentlyContinue) {
$script:UvCmd = "uv"
return
@@ -420,16 +388,7 @@ function Resolve-UvCmd {
return
}
# Check the well-known install locations the astral.sh installer drops
# uv into. Mirrors the probe order Install-Uv uses.
foreach ($uvPath in @("$env:USERPROFILE\.local\bin\uv.exe", "$env:USERPROFILE\.cargo\bin\uv.exe")) {
if (Test-Path $uvPath) {
$script:UvCmd = $uvPath
return
}
}
throw "uv is not installed or not on PATH. Run install.ps1 -Stage uv first."
throw "uv is not installed. Run install.ps1 -Stage uv first."
}
function Test-Python {
+61 -150
View File
@@ -475,39 +475,22 @@ install_uv() {
return 0
fi
log_info "Checking for uv package manager..."
# Hermes owns its own uv at $HERMES_HOME/bin/uv. Always install there —
# no PATH probing, no conda guards, no multi-location resolution chains.
# The runtime update path (hermes_cli/managed_uv.py) looks in the same
# place, so install.sh and `hermes update` stay in sync.
local _managed_uv="$HERMES_HOME/bin/uv"
# Check common locations for uv
if command -v uv &> /dev/null; then
UV_CMD="uv"
if [ -x "$_managed_uv" ]; then
UV_CMD="$_managed_uv"
UV_VERSION=$($UV_CMD --version 2>/dev/null)
log_success "uv found ($UV_VERSION)"
log_success "Managed uv found ($UV_VERSION)"
return 0
fi
# Check ~/.local/bin (default uv install location) even if not on PATH yet
if [ -x "$HOME/.local/bin/uv" ]; then
UV_CMD="$HOME/.local/bin/uv"
UV_VERSION=$($UV_CMD --version 2>/dev/null)
log_success "uv found at ~/.local/bin ($UV_VERSION)"
return 0
fi
log_info "Installing managed uv into $HERMES_HOME/bin ..."
mkdir -p "$HERMES_HOME/bin"
# Check ~/.cargo/bin (alternative uv install location)
if [ -x "$HOME/.cargo/bin/uv" ]; then
UV_CMD="$HOME/.cargo/bin/uv"
UV_VERSION=$($UV_CMD --version 2>/dev/null)
log_success "uv found at ~/.cargo/bin ($UV_VERSION)"
return 0
fi
# Install uv
log_info "Installing uv (fast Python package manager)..."
# Capture installer output so a failure shows the user WHY (network,
# glibc mismatch on old distros, missing curl, ~/.local/bin not
# writable, disk full, corp proxy / TLS interception, etc.) instead
# of the previous "✗ Failed to install uv" with zero diagnostic.
#
# Two-stage: download the installer, then run it. Piping
# `curl | sh` masks curl failures (sh exits 0 on empty stdin)
# and conflates network errors with installer errors.
@@ -522,26 +505,22 @@ install_uv() {
rm -f "$_uv_install_log" "$_uv_installer"
exit 1
fi
if sh "$_uv_installer" >>"$_uv_install_log" 2>&1; then
# UV_UNMANAGED_INSTALL tells the astral installer to place the binary
# directly into $HERMES_HOME/bin instead of ~/.local/bin.
if UV_UNMANAGED_INSTALL="$HERMES_HOME/bin" sh "$_uv_installer" >>"$_uv_install_log" 2>&1; then
rm -f "$_uv_installer"
# uv installs to ~/.local/bin by default
if [ -x "$HOME/.local/bin/uv" ]; then
UV_CMD="$HOME/.local/bin/uv"
elif [ -x "$HOME/.cargo/bin/uv" ]; then
UV_CMD="$HOME/.cargo/bin/uv"
elif command -v uv &> /dev/null; then
UV_CMD="uv"
if [ -x "$_managed_uv" ]; then
UV_CMD="$_managed_uv"
else
log_error "uv installer reported success but binary not found on PATH"
log_error "uv installer reported success but binary not found at $_managed_uv"
log_info "Installer output:"
sed 's/^/ /' "$_uv_install_log" >&2
log_info "Try adding ~/.local/bin to your PATH and re-running"
rm -f "$_uv_install_log"
exit 1
fi
rm -f "$_uv_install_log"
UV_VERSION=$($UV_CMD --version 2>/dev/null)
log_success "uv installed ($UV_VERSION)"
log_success "Managed uv installed ($UV_VERSION)"
else
log_error "Failed to install uv"
log_info "Installer output:"
@@ -579,7 +558,6 @@ check_python() {
if PYTHON_PATH="$("$UV_CMD" python find "$PYTHON_VERSION" 2>/dev/null)"; then
PYTHON_FOUND_VERSION="$("$PYTHON_PATH" --version 2>/dev/null)"
log_success "Python found: $PYTHON_FOUND_VERSION"
ensure_fts5
return 0
fi
@@ -589,7 +567,6 @@ check_python() {
PYTHON_PATH="$("$UV_CMD" python find "$PYTHON_VERSION")"
PYTHON_FOUND_VERSION="$("$PYTHON_PATH" --version 2>/dev/null)"
log_success "Python installed: $PYTHON_FOUND_VERSION"
ensure_fts5
else
log_error "Failed to install Python $PYTHON_VERSION"
log_info "Install Python $PYTHON_VERSION manually, then re-run this script"
@@ -597,104 +574,6 @@ check_python() {
fi
}
# Probe whether $1 (a python executable) links a SQLite with the FTS5
# module compiled in. Hermes' session store (hermes_state.py) creates FTS5
# virtual tables for full-text session search; a SQLite without FTS5 makes
# the bundled-python path unusable for that feature. Returns 0 if FTS5 works.
_python_has_fts5() {
"$1" - <<'PY' 2>/dev/null
import sqlite3, sys
try:
sqlite3.connect(":memory:").execute("CREATE VIRTUAL TABLE t USING fts5(x)")
except Exception:
sys.exit(1)
PY
}
# Reinstall $PYTHON_VERSION with the current uv and re-resolve PYTHON_PATH.
# Returns 0 if the resulting interpreter ships FTS5.
_reinstall_python_with_fts5() {
local uv_bin="$1"
"$uv_bin" python install "$PYTHON_VERSION" --reinstall >/dev/null 2>&1 || return 1
PYTHON_PATH="$("$uv_bin" python find "$PYTHON_VERSION" 2>/dev/null)"
PYTHON_FOUND_VERSION="$("$PYTHON_PATH" --version 2>/dev/null)"
[ -n "${PYTHON_PATH:-}" ] && _python_has_fts5 "$PYTHON_PATH"
}
_warn_no_fts5() {
# Could not obtain an FTS5-capable interpreter (offline, pinned env, etc.).
# Install proceeds — Hermes degrades gracefully and disables only full-text
# session search — but warn so it isn't a silent gap.
log_warn "Could not obtain an FTS5-capable Python. Hermes will run, but"
log_warn "full-text session search will be disabled until FTS5 is present."
}
# Guarantee the resolved uv-managed interpreter ships FTS5. uv's Python
# distributions only gained FTS5 in mid-2025 (python-build-standalone #694),
# but WHICH builds a given uv can install is baked into the uv binary's
# download manifest — so a stale uv (e.g. `pip install uv==0.7.20`) only knows
# about pre-FTS5 builds, and even `uv python install --reinstall` just pulls the
# same FTS5-less interpreter. A plain reinstall with an old uv is therefore a
# no-op for FTS5. To actually fix everyone's install, we escalate uv itself:
#
# 1. reinstall with the current $UV_CMD (handles a stale *interpreter* under
# an already-current uv)
# 2. if still no FTS5, bring uv up to date (`uv self update`) and reinstall —
# this is what fixes a stale standalone uv
# 3. if uv can't self-update (pip/apt/brew-managed uv refuses), install a
# fresh standalone uv via the official installer into a temp dir and use
# THAT to reinstall — this fixes package-manager-managed stale uv
#
# Pythons live in uv's shared store, so a fresh uv's --reinstall overwrites the
# stale interpreter in place and the installer's later `uv python find` resolves
# to it. Keeps session search working without bundling a second SQLite or asking
# the user to do anything.
ensure_fts5() {
[ -n "${PYTHON_PATH:-}" ] || return 0
if _python_has_fts5 "$PYTHON_PATH"; then
return 0
fi
# Termux / non-uv installs have nothing to escalate.
[ -n "${UV_CMD:-}" ] || { _warn_no_fts5; return 0; }
log_warn "Resolved Python's SQLite lacks the FTS5 module (session search needs it)."
log_info "Reinstalling a current Python $PYTHON_VERSION with FTS5 via uv..."
if _reinstall_python_with_fts5 "$UV_CMD"; then
log_success "FTS5 available ($PYTHON_FOUND_VERSION)"
return 0
fi
# Still no FTS5 — the uv binary itself is too old to know about FTS5-capable
# Python builds. Try to update uv in place.
log_info "uv is too old to provide an FTS5-capable Python — updating uv..."
if "$UV_CMD" self update >/dev/null 2>&1; then
if _reinstall_python_with_fts5 "$UV_CMD"; then
log_success "FTS5 available ($PYTHON_FOUND_VERSION)"
return 0
fi
fi
# `uv self update` is unavailable on externally-managed uv (pip/apt/brew),
# which is exactly the case the user hit (`pip install uv==0.7.20`). Install
# a fresh standalone uv into a temp dir and use it just for the reinstall.
log_info "Installing an up-to-date standalone uv to obtain an FTS5 Python..."
local _tmp_uv_dir _fresh_uv
_tmp_uv_dir="$(mktemp -d 2>/dev/null || echo "/tmp/hermes-fresh-uv.$$")"
mkdir -p "$_tmp_uv_dir"
if curl -LsSf https://astral.sh/uv/install.sh 2>/dev/null \
| env UV_INSTALL_DIR="$_tmp_uv_dir" UV_UNMANAGED_INSTALL="$_tmp_uv_dir" sh >/dev/null 2>&1; then
_fresh_uv="$_tmp_uv_dir/uv"
if [ -x "$_fresh_uv" ] && _reinstall_python_with_fts5 "$_fresh_uv"; then
log_success "FTS5 available ($PYTHON_FOUND_VERSION)"
rm -rf "$_tmp_uv_dir"
return 0
fi
fi
rm -rf "$_tmp_uv_dir"
_warn_no_fts5
}
# Best-effort automatic git provisioning, mirroring install.ps1's Install-Git
# (which downloads PortableGit on Windows). git is required to clone the repo,
# and a fresh "normie" machine with no developer tools won't have it. Returns 0
@@ -2343,10 +2222,10 @@ postinstall_mode() {
fi
}
# Build apps/desktop into a launchable Hermes.app. Mirrors install.ps1's
# Build apps/desktop into a launchable native app. Mirrors install.ps1's
# Install-Desktop: a root-level npm install so the apps/* workspace resolves
# the desktop's own deps (Electron ~150MB), then `npm run pack`
# (electron-builder --dir) which emits release/mac*/Hermes.app. Only invoked
# (electron-builder --dir) which emits an unpacked app for the current OS. Only invoked
# via the 'desktop' stage / --include-desktop, which the Electron app's own
# first-launch bootstrap never requests (it must not rebuild itself).
install_desktop() {
@@ -2382,7 +2261,7 @@ install_desktop() {
log_success "Desktop workspace dependencies installed"
# 2. Build. `npm run pack` = tsc + vite build + electron-builder --dir,
# producing an unpacked release/mac*/Hermes.app. We disable signing
# producing an unpacked app for the current OS. We disable signing
# auto-discovery so electron-builder falls back to an ad-hoc signature
# instead of grabbing an unrelated Developer ID from the keychain; a
# real signed/notarized .dmg needs Apple credentials and is a separate
@@ -2395,21 +2274,53 @@ install_desktop() {
}
local app=""
local cand
for cand in \
"$desktop_dir/release/mac-arm64/Hermes.app" \
"$desktop_dir/release/mac/Hermes.app"; do
if [ -d "$cand" ]; then
app="$cand"
break
if [ "$OS" = "linux" ]; then
if [ -x "$desktop_dir/release/linux-unpacked/Hermes" ]; then
app="$desktop_dir/release/linux-unpacked/Hermes"
elif [ -x "$desktop_dir/release/linux-unpacked/hermes" ]; then
app="$desktop_dir/release/linux-unpacked/hermes"
fi
done
else
local cand
for cand in \
"$desktop_dir/release/mac-arm64/Hermes.app" \
"$desktop_dir/release/mac/Hermes.app"; do
if [ -d "$cand" ]; then
app="$cand"
break
fi
done
fi
if [ -z "$app" ]; then
log_error "Desktop build completed but no Hermes.app was found under $desktop_dir/release/"
log_error "Desktop build completed but no app was found under $desktop_dir/release/"
return 1
fi
log_success "Desktop app built: $app"
# Linux: Electron's chrome-sandbox helper needs root:root 4755 or the
# sandboxed renderer will abort on startup. Check the file is a regular
# file (not a symlink) before chown/chmod so we don't follow an
# attacker-controlled link to an arbitrary path.
if [ "$OS" = "linux" ]; then
local sandbox="$desktop_dir/release/linux-unpacked/chrome-sandbox"
if [ -f "$sandbox" ] && [ ! -L "$sandbox" ]; then
if [ "$(id -u)" -eq 0 ]; then
chown root:root "$sandbox" && chmod 4755 "$sandbox" || {
log_error "Cannot configure Electron sandbox helper: $sandbox"
return 1
}
elif command -v sudo >/dev/null 2>&1; then
sudo chown root:root "$sandbox" && sudo chmod 4755 "$sandbox" || {
log_error "Cannot configure Electron sandbox helper (sudo failed): $sandbox"
return 1
}
else
log_error "Cannot configure Electron sandbox helper without sudo: $sandbox"
return 1
fi
fi
fi
# macOS: make the locally-built (ad-hoc) app relaunchable after an in-place
# self-update. An ad-hoc bundle has no stable Designated Requirement, so a
# later in-place rebuild (new cdhash) plus the inherited quarantine flag
+3
View File
@@ -45,6 +45,7 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json"
# Auto-extracted from noreply emails + manual overrides
AUTHOR_MAP = {
"liliangjya@gmail.com": "truenorth-lj",
"ben.bartholomew@vectorize.io": "benfrank241",
"74339271+SaguaroDev@users.noreply.github.com": "SaguaroDev",
"subw3@mail2.sysu.edu.cn": "Subway2023",
@@ -373,6 +374,7 @@ AUTHOR_MAP = {
"harish.kukreja@gmail.com": "counterposition",
"nidhi2894@gmail.com": "nidhi-singh02",
"35294173+Fearvox@users.noreply.github.com": "Fearvox",
"fearvox1015@gmail.com": "Fearvox",
"hypnus.yuan@gmail.com": "Hypnus-Yuan",
"15558128926@qq.com": "xsfX20",
"binhnt.ht.92@gmail.com": "binhnt92",
@@ -1424,6 +1426,7 @@ AUTHOR_MAP = {
"2663402852@qq.com": "x1am1", # PR #35098 (chown root-owned top-level HERMES_HOME state files)
"nicsequenzy@gmail.com": "polnikale", # PR #35717 (discover Playwright headless_shell browser)
"wasdhkzk@gmail.com": "whyhkzk", # PR #32407 (sandbox-mirror inner-container guard; commits authored as whyhkzk + zhukun)
"leonard@sellem.me": "leonardsellem", # PR #37405 (desktop WS origin guard on remote/Tailscale binds)
}
+104
View File
@@ -22,6 +22,8 @@ from agent.auxiliary_client import (
_get_provider_chain,
_is_payment_error,
_is_rate_limit_error,
_is_model_not_found_error,
_refresh_nous_recommended_model,
_normalize_aux_provider,
_try_payment_fallback,
_resolve_auto,
@@ -1298,6 +1300,108 @@ class TestIsPaymentError:
assert _is_payment_error(exc) is False
class TestIsModelNotFoundError:
"""_is_model_not_found_error detects stale/invalid model 404s, distinct
from payment errors."""
def test_nous_openrouter_catalog_404(self):
"""The exact incident error: a Portal-recommended model dropped from
the Nous OpenRouter catalog."""
exc = Exception(
"Model 'gpt-5.4-mini' not found. The requested model does not "
"exist in our configuration or OpenRouter catalog."
)
exc.status_code = 404
assert _is_model_not_found_error(exc) is True
def test_openai_style_model_does_not_exist(self):
exc = Exception("The model `gpt-9-turbo` does not exist")
exc.status_code = 404
assert _is_model_not_found_error(exc) is True
def test_invalid_model_id_400(self):
exc = Exception("openrouter/foo/bar is not a valid model ID")
exc.status_code = 400
assert _is_model_not_found_error(exc) is True
def test_no_such_model(self):
exc = Exception("no such model: phantom-v1")
exc.status_code = 400
assert _is_model_not_found_error(exc) is True
def test_billing_404_is_not_model_not_found(self):
"""Free-tier / credit 404s belong to _is_payment_error, not here —
the two predicates must not overlap."""
exc = Exception(
"Model 'gpt-5' is not available on the free tier. Upgrade."
)
exc.status_code = 404
assert _is_model_not_found_error(exc) is False
assert _is_payment_error(exc) is True
def test_out_of_funds_404_is_not_model_not_found(self):
exc = Exception(
"Your API key is blocked or out of funds. model_not_found"
)
exc.status_code = 404
# billing keyword wins — payment owns it
assert _is_model_not_found_error(exc) is False
def test_rate_limit_is_not_model_not_found(self):
exc = Exception("rate limit exceeded, retry after 5s")
exc.status_code = 429
assert _is_model_not_found_error(exc) is False
def test_500_is_not_model_not_found(self):
exc = Exception("model does not exist") # right phrase, wrong status
exc.status_code = 500
assert _is_model_not_found_error(exc) is False
class TestRefreshNousRecommendedModel:
"""_refresh_nous_recommended_model picks a fresh model after a stale 404."""
def test_returns_fresh_portal_recommendation(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.models.get_nous_recommended_aux_model",
lambda **kw: "stepfun/step-3.7-flash:free",
)
out = _refresh_nous_recommended_model(
vision=True, stale_model="openai/gpt-5.4-mini")
assert out == "stepfun/step-3.7-flash:free"
def test_falls_back_to_default_when_portal_matches_stale(self, monkeypatch):
"""If the Portal still recommends the model that just 404'd, fall back
to the known-good default."""
monkeypatch.setattr(
"hermes_cli.models.get_nous_recommended_aux_model",
lambda **kw: "openai/gpt-5.4-mini",
)
out = _refresh_nous_recommended_model(
vision=True, stale_model="openai/gpt-5.4-mini")
assert out == "google/gemini-3-flash-preview"
def test_falls_back_to_default_when_portal_unavailable(self, monkeypatch):
def _boom(**kw):
raise RuntimeError("portal down")
monkeypatch.setattr(
"hermes_cli.models.get_nous_recommended_aux_model", _boom)
out = _refresh_nous_recommended_model(
vision=False, stale_model="some/dead-model")
assert out == "google/gemini-3-flash-preview"
def test_returns_none_when_no_distinct_alternative(self, monkeypatch):
"""When the failed model IS the default and the Portal has nothing
else, there's no usable alternative."""
monkeypatch.setattr(
"hermes_cli.models.get_nous_recommended_aux_model",
lambda **kw: "google/gemini-3-flash-preview",
)
out = _refresh_nous_recommended_model(
vision=False, stale_model="google/gemini-3-flash-preview")
assert out is None
class TestIsRateLimitError:
"""_is_rate_limit_error detects 429 rate-limit errors warranting fallback."""
+17 -13
View File
@@ -128,11 +128,11 @@ class TestPriorityProcessingModels(unittest.TestCase):
assert model_supports_fast_mode(model), f"{model} should support fast mode"
def test_all_anthropic_models_supported(self):
"""Per Anthropic docs, fast mode is currently Opus 4.6 only.
"""The speed=fast parameter is gated to Opus 4.6.
Sending speed=fast to Opus 4.7, Sonnet, or Haiku returns HTTP 400.
Pre-fix this test asserted all Claude variants supported fast mode,
which mirrored the bug rather than the API contract.
(Opus 4.8's fast offering is a separate ``…-fast`` model id selected
via the model field, not this parameter see the adapter test.)
"""
from hermes_cli.models import model_supports_fast_mode
@@ -144,16 +144,15 @@ class TestPriorityProcessingModels(unittest.TestCase):
for model in supported:
assert model_supports_fast_mode(model), f"{model} should support fast mode"
# Unsupported per Anthropic API: Opus 4.7, Sonnet, Haiku
# Unsupported per Anthropic API: Opus 4.7/4.8, Sonnet, Haiku
unsupported = [
"claude-opus-4-7",
"claude-opus-4-7", "claude-opus-4-8", "claude-opus-4.8",
"claude-sonnet-4-6", "claude-sonnet-4.6", "claude-sonnet-4",
"claude-haiku-4-5", "claude-3-5-haiku",
]
for model in unsupported:
assert not model_supports_fast_mode(model), (
f"{model} should NOT support fast mode — Anthropic restricts "
f"speed=fast to Opus 4.6"
f"{model} should NOT support the speed=fast parameter"
)
def test_codex_models_excluded(self):
@@ -275,10 +274,11 @@ class TestAnthropicFastMode(unittest.TestCase):
assert model_supports_fast_mode("anthropic/claude-opus-4.6") is True
def test_anthropic_non_opus46_models_excluded(self):
"""Anthropic restricts fast mode to Opus 4.6 — others must be excluded.
"""The speed=fast parameter is gated to Opus 4.6 — others excluded.
Per https://platform.claude.com/docs/en/build-with-claude/fast-mode,
sending speed=fast to Opus 4.7, Sonnet, or Haiku returns HTTP 400.
Opus 4.8 uses a separate ``-fast`` model id, not this parameter.
"""
from hermes_cli.models import model_supports_fast_mode
@@ -286,6 +286,7 @@ class TestAnthropicFastMode(unittest.TestCase):
assert model_supports_fast_mode("claude-sonnet-4.6") is False
assert model_supports_fast_mode("claude-haiku-4-5") is False
assert model_supports_fast_mode("claude-opus-4-7") is False
assert model_supports_fast_mode("claude-opus-4-8") is False
assert model_supports_fast_mode("anthropic/claude-sonnet-4.6") is False
assert model_supports_fast_mode("anthropic/claude-opus-4-7") is False
@@ -314,13 +315,15 @@ class TestAnthropicFastMode(unittest.TestCase):
assert result == {"speed": "fast"}
def test_resolve_overrides_returns_none_for_unsupported_claude(self):
"""Opus 4.7 and other Claude models don't support fast mode (API 400s).
"""Opus 4.7/4.8 and other Claude models don't take the speed param.
Per Anthropic docs, fast mode is currently Opus 4.6 only.
The speed=fast parameter is Opus 4.6 only (Opus 4.8 uses a separate
``-fast`` model id instead).
"""
from hermes_cli.models import resolve_fast_mode_overrides
assert resolve_fast_mode_overrides("claude-opus-4-7") is None
assert resolve_fast_mode_overrides("claude-opus-4-8") is None
assert resolve_fast_mode_overrides("claude-sonnet-4-6") is None
assert resolve_fast_mode_overrides("claude-haiku-4-5") is None
@@ -332,7 +335,7 @@ class TestAnthropicFastMode(unittest.TestCase):
assert result == {"service_tier": "priority"}
def test_is_anthropic_fast_model(self):
"""Fast mode is currently Opus 4.6 only — other Claude variants must be excluded."""
"""The speed=fast parameter is Opus 4.6 only — other Claude excluded."""
from hermes_cli.models import _is_anthropic_fast_model
# Supported: Opus 4.6 in any form
@@ -341,8 +344,9 @@ class TestAnthropicFastMode(unittest.TestCase):
assert _is_anthropic_fast_model("anthropic/claude-opus-4-6") is True
assert _is_anthropic_fast_model("claude-opus-4.6:fast") is True
# Unsupported per Anthropic API contract — would 400 if we sent speed=fast
# Unsupported — would 400 (4.7) or uses a separate model id (4.8)
assert _is_anthropic_fast_model("claude-opus-4-7") is False
assert _is_anthropic_fast_model("claude-opus-4-8") is False
assert _is_anthropic_fast_model("claude-sonnet-4-6") is False
assert _is_anthropic_fast_model("claude-haiku-4-5") is False
@@ -368,7 +372,7 @@ class TestAnthropicFastMode(unittest.TestCase):
assert cli_mod.HermesCLI._fast_command_available(stub) is False
def test_fast_command_hidden_for_anthropic_opus_47(self):
"""Opus 4.7 doesn't support fast mode — /fast must be hidden."""
"""Opus 4.7 doesn't take the speed=fast parameter — /fast must hide."""
cli_mod = _import_cli()
stub = SimpleNamespace(
provider="anthropic", requested_provider="anthropic",
@@ -41,6 +41,7 @@ def cron_env(tmp_path, monkeypatch):
(hermes_home / "cron").mkdir()
(hermes_home / "cron" / "output").mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("HERMES_BUNDLES_DIR", str(hermes_home / "skill-bundles"))
# Patch the module-level SKILLS_DIR snapshots that `skill_view()`
# uses. Without this, the tool resolves against the real
@@ -49,6 +50,11 @@ def cron_env(tmp_path, monkeypatch):
monkeypatch.setattr(_skills_tool, "SKILLS_DIR", skills_dir)
monkeypatch.setattr(_skills_tool, "HERMES_HOME", hermes_home)
# Reset bundle cache and make bundle discovery hit this test home.
import agent.skill_bundles as _skill_bundles
_skill_bundles._bundles_cache = {}
_skill_bundles._bundles_cache_mtime = None
# Return both the home dir and the scheduler module so tests use the
# CURRENT module object (post any reload that happened in fixtures of
# previously-executed tests in the same worker).
@@ -66,6 +72,20 @@ def _plant_skill(hermes_home: Path, name: str, body: str) -> None:
)
def _plant_bundle(hermes_home: Path, name: str, skills: list[str], instruction: str = "") -> None:
"""Drop a bundle YAML into ~/.hermes/skill-bundles/ and refresh cache."""
bundles_dir = hermes_home / "skill-bundles"
bundles_dir.mkdir(parents=True, exist_ok=True)
lines = [f"name: {name}", "skills:"]
lines.extend(f" - {skill}" for skill in skills)
if instruction:
lines.append("instruction: |")
lines.extend(f" {line}" for line in instruction.splitlines())
(bundles_dir / f"{name}.yaml").write_text("\n".join(lines) + "\n", encoding="utf-8")
import agent.skill_bundles as _skill_bundles
_skill_bundles.scan_bundles()
# ---------------------------------------------------------------------------
# _scan_assembled_cron_prompt — isolated unit
# ---------------------------------------------------------------------------
@@ -255,3 +275,47 @@ class TestBuildJobPromptScansSkillContent:
prompt = scheduler._build_job_prompt(job)
assert prompt is not None
assert "could not be found" in prompt
def test_skill_bundle_in_job_skills_loads_referenced_skills(self, cron_env):
hermes_home, scheduler = cron_env
_plant_skill(hermes_home, "alpha-skill", "Alpha guidance for the cron task.")
_plant_skill(hermes_home, "beta-skill", "Beta guidance for the cron task.")
_plant_bundle(
hermes_home,
"article-pipeline",
["alpha-skill", "beta-skill"],
instruction="Use the skills in order.",
)
job = {
"id": "job-bundle",
"name": "bundle cron",
"prompt": "write the report",
"skills": ["article-pipeline"],
}
prompt = scheduler._build_job_prompt(job)
assert prompt is not None
assert '"article-pipeline" skill bundle' in prompt
assert "Alpha guidance for the cron task." in prompt
assert "Beta guidance for the cron task." in prompt
assert "Bundle instruction: Use the skills in order." in prompt
assert "skill(s) were listed for this job but could not be found" not in prompt
def test_bundle_name_shadows_skill_name_for_cron_jobs(self, cron_env):
hermes_home, scheduler = cron_env
_plant_skill(hermes_home, "article-pipeline", "Standalone skill should not win.")
_plant_skill(hermes_home, "bundle-member", "Bundle member should win.")
_plant_bundle(hermes_home, "article-pipeline", ["bundle-member"])
job = {
"id": "job-bundle-shadow",
"name": "bundle shadows skill",
"prompt": "run",
"skills": ["article-pipeline"],
}
prompt = scheduler._build_job_prompt(job)
assert prompt is not None
assert "Bundle member should win." in prompt
assert "Standalone skill should not win." not in prompt
+79
View File
@@ -0,0 +1,79 @@
"""Harness: the image ships a prebuilt TUI bundle, not a runtime npm install.
Regression guard for the hosted-chat failure where the embedded dashboard
Chat tab died with a 502 / "[session ended]". Root cause: the image installs
only a subset of the npm monorepo workspaces (root/web/ui-tui, never apps/*),
so the actualized node_modules permanently disagrees with the canonical
package-lock.json. Without HERMES_TUI_DIR set, ``_make_tui_argv`` falls
through to ``_tui_need_npm_install`` (which returns True forever) and tries a
runtime ``npm install`` that can never converge and races itself across
concurrent /api/pty connections ENOTEMPTY.
The fix is ``ENV HERMES_TUI_DIR=/opt/hermes/ui-tui`` in the Dockerfile, which
makes the launcher take the prebuilt-bundle fast path (``node --expose-gc
.../dist/entry.js``) and skip the install check entirely. These tests assert
that invariant holds in the built image.
"""
from __future__ import annotations
import json
import shlex
import subprocess
def _exec_py(image: str, py: str) -> str:
"""Run a Python snippet inside the image as the hermes user, return stdout."""
inner = (
"source /opt/hermes/.venv/bin/activate && "
"cd /opt/hermes && "
f"python3 -c {shlex.quote(py)}"
)
# Drop to the hermes user (UID 10000) so we exercise the same path the
# dashboard PTY child runs as — not root.
cmd = [
"docker", "run", "--rm", "--entrypoint", "su", image,
"hermes", "-s", "/bin/bash", "-c", inner,
]
r = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
assert r.returncode == 0, f"in-container python failed:\n{r.stderr[-2000:]}"
return r.stdout.strip()
def test_hermes_tui_dir_env_is_set(built_image: str) -> None:
"""HERMES_TUI_DIR must point at the prebuilt bundle dir in the image."""
r = subprocess.run(
["docker", "run", "--rm", "--entrypoint", "sh", built_image,
"-c", 'printf "%s" "$HERMES_TUI_DIR"'],
capture_output=True, text=True, timeout=60,
)
assert r.returncode == 0, r.stderr[-2000:]
assert r.stdout.strip() == "/opt/hermes/ui-tui", (
f"HERMES_TUI_DIR={r.stdout.strip()!r} (expected /opt/hermes/ui-tui)"
)
def test_prebuilt_bundle_present_and_no_runtime_install(built_image: str) -> None:
"""The launcher must (a) find the prebuilt bundle and (b) NOT want an
npm install i.e. it takes the same path as a nix/packaged release."""
py = (
"import json\n"
"from pathlib import Path\n"
"from hermes_cli.main import _tui_need_npm_install, _find_bundled_tui, _make_tui_argv\n"
"ui = Path('/opt/hermes/ui-tui')\n"
"argv, cwd = _make_tui_argv(ui, tui_dev=False)\n"
"out = {\n"
" 'dist_entry_exists': (ui / 'dist' / 'entry.js').is_file(),\n"
" 'need_npm_install': _tui_need_npm_install(ui),\n"
" 'argv': argv,\n"
" 'uses_prebuilt': ('dist/entry.js' in ' '.join(argv)) and ('npm' not in argv[0].lower()),\n"
"}\n"
"print(json.dumps(out))\n"
)
out = json.loads(_exec_py(built_image, py))
assert out["dist_entry_exists"], "prebuilt ui-tui/dist/entry.js missing from image"
# With HERMES_TUI_DIR set, _make_tui_argv returns the prebuilt path BEFORE
# ever reaching the install check — so the resolved argv is what matters.
assert out["uses_prebuilt"], f"launcher did not take prebuilt path: argv={out['argv']!r}"
assert "npm" not in out["argv"][0].lower(), (
f"launcher resolved to an npm invocation, not the prebuilt bundle: {out['argv']!r}"
)
+82
View File
@@ -267,6 +267,88 @@ class TestRunBackgroundTask:
mock_agent_instance.shutdown_memory_provider.assert_called_once()
mock_agent_instance.close.assert_called_once()
@pytest.mark.asyncio
async def test_media_files_routed_by_type(self, monkeypatch):
"""Result media is routed to the type-specific sender, not send_document.
A TTS clip should arrive as a voice bubble, a video as a video, an
image as a native image, and everything else as a document.
"""
from gateway import run as gateway_run
runner = _make_runner()
runner._resolve_session_agent_runtime = MagicMock(
return_value=("test-model", {"api_key": "test-key"})
)
runner._resolve_session_reasoning_config = MagicMock(return_value=None)
runner._load_service_tier = MagicMock(return_value=None)
runner._resolve_turn_agent_config = MagicMock(
return_value={
"model": "test-model",
"runtime": {"api_key": "test-key"},
"request_overrides": None,
}
)
runner._run_in_executor_with_context = AsyncMock(
return_value={"final_response": "see attached", "messages": []}
)
monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {})
# Four real files so the media-delivery path validator accepts them
# (default mode requires the file to exist as a regular file).
import os as _os
import tempfile as _tempfile
_tmpdir = _tempfile.mkdtemp(prefix="bg_media_")
_ogg = _os.path.join(_tmpdir, "clip.ogg")
_mp4 = _os.path.join(_tmpdir, "render.mp4")
_png = _os.path.join(_tmpdir, "chart.png")
_pdf = _os.path.join(_tmpdir, "report.pdf")
for _p in (_ogg, _mp4, _png, _pdf):
with open(_p, "wb") as _fh:
_fh.write(b"x")
# ogg flagged as voice, mp4 video, png image, pdf doc.
media = [
(_ogg, True),
(_mp4, False),
(_png, False),
(_pdf, False),
]
mock_adapter = AsyncMock()
mock_adapter.send = AsyncMock()
mock_adapter.send_voice = AsyncMock()
mock_adapter.send_video = AsyncMock()
mock_adapter.send_image_file = AsyncMock()
mock_adapter.send_document = AsyncMock()
mock_adapter.send_image = AsyncMock()
# No text, no markdown images — just the four media attachments.
mock_adapter.extract_media = MagicMock(return_value=(media, ""))
mock_adapter.extract_images = MagicMock(return_value=([], ""))
# Non-telegram platform so every audio ext routes through send_voice.
runner.adapters[Platform.DISCORD] = mock_adapter
source = SessionSource(
platform=Platform.DISCORD,
user_id="12345",
chat_id="67890",
user_name="testuser",
)
try:
await runner._run_background_task("make stuff", source, "bg_test")
mock_adapter.send_voice.assert_called_once()
assert mock_adapter.send_voice.call_args.kwargs["audio_path"] == _ogg
mock_adapter.send_video.assert_called_once()
assert mock_adapter.send_video.call_args.kwargs["video_path"] == _mp4
mock_adapter.send_image_file.assert_called_once()
assert mock_adapter.send_image_file.call_args.kwargs["image_path"] == _png
mock_adapter.send_document.assert_called_once()
assert mock_adapter.send_document.call_args.kwargs["file_path"] == _pdf
finally:
import shutil as _shutil
_shutil.rmtree(_tmpdir, ignore_errors=True)
@pytest.mark.asyncio
async def test_telegram_dm_topic_completion_preserves_reply_anchor_metadata(self, monkeypatch):
"""Background completion metadata must let Telegram send thread id plus reply id."""
@@ -0,0 +1,350 @@
"""Regression tests for the gateway platform fd-leak fix (#37011).
Without an explicit ``disconnect()`` on adapters that fail to connect in
the reconnect watcher, every retry leaks the resources the adapter
opened in ``__init__`` for ``APIServerAdapter`` that means 2 file
descriptors per attempt (the SQLite ``response_store.db`` and its WAL
sidecar). At the 300s backoff cap that's ~12 fds/hour; the default
2560-fd ulimit is exhausted in ~12h of continuous failure, after which
the gateway raises ``OSError: [Errno 24] Too many open files`` on
every ``open()`` and becomes a zombie.
These tests pin all three failure paths in
``_platform_reconnect_watcher`` (non-retryable error, retryable error,
exception during connect) to call ``adapter.disconnect()`` on the
unowned adapter, plus the path-level ``APIServerAdapter.disconnect()``
behavior of also closing the ``ResponseStore``. The pre-fix
implementation did not call ``disconnect()`` on any of these paths;
this file would have caught the regression and now pins the fix.
"""
from __future__ import annotations
import asyncio
import time
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from gateway.config import GatewayConfig, Platform, PlatformConfig
from gateway.platforms.api_server import APIServerAdapter, ResponseStore
from gateway.platforms.base import BasePlatformAdapter, SendResult
from gateway.run import GatewayRunner, _dispose_unused_adapter
def _make_runner() -> GatewayRunner:
"""Create a minimal GatewayRunner via object.__new__ to skip __init__.
Mirrors the helper in test_platform_reconnect.py so this file
is drop-in compatible with the existing reconnect test suite.
"""
runner = object.__new__(GatewayRunner)
runner.config = GatewayConfig(
platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="test")}
)
runner._running = True
runner._shutdown_event = asyncio.Event()
runner._exit_reason = None
runner._exit_with_failure = False
runner._exit_cleanly = False
runner._failed_platforms = {}
runner.adapters = {}
return runner
async def _run_watcher_one_iteration(runner: GatewayRunner) -> None:
"""Drive ``_platform_reconnect_watcher`` for exactly one retry pass.
Patches ``asyncio.sleep`` to advance the watcher's internal
``await asyncio.sleep(10)`` initial delay and the 1-second inner
sleeps without actually waiting. Mirrors the pattern used in
``test_platform_reconnect.py::TestPlatformReconnectWatcher``.
"""
real_sleep = asyncio.sleep
call_count = 0
async def fake_sleep(_n: float) -> None:
nonlocal call_count
call_count += 1
if call_count > 2:
# Two sleeps is enough to get past the initial 10s wait
# and the first inner-tick check. After that, stop the
# watcher so the test returns.
runner._running = False
await real_sleep(0)
with patch("asyncio.sleep", side_effect=fake_sleep):
await runner._platform_reconnect_watcher()
class _CountingAdapter(BasePlatformAdapter):
"""Adapter that records every disconnect() call for fd-leak assertions.
The base ``BasePlatformAdapter.disconnect()`` is a no-op by default
for stub adapters, which is why the pre-fix reconnect watcher
silently leaked: the would-be dispose calls were happening on
objects that did nothing on disconnect. This stub mimics the real
``APIServerAdapter`` shape every constructor call opens 2 fds
(the SQLite db + WAL), and every disconnect() must close them.
"""
def __init__(self, *, succeed: bool = False, fatal_error: str | None = None,
fatal_retryable: bool = True, raise_during_connect: bool = False):
super().__init__(PlatformConfig(enabled=True, token="t"), Platform.TELEGRAM)
# 2 fds to track: the canonical "ResponseStore" pair. The
# reconnect watcher should call disconnect() once per
# construction; otherwise these stay open and contribute to
# the gateway-wide fd count.
self._open_fds = 2
self._disconnect_calls = 0
self._succeed = succeed
self._fatal_error = fatal_error
self._fatal_retryable = fatal_retryable
self._raise_during_connect = raise_during_connect
async def connect(self) -> bool:
if self._raise_during_connect:
raise RuntimeError("simulated connect exception")
if self._fatal_error:
self._set_fatal_error(
"test_code", self._fatal_error, retryable=self._fatal_retryable,
)
return False
return self._succeed
async def disconnect(self) -> None:
self._disconnect_calls += 1
self._open_fds = 0 # fd release on dispose
async def send(self, chat_id, content, reply_to=None, metadata=None):
return SendResult(success=True, message_id="1")
async def send_typing(self, chat_id, metadata=None):
return None
async def get_chat_info(self, chat_id):
return {"id": chat_id}
def _seed_runner_with_one_failure(runner: GatewayRunner) -> None:
"""Queue a single platform for the reconnect watcher to pick up."""
runner._failed_platforms[Platform.TELEGRAM] = {
"config": PlatformConfig(enabled=True, token="t"),
"attempts": 0,
"next_retry": time.monotonic() - 1, # eligible immediately
}
class TestReconnectFDLeakRegression:
"""All three reconnect failure paths must dispose the unowned adapter.
The pre-fix implementation constructed a fresh adapter on every
retry and dropped it on the floor when connect() failed. That leaks
2 fds per retry (for ``APIServerAdapter``) at the 300s backoff cap,
exhausting the 2560-fd ulimit in ~12h of continuous failure (#37011).
"""
@pytest.mark.asyncio
async def test_nonretryable_failure_disposes_unowned_adapter(self):
"""A fatal error (bad auth, etc.) must call disconnect() exactly once.
The adapter failed to connect and is being removed from the
retry queue. Nothing else owns it, so the watcher is the only
code with a chance to call disconnect() and disconnect() is
the only place the SQLite fds get closed. One retry, one
dispose, no leak.
"""
runner = _make_runner()
_seed_runner_with_one_failure(runner)
adapter = _CountingAdapter(
succeed=False, fatal_error="bad token", fatal_retryable=False,
)
with patch.object(runner, "_create_adapter", return_value=adapter), \
patch.object(runner, "_connect_adapter_with_timeout",
new=AsyncMock(return_value=False)):
await _run_watcher_one_iteration(runner)
# The intent of this test is "the watcher calls disconnect()
# exactly once on the unowned adapter" — not "at least once".
# An accidental double-dispose would be a new bug to catch
# (e.g. the watcher's two failure paths both calling dispose
# for the same adapter instance). Tighten to == 1.
assert adapter._disconnect_calls == 1, (
f"non-retryable reconnect failure must call adapter.disconnect() "
f"exactly once; got {adapter._disconnect_calls} calls. "
"Without it, 2 fds leak per retry at the 300s backoff cap "
"(#37011). More than one call would also be a bug — the "
"adapter has already been disposed once, a second call is "
"wasted work and may itself raise."
)
assert adapter._open_fds == 0, (
f"adapter fds not released after disconnect(); "
f"{adapter._open_fds} still open. This is the fd leak #37011."
)
@pytest.mark.asyncio
async def test_retryable_failure_disposes_unowned_adapter(self):
"""A retryable failure (network blip) must also call disconnect().
This is the path that fires most often in production: a
transient DNS resolution failure or upstream outage, which
back-offs to 300s and retries indefinitely. The watcher
tracks ``info["attempts"]`` and reschedules, but the failed
adapter is still dropped on the floor without dispose.
"""
runner = _make_runner()
_seed_runner_with_one_failure(runner)
adapter = _CountingAdapter(
succeed=False, fatal_error="dns timeout", fatal_retryable=True,
)
with patch.object(runner, "_create_adapter", return_value=adapter), \
patch.object(runner, "_connect_adapter_with_timeout",
new=AsyncMock(return_value=False)):
await _run_watcher_one_iteration(runner)
assert adapter._disconnect_calls >= 1, (
f"retryable reconnect failure must call adapter.disconnect(); "
f"got {adapter._disconnect_calls} calls. This is the hot path "
"for the fd leak in #37011."
)
@pytest.mark.asyncio
async def test_exception_during_connect_disposes_unowned_adapter(self):
"""An exception escaping connect() (aiohttp start crash, etc.) disposes.
The ``except Exception`` arm in the watcher used to skip the
dispose call entirely. Pre-fix, this leaked the same 2 fds
per retry as the other two branches.
"""
runner = _make_runner()
_seed_runner_with_one_failure(runner)
adapter = _CountingAdapter(raise_during_connect=True)
with patch.object(runner, "_create_adapter", return_value=adapter), \
patch.object(runner, "_connect_adapter_with_timeout",
new=AsyncMock(side_effect=RuntimeError("boom"))):
await _run_watcher_one_iteration(runner)
assert adapter._disconnect_calls >= 1, (
f"exception-during-connect must call adapter.disconnect(); "
f"got {adapter._disconnect_calls} calls. The except-arm of the "
"reconnect watcher is one of the three leak paths in #37011."
)
@pytest.mark.asyncio
async def test_dispose_helper_handles_none(self):
"""``_dispose_unused_adapter(None)`` is a no-op (defensive)."""
await _dispose_unused_adapter(None) # must not raise
@pytest.mark.asyncio
async def test_dispose_helper_swallows_disconnect_exception(self):
"""A disconnect() that itself raises must not abort the watcher loop.
Half-constructed adapters can raise from disconnect() because
some of their __init__ state is missing. The watcher loop
would then die and stop retrying, masking the original
configuration error as a hard crash.
"""
disconnect_calls = 0
class _RaisingAdapter(BasePlatformAdapter):
def __init__(self):
super().__init__(
PlatformConfig(enabled=True, token="t"),
Platform.TELEGRAM,
)
async def connect(self) -> bool:
return True
async def disconnect(self) -> None:
nonlocal disconnect_calls
disconnect_calls += 1
raise RuntimeError("half-constructed; aiohttp app never started")
async def send(self, chat_id, content, reply_to=None, metadata=None):
return SendResult(success=True, message_id="1")
async def send_typing(self, chat_id, metadata=None):
return None
async def get_chat_info(self, chat_id):
return {"id": chat_id}
await _dispose_unused_adapter(_RaisingAdapter()) # must not raise
assert disconnect_calls == 1
class TestAPIServerDisconnectClosesResponseStore:
"""The platform-level fix: ``APIServerAdapter.disconnect()`` must close its ResponseStore.
Without this, the reconnect watcher's dispose call (see the
test class above) is a no-op for ``APIServerAdapter`` the
aiohttp web server stops, but the SQLite ``ResponseStore``
connection stays open. The DB file plus its WAL sidecar = 2 fds,
which is the headline leak in #37011.
"""
def _build_adapter_with_store(self, store: ResponseStore) -> APIServerAdapter:
"""Build an APIServerAdapter with the required internal state.
We bypass ``__init__`` (which would try to start aiohttp
immediately) and set just the fields ``disconnect()`` reads.
"""
adapter = APIServerAdapter.__new__(APIServerAdapter)
adapter._mark_disconnected = lambda: None # type: ignore[method-assign]
adapter._site = None
adapter._runner = None
adapter._app = None
adapter._response_store = store
adapter.platform = Platform.API_SERVER
return adapter
@pytest.mark.asyncio
async def test_disconnect_closes_response_store(self, tmp_path):
"""Closing the adapter's ResponseStore releases its SQLite connection.
We point the ``ResponseStore`` at a tmp db so we can verify
its ``close()`` is called by ``APIServerAdapter.disconnect()``.
The real ``ResponseStore.__init__`` opens a SQLite connection
to ``~/.hermes/response_store.db`` (or :memory: as a fallback),
which is exactly the resource that was leaking pre-fix.
"""
import sqlite3
store = ResponseStore(max_size=10, db_path=str(tmp_path / "rs.db"))
adapter = self._build_adapter_with_store(store)
await adapter.disconnect()
# Post-disconnect, the underlying sqlite3 conn should be closed.
# sqlite3 raises ``ProgrammingError: Cannot operate on a closed
# database`` for any further operation. We assert on the
# specific exception type (not bare ``Exception``) so the test
# only passes when the close actually took effect — a generic
# ``Exception`` catcher would mask unrelated failures (env
# issues, AttributeError, etc.).
with pytest.raises(sqlite3.ProgrammingError):
store._conn.execute("SELECT 1").fetchone()
@pytest.mark.asyncio
async def test_disconnect_swallows_response_store_close_exception(self, tmp_path):
"""A misbehaving ResponseStore.close() must not abort adapter shutdown.
Real-world failure mode: the SQLite file was unlinked out
from under us (operator rm'd ``response_store.db`` during a
disk pressure event). ``close()`` raises. The watcher must
continue with the aiohttp shutdown, not bail.
"""
store = ResponseStore(max_size=10, db_path=str(tmp_path / "rs.db"))
def _boom() -> None:
raise RuntimeError("sqlite file vanished")
store.close = _boom # type: ignore[method-assign]
adapter = self._build_adapter_with_store(store)
# Must not raise — disconnect() swallows the close error and
# continues to the aiohttp teardown (no-op here since we
# bypassed __init__).
await adapter.disconnect()
+78 -26
View File
@@ -39,6 +39,42 @@ def mock_args():
return SimpleNamespace()
# ---------------------------------------------------------------------------
# Managed-uv compatibility for tests that patch shutil.which
# ---------------------------------------------------------------------------
# The production code now uses ``ensure_uv()`` / ``update_managed_uv()``
# instead of ``shutil.which("uv")``. Many tests in this file patch
# ``shutil.which`` to control whether uv is "available" — these autouse
# fixtures make the managed_uv functions delegate to the patched
# ``shutil.which`` so the existing test setup keeps working without
# per-test changes.
@pytest.fixture(autouse=True)
def _patch_managed_uv(request):
"""Make managed_uv helpers follow shutil.which mocking in tests."""
import shutil
# resolve_uv delegates to shutil.which("uv") so that test patches
# on shutil.which flow through naturally.
def _fake_resolve_uv():
return shutil.which("uv")
def _fake_ensure_uv():
path = shutil.which("uv")
return (path, False) # never freshly bootstrapped in tests
def _fake_update_managed_uv():
return None # never actually self-update in tests
def _fake_rebuild_venv(*args, **kwargs):
return True # no-op in tests
with patch("hermes_cli.managed_uv.resolve_uv", side_effect=_fake_resolve_uv), \
patch("hermes_cli.managed_uv.ensure_uv", side_effect=_fake_ensure_uv), \
patch("hermes_cli.managed_uv.update_managed_uv", side_effect=_fake_update_managed_uv), \
patch("hermes_cli.managed_uv.rebuild_venv", side_effect=_fake_rebuild_venv):
yield
class TestCmdUpdatePip:
"""Regression tests for pip-install update flows."""
@@ -198,36 +234,50 @@ class TestCmdUpdateBranchFallback:
if call.args and call.args[0][0] == "/usr/bin/npm"
]
# cmd_update runs npm commands in four locations:
# 1. repo root — slash-command / TUI bridge deps (subprocess.run)
# 2. ui-tui/ — Ink TUI deps (subprocess.run)
# 3. web/ — npm install (subprocess.run)
# 4. web/ — npm run build (_run_with_idle_timeout)
# cmd_update runs npm commands in these locations:
# 1. repo root — root-only install (--workspaces=false)
# 2. repo root — workspace install (--workspace ui-tui --workspace web)
# 3. web/ — npm ci --silent (if lockfile not at root)
# via _build_web_ui (subprocess.run)
# 4. web/ — npm run build (_run_with_idle_timeout)
#
# Repo-root and ui-tui installs intentionally omit `--silent` and run
# without `capture_output` so optional postinstall scripts (e.g.
# With a single workspace lockfile at the repo root, the root
# install covers all workspaces. The web/ ci call runs from the
# workspace root too (parent of web_dir) when the root lockfile
# exists.
#
# The root install omits `--silent` and runs without
# `capture_output` so optional postinstall scripts (e.g.
# `@askjo/camofox-browser`'s browser-binary fetch) print progress —
# otherwise long downloads look like a hang (#18840). The web/ install
# keeps `--silent` because its build step is short and noisy.
update_flags = [
# otherwise long downloads look like a hang (#18840).
root_flags = [
"/usr/bin/npm",
"ci",
"--no-fund",
"--no-audit",
"--progress=false",
"--workspaces=false",
]
ws_flags = [
"/usr/bin/npm",
"ci",
"--no-fund",
"--no-audit",
"--progress=false",
"--workspace",
"ui-tui",
"--workspace",
"web",
]
# Repo root additionally passes --workspaces=false so npm does not
# recursively install every apps/* workspace (desktop, shared).
repo_flags = [*update_flags, "--workspaces=false"]
assert npm_calls[:2] == [
(repo_flags, PROJECT_ROOT),
(update_flags, PROJECT_ROOT / "ui-tui"),
(root_flags, PROJECT_ROOT),
(ws_flags, PROJECT_ROOT),
]
if len(npm_calls) > 2:
# Only the web/ install is left in subprocess.run; the build moved
# to _run_with_idle_timeout to make Vite progress visible (#33788).
# The web/ install runs from the workspace root when the root
# lockfile exists (npm workspaces hoist node_modules upward).
assert npm_calls[2:] == [
(["/usr/bin/npm", "ci", "--silent"], PROJECT_ROOT / "web"),
(["/usr/bin/npm", "ci", "--silent"], PROJECT_ROOT),
]
# The web UI build itself went through the streaming helper.
@@ -236,21 +286,23 @@ class TestCmdUpdateBranchFallback:
assert idle_args[0] == ["/usr/bin/npm", "run", "build"]
assert idle_kwargs["cwd"] == PROJECT_ROOT / "web"
# Regression for #18840: repo root + ui-tui installs must stream
# output (capture_output=False) so postinstall progress is visible
# to the user.
repo_and_tui_calls = [
# Regression for #18840: root npm installs must stream output
# (capture_output=False) so postinstall progress is visible
# to the user. The _build_web_ui install uses --silent and
# capture_output=True, so exclude it.
root_install_calls = [
call
for call in mock_run.call_args_list
if call.args
and call.args[0][0] == "/usr/bin/npm"
and call.args[0][1] == "ci"
and call.kwargs.get("cwd") in {PROJECT_ROOT, PROJECT_ROOT / "ui-tui"}
and call.kwargs.get("cwd") == PROJECT_ROOT
and "--silent" not in call.args[0]
]
assert len(repo_and_tui_calls) == 2
for call in repo_and_tui_calls:
assert len(root_install_calls) == 2 # root-only + workspace install
for call in root_install_calls:
assert call.kwargs.get("capture_output") is False, (
"repo-root / ui-tui npm install must stream output "
"repo-root npm install must stream output "
"(no capture_output) so postinstall progress is visible"
)
+141 -19
View File
@@ -29,7 +29,8 @@ from hermes_cli import web_server
from hermes_cli.dashboard_auth import clear_providers, register_provider
from hermes_cli.dashboard_auth.ws_tickets import (
_reset_for_tests,
consume_ticket,
consume_internal_credential,
internal_ws_credential,
mint_ticket,
)
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
@@ -279,10 +280,33 @@ class TestWsAuthOkGated:
content = log_file.read_text()
assert "ws_ticket_rejected" in content
def test_internal_credential_accepted(self, gated_app):
"""Server-spawned children present the process-lifetime internal
credential via ?internal= and are accepted in gated mode."""
cred = internal_ws_credential()
ws = _fake_ws(query={"internal": cred})
assert web_server._ws_auth_ok(ws) is True
# ---------------------------------------------------------------------------
# _build_sidecar_url — gated mode mints a server-internal ticket
# ---------------------------------------------------------------------------
def test_internal_credential_is_multi_use(self, gated_app):
"""Unlike single-use tickets, the internal credential survives
repeated use so the child can reconnect."""
cred = internal_ws_credential()
for _ in range(3):
ws = _fake_ws(query={"internal": cred})
assert web_server._ws_auth_ok(ws) is True
def test_wrong_internal_credential_rejected(self, gated_app):
# Mint the real one so the store is non-empty, then present a bogus value.
internal_ws_credential()
ws = _fake_ws(query={"internal": "not-the-internal-credential"})
assert web_server._ws_auth_ok(ws) is False
def test_internal_credential_not_accepted_in_loopback(self, loopback_app):
"""Outside gated mode, ?internal= is meaningless — only ?token= works.
A naked internal credential must not authenticate."""
cred = internal_ws_credential()
ws = _fake_ws(query={"internal": cred})
assert web_server._ws_auth_ok(ws) is False
class TestWsRequestIsAllowedGated:
@@ -379,10 +403,16 @@ class TestWsHostOriginGuardOrigins:
"""The WS Origin guard must let the packaged desktop shell connect.
Electron loads the packaged renderer over ``file://``, so its WebSocket
handshake carries ``Origin: file://`` (or the opaque ``null``). The
DNS-rebinding guard only needs to block cross-site http(s) origins. On a
loopback bind these non-web origins are trusted because the session token
is the real gate. Public/gated binds keep rejecting them.
handshake carries ``Origin: file://`` (or the opaque ``null``, or a custom
``app://`` scheme). The DNS-rebinding guard only needs to block cross-site
http(s) origins a malicious web page can never forge a non-web origin.
This guard runs only AFTER ``_ws_auth_ok`` has validated the WS credential
(session token on loopback / ``--insecure`` binds, single-use ``?ticket=``
on OAuth-gated binds), so a non-web origin is trusted in every mode: the
credential is the real gate, and a ``file://`` / ``null`` origin cannot
originate a DNS-rebinding browser attack. ``http(s)`` origins are still
match-checked against the bound host.
"""
def _ws(self, *, origin, host):
@@ -413,11 +443,56 @@ class TestWsHostOriginGuardOrigins:
ws = self._ws(origin="http://evil.test", host="127.0.0.1:8080")
assert web_server._ws_host_origin_is_allowed(ws) is False
def test_gated_file_origin_rejected(self, gated_app):
# A public/gated bind has no legitimate file:// client.
ws = self._ws(origin="file://", host="fly-app.fly.dev")
def test_explicit_non_loopback_file_origin_allowed(self, insecure_explicit_host_app):
"""Packaged Hermes Desktop also uses file:// when connecting to a
Tailscale/LAN dashboard bind.
The WebSocket route calls _ws_auth_ok before this guard, so in
non-gated mode the legacy session token remains the auth boundary.
"""
ws = self._ws(origin="file://", host="100.64.0.10:9119")
assert web_server._ws_host_origin_is_allowed(ws) is True
def test_explicit_non_loopback_null_origin_allowed(self, insecure_explicit_host_app):
ws = self._ws(origin="null", host="100.64.0.10:9119")
assert web_server._ws_host_origin_is_allowed(ws) is True
def test_explicit_non_loopback_cross_site_http_origin_rejected(
self, insecure_explicit_host_app
):
ws = self._ws(origin="http://localhost:9119", host="100.64.0.10:9119")
assert web_server._ws_host_origin_is_allowed(ws) is False
def test_gated_file_origin_allowed(self, gated_app):
# The packaged desktop app drives a remote OAuth-GATED gateway over a
# file:// renderer origin. The WS route validates the single-use
# ?ticket= in _ws_auth_ok before this guard runs, and a file:// origin
# can't be a DNS-rebinding browser attack, so the Origin guard must let
# it through. This is the regression that broke desktop → hosted
# gateway connections — every WS upgrade got HTTP 403 even with a valid
# ticket.
ws = self._ws(origin="file://", host="fly-app.fly.dev")
assert web_server._ws_host_origin_is_allowed(ws) is True
def test_gated_null_origin_allowed(self, gated_app):
ws = self._ws(origin="null", host="fly-app.fly.dev")
assert web_server._ws_host_origin_is_allowed(ws) is True
def test_gated_app_scheme_origin_allowed(self, gated_app):
ws = self._ws(origin="app://.", host="fly-app.fly.dev")
assert web_server._ws_host_origin_is_allowed(ws) is True
def test_gated_cross_site_http_origin_still_host_checked(self, gated_app):
# An http(s) origin is still subjected to the same-host check even on a
# gated bind: a cross-site http origin whose netloc doesn't match the
# bound host is rejected. Real browser DNS-rebinding defence unchanged.
ws = self._ws(origin="https://evil.test", host="fly-app.fly.dev")
assert web_server._ws_host_origin_is_allowed(ws) is False
def test_gated_same_host_https_origin_allowed(self, gated_app):
ws = self._ws(origin="https://fly-app.fly.dev", host="fly-app.fly.dev")
assert web_server._ws_host_origin_is_allowed(ws) is True
class TestSidecarUrl:
def test_loopback_uses_session_token(self, loopback_app):
@@ -426,18 +501,20 @@ class TestSidecarUrl:
assert f"token={web_server._SESSION_TOKEN}" in url
assert "ticket=" not in url
def test_gated_uses_ticket(self, gated_app):
def test_gated_uses_internal_credential(self, gated_app):
url = web_server._build_sidecar_url("ch-1")
assert url is not None
assert "token=" not in url
assert "ticket=" in url
# And the ticket should be live.
ticket = url.split("ticket=")[1].split("&")[0]
info = consume_ticket(ticket)
# Sidecar tickets are bound to the pseudo-user so audit logs can
# distinguish them from real browser tickets.
assert info["user_id"] == "pty-sidecar"
assert "ticket=" not in url
assert "internal=" in url
# The value should be the live process-lifetime internal credential,
# multi-use so the child can reconnect /api/pub.
cred = url.split("internal=")[1].split("&")[0]
info = consume_internal_credential(cred)
assert info["user_id"] == "server-internal"
assert info["provider"] == "server-internal"
# Multi-use: a second consume still succeeds (unlike a ticket).
assert consume_internal_credential(cred)["provider"] == "server-internal"
def test_no_bound_host_returns_none(self, gated_app):
web_server.app.state.bound_host = None
@@ -445,3 +522,48 @@ class TestSidecarUrl:
assert web_server._build_sidecar_url("ch") is None
finally:
web_server.app.state.bound_host = "fly-app.fly.dev"
# ---------------------------------------------------------------------------
# _build_gateway_ws_url — the TUI child's primary JSON-RPC backend WS.
# Loopback uses ?token=; gated mode uses the multi-use internal credential
# (NOT a single-use ticket — the child reuses this URL across reconnects).
# ---------------------------------------------------------------------------
class TestGatewayWsUrl:
def test_loopback_uses_session_token(self, loopback_app):
url = web_server._build_gateway_ws_url()
assert url is not None
assert "/api/ws?" in url
assert f"token={web_server._SESSION_TOKEN}" in url
assert "internal=" not in url
def test_gated_uses_internal_credential(self, gated_app):
url = web_server._build_gateway_ws_url()
assert url is not None
assert "/api/ws?" in url
assert "token=" not in url
assert "ticket=" not in url
assert "internal=" in url
cred = url.split("internal=")[1].split("&")[0]
# The credential authenticates against _ws_auth_ok in gated mode.
ws = _fake_ws(query={"internal": cred})
assert web_server._ws_auth_ok(ws) is True
def test_gated_credential_matches_sidecar(self, gated_app):
"""Both server-internal builders share one process credential, so a
single value authenticates /api/ws and /api/pub alike."""
gw = web_server._build_gateway_ws_url()
sc = web_server._build_sidecar_url("ch-1")
assert gw is not None and sc is not None
gw_cred = gw.split("internal=")[1].split("&")[0]
sc_cred = sc.split("internal=")[1].split("&")[0]
assert gw_cred == sc_cred
def test_no_bound_host_returns_none(self, gated_app):
web_server.app.state.bound_host = None
try:
assert web_server._build_gateway_ws_url() is None
finally:
web_server.app.state.bound_host = "fly-app.fly.dev"
@@ -159,3 +159,73 @@ class TestConcurrency:
assert len(results) == 20
# Every consume returns a distinct user_id (no cross-thread bleed).
assert {r["user_id"] for r in results} == {f"u{i}" for i in range(20)}
# ---------------------------------------------------------------------------
# Process-lifetime internal credential (server-spawned PTY child auth).
# Direct unit coverage for internal_ws_credential / consume_internal_credential
# — _ws_auth_ok exercises these indirectly, but the mint-once, unminted, and
# empty-value branches are only reachable via direct calls.
# ---------------------------------------------------------------------------
class TestInternalCredential:
def test_minted_once_is_stable(self):
"""Successive calls return the same process-lifetime value."""
first = ws_tickets.internal_ws_credential()
second = ws_tickets.internal_ws_credential()
assert first == second
assert len(first) >= 32 # token_urlsafe(32)
def test_round_trip_identity(self):
cred = ws_tickets.internal_ws_credential()
info = ws_tickets.consume_internal_credential(cred)
assert info["user_id"] == ws_tickets.INTERNAL_USER_ID
assert info["provider"] == ws_tickets.INTERNAL_PROVIDER
def test_multi_use(self):
"""Unlike a single-use ticket, the credential survives repeated consume."""
cred = ws_tickets.internal_ws_credential()
for _ in range(5):
assert (
ws_tickets.consume_internal_credential(cred)["provider"]
== ws_tickets.INTERNAL_PROVIDER
)
def test_rejected_before_mint(self):
"""With nothing minted yet, any value is rejected (expected is None)."""
# autouse _reset leaves _internal_credential == None at test start.
with pytest.raises(TicketInvalid):
ws_tickets.consume_internal_credential("anything")
def test_empty_value_rejected(self):
ws_tickets.internal_ws_credential() # mint so expected is non-None
with pytest.raises(TicketInvalid):
ws_tickets.consume_internal_credential("")
def test_wrong_value_rejected(self):
ws_tickets.internal_ws_credential()
with pytest.raises(TicketInvalid):
ws_tickets.consume_internal_credential("not-the-credential")
def test_reset_clears_and_remints(self):
first = ws_tickets.internal_ws_credential()
_reset_for_tests()
# The old value no longer validates after reset.
with pytest.raises(TicketInvalid):
ws_tickets.consume_internal_credential(first)
# A fresh mint produces a different value.
second = ws_tickets.internal_ws_credential()
assert second != first
assert ws_tickets.consume_internal_credential(second)["user_id"] == (
ws_tickets.INTERNAL_USER_ID
)
def test_independent_of_ticket_store(self):
"""The internal credential is not a ticket — minting tickets doesn't
touch it, and consuming the credential doesn't consume tickets."""
cred = ws_tickets.internal_ws_credential()
ticket = mint_ticket(user_id="u1", provider="nous")
# Consuming the internal credential leaves the ticket intact.
ws_tickets.consume_internal_credential(cred)
assert consume_ticket(ticket)["user_id"] == "u1"
@@ -0,0 +1,191 @@
"""Tests for the configurable default interface (cli vs tui).
`hermes` launches the classic prompt_toolkit REPL by default, but users can
flip ``display.interface: tui`` in config.yaml to make the modern Ink TUI the
default for bare ``hermes`` / ``hermes chat``. Explicit flags always win:
--cli forces the classic REPL (highest precedence)
--tui / HERMES_TUI=1 forces the TUI
display.interface the configured default
(unset) classic REPL
These tests pin that precedence at every layer that makes the decision:
* ``_resolve_use_tui(args)`` the canonical args-aware resolver used by
``cmd_chat`` and the Termux fast-TUI path.
* ``_wants_tui_early(argv)`` the dependency-free early resolver used by
mouse-residue suppression and the Termux fast paths, before argparse and
``hermes_cli.config`` are importable.
* the argument parser both ``--cli`` and ``--tui`` parse at the top
level and under the ``chat`` subcommand and are relaunch-inherited.
"""
from __future__ import annotations
import os
from types import SimpleNamespace
import pytest
from hermes_cli import main as m
@pytest.fixture(autouse=True)
def _reset_early_cache(monkeypatch):
# The early resolver memoizes the config read; clear it so each test sees
# a fresh value, and make sure no stray HERMES_TUI leaks in.
monkeypatch.setattr(m, "_EARLY_INTERFACE_CACHE", None)
monkeypatch.delenv("HERMES_TUI", raising=False)
yield
monkeypatch.setattr(m, "_EARLY_INTERFACE_CACHE", None)
def _args(**kw):
kw.setdefault("cli", False)
kw.setdefault("tui", False)
return SimpleNamespace(**kw)
def _patch_config(monkeypatch, interface):
import hermes_cli.config as cfg
monkeypatch.setattr(
cfg, "load_config", lambda: {"display": {"interface": interface}}
)
# ---------------------------------------------------------------------------
# _resolve_use_tui — args-aware resolver
# ---------------------------------------------------------------------------
class TestResolveUseTui:
def test_cli_flag_beats_config_tui(self, monkeypatch):
_patch_config(monkeypatch, "tui")
assert m._resolve_use_tui(_args(cli=True)) is False
def test_cli_flag_beats_tui_flag_and_env(self, monkeypatch):
_patch_config(monkeypatch, "tui")
monkeypatch.setenv("HERMES_TUI", "1")
assert m._resolve_use_tui(_args(cli=True, tui=True)) is False
def test_tui_flag_beats_config_cli(self, monkeypatch):
_patch_config(monkeypatch, "cli")
assert m._resolve_use_tui(_args(tui=True)) is True
def test_env_beats_config_cli(self, monkeypatch):
_patch_config(monkeypatch, "cli")
monkeypatch.setenv("HERMES_TUI", "1")
assert m._resolve_use_tui(_args()) is True
def test_config_tui_with_no_flags(self, monkeypatch):
_patch_config(monkeypatch, "tui")
assert m._resolve_use_tui(_args()) is True
def test_config_cli_is_default(self, monkeypatch):
_patch_config(monkeypatch, "cli")
assert m._resolve_use_tui(_args()) is False
def test_interface_value_is_case_insensitive(self, monkeypatch):
_patch_config(monkeypatch, "TUI")
assert m._resolve_use_tui(_args()) is True
def test_load_config_failure_falls_back_to_cli(self, monkeypatch):
import hermes_cli.config as cfg
def boom():
raise RuntimeError("config unreadable")
monkeypatch.setattr(cfg, "load_config", boom)
assert m._resolve_use_tui(_args()) is False
# ---------------------------------------------------------------------------
# _wants_tui_early — dependency-free early resolver
# ---------------------------------------------------------------------------
class TestWantsTuiEarly:
@pytest.fixture
def home_with_interface(self, tmp_path, monkeypatch):
def _make(interface):
(tmp_path / "config.yaml").write_text(
f"display:\n interface: {interface}\n"
)
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setattr(m, "_EARLY_INTERFACE_CACHE", None)
return _make
def test_config_tui_bare_argv(self, home_with_interface):
home_with_interface("tui")
assert m._wants_tui_early([]) is True
def test_cli_flag_overrides_config_tui(self, home_with_interface):
home_with_interface("tui")
assert m._wants_tui_early(["--cli"]) is False
def test_tui_flag_with_config_cli(self, home_with_interface):
home_with_interface("cli")
assert m._wants_tui_early(["--tui"]) is True
def test_env_with_config_cli(self, home_with_interface, monkeypatch):
home_with_interface("cli")
monkeypatch.setenv("HERMES_TUI", "1")
assert m._wants_tui_early([]) is True
def test_config_cli_bare_argv(self, home_with_interface):
home_with_interface("cli")
assert m._wants_tui_early([]) is False
def test_missing_config_defaults_to_cli(self, tmp_path, monkeypatch):
# HERMES_HOME points at an empty dir — no config.yaml.
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setattr(m, "_EARLY_INTERFACE_CACHE", None)
assert m._wants_tui_early([]) is False
def test_unreadable_config_defaults_to_cli(self, tmp_path, monkeypatch):
# Garbage YAML must not crash the hot path; falls back to cli.
(tmp_path / "config.yaml").write_text("this: : : not valid yaml\n")
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setattr(m, "_EARLY_INTERFACE_CACHE", None)
assert m._wants_tui_early([]) is False
# ---------------------------------------------------------------------------
# argument parser — flags exist at both levels and are relaunch-inherited
# ---------------------------------------------------------------------------
class TestParserFlags:
def _parser(self):
from hermes_cli._parser import build_top_level_parser
parser, _subparsers, _chat = build_top_level_parser()
return parser
def test_top_level_cli_flag(self):
args = self._parser().parse_args(["--cli"])
assert args.cli is True and args.tui is False
def test_top_level_tui_flag(self):
args = self._parser().parse_args(["--tui"])
assert args.tui is True and args.cli is False
def test_chat_subcommand_cli_flag(self):
args = self._parser().parse_args(["chat", "--cli"])
assert args.cli is True
def test_chat_subcommand_tui_flag(self):
args = self._parser().parse_args(["chat", "--tui"])
assert args.tui is True
def test_cli_and_tui_are_relaunch_inherited(self):
from hermes_cli.relaunch import _INHERITED_FLAGS_TABLE
inherited = {flag for flag, _takes_value in _INHERITED_FLAGS_TABLE}
assert "--cli" in inherited
assert "--tui" in inherited
# ---------------------------------------------------------------------------
# config default — shipped default preserves classic behavior
# ---------------------------------------------------------------------------
def test_default_config_interface_is_cli():
from hermes_cli.config import DEFAULT_CONFIG
assert DEFAULT_CONFIG["display"]["interface"] == "cli"
+69
View File
@@ -153,6 +153,75 @@ def test_gui_skip_build_launches_existing_packaged_app_without_npm(tmp_path, mon
assert mock_run.call_args.args[0] == [str(packaged_exe)]
def test_gui_linux_configures_sandbox_before_launch(tmp_path, monkeypatch):
root = _make_desktop_tree(tmp_path)
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
packaged_exe = _make_packaged_executable(root, monkeypatch, platform="linux")
sandbox = packaged_exe.parent / "chrome-sandbox"
sandbox.write_text("", encoding="utf-8")
sandbox.chmod(0o755)
ok = subprocess.CompletedProcess([], 0)
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/sudo"), \
patch("hermes_cli.main.subprocess.run", return_value=ok) as mock_run, \
pytest.raises(SystemExit) as exc:
cli_main.cmd_gui(_ns(skip_build=True))
assert exc.value.code == 0
assert mock_run.call_args_list[0].args[0] == ["/usr/bin/sudo", "chown", "root:root", str(sandbox)]
assert mock_run.call_args_list[1].args[0] == ["/usr/bin/sudo", "chmod", "4755", str(sandbox)]
assert mock_run.call_args_list[2].args[0] == [str(packaged_exe)]
def test_gui_linux_rejects_symlink_sandbox(tmp_path, monkeypatch):
root = _make_desktop_tree(tmp_path)
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
packaged_exe = _make_packaged_executable(root, monkeypatch, platform="linux")
# Point chrome-sandbox at an unrelated file via symlink
target = tmp_path / "dangerous"
target.write_text("pwned", encoding="utf-8")
sandbox = packaged_exe.parent / "chrome-sandbox"
sandbox.symlink_to(target)
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/sudo"), \
patch("hermes_cli.main.subprocess.run") as mock_run, \
pytest.raises(SystemExit) as exc:
cli_main.cmd_gui(_ns(skip_build=True))
assert exc.value.code == 1
# Must NOT have called sudo chown/chmod on the symlink target
for call in mock_run.call_args_list:
assert "chown" not in call.args[0]
assert "chmod" not in call.args[0]
def test_gui_linux_skips_fixup_when_already_configured(tmp_path, monkeypatch):
root = _make_desktop_tree(tmp_path)
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
packaged_exe = _make_packaged_executable(root, monkeypatch, platform="linux")
sandbox = packaged_exe.parent / "chrome-sandbox"
sandbox.write_text("", encoding="utf-8")
# Simulate root-owned 4755 — lstat().st_uid==0 and mode==0o4755
# We can't actually chown to root in tests, so mock lstat to return
# the expected values directly.
import stat as stat_mod
fake_stat = type("s", (), {"st_uid": 0, "st_mode": 0o4755 | stat_mod.S_IFREG})()
sandbox_lstat_orig = type(sandbox).lstat
monkeypatch.setattr(type(sandbox), "lstat", lambda self: fake_stat)
launch_ok = subprocess.CompletedProcess([str(packaged_exe)], 0)
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/sudo"), \
patch("hermes_cli.main.subprocess.run", return_value=launch_ok) as mock_run, \
pytest.raises(SystemExit) as exc:
cli_main.cmd_gui(_ns(skip_build=True))
assert exc.value.code == 0
# Only the launch call — no sudo chown/chmod
mock_run.assert_called_once()
assert mock_run.call_args.args[0] == [str(packaged_exe)]
def test_gui_source_mode_uses_renderer_build_and_electron(tmp_path, monkeypatch):
root = _make_desktop_tree(tmp_path)
desktop_dir = root / "apps" / "desktop"
+205
View File
@@ -0,0 +1,205 @@
"""Tests for hermes_cli.managed_uv — one path, no guessing."""
from __future__ import annotations
import os
import stat
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_executable(path: Path) -> None:
"""Create a minimal fake uv binary at *path*."""
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("#!/bin/sh\necho uv 0.1.2\n")
path.chmod(path.stat().st_mode | stat.S_IEXEC)
# ---------------------------------------------------------------------------
# managed_uv_path
# ---------------------------------------------------------------------------
class TestManagedUvPath:
def test_posix(self, tmp_path):
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
patch("hermes_cli.managed_uv.platform.system", return_value="Linux"):
from hermes_cli.managed_uv import managed_uv_path
assert managed_uv_path() == tmp_path / "bin" / "uv"
def test_windows(self, tmp_path):
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
patch("hermes_cli.managed_uv.platform.system", return_value="Windows"):
from hermes_cli.managed_uv import managed_uv_path
assert managed_uv_path() == tmp_path / "bin" / "uv.exe"
# ---------------------------------------------------------------------------
# resolve_uv
# ---------------------------------------------------------------------------
class TestResolveUv:
def test_missing_returns_none(self, tmp_path):
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path):
from hermes_cli.managed_uv import resolve_uv
assert resolve_uv() is None
def test_existing_executable(self, tmp_path):
_make_executable(tmp_path / "bin" / "uv")
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path):
from hermes_cli.managed_uv import resolve_uv
result = resolve_uv()
assert result == str(tmp_path / "bin" / "uv")
def test_non_executable_file_returns_none(self, tmp_path):
uv = tmp_path / "bin" / "uv"
uv.parent.mkdir(parents=True)
uv.write_text("not a binary")
# Ensure no execute bit
uv.chmod(0o644)
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path):
from hermes_cli.managed_uv import resolve_uv
assert resolve_uv() is None
# ---------------------------------------------------------------------------
# ensure_uv
# ---------------------------------------------------------------------------
class TestEnsureUv:
def test_already_installed_no_bootstrap(self, tmp_path):
_make_executable(tmp_path / "bin" / "uv")
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path):
from hermes_cli.managed_uv import ensure_uv
path, fresh = ensure_uv()
assert path == str(tmp_path / "bin" / "uv")
assert fresh is False
def test_installs_if_missing_sets_bootstrap_flag(self, tmp_path):
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
patch("hermes_cli.managed_uv._install_uv") as mock_install:
# Simulate the installer creating the binary
def fake_install(target):
_make_executable(target)
mock_install.side_effect = fake_install
from hermes_cli.managed_uv import ensure_uv
path, fresh = ensure_uv()
assert path == str(tmp_path / "bin" / "uv")
assert fresh is True
mock_install.assert_called_once()
def test_install_failure_returns_none_false(self, tmp_path):
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
patch("hermes_cli.managed_uv._install_uv", side_effect=RuntimeError("network down")):
from hermes_cli.managed_uv import ensure_uv
path, fresh = ensure_uv()
assert path is None
assert fresh is False
# ---------------------------------------------------------------------------
# rebuild_venv
# ---------------------------------------------------------------------------
class TestRebuildVenv:
def test_removes_old_venv_and_creates_new(self, tmp_path):
venv_dir = tmp_path / "venv"
venv_dir.mkdir()
(venv_dir / "old_file").write_text("stale")
uv_bin = str(tmp_path / "bin" / "uv")
def fake_run(cmd, **kwargs):
m = MagicMock(returncode=0)
if cmd[1] == "venv":
# Simulate uv creating the venv dir
venv_dir.mkdir(exist_ok=True)
bin_dir = venv_dir / "bin"
bin_dir.mkdir(parents=True, exist_ok=True)
(bin_dir / "python").write_text("#!/bin/sh\necho Python 3.11.0")
elif "--version" in cmd:
m.stdout = "Python 3.11.0"
return m
with patch("hermes_cli.managed_uv.subprocess.run", side_effect=fake_run), \
patch("hermes_cli.managed_uv.shutil.rmtree") as mock_rmtree:
from hermes_cli.managed_uv import rebuild_venv
result = rebuild_venv(uv_bin, venv_dir)
assert result is True
mock_rmtree.assert_called_once_with(venv_dir, ignore_errors=True)
def test_rebuild_failure_returns_false(self, tmp_path):
venv_dir = tmp_path / "venv"
uv_bin = str(tmp_path / "bin" / "uv")
with patch("hermes_cli.managed_uv.subprocess.run") as mock_run, \
patch("hermes_cli.managed_uv.shutil.rmtree"):
mock_run.return_value = MagicMock(returncode=1, stderr="nope")
from hermes_cli.managed_uv import rebuild_venv
result = rebuild_venv(uv_bin, venv_dir)
assert result is False
# ---------------------------------------------------------------------------
# update_managed_uv
# ---------------------------------------------------------------------------
class TestUpdateManagedUv:
def test_no_uv_returns_none(self, tmp_path):
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path):
from hermes_cli.managed_uv import update_managed_uv
assert update_managed_uv() is None
def test_self_update_success(self, tmp_path):
_make_executable(tmp_path / "bin" / "uv")
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
patch("hermes_cli.managed_uv.subprocess.run") as mock_run:
# uv self update succeeds
mock_run.return_value = MagicMock(returncode=0, stdout="uv 0.2.0")
from hermes_cli.managed_uv import update_managed_uv
result = update_managed_uv()
assert result == str(tmp_path / "bin" / "uv")
# First call is self update, second is --version
assert mock_run.call_count == 2
assert mock_run.call_args_list[0][0][0] == [str(tmp_path / "bin" / "uv"), "self", "update"]
def test_self_update_failure_non_fatal(self, tmp_path):
_make_executable(tmp_path / "bin" / "uv")
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
patch("hermes_cli.managed_uv.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=1, stderr="nope")
from hermes_cli.managed_uv import update_managed_uv
result = update_managed_uv()
# Still returns the path — failure is non-fatal
assert result == str(tmp_path / "bin" / "uv")
# ---------------------------------------------------------------------------
# _install_uv internals
# ---------------------------------------------------------------------------
class TestInstallUvInternals:
def test_posix_sets_uv_unmanaged_install(self, tmp_path):
target = tmp_path / "bin" / "uv"
with patch("hermes_cli.managed_uv._install_uv_posix") as mock_posix:
from hermes_cli.managed_uv import _install_uv
_install_uv(target)
mock_posix.assert_called_once()
call_env = mock_posix.call_args[0][0]
assert call_env["UV_UNMANAGED_INSTALL"] == str(tmp_path / "bin")
def test_windows_sets_uv_install_dir(self, tmp_path):
target = tmp_path / "bin" / "uv.exe"
with patch("hermes_cli.managed_uv.platform.system", return_value="Windows"), \
patch("hermes_cli.managed_uv._install_uv_windows") as mock_windows:
from hermes_cli.managed_uv import _install_uv
_install_uv(target)
mock_windows.assert_called_once()
call_env = mock_windows.call_args[0][0]
assert call_env["UV_INSTALL_DIR"] == str(tmp_path / "bin")
+36 -2
View File
@@ -609,7 +609,12 @@ def test_visible_providers_include_nous_subscription_when_logged_in(monkeypatch)
providers = _visible_providers(TOOL_CATEGORIES["browser"], config)
assert providers[0]["name"].startswith("Nous Subscription")
# The managed Nous row is listed (not necessarily first — "Local Browser"
# sorts first so a fresh-install Enter lands on the free local backend).
assert any(p["name"].startswith("Nous Subscription") for p in providers)
# "Local Browser" must be the index-0 default so pressing Enter never
# walks a user into a paid Nous Portal login.
assert providers[0]["name"] == "Local Browser"
def test_visible_providers_show_nous_subscription_when_logged_out(monkeypatch):
@@ -685,7 +690,9 @@ def test_visible_providers_force_fresh_shows_nous_subscription_after_upgrade(mon
force_fresh=True,
)
assert providers[0]["name"].startswith("Nous Subscription")
# The managed Nous row reappears after the entitlement upgrade. It is no
# longer asserted to be first — "Local Browser" sorts first by design.
assert any(p["name"].startswith("Nous Subscription") for p in providers)
assert ("features", True) in calls
@@ -702,6 +709,33 @@ def test_local_browser_provider_is_saved_explicitly(monkeypatch):
assert config["browser"]["cloud_provider"] == "local"
def test_fresh_install_browser_default_is_free_local_not_paid_nous():
"""On a fresh install the browser picker must default to the free local
backend, never the paid Nous Subscription gateway.
Regression: the Nous row used to sort first, so the menu cursor defaulted
to index 0 (Nous) and pressing Enter walked users straight into a Nous
Portal login for a paid offering (Javier's bug, June 2026).
"""
from hermes_cli.tools_config import _detect_active_provider_index
providers = TOOL_CATEGORIES["browser"]["providers"]
assert providers[0]["name"] == "Local Browser"
assert providers[0]["browser_provider"] == "local"
# Nothing active/configured → cursor defaults to index 0 (the free local row).
assert _detect_active_provider_index(providers, {}) == 0
def test_fresh_install_tts_default_is_free_edge_not_paid_nous():
"""TTS picker defaults to the free Edge backend on a fresh install."""
from hermes_cli.tools_config import _detect_active_provider_index
providers = TOOL_CATEGORIES["tts"]["providers"]
assert providers[0]["name"] == "Microsoft Edge TTS"
assert providers[0]["tts_provider"] == "edge"
assert _detect_active_provider_index(providers, {}) == 0
def test_reconfigure_lists_enabled_web_without_existing_provider_config(monkeypatch):
config = {"platform_toolsets": {"cli": ["web"]}}
seen = {}
+106
View File
@@ -193,3 +193,109 @@ def test_make_tui_argv_keeps_desktop_always_build_behaviour(
assert calls
assert calls[0][0][0] == ["/bin/npm", "run", "build"]
# ── _workspace_root helper ──────────────────────────────────────────
def test_workspace_root_returns_parent_when_subpackage(tmp_path: Path, main_mod) -> None:
"""Sub-package has package.json, no lockfile; parent has lockfile → parent."""
sub = tmp_path / "ui-tui"
sub.mkdir()
(sub / "package.json").write_text("{}")
(tmp_path / "package-lock.json").write_text("{}")
assert main_mod._workspace_root(sub) == tmp_path
def test_workspace_root_returns_dir_when_standalone(tmp_path: Path, main_mod) -> None:
"""No package.json → not a sub-package, return dir itself."""
assert main_mod._workspace_root(tmp_path) == tmp_path
def test_workspace_root_returns_dir_when_own_lockfile(tmp_path: Path, main_mod) -> None:
"""Has package.json AND its own lockfile → standalone, return dir."""
(tmp_path / "package.json").write_text("{}")
(tmp_path / "package-lock.json").write_text("{}")
(tmp_path.parent / "package-lock.json").write_text("{}")
assert main_mod._workspace_root(tmp_path) == tmp_path
def test_workspace_root_returns_dir_when_no_parent_lockfile(
tmp_path: Path, main_mod
) -> None:
"""Has package.json, no own lockfile, but parent also has no lockfile → standalone."""
sub = tmp_path / "ui-tui"
sub.mkdir()
(sub / "package.json").write_text("{}")
# tmp_path has no package-lock.json either
assert main_mod._workspace_root(sub) == sub
def test_workspace_root_consistent_with_need_npm_install(
tmp_path: Path, main_mod
) -> None:
"""Divergence regression: if someone creates ui-tui/package-lock.json
by accident, _workspace_root (used by both _tui_need_npm_install AND
the npm install cwd) returns ui-tui/ for both, so they never disagree.
Before the shared helper, _tui_need_npm_install used a 3-condition
check (falling back to ui-tui/ when its own lockfile exists) while
the npm install cwd used a simpler check (still going to the parent
because the parent lockfile still exists). The shared helper
eliminates the split.
"""
sub = tmp_path / "ui-tui"
sub.mkdir()
(sub / "package.json").write_text("{}")
# Both sub and parent have lockfiles — accidental state
(sub / "package-lock.json").write_text("{}")
(tmp_path / "package-lock.json").write_text("{}")
ws = main_mod._workspace_root(sub)
# _workspace_root sees sub has its own lockfile → treats it as standalone
assert ws == sub
# _tui_need_npm_install also uses _workspace_root, so both agree
assert main_mod._tui_need_npm_install.__code__.co_names
# (Smoke test: just confirm _tui_need_npm_install doesn't crash)
# It won't need install because the lockfile exists and there's no
# hidden lockfile to compare against, and ink is missing → True.
# But the key invariant is: ws_root for the need-check == ws_root
# for the install cwd — both use _workspace_root(sub).
def test_no_stray_lockfiles_in_workspace_subdirs(main_mod) -> None:
"""Workspace sub-directories must not contain their own package-lock.json.
With a single workspace root lockfile, per-directory lockfiles are
always accidental (typically from running ``npm install`` inside the
wrong directory). They cause ``_workspace_root`` to treat the
sub-package as standalone, which breaks hoisted ``node_modules``
resolution and can silently diverge the install cwd from the
lockfile-check root.
This is an invariant, not a change-detector: the workspace structure
is not expected to gain per-dir lockfiles.
"""
root = main_mod.PROJECT_ROOT
# Workspace members that live one level below the root and should
# NOT have their own lockfile. (ui-tui/packages/* members are
# two levels deep and even less likely to get accidental lockfiles,
# but we check them too for completeness.)
subdirs = [
root / "ui-tui",
root / "web",
root / "apps" / "desktop",
root / "apps" / "shared",
]
# Also sweep ui-tui/packages/* (hermes-ink etc.)
tui_pkgs = root / "ui-tui" / "packages"
if tui_pkgs.is_dir():
subdirs.extend(d for d in tui_pkgs.iterdir() if d.is_dir())
stray = [d for d in subdirs if (d / "package-lock.json").is_file()]
assert not stray, (
"stray package-lock.json found in workspace sub-directory(es); "
"delete them and run `npm install` from the repo root instead: "
+ ", ".join(str(d / "package-lock.json") for d in stray)
)
+36
View File
@@ -1,6 +1,7 @@
from pathlib import Path
from subprocess import CalledProcessError
from types import SimpleNamespace
from unittest.mock import patch
import pytest
@@ -8,6 +9,41 @@ from hermes_cli import config as hermes_config
from hermes_cli import main as hermes_main
# ---------------------------------------------------------------------------
# Managed-uv compatibility for tests that patch shutil.which
# ---------------------------------------------------------------------------
# The production code now uses ``ensure_uv()`` / ``update_managed_uv()``
# instead of ``shutil.which("uv")``. Many tests in this file patch
# ``shutil.which`` to control whether uv is "available" — these autouse
# fixtures make the managed_uv functions delegate to the patched
# ``shutil.which`` so the existing test setup keeps working without
# per-test changes.
@pytest.fixture(autouse=True)
def _patch_managed_uv(request):
"""Make managed_uv helpers follow shutil.which mocking in tests."""
import shutil
# resolve_uv delegates to shutil.which("uv") so that test patches
# on shutil.which flow through naturally.
def _fake_resolve_uv():
return shutil.which("uv")
def _fake_ensure_uv():
path = shutil.which("uv")
return (path, False) # never freshly bootstrapped in tests
def _fake_update_managed_uv():
return None # never actually self-update in tests
def _fake_rebuild_venv(*args, **kwargs):
return True # no-op in tests
with patch("hermes_cli.managed_uv.resolve_uv", side_effect=_fake_resolve_uv), \
patch("hermes_cli.managed_uv.ensure_uv", side_effect=_fake_ensure_uv), \
patch("hermes_cli.managed_uv.update_managed_uv", side_effect=_fake_update_managed_uv), \
patch("hermes_cli.managed_uv.rebuild_venv", side_effect=_fake_rebuild_venv):
yield
def test_stash_local_changes_if_needed_returns_none_when_tree_clean(monkeypatch, tmp_path):
calls = []
+36
View File
@@ -20,6 +20,42 @@ from unittest.mock import patch
import pytest
# ---------------------------------------------------------------------------
# Managed-uv compatibility for tests that patch shutil.which
# ---------------------------------------------------------------------------
# The production code now uses ``ensure_uv()`` / ``update_managed_uv()``
# instead of ``shutil.which("uv")``. Many tests in this file patch
# ``shutil.which`` to control whether uv is "available" — these autouse
# fixtures make the managed_uv functions delegate to the patched
# ``shutil.which`` so the existing test setup keeps working without
# per-test changes.
@pytest.fixture(autouse=True)
def _patch_managed_uv(request):
"""Make managed_uv helpers follow shutil.which mocking in tests."""
import shutil
# resolve_uv delegates to shutil.which("uv") so that test patches
# on shutil.which flow through naturally.
def _fake_resolve_uv():
return shutil.which("uv")
def _fake_ensure_uv():
path = shutil.which("uv")
return (path, False) # never freshly bootstrapped in tests
def _fake_update_managed_uv():
return None # never actually self-update in tests
def _fake_rebuild_venv(*args, **kwargs):
return True # no-op in tests
with patch("hermes_cli.managed_uv.resolve_uv", side_effect=_fake_resolve_uv), \
patch("hermes_cli.managed_uv.ensure_uv", side_effect=_fake_ensure_uv), \
patch("hermes_cli.managed_uv.update_managed_uv", side_effect=_fake_update_managed_uv), \
patch("hermes_cli.managed_uv.rebuild_venv", side_effect=_fake_rebuild_venv):
yield
# ---------------------------------------------------------------------------
# is_uv_tool_install
# ---------------------------------------------------------------------------
+252
View File
@@ -327,6 +327,258 @@ def test_anthropic_pkce_branch_still_works():
assert "claude.ai" in body["auth_url"]
def test_xai_oauth_listed_as_loopback_flow():
"""xAI Grok OAuth must surface in the catalog as a first-class loopback flow."""
resp = client.get("/api/providers/oauth", headers=HEADERS)
assert resp.status_code == 200, resp.text
providers = {p["id"]: p for p in resp.json()["providers"]}
assert "xai-oauth" in providers
assert providers["xai-oauth"]["flow"] == "loopback"
assert "grok" in providers["xai-oauth"]["name"].lower()
def test_xai_loopback_start_returns_authorize_url(monkeypatch):
"""Start MUST bind the loopback listener and hand back an xAI authorize URL."""
from hermes_cli import auth as auth_mod
from hermes_cli import web_server as ws
class _FakeServer:
def shutdown(self):
pass
def server_close(self):
pass
class _FakeThread:
def join(self, timeout=None):
pass
redirect_uri = (
f"http://{auth_mod.XAI_OAUTH_REDIRECT_HOST}:{auth_mod.XAI_OAUTH_REDIRECT_PORT}"
f"{auth_mod.XAI_OAUTH_REDIRECT_PATH}"
)
monkeypatch.setattr(
auth_mod,
"_xai_oauth_discovery",
lambda *a, **k: {
"authorization_endpoint": "https://auth.x.ai/oauth2/auth",
"token_endpoint": "https://auth.x.ai/oauth2/token",
},
)
monkeypatch.setattr(
auth_mod,
"_xai_start_callback_server",
lambda *a, **k: (_FakeServer(), _FakeThread(), {"code": None, "error": None}, redirect_uri),
)
# Don't let the background worker run a real callback wait/exchange.
monkeypatch.setattr(ws, "_xai_loopback_worker", lambda sid: None)
resp = client.post("/api/providers/oauth/xai-oauth/start", headers=HEADERS)
assert resp.status_code == 200, resp.text
body = resp.json()
try:
assert body["flow"] == "loopback"
assert "user_code" not in body # loopback has nothing to paste/show
assert body["auth_url"].startswith("https://auth.x.ai/oauth2/auth?")
assert "code_challenge" in body["auth_url"]
sess = ws._oauth_sessions[body["session_id"]]
assert sess["provider"] == "xai-oauth"
assert sess["flow"] == "loopback"
finally:
ws._oauth_sessions.pop(body["session_id"], None)
def test_xai_loopback_worker_persists_tokens_on_success(monkeypatch):
"""The worker exchanges the callback code and marks the session approved."""
from hermes_cli import auth as auth_mod
from hermes_cli import web_server as ws
saved = {}
session_id = "xai-loopback-success-test"
ws._oauth_sessions[session_id] = {
"session_id": session_id,
"provider": "xai-oauth",
"flow": "loopback",
"created_at": time.time(),
"status": "pending",
"error_message": None,
"server": object(),
"thread": object(),
"callback_result": {"code": "auth-code", "state": "st"},
"redirect_uri": "http://127.0.0.1:56121/callback",
"verifier": "verifier",
"challenge": "challenge",
"state": "st",
"token_endpoint": "https://auth.x.ai/oauth2/token",
"discovery": {"token_endpoint": "https://auth.x.ai/oauth2/token"},
}
monkeypatch.setattr(
auth_mod,
"_xai_wait_for_callback",
lambda *a, **k: {"code": "auth-code", "state": "st"},
)
monkeypatch.setattr(
auth_mod,
"_xai_oauth_exchange_code_for_tokens",
lambda **k: {
"access_token": "xai-access",
"refresh_token": "xai-refresh",
"expires_in": 3600,
"token_type": "Bearer",
},
)
monkeypatch.setattr(
auth_mod,
"_save_xai_oauth_tokens",
lambda tokens, **k: saved.update(tokens),
)
monkeypatch.setattr(ws, "_add_xai_oauth_pool_entry", lambda *a, **k: None)
try:
ws._xai_loopback_worker(session_id)
assert ws._oauth_sessions[session_id]["status"] == "approved"
assert saved["access_token"] == "xai-access"
assert saved["refresh_token"] == "xai-refresh"
finally:
ws._oauth_sessions.pop(session_id, None)
def test_xai_loopback_worker_fails_on_state_mismatch(monkeypatch):
"""A mismatched OAuth state must fail the session, not persist tokens."""
from hermes_cli import auth as auth_mod
from hermes_cli import web_server as ws
session_id = "xai-loopback-state-test"
ws._oauth_sessions[session_id] = {
"session_id": session_id,
"provider": "xai-oauth",
"flow": "loopback",
"created_at": time.time(),
"status": "pending",
"error_message": None,
"server": object(),
"thread": object(),
"callback_result": {},
"redirect_uri": "http://127.0.0.1:56121/callback",
"verifier": "verifier",
"challenge": "challenge",
"state": "expected-state",
"token_endpoint": "https://auth.x.ai/oauth2/token",
"discovery": {},
}
monkeypatch.setattr(
auth_mod,
"_xai_wait_for_callback",
lambda *a, **k: {"code": "auth-code", "state": "ATTACKER-state"},
)
def _boom(**kwargs):
raise AssertionError("token exchange must not run on state mismatch")
monkeypatch.setattr(auth_mod, "_xai_oauth_exchange_code_for_tokens", _boom)
try:
ws._xai_loopback_worker(session_id)
sess = ws._oauth_sessions[session_id]
assert sess["status"] == "error"
assert "state mismatch" in sess["error_message"].lower()
finally:
ws._oauth_sessions.pop(session_id, None)
def test_xai_loopback_worker_skips_persist_when_cancelled(monkeypatch):
"""If the session is cancelled while waiting, the worker must not persist."""
from hermes_cli import auth as auth_mod
from hermes_cli import web_server as ws
session_id = "xai-loopback-cancel-test"
ws._oauth_sessions[session_id] = {
"session_id": session_id,
"provider": "xai-oauth",
"flow": "loopback",
"created_at": time.time(),
"status": "pending",
"error_message": None,
"server": object(),
"thread": object(),
"callback_result": {},
"redirect_uri": "http://127.0.0.1:56121/callback",
"verifier": "verifier",
"challenge": "challenge",
"state": "st",
"token_endpoint": "https://auth.x.ai/oauth2/token",
"discovery": {},
}
def _wait_then_cancel(*args, **kwargs):
# Simulate the user cancelling (DELETE /sessions/{id}) while we were
# blocked on the callback: the session vanishes, then a valid code
# arrives. The worker must notice and bail before persisting.
ws._oauth_sessions.pop(session_id, None)
return {"code": "auth-code", "state": "st"}
monkeypatch.setattr(auth_mod, "_xai_wait_for_callback", _wait_then_cancel)
def _must_not_persist(*args, **kwargs):
raise AssertionError("tokens must not be persisted for a cancelled session")
monkeypatch.setattr(auth_mod, "_save_xai_oauth_tokens", _must_not_persist)
monkeypatch.setattr(ws, "_add_xai_oauth_pool_entry", _must_not_persist)
# Should return cleanly without raising and without persisting.
ws._xai_loopback_worker(session_id)
assert session_id not in ws._oauth_sessions
def test_cancel_loopback_session_shuts_down_callback_server():
"""Cancelling a loopback session must free the bound callback port now."""
from hermes_cli import web_server as ws
shutdown_calls = {"shutdown": 0, "close": 0, "join": 0}
class _FakeServer:
def shutdown(self):
shutdown_calls["shutdown"] += 1
def server_close(self):
shutdown_calls["close"] += 1
class _FakeThread:
def join(self, timeout=None):
shutdown_calls["join"] += 1
# callback_result is the dict the worker's _xai_wait_for_callback polls.
callback_result = {"code": None, "error": None}
session_id = "xai-loopback-cancel-shutdown-test"
ws._oauth_sessions[session_id] = {
"session_id": session_id,
"provider": "xai-oauth",
"flow": "loopback",
"created_at": time.time(),
"status": "pending",
"server": _FakeServer(),
"thread": _FakeThread(),
"callback_result": callback_result,
}
try:
resp = client.delete(
f"/api/providers/oauth/sessions/{session_id}", headers=HEADERS
)
assert resp.status_code == 200, resp.text
assert resp.json()["ok"] is True
assert shutdown_calls == {"shutdown": 1, "close": 1, "join": 1}
# The waiting worker must be signalled so it returns promptly instead
# of spinning until the timeout.
assert callback_result["error"] == "cancelled"
assert session_id not in ws._oauth_sessions
finally:
ws._oauth_sessions.pop(session_id, None)
def test_unknown_pkce_provider_rejected_cleanly():
"""A future PKCE provider without an explicit branch must NOT silently route to Anthropic.
+1 -1
View File
@@ -3415,7 +3415,7 @@ class TestPtyWebSocket:
# subscriber registration and the message is dropped.
deadline = time.monotonic() + 5.0
while time.monotonic() < deadline:
if ws_mod._event_channels.get("broadcast-test"):
if ws_mod.app.state.event_channels.get("broadcast-test"):
break
time.sleep(0.01)
else:
+3 -1
View File
@@ -69,7 +69,9 @@ class TestWebUIBuildNeeded:
def test_returns_true_when_package_lock_newer_than_dist(self, tmp_path):
web_dir, dist_dir = _make_web_dir(tmp_path)
_touch(dist_dir / ".vite" / "manifest.json", offset=-10)
_touch(web_dir / "package-lock.json")
# With a single workspace root lockfile, the lockfile lives at the
# project root (tmp_path), not inside web_dir.
_touch(tmp_path / "package-lock.json")
assert _web_ui_build_needed(web_dir) is True
def test_returns_true_when_vite_config_newer_than_dist(self, tmp_path):
+23 -2
View File
@@ -1,5 +1,7 @@
"""Tests for plugins/memory/honcho/session.py — HonchoSession and helpers."""
import time
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import MagicMock
@@ -1538,8 +1540,27 @@ class TestDialecticLifecycleSmoke:
return provider, mock_manager, cfg
def _await_thread(self, provider):
if provider._prefetch_thread:
provider._prefetch_thread.join(timeout=3.0)
"""Block until the in-flight prefetch/prewarm thread has fully finished.
The earlier version did a single ``join(timeout=3.0)`` and then
proceeded regardless of whether the thread had actually finished. On a
loaded CI runner (6 parallel test slices), the background dialectic
thread's completion can slip past that 3s window, so the join times out
silently and the test reads ``_prefetch_result`` before the worker wrote
it a flaky ``session-start prewarm must land`` failure. We instead join
in a loop up to a generous ceiling and assert the thread is dead, so a
genuine hang surfaces as a clear, non-flaky failure instead of a race.
"""
thread = provider._prefetch_thread
if thread is None:
return
deadline = time.monotonic() + 30.0
while thread.is_alive() and time.monotonic() < deadline:
thread.join(timeout=1.0)
assert not thread.is_alive(), (
"prefetch/prewarm thread did not finish within 30s — "
"this is a real hang, not a timing flake"
)
def test_full_multi_turn_session(self):
"""Walks init → turns 1..8 → session end. Asserts at every step that
+66 -7
View File
@@ -735,18 +735,29 @@ def test_board_auto_initializes_missing_db(tmp_path, monkeypatch):
def test_ws_events_rejects_when_token_required(tmp_path, monkeypatch):
"""When _SESSION_TOKEN is set (normal dashboard context), a missing or
wrong ?token= query param must be rejected with policy-violation."""
"""Loopback mode: a missing or wrong ?token= must be rejected with
policy-violation; the correct token is accepted. The kanban WS now
delegates to web_server._ws_auth_ok, so we stub that with the real
loopback-token semantics (auth_required False constant-time token
compare)."""
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
kb.init_db()
# Stub web_server so _check_ws_token has a token to compare against.
# Stub web_server with a loopback-mode _ws_auth_ok (auth_required False →
# accept only the correct ?token=). Mirrors the real gate's loopback path.
import hermes_cli
import types
stub = types.SimpleNamespace(_SESSION_TOKEN="secret-xyz")
def _fake_ws_auth_ok(ws):
return ws.query_params.get("token", "") == "secret-xyz"
stub = types.SimpleNamespace(
_SESSION_TOKEN="secret-xyz",
_ws_auth_ok=_fake_ws_auth_ok,
)
monkeypatch.setitem(sys.modules, "hermes_cli.web_server", stub)
monkeypatch.setattr(hermes_cli, "web_server", stub, raising=False)
@@ -774,6 +785,51 @@ def test_ws_events_rejects_when_token_required(tmp_path, monkeypatch):
assert ws is not None # handshake succeeded
def test_ws_events_accepts_gated_ticket(tmp_path, monkeypatch):
"""Gated OAuth mode: the WS must accept a single-use ?ticket= (and reject
a bare ?token=, even one matching _SESSION_TOKEN). This is the regression
for the hosted-dashboard bug where the kanban live-events WS 1008'd on
every gated deployment because its bespoke check only knew _SESSION_TOKEN.
We stub _ws_auth_ok with the real gated semantics (ticket-only)."""
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
kb.init_db()
import hermes_cli
import types
def _fake_ws_auth_ok(ws):
# Gated mode: only a known ticket is accepted; token path rejected.
return ws.query_params.get("ticket", "") == "good-ticket"
stub = types.SimpleNamespace(
_SESSION_TOKEN="secret-xyz",
_ws_auth_ok=_fake_ws_auth_ok,
)
monkeypatch.setitem(sys.modules, "hermes_cli.web_server", stub)
monkeypatch.setattr(hermes_cli, "web_server", stub, raising=False)
app = FastAPI()
app.include_router(_load_plugin_router(), prefix="/api/plugins/kanban")
c = TestClient(app)
from starlette.websockets import WebSocketDisconnect
# Legacy token is rejected in gated mode, even if it's the real one.
with pytest.raises(WebSocketDisconnect) as exc:
with c.websocket_connect("/api/plugins/kanban/events?token=secret-xyz"):
pass
assert exc.value.code == 1008
# A valid ticket is accepted.
with c.websocket_connect(
"/api/plugins/kanban/events?ticket=good-ticket"
) as ws:
assert ws is not None
def test_ws_events_board_query_param_default_overrides_current_board_pointer(tmp_path, monkeypatch):
"""The event stream must honor ``board=default`` even when the global
current-board pointer targets a different board.
@@ -806,7 +862,10 @@ def test_ws_events_board_query_param_default_overrides_current_board_pointer(tmp
import hermes_cli
import types
stub = types.SimpleNamespace(_SESSION_TOKEN="secret-xyz")
stub = types.SimpleNamespace(
_SESSION_TOKEN="secret-xyz",
_ws_auth_ok=lambda ws: ws.query_params.get("token", "") == "secret-xyz",
)
monkeypatch.setitem(sys.modules, "hermes_cli.web_server", stub)
monkeypatch.setattr(hermes_cli, "web_server", stub, raising=False)
@@ -842,10 +901,10 @@ def test_ws_events_swallows_cancellation_on_shutdown(tmp_path, monkeypatch):
monkeypatch.setattr(Path, "home", lambda: tmp_path)
kb.init_db()
# Short-circuit the token check — this test is about the cancellation
# Short-circuit the auth check — this test is about the cancellation
# path, not auth.
import plugins.kanban.dashboard.plugin_api as pa
monkeypatch.setattr(pa, "_check_ws_token", lambda t: True)
monkeypatch.setattr(pa, "_ws_upgrade_authorized", lambda ws: True)
class _FakeWS:
def __init__(self):
@@ -0,0 +1,95 @@
"""Guardrail: dashboard plugins must NOT read the session token directly.
The dashboard host exposes a sanctioned, gated-mode-aware auth surface on the
plugin SDK (``window.__HERMES_PLUGIN_SDK__``): ``fetchJSON`` (JSON REST),
``authedFetch`` (uploads / blob downloads), and ``buildWsUrl`` /
``buildWsAuthParam`` (WebSockets). These handle BOTH dashboard auth modes
loopback (``X-Hermes-Session-Token`` header) and gated OAuth
(``hermes_session_at`` cookie / single-use ``?ticket=``).
Plugins that hand-roll ``fetch`` / ``WebSocket`` and read
``window.__HERMES_SESSION_TOKEN__`` directly send an empty token in gated mode
and 401/1008. That bug shipped in the kanban and achievements plugins and was
invisible until the dashboard ran gated on hosted Fly agents.
This test fails if any bundled plugin's frontend reads the token global
directly, forcing new/edited plugins through the SDK surface instead. It is
the enforcement half of the "single sanctioned auth surface" design the SDK
helpers are the carrot, this test is the stick.
If you have a legitimate reason to reference the token name (e.g. a comment
explaining why NOT to use it), add the file to ``_ALLOWED_FILES`` with a note.
"""
from __future__ import annotations
import re
from pathlib import Path
import pytest
# Repo root: tests/plugins/<this file> → ../../
_REPO_ROOT = Path(__file__).resolve().parents[2]
_PLUGINS_DIR = _REPO_ROOT / "plugins"
# The forbidden global. Reading it directly bypasses the gated-mode auth path.
_FORBIDDEN = "__HERMES_SESSION_TOKEN__"
# Files explicitly allowed to mention the token (none today). Map path →
# reason so the allowance is self-documenting if one is ever needed.
_ALLOWED_FILES: dict[str, str] = {}
def _plugin_frontend_bundles() -> list[Path]:
"""Every plugin-shipped JS bundle the dashboard loads into the browser."""
if not _PLUGINS_DIR.is_dir():
return []
# Plugin dashboards live at plugins/<name>/dashboard/dist/*.js
return sorted(_PLUGINS_DIR.glob("*/dashboard/dist/*.js"))
def test_there_are_plugin_bundles_to_check() -> None:
"""Sanity: the glob actually finds the bundles, so a future layout change
doesn't silently turn this guard into a no-op."""
bundles = _plugin_frontend_bundles()
names = {b.parent.parent.parent.name for b in bundles}
# kanban + hermes-achievements are bundled today; assert at least one is
# found so the guard can't pass vacuously.
assert bundles, "no plugin dashboard bundles found — glob/layout drift?"
assert names, "could not resolve plugin names from bundle paths"
@pytest.mark.parametrize(
"bundle",
_plugin_frontend_bundles(),
ids=lambda p: str(p.relative_to(_REPO_ROOT)),
)
def test_plugin_bundle_does_not_read_session_token(bundle: Path) -> None:
rel = str(bundle.relative_to(_REPO_ROOT))
text = bundle.read_text(encoding="utf-8", errors="replace")
if rel in _ALLOWED_FILES:
return # explicitly allowed (with a documented reason)
# Only flag CODE reads of the token global, not mentions in ``//`` comments
# (e.g. a comment explaining why the SDK helper is used instead). A line is
# a code read if it contains the global and the global appears before any
# ``//`` comment marker on that line.
offending: list[str] = []
for i, line in enumerate(text.splitlines(), start=1):
idx = line.find(_FORBIDDEN)
if idx == -1:
continue
comment_idx = line.find("//")
in_comment = comment_idx != -1 and comment_idx < idx
if not in_comment:
offending.append(f" {i}: {line.strip()}")
if not offending:
return
pytest.fail(
f"{rel} reads {_FORBIDDEN} directly — this bypasses gated-mode auth "
f"and 401/1008s on OAuth-gated dashboards. Use the plugin SDK instead: "
f"SDK.fetchJSON (JSON), SDK.authedFetch (uploads/downloads), or "
f"SDK.buildWsUrl (WebSockets). Offending lines:\n" + "\n".join(offending)
)
+76
View File
@@ -0,0 +1,76 @@
"""Regression for #37718: macOS microphone entitlement must be inherited.
Hermes Desktop signs with ``hardenedRuntime: true`` and points electron-builder
at two entitlement files (see ``apps/desktop/package.json``):
* ``entitlements`` ``electron/entitlements.mac.plist`` (the main app), and
* ``entitlementsInherit`` ``electron/entitlements.mac.inherit.plist`` (the
Electron Helper / Setup processes).
Under the hardened runtime, the process that actually opens the microphone is a
Helper, which inherits the *inherit* plist. ``com.apple.security.device.audio-input``
lived only in the main plist, so macOS' TCC layer refused the microphone with::
Prompting policy for hardened runtime; service: kTCCServiceMicrophone
requires entitlement com.apple.security.device.audio-input but it is missing
and never showed the permission prompt. These tests pin that every device
entitlement granted to the main app is also granted to the inherited helpers.
"""
from __future__ import annotations
import plistlib
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
ELECTRON_DIR = REPO_ROOT / "apps" / "desktop" / "electron"
MAIN_PLIST = ELECTRON_DIR / "entitlements.mac.plist"
INHERIT_PLIST = ELECTRON_DIR / "entitlements.mac.inherit.plist"
DEVICE_PREFIX = "com.apple.security.device."
def _load(plist: Path) -> dict:
assert plist.is_file(), f"missing entitlements file: {plist}"
with plist.open("rb") as fh:
return plistlib.load(fh)
def test_inherit_plist_grants_microphone() -> None:
"""The helper-inherited plist must grant audio-input (regression #37718)."""
inherit = _load(INHERIT_PLIST)
assert inherit.get("com.apple.security.device.audio-input") is True, (
"entitlements.mac.inherit.plist must grant "
"`com.apple.security.device.audio-input`; without it the hardened-runtime "
"Helper process is denied the microphone and no TCC prompt appears (#37718)."
)
def test_device_entitlements_are_inherited() -> None:
"""Every device.* entitlement on the main app must also be inherited."""
main = _load(MAIN_PLIST)
inherit = _load(INHERIT_PLIST)
main_device = {
key: val
for key, val in main.items()
if key.startswith(DEVICE_PREFIX) and val is True
}
missing = [key for key in main_device if inherit.get(key) is not True]
assert not missing, (
"Device entitlements present in entitlements.mac.plist but missing from "
f"entitlements.mac.inherit.plist: {missing}. Helper/Setup processes inherit "
"the latter under hardenedRuntime, so any device access the app needs must "
"be listed in both (#37718)."
)
@pytest.mark.parametrize("plist", [MAIN_PLIST, INHERIT_PLIST])
def test_entitlement_files_are_valid_plists(plist: Path) -> None:
"""Both entitlement files must remain well-formed plist dictionaries."""
data = _load(plist)
assert isinstance(data, dict) and data, f"{plist.name} should be a non-empty dict"
+4 -2
View File
@@ -147,11 +147,13 @@ def test_dockerfile_installs_tui_dependencies(dockerfile_text):
# because it's referenced as a ``file:`` workspace dependency from
# ``ui-tui/package.json`` — copying the tree avoids npm stopping at a
# bare ``package.json`` shell.
# With a single workspace root lockfile, only the root package-lock.json
# is copied; per-workspace lockfiles no longer exist.
assert "ui-tui/package.json" in dockerfile_text
assert "ui-tui/package-lock.json" in dockerfile_text
assert "ui-tui/packages/hermes-ink/" in dockerfile_text
assert "package-lock.json" in dockerfile_text
assert any(
"ui-tui" in step and "npm" in step and (" install" in step or " ci" in step)
"npm" in step and (" install" in step or " ci" in step)
for step in _run_steps(dockerfile_text)
)
@@ -0,0 +1,152 @@
"""Contract test: the s6-overlay stage2 hook seeds gateway_state.json from
HERMES_GATEWAY_BOOTSTRAP_STATE on first boot, so a freshly-provisioned
container can come up with the gateway already running.
Background. On a blank volume there is no gateway_state.json, so the boot
reconciler (cont-init.d/02-reconcile-profiles ->
container_boot.reconcile_profile_gateways) registers the gateway-default s6
slot but leaves it DOWN it only auto-starts when the last recorded state was
"running". A container provisioned on a fresh volume therefore comes up with
the gateway down until something starts it.
An orchestrator that wants the gateway running from first boot sets
HERMES_GATEWAY_BOOTSTRAP_STATE=running; stage2-hook.sh (installed as
/etc/cont-init.d/01-hermes-setup, which runs lexicographically BEFORE
02-reconcile-profiles) seeds the state file so the reconciler sees
prior_state=running and brings the slot up on the very first boot.
This mirrors the existing HERMES_AUTH_JSON_BOOTSTRAP env-seed pattern: it seeds
the SAME gateway_state.json the reconciler already consults, guarded by
``[ ! -f ]`` so persisted runtime state always wins on subsequent boots (a
deliberately-stopped gateway must stay stopped across restarts).
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
import tempfile
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
STAGE2_HOOK = REPO_ROOT / "docker" / "stage2-hook.sh"
@pytest.fixture(scope="module")
def stage2_text() -> str:
if not STAGE2_HOOK.exists():
pytest.skip("docker/stage2-hook.sh not present in this checkout")
return STAGE2_HOOK.read_text()
def _seed_block(text: str) -> str:
"""Extract the ``if [ ! -f "$HERMES_HOME/gateway_state.json" ] && … fi``
block that seeds the gateway state file from the bootstrap env var."""
m = re.search(
r'(if \[ ! -f "\$HERMES_HOME/gateway_state\.json" \] && \\\n'
r"(?:.*\n)*?fi)",
text,
)
assert m, (
"stage2-hook.sh must contain the gateway_state.json bootstrap-seed block "
"guarded on HERMES_GATEWAY_BOOTSTRAP_STATE"
)
return m.group(1)
def test_seed_block_present_and_guarded(stage2_text: str) -> None:
block = _seed_block(stage2_text)
# Must be a first-boot-only seed (the [ ! -f ] guard) keyed on the env var.
assert '[ ! -f "$HERMES_HOME/gateway_state.json" ]' in block, (
"seed must be guarded by [ ! -f ] so persisted state wins on restart"
)
assert "HERMES_GATEWAY_BOOTSTRAP_STATE" in block
assert "gateway_state" in block
def _run_seed(
text: str, *, env_value: str | None, preexisting: str | None
) -> str | None:
"""Run the extracted seed block in a sandbox $HERMES_HOME.
``env_value`` is the HERMES_GATEWAY_BOOTSTRAP_STATE value (None = unset).
``preexisting`` is the contents of a gateway_state.json placed before the
block runs (None = no file). Returns the file's contents afterwards, or
None if it doesn't exist. ``chown``/``chmod`` are stubbed so the block
runs without real root.
"""
bash = shutil.which("bash")
if bash is None:
pytest.skip("bash not available")
block = _seed_block(text)
with tempfile.TemporaryDirectory() as d:
dpath = Path(d)
home = dpath / "home"
home.mkdir()
state_file = home / "gateway_state.json"
if preexisting is not None:
state_file.write_text(preexisting)
env_line = (
f'export HERMES_GATEWAY_BOOTSTRAP_STATE="{env_value}"\n'
if env_value is not None
else "unset HERMES_GATEWAY_BOOTSTRAP_STATE\n"
)
script = (
"set -e\n"
f'HERMES_HOME="{home}"\n'
# Stub privilege ops — the sandbox isn't root.
"chown() { :; }\n"
"chmod() { :; }\n"
+ env_line
+ block
)
script_path = dpath / "harness.sh"
script_path.write_text(script)
proc = subprocess.run(
[bash, str(script_path)], capture_output=True, text=True
)
assert proc.returncode == 0, proc.stderr
if not state_file.exists():
return None
return state_file.read_text()
def test_seeds_running_state_on_blank_volume(stage2_text: str) -> None:
"""env=running + no pre-existing file -> writes a valid running state."""
out = _run_seed(stage2_text, env_value="running", preexisting=None)
assert out is not None, "seed must create gateway_state.json"
assert json.loads(out).get("gateway_state") == "running"
def test_does_not_clobber_existing_state(stage2_text: str) -> None:
"""The [ ! -f ] guard: an existing state file is never overwritten, even
when the bootstrap env var says running. A deliberately-stopped gateway
must stay stopped across restarts."""
existing = json.dumps({"gateway_state": "stopped", "pid": 123})
out = _run_seed(stage2_text, env_value="running", preexisting=existing)
assert out == existing, "seed must not clobber a persisted state file"
def test_no_seed_when_env_unset(stage2_text: str) -> None:
"""No env var -> no file written (preserves the default down-on-first-boot
behaviour for orchestrators that don't opt in)."""
out = _run_seed(stage2_text, env_value=None, preexisting=None)
assert out is None, "seed must not run when HERMES_GATEWAY_BOOTSTRAP_STATE is unset"
def test_non_running_value_ignored(stage2_text: str) -> None:
"""Only a literal "running" is honoured; any other value is ignored so a
typo can't write a bogus state. (The reconciler's _AUTOSTART_STATES is
exactly {"running"}.)"""
for bogus in ("stopped", "Running", "1", "true", "starting"):
out = _run_seed(stage2_text, env_value=bogus, preexisting=None)
assert out is None, (
f"only 'running' should seed a state file, not {bogus!r}"
)
+1
View File
@@ -6659,6 +6659,7 @@ def _(rid, params: dict) -> dict:
picker_hints=True,
canonical_order=True,
pricing=True,
capabilities=True,
max_models=50,
)
return _ok(rid, payload)
-7449
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import { handleMouseEvent } from './components/App.js'
import { createSelectionState, startSelection, updateSelection } from './selection.js'
import { createSelectionState, hasSelection, startSelection, updateSelection } from './selection.js'
const makeApp = () => {
const selection = createSelectionState()
@@ -39,6 +39,39 @@ describe('handleMouseEvent right-click selection behavior', () => {
expect(app.clickCount).toBe(0)
})
it('clears the highlight after a successful right-click copy', async () => {
const app = makeApp()
startSelection(app.props.selection, 0, 0)
updateSelection(app.props.selection, 4, 0)
expect(hasSelection(app.props.selection)).toBe(true)
handleMouseEvent(app, { action: 'press', button: 2, col: 3, kind: 'mouse', row: 1, sequence: '' })
await Promise.resolve()
await Promise.resolve()
// Deliberate copy clears the selection (visual confirmation + a follow-up
// right-click on empty space pastes rather than re-copying a stale range).
expect(hasSelection(app.props.selection)).toBe(false)
expect(app.props.onSelectionChange).toHaveBeenCalled()
})
it('keeps the highlight when right-click copy fails (no clipboard path)', async () => {
const app = makeApp()
app.props.onCopySelectionNoClear.mockResolvedValue('')
startSelection(app.props.selection, 0, 0)
updateSelection(app.props.selection, 4, 0)
handleMouseEvent(app, { action: 'press', button: 2, col: 3, kind: 'mouse', row: 1, sequence: '' })
await Promise.resolve()
await Promise.resolve()
// Copy didn't land, so the highlight must survive (and we fall back to the
// right-click paste handler instead).
expect(hasSelection(app.props.selection)).toBe(true)
})
it('falls back to right-click handlers when selection copy has no clipboard path', async () => {
const app = makeApp()
app.props.onCopySelectionNoClear.mockResolvedValue('')
@@ -17,7 +17,7 @@ import {
parseMultipleKeypresses
} from '../parse-keypress.js'
import reconciler from '../reconciler.js'
import { finishSelection, hasSelection, type SelectionState, startSelection } from '../selection.js'
import { clearSelection, finishSelection, hasSelection, type SelectionState, startSelection } from '../selection.js'
import { getTerminalFocused, setTerminalFocused } from '../terminal-focus-state.js'
import { TerminalQuerier, xtversion } from '../terminal-querier.js'
import { isXtermJs, setXtversionName, supportsExtendedKeys } from '../terminal.js'
@@ -648,7 +648,15 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void {
void app.props
.onCopySelectionNoClear()
.then(text => {
if (!text) {
if (text) {
// Right-click copy is a deliberate action (unlike copy-on-select
// during a drag, which keeps the highlight so the drag can
// continue). Clear the highlight so the user gets visual
// confirmation the copy landed and a follow-up right-click on
// empty space pastes instead of re-copying the stale range.
clearSelection(sel)
app.props.onSelectionChange()
} else {
app.props.onMouseDownAt(col, row, baseButton)
}
})
+122
View File
@@ -0,0 +1,122 @@
import { describe, expect, it } from 'vitest'
import { blockRenders, hasLeadGap, messageGroup, prevRenderedMsg } from '../domain/blockLayout.js'
import type { Msg } from '../types.js'
const m = (over: Partial<Msg>): Msg => ({ role: 'assistant', text: '', ...over })
describe('messageGroup', () => {
it('classifies each block kind into its visual band', () => {
expect(messageGroup(m({ role: 'assistant' }))).toBe('model')
expect(messageGroup(m({ role: 'assistant', kind: 'diff' }))).toBe('diff')
expect(messageGroup(m({ role: 'system', kind: 'trail' }))).toBe('trail')
expect(messageGroup(m({ role: 'system' }))).toBe('note')
expect(messageGroup(m({ role: 'user' }))).toBe('user')
expect(messageGroup(m({ role: 'user', kind: 'slash' }))).toBe('slash')
expect(messageGroup(m({ role: 'system', kind: 'intro' }))).toBe('intro')
expect(messageGroup(m({ role: 'system', kind: 'panel' }))).toBe('intro')
})
})
describe('hasLeadGap', () => {
const trail = m({ role: 'system', kind: 'trail' })
const model = m({ role: 'assistant' })
const note = m({ role: 'system' })
const user = m({ role: 'user' })
const diff = m({ role: 'assistant', kind: 'diff' })
const slash = m({ role: 'user', kind: 'slash' })
it('opens a gap only at a boundary between working-area groups', () => {
expect(hasLeadGap(trail, model)).toBe(true)
expect(hasLeadGap(model, trail)).toBe(true)
expect(hasLeadGap(model, note)).toBe(true)
expect(hasLeadGap(note, model)).toBe(true)
})
it('keeps same-group neighbours flush (the grouping)', () => {
expect(hasLeadGap(trail, trail)).toBe(false)
expect(hasLeadGap(model, model)).toBe(false)
expect(hasLeadGap(note, note)).toBe(false)
})
it('never gaps the first block (no predecessor)', () => {
expect(hasLeadGap(undefined, model)).toBe(false)
expect(hasLeadGap(undefined, trail)).toBe(false)
})
it('suppresses the gap after blocks that already paint a trailing line', () => {
// user and diff carry their own marginBottom — the following block must
// not add a second blank line on top of it.
expect(hasLeadGap(user, trail)).toBe(false)
expect(hasLeadGap(user, model)).toBe(false)
expect(hasLeadGap(diff, model)).toBe(false)
})
it('still gaps after a slash echo (it has no trailing margin)', () => {
expect(hasLeadGap(slash, model)).toBe(true)
expect(hasLeadGap(slash, trail)).toBe(true)
})
it('lets user / slash / diff own their spacing (never managed here)', () => {
expect(hasLeadGap(model, user)).toBe(false)
expect(hasLeadGap(model, slash)).toBe(false)
expect(hasLeadGap(model, diff)).toBe(false)
})
})
describe('blockRenders', () => {
const trail: Msg = { role: 'system', kind: 'trail', text: '', tools: ['Edit foo.ts'] }
const model: Msg = { role: 'assistant', text: 'hi' }
const todos: Msg = { role: 'system', kind: 'trail', text: '', todos: [{ content: 'a', id: '1', status: 'pending' }] }
it('always renders non-trail blocks', () => {
expect(blockRenders(model, { detailsMode: 'hidden', commandOverride: true })).toBe(true)
})
it('renders a content-bearing trail unless every section is hidden', () => {
expect(blockRenders(trail, { detailsMode: 'collapsed' })).toBe(true)
expect(blockRenders(trail, { detailsMode: 'expanded' })).toBe(true)
// /details hidden routes through commandOverride, which hides every section.
expect(blockRenders(trail, { detailsMode: 'hidden', commandOverride: true })).toBe(false)
})
it('does not render a content-less trail (e.g. finalDetails with only a token tally)', () => {
const tally: Msg = { role: 'system', kind: 'trail', text: '', toolTokens: 40 }
expect(blockRenders(tally, { detailsMode: 'expanded' })).toBe(false)
})
it('keeps todo trails visible even when details are hidden', () => {
expect(blockRenders(todos, { detailsMode: 'hidden', commandOverride: true })).toBe(true)
})
})
describe('prevRenderedMsg', () => {
const hiddenCtx = { commandOverride: true, detailsMode: 'hidden' as const }
const shownCtx = { detailsMode: 'collapsed' as const }
const rows: Msg[] = [
{ role: 'user', text: 'q' }, // 0
{ role: 'system', kind: 'trail', text: '', tools: ['Edit foo.ts'] }, // 1
{ role: 'assistant', text: 'first' }, // 2
{ role: 'system', kind: 'trail', text: '', tools: ['Edit bar.ts'] }, // 3
{ role: 'assistant', text: 'second' } // 4
]
const at = (i: number) => rows[i]
it('returns the literal predecessor when everything renders', () => {
expect(prevRenderedMsg(at, 2, shownCtx)).toBe(rows[1])
expect(prevRenderedMsg(at, 4, shownCtx)).toBe(rows[3])
})
it('skips hidden trails so grouping sees the nearest visible block', () => {
// With trails hidden, the prose at index 2 groups against the user (not the
// invisible trail) and the prose at index 4 groups against the prose at 2.
expect(prevRenderedMsg(at, 2, hiddenCtx)).toBe(rows[0])
expect(prevRenderedMsg(at, 4, hiddenCtx)).toBe(rows[2])
})
it('returns undefined at the top of the transcript', () => {
expect(prevRenderedMsg(at, 0, shownCtx)).toBeUndefined()
})
})
@@ -24,6 +24,14 @@ describe('virtual height estimates', () => {
expect(estimatedMsgHeight(msg, 26, { compact: false, details: false, userPrompt: 'Ψ >' })).toBe(4)
})
it('adds one row for a group-boundary lead gap', () => {
const msg: Msg = { role: 'assistant', text: 'reply' }
expect(estimatedMsgHeight(msg, 80, { compact: false, details: false, leadGap: true })).toBe(
estimatedMsgHeight(msg, 80, { compact: false, details: false, leadGap: false }) + 1
)
})
it('includes detail sections when visible', () => {
const msg: Msg = { role: 'assistant', text: 'ok', thinking: 'line 1\nline 2', tools: ['Tool A', 'Tool B'] }

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