Drop two PrimaryProfilePin cases that only restate the constructor
defaults and blank-string normalisation, and the wiring-routing test that
froze POOL_LIMITS_SETTINGS_ROUTE to a literal string — a snapshot of the
constant, not a behaviour contract. The two kept pin tests cover the bug
(a live primary keeps answering for its booted profile after the stored
preference moves; teardown releases the pin), and the notifications tests
cover the toast action end-to-end.
`primaryProfileKey()` re-read active-profile.json on every call. The rail's
live workspace switch rewrites that file via `hermes:profile:remember`
WITHOUT re-homing the primary, so after a switch the routing table disagreed
with the running process: a request for the profile the primary actually
booted as (e.g. "default") no longer matched `primaryProfile` in
`resolveProfileBackendRoute`, fell through to the pool, and spawned a second
backend for the same HERMES_HOME.
The duplicate was keepalive-fresh so LRU eviction spared it, it burned a pool
slot, and with the default cap of 3 every further profile queued and failed
with `Local backend start for "<profile>" timed out while waiting for a free
slot` (repro in desktop.log: "default" spawned as a pool backend while the
primary "default" was still running; coder/qwen then timed out for 20+ min).
Snapshot the launch profile in `startHermes()` (PrimaryProfilePin.pin) and
release it in `resetHermesConnection()` so the next start follows the stored
preference again. The pin is a tiny pure module with tests; main.ts only owns
the file read and the two call sites.
Switching the pooled dispatch probe to /api/health (salvaged from #97914)
would 404 on every dispatch against a remote older than 0.19, retire the
tunnel and reconnect forever - the same storm #107997 describes, moved to
old backends. Fall back to /api/status on an explicit 404 exactly the way
the boot readiness probe already does (backend-health.ts). The legacy
fallback idea and its test are taken from #101976 (@edosulai); the rest of
that PR (timeout-tolerance streak, ServerAlive SSH options) is not adopted.
Co-authored-by: Edo Sulaiman <edosulai@icloud.com>
Cold /api/status through a Windows no-mux SSH forward routinely exceeds
the 2.5s dispatch budget, so Desktop retires a live tunnel and respawns.
Use the cheap /api/health route (5s, same as DEFAULT_HEALTH_PROBE_TIMEOUT_MS).
Background liveness still probes /api/status at 10s.
A 2500ms dispatch probe is shorter than quiet-box hermes serve cold-start (~6-8s), so a just-woken pooled backend always fails and reconnects. Reuse REMOTE_LIVENESS_TIMEOUT_MS (10s) for that probe.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(desktop): centralize guide handoff receipt reads
Resolve the guide receipt key and value together in setup-profile. Use the helper at all four read sites so connection scoping follows one implementation.
* fix(desktop): recover from unreadable handoff receipts
Memoize receipt reads and show Retry only for the error phase. Quarantine corrupt data before retrying, and resolve the guide identity when the failed request did not retain it so a fresh build can start.
Cover preservation of corrupt data and removal from the active receipt key with an invariant test.
* fix(desktop): validate persisted onboarding phases from one list
Derive OnboardingPhase and persisted-value validation from the same phase list so future phases survive relaunch. Verify every persisted phase reloads and an unknown value falls back to idle.
* fix(desktop): share window centering arithmetic
Extract centeredBounds and use it for onboarding boot and window growth. Keep the existing work-area clamps and coordinate rounding unchanged.
* fix(desktop): compute progress steps inline
Remove the ineffective ProgressCard memo because streaming flushes replace the messages array. Keep the same transcript scan and rendered steps.
* fix(desktop): center the free-tier status chip detail
Wrap the model label and sign-in badge in an inline flex span with a shared gap. This centers the badge beside the model text without changing other status-bar details.
* fix(desktop): derive the guide receipt key in one place
The Retry path spelled the key derivation out again because the read helper throws on a corrupt receipt before it can return the key. A separate guideHandoffReceiptKey serves both the reader and the quarantine, so the derivation has one home again.
* fix(desktop): keep the free-tier badge at its intended leading
Badge declares leading-none, but the class merger drops it behind the size variant's font-size class, so the badge inherits a 1.5 leading and renders 16px tall next to an 11px label. That height, not the inline alignment, is what read as a detached badge. Restating leading-none on the chip's badge brings it to 11.6px, inside the label's cap height. The Badge component itself is left alone; every other badge in the app has the same dropped leading and that is a separate decision.
- Multi-range requests (any comma) now fall back to a full 200 instead of silently serving only
the first part (RFC 7233 permits ignoring Range); documented + pinned by a test.
- Open the file first and fstat that handle, then stream from the same FileHandle, so
Content-Length and the bytes delivered come from one open file (no stat/stream race).
Handing the FileHandle (not the raw fd) to createReadStream avoids a double close.
- ENOENT/ENOTDIR return a 404 Response instead of rejecting; covered by a test.
The custom setPermissionCheckHandler only allowed media/audioCapture/
videoCapture, which made Electron deny the 'automatic-fullscreen'
permission consulted during HTML5 video requestFullscreen(). The
request handler's isMediaCapturePermission() also returned false for
'fullscreen'. Result: the native fullscreen button on <video controls>
in chat silently did nothing.
Allow 'fullscreen' + 'automatic-fullscreen' in both handlers.
Verified with a minimal Electron repro using Hermes' exact handlers:
requestFullscreen() failed with 'TypeError: Permissions check failed'
before; works after. User-verified in the packaged desktop app.
Let settled remote sessions continue their first quit. Fence late local starts and join existing local and SSH drains without cancelling managed update recovery.
Co-authored-by: Gille <4317663+helix4u@users.noreply.github.com>
Co-authored-by: ChanPark03 <parkchan0302@gmail.com>
* refactor(desktop): compress intro reveal
Remove the unused inline cinematic fallback and its skip callback plumbing now that the native window owns playback. Keep native timing and exit behavior unchanged, colocate the spinner with text effects, and document the current launch and handoff contract.
Area delta against B1: 47 additions, 53 deletions, net -6 lines across six files. Most fork verdicts were already applied in B1.
* refactor(desktop): compress guided chat surface
Remove random greeting variants and retain one existing opener per locale,
while preserving the banked greeting and machine-name suggestion. Trim
assembly commentary while keeping the reasons for its layout invariants.
Move solo-boot and window-growth IPC into a topical Electron sibling so
onboarding handlers no longer grow main.ts. Preserve sender gating,
reveal ordering and window geometry.
* refactor(desktop): compress onboarding handoff
Split welcome-chat kickoff from durable handoff effects and wire each
hook directly. Remove duplicated option types, a redundant readiness
comparison, nullable receipt-key state and stale prose while preserving
B1 routing and recovery.
* refactor(desktop): compress guided chat back half
Remove the duplicate handoff completion key and its reader/writer helpers.
Use the onboarding phase record for replay guards and settled cards while
keeping accepted receipts as the completion boundary.
Preserve the signpost and plugin plan under ruling 5.
* refactor(desktop): compress stores and transcript integration
Remove unused machine reset and untargeted host composer submission. Trim machine and presence commentary while preserving their live consumers. Wire reasoning through the existing scratchpad surface and memoise progress history without mutating it.
Keep parser and connector rendering under rulings 4 and 5. Area delta: 36 insertions, 101 deletions; net -65 lines.
* fix(desktop): keep skipped onboarding apart from a completed handoff
Make skipGuide() persist skipped and let beginOnboardingHandoff accept
guided or skipped. requestSetupHandoff and HandoffCard derive completion
from the accepted receipt.
The latch merge conflated skipping the guide with starting the first
build. A later handoff therefore claimed "was started" without creating
a session. Preserve skipping as its own terminal phase so a later
handoff can create the build and reach done only after acceptance.
* refactor(desktop): B2 review notes
Correct the layout-growth comment in assembly.ts: growing to preserve
the chat size balloons the window. Restore the WHY clauses in
onboarding-handoff.ts and onboarding-kickoff.ts for pending title metadata
on older backends and the caller's requestGateway reading the create pin.
Move guideSourceConnectionId beside $setupSession in setup-profile.ts
and derive each hook's option types from useSessionActions, so kickoff
no longer imports the heavier handoff leg.
Move $handoffError and retrySetupHandoff beside $setupHandoff in
setup-profile.ts and update the card and handoff hook importers.
This removes the setup-profile/handoff-receipt cycle and leaves receipt
persistence dependent only on storage and its receipt type.
* fix(desktop): derive the first-build receipt key one way
Use guideSourceConnectionId(guide.storedId) for the save and request
receipt keys, matching resume and HandoffCard. Keep guide.connectionId
for RPC routing and preserve the receipt key's string format.
At boot the resume path only knows the guide's stored id. When no owner
hint exists but an active gateway connection does, keying writes by the
resolver's ambient route hides the accepted receipt from resume and
leaves the card on Opening. One derivation lets every path find the same
receipt without changing where the build request is sent.
* feat(desktop): intro type at 150% for legibility
Set the intro window's root font size to 150%. Every measure in the intro
is in rem, so the chat card, its rows, bubbles and gaps scale together.
The brand close uses viewport units; its wordmark and tagline are scaled
by hand to match (6.8 to 10.2 vmin, 1.35 to 2 vmin). The hero card's
width cap rises from 900 to 1350 px so lines keep their length on a
large display; its minimum width is unchanged so the three-column stage
still fits a laptop.
The intro fills a display the user sits back from, and at the app's 16 px
root its text read too small on a large monitor (director ruling).
* fix(desktop): status bar keeps one fill under glass; free-tier chip reads Nous, model, Sign in
Under the glass appearance in sidebar scope, the body paints a hard stop
at the rail's edge (glass mix left, opaque chrome right) and the status
bar was transparent, so the seam ran through the bar and cut whichever
item sat on it: in the 886 px guided window, the free-tier chip. The bar
now belongs to the opaque content column across the full width, the way
Finder's does; window scope has no seam and keeps the transparent bar.
The chip itself read "Nous · free tier · nous/welcome" with the Sign in
badge touching the label. It now reads "Nous", the model id small and
monospace, then a solid Sign in badge set off by a gap; the full
"Nous · model" string moves to the tooltip. "Free tier" is no longer
said in the bar (director ruling).
* feat(desktop): port guided onboarding substrate
Add seeded session creation, transcript directives, profile routing, and the shared window and pane primitives needed by the guided flow. Keep later-step mounts deferred and exclude provider selection and retry machinery.
* refactor(desktop): anti-slop cleanup for substrate
Assemble seed parameters in the existing create helper and use the owning transcript attribute type. Read the guaranteed gateway and connection contracts directly to remove runtime type probes and unchecked assertions.
* test(desktop): create-overrides invariants
Verify that reasoning and title overrides do not select a provider or model. Empty overrides and seeds add no parameters.
* feat(desktop): port first-run cinematic window
Play the cinematic behind the guest onboarding launch flag using bundled Collapse and JetBrains Mono. Give the native window its own controller and restore the app on skip, renderer deadman or native watchdog.
Drop the perf scenario because it depends on the removed replay hook. Guided chat kickoff and app-shell gate wiring remain with their later steps.
* refactor(desktop): anti-slop cleanup for cinematic
Preserve audio and canvas behavior through named types and inferred results. Split the viewport node and frame drawing to keep control flow bounded. Cut comments that only repeat the code.
* feat(desktop): add onboarding gate and answers stores
Track cinematic, guided chat, handoff and completion in one phase record. Queue the guide after the intro and share pending kickoff work between callers.
Keep existing saved answers while dropping retired preferences. Leave intro seen-state ownership with the cinematic store.
* feat(desktop): port guided onboarding chat
Add guided setup cards, runbooks, machine context, and onboarding presence. Connect transcript rendering and first-build progress to the desktop behind the onboarding flag. Leave session kickoff and handoff execution for the next step.
* refactor(desktop): anti-slop cleanup for guided chat
Keep directive and layout lookups typed. Remove unsafe test casts and isolate onboarding transcript calculations without changing the flow.
* feat(desktop): connect guided onboarding to durable first-build handoff
Start the guide only after its profile backend confirms bootstrap readiness. Seed or adopt the welcome chat, then transfer the first build to default with a durable receipt and explicit retry.
Wire cinematic completion, screen stand-down, layout growth and progress check-ins. Save agreed preferences before creating the build and release prompt slots after storage refusal.
* refactor(desktop): anti-slop cleanup for onboarding handoff
Reuse the gateway request and error contracts. Isolate guide adoption and snapshot validation while preserving receipt recovery and reasoning overrides.
Validate persisted receipt fields at the JSON boundary without coercion. Keep corrupt identities rejected and retain only the permitted test mocks.
* fix(desktop): guided chat review fixes
Wire the native machine probe so guided setup can suggest a name and offer the right first task. Restore the comments that explain the flow boundaries.
The directive registration uses the launch flag to preserve ordinary chat. Ruling 6 folds active.ts into assembly to keep activity ownership together and removes the second greeting source so the seeded and visible greetings agree.
* fix(desktop): handoff review fixes
Probe the guide backend before switching profiles so a readiness refusal keeps classic onboarding on the current backend.
Restore list-valued personalization coverage and routing rationale. Remove the obsolete setup status fixture.
* chore(desktop): onboarding script cull and rehearsal recipe
Document a temporary-state rehearsal using the existing onboarding flag and optional portal stand-in. Keep the main scripts unchanged and retain window growth for the guided chat.
* fix(connectors): reject incomplete catalog responses
* feat(gateway): scope connector controls to the owning session
* feat(desktop): connect apps through native session-owned controls
* feat(desktop): gate connector cards and enable free-tier access
Use the launch flag before mounting connector controls so classic transcripts add no status requests. Allow existing free-tier identities through the read-only tool gateway gate and test the owning-profile RPC path with A’s launch gate. Keep authorization links out of previews.
* style(desktop): format connector translations
Apply Prettier to the connector copy blocks while preserving upstream translations and free-tier wording.
* refactor(desktop): anti-slop cleanup for connector card
Use the transcript JSON contract and concrete RPC parameters. Preserve malformed-value filtering at one string boundary and make the fixture and row types explicit. Keep connector execution and cancellation behavior unchanged.
* feat(desktop): detect initial language from the OS
Use the native machine locale when no supported language is saved. Preserve explicit choices and leave inferred languages out of config.
* refactor(desktop): anti-slop cleanup for initial locale detection
Keep unvalidated config values at the existing validation boundary. Pass no saved choice after that boundary has ruled it out, preserving locale precedence.
* test(desktop): onboarding port test set
Make native window tests reject duplicate IPC handlers and isolate disabled onboarding. Assert the active gate mock when onboarding re-enables.
Keep the test set limited to behavior carried by the port.
* fix(desktop): recover failed guide kickoff and reveal once
The review found that a failed guide create stranded the solo shell and draft profile, and solo boot faded an already visible window a second time. Restore the prior route and layout, release onboarding through its existing phase record, and surface create failures. Let the film own the reveal while solo boot animates the visible resize.
* fix(desktop): preserve transcript ownership across cards and handoff
The review reproduced answers submitted to the focused chat, repeated questions disabled across sessions, handoff recovery using foreground identity, and mount-dependent progress history. Target each card’s own composer, scope settlement to its message and session, carry the issuing guide through handoff, and derive progress from its transcript with streaming activity. Reuse the existing owner ladder for exact and profile-only routes.
* fix(gateway): preserve connector ownership with profile routing
The review found that shared-primary profile metadata was rejected before connector dispatch, while desktop controls treated a missing registry id as missing ownership. Accept profile only as routing metadata and keep the live transport as authorization. Resolve card ownership through the existing exact/profile ladder, retaining ambient routing only for the single-backend case.
* fix(desktop): resolve plugin roots and gate the Basic layout
The review found that the first plugin build was seeded with a different installation’s fixed path, and the director ruled that flag-off layouts must match main. Resolve the running desktop’s plugin root before seeding a plugin build and register Basic only when onboarding is enabled. Keep the runbook wording and the ordinary four layout presets intact.
* fix(desktop): clear review-fix slop findings
The slop gate flagged an undocumented layout-data assertion and unknown-return types in the new test selectors. Record the layout registry invariant and preserve each selector’s return type. The only remaining production finding is the accepted connector-tools baseline.
* fix(desktop): detect the OS language on a fresh install
The review found that the merged English config default prevented the
desktop from probing the OS language on a fresh install. Add an opt-in
saved-values read so an absent choice remains distinct from saved English.
Preserve default-valued English only for explicit language saves; unrelated
settings saves must not turn a merged default into a language choice.
Older backends ignore the new query options and keep returning merged
English, preserving their existing desktop behavior.
* test(desktop): make the flag-off layout registry test deterministic
The flag-off test awaited the full controller import, pulling in the UI
graph and installing application watchers just to read layout presets.
That import took 9.5 seconds locally and timed out in the director's run.
Move the existing trees and registration into a small layout-presets
module. Production and the synchronous test use the same flag-gated
registration, without starting the controller in the test. Keep the real
registry invariant and dispose the test's contributions after completion.
* fix(desktop): keep the transcript parser and ::ask behind the onboarding flag
Register the guided chat's question card only with onboarding enabled.
Restore main's whole-paragraph parser and contribution rendering when the
flag is off, including its streaming prose behavior. Keep segmentation for
the guided flow until B4 decides the parser's wider use.
Restore main's two parser test files so its existing product and plugin
contracts remain the flag-off check.
* test: drop the onboarding and connector tests pending a later ticket
Apply the director's ruling to remove B1's added test files and restore
main's existing suites. Keep only the gateway route-reader mock contract
that main's profile tests need against the shipped activation behavior;
their cases and assertions stay intact.
The flow's shape is not settled and B3/B4 rewrite it. The connector layer
will also be reworked. The live CDP run is the flow check until a follow-up
ticket brings tests back.
---------
Co-authored-by: brooklyn! <brooklyn.bb.nicholson@gmail.com>
A GitHub outage, a rate limit, a corporate proxy intercepting TLS and a DNS
failure all rendered as the same generic line, so #105855 read as a Hermes
bug during a run of GitHub incidents. The main process now classifies the
failure (HTTP status incl. 403/429 rate-limit and 5xx outage wording, DNS,
timeout, connection refused/reset, TLS) into one actionable sentence; the
overlay shows it under the title and About appends it to the status line.
httpStatusError became the single writer of statusCode but the PR left four verbatim
readers (native-auth-decisions, two in connection-config, the startHermes catch).
readStatusCode in api-transport is the read side. The module-level bootProgressHeldFor
existed only to dedupe a log line; logging where the latch is set does the same with no
mutable state. No behaviour change.
Drop the unrun e2e spec (ci.yaml keeps e2e-desktop at `if: false`), the four
main.ts source-grep tests (banned by AGENTS.md; each was redundant with a
pure-function test in this file, native-auth-decisions or backend-start-failure)
and the two duplicated rotation/hold cases.
mintGatewayWsTicket swallowed every forced-refresh failure into null, so a 5xx or
timeout from /auth/native/refresh re-threw the original bearer 401 and latched the
boot as "session expired" while the refresh token was still stored. Let a transport
failure propagate: ensureNativeAccessToken already returns null (and drops the
tokens) on a 401, which is the only refresh outcome that confirms the rejection.
fetchJsonViaOauthSession and finalizeGatewayDownload still hand-rolled the
`Error("<status>: <body>")` + statusCode shape the helper was introduced to
consolidate, so the "shared by all three paths" claim was false on landing.
The download-transport source test now asserts the helper call.
Cold-launching against a gated remote gateway whose stored session has been
invalidated server-side alternated between the connecting state and the
recovery overlay; the Sign in button was only intermittently clickable.
Root cause: fetchJson() built a bare Error("401: ...") for HTTP failures on
the native-bearer path, dropping res.statusCode. Every downstream classifier
is shape-based (isGatewayAuthRejection, isServerSideHttpError, the
ensureNativeAccessToken 401 check), so the confirmed rejection looked like a
transport blip: withTransientRetries hammered it, gatewayTicketFailure used
the transport copy, startHermes tagged the boot retryable, and the renderer's
bounded boot-retry loop re-emitted running:true over the overlay on every
attempt. Separately, gatewayTicketFailure only ever set needsOauthLogin,
which isReauthRequiredError ignores, so even a structured 401 from the cookie
path never latched.
- api-transport: httpStatusError() is the one HTTP-error shape; fetchJson and
fetchPublicJson use it, matching fetchJsonViaOauthSession.
- mintGatewayWsTicket: the gateway never rotates a native bearer server-side
(dashboard_auth/middleware.py), so a bearer 401 gets ONE forced
/auth/native/refresh; a live refresh token retries the mint once, a dead one
drops the stored tokens and the rejection is confirmed.
- gatewayTicketFailure: a confirmed 401/403 is tagged isReauthRequired so
startHermes latches it and marks the boot non-retryable.
- startHermes: latches are set before the first await in the failure path;
updateBootProgress holds every update that is not a re-emit of the latched
failure until a recovery path clears the latch.
Tests: composition + main.ts source pins (remote-reauth-latch.test.ts), unit
coverage for the new helpers, and a Playwright e2e that boots the real app
against a fake gateway with a dead session and proves the overlay latches
once (one mint, one refresh, retryable:false, Sign in stays clickable).
The salvaged commit answered a passive read with its own pool lookup in the
two REST dispatchers. That lookup recomputed the pool key and got it wrong
for every route that is not the plain v1 pool: forced-local registry children
live at `conn:local::<profile>`, and primary-routed profiles (the active
profile, global-remote, registry-primary reuse) are served by startHermes()
and are never in backendPool. Bot tiles always carry an ownerRoute with a
connectionId, so their reconcile threw "no warm backend" on every tick for
a warm backend, and the bare catch in reconcileTileTranscripts hid it --
open bot chats silently stopped receiving background deliveries.
Thread `passive` into ensureBackend / ensureRegistryBackend instead and
decide at the one place each route already does its `backendPool.get`:
existing entry -> serve it without refreshing lastActiveAt; no entry ->
throw before evictLruPoolBackends/spawn. Primary routes never reach a
spawn site, so they stay served. The remote-descriptor revalidation stays
on the passive path.
A passive read never dials, so the registry dispatcher keeps it OUT of the
backendDialClaims coalescing: an interactive open joining an in-flight
passive read would otherwise inherit its "no warm backend" rejection
instead of spawning.
getLatestSessionMessages takes `{ passive }` (matching getSessionMessages)
instead of a positional boolean; the reconcile tests assert the passive
intent.
Passive tile reconciles (reconcileTileTranscripts on every sessions.changed
tick) were cold-starting pooled backends for every open bot tile, including
hidden ones, and holding each spawned slot for its lifetime via the 10s touch
loop. With 15-20+ profiles this permanently saturated the Warm Bot Backends
pool so interactive opens timed out.
Add a passive read intent: getLatestSessionMessages now accepts a passive
flag forwarded as HermesApiRequest.passive. The Electron main process honors
it by serving only already-warm pool entries and failing fast otherwise,
without spawning or bumping lastActiveAt. reconcileTileTranscripts uses
passive reads so background refresh never consumes a pool slot; interactive
opens keep the normal spawn path.
Every desktop client ran `git fetch origin main` twice every 30 minutes
(client + backend check), plus on every window focus, with no cache in the
Electron main process and `force=true` hardcoded on the backend poll so the
backend's 6h cache was bypassed too. Across the install base GitHub measured
~33.8M fetch/clone requests in 24h against the repo and asked us to poll via
the API and releases instead.
Passive checks now:
- read the branch tip with GET /repos/{slug}/commits/{branch} using the
application/vnd.github.sha media type (40-byte body), and only when the tips
differ call the compare endpoint for the exact behind count and the commit
list the overlay renders. No pack negotiation; `git fetch` runs only when
the user applies an update.
- cache the answer on disk for 24h (1h on failure), keyed on local HEAD and
branch so applying an update or switching branch invalidates immediately.
- run from the renderer every 24h instead of 30min; window focus re-checks
only once the daily cadence has elapsed; the poller never passes `force`.
Menu "Check for Updates", Settings "Check now", opening the overlay and the
post-apply re-checks still force a fresh read.
Non-GitHub origins keep a single `ls-remote` (ref advertisement only).
runGit resolves on 'close' rather than 'exit' — the early-resolving
`remote get-url` returned "" often enough to route checks down the
non-GitHub path.
update-count.ts (shallow/full-clone counting heuristics for the fetch path)
is deleted; its compare-payload parsing moved to update-api-check.ts.
* fix(auth): close the free tier's gaps against the gateway's welcome-tier contract
The inference gateway's welcome tier (NousResearch/api DOCS/anon-tier/plan.md) serves an
anonymous account exactly one model on its own host, refuses everything else with a structured
429, cross-refuses a request on the wrong host with a 400 (403 while the tier is dark), and
tells a signed-in account that still asks for `nous/welcome` what to switch to in an
`x-nous-model-switch` header. Four client-side gaps against that contract:
- Auxiliary calls were refused on every session. The auxiliary client asked the welcome host
for the Portal's recommended compaction/vision model, a guaranteed 429 `model_not_free`
before each fallback. On the welcome host it now uses `nous/welcome` (its backing model
covers auxiliary work) and skips Nous for vision, which the welcome model does not take.
- The structured 429 body was never read. The classifier now parses `reason` /
`retry_after` / `alternates` / `upgrade_url`: `model_not_free` and `feature_not_free` are
non-retryable gates that fall back; `at_capacity`, `admission_closed` and `rate_limited`
are rate limits that honour `retry_after` and never rotate the free tier's only credential.
The wrong-host 400 and the dark-tier 403 are deterministic, so they abort this route and
fall back instead of retrying or re-exchanging. The terminal paths say what happened and
name the sign-in (`/login` in a chat, `hermes auth upgrade` in a terminal).
- The `x-nous-model-switch` header was ignored. The chat-completions transport records it
beside the rate-limit and credits headers; the next call moves the session, and the config
default when it still names `nous/welcome`, to the backing model the gateway named.
- A guest fell back to the paid host. With `inference_base_url` absent from the exchange or
outside the host allowlist, routing defaulted to inference-api, where every request is a
400. A guest now defaults to the welcome literal at the exchange, in the shared store's
shape, and in effective routing.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit fc758aad7efceff6223fc144a9b5c69f13e41bd8)
* feat(auth): the free tier is set up on request; nous.guest_setup decides whether also on first use
A caller that names nous/welcome on a Nous route with no Nous identity in reach — the guided
setup's session (provider=nous, which skips the resolver's nothing-configured rung), the free-tier
picker row, a bare --provider nous pointed at it — is asking for the free tier. The OAuth runtime
rung now sets it up there instead of failing "not logged in", so the guided chat no longer races
the root profile's first-run mint.
nous.guest_setup is the policy seam: "auto" (default) keeps today's first-use setup wherever
nothing else is configured; "on-request" mints only when the free tier is asked for by name
(nous/welcome, /login, hermes auth upgrade, replacing a retired identity). Implicit callers —
the resolver's last rung, the first-run check, free_tier.status, the CLI's background setup, the
connector token path — still adopt what the shared store holds, so every profile follows the one
identity the guided setup created, but never create one on their own.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit ae915ddc65ecdb81b81e29b604671d15cd49233c)
(cherry picked from commit 62ad1ff3ab200ea064975a32c502041b25910165)
* feat(auth): the guided setup provisions the free tier explicitly; nous.guest_setup is auto | explicit
Two questions govern the free tier: may it exist (nous.guest) and who may CREATE the identity
(nous.guest_setup). "auto" (default) keeps today's first-use setup wherever nothing else is
configured. "explicit" means Hermes never creates one on its own: the only creator is the new
provision_free_tier() primitive, exposed as the free_tier.provision RPC, which the guided setup
on Hermes Desktop calls as its first step — on the root gateway, before the setup profile and
before the guided chat exists — so the identity lands in the root store every profile reads
through and is there before any session asks for nous/welcome. That closes the race against the
backend's own setup, and makes "only when the setup-bot flow is used" literally true.
The earlier "on-request" tier is replaced: it minted whenever any caller named nous/welcome
(the hermes model row, --provider nous), which treated a model name as intent and was broader
than the guided setup. Under "explicit" a nous/welcome request with no identity fails "not
logged in" as before the free tier existed, and /login or hermes auth upgrade report nothing to
sign in from. Implicit callers still adopt an identity the shared store holds, and a retired
credential is replaced (a continuation, not a creation).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit c63d2c935c1e59016164fdfb90cf70b4094466a0)
* fix(auth): remove the nous.guest_setup knob; the free tier is created on first use
`nous.guest_setup: auto | explicit` decided who may CREATE the free-tier identity. Under its
default every line it added was inert (`may_mint` always true), nothing in tree set `explicit`,
unknown values read as `auto`, and under `explicit` a CLI-only install could never get an
identity, which contradicts the first-run contract (first command mints, then chats).
The mint race the knob accompanied is already benign: every caller takes the profile lock then
the shared-store lock, and the loser adopts what the winner wrote. What makes the guided setup
win deterministically is `provision_free_tier()` behind the `free_tier.provision` RPC, which
stays. `nous.guest` remains the only free-tier policy.
Removed: `guest_setup_policy()` and its constants, the `explicit=` / `may_mint=` threading through
`ensure_portal_identity` and `_reconcile_and_provision`, the flag at the three replacement call
sites (now no-ops), the config default, the docs section, and the four `guest_setup` test-config
entries. The three policy tests that hold regardless of the knob are kept under
`TestExplicitProvision`; the two that only tested the knob are deleted.
(cherry picked from commit d8a50526d93c374c0067dd935b5a65055e0af261)
* fix(gateway): a server-driven model switch off nous/welcome does not evict the cached agent
When a signed-in account still asks the paid host for `nous/welcome`, the inference gateway
serves the current backing model and names it in `x-nous-model-switch`. `apply_model_switch`
moves the live session to that model and moves `config.yaml`'s default off the alias in the
same step. The messaging gateway's fallback-eviction check compares the agent's model with the
config default and evicts on any mismatch that is not a /model override, so when the config
write did not land (unreadable config, lock) the cached agent was evicted once per turn, and
prompt caching with it.
`apply_model_switch` now stamps the alias it moved the session off on the agent, and
`_is_intentional_model_switch` treats "agent moved off the alias the config still carries" as
deliberate, beside the existing /model override case. The check takes the agent and the config
model instead of a bare model string; its one caller in `_run_agent_evict_on_fallback` passes them.
(cherry picked from commit 696d1ec86b69db28bf002c841e9389b85178a954)
* fix(auth): the free tier outranks implicit host credentials in provider resolution
On a fresh install with a leftover ~/.aws profile, resolve_provider("auto")
reached the Bedrock rung before the free-tier rung, so the first turn ran on
Bedrock and failed 403 while the free tier was still being minted in the
background at agent setup (NS-829). Live on a Mac with ~/.aws present: 28 s,
three retries, no answer; the next process then switched to nous/welcome.
The free-tier rung now sits directly above the Bedrock chain: when nous.guest
is on, an existing free-tier identity answers, else a blocking mint runs, and
only then does the boto chain get a say. Everything above is unchanged and
still wins: CLI creds, config.yaml model.provider, env keys, the OpenRouter
pool, a logged-in active_provider. nous.guest: false skips the rung, and a
failed mint still falls through to Bedrock and the no-provider guidance.
Tests: six precedence cases (identity present, fresh mint, free tier off, env
key still wins, sign-in still wins, failed mint falls through). The opt-out
test now neutralizes the AWS chain like the precedence tests do; on a machine
with ~/.aws it was failing for the same reason as the bug.
Live after the fix, same Mac, AWS credentials visible, isolated shared store:
identity minted 2 s in, turn on model=nous/welcome provider=nous, answer in
11 s.
(cherry picked from commit a04b05260cd334dd7199ad9b6cd5b2538364c75a)
* fix(auth): review follow-ups for the free-tier rung (NS-829)
- tests/agent/test_bedrock_integration.py: the Bedrock auto-detect test switches
the free tier off; its contract is the boto chain, and the free tier now
sits above it.
- gateway/run_notifications.py: the free-tier startup line reads auth.json
before consulting the resolver, so a gateway boot on a machine with AWS
credentials never mints or refreshes over the network.
- hermes_cli/anon_auth.py: module docstring says where the free tier sits in
the ladder instead of "the ladder is untouched".
- tests/hermes_cli/test_provider_precedence.py: two invariant tests instead of
six (parametrized ladder cases; a failed mint that returns None or raises
falls through to Bedrock).
scripts/run_tests.sh on the five affected files: 147 passed, 0 failed.
(cherry picked from commit 10790d148c60ada11b9ecdde2cd2c836c6a82a11)
* feat(auth): HERMES_GUEST_ONBOARDING=1 is the one launch gate for the free tier; HERMES_FORCE_GUEST is gone
The free tier is pre-GA. Until GA it must not exist for anyone who did not
ask for it: no identity minted, no portal traffic, no free-tier copy on any
surface. One environment variable now decides that, and one function reads it.
`guest_enabled()` returns False unless `HERMES_GUEST_ONBOARDING` is exactly
"1"; only then does `nous.guest` (the user's off switch) get consulted. Every
free-tier site already funnels through `guest_enabled()`, so the gate closes
minting, routing, connector entitlement, status lines and the picker row in
one place. With the variable unset, `resolve_provider("auto")` on a fresh
install raises `no_provider_configured` exactly as upstream does.
`HERMES_FORCE_GUEST` and `force_guest_mode()` are removed. They inverted the
gate (forced the tier ON over `nous.guest: false`), their "new" value re-minted
identities as a side effect of provider resolution, and `_has_any_provider_
configured` read them ahead of every other check, making the CLI a second
reader of a flag that must have exactly one. `_forced_new_done` and the
`force` parameter of `_reconcile_and_provision` go with them.
Supersedes the dev lever introduced in fcf9d11679 (rung 1) and hardened in
b5c162c3ec. Ruling: NS-845 Q1.1 (recorded on NS-847).
Not a user preference: the variable is never written to config.yaml or .env
and never shown in setup. It is deleted at GA together with its comment in
anon_auth.py. This is a deliberate, temporary exception to the "no new
HERMES_* env vars for non-secret config" rule.
Tests: fixtures set the gate instead of deleting the old lever; one new
invariant (`test_launch_gate_off_means_no_free_tier_at_all`) proves that "",
"0", "true" and "new" all leave the tier off with zero portal calls, red on the
previous commit. The `HERMES_FORCE_GUEST=new` re-mint test is deleted with the
feature.
* feat(auth): the free-tier identity is created in one place, at boot; every other site is a read
Before this commit eight sites could create a Nous free-tier identity as a
side effect of something else: resolving a provider, the CLI's first-run
check, the CLI's session setup (in the background beside an own key), a
connector bearer read, the desktop polling `free_tier.status`, the sign-in
precondition, the desktop's `free_tier.provision`, and the dead-credential
re-mint. A poll could mint. Provider resolution could hit the network. Two
of them raced each other on a fresh install.
Now `hermes_cli/free_tier_bootstrap.py::run_bootstrap` is the only creator.
`hermes serve` runs it on a daemon thread from `_lifespan` beside the other
background boots; `cmd_chat` runs it synchronously before the first-run
guard. It inventories credentials first (`resolve_provider("auto",
skip_free_tier=True)`: what would carry inference if the free tier did not
exist), creates the identity only when `guest_enabled()`, resolves inference,
records a `SetupRecord` in process memory and broadcasts ONE `setup.ready`
event. It runs on every boot; only the mint is gated.
`ensure_portal_identity` now requires `explicit=True` and raises otherwise.
Its callers are the bootstrap, the desktop's `free_tier.provision` (the
explicit retry when the boot could not create the identity) and the two
dead-credential replacements (`auth_nous.resolve_nous_runtime_credentials`,
`managed_tool_gateway._replace_dead_guest_token`). The background thread
path and `provision_free_tier` are deleted with their last callers.
Reads that used to mint and now only read: `auth.py::resolve_provider`
rung 7 (an existing identity still outranks the Bedrock chain, NS-829
ordering kept), `main.py::_has_any_provider_configured`,
`cli_agent_setup_mixin._ensure_runtime_credentials`,
`managed_tool_gateway.read_nous_access_token` (no identity -> None),
`anon_sign_in.run_sign_in` (no identity -> Unavailable),
`methods_free_tier` `free_tier.status`.
`setup.status` answers from the record for the launch profile, blocking up
to 8 s while the bootstrap is in flight so a client's first poll lands after
the identity exists rather than racing it; a named profile, or a process
that never ran the bootstrap, keeps today's live probe. The record's fields
ride along additively (`ready`, `free_tier`, `other_providers`,
`inference_provider`).
Identity and inference are decoupled (NS-845 Q1.3): the mint sets
`active_provider="nous"` only when the inventory found nothing else usable
(`_mint_locked(carries_inference=)`); an adopted account always does. A token
refresh no longer re-elects the provider it refreshed
(`_save_provider_state_to_source` writes credentials, not the user's
choice) — that write was how an own-key install ended up on the free tier
after the first connector call.
Supersedes the mint sites in fcf9d11679, a42d0748fc (first-run check),
bbbaa8935a (CLI background setup), 0179efc989 (`free_tier.status` mint),
62ad1ff3ab / c63d2c935c / d8a50526d9 (the `nous.guest_setup` knob and
`provision_free_tier`), and a04b05260c (blocking mint in the resolver).
Ruling: NS-845 Q1.2 + Q1.3, recorded on NS-847.
Tests: `TestBootstrapIsTheOneCreator` (one mint per process; own key keeps
inference; reads never reach the portal; a refused mint is memoised),
`free_tier.status` fails loudly if it ever calls the creator, the resolver
stub fails loudly if resolution ever mints, `setup.status` reads the record,
`skip_free_tier` proves the inventory question. The three sign-in tests for
the deleted pre-mint collapse into one (`no identity -> Unavailable, zero
portal calls`). Live: real `_lifespan` boot with a fake portal, gate on and
off (/tmp/ns847-recon/evidence/e2e-rung5-c2-serve-boot.txt), and the CLI
matrix incl. an own-key cell (e2e-rung5-c2-bootstrap.txt), 20/20.
* fix(credits): the welcome host is free-tier evidence, so a free-tier identity never sees "run /topup"
A free-tier identity carries $0 by design, so the portal seed reports
`paid_access=False` for it. `is_free_tier_model` did not know the welcome
host, read that as a depleted account, and every free-tier turn ended with
the credits-depleted notice telling the user to top up an account they do
not have.
Rule (4) in `is_free_tier_model`: a `base_url` on the Nous welcome host
(`anon_auth.route_is_welcome_host`) is the free tier. The host is the
evidence, not the model name: the paid inference host can serve
`nous/welcome` to a named account and that account's depletion is real, so
`("nous/welcome", <inference host>)` stays False. Local data only, like the
three rules above it.
Restores the two contracts dropped by hermes-magic 674e11d1eaa (the
prototype line ran without unit tests): the welcome host is free without
any pricing evidence; the model name alone is not. The first is red without
this fix.
* fix(copy): free-tier text stops promising a connector transfer and never names the config key
Sign-in copy on every surface said "Sign in to keep your connectors" and
ended with "Your connectors are kept." The transfer registry that would
make that true is empty (NS-821): nothing carries over today. The copy now
says what signing in does give ("unlock more models and tools") and the
completion line names the account, not a transfer. The docs page loses the
"connectors carry over" paragraph for the same reason.
The picker's off-state line exposed `nous.guest: false` and the word
"guest"; user copy names the free tier only (R-USR-1).
The docs page gains the pre-rollout note: until GA nothing on it happens
without `HERMES_GUEST_ONBOARDING=1`. Its "first command mints" and
"replaced on next use" sentences now describe the boot bootstrap.
zh is a strict locale: the `freeTier` block was English placeholder text
copied from `en`; it is now Chinese. `connectorsKept` is renamed
`completedBody` since it no longer talks about connectors.
* feat(desktop): the free-tier launch flag is decided once in Electron and stamped onto every backend spawn
The Python backend reads `HERMES_GUEST_ONBOARDING` and treats exactly "1"
as on. Until now nothing in the desktop set it, so a packaged app could
never turn the free tier on, and a backend spawned by the app could
disagree with the app about whether the tier was live.
`electron/guest-onboarding.ts` owns the decision: `guestOnboardingEnabled`
is true when the launch env has `HERMES_GUEST_ONBOARDING=1` or argv has
`--guest-onboarding` (the packaged-app spelling). It is read ONCE at launch
into a module constant. `desktopBackendSpawnEnv` wraps every backend env
as the outermost call and writes the flag LAST, as "1" or an explicit "0",
so no earlier spread (`process.env`, `backend.env`) can resurrect a stray
value from the parent shell.
Stamped onto all three spawn sites: the primary `serve` spawn, the pooled
per-profile spawn, and the remote SSH `exec env ...` command (which gains
` HERMES_GUEST_ONBOARDING=1` only when on). The embedded terminal PTY and
the backend probes are not backend spawns and do not get it: a
`hermes --tui` typed in the pane must not mint.
The renderer learns the same fact read-only through the existing
`hermes:launch-flags` sync IPC (`guestOnboarding`) and preload
(`window.hermesDesktop.guestOnboardingEnabled`).
Ruling: NS-845 Q1.1 / Q2 (env var is the contract, `--guest-onboarding`
maps to it in main). Two invariant tests on the pure helpers: only "1" or
the argv flag enables; the spawn env carries "1"/"0" as the last word and
preserves every other key.
* feat(desktop): the renderer learns free-tier readiness from one `setup.ready` push, not a 60 s poll
The backend's boot bootstrap now announces `setup.ready` once, after it has
created (or refused) the free-tier identity and resolved the inference
route. The renderer used to discover both by polling `setup.status`,
`setup.runtime_check` and `free_tier.status` every 60 s from
`useStatusSnapshot`; a fresh install's chip, notice strip and onboarding
overlay could sit stale for up to a minute after boot, and three RPCs a
minute per window kept asking a question whose answer changes only at
boundaries the backend already announces.
`handleLifecycleEvent` routes `setup.ready` (active source only, like
`skin.changed`) to `notifySetupReady()`, a one-shot tick atom in
`live-sync.ts` beside the other change ticks. `useStatusSnapshot` listens
to it and runs one readiness round at once (`setup.status` +
`setup.runtime_check` + `free_tier.status`). The readiness legs also run
once on open and on return from another app, as today. The 60 s tick keeps
only `getStatus()`.
`SetupStatusSnapshot` types the record's additive fields (`ready`,
`free_tier`, `other_providers`, `inference_provider`); readiness semantics
are unchanged and still key on `provider_configured` + `runtime_check`.
Ruling: NS-845 Q1.2 (renderer half). Tests: the lifecycle branch fires one
refresh from the active source and none from another; the snapshot hook's
contract is three legs on open, one leg on the tick.
* fix(cli): the banner names the free tier's model instead of "no model configured"
The welcome banner prints before credentials resolve, so on a fresh install
`model` is empty and the banner said, in red, "no model configured — run
/model or hermes setup". Under the free tier that is false: the route is
already known from local state (identity on disk, tier on), and the first
message will run on `nous/welcome`.
`_banner_left_lines` now asks the route the same question when `model` is
empty (`guest_carries_inference()`, a local read) and shows `welcome · Nous
Research`. When nothing resolves the red line stays. Ruling: NS-845 ("the
banner's 'no model configured' line reads the resolved route").
Live: fresh HERMES_HOME + fake portal, gate on -> `welcome · Nous Research`;
gate off -> the red line, zero portal calls.
* fix(aux): vision on the free tier uses nous/welcome too
The text-only modality on the gateway's `nous/welcome` row is DeepSeek V4 Flash's, the
backing model until the repoint; `z-ai/glm-5.3-flash` is natively multimodal and the
repoint declares the welcome row `text+image->text`. Skipping Nous for vision on the
welcome host would have sent every image step past the free tier for no reason, so the
auxiliary client pins the route's one model for every lane. A backing model that takes
no images answers with the upstream's own error, which the ladder handles as it always has.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 7456e028faba55480db43015dc2c8df3e393a415)
* fix(gateway): hermes gateway run is a boot owner of the free tier too
Rung 5 made every demand-time free-tier site a read: resolve_provider,
the connector token, the /login precondition. That is only correct if
every process that can reach those sites ran the bootstrap first. The
CLI (cmd_chat) and hermes serve (_lifespan) did; the standalone
messaging gateway did not. A fresh HERMES_HOME with the gate on and
`hermes gateway run` reached provider resolution with no identity to
consume, and /login returned Unavailable. Reported by @andrexibiza on
#107697 (P1).
GatewayRunner.start now runs `free_tier_bootstrap.run_bootstrap` on an
executor thread right after startup recovery and BEFORE any adapter
connects, so a fast first DM cannot arrive with nothing to resolve. It
is its own step, not part of the turn-machinery warm-up: the warm-up is
an optimisation with an off switch (HERMES_STARTUP_WARMUP_TIMEOUT<=0);
the bootstrap is correctness and must always run. With the gate unset it
is a local inventory and no network.
Live, real GatewayRunner.start against a fake portal in a fresh home:
gate on -> 1 create, identity persisted, resolve_runtime_provider=nous,
/login precondition sees the identity
gate off -> 0 portal calls, no identity, no_provider_configured
Before the fix the gate-on row was identical to the gate-off row.
Test: the bootstrap seam runs before _start_prefilter_platforms and
delegates to the one creator. Red on 5554eb6993 (no seam), green here.
---------
Co-authored-by: Robin Fernandes <robin@soal.org>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Mirror execFile's stdout into a buffer so the hard-timer path can still
extract a PATH sentinel that printed before a wedged descendant kept
the callback from firing, instead of discarding it as null.
execFile's `timeout` only SIGTERMs the direct shell child. A profile
that spawns a daemon (e.g. Powerlevel10k's gitstatusd under a non-TTY
GUI launch) can leave a grandchild holding the stdout pipe open, so
the execFile callback never fires and runProbe's promise hangs
forever — pinning desktop boot at "Resolving Hermes backend"
indefinitely.
Add a hard deadline that force-resolves the probe past its timeout and
kills the whole process group (shell + any daemons it spawned) so a
hung profile can never park boot.
Fixes#107109
With `voice.auto_tts` on and HUD mode active, a reply could be spoken
twice: the HUD renderer and the hidden app window both claim
`speak:<messageId>` through `hermes:ambient:claim`, and main collapsed
the two claims with the same 1 s deduper it uses for the turn-end beep.
The app window under the HUD is throttled by Chromium, so its transcript
subscription fires well past 1 s, finds the key pruned, and is told it
owns the cue. Fixes#99717.
The unit was wrong, not the window: a spoken reply is minutes of audio
keyed by a durable message id. `createAmbientClaimArbiter` keeps the
tick-sized window for `sound:*` and holds `speak:*` claims for a
10-minute TTL. No HUD special-casing: the same race exists between any
two windows showing the same chat, and the arbiter fixes them all.
Diagnosis credit: #99810 (@liuhao1024) and #100289 (@shivamjg101),
whose TTL framing this follows.
The first map on Hyprland can leave the HUD tiled when `j/clients` does not
list the new client within the 8 × 50 ms the promotion loop allowed; the
window then stays a stretched transparent tile (#103091). Widen the budget to
24 × 100 ms. The loop returns on the first successful float+pin, so a prompt
compositor pays nothing extra.
Salvaged from #103116. Its second half (re-promote from focusWindow's
re-show branch) is dropped: the HUD is destroyed and respawned on every
toggle and is not minimizable, so that branch never runs for it.
Capabilities → Plugins is now a single table: one row per PACKAGE, with a
Desktop column (this app) and an Agent column (the selected profile). A
package with both halves is one row, never two; the kind badge is inferred
from what it ships (plugin.yaml → agent, plugin.js → desktop).
The desktop half of a unified agent+desktop package no longer loads from the
profile-shaped `plugins/<name>/desktop/` folder. Electron copies that half
into `~/.hermes/desktop-plugins/<name>/` beside a `.hermes-package.json`
marker (package name, source, origin repo/sha) and keeps it in sync: newer
source → re-copy, package uninstalled → copy removed, hand-installed
standalone folder of the same name → never overwritten. The renderer scans
exactly one root, so a pane can never appear, disappear, or re-scope when the
user switches profiles — the same switch reads the same value everywhere.
Why a copy rather than scanning every profile: two profiles can carry the
same package at different SHAs; a scan has to pick one silently. One copy,
one source of truth, stamped with where it came from.
- plugin-packages.ts: pure merge of desktop records + agent rows → rows
- plugins.manage list reports `has_desktop_half` so the pairing is explicit
- Install dialog (local backend): the desktop half is materialised from the
installed package instead of cloning a second standalone copy; remote
backends keep the separate clone. Target path now names the real profile
folder for non-default profiles.
- "Install here": a desktop half whose agent half is missing in the selected
profile pre-fills the dialog from the marker's origin; disabled with an
explanation for hand-copied folders with no origin.
- Profile selector moves into the Agent column header; hidden with 1 profile
- Rescan/Update reconcile the copies BEFORE rescanning (ordering bug)
- Drop the dead `agentPluginsRoot` IPC; docs updated (desktop.md, SDK,
bot-mode.md, hermes-desktop-plugins reference)
Live-dogfooded on a headless Electron with two profiles and a real
file:// git package: install both halves, profile switch ×3, Install here
into the second profile, v2 update via `hermes plugins update` → chip text
changes on Rescan, uninstall from both profiles → copy and row gone, broken
plugin row, cold restart, sash drag/reset, legacy Settings → Plugins
redirect.
Three things Teknium hit on the merged Plugins page:
1. Desktop plugins vanished on profile switch. `fs-ipc.ts::localPluginsRoot`
resolved `<HERMES_HOME>/profiles/<active>/desktop-plugins` for a named
Desktop profile, so a disk-installed plugin only existed under the profile
it was installed from. Desktop plugins extend the app, not an agent; the
root is now `<HERMES_HOME>/desktop-plugins` regardless of profile, gateway
or remote machine (`electron/desktop-plugins-root.ts`), with a one-time
migration that lifts any per-profile folders into the app root (root copy
wins on collision). Agent-plugin and logs roots stay profile-scoped.
On the page, the profile selector now renders INSIDE the framed Agent
plugins block as its header, and Desktop plugins sit outside that frame,
so the scope boundary is visible without reading the blurb.
2. The catalog viewport could not be resized. It gets the same top-edge drag
sash as the Skills hub picker (persisted height, double-click resets,
clamped so the lists above keep real height; iframe pointer-events off
while dragging).
3. Accent Picker, a theme-authoring toy shipped off by default, is removed
from the bundled set and published as a standalone desktop plugin at
https://github.com/NousResearch/hermes-desktop-accent-picker (same source,
esbuild-bundled plugin.js importing only the three loader-resolvable
specifiers). Docs point there.
Symptom (Discord/X report, macOS): exit HUD mode and the small HUD window
stays on screen while the main window never regains focus; the toggle now
reads the wrong state, so there is no in-app way out.
Three defects in the HUD lifecycle, all in the main process:
- closeHudWindow() detached the window's 'closed' listener before calling
win.close(). close() is a REQUEST that waits on the renderer's unload
handlers, so a HUD renderer that never answered left an always-on-top
window with no teardown owner and no close broadcast. The 'closed'
handler now stays attached and is the single teardown path; a bounded
requestHudClose() (electron/hud-close.ts) destroys the window after a
1.5 s grace if the renderer never lets go.
- ⌘W inside the HUD was routed to the MAIN renderer's close-active-tab
(installPreviewShortcut is wired on every chat window), so it closed a
tab in the hidden app window and left the HUD up. ⌘W in the HUD now
runs closeHudWindow(), the same path as the exit button.
- restoreMainWindowFromHud() was a bare show() and only armed when the
main window was isVisible() at HUD-open time. On macOS isVisible() is
false for an occluded window (Electron folds occlusionState in), so
the app never came back when the HUD was opened over another app, and
show() alone does not make the window key. The restore now arms on a
live main window and routes through focusWindow() (restore → show →
focus). Closes#88513 (credit: PR #90489's diagnosis).
Renderer: the handoff now also requests composer focus on the close
broadcast, so the app window gets the keyboard, not just the session.
Docs: ⌘/Ctrl+W added to the HUD exit list.
The Electron composite project imported src/global.d.ts and followed its renderer-only dependencies. Share the notification payload from a dependency-free Electron module instead; all desktop typechecks and lint now pass.
Capture durable focus identity before runtime bindings disappear, retain native handlers, and keep runtime approval actions and plugin callbacks on their source renderer.