Commit Graph

4774 Commits

Author SHA1 Message Date
John Paul Soliva 1eb771e2ff fix(desktop): the bot relay claims every outbox first and delivers per target, not one envelope at a time
drainRelayOutboxes drained one gateway's outbox and delivered its envelopes
before draining the next gateway, and delivered every envelope one after
another. One long turn — bot_relay.deliver may take up to
RELAY_DELIVER_TIMEOUT_MS, 25 minutes — therefore held every other bot's mail:
a sibling gateway's envelope sat unclaimed in its outbox, and since the
gateway checks the envelope's age against bot_mode.envelope_ttl_seconds
(15 minutes) at the claim, the sender's waiter received queued_expired for a
message nothing was wrong with; envelopes that did get claimed still waited
their turn behind unrelated deliveries, against a finite waiter.

Claim every gateway's outbox first, then deliver in lanes keyed by target
connection and profile: a lane runs its envelopes in order, one turn at a
time (the target gateway serialises that profile's turns behind its turn
lock anyway), and lanes run concurrently.
2026-09-15 18:51:23 -07:00
teknium1 672fd6b96f chore(desktop): drop a restating comment from the one-walk boot path
The WHY (one resolution instead of two) already lives on
resolveRendererIndexWithMissing; the call-site copy only repeated the code.
2026-09-15 18:50:24 -07:00
John Paul Soliva ab4bfda360 perf(desktop): resolve the renderer bundle once per window, not twice
createMainWindow walked the entire renderer generation twice before it
could call loadWindowUrl:

    const rendererIndex = DEV_SERVER ? null : resolveRendererIndex()
    const tornAssets = rendererIndex ? missingRendererAssets(rendererIndex) : []

resolveRendererIndex already computes exactly that list while choosing the
copy — it needs it to decide whether a copy is torn — and then throws it
away. missingRendererAssets is a BFS that readFileSync's every present
chunk whole and regex-scans it for the inline __vite__mapDeps table, so on
a release tree it is not a stat walk: measured against the real
apps/desktop/dist (252 chunks, 28.7 MiB of JS), one walk is 162
readFileSync calls reading 28.23 MiB, 576 existsSync calls, and 56.6 ms
median (min 55.8, 9 reps, warm page cache, darwin-arm64). Both walks run
synchronously on the main thread before the window gets its URL.

Return the list alongside the index. resolveRendererIndexWithMissing()
carries the existing body and hands back { index, missing }; the
path-only resolveRendererIndex() stays as a one-line wrapper so the nine
other call sites are untouched. The primary-window path takes one
resolution.

Semantics are unchanged in every branch: the same candidate is chosen, the
same log lines are emitted, and the missing list always describes the copy
actually returned. The all-copies-torn branch now reuses the first
candidate's list, captured on the first loop iteration, rather than
recomputing it for present[0] — recomputing there would have reintroduced
the second walk in exactly the case that matters most, and using the
loop's last value would have described a bundle we do not load.

Net effect on every primary-window boot: one fewer full walk, so 162 fewer
readFileSync calls, 28.23 MiB less synchronous reading, 288 fewer
existsSync calls, and ~57 ms of main-thread blocking removed before
loadURL. The win lands on packaged and --prod launches; DEV_SERVER skips
the walk entirely, so `vite dev` is unaffected.
2026-09-15 18:50:24 -07:00
teknium1 a22c731744 fix(desktop): refresh stale 4px scrollbar-width comments to 8px 2026-09-15 18:49:57 -07:00
teknium1 00c66225d4 fix(desktop): widen the portaled-menu scrollbar to match the app theme
`.dt-portal-scrollbar` is the same themed bar as `.scrollbar-dt`, applied
to overlays that portal under document.body (dropdown/context menus, the
command palette, the session and connection switchers). Widening only the
#root theme (#111634) would have left those lists on the 4px bar that was
too thin to grab; keep the two variants on one width (0.5rem = 8px).
2026-09-15 18:49:57 -07:00
kvnloo 22dc293f2a fix(desktop): widen themed scrollbars from 0.25rem to 0.5rem
The app-wide .scrollbar-dt theme (on #root) rendered 4px scrollbars
everywhere, including the conversation window, making them nearly
impossible to see or grab (#111634). Bump the webkit track size to 8px
so the thumb is actually hittable while staying a slim themed bar.

Fixes #111634
2026-09-15 18:49:57 -07:00
teknium1 b74f158b0d fix(desktop): do not prune the desktop half of a package folder the app cannot read
The ghost-prune loop in reconcileUnifiedDesktopHalves used existsSync on
the marker's source, which answers false for EACCES/EPERM as well as
ENOENT, so a mode-000 / ACL-denied package folder was treated as an
uninstall and its materialized half rm -rf'd with no warning.
Distinguish a genuinely missing source (ENOENT/ENOTDIR) from one the app
is not allowed to stat: keep the half and warn. materializeDesktopHalf
now also warns on a non-ENOENT stat failure instead of swallowing it.

Part of #111804
2026-09-15 18:48:59 -07:00
teknium1 b915405f92 fix(desktop): reconcile of unified plugin halves survives one unreadable package
`reconcileUnifiedDesktopHalves` let a stat/copy failure on one package
(`EPERM: lstat` on Windows in #111804; EACCES on a mode-000 file here)
reject the whole pass. The `hermes:fs:desktopPluginsRoot` IPC runs that
reconcile before returning the root, so the renderer never got a root and
every disk desktop plugin silently stopped loading. Warn about the one
package and keep materializing the siblings.

Part of #111804
2026-09-15 18:48:59 -07:00
teknium1 1cfa892db1 fix(desktop): make the zsh probe test legs visible and run them on CI
The zsh login-shell legs in remote-lifecycle.test.ts and
ssh-connection.test.ts silently returned when zsh was missing, and the
js-tests runner image ships no zsh, so the #111949 coverage never ran on
CI and a wrapper regression stayed green.

- js-tests.yml: install zsh on the Linux runner before the checks.
- Both legs now report vitest skips ('zsh not installed') instead of
  passing; the ssh-connection leg is its own test so the skip is visible.
- Docs: note the zsh degraded mode (no process-group kill for a hung
  probe's grandchildren) in the SSH connection guide.
2026-09-15 18:46:32 -07:00
teknium1 2388d401c9 test(desktop): capability probe through a real zsh login shell (#111949)
The user-visible failure was the ownership capability probe reporting a
current remote as unsupported when sshd ran it under a non-interactive zsh.
Pin the probe itself, not only the wrapper: run remoteSupportsSshOwnership
through `zsh -c` against a fixture CLI that advertises both flags; skipped
where no zsh is installed. Fails on the pre-fix wrapper (empty capture).

Co-authored-by: the-repeter <50600051+the-repeter@users.noreply.github.com>
2026-09-15 18:46:32 -07:00
KoNit-K 1267d7fdd0 fix(desktop): support zsh SSH probe watchdogs 2026-09-15 18:46:32 -07:00
teknium1 d30c05bebf fix(desktop): blank-line padding in the foreground-dial test 2026-09-15 18:46:08 -07:00
teknium1 5eb0ed4531 fix(desktop): Vault settings tab dials its scoped profile foreground
requestGatewayForProfile always dialed with the 'background' spawn priority,
so the Vault tab under the Settings 'Applies to' selector kept the #111651
infinite-spinner path on a cold profile even after the REST side moved to
scopedDialPriority. Let requestGatewayForProfile take a spawnPriority option
and pass 'foreground' from the Vault panel; ambient callers are unchanged.

Part of #111651
2026-09-15 18:46:08 -07:00
teknium1 d3ba5b307a fix(desktop): one scoped-dial priority helper; Capabilities selector dials foreground too
Fold the salvaged per-call ternaries in api/config.ts into a single
scopedDialPriority(scope) helper on api/client.ts and apply it to the
Capabilities scope selector's cold-start reads (getSkills / getToolsets /
getMcpCatalog), which hit the same background-capped pool queue when the
selector targets a stopped profile.

Drop the salvaged main.ts source-text change-detector test; keep only the
string update the existing #90812 wiring test needs. The renderer seam test
(hermes-capability-scope) is the invariant: an explicit scope carries
priority 'foreground', the ambient path stays untagged.

Part of #111651 (salvage #111672)
2026-09-15 18:46:08 -07:00
KoNit-K d6ff2777b7 fix(desktop): prioritize scoped settings backend dials 2026-09-15 18:46:08 -07:00
teknium1 2134950069 fix(desktop): keep the original boot error when post-spawn cleanup cannot prove ownership
In connect()'s post-spawn catch, a cleanupStale rejection (indeterminate
ownership probe) replaced the boot failure's message and kind. Catch it,
attach it as error.cleanupCause, and rethrow the original error.
2026-09-15 18:45:47 -07:00
teknium1 e368c01e28 fix(desktop): a lost SSH probe answer no longer kills or orphans a live remote backend
The Desktop SSH bootstrap proves the remote `hermes serve --isolated` alive
(`kill -0 … && echo ALIVE || echo DEAD`) and owned (argv probe printing
OWNED/FOREIGN) over the same SSH channel that is often mid-teardown right
after the served token was resolved. Both probes treated ANY answer that was
not the positive sentinel as the negative verdict, so an exec that resolved
with empty output was read as death: the boot failed with "remote dashboard
exited while its served token was being resolved", the post-spawn cleanup ran
the ownership probe on the same channel, read the lost answer as FOREIGN,
skipped the kill and removed the lockfile — one orphaned ~144 MB backend per
failed attempt, ten in one session on a 1 GB host (#111810).

`execProbeVerdict` now runs both probes: an answer that is neither sentinel is
indeterminate and is retried over a short bounded window (3 attempts, 500 ms);
with no definite answer it fails closed with a `transient-transport-error`, so
`cleanupStale` keeps the ownership record for the next connect to reap instead
of leaving a lockless orphan. The post-spawn catch probes liveness first so a
child that genuinely died at startup skips the ownership proof and the
original error ("exited before announcing") stays visible.

Slimmer redo of #111828 by @kokhlo: same mechanism, without the parallel
`verifyRemotePidAlive`/`verifyPidOwnership`/`readOwnershipVerdict` layer that
left `remotePidAlive` and the original probe as dead duplicates.

Fixes #111810

Co-authored-by: Konstantin Khlopkov <47825603+kokhlo@users.noreply.github.com>
2026-09-15 18:45:47 -07:00
teknium1 d4d9cf16d9 docs(desktop): record the partition-name invariant and the one-time re-sign-in
The salvaged comment promised the path component stays within
[A-Za-z0-9._-], but sanitizePartitionComponent() also emits the other
characters encodeURIComponent leaves alone (!~*'()). State the real
invariant — nothing Electron percent-escapes, pinned by the test as
"no ':' and no '%'" — and record the migration decision: one partition
name on every platform, so a non-primary cookie-auth remote signed in
on macOS/Linux under the old `:conn:` name is re-prompted once, and the
old `%3Aconn%3A` folder stays on disk, inert. apps/desktop/AGENTS.md gets
the rule so the next partition does not repeat #92183's folder name.
2026-09-15 18:45:13 -07:00
Peyton Lu 401a7d087a fix(desktop): keep cookie partitions colon-free so Windows jars work
Electron escapes ':' in a session partition name as '%3A' for the on-disk
profile folder. On Windows, a profile folder whose name contains '%3A' gets a
cookie store the network stack can neither read nor write: a jar placed there
reads back zero cookies, `cookies.set()` never reaches disk, and every request
goes out with no cookies at all — the gateway answers 401 `no_cookie` and the
desktop concludes the user is signed out.

Per-connection partitions (#92183) are the only ones carrying a colon beyond
the 'persist:' prefix, so every NON-primary cookie-auth remote hits this: the
dial mints no ticket, the reauth copy fires ("Remote Hermes gateway uses
OAuth, but you are not signed in…"), and the login window — riding the same
partition — writes into the same invisible jar. Nothing ever persists, so the
prompt returns on every connect. The registry primary and the v1 remote keep
the legacy `persist:hermes-remote-oauth` jar, whose folder has no '%3A', which
is why a single-gateway setup never shows the symptom.

Verified against the app's own Electron runtime (40.10.2, Windows): identical
jar bytes seeded into two partition folders read 3 cookies and mint a
ws-ticket (200) from `…-conn-…`, and 0 cookies / 401 from `…%3Aconn%3A…`.

Keep the partition path component inside [A-Za-z0-9._-]; a regression test
pins that it never contains ':' or '%'.
2026-09-15 18:45:13 -07:00
teknium1 5d2acf7066 test(desktop): pin deferred launcher-entry writes and the terminal-launch control
- cmd_gui with DESKTOP_STARTUP_ID: the entry is installed only after the fake Electron
  writes the reveal byte to HERMES_DESKTOP_READY_FD (spawn precedes install).
- cmd_gui without DESKTOP_STARTUP_ID (control): install still precedes the spawn.
- DeferredDesktopEntryInstall.finish(): an app that exits without revealing heals
  exactly once, after the exit.
- linux-launcher-ready: one byte, fd closed, variable consumed; garbage/absent = no-op.

Docs: user-guide/desktop.md explains when the entry write happens for grid launches.
2026-09-15 18:29:37 -07:00
teknium1 169c48fa13 fix(desktop): app-grid launches write hermes.desktop only after the window is on screen
`hermes desktop` used to create/refresh `~/.local/share/applications/hermes.desktop`
synchronously before spawning Electron. When the entry is ABSENT (first run after an
update, deleted by a cleaner, tombstoned by AV) that write lands while gnome-shell still
has the grid-launched ShellApp in STARTING; unpatched shells (before GNOME MR !4428)
drop the app's last strong reference on `installed_changed` and the next idle GC kills
the whole Wayland session, minutes to an hour later (#111906, residual after #111396).

Now a launch that carries `DESKTOP_STARTUP_ID` (app grid / menu) defers the write:
the launcher opens a pipe, hands Electron its write end as `HERMES_DESKTOP_READY_FD`
(pass_fds), and a worker thread installs the entry once Electron reports the main
window revealed (`onRevealed` in createWindow, via the new
`apps/desktop/electron/linux-launcher-ready.ts`), plus a 2 s settle so the compositor
has mapped the surface. If Electron exits without ever revealing a window, `finish()`
installs the entry after the exit (STOPPED app, no STARTING object) — self-heal
semantics survive. Terminal launches, the updater's detached relaunch and
`--build-only` have no DESKTOP_STARTUP_ID / spawn no app and keep writing immediately.

Why not simply write after Electron exits (#111915's approach): a daemon thread
started right before `sys.exit` is killed with the interpreter, so the heal is lost,
and a heal that waits for the user to quit the app leaves the menu entry missing for
the whole session.

Co-authored-by: KoNit-K <124019182+KoNit-K@users.noreply.github.com>
2026-09-15 18:29:37 -07:00
teknium1 c91770b268 fix(desktop): route tile title heals when its plugin route registers late
A route tile restored (or opened) before its plugin route registered kept the
humanized-path fallback as its tab title forever: paneMirror recomputes titles
only when one of its atoms changes, and watchRouteTiles listened to $routeTiles
alone. #112147 made the pane CONTENT heal on late registration; the title
still lagged.

routes.ts exposes $routesVersion, an atom bumped from registry.subscribeArea
(ROUTES_AREA) only while listened to, and watchRouteTiles passes it as `also`
so the sync re-runs and the pane re-registers with the contribution's title.
One test, red on origin/main.
2026-09-15 15:07:24 -07:00
teknium1 212f661048 fix(desktop): remembered plugin page survives boot when the session list beats disk plugins
A remembered plugin-page route is session-shaped until its route registers,
and disk plugins load through an async IPC chain. When the backend was
already running (remote/URL connection, macOS close-reopen with the process
alive) the session list could arrive first; the restore effect then read
'/html-gallery' as a session id nobody owned, navigated to the last chat and
erased the remembered route, so the page was lost on every later boot too.

The disk door now publishes $diskPluginsScanPending for the duration of its
first scan, and the restore latch holds a session-shaped remembered route
while that scan is pending, mirroring the existing "sessions not loaded yet"
latch. Once the scan settles the route is either a registered page (restored)
or a genuinely stale session (dropped as before). Two tests: the late
registration restores; the stale session still drops.
2026-09-15 15:07:03 -07:00
Austin Pickett 05e82b741d fix(desktop): Tab Strip → Auto copy names the other-zone exception 2026-09-15 16:07:39 -05:00
Austin Pickett 36010d0bba fix(desktop): a lone chat keeps its tab strip while another chat zone is open
Dragging a session tab out of the main strip into its own zone left the
workspace alone in main. Auto treated a lone pane as "not a tab", and the
uncloseable workspace is not stranded, so main lost its tab and its "+" —
while the tile it had just split from kept a strip of its own. Two chats
side by side, one with tabs and one without, reads as "the tabs
disappeared"; ⌥⌘T only helped because it wrote an explicit `always`.

The resolver gains one input, `siblingMainZone`: on auto, a lone main tile
keeps its strip whenever another zone in the layout also hosts a main tile.
A chat that is the whole window stays chromeless, side chrome is untouched,
and an explicit `never` still wins so Hide tabs keeps working.

Both callers read it from `$mainTileZoneCount`, a computed over the tree,
the hidden set and the registry version. It is a number, so zones re-render
only when a main zone appears or goes — never per sash-drag frame — and the
store's answer (what the toggle command flips against) and the strip on
screen cannot disagree.
2026-09-15 16:07:39 -05:00
teknium1 eaef52371f fix(desktop): late-contributed keybind actions reach the Keybinds settings map
Same class as the plugin-route bug: KeybindSettings subscribed to
useContributions(KEYBINDS_AREA) but discarded the snapshot and called the
impure allKeybindActions() in render, so the React Compiler memoized the
action list without the subscription as a visible input. A plugin keybind
registered after the tab mounted never appeared, despite the comment
promising "appear/disappear live".

allKeybindActions()/contributedKeybinds() take an optional contribution
snapshot (default: registry read, so the store/imperative callers are
unchanged) and the settings tab passes its subscription through. One test,
red on origin/main.
2026-09-15 12:08:52 -07:00
teknium1 51c9e8932a test(desktop): trim the #109063 suite to one invariant per surface
Drop the contributedRoutes() helper-contract tests (they pin the helper's
signature, not the user-visible failure) and the hot-replace/dispose
variants of the route-tile test: each surface now carries exactly one test
that is red on origin/main and green with the fix.
2026-09-15 12:08:52 -07:00
rahlquist adbf2103cd test(desktop): late-registered plugin route wins over the chat catch-all
Regression test from #111541: mounts ChatRoutesSurface at a not-yet-registered
path through the real useContributions + registry under the compiler-enabled
vitest ui project, registers the route after mount, and asserts the page
replaces the `:sessionId` chat fallback. Red on origin/main, green with the
snapshot-fed contributedRoutes(). Replaces the equivalent surface test from
#109418 so the workspace surface carries one invariant test.
2026-09-15 12:08:52 -07:00
liuhao1024 6fc7032aac fix(desktop): feed the ROUTES_AREA subscription snapshot into contributedRoutes (#109063)
React route consumers subscribed via useContributions(ROUTES_AREA) but
discarded its snapshot, deriving routes through an independent imperative
contributedRoutes() call. With React Compiler enabled the derived routes
could stay memoized across a late registration or hot replacement the
subscription DID deliver, so an installed plugin's page never mounted
while its sidebar entry did (#109063).

contributedRoutes() now accepts an explicit contribution snapshot,
defaulting to the registry read for imperative callers; both React
consumers (ChatRoutesSurface, RouteTilePane) pass their snapshot through.
2026-09-15 12:08:52 -07:00
teknium1 3272fb35aa docs: profile-scope invariant in AGENTS.md — one process serves many profiles; out-of-turn code binds its scope
Root AGENTS.md § Code Shape Rules replaces "module-level constants are fine — they cache after
_apply_profile_override() sets HERMES_HOME" (true for `hermes -p x <cmd>`, inverted under the
multiplex gateway and the Desktop/dashboard `serve` backend, where os.environ holds the LAUNCH
profile) with the invariant: a profile = home + secret scope + terminal scope, bound per profile
ACTIVITY, and every execution point with no turn on the stack binds it explicitly. Names the real
seams: gateway/run.py::_profile_runtime_scope, tui_gateway @_profile_scoped +
_session_profile_runtime_scope (+ _profile_runtime_scope_tokens, launch_profile_policy ->
set_multiplex_active), cron/scheduler_provider.py::_profile_cron_scope,
gateway/run_agent_cache.py::_run_release_in_profile_scope, tools/environments/local.py::
served_profile_child_env, agent/memory_provider.py::spawn_context_thread. Adds a routing-table row
for profiles / multiplex / secret scope.

Area AGENTS.md paragraphs, one per seam, for gateway/ (activity-not-turn binding, hooks per
profile, adapter YAML never reaches os.environ, unserved shared-ingress reported via
_note_unserved_secondary_platform + needs_attention at the single writer), tui_gateway/ (RPC
binding is home AND secret AND terminal; HOME-only is half-bound; teardown chokepoint), cron/
(per-home tick lock, ticker scope incl. pre-loop code, kanban notifier routing, worker liveness by
(pid, worker_started_at) fingerprint, descendant fence as a path), hermes_cli/ (DEFAULT_CONFIG
key <-> reader parity, service-install matrix, -p vs multiplex home binding), tools/ (check_fn
reads through get_secret and is cached per hermes_home_key, one env builder per spawn, MCP trust
per profile), plugins/ (lifecycle hooks are bound by the caller; never cache the home from
initialize()), apps/desktop/src/ (pooled serve per (connection, profile); remote topologies),
agent/ (end-of-session flush is caller-bound; set_multiplex_active gates fail-closed).

Corrects the statements the multiplex model made wrong, in the same PR: root module-constant
sentence; hermes_cli "sets HERMES_HOME before any import" (+ cli-internals.md);
ADDING_A_PLATFORM.md §2 raw os.getenv loader (now an _ENV_STEPS row through config.py::_getenv)
and §4 platform_env_map in gateway/run.py (now _PLATFORM_ALLOWLIST_ENV in pairing.py + registry
allowed_users_env); platform_registry.py "may set os.environ (guard with not os.getenv)";
cron/AGENTS.md hardcoded ~/.hermes/cron/.tick.lock; gateway-internals.md agent:main as THE key
format, ~/.hermes/hooks/, single-profile `gateway stop`, plus a new "Multiplexed profiles"
section; tools/AGENTS.md os.getenv check_fn sample; "installed per turn" wording; "one temp
HERMES_HOME" E2E wording; multi-profile-gateways.md intro lists system units, Windows tasks, s6
and the Desktop backend.
2026-09-15 10:59:22 -07:00
brooklyn! 673d33095b fix(desktop): keep code geometry stable while highlighting loads 2026-09-15 12:33:45 -05:00
brooklyn! 3ccad12c0a fix(desktop): preserve reading intent across history and pane changes
Co-authored-by: wukangcheng1994 <160389295+wukangcheng1994@users.noreply.github.com>
Co-authored-by: Zhuzewen <zhuzw@seer-robotics.ai>
Co-authored-by: KoNit-K <124019182+KoNit-K@users.noreply.github.com>
Co-authored-by: nftpoetrist <264138787+nftpoetrist@users.noreply.github.com>
Co-authored-by: aydnOktay <xaydinoktay@gmail.com>
Co-authored-by: FalconOrtiz <falcon.ortiz11@gmail.com>
Co-authored-by: Ahmett101 <ahmet.tunc@gmail.com>
Co-authored-by: Val Alexander <val@opencoven.ai>
Co-authored-by: networthexplained <networthexplained@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 12:33:45 -05:00
brooklyn! 76f9bf948b fix(desktop): retain scoped transcript paint through background hydration
Co-authored-by: Benjamin Brumbaugh <benbrumbaugh@gmail.com>
Co-authored-by: Daisuke Suzuki <dai.suzuki.829@gmail.com>
Co-authored-by: xrbs00 <178640517+xrbs00@users.noreply.github.com>
2026-09-15 12:33:45 -05:00
teknium1 69fd61b0ef fix(desktop): anchor the first-paint backfill so long sessions stop lurching (#99920)
Switching to a long tool-heavy session still painted the tail, then jumped a
full viewport when the first backfill step committed, on current main.

Root cause: anchorBeforePrepend() skipped while the load was unsettled, but the
settle loop hands a bottom-pinned load back at the FIRST-PAINT height, before
the backfill transition commits. Every backfill step therefore ran unanchored;
the prepend grew scrollHeight by thousands of px with scrollTop untouched and
the view sat near the top until use-stick-to-bottom's ResizeObserver re-pinned
it frames later (measured CLS 0.6-1.0 per switch, layout-shift entries of 0.61).

Fix:
- anchor a bottom-pinned load even while unsettled (shouldAnchorBeforePrepend);
  an unsettled offset restore still never anchors, the settle loop owns it
- record the anchor's scrollHeight and consume it only on a taller tree, so a
  commit that does not grow content (transcript arriving under the first-paint
  budget, a store-window refresh) cannot spend the anchor as a no-op
- clear any anchor on a cold switch, not only on a key change: the stale rows
  shown under the new key are collapsed by the swap and the anchor with them
- do not arm the backfill while the transcript is empty or nothing is hidden

Live A/B (isolated Desktop, Xvfb, CDP layout-shift capture, 3 rounds x 2 long
sessions): main CLS 0.19-0.65 with one-frame 3-4k px scroll jumps every switch;
fixed CLS 0 on every warm switch and no scroll jump (cold first open of a
session keeps only the sub-0.1 first-paint shifts). Show-earlier and remembered
offset restore controls unchanged.
2026-09-15 08:51:14 -07:00
teknium1 043632e283 feat(desktop): hideable profile rail with a statusbar profile dropdown stand-in
For people who run profiles as bots, the colored profile strip at the sidebar
foot duplicates the sessions list (community request). Add a persisted
`hermes.desktop.profileRailVisible` preference (on by default) toggled from the
Sessions view menu ("Profile rail"), the shell right-click menu, ⌘K
("Toggle profile rail") and an unbound `view.toggleProfileRail` keybind.

While the rail is hidden the statusbar grows a `ProfileSwitcher` dropdown
beside the gateway switcher ("This device ⌄ · Profiles ⌄") offering the same
choices the rail does: this gateway's profiles, All profiles, every other
gateway's agents in fleet mode, New / Import / Manage. It also answers the
`profile.create` hotkey the rail used to own, so no door is lost.
2026-09-15 08:50:18 -07:00
teknium1 6bc3628ce8 fix(desktop): drag gateway/profile groups by their header, not only the hidden handle
The Gateway & profile sidebar sections carried dnd-kit listeners on the lead
glyph alone, and that glyph only reveals its grabber on hover — so a press on
the row's name or empty space did nothing and users fell back to the ⋯ menu's
Move up / Move down. Bind the sortable to the whole header (same shape as a
project row), with the handle and the ⋯/caret cluster keeping their own
gestures; a sub-threshold press on the label is still the fold click.

Live: Playwright pointer drag on the header label reorders sections (before:
unchanged, after: reordered); handle-drag still works. Test drives dnd-kit's
keyboard sensor on the label and is red on the old binding.
2026-09-15 08:50:18 -07:00
kshitijk4poor 658f319147 fix(free-tier): setup.ready carries the failure block flat, the shape setup.status already spreads
`SetupRecord.as_payload()` serialised the record verbatim, so the broadcast
nested `failure: {...}` while `setup.status` spread the same four keys flat.
A client keyed on `error_code` saw it on one surface and not the other. Flatten
it in `as_payload`, declare the three optional keys on `SetupReadyPayload`, and
regenerate the TS/OpenRPC contract.
2026-09-15 20:44:42 +05:30
Robin Fernandes 1034215ae8 docs(free-tier): drop the rehearsal page and its references
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 20:44:42 +05:30
Robin Fernandes a241f42fc8 copy(free-tier): "it's free" without "keeps the free model" — signed-in free models are not the same model
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 20:44:42 +05:30
Robin Fernandes 16def7b8cc fix(free-tier): the desktop renders a free-tier refusal as its own card, not an OAuth re-login
A welcome-tier 403 classifies as auth_permanent, so the desktop's error
surface mapped it to "Your Nous Portal sign-in expired" with a Nous Portal
re-login button — the chat sentence never reached the user. Terminal results
on the free route now carry a structured free_tier block (kind + the chat
sentence); agent/error_surface.py turns it into a free_tier_<kind> code on
the provider layer with the sentence as `message`. The desktop gives those
codes their own titles, shows the backend sentence as the body, and offers
"Sign in with a Nous account" (the free-tier dialog) instead of the OAuth
re-login, with Retry only where a later send can succeed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 20:44:42 +05:30
Robin Fernandes d89cacc25f chore(free-tier): keep the rehearsal server out of the repo; the docs page explains the stand-in instead
The fault-injecting server served one-off manual rehearsal only and would
drift silently from the real services; the doc now says how to point the
desktop at any local stand-in (the three env overrides) and what such a
stand-in has to speak. The dev-only HERMES_EXTRA_WELCOME_HOSTS override stays,
pinned by a test in test_anon_failure_modes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 20:44:42 +05:30
Robin Fernandes 59fad62a40 fix(free-tier): review follow-ups — read the classifier's context, never replace a locked identity, re-inventory on retry
Correctness
- The welcome-tier recovery hooks (model_not_free move, wrong-host heal) and
  the long-wait rate-limit check read the turn's extract_api_error_context()
  dict, which never carries welcome_refusal / welcome_route. They now read
  classified.error_context, where _nous_welcome_tier parks them; the guard
  records the classifier's reset_at. Tests drive the real classifier and the
  real extractor so the two-context boundary is exercised.
- The connector path caught every AnonCredentialDead and re-minted; a locked
  account (anon_account_locked) is now retired without replacement, matching
  the inference resolver.
- A background bootstrap retry reused the boot-time provider inventory; it
  re-inventories, so a provider connected during the cooldown keeps
  inference.
- The desktop's setup.ready listener only refreshes an untouched picker
  (oauth mode, no local endpoint, idle flow) and re-checks after the
  readiness round, so an API-key form opened meanwhile is never dismissed.
- /__log on the rehearsal server sent its response while holding the state
  lock that _send re-acquires; the log is copied out first.

Reductions
- One shared FakePortal / install_portal (tests/hermes_cli/anon_portal.py)
  behind both free-tier fixtures, with a single httpx.Client transport seam.
- The rehearsal server's static inference answers are a table; dead
  scaffolding (REAL_PAID_URL, claim_codes, the no-op dead_once branch,
  extra_headers) removed.
- Setup-notice copy is a code-to-key map; its test uses real codes (the old
  loop built nonexistent ones and only exercised the fallback).
- The ineffective FreeTierErrorCode union is gone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 20:44:42 +05:30
Robin Fernandes 51e39af967 feat(free-tier): ruled behaviour for every welcome-api failure, with friendly copy and a fault-injecting rehearsal server
The free tier depends on the account service (NAS) and the welcome inference
host, and Hermes had no honest answer for most of the ways either can refuse
or fail: the NAS codes it matched were never sent, the tier-dark 403 carried
no message to match, a single boot-time blip disabled minting for the whole
process, and a structured rate-limit refusal never reached the cross-session
guard, so the "sign in for a bigger allowance" prompt was dead code.

Backend
- anon_auth: classify what NAS actually sends (404 not_found, 503
  temporarily_disabled, 429 + Retry-After, 428 pow_*, 403 account_locked)
  into one ANON_* code each, carrying retry_after / retryable on AuthError.
- Replace the process-lifetime mint memo with a per-profile cooldown that
  honours the server's wait, climbs a short ladder when the service is
  unreachable, never retries terminal codes, and yields to the user's own
  retry (force=True).
- Bootstrap record carries error_code / retryable / retry_after; a bounded
  background loop retries transient failures and re-announces setup.ready.
  setup.status and free_tier.status expose the block; free_tier.provision is
  the forced retry.
- Inference: a generic 403 from a welcome host is the tier refusing (keyed on
  the route); model_not_free moves onto the gateway's alternate once;
  anon_on_paid_host re-reads the route once; a long rate_limited refusal
  trips the cross-session guard; a locked account is retired but never
  replaced; terminal copy on the free route is one plain sentence.
- Sign-in: Failed keeps the service's code and wait; account_busy is
  retryable; the OAuth poll reports retryable / retry_after.
- All user-facing copy rewritten for first-time users: never "the free
  service is off" (what is unavailable is using Hermes without signing in,
  and signing in is free), no jargon, spoken waits.

Desktop
- A setup-failure notice above the provider picker: one sentence per code,
  a retry when the backend says one can work, the sign-in pointer only when
  the account service answered at all. The overlay re-checks readiness on
  setup.ready so a background success dismisses it.
- Sign-in dialog gains busy / unreachable / unavailable screens.

Rehearsal
- scripts/free_tier_fault_server.py stands in for both services with the
  real wire contract and a CORS-open scenario switch; HERMES_EXTRA_WELCOME_HOSTS
  (dev-only, env-only) lets the route rules treat it as the welcome host.
  Walkthrough in website/docs/developer-guide/free-tier-fault-rehearsal.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 20:44:42 +05:30
teknium1 15ebf7ec90 test(desktop): drop the never-assigned descriptor-profile knob from the connections mock
The salvaged switch-back regression only ever exercised the profile-less
primary shape; the mock's string branch had no writer. Publish the
profile-less local descriptor unconditionally and say why.
2026-09-15 06:32:53 -07:00
Konstantin Khlopkov 825324fdc9 fix(desktop): restore the last-used profile when the primary local descriptor is profile-less
The local primary backend (startHermes) publishes its descriptor without a
profile field, unlike the pooled/forced-local child path. Downstream, a
profile-less descriptor normalizes to 'default', so on a switch back to the
local source the store either refused to remember the source/profile pair or
killed the commit in targetIsActive() — landing the window on 'default'
every time (#110819).

- The primary local descriptor now carries the profile it booted with, and
  the unshared primary branch of ensureBackend() keeps the requested key on
  the descriptor instead of dropping it.
- selectConnection() tolerates a profile-less descriptor on the source it is
  landing: the activation already published the route that was asked for, so
  a missing profile no longer strands the switch.
2026-09-15 06:32:53 -07:00
teknium1 d735097376 fix(desktop): keep createdBy=learn through the starmap share code
The share-code codec encodes createdBy through a fixed table that only
knew none/agent/user, so idxOf returned 0 for 'learn' and a learned
skill round-tripped as createdBy: null. The 2-bit field has a free slot,
so 'learn' now occupies it, and metaBadges labels agent- and
learn-created skills alike as 'learned' — matching the Python side,
where both values count as learning signals.

Review finding: learn nodes lost createdBy in share-code round-trip.
2026-09-15 05:38:31 -07:00
teknium1 ee6fb0ed22 fix: clear the relay roster per sole connection, not once per below-two regime
rosterCleared was a single boolean latched the first time the peer set fell
below two connections. When the sole connection a was replaced by c between
ticks (still length 1) the flag stayed set, so c's gateway never received
the empty-roster push and kept the stale roster. Track the id of the
connection that got the clear instead and push whenever the sole
connection's id differs; reset once two or more connections relay again.

Review finding: routes [a] → tick → routes [c] → tick pushed no roster clear to c.
2026-09-15 05:19:12 -07:00
teknium1 c02a269351 fix(desktop): an empty relay route list does not spend the one roster clear
relayConnections() returns [] before the registry loads (and whenever the
host bridge is missing). The below-two clear must not latch on that empty
list, or the single connection that arrives on the next tick never gets its
roster cleared. Gate the clear on exactly one connection; formats the
salvaged test file.
2026-09-15 05:19:12 -07:00
John Paul Soliva 8b091e539c fix(desktop): the relay clears the remaining gateways' remote roster when the peer set drops below two
syncRelayRosters returned early with fewer than two connections, so a machine
removed from the registry stayed in every remaining gateway's
bot_relay/roster.json — still listed in each bot's system-prompt roster and
still a message_agent target — until a second connection appeared again.
Push the now-empty roster once when the set shrinks below two (and once at
start with a single connection, for a roster left behind by an earlier peer);
union pushes resume when a peer returns.
2026-09-15 05:19:12 -07:00
teknium1 33cd423d56 fix: remote probe watchdog kills the probe's process group, not just its child
The watchdog SIGKILLed only the direct child of the wrapper. A remote
`hermes` launcher that runs the CLI without exec leaves the hung grandchild
alive after its parent dies — exactly the broken-launcher class from
#110478 — so the probe still orphaned a process on the remote. Enabling
job control (`set -m`) around the probe start puts it in its own process
group; the watchdog now kills `-$__htp` first and the direct pid as the
fallback for shells that cannot turn job control on without a tty (dash),
where behaviour is unchanged.

Review finding: watchdog SIGKILLs only its direct child; a non-exec launcher leaves the hung grandchild orphaned.
2026-09-15 05:18:28 -07:00