Extends the Capabilities "Configuring:" profile selector (#86548) from
Tools/MCP to the WHOLE view — Skills, Tools, MCP, and Browse Hub now all
read and write the same selected profile — and brings Bot Mode's
one-click Skills Hub picker into the main Capabilities -> Skills tab.
Scope widening:
- skills/index.tsx: the selector renders once above whichever tab is
active. Skills list, toggles, bulk ops, editor, and archive are scoped
via the trailing-profile pattern; the skills RQ key gains the scope key.
Toolsets analytics (usage badges) load per scope. SkillsHub and the
hub picker are keyed/remounted per scope. Scope changes drop the open
editor/archive dialog and in-flight analytics (same hazards as an
app-wide profile switch); an app profile switch clears the override.
- hermes.ts: getSkills, setSkillEnabled, get/edit/deleteLearningNode,
getUsageAnalytics, and all seven skills-hub fetchers take the optional
trailing profile? (omitting preserves exact app-wide behavior).
- store/hub-actions.ts: runHubAction threads profile through spawn and
getActionStatus polling so install/uninstall/update and their logs run
against the scoped backend.
- hub.tsx: sources/search/preview queries keyed+scoped per profile;
install/uninstall/update/scan route to the scoped profile.
- archive-skill-confirm-dialog.tsx: optional profile prop.
One-click hub installs (from Hermes-Bot-Mode):
- skills/embedded-hub-picker.tsx: collapsible, resizable iframe of the
live Skills Hub (hermes-agent.nousresearch.com/docs/skills?embed=picker)
on the Skills tab. Origin-checked hermes-skill-pick postMessages route
through the standard hub action pipeline (background action, tailed
log, optimistic flip, Skills list + slash-completion invalidation),
scoped to the selected profile.
- i18n: skills.hub.picker* keys (en + zh; others fall back).
Tests: index.test.tsx — new case asserts picking a profile on the Skills
tab refetches skills scoped to it and routes toggles there (6/6);
toolset-config-panel 28/28. Full typecheck (3 tsconfigs) + eslint clean.
Client half of #87059. The gateway now fails ordinal-only truncation
closed for durable sessions (#87150), which turned the mis-aimed cut into
a visible edit-resend error for any bubble without a bound rowId (edit
after an interrupted turn, unstamped resume). Make the Desktop always
produce a durable address or degrade safely:
- runRewindSubmit: when a truncation request lacks a durable address,
resolve the target's row id by exact content against session.history
(which ships row_id per persisted row). Resolution is
exact-or-nothing: a unique text match wins; ambiguity is accepted only
when the target is provably the newest persisted turn (the
edit-after-interrupt shape). Anything else degrades to a PLAIN
resubmit — never a guessed cut. The client ordinal is dropped either
way (its space can diverge from the gateway's — the #87059 root).
- planReload/planRestore: degrade failed turns to a plain resubmit
(extends the #86623 pattern to regenerate/restore) and carry the
turn's persisted sourceText as the content key.
- rebindSurvivorRowIds: iterate the same failed-turn-aware ordinal
space as the truncate math.
- session-tile-actions: reload goes through the shared runRewindSubmit
primitive instead of a raw prompt.submit, so the tile surface gets the
same discipline.
A user turn whose submit failed keeps its optimistic bubble but never
reached the gateway, so counting it makes every later
truncate_before_user_ordinal overshoot the backend index (refused 4018,
regenerate dead for the rest of the session). Skip failed turns in the
one shared visible-user ordinal space (visibleUserMessageIndices) used by
truncate ordinals, ordinal->index resolution, and survivor-rowId
rebinding.
Based on #41275 by @vondelomlo, relocated onto the split
use-prompt-actions/ modules and widened from visibleUserOrdinal to the
shared index helper.
Each assistant reply now carries a small time badge below the message text
showing how long its turn took (message.start -> message.complete), so
users can gauge task latency at a glance without hovering.
The duration is computed renderer-side from the per-session turnStartedAt
timestamp the app already tracks and stamped onto the ChatMessage at
completion (successful and failed turns alike). It is not persisted
backend-side, so messages hydrated from history have no badge — matching
how reasoning-block durations already behave.
Also adds the assistant.thread.turnDuration i18n key across all five
locale files.
Follow-up hardening for the #74163 salvage: the no-payload settle gate used
turnStartedAt as "backend reported the turn live", but since the turn clock is
now optimistically seeded at submit (#86923), that signal is ambiguous.
Introduce ClientSessionState.turnLive, set on message.start, the running=true
session.info edge, and resume-onto-running paths; cleared by every settle.
The pre-start bail now gates on turnLive so a running=false heartbeat in the
submit gap still keeps the spinner up, while a genuinely started turn that
dies without a payload settles and unbricks the session.
A turn that finishes without ever producing an assistant payload never
reaches message.complete, so session.info with running=false is the only
event that can release it. The busy=false branch bailed out of the state
update whenever awaitingResponse was still set and no payload had been
seen, so awaitingResponse and busy stayed latched until the app was
restarted.
That is not a cosmetic indicator. The per-session busy flag is
authoritative for isTargetSessionBusy, so submitPrompt and the slash
dispatcher silently returned false: the user typed, pressed Enter, and
nothing happened, with no error. Per-session state does not self-heal on
a session switch, so the session was effectively bricked. It reproduces
on a gateway crash mid-stream, a provider error before the first delta,
and an agent-build failure.
The bail still has a real job: submit arms busy/awaitingResponse
optimistically, so a running=false heartbeat landing in the gap before
the turn spins up is a pre-start report, not a finished turn, and
settling on it would drop the spinner and re-open the send guard
mid-flight. Gate the bail on turnStartedAt, which is stamped only once
the backend reports the turn live and cleared by every settle: null means
no turn was ever reported running, so keep waiting; non-null means the
turn started and is now reported finished, so settle.
On recovery, catch up the surfaces the missing message.complete would
have refreshed. The sidebar refresh stays unscoped so a background
session's working dot clears without the user opening it, and it fires on
the recovery edge only because the unchanged-state guard short-circuits
every later heartbeat. The transcript hydrate is scoped to the active
session so an idle background session does not cost a REST call.
Follow-up on the cherry-picked gitlock work (#80501 by @RGerrish, covering
the #75133 / #75168 wedge first reported and fixed by @RelaxJonh):
- Drop the PR's ancestor-check halves in banner.py, update-count.ts and
main.ts: superseded by the compare-API status recovery that landed in
#86257/#86331 (ahead_by == 0 already reports local-ahead as up to date).
The salvaged update_cmd.py check path keeps main's compare-API structure
instead of the PR's tip-SHA-plus-ancestry print.
- Keep and wire clear_stale_git_locks() at the remaining wedge sites the
original PR targeted: hermes update apply, hermes update --check, and the
passive banner check.
- Add the desktop counterpart (electron/gitlock.ts) so checkUpdates() heals
the same wedge instead of reporting fetch-failed forever; mirrored
age + git-process guards; vitest coverage.
E2E verified: real --depth 1 clone with an aged .git/shallow.lock reproduces
"Unable to create '.git/shallow.lock': File exists"; clear_stale_git_locks
removes it and the fetch succeeds; a fresh lock (in-flight fetch) is
preserved.
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.