The progress box's timer (turnStartedAt) was only seeded by the backend's
message.start event, so the submit RPC -> gateway accept -> WS round trip
(seconds under load) showed no timer at all. Seed the per-session clock in
seedOptimistic at Enter-time; message.start now keeps an existing seed
(?? Date.now()) so backend-originated turns still arm there, the active-
session mirror reuses the seeded value instead of snapping to accept-time,
and the abort/failure paths retire the seed with the turn. Adds a
console.debug submit->accept latency probe at message.start.
* feat(sdk): export route-decoupled McpTab + ToolsetConfigPanel for plugins
Runtime plugins can only import from @hermes/plugin-sdk, but the real
Capabilities components (the full per-toolset config panel and the full MCP
tab with OAuth/API-key setup) were never exported there — so a plugin could
only reimplement bare checkbox lists. Export both, route-decoupled so they
render safely outside the Settings react-router context:
- toolset-config-panel.tsx: useOptionalNavigate() wraps useNavigate in try/catch
(returns null with no router); the 'manage keys' deep link becomes a no-op
when embedded outside Settings. In-Settings behavior unchanged.
- use-deep-link-highlight.ts: useOptionalSearchParams() degrades to inert params
with no router (shared by 4 in-Settings callers incl. McpTab; identical there).
- sdk/index.ts: export { McpTab }, export { ToolsetConfigPanel }, export type
HermesGateway, and host.getGateway() returning the live $gateway instance
(McpTab takes a HermesGateway prop; plugins had no way to get the instance).
Both components are already profile-aware (profile?: null|string, #86548), so a
plugin can scope them to a specific bot profile. tsc: 0 errors (unchanged from
baseline). Enables Hermes-Bot-Mode to show the real Tools+MCP config in the bot
editor instead of checkbox stand-ins.
* fix(lint): sort the new SDK exports into perfectionist/sort-exports order
CI check:lint failed — the capabilities exports were grouped by comment instead
of interleaved into the file's path-sorted export list. Place them at their
natural-ascending positions: ToolsetConfigPanel (@/app/settings) after @/app/routes,
McpTab (@/app/skills) after @/app/shell/*, HermesGateway (@/hermes) after
@/contrib/types. Verified 0 adjacent-unsorted export pairs.
---------
Co-authored-by: Teknium <teknium1@users.noreply.github.com>
Phases 3-5 of the multi-connection campaign in one PR (per Teknium), on top
of the registry (#86679) and composite-key backend routing (#86839). Agents
from every registered connection are now usable side by side.
Renderer socket registry (phase 3):
- backendScopeKey moves to apps/shared (@hermes/shared) so main-process pool
keys and renderer socket keys derive from ONE rule; the electron module
keeps a byte-identical twin (tsconfig project boundaries) pinned by a
cross-copy contract test.
- store/gateway secondaries are scope-keyed: entries carry (connectionId,
profile); registry-scoped entries dial through getConnectionFor +
getGatewayWsUrlFor (fresh per-connect OAuth tickets against the right
host); events keep the bare profile plus a connectionId tag; touch/idle
keepalive uses the scope key; pruning keeps entries whose PROFILE has live
work. New ensureGatewayForAgent/openGatewayForAgent fall through to the
profile path for local/null sources — single-source behavior byte-identical.
Union roster + plugin SDK (phases 3+4, the Bot Mode door):
- hermes:agents:roster enumerates every connection's /api/profiles
concurrently (eager REST, lazy sockets; unreachable sources report per-row;
undialed ssh boxes stay connect-on-demand) and flattens through
buildAgentRoster — the @name-device duplicate-handle rule applied once
across all sources, pure + tested.
- SDK: host.connections(), host.agents(), host.warmAgent(),
host.ensureAgent() — feature-detected so plugins degrade cleanly on older
Desktop builds.
Fan-out updates (phase 5):
- hermes:connections:update-all dispatches hermes update to every eligible
source in parallel: local via the app's own applyUpdates pipeline,
remote/ssh via the backend's own POST /api/hermes/update; cloud skipped as
platform-managed (updateEligibility, pure + tested); per-connection result
rows so one dead box can't wedge the batch. Settings → Connections gains
the "Update all instances" button (shown with 2+ connections).
Also: getJsonForBackend/postJsonForBackend helpers with the token/OAuth-cookie
auth split; docs section updated from "staged rollout" to live behavior.
Tests: +4 pure cases (cross-copy contract, roster handles, unreachable
sources, update eligibility); FULL desktop suite 5115 passed; tsc renderer +
electron + shared clean; eslint clean.
The scoped find captures the foreground chat surface at bar-open time and
marks it `data-find-root`. A keep-alive tab flip — clicking another session
tab in the same stack — hides that surface with `data-pane-hidden` and
activates an unmarked one, without a pathname change, so the FindBar (which
only closes on a route change) stays open. Every subsequent query then
resolved no visible `data-find-root`, reporting 0/0 while the surface on
screen contained the text; Cmd+G / Cmd+Shift+G became no-ops.
currentFindScope now re-targets when every marked root is hidden: it
re-resolves to the foreground chat surface, clears the old surface's
highlights and marker (so they do not resurface when its tab is revisited),
stamps `data-find-root` on the new surface, and re-arms the re-render
watcher there. The normal case is unchanged — the scope stays on the
captured surface while it remains visible. Also restores the missing
trailing newline in store/find-in-page.ts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The scoped find walker wraps transcript text nodes in <mark> elements that
React does not own. Assistant responses stream through markdown-text.tsx,
which rebuilds the markdown DOM on every delta, and a new message is
appended whenever the assistant answers — so a re-render of a changed
region detaches the marks we inserted, dropping the user's highlights while
the bar stays open.
Watch the captured scope with a MutationObserver and re-wrap only when an
unmarked occurrence of the active query actually reappears. The observer is
gated behind a re-entrancy flag while the walker is mutating, coalesced to
one re-apply per microtask, torn down when the bar closes or the query
clears, and restores the active ordinal so a mid-stream re-render doesn't
reset the user's place to match #1. An append that adds no matching text is
a no-op; re-wrapping only fires when highlights genuinely went stale.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The fast path trusted that marks matching the query case-insensitively
implied the set still covered every match. A stale mark from an earlier
query (raced re-wrap, external DOM writes) can pass that per-mark
comparison while live occurrences stay unwrapped — stepping then walks
old highlights and the new matches never light up. Gate the fast path on
a read-only coverage check (same skip rules as the walker) so any
unmarked occurrence forces a re-wrap.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Triage found the findNext fast path compared each existing `<mark>`'s
textContent byte-for-byte against the typed query. Since highlightMatches
stores the ORIGINAL-CASE source slice, the first differently-cased match
('Hermes' for 'hermes', sentence-initial capitals, ALL-CAPS) failed the
check, forcing a full re-wrap on every Enter/⌘G: fresh `<mark>` elements
lose `data-find-active`, `previousActive` resolves null, and the active
ordinal stays 1 forever (backward steps land on the last match every
time).
Compare case-insensitively. Regression test pins the differently-cased
stepping behavior: same marks survive, count stays 2, ordinal advances
to 2.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Team review (#81778) found the walker terminated sibling traversal when a
text node was entirely consumed by a match: `current = textNode ? ... :
null` — after replaceChild detaches the original node, its `.nextSibling`
reads null, so `<div>needle<span>needle</span></div>` searching "needle"
matched only the first occurrence. Any JSX bare-text + element sibling
pattern (tool rows, attachment rows) could silently drop later matches.
Capture the text node's own `nextSibling` BEFORE the first replaceChild
and resume the outer walker from it on full consumption. The `after`
split path is unaffected (the trailing text node is re-scanned).
Regression test pins `<div>needle<span>needle</span></div><p>needle</p>`
→ 3 matches. 17/17 scope tests pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pressing Cmd/Ctrl+F in Hermes Desktop searched the entire webContents,
so every keep-alive chat surface matched — a user in chat A got hits from
chat B, every background tile, and every other pane rendering a transcript.
That's not what users expect from in-page find; it's a global search, and
it deserves its own shortcut.
Replace `webContents.findInPage` (whole-document, unscoped) with a
renderer-side DOM walker that captures the active chat surface at
bar-open time and only walks text nodes inside that subtree. The Electron
bridge is retained for secondary session windows (each window still
searches its own webContents); the primary window no longer drives the
bridge.
Scope decision. "Current view" is the foreground `[data-chat-surface]`
filtered through the existing pane-visibility helper, so an inactive
keep-alive tab can't accidentally answer the lookup. The scope is
captured once when the bar opens, not re-resolved on every keystroke,
so a mid-search route change can't silently re-home the highlights — the
FindBar's route-change cleanup closes the bar first.
Walker. Walks text nodes in document order, splits nodes that span a
match boundary, wraps each match in `<mark class="find-hit">`, and tracks
the active match by a single `data-find-active` attribute. Step uses the
existing marks when the query has not changed (no DOM churn) and re-
wraps when it has. Closing the bar unwraps every mark and normalizes the
parent text, restoring the original DOM byte-for-byte.
Tests. New `find-in-page-scope.test.ts` covers the walker in isolation:
multi-match wrapping, case-insensitivity, cross-node splits, script/style
filtering, no-self-match against the search overlay, scope retarget on
surface swap, and unwrap-on-release. Rewrote the FindBar store/component
tests to assert against the real DOM (marks + counts) instead of the
bridge mock — keeping the bridge mock only for the listener-refcount
cases that still cover the secondary-window path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The salvaged measurement effect only listened to window `resize`, so
opening, closing, or drag-resizing the files pane while the find bar was
up left the bar in a stale position (either still covering the pane's
header or floating mid-window after the pane closed).
Harden the measurement path:
- ResizeObserver on the aside tracks drag-resizes of the pane rail.
- A body-scoped childList MutationObserver catches the pane mounting or
unmounting mid-find-session and retargets the ResizeObserver when the
aside's identity changes (pane reopened, panes flipped).
- All triggers coalesce into a single rAF-batched measure; everything
(observers, listener, pending frame) tears down when the bar closes.
Also extends find-bar.test.tsx with three positioning tests: default
right-4 when the pane is closed, parked left of an open pane, and
re-measuring when the pane opens/closes while the bar is up.
The find bar is fixed to the top-right of the window, which overlays the
right sidebar's header and first file rows whenever the Files pane is
open. Measure the pane's live rect and park the bar just left of it;
fall back to the previous right-4 position when the pane is closed.
Closes#0
The before-input-event handler was registered on every platform, so on macOS
and Windows — where the renderer's rebindable view.findInPage keybind already
owns Ctrl/Cmd+F — the chord became un-rebindable and would double-open when a
user remapped or cleared it. Restrict the install to process.platform ===
'linux' (the only platform #81727 affects); mac/Windows keep the renderer's
own keybind registry path.
Also corrects the docstring: the prior claim that 'GNOME Files owns Ctrl+F at
the windowing layer' is not accurate (Nautilus does not install global grabs).
The interception layer varies by distro/desktop; the fix sidesteps it by
acting at before-input-event regardless of cause, which is what actually
matters. The handler function itself stays platform-agnostic and injectable
for tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
IS_MAC was baked from process.platform at import time, so the unit test
titled "Cmd+F (macOS primary accelerator)" could never hit the meta (Cmd)
branch — it actually sent Ctrl and only exercised the literal-Ctrl-on-macOS
fallback. Make the platform detector an injectable default parameter and:
- assert meta+Cmd on macOS opens the FindBar (the real Cmd path),
- pin the dual-channel design (literal Ctrl also accepted on macOS),
- cross-check that meta alone does NOT open on Linux/Windows.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`as unknown as import('electron').BrowserWindow` trips
@typescript-eslint/consistent-type-imports ("import() type annotations are
forbidden"). Use the top-level `import type { BrowserWindow }` instead,
which the lint gate accepts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On Pop!_OS / GNOME-based Linux distros the GTK compositor grabs Ctrl+F at
the windowing layer (GNOME Files owns it) before the renderer's keydown
listener can fire, so the renderer's own `view.findInPage` keybind is
silently dead even though the binding is registered (#81727).
Claim the chord in the main process via `before-input-event` — that runs
strictly before the compositor shortcut can grab it. The renderer's
find-in-page pipeline still owns the FindBar UI and the actual search; we
just guarantee the press reaches it. The pre-existing `view.findInPage`
keybind stays as the renderer-side fallback for environments where the
compositor doesn't intercept.
Accept the platform's primary accelerator (Cmd on macOS, Ctrl elsewhere)
AND literal Ctrl on macOS so a non-macOS layout still works.
27/27 unit tests pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.
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.
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.