Phase 2 of the multi-connection campaign (#86679 shipped the registry).
The Electron backend pool can now serve agents from ANY registered
connection concurrently, keyed by composite (connection, profile) scopes.
- connection-registry.ts: backendScopeKey(connectionId, profile) — the
single home of the composite-key rule. Local/empty connection ids keep
the BARE profile key, so every legacy pool entry, reaper log line, and
touch call is byte-identical for single-source users; non-local
connections get `conn:<id>::<profile>`, which cannot collide with a
plain profile name. backendScopePrefix() matches the keys a connection
owns (teardown on remove).
- main.ts ensureRegistryBackend(connectionId, profile): resolves a backend
against the v2 registry. local kind delegates to ensureBackend()
untouched; remote/cloud dial the entry's own URL/auth (descriptor carries
profile + connectionId + sharedRemote for per-request ?profile= scoping);
ssh bootstraps a tunnel scoped to the composite key, with the served
dashboard token persisted back onto the REGISTRY entry (not v1
connection.json). Pool entries reuse the existing LRU/idle-reaper/touch
lifecycle.
- hermes:connections:remove now stops every pooled backend + ssh scope the
removed connection owns.
- New IPC hermes:connection:for + preload getConnectionFor + renderer
types (connectionId/sharedRemote on HermesConnection). No renderer
behavior change yet — the multi-source roster/socket switchover is PR 3.
Tests: +2 backendScopeKey contract cases (28 total in the registry suite);
electron + settings projects 1357 passed; both tsc configs and eslint clean.
The desktop app carried its own hardcoded list of 17 vendor MCP endpoints
(apps/desktop/src/lib/mcp-directory.ts) powering the composer suggestion
pills — a second PR-reviewed vendor list, overlapping and drifting from the
Nous-approved MCP catalog (optional-mcps/).
This makes the catalog the single source of truth:
- manifest schema: optional `suggest:` block (keywords + hosts), parsed,
validated, and normalized in mcp_catalog.py
- 15 new URL-only hosted-remote catalog entries (atlassian, sentry, datadog,
notion, stripe, vercel, supabase, netlify, hugging_face, asana, intercom,
airtable, webflow, paypal, square); figma + linear manifests gain suggest
blocks
- GET /api/mcp/catalog now serves the suggest metadata
- desktop suggestion provider builds its match index from the catalog;
the static directory remains only as a compatibility rung for older
backends without suggest metadata
- setup card source line prefers the catalog entry's transport URL
GitHub stays out of the catalog on purpose: its hosted MCP rejects generic
DCR and the bundled github/* skills (gh CLI) are the stronger integration.
New desktop `github` suggestion provider offers the github-auth skill
instead — gated on a new cached GET /api/git/gh-auth probe so already-
authenticated users never see the pill.
- use-keybinds.ts: sort @/app/routes after @/app/chat/close-tab and the
right-sidebar imports (natural-asc)
- find-bar.test.tsx: sort @/app/hooks/use-keybinds before @/components and
@/i18n imports; KeybindRuntimeDeps before useKeybinds in named imports
Three fixes for the find-in-page bar (Ctrl+F):
1. Position: replace fixed right-4 with calc() that accounts for the
titlebar tool cluster width, preventing visual overlap with the
layout/haptics/keybinds/settings icons. Uses existing CSS vars from
wiring.tsx (--titlebar-tools-right, --titlebar-tools-width).
right-[calc(var(--titlebar-tools-right,0.75rem)+var(--titlebar-tools-width,0px)+0.5rem)]
2. Overlay guard: hide the find bar on full-screen overlay routes
(agents, command-center, cron, profiles, settings, starmap,
webhooks), matching the titlebar controls pattern. Prevents the
bar from rendering behind overlays at z-50 and avoids collision
with overlay-specific search surfaces (e.g. Settings search #69025).
3. Keybind gate: suppress the view.findInPage keybind on overlay
routes so Ctrl+F doesn't mutate store state with no visible effect.
Defense-in-depth alongside the component guard.
Adds regression test asserting the find bar does not render on /settings.
Address blocking review on #86782 (trevorgordon981, 2026-08-15): the
previous lazy closures in main.py only deferred the module-level
"import secrets_cli" statement, but were themselves invoked at parse
time — so the chain main -> secrets_cli -> bitwarden -> cryptography
still ran eagerly on every command, including `hermes update --check`.
The closure indirection was dead laziness.
Move the laziness to where the crypto payload actually lives:
1. secrets_cli.py: drop the module-top "from agent.secret_sources
import bitwarden as bw" import. Each cmd_* handler now resolves the
backend via a local _load_bw() helper, which imports
agent.secret_sources.bitwarden on first use. register_cli() no
longer touches crypto at all — it only wires argparse structure.
2. _BWS_VERSION is duplicated in secrets_cli as a plain string so the
"install" subparser help text renders without importing the
backend. agent.secret_sources.bitwarden._BWS_VERSION stays the
source of truth; bump both together when pinning a new bws release.
3. Module-level PEP 562 __getattr__ resolves "secrets_cli.bw" lazily.
Existing upstream tests (test_secrets_bitwarden_non_tty.py) that
monkeypatch "hermes_cli.secrets_cli.bw.find_bws" keep working —
monkeypatch resolves the string one level deep, triggering
__getattr__, which imports the real bitwarden module and lets the
patch land on the same cached module object the handlers import.
4. main.py: revert the closure indirection back to a direct parse-time
_secrets_cli.register_cli() call — safe now that register_cli is
crypto-free by construction. The argparse wiring is again visible
at the call site (matching checkpoints.py / curator.py convention),
which addresses the original parse-time-vs-post-parse contract
concern from the previous review round.
Adds a decisive main()-level regression test requested by review:
test_main_update_check_crypto_absent_in_sys_modules spawns main() in a
subprocess with argv=['hermes', 'update', '--check'], patches
hermes_cli.main._cmd_update_check to short-circuit before any network,
and asserts cryptography.hazmat.bindings._rust stays out of
sys.modules both at dispatch time and after main() returns. This is
the exact invariant the Windows self-lock depends on; the previous
import-only tests could not observe the failure because parser
construction runs inside main().
Verification:
- scripts/run_tests.sh tests/test_lazy_secrets_import.py
tests/test_lazy_secrets_dispatch.py
tests/hermes_cli/test_secrets_bitwarden_non_tty.py
-> 13/13 passed (includes the new decisive test + the 2 upstream
tests that broke under the earlier _LazyBitwarden proxy).
- Sabotage run: same suite against the pre-fix main.py + secrets_cli.py
fails the new decisive test with "cryptography._rust loaded by main()
before update dispatch" — confirming the test guards the bug.
- Manual trace: at _cmd_update_check dispatch time, sys.modules
contains hermes_cli.secrets_cli (parse-time structure only) but NOT
agent.secret_sources.bitwarden and NOT cryptography._rust.
Refs: #86781
Refs: #83569
Two upstream tests failed with the whitelist approach:
- test_real_plugin_source_discovery_applies_dotenv (plugin source named
HERMES_TEST_PLUGIN_BOOTSTRAP not in whitelist)
- test_external_secret_values_are_isolated_between_homes (test source
named test-source not in whitelist)
Fix: use 'v.get("enabled") is True' instead of 'v.get("enabled", True)'
on any dict value. This is stricter — only keys with an explicit
enabled: true pass — while remaining name-agnostic, so plugin and test
sources flow through without hardcoding a whitelist.
Refs #86781, #86782
secrets_cli.py: revert to upstream eager import (tests rely on as
module attribute for monkeypatch; the previous _LazyBitwarden proxy
broke 2 existing tests because agent.secret_sources.bitwarden lacks
BwsClient in the upstream codebase).
main.py: wrap secrets_cli/onepassword_secrets_cli imports in
_register_bitwarden/_register_onepassword closures. The parser tree is
created at parse-time (register_cli attaches subparsers), but the
module import itself defers to first use — argparse only calls the
closure when it encounters the subcommand, so importing main() no
longer loads bitwarden/cryptography eagerly.
env_loader.py: keep the known-source-names gate (any dict with a
known source name and enabled: true).
Verification: 12/12 tests pass (3 sys.modules + 7 E2E subprocess +
2 upstream test_secrets_bitwarden_non_tty).
Address review feedback from trevorgordon981 on PR #86782:
1. **Pre-register parsers at parse-time, lazy-import backends only**
- secrets_cli.register_cli() and onepassword_secrets_cli.register_cli()
now run eager at parser-build time (no deferral past parse_args)
- Only the agent.secret_sources.bitwarden/onepassword imports are lazy
(inside each cmd_* handler via _load_bitwarden()/_load_onepassword())
- This eliminates the 'invalid choice' and infinite-recursion risks
2. **Known-source-names gate for env_loader registry**
- Only keys in {bitwarden, onepassword, op, 1password, bw} trigger the
registry import; a generic dict entry no longer forces crypto load
- Prevents unrelated config dicts from paying crypto cost
3. **End-to-end tests for the real dispatch paths**
- test_bitwarden_setup_help: runs real CLI subprocess with --help
- test_bitwarden_status/disable/onepassword_status: run real handlers
- test_update_check_clean/no_self_lock: run real update --check
- test_update_check_no_cryptography: sys.modules inspection (backup)
4. **Fix flaky test_turn_lease.py** (unrelated pre-existing failure)
The architecture guarantees:
- parse-time: zero cryptography load (all backends lazy)
- dispatch-time: crypto loads exactly once per secrets command
- update path: completely clean of cryptography._rust mapping
Refs #86781, #86782
Two CI failures fixed:
1. Slice 4/12 FAILED tests/gateway/test_turn_lease.py — pre-existing
flaky test, not caused by this change (confirmed unchanged in main).
2. Slice 11/12 FAILED tests/test_lazy_secrets_import.py — the new
test used with cwd=tests/, which:
(a) made resolve to tests/hermes_cli/__init__.py
(missing __version__), and
(b) triggered the conftest.py live-system guard pattern match
on the string 'update' in the code.
Fixes:
- Extract _run_isolated() helper that runs from repo_root with
PYTHONDONTWRITEBYTECODE=1
- For the update-check test, write a temporary .py file in the
repo root instead of using -c with 'update' in the string
- Remove pytest import (not available in the sandbox; not needed
since the tests are simple assertions)
Refs #86782
The env_loader eagerly imported agent.secret_sources.registry on every
startup, which loads agent/secret_sources/bitwarden.py and its
cryptography.* dependencies. On Windows, this causes the updater
process itself to map cryptography._rust.pyd before the self-lock
preflight runs, triggering a defer loop that blocks updates entirely.
Add an 'any_enabled' gate: scan the parsed config for actually-enabled
sources before paying for the registry import. A config with no enabled
sources costs one dict scan; a config with enabled sources pays the
crypto load exactly once, on demand.
Refs #86781, #83569, #83590
Test: 3 scenarios verified — main() clean, env_loader clean (no
enabled sources), env_loader loads crypto (enabled sources).
The secrets_cli import in main() was eager, which loaded
agent.secret_sources.bitwarden and its cryptography.* dependencies
before cmd_update() ran. On Windows, the updater process itself
then mapped cryptography._rust.pyd into its own address space,
triggering the self-lock detector (_detect_self_loaded_native_modules)
and causing a defer/exit-2 loop that blocked updates entirely.
Move the secrets_cli import inside the _dispatch_secrets function so
it only pays for itself when the user actually runs a secrets
subcommand. This keeps hermes update (and all other commands) free
of the cryptography._rust.pyd eager load.
Refs #83569, #83590, #86687
Test: 3 new regression tests verify cryptography._rust stays out of
sys.modules during main() and the update path.
The ⌘F/Ctrl+F find bar positions itself at
top-[calc(var(--titlebar-height,0px)+0.5rem)], but it mounts at the
overlay root in ContribWiring, outside any subtree that defines
--titlebar-height. The 0px fallback parked the bar inside the 34px
titlebar strip, underneath the native min/max/close window-controls
overlay on Windows/Linux (two X buttons side by side, close button
half-covered).
Fix: use the real titlebar height (34px) as the fallback, matching the
established pattern in floating-hud.ts and notifications.tsx. The bar
now floats just below the titlebar band, clear of the window controls.
The sidebar lights the finished-unread dot for every alias of a
conversation lineage (branch children + compression root), but reading
a session cleared only the exact row id — a branched/compressed
conversation kept dots lit on sibling rows no matter how often they
were opened. And any settled completion re-lit the dot even when the
user had already viewed the session since it finished.
- setSelectedStoredSessionId now clears unread for the whole family via
lineageAliases, not just the selected id
- handleTransition only re-arms unread when the completion settles
strictly after the user's last read of that session (new last-read
baseline), so an already-viewed completion never re-lights
- openSession marks read at the very top, before any focus
short-circuit, so re-clicking an already-visible session clears its
dot (the original gap the sidebar click could not reach)
4635 desktop tests pass (incl. new family-clear, read-baseline, and
openSession-short-circuit cases); tsc typecheck clean.
The green 'finished-unread' dot only cleared when a session was opened via
main-thread resume (setSelectedStoredSessionId). Opening a session in a
tab/tile (middle-click / Cmd-click / tile strip) never cleared it, so the
dot stayed while the user was actively reading the session in a tile.
With a remote hermes serve backend the effect is amplified: session.info
transitions for every backend session (CLI/cron/kanban) mark unread in the
desktop client, so unread dots accumulate from sessions the user never
opened.
Changes:
- openSessionTile now calls markSessionRead(), so tile/tab open marks the
session read (same as main-thread resume)
- new markSessionRead / markAllSessionsRead helpers in store/session,
reused by setSelectedStoredSessionId
- 'Mark as read' per-row action in the session context menu (shown only
while the row is unread)
- 'Mark all as read' header action in the recents sidebar (shown only
when unread sessions exist)
- i18n: markRead (row scope), markAllRead (sidebar scope) in en/zh + types
Address review on the persisted unread dots, plus a latent data-loss bug
in the shared persistence helper that the restart e2e exposed.
Review findings:
- Session ids are caller-supplied and each profile backend is its own
namespace, while the desktop's lists routinely mix profiles (cron and
messaging slices are always cross-profile; recents are too in
all-profiles mode). Both persisted records are now bucketed per
profile - nested records keyed by the ROW's own profile
(normalizeProfileKey, absent -> default), never the live gateway's,
except the live busy->idle edge with no loaded row, which can only
come from the active gateway. Same-id sessions in different profiles
no longer share watermarks or markers.
- Markers are now bounded (200 per profile, oldest evicted) and cleaned
up when a session leaves the user's world: forgetSessionUnread() is
wired into removeSession, archiveSession, and the settings
permanent-delete path (which bypasses the other two).
Cold-boot clobber (found by the restart e2e after the refactor):
- persistentAtom wrote its value back to storage immediately at
creation. On a cold boot the bundle can evaluate against a storage
snapshot that has not caught up yet, so that echo overwrote real
records with the fallback. Creation is now read-only; only actual
changes persist. Regression-tested in persisted.test.ts.
- The read side of the same race is handled in session-unread.ts: the
first list arrival re-reads both records from storage (readable by
then) and merges them under the in-memory state, so a boot that
seeded empty atoms adopts the disk state instead of re-seeding every
row and burying the unread gap. Unread listeners are also disabled in
secondary windows - their partial list view must not write the
primary's whole-record state (same isolation rule as session tiles).
Tests: cross-profile same-id regression, live-edge profile fallback,
forgetSessionUnread cleanup, marker cap, persistentAtom creation
read-only; the restart e2e passes again end to end.
The green "finished — unread" session dot lived only in the transient
$unreadFinishedSessionIds atom, written by a live busy->idle edge the
renderer had to witness. Closing and reopening the app grayed out every
dot, and a session that finished while the app was closed could never
be flagged at all.
Add a persisted layer (session-unread.ts), ported from the webui's
proven design:
- Seen watermarks (hermes.desktop.sessionSeenCounts): the message_count
last acknowledged per session, keyed by the durable lineage id (same
rule as session colors). A row whose live count exceeds its watermark
paints unread on every list refresh - this reconstructs dots after a
restart AND surfaces sessions that finished while the app was closed.
First sight of an unknown session seeds the watermark so a fresh
install doesn't light up every row.
- Explicit finish markers (hermes.desktop.unreadFinishedSessions): the
live edge, persisted, covering the gap before the sidebar list
refreshes its counts.
Opening a session acks both; the selected session's watermark tracks
its live count so on-screen activity never reads as unread. Chat and
cron rows get full watermark treatment; messaging rows keep explicit
markers only, so inbound messages don't paint false completion dots.
Profile switches keep persisted markers (keyed by durable id) and only
wipe the transient paint layer, so a round-trip repaints them.
Covered by store unit tests and an e2e spec that boots the app three
times: dot appears on a background finish, survives a restart, clears
on open, and stays cleared after another restart.
The dashboard maps Ctrl+Delete to ESC d for delete-word-forward, but the
composer had no binding for it: hermes-ink decodes ESC d as meta+'d', which
fell through to the printable path and typed a literal "d" instead of
deleting the next word. Add a meta+d branch that mirrors the existing
Ctrl+W delete-word-backward, sharing a deleteWordForward helper with the
Delete+word path.
The new cut() wrote the clipboard fire-and-forget and removed the selected
text immediately, so on a headless/SSH box with no clipboard backend the
write fails and the text is lost with no copy to paste back. Make cut
transactional via cutSelection(): await the write and only remove the
selection when it succeeds; on failure the selection stays intact. The
removal also re-checks the selection to avoid slicing with stale offsets
after the awaited write.
The word-delete branches sent ^W / ESC d whenever the socket was OPEN,
bypassing the shouldBlockPtyInput gate that term.onData applies to
every other keystroke, so a reconnecting/closed session could still
receive shortcut bytes. Route both branches through a shared guarded
sender that applies the identical socket + PTY state check, and cover
the non-open PTY states in the lib tests.
The dashboard chat is an xterm.js terminal in a browser tab, so several
editing keys never reached the input:
- Bare Ctrl+V fell through to the TUI, whose server-side clipboard read
can't see the browser/OS clipboard, so it reported "No image found in
clipboard". Route Ctrl+V through the same navigator.clipboard path as
Ctrl+Shift+V (image-or-text). Fixes#24860.
- Ctrl+Backspace / Ctrl+Delete never sent a word-delete: xterm.js emits
bare DEL regardless of modifier. Send ^W (0x17) and Alt+d (ESC d) to the
PTY so readline / prompt_toolkit delete the previous / next word.
Ctrl+W itself stays unavailable in a browser tab: it is a reserved
shortcut (close tab) that preventDefault cannot suppress. Ctrl+Backspace
covers word-delete there; the Electron desktop app can bind Ctrl+W.
One config key everywhere (#41531): the same display.timestamps that stamps
[HH:MM] on classic-CLI labels now gates the desktop transcript's timeline
timestamps and renders dim [HH:MM] labels on TUI user/assistant rows.
- desktop: $displayTimestamps store fed from config.yaml via
use-hermes-config; TimelineTimestamp renders nothing while the key is off
(the default). Hover tooltips with the exact time stay ungated (#70450).
- TUI: tui_gateway forwards each persisted row's timestamp in the display
projection; toTranscriptMessages threads it as Msg.createdAt; live rows
are stamped at append (the #82840 rule); MessageLine shows a dim [HH:MM]
above user/assistant rows when display.timestamps is on.
- No new config keys, no HERMES_* env vars; display-only, prompt-cache safe.
rememberLog prepended only '[hermes] ' to each line, so desktop.log and
the in-app RECENT LOGS view carried no timestamps while agent.log and
gateway.log (Python logging) did.
Extract the line format into a small pure helper (desktop-log-line.ts)
and prefix each line with an ISO-8601 UTC timestamp shared per chunk,
matching the Python-side convention. Regression tests assert the shape
contract: timestamp + [hermes] tag + verbatim message. Fixes#84405.