The salvaged branch predates the electron test project's node:test -> vitest
migration (test:desktop:platforms is now `vitest run --project electron`).
Import `test` from vitest so the suites are collected; assertions stay on
node:assert/strict per the existing electron test convention.
Addresses both review findings on the remote-gateway download PR:
1. Unbounded buffering (finding #1). fetchBuffer / fetchBufferViaOauthSession
accumulated the entire response (then copied it again via Buffer.concat)
before saveGatewayFile even opened the save dialog, so a large gateway file
could exhaust the native process. Both auth paths now stream: once response
headers arrive the connect timeout is cleared, the filename is derived, the
save dialog is shown, and the body is piped to the chosen destination with
backpressure. A read/write error tears down the stream and unlinks the
partial file. The byte-moving, data-URL decoding, and filename/path helpers
are extracted into gateway-file-download.ts so they're unit-testable without
Electron.
2. No fallback for older gateways (finding #2). saveGatewayFile required the new
/api/fs/download route. Desktop and the remote gateway update independently,
so a gateway predating this PR 404s. Added a 404-only compatibility fallback
to the existing capped /api/fs/read-data-url route (bounded, so it only
serves smaller files — enough to keep older backends working).
Tests: gateway-file-download.test.ts covers streaming, backpressure,
error-cleanup (unlink on write/response error), data-URL decoding, filename
derivation (incl. traversal reduction), and 404 detection;
gateway-file-download-transport.test.ts asserts both transports stream (no
whole-body Buffer.concat) and that the 404 fallback is wired. Both registered
in the desktop platform test list. Server-side /api/fs/download tests
(streaming + sensitive-file reject) already pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- The readonly-loader completer test now stubs
get_portable_mcp_server_names_nowait — real plugin discovery runs
load_config() during one-time process init, which is not the
per-keystroke read the test guards against.
- Cap _resolve_toolset_memo at 256 entries: generation-keyed entries
from stale generations are never hit again, so clear on overflow to
keep long sessions bounded.
_file_mutation_verifier_enabled and _turn_completion_explainer_enabled
re-read config.yaml on every call via load_config() (~1ms deepcopy per
call). finalize_turn runs these gates at the end of every turn, so each
turn paid two redundant config deepcopies. The sibling
_credits_notices_enabled already caches on self; mirror that pattern.
The env-var override stays authoritative and uncached, so runtime flips
still work. Config flips now apply on the next session, matching the
documented sibling semantics.
(cherry picked from commit f2d0e00d71ba35fa3a53cd41076261e3c5feb45d)
resolve_toolset() recursively walks toolset includes and, with
include_registry=True, merges registry-registered tools on every call —
each external call re-runs the includes walk and takes a fresh registry
snapshot under the registry lock. It is called dozens of times per
_get_platform_tools() (every /tools completion keystroke, per picker
render) and at 19 call sites across the CLI/gateway.
Memoise the external-entry result keyed on (name, include_registry,
registry id, registry generation). tools.registry exposes a monotonic
_generation counter bumped on every register/deregister/alias/MCP
refresh (its docstring explicitly invites generation-keyed memoisation),
so a cache entry is valid until the registry changes. External callers
never pass visited, so the memo engages exactly at the public entry
and the internal cycle-detection recursion is untouched.
Measured: _get_platform_tools drops 165us -> 59us per call (3x) with the
xAI credential fix simulated; /tools completion ~2ms -> ~77us/keystroke
combined. Regression tests: repeat resolution is a memo hit (get_toolset
called once), a generation bump forces a fresh resolve, and the memoised
result is identical to a fresh resolution.
(cherry picked from commit 3d36ecb273f6652d00556a7b9d846c08047801aa)
get_default_hermes_root() resolves HERMES_HOME against the platform
native home (~80us of path resolution) on EVERY call and is called at
31+ sites — every _load_global_auth_store() (per provider row in the
/model picker), kanban, backup, gateway, update. Its result depends
only on (HERMES_HOME, native home), so memoise it keyed on those two
inputs, compared for free on each call (freshness-correct even if a
test or plugin mutates HERMES_HOME mid-process).
_load_global_auth_store() re-read + re-parsed the global auth.json on
every call; read_credential_pool() -> load_pool() runs it once per
provider row in the /model picker even when the profile has entries and
the global fallback never fires. Memoise keyed on the global auth
file's path+mtime (same pattern as _nous_auth_status_cache); the store
only changes when a global-scope auth write touches the file.
Measured (profile mode, 30-provider global store): get_default_hermes_root
81us -> 10us; _load_global_auth_store 128us -> 66us; load_pool 165us ->
137us per call — ~2ms saved per /model picker render (20 provider rows).
Regression tests: hermes_constants memo pin (no path resolution on
repeat calls, HERMES_HOME change forces a fresh resolution); global-store
memo pins (store read once across repeats, mtime bump re-reads once,
absent store stays cheap).
(cherry picked from commit be348f32e5bd7479c26fabb652de549fb9c8a1e1)
The /tools and /personality completers run on every keystroke while the
user types those commands (complete_while_typing), and both re-read +
re-parse the full config on each keypress:
- _tools_completions called load_config() — the defensive deepcopy
(~340us/call on cache hit) even though it only reads toolset enable
state + MCP server names. Switched to load_config_readonly() (the
perf(agent) #74322 pattern; this per-keystroke site was missed).
- _personality_completions called load_cli_config() — a full YAML parse
+ deep merge of the built-in defaults (~110us) — on every keystroke.
Memoised keyed on the config file path+mtime (same pattern as load_env
/ _nous_auth_status_cache), so the parse runs once per config state.
Measured: /tools 357us -> 18us per keystroke; /personality parse drops
from 1-per-keystroke to 1-per-config-change (500 keystrokes -> 1 parse).
Regression tests: _tools_completions uses the readonly loader (deepcopy
loader never called); personality memo parses once across repeated
completions and re-parses once after a config mtime change.
(cherry picked from commit 2b3f897171f93dc6b099848ec5ab3763b7294870)
The main.py decomposition re-exported the sessions/update/dashboard command
surface with eager from-imports, so every hermes invocation (including
hermes --version) paid for update_cmd's dependency chain (jwt, click,
cryptography). Resolve the re-exports through the existing PEP 562 module
__getattr__ (same pattern as _PROVIDER_MODELS) so each module loads on
first actual use. Internal call sites go through a _self() helper because
bare-name lookups do not trigger __getattr__; _self() imports sys locally
since update tests patch hermes_cli.main.sys. The
_warn_stale_dashboard_processes back-compat alias moves into the lazy
surface, and the sessions argparse dispatch defers sessions_cmd to call
time. Monkeypatching hermes_cli.main.<name> keeps working: a patch sets a
real module attribute, which shadows __getattr__.
Measured (Windows 11, Python 3.11, median of 7 warm runs):
import hermes_cli.main 253ms -> 196ms (-22%).
(cherry picked from commit cad1083b71635f98815b698d69a18f7f58e15517)
Read the durable display transcript when creating a branch instead of copying the compacted model projection. Hydrate the Desktop branch boundary from persisted history, avoid stale whole-chat counts, and seed the new tile from the backend snapshot. Add regression coverage for compacted histories, visible-message counts, selected prefixes, and hydration races.
(cherry picked from commit c3d2d759ae104395adde215b2e293ccf8e895684)
Residual hunk from PR #84832's picker-rebind commit; the functional
surfaces.tsx change landed on main via #86250.
(cherry picked from commit 990936d6f6e752ddb474787c1f7fdc3ad4f4dc60, docstring hunk only)
CLI-origin sessions have no session_key; the Desktop history-truncation
path called replace_messages(session["session_key"], ...) with None,
whose reinsert violated the messages.session_id FK -> "FOREIGN KEY
constraint failed" -> "Restore failed" on resume. Key the persist off
the durable session id instead.
Extracted from PR #81904 (the scope=compacted API half was superseded by
include_compacted, #86595). The PR's companion change defaulting
session_key to the session id at insert time is deliberately NOT taken:
main treats a non-NULL session_key as "this is a gateway session"
(list_gateway_sessions, orphan gateway-session repair), so the default
would misclassify every CLI session.
(extracted from PR #81904, commit cef9b9b27d)
The right-hand chat panel (model picker + session list) is a fixed 240px
column on desktop with no way to hide it. Add a collapse button (X) in the
panel header and a floating 'panel' button over the terminal to reopen it,
mirroring the collapsible app sidebar. The choice is persisted in
localStorage (hermes-chat-panel-collapsed) so it survives reloads.
(cherry picked from commit 854f1325a5490bcc968723a82746c939118f6b67)
Collapsed tool zones (terminal/logs) kept a down chevron after minimize,
so the restore affordance looked like another collapse. Point the icon in
the action direction — down when expanded, up when collapsed — matching
master-detail collapsible detail headers. Same fix for floating panes.
(cherry picked from commit 3392aeb9dba0ed9e9cabfb96b6010e46fa453ca1)
Under display.sections.thinking: collapsed, the TUI now keeps the LIVE
reasoning panel open while reasoning streams and collapses it the moment
the reasoning phase ends (first tool call, final answer, or new turn).
Previously 'collapsed' meant the panel was always collapsed — including
the currently-streaming reasoning — and there was no way to get
'expanded while live, collapsed when done'. This makes 'collapsed' an
auto preference:
- turnController tags the open reasoning segment isLiveReasoning and
seals the tag in endReasoningPhase/closeReasoningSegment
- streamingAssistant passes reasoningActive only to the live segment,
so sealed reasoning segments from earlier phases stay collapsed
- ToolTrail auto-opens while reasoningActive under collapsed mode;
expanded/hidden/MoA-reference semantics are unchanged
Adds thinkingLiveCollapse.test.tsx covering open-on-stream, close-on-
finish (including mid-turn rerender), and the expanded-mode no-op.
(cherry picked from commit 6ef4ef77d3a0837c8ef5c5dac3de74f352bd637d)
Main extracted a session_search() wrapper (owned-DB lifecycle) around
_session_search_impl after #82595 was opened; the cherry-picked detail
parameter landed on the impl only. Append it to the wrapper with the
same positional-compatibility contract and pass it through.
When the 1h provider_models_cache.json TTL lapses, the model picker
serially fetches /v1/models for each authenticated provider. With 10+
providers this stacks to 15-30s of blocking before the picker renders.
Add a parallel prefetch step before the serial picker build loops:
- _collect_authed_provider_slugs(): lightweight credential pre-scan
that mirrors sections 1/2/2b without fetching model lists
- _prefetch_provider_models_parallel(): ThreadPoolExecutor-based
concurrent fetch of stale/missing cache entries (max 8 workers)
- update_provider_cache_entry(): thread-safe single-entry cache writer
with threading.Lock to prevent concurrent write races
Guardrails:
- Skipped when <=3 authed providers (overhead not worth it)
- Skipped when refresh=True (serial path force-refreshes)
- Exception-isolated (falls back to serial path on any failure)
- No behavioral change (same model lists, same picker output)
Closes#80413
(cherry picked from commit 89dddd6cb5d53d73278e0518c375fb5b878e5c6b)
_create_request_anthropic_client() built a fresh anthropic.Anthropic
client (and httpx pool) on every single LLM call, and
_close_request_anthropic_client() always fully closed it right after
- unlike the OpenAI-wire path, which caches and reuses one warm
client across sequential calls via a single-slot cache keyed on the
effective client kwargs.
Add the same single-slot cache to the Anthropic-wire path: keyed on
credentials, base URL/Bedrock region, per-model timeout, and the
1M-beta flag; in_use guards concurrent calls from sharing one pool's
close/abort lifecycle; poisoned marks a cross-thread-aborted slot so
the owner-thread close discards it; reuse only on request_complete /
stream_request_complete (the same _REQUEST_CLIENT_REUSE_REASONS the
OpenAI path already uses). Wires a teardown hook into
release_clients()/close() mirroring _close_cached_request_openai_client.
Fixes #HPA-02
(cherry picked from commit 37f90df15593e6ded0390f827f8e5604bf0acc86)
Every search route in _search_messages_impl (FTS, CJK bigram, trigram,
LIKE fallback, rebuild-gap supplement) selected m.content, then the
result tail popped it unread. On DBs with multi-MB tool rows, each
search read and materialized up to `limit` full rows only to discard
them. Snippets come from snippet()/substr() in SQL and the context
window is re-fetched by id, so no code path ever read the column.
Drop the column from all six SELECT lists. Returned dicts are
unchanged: content was never part of the public result (the pop ran
before return), and tests/test_hermes_state.py already documents that
contract.
(cherry picked from commit d0c3af167e7dd4eb18e1bea29ba107a94911ea24)