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.
A span whose body is a backslash command is real math, and a CJK
variable inside one equation must still escape while the next span stays.
Co-authored-by: Yun. <fangyun1008@gmail.com>
escapeCjkProseDollars treated a real closing dollar as the next opener,
so CJK text between two formulas escaped the first equation's closer.
Co-authored-by: Yun. <fangyun1008@gmail.com>
InlineHtmlFrame derived colorScheme from useIsDark(), which reads the
.dark class through React state that only updates a render after
applyTheme() has already mutated the DOM (use-theme-epoch.ts's own
comment documents this ordering: "a child's effect runs before the
provider's applyTheme"). collectThemeBridge() reads the CSS token
values live via getComputedStyle() on every render, so a render caught
between the DOM mutation and the epoch-triggered re-render built a
frame with fresh dark tokens but a stale light color-scheme -- a
transparent iframe with color-scheme:light still paints its canvas
white, so the widget showed near-white text on a white canvas (#123048).
Move the color-scheme read into collectThemeBridge() itself, off
document.documentElement.dataset.hermesMode (the same attribute
applyTheme() sets the token values from, and the pattern
lib/selection-copy-colors.ts's renderedMode() already uses for the
same reason), so the scheme and the tokens it decorates always come
from one synchronous read.
Quitting the Desktop app wrote "[boot] Restarting desktop connection" to
desktop.log and pushed the same message to the renderer over
hermes:boot-progress. Nothing was restarting: the quit coordinator called
teardownPrimaryBackendAndWait() with the default soft=false, and soft=false
is what makes resetHermesConnectionState() call
resetBootProgressForReconnect().
Name the two teardown intents in quit-teardown.ts and use them at every
deliberate primary teardown: a teardown that re-homes ('reconnect', update
hand-off and bundle swap) keeps the announcement; one that brings nothing
back ('quit', the quit coordinator and the uninstall teardown) stays silent.
Fixes#123437.
Clicking the project-group trunk + passed the null wire path through, which
downstream treats as the reserved Home/detached signal, silently creating a
global session. Fall back to the first repo root for path-less explicit
projects; Home keeps null. Covers click and new-session drag cwd.
clipboardPasteFrom was called with unbounded clipboard text; the gateway's
RfbClientFilter closes the display WebSocket on any ClientCutText over
_MAX_CUT_TEXT (256 KiB), turning an oversized paste into a full stream
disconnect instead of a dropped paste. Clamp before forwarding and add a
test pinning the ceiling.
Take over over a bot's remote screen never sent the client's text
clipboard to noVNC: screen-pane.tsx built the RFB session but had no
paste handler and never called clipboardPasteFrom, so Cmd/Ctrl+V into
a remote input did nothing (#123089). The gateway already gates
ClientCutText as lease-checked input, so the missing leg was purely
client-side.
Add an explicit-gesture `paste` listener on the canvas, forwarded only
while this viewer's RFB client is not view-only (i.e. holds the
lease); no polling, no clipboard logging. Listener is torn down with
every detach so it never outlives its RFB client.
The hermes-media://stream resolveLocalFile boundary ran
resolveMediaRequestPath on the path the protocol handler had already
percent-decoded and slash-stripped (parseMediaProtocolTarget), so the
second decode+strip turned every absolute POSIX media path
(/home/.../.hermes/...) into a cwd-relative one. The resolver then
pinned it under the app's cwd and the file ENOENTed into the handler's
silent 404 — inline TTS/audio playback dead with no log line, which is
the Linux symptom in hermes-agent 123823. A second decodeURIComponent
also threw URIError on filenames containing a literal %.
Restore the single decode at the protocol layer: the boundary now only
bridges (resolveMediaStreamFile), matching what 7722761581 intended as
byte-for-byte behavior but broke when it rewired main.ts.
Fixes https://github.com/NousResearch/hermes-agent/issues/123823
Opening a deleted/renamed artifact reported "No application found to open
URL": macOS LaunchServices answers a non-existent path with
kLSApplicationNotFoundErr (-10814), and the open route handed resolved
paths to the OS without ever stat-ing. On current main the file route is
reveal-in-folder only, which is worse on a miss — showItemInFolder of a
non-existent path is a silent no-op on macOS, so the click does nothing
at all.
- hardening.ts: assertExistingPathForOpen — pre-open stat wired into
openExternalFile before the reveal-dedupe window is touched (a miss
must not poison dedupe against a legitimate retry). Only
ENOENT/ENOTDIR become an honest missing-file failure.
- external-open.ts: reportPreOpenStatFailure — the guard's
classification lives in the electron-free module with the rest of the
open logic, so it unit-tests without electron. A miss is reported and
the route stops; every other stat failure (EACCES on a locked volume,
ELOOP, Windows EPERM, ENAMETOOLONG) is logged and the OS is still
asked, so an existing-but-locked file keeps its real error — a stat
failure never fabricates a miss and never swallows the click.
- preload.ts: the failure dialog merged in #122023 had no preload
forwarder for hermes:external-open-failed, so every open failure was
silently swallowed in every window (the optional-chained bridge is
invisible to typecheck). Adds the onExternalOpenFailed listener plus a
test tripwire so the dead wiring cannot come back.
- The failure payload gains code: 'missing-file'; the dialog shows
localized "File not found" copy for it instead of the no-browser
text (i18n types + en).
Fixes https://github.com/NousResearch/hermes-agent/issues/122027
Co-authored-by: Andrew Ho <andrewho.sf@gmail.com>
Co-authored-by: Enough1122 <10966420+Enough1122@users.noreply.github.com>
A session.reclaimed (or roster-activity) wake re-resolves the open bot
chat so the next send doesn't eat a stale runtime id — but it went through
the full navigating open, so with the Kanban board (or any other route)
in the foreground the route flipped to the Bot Chat. Background events
must offer, not hijack.
host.openSession gains refreshInPlace: the wake still consults the
registry, dials the owner backend, and stamps the owner hint, but never
calls the core open (route/tile), never re-publishes the bots workspace
scope, and never flips the all-profiles view. It refreshes through the
same levers the SDK's own hydration probe uses — the tile delegate's
resumeTile(refreshTranscript), or the armed requestSessionResume that
the route-resume effect consumes only while the route already points at
the session. A chat that isn't on screen re-opens nothing.
openBotCanonicalChat threads this as background: true, and a background
re-resume never MINTS a missing registry row. The reclaim listener and
the roster-activity refresh both pass it.
Fixes https://github.com/NousResearch/hermes-agent/issues/121874
MarkdownLink dropped formatted link labels: childrenToText only handled
plain strings, so an inline-code label ([`v1.0.1`](url)) fell through to a
title fetch or a URL-slug fallback that title-cased the identifier.
Flatten element children so the authored label wins with exact casing,
and keep the casing of separator-less slug tokens that look like
identifiers (digits, dots, mixed case) instead of title-casing them.
Fixes https://github.com/NousResearch/hermes-agent/issues/121321
Add a blanket prefers-reduced-transparency gate to styles.css that sets
backdrop-filter: none everywhere, mirroring the existing blanket
reduced-motion rule. Frosted surfaces (composerDockCard via
composerSurfaceGlass, menus, popovers) re-sample their backdrop every
animation frame; on transparent plugin wallpaper themes a late filter
pass paints unblurred and the whole docked area strobes (issue 121910).
The alpha-mixed fills (--composer-fill at 88%) carry legibility on
their own, so nothing changes visually unless the OS accessibility
signal is on.
Invariant companion to the narrow-overlay width fix: the docked tree
path (routines register/unregister across botChatOwnsWorkspace edges,
workspace removeTreePane + re-adopt, bots pane unregister/re-register
with enforced dock) preserves the sash-dragged widthOverride — the
narrow overlay was the only path that discarded it.
The narrow edge overlay sized itself from the pane's declared data.width
(260px for bots), discarding the persisted widthOverride the sash drag
wrote, so every chat-switch reveal snapped the panel back to its declared
width. Size from the same fixedTrackSize resolution the pane's docked
zone uses, extracted as a pure narrowOverlayWidth helper so the
resolution itself is testable under jsdom.
Co-authored-by: kokhlo <47825603+kokhlo@users.noreply.github.com>
Follow-up to the stale-approval fix: clearClarifyRequest lives in
@/store/clarify, not @/store/prompts — the wrong-module import slipped
through because the prompts barrel happens to export similarly named
clears. Caught by the vitest run of the new specs.
The backend already reports {content, exists} from GET
/api/profiles/{name}/soul, but both SOUL.md editors read only content —
a profile whose personality lives in config.yaml
(agent.personalities.<name>) rendered a blank CodeEditor with no hint
that anything was missing or where the persona actually lives (#89436).
Both surfaces (Profiles view editor and the sidebar Edit SOUL.md
dialog) now show a localized notice when exists is false: no SOUL.md
exists yet, instructions below will create one on save, and config.yaml
personality presets are managed separately. The notice clears on a
successful save; an existing empty file or a read error is never
misreported as missing.
i18n: soulMissing added to every full locale (de/es/fr are override
locales and fall back to English). Component tests cover the
missing/empty/read-error distinction and cross-profile leakage; an e2e
spec drives both editors end to end.
The config.yaml read-back fallback and the dashboard source indicator
stay deferred to the personality-precedence work (#82801).
Salvaged from PR #105201 (author preserved).
Fixes#89436
Every non-zero gh exit collapsed to a generic "is gh installed and
authenticated?" — a lie whenever gh was fine and the real failure was
"no commits between main and feature", a missing upstream, or a refused
push (#87731). The user had to drop to a terminal to learn what gh
already printed.
- apps/desktop git-review-ops.ts runGh() now resolves {ok, stdout,
stderr} (execFile's err.stderr carries the exit's own stderr), and
reviewCreatePr() prefixes the surfaced message with gh's reason,
keeping the generic text only when gh reported nothing.
- hermes_cli web_git.py _gh() keeps (ok, stdout, stderr) through the
same collapse — _run already captured stderr; it was discarded at the
tuple boundary — and review_create_pr() surfaces a bounded stderr
tail (400 chars) plus the gh context.
Tests pin the contract with unique stderr markers and non-zero exits on
both wrappers; successful-path return contracts unchanged.
Electron half salvaged from PR #87751 (author preserved); CLI half added
per the issue's acceptance scope.
Fixes#87731
Two clear paths missed, both leaving a per-session approval entry parked
after the turn ended — the floating "↓ needs approval" bar then
reappeared on a session the sidebar already showed as finished, whenever
scrolling unmounted the inline anchor (#86577):
- session.reclaimed now clears the prompts keyed to the reclaimed
runtime id. The runtime id rotates on every resume, so the NEW
runtime's turn-end edges can never remove an entry keyed to the old
one; a reopened conversation remounted the stale bar.
- a running=false session.info for a session we knew was live (busy or
awaitingResponse) now clears its prompts. The agent loop's finally
block emits running=false even when a reconnect gap or crash swallowed
message.complete — the only existing turn-end clear — so the terminal
edge doubles as an authoritative prompt clear. Bystander sessions are
untouched: both clears are scoped per session id.
Tests: the reclaimed-runtime approval retires while a bystander keeps
its prompt; a missed-complete turn retires its approval; an idle
session's running=false heartbeat does not.
Re-implemented on the split gateway-event modules from PR #86616
(author credited); the needsInput sidebar-dot half stays with the closed
sibling #86565.
Fixes#86577
Salvaged from a fix by fangliquanflq (GitHub account since removed).
A remounted <video> restarted its clip from the top, which read as the
attachment reloading mid-playback. Remember the last position and pause state
per source (bounded, process-memory) and restore them on mount.
The ZoomableImage dialog's open flag was component-local useState, so the
routine transcript remounts while a turn streams (render-budget slice
recycling, markdown re-parse) closed a user-opened preview with no gesture.
Hoist it into a nanostore atom keyed by source identity; the store is cleared
on explicit close and on a session switch, never by a remount.
Fixes https://github.com/NousResearch/hermes-agent/issues/123018
A loopback URL the model writes into a sentence is user-facing content:
"the dev server is at http://localhost:3000" rendered with the address
deleted. The prose pass stripped any bare loopback URL that lacked a
path segment, so only the fenced preview hand-off path survived. Drop
the prose-strip regex; the only remaining suppression is a fenced block
whose whole body is the loopback URL (LOCAL_PREVIEW_ONLY_RE), which the
preview widget already paints.
Fixes https://github.com/NousResearch/hermes-agent/issues/121683
The package marker named the temp clone, which install deletes. The next
reconcile treats that missing source as an uninstalled package and prunes
the half. Point the marker at the published folder so a remote backend, or
a Desktop-only install, survives until a local agent package can take over.
Wrap each sandbox's use in try/finally so cleanupSandbox() still runs
when a mid-test assertion throws, instead of only after all assertions
for that sandbox pass. The existence assertion stays a separate
statement after the try/finally.
Each POSIX hand-off marker test spawns three mkdtemp() sandboxes under
the OS temp directory (tagged preserved/refreshed/oversized) and never
removed them, so every test run left three directories behind. Traced
the production scripts/desktop-update/posix.sh --self-test-marker path
itself and confirmed it writes the marker/result files directly under
HERMES_HOME, not into a separate staging directory it creates -- the
leaked "hermes-handoff-marker-<tag>-<rand>" directories come from this
test's own sandbox() helper, not from posix.sh/windows.ps1.
Fixes#122130
The backend update-check endpoint answers behind: -1
(source_check.UPDATE_AVAILABLE_NO_COUNT) when the checkout is behind but
the count can't be computed — a shallow clone without a merge-base or an
unusable GitHub compare. mapBackendCheck clamped it to 0, making that
state byte-identical to the genuinely-up-to-date answer while
update_available still pitched the install, so every behind-based branch
disagreed with the overlay on the same screen and the changelog rendered
"what changed" over zero rows.
DesktopUpdateStatus already types this state as null ("never render it
as a literal number"), so pass the sentinel through as null. The
check-failed predicate is unchanged: it still keys on behind === null
with can_apply, which -1 correctly does not trigger (the check did run).
Also updates the pre-existing updates.test.ts expectation that encoded
the old clamp, and pins the sentinel in updates-backend-check.test.ts.
Fresh fix for #120852 (PR #120860 was deleted; nothing to salvage).
Any display.status -32601 rendered 'Screen needs a newer Hermes / Update
the bot's Hermes to use Screen' on every surface, with no distinction
between a stale git install the user can update and a Portal-managed
release they cannot: against Hermes Cloud the managed tab reports 'latest
release / Up to date' at the same time, which reads as a contradiction.
The roster already knows which kind of backend a bot runs on: the registry
stamps connectionKind: 'cloud' onto the row (annotateBotSource; Connection
Kind 'cloud' is remote-shaped but kept distinct for exactly this kind of
difference). Add isManagedBackend(bot) and pick the copy accordingly:
managed backends get 'Screen is not available on this managed Hermes
release yet' (portalUnavailableManaged, localized in en/ja/zh/zh-hant);
self-upgradable backends keep the update instruction.
Applies to all three surfaces that render the unavailable state: the
Screen pane's empty state, the sidebar portal subtitle, and the hero
caption. The managed release genuinely lacks display.* (methods_display.py
is absent at v2026.9.21), so hiding the surface stays correct — only the
sentence was wrong.
Fixes#120852