Hermes points TMPDIR at ~/.hermes/cache/scratch, so every Desktop test
run's leftovers pile up there until the 24h idle pruner catches them:
one day on a dev host left 688 playwright-tracing-*, 152
playwright-artifacts-*, 75 hermes-e2e-mock-* sandboxes and ~600 small
vitest mkdtemp dirs.
- Playwright (main + core configs): e2e/run-tmp.ts gives the run one
temp root that workers, Electron and the backend inherit via TMPDIR,
and removes it when the runner exits. Covers the stack-trace dirs the
Electron tracing patch orphans (2 per launch, even on green runs),
sandboxes a failed/timed-out setup never cleans, and Playwright's own
artifact dirs. A SIGKILLed run now leaves one pw-* dir for the pruner.
The update suite keeps os.tmpdir(): its seeded install is reused
across runs and bakes its path into AF_UNIX sockets.
- vitest (apps/desktop): globalSetup does the same for the run; one
`--project electron` run left 54 dirs from tests that never remove
what they mkdtemp.
drive_preview (and tour) were refused with "The in-app browser only takes
actions in the session the user is looking at." for a compressed-but-still-
selected conversation: the backend stamps preview.act.request with the
RUNTIME session id, which auto-compression rotates mid-conversation, while
the pane keeps the durable/lineage id it navigated to — so
`sessionId === activeSessionId` failed for the rest of the conversation
even though desktop_preview open/read kept working.
Both sides of the check now resolve to the same identity class before
comparing: the request's runtime id maps to its stored id through the
message stream's session state cache (rotation-aware), unknown ids pass
through unchanged (they may already be stored ids), and a final lineage
match lets a compression-rotated tip and its root read as one
conversation — the same one-row lineage test session.info matching uses.
Branch siblings that only share a root stay distinct, and a background
tile session is still refused by the gate.
The same matcher backs windowHostsSession, so the window hosting the
conversation claims the request instead of leaving it unanswered when the
ids differ only by rotation.
Fixes#122062
The login-shell PATH merge (shell-path.ts) writes the user's interactive
PATH into process.env ahead of the inherited entries. When Desktop is
started by `hermes desktop`, the inherited PATH is where the PM store's
node/npm/uv dirs sit, so every Hermes-toolchain child built from
process.env (the `hermes serve` backend, backend probes, the source/posix
update hand-offs via sourceUpdateEnvironment, the bootstrap installer)
resolved the user's nvm/Homebrew/~/.local/bin copies first.
buildDesktopBackendEnv and the bootstrap installer env now move PATH
entries under the store root (HERMES_RUNTIME_DIR, else <hermes home>/tools,
matching pm.environments.store_root) to the front, other entries in their
existing order. process.env itself is unchanged, so the embedded terminal
and external terminal keep the user's own PATH order.
* fix(desktop): bound NVIDIA EGL fallback to confirmed-broken driver series
The 580+ check was open-ended: every future NVIDIA series (615.x, #123203)
was forced onto CPU SwiftShader rendering even though only 580.x has
confirmed EGL probe reports (#40077: 580.159.03, 580.173.02) and 570.x is
the recommended downgrade. Replace the >= floor with a closed set of
known-broken series majors so healthy newer drivers keep GPU rendering.
HERMES_DESKTOP_NVIDIA_SWIFTSHADER still overrides both ways.
* docs(desktop): document the NVIDIA SwiftShader override and scope its force-on claim
- add HERMES_DESKTOP_NVIDIA_SWIFTSHADER to environment-variables.md (both
directions, with the recovery-hatch framing the closed-set design leans on)
- correct the module header: force-on does not apply where an earlier gate
already returned (remote display, WSLg, DISABLE_GPU=0)
Review follow-ups on PR #123213.
* test(desktop): pin NVIDIA EGL detection to set membership across a driver sweep
The closed-set test only checked that 580 is a member, so it still passed
if a neighbouring series was added to the set or the check drifted back
to a floor. Sweep majors 500-700 and require enable === set.has(major);
this fails against a '>= 580' floor. Also clears the prettier arrow-paren
diff that js-autofix would have reverted on merge.
---------
Co-authored-by: Austin Pickett <austinpickett@users.noreply.github.com>
* fix(desktop): interrupt a deleted session's runtime so no prompt outlives it
Deleting a session did not stop its in-flight turn unless that session was the
focused one. `removeSession` gated runtime teardown on selection:
`closingRuntimeId = wasSelected ? activeSessionIdRef.current : null`. A
NON-selected (sidebar/background) session therefore never sent `session.close`,
and even the selected path only called `session.close` — which tears the runtime
down but does not walk the interrupt path that releases approval / clarify /
sudo / secret waits (`tui_gateway/session_lifecycle.py`: `_finalize_session`
interrupts delegations but never calls `resolve_gateway_approval(..., 'deny')`;
only `_interrupt_session_turn` does, via `_clear_pending` →
`server_requests.cancel`). A blocked run could outlive its sidebar row and
surface a blocking prompt for a conversation that no longer existed.
Fix:
1. `removeSession` (`use-session-actions/index.ts`) resolves the doomed runtime
from the selection OR `runtimeIdByStoredSessionIdRef`, marks it interrupted
(`interrupted: true`, `needsInput: false` — rolled back if the interrupt RPC
genuinely fails), sends `session.interrupt` before `session.close`, and
flushes `clearAllPrompts` + `clearClarifyRequest` for that runtime. The RPCs
route by the row's owner (`requestForSessionProfile`), matching the existing
delete/close contract.
2. Blocking-input handouts no longer park a card for an interrupted runtime
(`server-requests.ts`: new `sessionStopped` guard on approval / sudo / secret
/ vault.code / vault.save_login / vault.unlock_prompt; `input-requests.ts`:
same guard on `connection.request`). This closes the window between the local
interrupted flag and a prompt frame already queued on the transport.
Fixes#75587
* fix(desktop): answer, don't drop, blocking-input requests for a stopped session
The interrupted-session guard on approval / sudo / secret / vault requests
returned without touching the request, so the backend kept blocking on a frame
that would never come — until its own deadline, or until `request.cancel`
happened to cover it. The channel contract already says a JSON-RPC error reads
as "unanswered" on the Python side (the same result `server_requests.cancel`
produces), so fail the request and let the tool return now. The clarify
handler's interrupted branch already answered; this makes the family match.
Also drop the second `clearAllPrompts` / `clearClarifyRequest` flush after the
row delete — the flush right after `session.interrupt` already ran, and any
later frame is declined by the guard, so the repeat was defense in depth.
Tests: assert the guard fails the request (and a live session is untouched),
assert the interrupted flag is set before the first RPC with the right shape,
and cover the rollback when `session.interrupt` fails for a non-gone reason
(no close, no delete, live state restored, row survives).
* fix(desktop): re-point the window route after a primary connection apply
sendConnectionApplied() notified the renderer with a bare
hermes:connection:applied event, so the renderer's soft switch re-dialed
profile-less and main answered it from the window's recorded route
(resolveDesktopConnectionRequest). That record names the source the user just
LEFT, so after an apply the renderer kept dialing the stale registry-scoped
gateway: the session list never re-scoped, the status bar kept the old
backend's version, the old socket stayed ESTAB, and only a full restart
recovered. A locally recorded route is unscoped and follows the new v1 config,
which is why local -> remote looked fine while remote -> local did not.
Re-point the applied window's recorded route at the newly applied primary in
the same step as the notify, so the profile-less re-dial targets the gateway
just applied (and a window launched from that route afterwards inherits it).
An applied registry source stays registry-scoped to that exact identity; a
This-device apply stays unscoped so the dial follows the freshly written v1
config. The route rewrite and the notify only run once the config/registry
write has committed, so a rolled-back apply cannot leave a record naming a
source that never landed.
Fixes#92352
* fix(desktop): re-point the window route only on a global primary apply
A profile-scoped apply (the v1 per-profile remote override, payload.profile
set) never reconciles the registry, so its primary is unchanged: re-pointing
the main window's route there would yank a window viewing another registry
source onto the primary for an apply that did not move it. Keep the bare
notify for scoped applies and re-point only when the registry primary was
actually reconciled.
Read the applied primary from the registry snapshot that was just written
(nextRegistry) instead of re-reading the file inside the notify.
* fix(desktop): never adopt a page from a backend that ignored order=latest
The desktop's transcript authority is GET .../messages?order=latest, but a
backend built before the `order` param silently drops it (FastAPI ignores
unknown query params), serves the OLDEST page, and still answers with a
`pagination` object. That page was adopted as the tail: the transcript
silently became its first N rows, and "Show earlier" then prepended rows
N..2N counted from the oldest end.
Only a page that echoes `pagination.order === 'latest'` may be adopted as
the tail. Anything else is read as the complete transcript instead.
Fixes#92508
* fix(desktop): keep the order-echo fallback passive and skip re-reads it does not need
The compatibility read for a backend that ignored `order=latest` dropped
the caller's `passive` flag, so a hidden tile's background refresh
(#103375) could hold or wake a backend through the follow-up paged read.
It also paged the whole transcript again for pages that already held
every row: a response with no `pagination` (the pre-paging generation,
which previously cost one request) and an orderless page that came back
short from offset 0.
Thread `passive` through `getAllSessionMessages`, and only page again
for a FULL orderless page; the other two are adopted as the complete
transcript with `pagination` stripped.
Desktop cron run entries (sidebar CRON JOBS peek and the Cron page's run
history) listed fine but clicking one never loaded the transcript: the row
highlighted and nothing else happened.
The open path was handed only the run's id. The clicked ROW is the identity
that carries the owning (connection, profile); an id-only resume resolves
against the ambient backend, which over SSH/remote has never seen the run.
Session-list rows are tagged with their serving registry connection
(stampRowsWithOwningConnection via /api/sessions and the sidebar slices), but
getCronJobRuns returned the backend rows untagged — so even the owner-aware
wiring door had no owner to pin.
Route both cron surfaces through the same owner-aware open the sessions
sidebar uses, and tag the run rows with the connection that served them.
Fixes#82527
readProjectDir now filters with SHOW_IGNORED_EXCLUDED when the project
opted into showing gitignored files, so out/vendor/coverage listings
survive the toggle; the default ALWAYS_EXCLUDED pass and the gitignore
pass are unchanged for everyone else.
Fixes https://github.com/NousResearch/hermes-agent/issues/55169
ALWAYS_EXCLUDED dropped out/, vendor/, coverage/ and friends even for
projects whose owner flipped the per-project eye toggle on. Deny-by-default
.gitignore layouts rely on exactly those names for real build output, so the
toggle could never deliver what it promises there.
Split the set: SHOW_IGNORED_EXCLUDED is the floor the local Electron bridge
and the remote /api/fs/list transport already strip (VCS internals,
dependency/build/cache dirs), so the tree keeps its hygiene when opted out
while an opted-in project can browse the entries both transports return.
Fixes https://github.com/NousResearch/hermes-agent/issues/55169
Two new Composer rows in the keyboard-shortcuts panel — Reasoning level
up / Reasoning level down — stepping the active session's effort through
the ladder from the previous commit. Ships unbound like dictation: the
chords users pick for a runtime dial (Alt+., Ctrl+Alt+↑, Numpad +/-) are
too personal to claim by default.
A new `editableTargetPolicy: 'modified'` on the action opts it into
firing while the composer is focused for any combo carrying a real
modifier — including Alt and Numpad chords without a primary modifier,
which the global mod/ctrl rule never covered. Bare and shift-only
rebinds stay with the input, so typing is never hijacked.
The handler writes optimistically with rollback and a monotonic request
sequence, so rapid presses can't have a slow earlier response revert a
newer one. Without a live session the draft pick still steps (it ships
on the next session.create) and no config.set is issued — without a
session the RPC falls back to persistent profile config.
Desktop only for now: the Ink TUI has no rebindable keybind registry to
register these actions in (its handlers are hardcoded useInput chains).
Builds on the approach of the closed-unmerged #46174 (handler shape,
editable-target policy, stale-response guard).
Fixes https://github.com/NousResearch/hermes-agent/issues/71627
Reasoning effort today is reachable only through the model menu or
/reasoning — both interrupt keyboard-first workflows when moving between
quick and hard tasks. This adds the pure stepping logic those surfaces
share: one notch through off → minimal → low → medium → high → xhigh,
clamped at both ends, with max/ultra kept off the ladder so a step never
crosses into the expensive tiers by accident.
The session write uses the same session-scoped config.set the model menu
issues and normalizes the gateway's echo so callers settle their
optimistic store on the reported truth.
Part of the #71627 keybind work.
_history_dict_text has always had both image renderings, but
_coerce_message_text hard-coded image_urls=True at both call sites, so the
reference-form branch was unreachable from the API: a remote client re-read
the sum of every attachment the conversation ever carried (26.30 MiB on the
measured session.resume, paid again on every reconnect) with no way to ask
for less. session.resume now accepts inline_images (default true) and
threads it through every path that carries messages — cold/lazy/eager
resume, live reattach via _live_session_payload — routing to the existing
[image] branch when false. The REST twin lands in the follow-up commit.
Fixes https://github.com/NousResearch/hermes-agent/issues/116511
Desktop archives sessions via PATCH /api/sessions/{id} -> set_session_archived,
but the TUI gateway had no equivalent JSON-RPC method, so TUI clients had to
keep every session visible or delete it permanently. Open PR #47184 added this
against the pre-split server.py layout and resolves only live runtime sessions;
this lands the method on current main next to session.set_hidden with the same
two-tier resolution: live runtime id first (unpersisted drafts defer via
pending_archived, applied by _ensure_session_db_row like pending_hidden), then
a stored id/key resolved through the profile db, covering the historical rows
the session.list picker shows. Contract declared in tui_gateway/contracts and
rendered into the generated TS/OpenRPC catalog.
Fixes https://github.com/NousResearch/hermes-agent/issues/47168
/queue has /q; /steer forced users to type the full word mid-run just to
inject a line after the next tool call. Add the same one-letter alias in
the central registry (every surface derives from it) and mirror it in the
TUI's local slash registry, the way /q is mirrored — checked that no
TUI-local /s binding shadows it (the /q vs /quit collision, #31983).
/i for interrupt stays out: /interrupt is not a registered command yet
(#69954 holds that decision), and the issue gates the alias on its
existence.
Fixes https://github.com/NousResearch/hermes-agent/issues/119176
Since #43109 every OS drag-drop is unconditionally routed through the
file.attach staging pipeline, even when the gateway runs on this
machine and shares its filesystem. Local-mode users paid a duplicate
copy per dropped file and lost the original path semantics.
partitionDroppedFiles now takes the staging context (session remote vs
local via the connection store, backend cwd, terminal backend). When
the backend resolves this machine's paths as-is — a local connection
on a non-container backend — a non-image OS drop keeps its
original-path inline @file: ref, exactly like in-app project-tree
drags. Remote gateways, container backends (docker/ssh/...), Windows
host -> POSIX-backend crosses, images (vision needs the bytes queued
gateway-side), and path-less drops still go through the upload
pipeline. Applied across every drop surface: the conversation area,
the composer form, the contenteditable input, and the message-edit
composer.
Fixes https://github.com/NousResearch/hermes-agent/issues/52427
The byte-vs-path upload decision now lives in one pure helper both the
submit-time upload pipeline and drop-time routing read, so the two
sides can never drift. Behavior unchanged; next commit adds a caller.
#92569 made profile.switch.N unconditional and shipped view.tabSlot.N
unbound, which threw away the hover → focused → workspace tab dispatch
from #74447: holding ⌘ still painted tab-number hints, but the chord
switched profiles. The reporter's real complaint was that the tab
dispatch was hardcoded inside the profile handler, so rebinding the
chord could not separate the two.
Give the keybind registry a `passthrough` action kind: the combo index
keeps every action bound to a chord in registration order, the
dispatcher runs the first and, when a passthrough handler returns
`false`, hands the chord to the next. view.tabSlot.N defaults to ⌘N
ahead of profile.switch.N and declines when no zone is a real tab
strip or the strip has no Nth tab, so ⌘N is "tab N" over a strip and
"profile N" anywhere else. Either action can be rebound on its own,
the panel does not flag the layered pair as a conflict, and the held-⌘
hints key off the tab action they describe.
A large-paste file chip reads "Pasted content" (path on hover) instead of
a long absolute path, and a turn that is only file chips renders them in
the bubble rather than an empty bubble above a detached row.
A persisted user row carries the composer's leading @file:/@folder: block
inline, so reloaded, reconciled and in-flight bubbles opened with a raw
path and a blank line where the live bubble showed a chip under the
prompt. Hydration now moves that leading block into attachmentRefs, the
same shape the optimistic row uses, and the in-flight prompt projects
through the same conversion.
Two settle paths could each paint a second bubble for one stored row:
1. use-message-stream: a rewritten final (response_previewed /
response_transformed) shares no text prefix with the streamed interim, so
the prefix heuristics missed and the volatile boundary flag could not
speak for it once a chained message.start reset it. The terminal frame
names the stored row it settled (persisted_turn.final_assistant_row_id);
settle onto the display-only interim keyed on that durable receipt instead
of appending a duplicate.
2. use-session-actions overlayConcurrentMessageChanges: the committed row
and the settled live row capture the same reply at two moments while it
kept streaming, so the strict text-equality guard rejected one as a
duplicate of the other. Accept either as a forward text-extension of the
other via isStrictAnswerTextExtension, the same trade
removeRepresentedLocalLiveProjection already made.
Four new tests cover the rewritten-previewed, rewritten-transformed,
lagging-live-row, and ran-past-committed-row shapes.
Fixes https://github.com/NousResearch/hermes-agent/issues/123993
Fixes https://github.com/NousResearch/hermes-agent/issues/124128
Refs https://github.com/NousResearch/hermes-agent/issues/122167
Archiving a session could resurrect it in the sidebar: a sessions page read
before the archive RPC committed could land after the projects.tree refresh
pruned the removal tombstone, so the only remaining guard (tombstone
membership) had nothing to say and the stale row re-entered through the
keep set. Tombstones also only matched the row's tip + lineage root, so the
same conversation could return under an intermediate lineage segment id.
- session-removal: track the direction of the last removal edge per id;
`sessionRemovalIntersected` answers "did this removal lifecycle move
toward removal since this fetch started", surviving the tree prune.
`tombstoneRowIds` names every id a row answers to (tip, root, and all
lineage segments).
- use-session-list-actions: capture a tombstone-generation snapshot at the
start of every fetch (recents, messaging refresh, platform load-more) and
reject rows raced toward removal; filter after the merge so survivors are
covered too; the cron slice gets the same guard.
- projects: fetchProjectSessions (drill-in) applies the same guard via
`excludeProjectSessions`, keeping the project reference on no-match.
Renaming a session patched only the bare-id `$sessions` slice, leaving
project-scoped rows (overview previews, entered-project lanes), cron and
messaging rows on the stale title until an unrelated refetch.
- session: `applySessionTitle` patches every sidebar slice
(recents/cron/messaging/unlisted), matching rows lineage-aware via
sessionMatchesStoredId, reference-stable when nothing changes.
- projects: `applyRenamedSessionTitle` mirrors the rename into the cached
project tree (lane rows + preview sessions, recomputing counts) and
re-pulls the authoritative tree.
- Wire both into the rename dialog, the /title slash command, and the
session.title gateway event.
Fixes https://github.com/NousResearch/hermes-agent/issues/123685
Fixes https://github.com/NousResearch/hermes-agent/issues/123337
The launcher replaced TMPDIR with /tmp for the whole Electron process tree, so
the Python backend inherited TMPDIR=/tmp while TMP/TEMP/HERMES_SCRATCH_DIR
still named the scratch dir; apply_scratch_tmp_env then treated /tmp as
user-set and the agent subtree wrote temp files to /tmp.
The launcher now passes the original in HERMES_DESKTOP_TMPDIR and Electron main
restores it right after requestSingleInstanceLock(), so every child gets the
profile scratch dir back.
The override threshold is Chromium's real budget instead of the shared 50-byte
socket constant: sun_path holds 107 bytes and Chromium appends
/scoped_dirXXXXXX/SingletonSocket (33 bytes, measured on Electron 40), so a
TMPDIR up to 74 bytes is kept.
Retention (boundRetainedTranscript, #77311) releases the store's head down to the live window plus its budget, so a long tool-heavy chat keeps a store shorter than the latest 120-row page renders. The stale-transcript guard compared authored counts, so every send on such a chat saw 'remote ahead', installed the refreshed page, got re-trimmed on the next anchor move, and refused again — a permanent 'Chat out of date' loop with no convergence (#123909).
Compare tips first: the last durable rowId of the local view equal to the page's own last durable rowId means the view is current however much head was paged out, while a peer window's newer row still changes the tip (#65047 protection intact). Counts remain the fallback when tips cannot be established (no durable ids on either side).
A Kanban board opened in a split route tile rendered no board switcher:
the board contributed it to WORKSPACE_PAGE_HEADER_AREA unconditionally,
and only the workspace pane paints that area. The tile's contribution
also leaked into another page's header and shared its id with the full
page's, so closing the tile removed the page's switcher.
Add WorkspacePageHeaderControl (exported via the plugin SDK). The
workspace pane's render provides a private host context; inside it the
control projects into the page header, anywhere else it renders inline.
The board mounts BoardSwitcher once, through it, in its own header row.
Fixes#123597
Originally authored by Justin Haynes (@jhaynes).
The desktop voice conversation hardcoded its silence hold at 1,250 ms and
ignored the voice.silence_duration config key the CLI, TUI and gateway
capture paths already honour, so users who pause mid-thought were cut off
with no way to raise the threshold, and fast local STT/TTS stacks sat
through a rigid extra second of dead air.
Seed a $voiceSilenceMs atom from /api/config on every refresh (the same
useHermesConfig path as stop_phrases / barge-in sensitivity): a value the
user actually changed (differing from /api/config/defaults) overrides the
tuned desktop hold; an untouched install keeps 1.25 s, because /api/config
merges DEFAULT_CONFIG and would otherwise raise the hold to 3 s for
everyone. Malformed or non-positive values fall back like the gateway's
shape-safe lookup, and booleans never coerce to 1 s.
The barge-in utterance endpoint reads the same atom live per frame, so
barge-in capture and the voice loop stay matched across config refreshes.
Supersedes #83572 (live-atom read, no backend-default comparison, would
triple the hold to 3 s for untouched installs) and #85772 (same, plus
accepts booleans as 1 s).
Fixes https://github.com/NousResearch/hermes-agent/issues/83518
Fixes https://github.com/NousResearch/hermes-agent/issues/124760
The new foldPersonalityName call sites and the @/lib/personalities import
tripped the curly and perfectionist/sort-imports rules, reddening the
desktop check:lint gate. Brace the single-statement ifs and move the
value import into its natural-sorted internal position.
Both dropdown readers (personalityOptions, personalityNamesFromConfig) listed
config keys verbatim via Object.keys, but the runtime folds every key
(available_personalities: str(name).strip().lower(), skipping the neutral
spellings none/default/neutral). So a root/agent case clash (Catgirl vs catgirl),
a whitespace-padded name, or a neutral spelling surfaced a row the runtime never
resolves — the user could pick it and get a different (or no) definition than the
one shown, with the widened root-block read (#123297) making mixed case the likely
input.
Add a shared foldPersonalityName + NEUTRAL_PERSONALITY_NAMES in @/lib/personalities
mirroring hermes_cli/personality.py, fold+skip in both readers, and route
normalizePersonalityValue through it (which also folds the 'neutral' spelling it
previously missed). Reuse the existing isPlainObject guard in helpers. Pin the
behavior with case-variant + whitespace + neutral-name cases in both test files.
Reported by @Enough1122.
Address review on #123378:
- Add enumOptionsFor('display.personality', …) tests with a root-level
`personalities` block, deriving expected built-ins from
BUILTIN_PERSONALITIES (change-detector rule). Reverting the new root
read now fails these, closing the coverage gap the reviewer flagged.
- Replace the Set-based clash assertion in personalityNamesFromConfig
with direct array equality so it pins membership, dedupe, and the
root-before-agent ordering the CLI listing uses.
The Desktop Settings → Chat → Personality dropdown and the config-driven
personality name list read only `agent.personalities`, so a persona
registered under the root-level `personalities` block — which the Python
runtime, CLI `/personality`, and gateway all honour via
`available_personalities()` — never appeared in the GUI and could not be
selected.
Read both blocks in `personalityOptions` (settings/helpers.ts) and
`personalityNamesFromConfig` (lib/chat-runtime.ts), mirroring the runtime's
merge order (root `personalities`, then `agent.personalities`), so the two
surfaces list the same personas.
The desktop package.json carries a 0.0.0 placeholder on main (real versions come from channel builds and the live backend), but two About paths repeated it verbatim: appVersionInfo fell back to packageVersion when no display version was baked, and the native panel was seeded - and could be refreshed - with an empty applicationVersion, which macOS renders as the bundle's 0.0.0. Skip placeholder values ('', 0.0.0, unknown) across every candidate, and map the native panel string through nativeAboutVersion so unknown becomes an explicit label (backend spelling git.<short>[.dirty]) instead of 0.0.0. The renderer keeps its ''-means-unavailable contract. Closes#124581.
model.info and saved profiles report a user-defined provider as custom:<key>, but the catalog row uses the bare key as its slug. Settings > Model and the Bot Mode picker compared the two with ===, so a saved custom provider never found its row. Settings showed a duplicate custom:<key> entry and a Set up provider button, and the bot editor fell back to the manual form.
Both now match rows with catalogProviderMatches, like the composer picker already does. Settings uses a small findCatalogProvider helper for every row lookup, including the aux and MoA slots and the endpoint passed on Set to main. catalogProviderMatches is now exported through the plugin SDK so the bot picker can use it.