The two waitForHermesReady cloud-503 tests froze now() at 0, so the
readiness loop never crossed its deadline — the vitest electron project
hung for the full 20-minute CI budget. Advance the clock per poll like
the sibling readiness tests do.
eslint --fix output: blank lines before statements and the import-order
spacing in connection-config.test.ts that the check:lint gate rejects.
Formatting only — no logic change.
The original implementation classified 502/503/504 only inside the readiness
loop, but for OAuth-backed Cloud connections the WebSocket-ticket mint runs
before waitForHermesReady. A server fault there was wrapped by
gatewayTicketFailure into a generic message and the Cloud-down classifier was
never reached. This closes that boundary and fixes a latent regex defect.
- isServerSideHttpError: structured-first (err.statusCode for 502/503/504),
legacy 'NNN:' prefix as fallback, non-Error inputs rejected. Also fixes the
committed '\d' (double-escaped, matched a literal backslash) that made the
function never detect a status prefix.
- makeNousCloudBackendDownError: single factory for the actionable Cloud-down
error (isCloudBackendDown/statusCode/detail/cause), shared by both the
ticket-mint boundary and readiness exhaustion.
- main.ts: run the Cloud classifier at mintGatewayWsTicket before the
gatewayTicketFailure wrap; 401/403 still route to reauth.
- connection-config.ts: gatewayTicketFailure preserves an integer statusCode
from the source error; auth semantics unchanged.
- boot-progress/IPC: carry isCloudBackendDown and statusCode through
DesktopBootProgress so the renderer overlay (a PR-body promise) can key on
the structured result rather than re-classifying the message string.
Tests: backend-health (structured detection, non-Error rejection, factory
shape/cause/guards, legacy fallback), connection-config (statusCode preserve,
401/403 reauth, integer-only copy), and an OAuth ticket-mint integration
regression (Cloud 503 -> actionable Cloud-down; 401 -> reauth). Connection-
config suite 80/80 green; backend-health sync tests green; the async readiness
loop tests cannot run on this host (pre-existing local-run limitation) and are
the CI gate. PR #85373 (#85335).
When a Hermes Desktop connects to a Nous-managed cloud agent
(*.agents.nousresearch.com) and that backend returns HTTP 502/503/504,
the previous error message was the opaque generic 'Hermes backend did
not become ready: 503: ...' with no guidance that the cloud server
itself is down.
Add isServerSideHttpError and isNousCloudAgentUrl helpers and use them
in waitForHermesReady to detect this exact scenario. When triggered,
throw an error with the hostname, status code, and recovery paths:
check the Nous Portal, switch to Local mode, or reach out on Discord.
Also adds a isCloudBackendDown flag and statusCode property on the
thrown error so the renderer overlay can render specialized UI if desired.
Turn errors now carry a structured {layer, code, retryable} descriptor
(agent/error_surface.py) built from the same classifier the retry loop
uses. The tui_gateway stamps it on terminal error frames, retained
failed-turn snapshots, and resume replay; the Desktop error card renders
the layer title (provider / endpoint / streaming / auth / billing /
gateway / runtime / disk) plus matched actions: Retry, Switch provider,
Open logs, Copy diagnostics.
Older backends that omit the descriptor keep today's behavior (generic
title, string-sniff fallbacks) — the field is advisory on both sides.
Follow-ups to the salvaged WSL-bridge gating (#66447):
- wsl-path-bridge.ts: discard wsl.exe stderr so the 'WSL is not installed'
banner can never leak into an attached console on WSL-less machines (#80184).
- scripts/desktop-update/windows.ps1: add an explorer.exe-mediated detached
relaunch rung between the WMI attempt and the tethered Start-Process
fallback. When Win32_Process.Create fails (observed ReturnValue 8), the
Desktop no longer re-attaches to the hand-off console, so its stdout stops
flooding the window and the console can close.
Seed backend-mode state before creating the first window and update it after runtime resolution. Keep remote reconnects gated until a local backend is confirmed.
Cover Windows child-process suppression and bridge state transitions.
* fix(desktop): stop layering Windows glass windows, and don't make them transparent
A Windows chat window under glass rendered while focused and went dead the
moment it lost it. Two things put it on a compositing path DWM will not draw
acrylic behind, both no-ops that looked free:
`opacity: windowOpacity()` was passed on every window. Under glass on Windows
fade is 0, so the value is always 1 — but Electron's `SetOpacity` calls
`SetLayered()` and `SetLayeredWindowAttributes(..., LWA_ALPHA)` before it looks
at the value, and nothing ever takes `WS_EX_LAYERED` back off. A layered window
composites through the legacy redirection surface, which Windows documents as
mutually exclusive with `UpdateLayeredWindow`. Opacity is now only passed when
the state actually fades, and the runtime path keeps setting it for a window
that is already faded so it can still come back to opaque.
`transparent: true` was set on every glass-capable Windows chat window, on the
premise that DWM materials only reach the client area that way (electron#49443,
which was closed as need-info against an EOL Electron 28). They do not need it:
`IsTranslucent` answers yes off `background_material_` alone, which is what
gives the page its transparent default backing, and `SetBackgroundMaterial`
flips widget translucency live. Its one gate is a frameless window, and
`titleBarStyle: 'hidden'` already satisfies it. What `transparent` did add was
permanent — the widget pinned to kTranslucent for the window's whole life, so
even glass-OFF windows paid a DirectComposition redraw per frame
(electron#39895), plus the documented transparent-window limits, including that
a resizable transparent window is unsupported and breaks (electron#48421).
Both landed latent in #89837 and only surfaced when #90587 turned glass on by
default and dropped the opaque backing that had been hiding them.
* docs(desktop): name the one thing the opacity guard cannot undo
Electron exposes no way back off WS_EX_LAYERED, so a Windows window that has
been faded once keeps the layered compositing path until it is recreated. Not
opening the door on the default path is the whole of the fix; say so where the
guard lives rather than leaving a reviewer to work out the gap.
A name with no bundled brand glyph had nothing to render, and the renderer
could not go find one — CORS makes another origin's markup unreadable.
So resolution moves to the main process: read the page, collect every icon
it declares in its head and web manifest, rank them, sniff the bytes to
catch a challenge page served as image/png, and cache the answer per host
on disk. Only ever the site's own marks — a public icon service would
answer for the hosts behind a bot wall, but asking one means telling a
third party which tools someone is wiring up, so an unreadable site keeps
its monogram instead.
Messaging platforms, the MCP servers tab, and the connector surfaces each
drew their own square brand chip, and the three copies had already drifted
on radius, glyph size, and what an unknown name falls back to.
They now share one AvatarChip. Size and radius stay the caller's call;
everything else is fixed in one place, so a Slack row and a Linear card
wear the same mark. brandFor also stops caring how a name is spelled —
catalog slug, registry id, or display name all reach the same glyph.
The two deepEqual tests pin the exact env object, so the new spawn tag
field made them red. Assert the tag in both shapes and add direct
spawnTag() coverage (winms-derived seconds, dash fallback, non-winms
markers rejected).
Every long-lived Hermes process is now positively identifiable so reapers
never have to guess lineage from PPID archaeology or cmdline shape:
- hermes_cli/process_identity.py (new): HERMES_SPAWN tag build/parse,
spawn-ledger.json self-registration keyed on (pid, create_time) — PID
reuse cannot forge the pair — with #89298-style corrupt-file quarantine,
and a kill-on-close job-object self-attach (BREAKAWAY_OK preserved for
the existing CREATE_BREAKAWAY_FROM_JOB escape hatches).
- serve/dashboard (web_server.py) and the gateway entry point register
themselves at startup and attach to the job; Desktop legacy
HERMES_PARENT_PID/winms marker reused as spawner identity so lineage
works with every Desktop version.
- Desktop stamps HERMES_SPAWN on backend spawns (parent-process-identity.ts).
- hermes update gets a positive-identity rung ahead of the heuristic ones:
_ledger_reapable_backend_pids reaps holders the ledger PROVES are orphaned
backends (purpose reapable + recorded spawner provably dead) in ANY update
context. Ledger-unknown holders fall through to the existing rungs.
22 new tests, sabotage-verified.
parseBackendOwnership returned [] for unreadable JSON and reapOrphans
unconditionally rewrote survivors — one corrupt read replaced the roster
with [], permanently orphaning every backend it described. The sweep now
detects corruption, parks the file as .corrupt (evidence preserved), and
skips the rewrite; empty/missing files keep the legacy sweep behavior.
Three fixes from one remote-gateway (VPS) debug bundle, all live-reproduced
and re-verified on a headed Electron seat via CDP:
- Bots roster no longer shrinks during a gateway outage: source enumeration
is bounded (10s/source instead of wedging the roster IPC >30s behind a
dead dial) and a bounced remote source keeps painting its last-known
profile list (was SSH-only), so 4 bots never show as 2 mid-outage.
- Pool backend spawns that die before the child exists (forced-local spawn
of a profile that only exists on the remote) now log the failure to
desktop.log, and the profile-exists guard runs BEFORE the Starting line —
no more orphaned no-READY/no-exit spawn bursts in bundles.
- An SSH host-key change (VPS reinstall) is classified terminal like a
reauth rejection: it latches, the boot-failure overlay shows the
ssh-keygen -R guidance, and the renderer stops the infinite boot-retry
loop (one bundle had 157 consecutive failures over 2.5h). Reset/repair/
apply-config clear the latch; live-verified Retry-after-fix boots clean.
Remote-mode installs had every update affordance (About panel Update now,
⌘K Update Hermes, the update-ready toast) pointed at the BACKEND only, so
users updated their VPS forever while the desktop app itself sat weeks
stale — with no signal it was behind (the skew warning only fired the
other way). Reported by Santiago Sarceda: mac app on v0.20.0 kept
repro'ing UI bugs fixed on main because 'update' never touched the app.
- store/updates.ts: applyEverythingUpdate() orchestrates all targets —
active backend first (detailed progress), every other eligible
registered gateway via the existing Electron fan-out (cloud rows skip),
the client LAST (its apply relaunches the app). startActiveUpdate/
requestActiveUpdate route through it whenever more than one update
target exists; single-machine installs keep the one-button flow.
- After ANY successful backend update, the client version is re-checked
and a one-click 'Update desktop app' warning fires if the GUI is still
behind — the reverse-skew signal that didn't exist.
- electron: hermes:connections:update-all accepts optional excludeIds so
the flow doesn't double-dispatch the active backend / local runtime.
- i18n: 7 new updates.* keys across en/zh/zh-hant/ja/ar.
- docs: desktop.md Updating section + multi-connection guide.
- tests: 10 new cases (gating, ordering, exclusions, failure isolation,
memoization, nudge on/off).
The light default carries a single point of fade so the window edge reads as
glass rather than as paint. That point followed anyone who dragged the tint
to zero, leaving a window that asked to be opaque sitting at 0.9999.
Fade now applies only while glass is actually active, not merely selected.
Translucency was one number serving both appearances and both platforms,
resting at zero. A lever that starts at zero is a feature nobody finds, and
one number cannot serve four situations: a tint that reads as a whisper over
a dark palette is a milky sheet over a light one, and the same numbers that
read as frost on macOS vibrancy read as a washed sheet over Windows acrylic,
which composites its own tint in DWM before the page is drawn.
So the state splits. `mode` stays global — clear versus glass is a choice
about the window, not the palette — while the values resolve through a
ladder, per key: the appearance you are looking at, then a shared base, then
the platform default. Tuning light mode stays in light mode; an untouched
dark keeps inheriting. A v1 state lands in base, so a window someone already
tuned crosses the upgrade with exactly what was on screen.
Main reads the same defaults at window creation, because a window born
opaque cannot reliably be swapped to glass afterwards.
The chat backdrop goes off by default in the same pass: it was competing
with the glass field for the same surface.
saveGatewayFile rode the OAuth cookie partition even when hermes:api already
held a native bearer, so listing worked and Download 401'd.
Co-authored-by: 686f6c61 <github@00b.tech>
Downloads have to present the same bearer-vs-cookie choice as oauth REST.
A cookie-only save against a cookieless native session is the Files-panel 401.
Co-authored-by: 686f6c61 <github@00b.tech>
The HUD asked for vibrancy directly and always with the 'hud' material —
one of the two rungs the macOS census rejected, because it collapses into
under-window on blur and so changed the frost the moment another app took
focus. It also ignored the translucency setting entirely: Glass off still
frosted, and Windows got nothing at all.
hudFrostFor is the mapping for a transparent window, beside vibrancyFor in
the shared module both processes read. Two gates give it its answer: the
renderer's report that the band actually covers the window, and the user's
Glass setting. Off resolves to no material rather than a resting one, since
a transparent window has no opaque page to hide an unwanted frost behind.
Windows 11 rides setBackgroundMaterial through the same call, so the HUD
follows the frost ladder on both platforms. Main self-diffs and keys the
latch to the window, so a Settings change re-frosts a live HUD, a tint drag
touches nothing native, and a HUD respawned on another profile is not
mistaken for the window that already carried the material.
Deciding whether the OS can back glass needs os.release(), but every
Hermes window runs its preload with sandbox: true, where require is a
polyfill limited to electron, events, timers and url. The node:os import
threw before contextBridge ran, so window.hermesDesktop was never defined
and the app booted straight into "Desktop IPC bridge is unavailable".
Main already computes both verdicts, so preload asks for them over a
synchronous channel instead. No reply degrades to no glass, which is an
ordinary opaque window rather than a page thinned over nothing.
52 handlers move into five registrars — git, pet overlay, hud, fs and
terminal. Each takes injected deps (window handles, binary resolvers,
path hardening) following the existing electron/ module pattern rather
than closing over main.ts locals, and terminal-ipc returns its dispose
helpers so SSH teardown and app shutdown keep working.
Glass was macOS-only because it rode setVibrancy. Windows 11 22H2 has a
first-party equivalent in setBackgroundMaterial, so the mode now resolves
its backing per platform instead of per-OS-check: macOS keeps vibrancy,
Windows 11 gets DWM acrylic / tabbed / mica, and everything older stays on
Clear. No third-party native addon.
Two Windows-specific details the mapping has to respect. DWM only paints
the client area of a transparent window (electron#49443), so glass-capable
Windows chat windows are born transparent with the opaque themed
backgroundColor covering them while glass is off — a live Clear/Glass
toggle then needs no window recreate. And Windows exposes three backdrops
for four frost rungs, so the two heaviest both resolve to mica; the mapping
stays total so a frost saved on a Mac still renders.
Glass support is computed once from os.release() and shared: main uses it
for the persisted default and every window, preload publishes it to the
renderer so the UI can't offer a mode the window can't back.
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).