The multi-connection registry (Settings -> Connections) shipped with no
entry point outside the settings nav, and the product-owner report was
blunt: 'I didn't see any obvious way to hook up multiple gateways.'
- Profile rail: a plug pill pinned beside Manage ('Connect another
Hermes gateway...') deep-links to /settings?tab=connections. Always
visible, including for single-profile first-run users.
- Command palette: Settings -> Connections is now a searchable entry
(keywords: add gateway, remote, ssh, cloud, instances, registry).
- i18n: profiles.connectGateway added to en/types/zh; other locales
fall back through defineLocale.
- Tests: profile-rail-connect.test.tsx covers the deep link and the
single-profile visibility guarantee.
Consult messaging and cron caches before the by-id fallback so opening a sidebar row neither depends on a redundant network lookup nor duplicates it into regular recents.
Co-authored-by: protas-box <protas.box@icloud.com>
Key resolved platform totals by Desktop profile and source so profile switches neither inherit another profile's count nor discard a count that was already resolved. Keep the full reset for connection configuration changes.
Co-authored-by: frendo <frendo.wu@gmail.com>
Complete the sidebar profile-scope contract across remote Electron routing, older-backend fallbacks, standalone messaging refreshes, and pagination. Reject stale profile responses and keep the explicit all-profiles view unified.
Co-authored-by: 墨綠BG <s5460703@gmail.com>
Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
MCP server problems (expired OAuth tokens especially) were only
discovered when the user visited the MCP page and a probe ran. Now a
renderer-side background checker (store/mcp-health.ts) sweeps the
active profile's enabled HTTP/SSE MCP servers on gateway connect and
every 30 minutes, and fires an in-app notification with a "Sign in"
action ("<name> MCP needs re-authentication") that navigates to the
MCP page with ?server=<name> so useDeepLinkHighlight focuses the
server and its Authenticate button. Navigation only — OAuth flows are
never auto-launched.
stdio servers are deliberately excluded: probing a stdio server SPAWNS
a local process, so a background timer must never touch them. Only
url-shaped servers (where OAuth expiry lives) are swept, sequentially.
The tab's probeCache/serverFingerprint/probeKey/NEEDS_AUTH_RE moved to
a shared lib/mcp-probe-cache.ts (behavior identical) so the page and
the checker share one probe cache and its 5-minute TTL — neither
surface re-probes what the other just learned.
Notifications fire only on a TRANSITION into needs-auth/error (pure
state machine, unit-tested), hard-capped at one per server per app
session, keyed per profile. Profile switches drop pending timers and
re-arm for the new profile; sweeps never run while the gateway is
disconnected. No new config knobs. i18n keys added across
en/zh/zh-hant/ja/ar + types.
Adds hermes://mcp/install?name=NAME&config=B64 (base64url or standard
base64 JSON), mirroring Cursor's mcp/install deep link, so vendors and
docs can offer an "Add to Hermes" button.
- Electron: the existing generic hermes:// handler already forwards
{kind, name, params}; only its comment is updated (no new handler).
- Renderer: use-desktop-integrations routes kind=mcp/name=install into
a pending-install store; a new confirmation dialog shows the server
name and the FULL pretty-printed config (attacker-controllable input),
with a prominent caution for stdio command entries. Nothing is written
until the user confirms; existing names require a rename or cancel.
On confirm the server is merged over a fresh fetch of the current map
via saveMcpServers, then navigation lands on /skills?tab=mcp&server=…
so useDeepLinkHighlight focuses the new row.
- Validation: name ^[A-Za-z0-9._-]{1,64}$; config must decode to an
object with a string http(s) `url` or a string `command` (never both);
payloads over 32KB rejected; failures surface as a toast.
- Pure parser in src/lib/mcp-deeplink.ts with unit tests (url shape,
command shape, bad base64, non-object, javascript: URL, oversized).
- i18n keys in types + en/zh/zh-hant/ja/ar.
- Docs: "Add to Hermes link" section in the MCP config reference.
Desktop/TUI count full displayed lineage after compression, but
prompt.submit validated truncate ordinals against tip-only history.
Translate via display_history_prefix and recover stale 4018s on Desktop.
Co-authored-by: Cursor <cursoragent@cursor.com>
Each configured server row on the MCP Capabilities page now shows what it
costs and whether it earns its keep:
- ~per-call token estimate of the server's tool schemas, summed over ENABLED
tools only (ceil(schema_chars/4) via the existing include/exclude filter)
- 30-day usage count from getUsageAnalytics(30), cached per scope profile
like the Toolsets tab's toolCallsCache, mapped to servers via the
mcp__<server>__<tool> registry-name convention (tools/mcp_tool.py)
- a subtle muted "unused" pill on enabled, probed-ok servers with nonzero
schema cost and zero 30-day uses — never a dialog
Backend: the /api/mcp/servers/{name}/test probe now fills an additive
per-tool `schema_chars` (length of the SAME converted registry schema the
agent registers). Older backends omit it → renderer shows counts only;
older renderers ignore the extra key. Display-only: nothing changes what
schemas are sent to models, no config knobs.
i18n keys (costTokens/usage30d/unusedPill) added to types/en/zh/zh-hant/ja
(ar inherits en via defineLocale overrides). Pure math lives in
lib/mcp-cost.ts with unit tests; Python wire shape pinned in
tests/hermes_cli/test_web_server_profile_unification.py.
Replace the fixed 500-message REST hydration (getLatestSessionMessages)
with a 120-row newest-first tail page. When the page comes back full, a
new per-session tail store records "possibly truncated + next offset";
"Show earlier" — once the DOM budget and the in-memory store window are
both exhausted — fetches the next older page via the new
getOlderSessionMessages helper (order latest + offset, matching the
backend's back-from-newest paging semantics) and prepends it to the
session store, deduped by durable row id and race-guarded against
session switches. Legacy backends without pagination metadata fall back
to the one-shot full transcript and retire the action.
Tail-page refreshes (background sync, post-turn rehydrate, re-activate,
cold-resume prefetch) graft the refreshed tail onto any backfilled
prefix instead of clobbering it, preserving reference identity on
no-ops. includeCompacted stays on every read — compaction-archived rows
remain part of the durable display history.
PowerShell 5.1 cold starts take 2.4-8s on affected Windows hosts, so the
shared 3s execText timeout hard-failed the parent start-marker probe for
any PID that still needs the PowerShell path (e.g. backend children).
Make execText's timeout overridable and raise the marker probe to 30s.
Fixes#87169
The composer's global Esc-to-cancel listener (useComposerEscCancel) fires
whenever the turn is busy and the active composer matches — but overlays
(Settings, Command Center, agents, cron, …) cover the chat while the
composer stays mounted and 'active' beneath them, so pressing Esc on any
of those pages interrupted the session the user wasn't even looking at.
OverlayView's own escape-layer Esc-to-close fired too, but the stream was
already dead.
Stand Esc down with composerFocusBlockedBySurface() — the same signal the
type-to-focus path uses (BLOCKING_OVERLAY includes OverlayView's
[data-overlay-surface] marker). Esc on an overlay now closes the overlay
via its escape layer instead of canceling the stream beneath it.
Fixes#82618
reapOrphans() treats any recorded backend with a matching process identity
as orphan-reapable — including a backend owned by another live instance. The
ownership file is shared across instances, so a second launch that reaches
reap (even without the lock) SIGTERMs the running instance's backend.
Claims now record the spawning Electron (parentPid + parentStartMarker, the
same values already passed to the backend as HERMES_PARENT_PID /
HERMES_PARENT_START_MARKER), and reapOrphans() skips any entry whose parent
is still running. Even a second instance that wins a stale lock can never
kill a live instance's backend. Legacy entries without parent data keep the
old behaviour.
Known tradeoff: a parent-liveness probe failure preserves the record, so a
genuinely orphaned backend under a still-running parent is leaked until it
dies naturally. That is preferable to killing a live instance's backend.
Tests: parent-aware reap in backend-ownership.test.ts (live parent
preserved, dead parent still reaped, probe failure preserved, parent
identity round-trips through claim/parse).
startHermes() is the only entry point that can reap, spawn, claim, and
therefore destroy a backend. Belt-and-suspenders on the exact killing line:
even if some future path reaches it in a lock-losing process (a refactor, a
dev harness, a race), the instance stays inert — no reap, no spawn, no
claim — instead of SIGTERMing the running instance's backend (#87295).
app.quit() does not stop a lock-losing instance from reaching whenReady:
the before-quit teardown coordinator defers the quit (event.preventDefault
+ async backend shutdown), and ready fires in that window. The losing
instance then runs the full startup whose reapOrphans() SIGTERMs the
running instance's live backend (#87295).
The lock-loser holds no state and no backend — requestSingleInstanceLock()
has already delivered the argv to the primary by the time it returns false —
so there is nothing to clean up. app.exit(0) terminates immediately, before
ready, so a second launch routes into the running window and never touches
backend machinery.
Review NIT: a malformed stored binding of just 'mod'/'ctrl' (never
produced by comboFromEvent) could pass the shape-only mod/ctrl check.
Reject bare-modifier bases in actionAllowedInInput and pin it in the
suite.
#86586 replaced the combo-based input gate (any Cmd/Ctrl chord fires
while typing) with an action allowlist that dropped session.new and
every other mod-chord not explicitly listed. ⌘N/⌘T/⌘⇧N and friends
became dead keys whenever focus was in the composer.
Restore the pre-regression rule: primary-modifier chords stay global
even in text fields; the allowlist now gates only bare/Shift/Alt combos,
so rebound letter keys can never hijack typing. Text-navigation chords
(Ctrl+Arrow/PgUp/PgDn) still stay with the input.
Plugins deleting profiles via `cli.exec ['profile','delete',…]` bypass the
Electron-side DELETE /api/profiles interception (prepareProfileDeleteRequest),
so a live pool backend — e.g. one the roster's hover pre-warm just woke —
holds the profile dir open and the renderer's reconnect respawns it
mid-delete, recreating the directory (#52279). Bot Mode's right-click Delete
hits this every time because right-click hovers the row first.
Add host.deleteProfile(name) to the plugin SDK: routes through the same
teardown-routed REST path core's DeleteProfileDialog uses (backend teardown
first, next request routed away), rejects on 'default', and re-homes the app
to the default profile when the deleted profile was the live gateway's —
mirroring the core dialog's ordering.
Reported by @BkashJosi (Bot Mode: deleting a bot errors while its session
is awake).
The MCP tab's left column previously split the configured fleet and the
Nous-approved catalog behind a Servers/Catalog tab toggle. Installed
entries appeared in both views and the install button lived a tab flip
away from the list it fed.
Now one scrolling column: configured servers (live status, toggles,
probes) on top, a Catalog section below offering only entries not yet
installed. Installing moves the entry up into the fleet list; the
zero-servers empty state keeps the catalog visible beneath it instead
of hiding it behind a full-page invitation.
Bot Mode's Advanced view embeds this same McpTab via the plugin SDK, so
the unification mirrors there automatically.
- removed leftView state + TextTab toggle; section headers reuse the
existing tabServers/tabCatalog strings (no i18n changes)
- availableCatalog memo filters installed/name-clashing entries
- catalog memoized to satisfy react-hooks/exhaustive-deps
Switching to a profile with no provider configured popped the blocking
onboarding overlay (Nous Portal / provider picker, "gateway isn't
ready") the moment the profile's runtime info arrived — punishing the
user for merely looking at an unconfigured bot/profile.
The passive credential_warning (session create/activate/resume info,
stream heartbeats) is now stashed instead of opening the overlay.
The submit path consumes it when the user actually tries to chat and
opens onboarding then, before the doomed send; the draft stays in the
composer. A warning-free session event clears the stash, so healed or
switched-away profiles never fire stale onboarding. Turn-error paths
(a real failed send) still open onboarding immediately, unchanged.
The Skills tab's three panes are now all drag-resizable:
- List/detail column seam: MasterDetail grows an optional resizeId that
turns the seam between the rail and the detail pane into a vertical
drag sash (same visual language as DetailPane's top-edge sash). The
rail width persists in the shared pane store under that id;
double-click resets to the default 0.75fr track. Skills and Tools
tabs share one id so the split stays consistent across tabs.
- Skills Hub section: the embedded hub picker's top edge is now a drag
sash — pull the hub pane up to grow it (the skills list above
absorbs the change). Height persists through the same pane store,
double-click resets, and the cross-origin iframe gets
pointer-events:none during the gesture so it can't swallow the drag.
Replaces the old native CSS corner-resize handle.
- The skill editor bottom pane already resized via DetailPane's sash.
MASTER_DETAIL_WIDE_COLS keeps its exported shape (MCP tab reads it) —
the --md-split var falls back to the declared track when unset, so
grids without a sash render exactly as before.
Consolidates the Capabilities view around the Skills tab and opens the
whole surface to plugins:
- Skills tab: the embedded Skills Hub picker now renders BELOW the
installed-skills list, expanded by default, with the update-all action
in its header. "+ Add to this Agent" picks are refused with a toast
when the skill is already installed in the scoped profile (name and
identifier both checked against the unfiltered list).
- Detail pane: shows the ENTIRE skill for any provenance — frontmatter
metadata rendered as key/value rows plus the full SKILL.md body —
via the existing GET /api/skills/content (new getSkillContent fetcher,
profile-scoped, cached per skill+scope).
- Browse Hub top-level tab removed (hub.tsx deleted, tabHub i18n keys
dropped); the hub lives inside Skills now. Legacy ?tab=hub URLs fall
back to Skills via useRouteEnumParam. Hub actions/store and all hub
REST fetchers are unchanged.
- SkillsView gains `embedded` (tab state local to the component instead
of the route ?tab= param) and `fixedProfile` (pins every tab to one
profile; the scope selector hides and the profiles roster fetch is
skipped) and is exported from @hermes/plugin-sdk — so Hermes-Bot-Mode
can render the real Capabilities surface inside its create/edit agent
Advanced section pinned to a bot.
- i18n: hub.alreadyInstalled (en + zh; others fall back).
Tests: index.test.tsx 8/8 (new: full-skill detail pane renders
frontmatter+body; picker refuses already-installed picks and is expanded
by default); toolset-config-panel 28/28; hermes-parity 11/11. Typecheck
(3 tsconfigs) + eslint clean.
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.