Commit Graph

843 Commits

Author SHA1 Message Date
Teknium 7dec81568e test(desktop): trim salvaged pool tests to invariants
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.
2026-09-11 06:23:18 -07:00
Mabolla 19cff347ad fix(desktop): wait for an evicted backend to release its slot
Await LRU teardown in each pooled backend creation path so replacement wakes do not race an exiting child for the hard spawn slot.
2026-09-11 06:23:18 -07:00
Mabolla 919dea9020 fix(desktop): await LRU teardown before queued wake
Wait for selected stale backend processes to exit before a replacement profile wake enters the bounded spawn queue.
2026-09-11 06:23:18 -07:00
joaomarcos a863bbcc28 fix(desktop): make pool slot timeouts actionable 2026-09-11 06:23:18 -07:00
Alexandru Ionescu d27180ba7f fix(desktop): pin primary backend routing to its booted profile
`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.
2026-09-11 06:23:18 -07:00
Teknium d1bd7b00df fix(desktop): dispatch probe falls back to /api/status on pre-/api/health remotes
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>
2026-09-11 06:22:35 -07:00
bennybuoy ee8257d601 fix(desktop): probe /api/health on pooled SSH dispatch, not /api/status
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.
2026-09-11 06:22:35 -07:00
KoNit-K 8ab08968f4 fix(desktop): give pooled remote dispatch probes the cold-start liveness budget
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>
2026-09-11 06:22:35 -07:00
hermes-seaeye[bot] efca39279a fmt(js): npm run fix on merge (#108214)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-11 12:53:17 +00:00
Siddharth Balyan 0591da2ba6 Guided first launch: review fixes from #107985 and the free-tier chip badge (NS-848, NS-855) (#108211)
* 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.
2026-09-11 12:46:48 +00:00
brooklyn! e6aa2e9fe4 fix(desktop): satisfy Electron permission type check 2026-09-11 07:37:59 -05:00
YuhGuan 244a42f636 fix(desktop): media-range review follow-ups — ignore multi-range as a whole, fstat the handle being streamed, 404 for missing files
- 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.
2026-09-11 07:37:59 -05:00
Youssef 360c1ee836 fix(desktop): allow HTML5 video fullscreen through permission handlers
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.
2026-09-11 07:37:59 -05:00
brooklyn! f36b6b0ce6 fix(desktop): cancel bootstrap manifest work during quit 2026-09-11 07:33:17 -05:00
brooklyn! 7b9808e43b fix(desktop): complete quit through one bounded teardown barrier
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>
2026-09-11 07:33:17 -05:00
brooklyn! 8607d6a52a fix(desktop): retain and cancel owned backend lifecycle work
Track pending starts, pre-claim children, and teardown removed from routing. Bound cleanup and cancel setup/update waits while preserving settled remote descriptors.

Co-authored-by: Gille <4317663+helix4u@users.noreply.github.com>

Co-authored-by: ChanPark03 <parkchan0302@gmail.com>
2026-09-11 07:33:17 -05:00
brooklyn! 556d19c2d5 fix(desktop): preserve refresh errors in media and verify auth recovery over HTTP
Co-authored-by: Sora-bluesky <sora.bluesky.dev@gmail.com>

Co-authored-by: Zeus-Deus <github.commits@widow.cc>
2026-09-11 07:32:34 -05:00
brooklyn! 22751c8fd9 fix(desktop): preserve remote auth through refresh failures and login races
Salvage native refresh coordination and cookie fallback without losing forced bearer rotation or replaying REST mutations.

Co-authored-by: Sora-bluesky <sora.bluesky.dev@gmail.com>

Co-authored-by: Zeus-Deus <github.commits@widow.cc>
2026-09-11 07:32:34 -05:00
brooklyn! 8d092c2f71 fix(desktop): validate remote OAuth through ticket minting
Handle truncated OAuth responses and keep confirmed auth recovery stable. Preserve the current attempt guard before latching failures; the older pre-guard latch proposal is not carried forward.

Co-authored-by: xxxigm <tuancanhnguyen706@gmail.com>
Co-authored-by: FalconOrtiz <falcon.ortiz11@gmail.com>
Co-authored-by: Ugo Enyioha <ugo.enyioha@outlook.com>
Co-authored-by: Bartok9 <259807879+Bartok9@users.noreply.github.com>
2026-09-11 07:14:55 -05:00
Siddharth Balyan b8e8639445 Guided first launch: smaller code and review fixes over PR B1 (NS-848, PR B2) (#107985)
* 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).
2026-09-11 15:45:48 +05:30
Siddharth Balyan 0e927c914d Guided first launch behind HERMES_GUEST_ONBOARDING: intro, guided chat, first task in default (NS-848, PR B1) (#107958)
* 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>
2026-09-11 15:45:43 +05:30
fangliquanflq ad6fdd4f1c fix(desktop): route session reads through primary backend 2026-09-11 02:23:29 -07:00
Teknium 66a13703b3 fix(desktop): update-check failures name the real cause instead of 'couldn't reach the update server'
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.
2026-09-11 02:06:32 -07:00
hermes-seaeye[bot] 754dd430ce fmt(js): npm run fix on merge (#108028)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-11 07:32:00 +00:00
kshitijk4poor 4f137c73fc refactor(desktop): one readStatusCode beside httpStatusError; log the reauth latch once
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.
2026-09-11 12:53:07 +05:30
kshitijk4poor 9cb9eda782 test(desktop): trim the reauth-latch suite to the two composition invariants
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.
2026-09-11 12:53:07 +05:30
kshitijk4poor e91ad53fda fix(desktop): a failed native refresh only confirms reauth on a dead refresh token
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.
2026-09-11 12:53:07 +05:30
kshitijk4poor 0dd6bee93a refactor(desktop): route every REST status error through httpStatusError
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.
2026-09-11 12:53:07 +05:30
Zeus-Deus 8d22781b1c fix(desktop): latch an expired remote OAuth session on the first confirmed 401 instead of flickering the Sign in overlay (#95701)
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).
2026-09-11 12:53:07 +05:30
kshitijk4poor 128e411023 fix(desktop): resolve passive reads inside the backend resolvers
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.
2026-09-11 12:26:01 +05:30
kyssta-exe 66a56cc337 fix(desktop): prevent background tile reconcile from starving pool slots (#103375)
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.
2026-09-11 12:26:01 +05:30
Teknium 6dfcf15685 fix(desktop): passive update checks use the GitHub API once a day, never git fetch
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.
2026-09-10 18:15:54 -07:00
Siddharth Balyan 4bdd64b334 The free tier is created in one place, at boot, only behind HERMES_GUEST_ONBOARDING=1 (NS-847) (#107697)
* 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>
2026-09-11 03:45:33 +05:30
yoniebans 8762a08b67 test(desktop): verify PATH probe child kill on timeout 2026-09-10 20:08:03 +02:00
KoNit-K f2b33fb4a2 fix(desktop): kill the PATH probe child on timeout 2026-09-10 20:08:03 +02:00
chelsealong 4b16a99f71 fix(desktop): preserve a sentinel already captured when force-settling
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.
2026-09-10 19:15:52 +02:00
chelsealong 00c0259bbc fix(desktop): force-settle the login-shell PATH probe past its timeout
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
2026-09-10 19:15:52 +02:00
Teknium 50ea107d9d fix(desktop): read an auto-TTS reply aloud once when the HUD is open
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.
2026-09-10 09:38:07 -07:00
Konstantin Khlopkov 62081b87b7 fix(desktop): give the Hyprland HUD float/pin promotion room for a lagging j/clients
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.
2026-09-10 09:37:47 -07:00
hermes-seaeye[bot] 110736c0bc fmt(js): npm run fix on merge (#107454)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-10 15:26:17 +00:00
Teknium 248ff2d3e8 feat(desktop): one row per plugin; desktop halves are app-level copies, never profile-scoped
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.
2026-09-10 08:19:55 -07:00
Teknium ff93f0ee9b fix(desktop): desktop plugins are app-level, not per profile; catalog gets a drag sash; Accent Picker moves to its own repo
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.
2026-09-10 08:19:55 -07:00
Teknium 8092f0e8ee fix(desktop): leaving HUD mode always gives the app window back (macOS)
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.
2026-09-10 08:19:13 -07:00
hermes-seaeye[bot] cfdbbb6e35 fmt(js): npm run fix on merge (#107102)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-10 04:49:08 +00:00
hermes-seaeye[bot] 425b00174d fmt(js): npm run fix on merge (#107101)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-10 04:43:55 +00:00
brooklyn! 78052fe437 fix(desktop): keep notification IPC types independent of the renderer
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.
2026-09-09 23:37:48 -05:00
brooklyn! 7107458d96 fix(desktop): route native notifications back to their source window
Capture durable focus identity before runtime bindings disappear, retain native handlers, and keep runtime approval actions and plugin callbacks on their source renderer.
2026-09-09 23:37:48 -05:00
Brian Sweatt f557829bff fix(desktop): retain notification handlers across banner dismissal
Adapted from https://github.com/NousResearch/hermes-agent/pull/65897 (a6fb6fe063e235c33b41212bd9cdaea4f9dcb1dc). Keep handlers across close, release on consumption or failure, and dismiss expired notifications before releasing them.

Co-authored-by: brooklyn! <brooklyn.bb.nicholson@gmail.com>
2026-09-09 23:37:48 -05:00
outdog-hwh a245d9aaa4 fix(desktop): resolve native notification action indexes
Adapted from https://github.com/NousResearch/hermes-agent/pull/51893 (a70b0991b2d72796479d63532ccf0fac15b17ba0).

Co-authored-by: brooklyn! <brooklyn.bb.nicholson@gmail.com>
2026-09-09 23:37:48 -05:00
hermes-seaeye[bot] 1675f1f2c2 fmt(js): npm run fix on merge (#107057)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-10 03:02:40 +00:00