Salvage follow-up to the cherry-picked #112055 hunk (@Finn763):
- tests: fold the landed-commit control into the turn-hold-miss test and drop the
two extra end-to-end tests (sub-limit identity is already pinned by the pure
contract test) so the fix carries exactly two invariant tests.
- gateway/run_turn.py::bound_model_input_without_hygiene: drop the isinstance /
limit<=0 defences — the caller always passes the transcript list and a
_knob()-validated positive int, so the checks could never fire.
- docs: configuration.md now says hygiene_hard_message_limit also bounds the
model payload on any turn where hygiene did not land (payload-only, disk
untouched, landed summaries adopted as-is).
Co-authored-by: Noor-Sol <187156657+Noor-Sol@users.noreply.github.com>
Co-authored-by: Kevin Rajan <7121943+kvnloo@users.noreply.github.com>
A long-lived gateway DM rots because a turn where hygiene did not commit
(turn-hold expiry, idle/ceiling timeout, cooldown, or one already in flight)
fed the model the FULL uncompressed transcript — watermark-fenced summaries
can land late, but a turn with none had no fail-closed bound at all.
bound_model_input_without_hygiene() keeps the leading system/session_meta
setup rows plus the newest tail, total <= hygiene_hard_message_limit, and
never starts the kept tail on an orphaned tool result. It is deterministic
and payload-only: the stored transcript is untouched, so the agent's durable
prefix slice (history_offset) and the disk rows are unchanged, and the
hygiene compressor still sees the full transcript.
Applied at the three exits of _hmwa_run_session_hygiene where hygiene did
not land. A landed compression publishes a NEW list on attempt.history, so
object identity (`attempt.history is history`) decides — an adopted summary
is passed through byte-identical, and below the limit the bound returns the
same list object (no copy, no behaviour change).
The durable-run events stream (`GET /v1/runs/{id}/events`) hardcoded its own 30 s idle
keepalive, so it still fell outside the ~20 s idle deadline common to remote
OpenAI-compatible clients even after CHAT_COMPLETIONS_SSE_KEEPALIVE_SECONDS dropped to
10 s. Point it at the shared constant so every API-server SSE writer (chat completions,
Responses, native session stream, run events) keeps the wire alive on the same cadence.
Rewrite the salvaged regression test as two invariants: an idle OpenAI stream writes a
`: keepalive` comment inside the remote-client window (module-local fake clock, no global
`time.monotonic`/`asyncio.wait_for` patching, which would also steer asyncio), and the run
events stream follows the shared constant end-to-end through aiohttp. Both are red on
origin/main.
The streaming section documented only the manual model.streaming escape
hatch; users hitting a degraded gateway need to know the session flips to
non-streaming on its own and why the warning appears.
Drop the raw JSONDecodeError belt from _is_provider_stream_empty_frame_error:
every chat stream iteration goes through _iter_provider_stream_chunks, which
already translates the decode failure, so the belt guarded a path that does
not exist. Drop the explicit classifier entry for the new code: unknown codes
already resolve to the same retryable unknown verdict (verified live: identical
ClassifiedError with and without the entry).
An empty `data:` frame (or a frame carrying only `event:` / `id:`) is a legal SSE
no-op, but the OpenAI SDK still hands it to `json.loads`, which raises
`JSONDecodeError` with an empty document. The streaming helper translated that
into `ProviderStreamError(provider_stream_non_json_data)`, nothing recognised it
as recoverable, and the main loop retried streaming -- identically -- until the
retry budget ran out: "API call failed after 3 retries: Provider stream returned
non-JSON SSE data". A degraded gateway answers EVERY streaming request that way,
so the retries only repeated the failure while the same request sent
non-streaming succeeded seconds later.
An empty document means no payload, which is a different fact from a malformed
payload: give it its own code, and when it appears before any delta switch the
session to non-streaming (the existing `_disable_streaming` mechanism, already
used for "stream not supported", Bedrock IAM denials and adapter-returned final
responses) so the retry goes out on a channel the degraded gateway can answer.
Non-empty payloads keep today's fatal semantics; failures after deltas keep the
existing stream-drop handling.
Tests: the turn-level recovery test drives the real SDK decoder over a real
httpx response and is red on base (three identical streaming attempts, turn
lost); the boundary test pins the malformed-payload path so a future "ignore bad
frames" change cannot swallow genuine provider errors.
A multiplex gateway runs plugin discovery for every served profile, and each
discovery ran plugin_compat.scan_plugin (an ast.parse + two ast.walk passes per
file) over every external plugin: on an 11-profile host that was ~0.4s per
profile of identical work on the gateway boot path, ahead of adapter connect.
The scan result is now cached process-wide on the plugin dir's (relpath,
mtime_ns, size) signature whenever the loaded manifest is used; a changed file
rescans, a caller-supplied manifest bypasses the cache.
Measured over the 11-profile MCP discovery loop on the reporting host:
3.78s -> 3.01s; per empty profile 0.25-0.30s -> 0.11s.
Salvage of #111385 (@JoaoMarcos44): the 30s -> 120s settle window is kept so a
slow host's gateway can publish its state stamp. This commit bounds the other
side of that trade: when every restarted systemd unit reports neither active
nor activating, the successor has died and nothing will ever publish, so the
poll fails closed at once instead of spending the full 120s. Unknown states
(no units, systemctl missing or slow) keep waiting. Also drops the test
assertion pinning the 2s poll cadence.
A systemd unit can be active before the replacement gateway finishes
bootstrap and publishes gateway_state.json. The 30s poll in
_collect_fleet_snapshot() then returned [] with rows_expected=true,
causing _verify_fleet_after_update() to mark the restart incomplete and
exit(1) before clearing fleet_restart_pending. Every later CLI
startup/doctor therefore printed a false "did not restart" warning even
though the live gateway already served expected_sha.
Allow the default systemd startup budget plus publication slack by
extending the bounded settle window to 120s. Keeps fail-closed on
stale/down/empty after the deadline, only widens the window for slow
bootstraps (e.g. Raspberry Pi).
Fixes#111272
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.
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.
`_readiness_check` bound a secret scope only for a named non-launch
profile and used nullcontext for the launch profile. Once the process
multiplexes (`set_multiplex_active(True)`) `get_secret` fails closed, so
the launch profile's `setup.runtime_check` died on the first profile-scoped
read inside `resolve_runtime_provider` (`HERMES_CODEX_BASE_URL` in
`_pool_entry_mode_and_url`, added by b62bb2a3d5) and the Desktop showed
onboarding while sessions — which resolve under
`_session_profile_runtime_scope` — worked fine.
Route the launch profile through the same helper: `profile_home=None`
binds the launch profile's frozen `.env` scope only when multiplexing is
active (`_profile_runtime_scope_tokens` returns None otherwise, keeping
the single-profile `os.environ` fallthrough for systemd / `op run`
injection). The unknown-profile `ok:False` answer is untouched — no
`@_profile_scoped`, whose `_profile_home` raise would turn it into an
error.
Slimmer shape than the PR's `scope_launch_profile` flag: the flag guarded
nothing the helper does not already decide, and setup.status reads the
same `.env`-derived state.
Fixes#112061
Invariant for #111775 on the registered RPC: primary AuthError + one complete
fallback entry -> ok:True with the fallback provider and model (what
_make_agent builds); explicit `provider` still answers the primary's strict
failure. Red on origin/main (ok:False, "No Anthropic credentials found.").
Without an explicit `provider`, `setup.runtime_check` called the strict
`resolve_runtime_provider(requested=None)` while `_make_agent` goes through
`_resolve_agent_model_runtime` (startup model + provider pin, then the
configured fallback chain). With the primary blocked and a working fallback
entry the probe answered ok:False (the primary's auth error) and the Desktop
showed onboarding for a backend whose sessions built fine. The probe also
reported `model: null` because the resolver never populates that key.
The default case now runs the session builder's resolver; an explicit
`provider` stays a strict single-provider check (onboarding verifies the
provider just connected — another provider's fallback must not mask a failed
connection) and resolves against the startup model like the session would.
Both branches report the selected model.
Fixes#111775
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.
Every entry is pinned to the commit reviewed on 2026-09-15 and passed, at that
commit: `hermes plugins validate` (declared tools/hooks/middleware match
registrations, no built-in tool collisions) in both the main venv and a bare
`pip install -e .` venv, `tools.plugin_guard.scan_plugin` with a non-dangerous
verdict, a self-updater grep over every .js file, and a vendor-credential-store
write grep over every .py file. Capabilities and requires_env are copied from
each plugin.yaml; category was assigned by hand.
Teknium ruled that maintainers may add batches of community plugins from a
reviewed sweep instead of waiting for each owner to submit. Rule 5 and the
user-guide checklist now say so, and give authors the explicit right to adjust
or remove a swept-in entry via their own PR.
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.
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.
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.
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.
Checkpoints were enabled by default from 9e845a6e (2026-03-16) until #20709
(2026-05-06) flipped the default back to off; the migration of that window
wrote `checkpoints.enabled: true` into user configs, where a later default
flip cannot reach it. Users who never type /rollback have carried a GB-scale
`~/.hermes/checkpoints/store` since (one live install: 1.2 GB across 250
projects, mostly disposable worktrees and /tmp dirs), and the cap cannot
bring it down because every project keeps at least one snapshot.
Silently flipping the key back is indistinguishable from overriding a real
opt-in, so this surfaces it instead: `checkpoint_footprint_notice()` returns
one line when checkpoints are enabled AND the store is at or above
`max_total_size_mb`, naming the opt-out (`hermes config set
checkpoints.enabled false` + `hermes checkpoints clear`) and the
retention knob. `hermes update` prints it with the post-update notices;
`hermes doctor` reports it as a warning after the state.db check.
Registry provider and the openrouter branch both hydrate an on-disk
`env:VAR_2` row into round_robin without writing the secret to auth.json;
an unset VAR keeps the row out of rotation but on disk (#9331).
The openrouter branch of _seed_from_env returns before the generic loop,
so a persisted `env:OPENROUTER_API_KEY_2` row stayed empty exactly like
the registry-provider case #103067 fixed. Fold the "declared vars + on-disk
env rows" union into one helper both branches use.
Restores the env-source seeding loop in _seed_from_env that was dropped
by upstream refactors. Without this, any env-source entry whose env var
name isn't in the provider registry's hardcoded api_key_env_vars tuple
stays empty forever, gets filtered out of rotation by _available_entries,
and round-robin silently degrades to single-key behavior.
Forward-port of ba71e00db07c6263ee8d44b27dfbce2a92e6b39c onto current main
f1ccf436a2. Narrow insertion after final
env_vars list in _seed_from_env: scan only existing entries whose source
begins with env:, extract/dedupe the named variable, and let the existing
seeding path hydrate it via get_env_prefer_dotenv (preserving _Seeder,
secret-scope/profile isolation, borrowed-secret sanitization, and
suppression behavior).
Tests: 10 new tests in test_credential_pool_seed_existing_env_sources.py
covering three-key hydration, round_robin cycling, dotenv/secret-scope
resolution without os.environ bypass, duplicate dedupe, manual rows
untouched, unset remains unavailable, and sanitized persistence.
The scan is detection-only and its findings are the repo-wide baseline of
CVEs in pinned dependencies — identical for every PR, unrelated to any
PR's diff. Reporting that baseline in each PR's review comment read as
"this PR has 76 vulnerabilities" to contributors, and the SARIF upload
tripped GitHub's per-installation API rate limit during merge trains.
The scheduled weekly run (plus workflow_dispatch) keeps feeding the
Security tab; the per-PR workflow_call, the review_status wrapper job,
and the orchestrator's now-unneeded SARIF permissions are removed.
scripts/check_profile_scope_patterns.py runs the validated hazard regexes in
scripts/ci/profile_scope_patterns.json (18 of the 31 campaign patterns: every one has a scope_hint
and hits <= 50 sites on main; the wider ones are review greps, not lint) against the lines added
vs the PR base and prints file:line, pattern id/class and why. Always exits 0: most shapes have
legitimate sites (a standalone `hermes -p x` process where environ IS the profile), so the
reviewer reads each finding against its scope hint. Wired into lint.yml beside the public-surface
diff with continue-on-error.
Proof: the pre-fix tools/bot_relay.py (`env = dict(os.environ)`, before the served_profile_child_env
change) is flagged as P05/C2; the fixed file and this branch's diff vs main report 0 findings.
Test: a fixture with the hazard is flagged on the right lines, the scoped-builder version is not,
and the line filter hides hits outside the added range.
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.
Symptom: `hermes update` sat for ~40s after "Refreshing cua-driver" and ended
with "Fleet version check returned no rows" (exit 1); the restarted gateway
took 26s to reach "Starting Hermes Gateway" instead of the usual 3s. The
gateway constructor was running `maybe_auto_prune_checkpoints` synchronously,
before the control socket, adapters and the code_sha stamp, and on a 1.2 GB
store its `git gc --prune=now` (a full repack) takes 20-28s — twice, because
the size-cap shrink gc'd again even when it could drop nothing.
The same defect sat on the tool-call path: `CheckpointManager._take` ran
`_enforce_size_cap`, whose `_shrink_store_to_cap` returned True without
dropping anything and triggered a 20-28s gc on the first file-mutating tool
call of every turn once the store was over the cap. That loop also re-measured
a pack size that cannot move without a gc, so a single over-cap checkpoint
dropped 20 rounds of history and flattened every project to one snapshot.
- `_take` never gcs: `_prune` and `_enforce_size_cap` rewrite refs (cheap),
drop at most one snapshot round, and mark the store `.gc-pending`.
- `prune_checkpoints` gcs only when a ref moved (project deleted, or the
pending marker), and its cap loop is drop -> gc -> re-measure.
- `maybe_auto_prune_checkpoints` claims the interval marker before the run
so a failing prune costs one day, not a gc per housekeeping tick.
- `auto_prune_from_config` is the one config-driven entry point; the gateway
calls it from the housekeeping tick (last chore), the CLI from a daemon
thread. Nothing on either startup path waits for git.
Live A/B on a copy of a real 1.2 GB / 224-ref store: checkpoint 20.5s ->
1.2-1.6s (0 inline gc); the single repack (19.6s) now runs in the prune.
Figma's authorization-server metadata advertises
authorization_response_iss_parameter_supported and its redirect omits iss,
so the mcp SDK's RFC 9207 check discarded every valid code and login never
finished. For that one issuer the provider fills a missing iss with the
discovered issuer and warns; a mismatching iss still fails and every other
server keeps the strict rule.
Fixes#111135
hermes_constants owns the _HERMES_HOME_OVERRIDE ContextVar. Evicting it hands later
imports a fresh var: a reset token taken through the old module raises, and an
override set before the purge silently disappears. _reload_updated_runtime_modules
already re-executes it in place before every purge, so new symbols still arrive.
Caught by tests/hermes_cli/test_update_config_reload_tools_config.py on the stack.
`_STALE_PURGE_EXCLUDED_TOP_LEVEL` had one reader and a re-export nothing patched;
the config-check comment claimed root modules stay cached across the purge (no
longer true); the purge test module docstring still described package prefixes.
The checkout root is where the update just pulled into, so "root unreadable" cannot
happen after a successful pull — drop the OSError fallback and the static five-name
tuple it fell back to (the tuple was the drift that caused the bug). Drop the phantom
`hermes_cli.hermes_logging` protection entry: no such module exists; the real
`hermes_logging` is root-level and now protected by name. Keep two tests: the stale
root `utils` scenario (red on base) and the hermes_logging protection.
`_STALE_PURGE_PREFIXES` listed five package names, so every top-level module in
the checkout root survived the post-pull purge. `hermes update` then imported
new source against a cached pre-pull `utils`, `hermes_constants`, `plugins` or
`providers`.
Field failure (2026-09-12, macOS): updating 0.20.6 -> 0.21.2 crossed 3145986c20,
which added `base_url_origin` to `utils.py`. The restart phase's up-front
`from hermes_cli.gateway import ...` pulled `agent.auxiliary_client`, whose
`from utils import base_url_origin` hit the cached 0.20.6 `utils`:
Update incomplete - gateway auto-restart failed: cannot import name
'base_url_origin' from 'utils' (.../hermes-agent/utils.py)
The purge docstring already claims it evicts EVERY cached Hermes module; the
hardcoded tuple was the same "re-fixed per symptom" shape it replaced. Scan
`PROJECT_ROOT` instead: top-level `.py` files plus directories with an
`__init__.py`. 50 names here, and a newly added module can no longer drift out.
Two exclusions, both deliberate:
- `hermes_logging` joins `_STALE_PURGE_PROTECTED`. Its queue listener, handler
list and `_logging_initialized` flag are module globals, so a fresh copy
starts a second QueueListener over the same log files while the first runs.
- `tests` is never purged. pytest resolves fixtures through the identity of its
already-imported test modules.
Falls back to the old tuple when the root is unreadable.
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.
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.
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.