Three fixes for the "Install on this agent" pipeline, covering the whole
split-brain class between action-spawning endpoints and their status polls:
1. electron/connection-config.ts — the /api/actions/{name}/status poll family
now routes to the same backend as every action-spawning route. Before,
POST /api/skills/hub/install ran on the PRIMARY backend (scoped route)
while the follow-up status poll for a non-default profile routed to the
profile's POOLED backend, which never registered the dynamic action name
(skills-install-<slug>-<hash> lives only in the spawning process's
memory) -> 404 "Unknown action" toast even though the install succeeded.
POST /api/mcp/catalog/install joins the scoped table for the same reason.
2. src/store/hub-actions.ts — a non-zero subprocess exit now rejects with the
action log tail so the caller's catch toasts it. Before, a failed install
(scan gate, network, bad identifier) stopped silently: no toast, no row
flip, and the unchanged skills list read as "install did nothing".
3. src/contrib/runtime-loader.ts — a disk plugin copy shadowed by a bundled
twin now publishes a visible "(stale disk copy)" inventory row carrying
the folder path, instead of a console.info nobody sees. Stale
desktop-plugins/ leftovers from dev deploys are the same folders that
actively break the feature on shells without the bundled twin.
One renderer coordinator owns every right-click and replaces the
native Electron menus. Menus are assembled from what the click
landed on:
- Links and images get open/copy/save sections; chat links add the
reach-aware resolved-URL copy on remote gateways.
- Editables get spell-check suggestions (async-appended when
Chromium's facts arrive from main), cut/copy/paste, and select
all. Cut and copy need a selection; paste needs a non-empty
clipboard; select all needs field content. The verbs show their
accelerators instead of icons and dispatch a frame after the menu
closes, so the radix focus trap cannot steal the target. Select
all runs renderer-side, scoped to the field, because main's
selectAll acts on the focused frame and could grab the transcript.
- Terminals answer through registered xterm handles; the read-only
agent terminal hides paste.
- The in-app browser guest builds the same menus from the webview
tag's context-menu event: Chromium's editFlags gate the edit
verbs, spell-check rides the event, and Inspect element closes
every menu. Coordinates arrive as window-relative device pixels,
so the handler divides by the window zoom factor; guest edit
commands focus the webview first, because they act on the focused
webContents.
- Bare app chrome falls back to the window verbs.
Labels come from the locale files in all five languages. Main keeps
thin IPC verbs: edit commands, copy-image-at-gesture, spell-check
actions, and dictionary-add for guests (the tag has no session API).
The e2e spec exercises the real focus trap; it is blocked today by
the gateway-checking stall that also fails e2e/chat.spec.ts.
Adds a reviewable in-app install path for Hermes plugins:
hermes://plugin/install?repo=owner/repo (and Settings -> Plugins ->
Install from Git) opens a confirmation modal showing the repo identity
and source links, shallow-clones to probe for agent and/or desktop
plugin artifacts, lets the user pick components, then installs — agent
side through the gateway's new plugins.manage `install` action (wrapping
the existing dashboard_install_plugin), desktop side through a new
Electron git-install module with subdir-escape guards, a 60s clone
timeout, non-interactive git env, and insecure-scheme warnings. Never
auto-installs; hybrid repos get one dialog. Legacy plugin-agent /
plugin-desktop deeplinks route into the same modal.
Salvaged from PR #82735 by @serefyarar (net diff applied onto current
main as a single authored commit; the branch carried merge commits).
The preview screenshot PNG from the original branch was intentionally
not carried over — images live in PR bodies, not the repo.
The global SESSIONS sidebar only aggregated local profiles and v1 per-profile
remote overrides. Sessions living on v2 registry connections (remote/cloud/ssh
gateways) never appeared — the remote API returned the rows, but the renderer's
Sessions component received an empty array (#88880).
- electron/profile-session-routing.ts: fetchRegistrySessionRows reads each
CONNECTED registry gateway's session list (ssh backends natively, shared
remote/cloud hosts via one cross-profile aggregate with a legacy flat-list
fallback), tagging rows with connection_id + owning profile.
spliceRegistrySessionRows dedupes them into the unified list and extends
per-profile totals. Reads never pass include_hidden, so Bot Mode's hidden
canonical chats stay OUT of the global list, same as local sessions.
- electron/main.ts: mergeRemoteProfileSessions splices registry rows; the
/api/profiles/sessions[+/sidebar] intercepts also fire when registry
gateways are pooled (previously only v1 remote overrides). Only
already-pooled backends are read — a sidebar refresh never dials or spawns
a backend (the roster-respawn trap), and a dead gateway contributes nothing.
- types/hermes.ts + use-session-actions: SessionInfo carries connection_id;
resuming a registry-owned row activates its connection-scoped gateway
(ensureGatewayAgent) instead of a same-named local profile.
- store/gateway.ts: ensureActiveGatewayOpen rides out an in-flight secondary
activation (bounded 8s) instead of failing instantly — the Sessions "+"
during remote wake no longer errors "Hermes gateway is not connected".
Tests: 5 new registry-source/splice unit tests (tagging, shared-host
aggregate + legacy fallback, dead-gateway isolation, hidden-flag contract,
dedupe/totals) and a sabotage-verified activation-wait regression test.
Extends ctx.os.notify (the curated plugin OS door from #78685) with icon,
action buttons, and a serializable `activate` target. Body/action clicks
focus the window and navigate to the plugin's screen; activation paths
share one resolver (hermes-open-target.ts) with hermes:// OS deep links,
so `hermes://index-network/intent/1`, `/index-network/intent/1`, and
{ path, params } all land on the same hash-router route. Approval
notifications keep their existing session-scoped channel.
Salvaged from PR #84192 by @serefyarar (net diff of the PR branch applied
onto current main; branch carried merge commits so a single authored
commit preserves attribution).
Only main can answer whether a loopback address is reachable: it holds
the live SSH connection and the profile precedence that picks it. The
renderer asks over IPC instead of guessing.
Forwards are scoped to the connection that authorized them and dropped
whenever it changes, so a new host never inherits a tunnel into the old
one. A failed forward logs and returns the original URL rather than
breaking the load path.
Opens a local->remote forward on demand so a gateway-side dev server URL
resolves to the gateway, not the laptop rendering the webview.
One lease per remote port, reused across pages and expired after 15
minutes; the port allowlist is deliberately absent, since the transport
we are already authenticated on is the security boundary and a curated
list just means the next framework default silently fails.
Lease and capability shape adapted from #87243.
Co-authored-by: tuancookiez-hub <tuancookiez-hub@users.noreply.github.com>
The application menu is only installed on macOS (#77845) — everywhere
else it is set to null, which strips the role accelerators with it. A
menu-only Cmd+R meant Windows and Linux got no page reload at all.
Moves the claim into the same before-input-event hook Cmd+W already uses
for exactly this reason, and drops the accelerator from the menu item so
one keypress can't fire both paths on macOS.
Cmd+R over a focused page reloads that page instead of the whole shell,
a trackpad swipe walks its history, and a mouse's back/forward buttons
do the same. Shift+Cmd+R still force-reloads the window.
All three arrive as native input, so main decides. A webview guest is
out-of-process: when the pointer and focus are inside the page, no
renderer signal sees it — not activeElement, not the layout tree's
hover/focus ladder. Main asks Electron for the focused webContents and
acts on it directly, and only falls through to the renderer when the
gesture landed on Hermes' own chrome.
Also adds a Reload window command to the palette, since Cmd+R no longer
always means that.
Window Translucency shipped defaulting to Clear, which means the mode worth
finding is the one nobody sees — Glass is the better-looking half and the
reason the feature exists. A fresh macOS profile now starts with Glass
selected.
Nothing turns on. The intensity still defaults to 0, so the window is
byte-for-byte what it is today until the user moves the lever; the default
only decides which mode that lever will drive. windowOpacityFor stays 1 and
the window is still born with its opaque backing.
The one profile that must NOT flip is one already carrying a non-zero
intensity with no mode recorded: it predates the setting, has been rendering
as clear the whole time, and defaulting it to glass would change a window
someone deliberately tuned. normalizeMode takes the saved intensity and keeps
those on clear.
The renderer store was hand-rolling its own copy of this rule, so it now
routes through the shared normalizer and the two can't disagree.
The store's default test only passed because a beforeEach reset the atom
before it looked — it asserted the post-reset value, not the default, so it
would have stayed green through this change. It now snapshots the atom at
import time. All three mutations (default back to clear, escape hatch
removed, glass leaking onto non-mac) fail the suite.
Pre-handoff polish, no behavior change. The three translucency pickers
shared a verbatim five-line onChange (haptic, set, conditional pulse) —
one pickTranslucency helper now serves mode, frost and area. Dead
re-exports cut: the electron adapter no longer forwards renderer-only
symbols (TRANSLUCENCY_STEP, TranslucencyMode, GlassScope), and the store's
re-export block shrinks to what its call sites read; the store test takes
the shared constants from @hermes/shared/translucency directly.
Dragging the intensity slider was janky, and the four frost levels looked
nearly identical. Same cause.
At step=1 a drag emits ~100 updates, and every one of them did four
expensive things: a synchronous localStorage.setItem, an IPC wake, a
synchronous fs.writeFileSync in main, and setVibrancy + setBackgroundColor
on every open window. The vibrancy call is the one that also broke the
frost picker — it animates over 150ms, so re-issuing it per tick restarted
the animation before macOS could ever settle the material. The levels
weren't indistinguishable, they were never finishing.
The fix follows from a property the mode already has: under glass the
intensity is a pure renderer concern. windowOpacityFor returns 1 for the
whole range, so main has nothing to do on an intensity change at all.
- main diffs the incoming state against the current one and passes a
`changed` set to applyWindowTranslucency. An intensity-only change under
glass now touches zero native properties. Crossing zero still moves the
backing, since that flips glass on and off.
- main's disk write is coalesced onto a 250ms trailing timer, flushed on
before-quit. Only a cold launch reads that file.
- the renderer paints every tick (the field has to track the hand) but
coalesces the localStorage write and the IPC send onto a 120ms trailing
timer, flushed on pagehide.
Covered as contracts rather than timings: a table asserting exactly what
each kind of update changes natively (nothing, across the whole intensity
range under glass), and store tests that a six-tick drag produces one write
and one IPC call while every intermediate value still paints. Both
directions mutation-checked — restoring per-tick writes fails, and
debouncing the paint fails too.
Restores the four features SHL0MS built on #84329 that an earlier pass on
this branch had carved out, reconciled onto the shared translucency state
rather than the four-atom store they were written against.
- Frost picker. macOS exposes no blur-radius knob, so the vibrancy material
IS the frost control. The four in the ladder come from a pixel census on
macOS 26: the 14 Electron materials collapse to 9 distinct looks, and
these four are the widest separations that stay distinct in BOTH
appearances. sidebar/hud collapse into under-window when unfocused, which
is why they're deliberately absent -- normalizeMaterial rejects them, with
a test saying why.
- Sidebar-only glass, the Finder shape. <body> stays the single painter and
splits at the rail's live-measured edge with a hard gradient stop, so
there's no smear across the seam and no per-layer tint stacking. RTL
mirrors.
- Full-range tint. glassSurfaceKeep runs linear to zero, so the top of the
lever is bare untinted blur instead of stopping at a 30% wash. Text, cards
and the composer keep their own opaque tokens, which is what makes 100%
usable rather than unreadable.
- Peek. The settings overlay covers the very effect its slider controls, so
holding the slider ghosts the whole overlay layer and the live window
becomes the preview. A counter, not a boolean: a held drag and a timed
pulse from a picker click overlap, and the drag must not be cancelled by a
pulse expiring underneath it.
One change from the original: the peek's transition is scoped with :has() to
the overlay that arms it. The version on #84329 shipped a bare
`[data-overlay-surface] { transition: opacity 420ms }`, which gave every
overlay in the app -- command center, cron, agents, model picker -- a 420ms
opacity transition for the life of the process to serve one slider. Verified
by running the candidate rules through lightningcss: the scoped selectors
survive minification and no un-gated overlay transition remains.
visualEffectState is pinned to 'active' at each chat window, because several
materials collapse to a shared inactive look on blur -- without it the frost
choice silently erases itself whenever the user clicks another app.
Co-authored-by: SHL0MS <SHL0MS@users.noreply.github.com>
Every chat window constructor repeated the same three translucency-related
options -- the vibrancy material, the native opacity, the webContents
backing -- and the glass work was about to make that four things to keep in
step across three sites, with the cold-launch backing rule (omit
backgroundColor, never pass alpha) restated at each. chatWindowSurfaceOptions()
states it once, and the comment naming the HUD, pet overlay, quick entry and
wake indicator as deliberately-not-chat-windows lives with it.
The settings row reads the store's single object rather than two atoms.
The mapping had grown three copies: the clear-mode ramp in
electron/window-opacity.ts, a second clamp + mode normalizer in
electron/translucency.ts, and a third clamp in the renderer store, with a
"keep in sync" comment standing in for a shared type. Anything the two
processes must agree on -- what a mode is, where the lever clamps, what
intensity means as an opacity -- now lives in apps/shared/src/translucency.ts
and both ends import it.
electron/translucency.ts keeps only the piece that needs a BrowserWindow to
mean anything (the constructor backing), and re-exports the rest so main.ts
has a single import. Its relative specifier is deliberate: the electron
bundle is built by esbuild with no tsconfig path resolution, so a bare
@hermes/shared/translucency would typecheck and then fail to bundle -- the
same constraint connection-registry.test.ts documents for backendScopeKey.
The renderer's tsconfig drops its reference to the electron project. With
both projects claiming the shared file, that edge made the renderer resolve
it through the electron project's build output and demand a prior
`tsc --build`. Nothing in src/ consumes electron's emitted types, so the
reference bought nothing; `npm run typecheck` still checks both projects.
Constructor backgroundColor with alpha is silently treated as opaque on a
non-transparent window (Electron only documents constructor alpha with
`transparent: true`), so windows created while glass was persisted were
born with an opaque backing and the vibrancy material never showed —
exactly the state a user lands in after toggling glass on and relaunching,
or when the renderer re-reports the persisted state at boot (the IPC
handler correctly dedupes it, so no runtime swap ever fired).
Measured on macOS 26 / Electron 40 (side-by-side spike windows, pixel
luminance): ctor '#00000000' = flat opaque (lum 38, same as no glass);
omitting backgroundColor entirely = vibrancy visible (lum 57). Runtime
setBackgroundColor swaps are also LOST while a fresh process's compositor
is settling — swaps at 1s/3s/6s after creation never landed, including
from 'ready-to-show' and 'did-finish-load'; a 10s swap stuck. So cold
launches must be right at creation: windowBackingOptions() spreads either
{} (glass) or the themed anti-flash backing (everything else) into the
three chat-window constructors. The runtime swap path stays for live
Settings toggles, where the window is long settled.
Two opaque layers sat between the transparent page and the vibrancy material, so glass read as a slight lightening instead of a blur. The contrib shell root and the SidebarProvider wrapper paint full-window opaque fills above body; both are cleared under glass so body's tint is the window's only field paint. And Chromium composites the page against the window backgroundColor before macOS composites the window, so chat windows now get an alpha-0 backing when glass is active, at creation for cold launches and via setBackgroundColor on runtime toggles, scoped to registered chat windows so the transparent special-purpose windows (HUD, pet, quick entry) are untouched.
The translucency slider maps to native window opacity, which fades the whole window including text; over a busy wallpaper even low settings get hard to read. This adds a second mode to the same lever: Glass keeps the window opaque at the native level and instead thins the renderer's field surfaces (chat surface + sidebar) over the macOS vibrancy material every chat window already carries, so the desktop shows through as a smooth matte blur while text keeps full contrast.
One lever, two modes: Clear stays the default and is byte-identical in behavior; Glass is macOS-only (other platforms normalize to clear on both sides of the IPC). Mode persists next to intensity in translucency.json and localStorage, applies live to all open windows, and survives cold launch. Raised surfaces (cards, popovers, composer, terminal) keep opaque fills; the terminal surface is pinned because xterm resolves its background to a concrete color for its canvas.
setOpacity fades the entire window, text included, so the band a user can
still read in sits just under opacity 1. The linear ramp spent that band
in its first ~7% and the remaining travel on settings nobody can work in:
combined with the old 5% step, the lever had about two usable stops.
Curve the ramp instead. Both endpoints are bit-identical to the linear
mapping -- 0 is byte-for-byte the opaque window and 100 is still the 0.3
floor -- while the readable band now covers roughly the first third of the
travel.
Extract the mapping and its clamp into electron/window-opacity so it is
reachable from tests, following the electron/zoom split. main.ts keeps
ownership of the persisted intensity and passes it in.
Backend: /api/status now carries a stable random install_id persisted once
under the root HERMES_HOME, shared by every profile of the install.
Desktop: roster enumeration captures it per connection (TTL-cached probe),
buildAgentRoster collapses same-install rows with a deterministic canonical
pick (active > local > ssh > remote > cloud > earliest), the @name-device
handle rule runs after the collapse, and Settings → Gateways shows a
display-only 'Same backend as' hint. Backends without install_id bypass the
collapse (fully backward compatible).
Merge Settings → Connections into Settings → Gateway as a single
"Gateways" page:
- One nav entry ("Gateways"); the Connections nav entry is removed. The
legacy `?tab=connections` deep link stays in the route enum and
redirects to the unified page, and renders it in the interim frame so
saved routes/bookmarks don't break or flash.
- The connections registry UI (list, add, edit, delete, test, make
primary, update-all) moves into its own component
(connections-registry.tsx) composed at the bottom of the Gateways
page; connections-settings.tsx is deleted (nothing else imported it).
- The Add flow now offers ALL kinds: Local, Hermes Cloud, Remote
gateway, and SSH (previously only remote/ssh). The Local kind button
is disabled while the managed local entry exists; a hint points cloud
adds at the sign-in/discovery flow above.
- Duplicate prevention, enforced in the save path on both sides of the
IPC boundary (renderer inline error + main-process
normalizeConnectionInput throw): only one 'local' entry ever;
remote/cloud dupes keyed on the normalized URL (trim, strip trailing
slashes, lowercase) across both kinds; ssh dupes keyed on normalized
user@host:port + remote profile.
- The page-level "Applies to" ScopeChip row is gone. Per-profile
gateway overrides are now an explicit "Per-profile overrides"
subsection listing the default connection and each named profile,
with an Edit affordance that drives the same scope state machinery.
- Command palette and profile-rail deep links retarget the unified
page; i18n nav rename + new strings across types, en, ja, zh,
zh-hant, ar.
Tests: new connections-registry.test.tsx (registry UI + dedupe helper
units, including the inline duplicate rejection), electron
connection-registry.test.ts dedupe cases, and updated
profile-rail-connect test.
Follow-ups on the salvaged bounded-teardown fix:
- Replace the source-grep regression test (backend-lifecycle.test.ts asserted
main.ts source text) with behavior-contract tests against the extracted
createPoolStopper(): handle retention until bounded exit, stop dedup,
stopAll completeness, and the respawn-awaits-dying-backend gate.
- Gate the registry-local spawn path on the in-flight stop too (sibling site
the original PR predates).
- Route teardownPoolBackendAndWait through the shared stopper so profile
delete/rename teardown covers both local pool keys with the same bounded
semantics.
Follow-ups on top of the salvaged rename lifecycle:
- Retire both local renderer socket scopes for the OLD profile name before
the rename PATCH (rename-profile-dialog), mirroring the delete-path fix
from #88638 — a retained socket otherwise treats the rename's backend
teardown as a transient drop and its reconnect respawns the old-name
backend, whose ensure_hermes_home() recreates the directory the rename
just moved (#45474).
- Hold the profile deletion gate for rename requests too, so a concurrent
renderer reconnect entering ensureBackend() mid-rename cannot respawn the
old-name backend.
- Regression tests for retire-before-rename ordering and the
validation-rejected path.
Builds on the salvaged #88598 multi-source roster work:
- New Agent "Create on" picker (multi-connection registries only): the
profiles.create/configure/describe/mcp.catalog calls route to the picked
connection's backend via host.requestProfile route descriptors — the
window's active gateway never switches. Remote-target drafts discard via
the remote CLI; appearance/title write into the remote profile's ui_meta
and asset store; the taken-name check is scoped to the target machine's
roster; the live SkillsView Capabilities tab (active-gateway-bound) falls
back to the staged checklists for remote targets.
- Group chats can seat bots from other registered connections: member turns
(session.create/resume, prompt.submit, reply polling) route to each
member's own source through the new requestForBot helper. Remote member
descriptors persist on the room record (bot-meta is active-gateway-scoped
by design); watermarks/sessions key by source-qualified member keys so
same-named agents on two machines never share state; @name-device handles
resolve in room mentions; room lines and turn prompts badge cross-machine
speakers with their device.
- pidIsOurDashboard: a dead remote PID (ps exits non-zero) now reads as
FOREIGN instead of throwing "Could not verify SSH backend process
ownership" — the misleading error from #88625's secondary report.
Tests: cross-connection-bots.test.mjs (route descriptors, requestForBot
routing, member keys, disambiguated mentions, device badges, source
contracts); plugin suite 180/180; electron vitest 220/220.
Clicking Mac Mini / Spark (the device default row) passed the desktop
pool key as the remote Hermes profile. That profile does not exist, so
the chat never opened. Named profiles (bob, dixie) already sent a real
name and worked.
Also refuse to fall back to this-device's default chat pin when the
remote source did not actually become active.
A leftover sshConnections key made roster polling call
ensureRegistryBackend for every SSH source every ~5s. That spawned
remote dashboards, the mux died, and the renderer hit hermes:api
ECONNRESET / liveness-probe drops.
SSH inventory stays on the cached ls path only. Clicking a bot still
dials that one source.
A first inventory miss used to stick forever as a seeded default until
the user hit Test. Retry after 60s; a successful cache still never
re-probes, and Test still forces an immediate refresh.
Clicking the local agent left connectionId null, so the roster treated
the registry primary (often an SSH box) as active and dropped its
profiles while inventing a "This device" shadow of default.
Inventory undialed SSH sources with a cached ls of ~/.hermes/profiles
instead of requiring the window to switch onto that machine. Hostile
HERMES_HOME values are rejected before the listing command runs.
hermes update moves the source tree, but the desktop UI (including bundled
plugins like Bot Mode) is compiled into the app binary at build time. A
terminal-side update — or an in-app update whose bundle-swap leg failed —
leaves a new runtime under an old renderer: About reports the new Hermes
version while the sidebar is missing that version's desktop features
(the 'no Bots tab after the Bot Mode update' reports).
Detect the skew by comparing the packaged install-stamp commit against the
tree (git rev-list --count <stamp>..HEAD -- apps/desktop) and surface it:
- Settings > About: amber warning banner pointing at the updater
- macOS native About panel: suffix on the version line
- hermes:version IPC gains bundleOutOfSync / bundleCommitsBehind
Fail-quiet by design (no stamp / fallback stamp / git failure = no warning)
so dev runs and non-git builds never see a false 'install is torn' alarm.
All 5 locales covered.
Follow-up to #88523/#88542 from a community report (main agent listed twice,
both rows unnamed @default handles). Two distinct bugs, both specific to
remote-gateway-primary desktops:
1. Phantom "This device" default (electron): the roster enumeration dialed
ensureRegistryBackend for the registry's local entry unconditionally. On a
remote-primary desktop that forces resolveRegistryLocalRoute into the
forced-local branch — SPAWNING a local backend the user never asked for.
That backend enumerates a `default` profile, so a second default agent
appears AND the duplicate-handle rule forces -device suffixes onto the
real one. New shouldDeferLocalEnumeration() treats the forced-local route
as connect-on-demand (same courtesy as undialed SSH sources): the local
entry only enumerates when it is the delegate route (local-primary
desktops, byte-identical behavior) or a forced-local child is already
pooled (the user opened one).
2. Main agent renamed to a connection label (plugin): displayName keyed the
"show the connection label for a default row" rule off sourceScoped —
which annotation also sets on ACTIVE-source rows. A remote-gateway user's
main agent rendered as an IP-derived label (or bare handle) instead of
"Hermes"/their title. Key it off remoteSource: only THIN rows from
another source trade the friendly name for their source label.
Tests: shouldDeferLocalEnumeration route matrix (delegate always enumerates;
forced-local defers until a conn:local:: child exists; bare-key remote
descriptor doesn't count), displayName regression (active default stays
Hermes/title; thin remote default still shows its source label).
A URL-remote desktop whose PRIMARY profile has a per-profile remote
override lists the gateway's sub-profiles in the Bots pane, but
clicking one fell through the routing table's last case and spawned a
fresh local backend that shared nothing but the name (#88296).
resolveProfileBackendRoute now consults primaryRemoteActive: when the
primary's own backend is remote and the sub-profile has no stored
entry of its own, it routes through the primary gateway with profile
scoping (the same shared-primary flow global remote uses). Profiles
with their own local entries still pool locally.