The salvage cherry-pick landed nested/interleaved describes in
context.test.tsx — split them out so each suite has its own beforeEach,
and rename the local-bridge describe to what it actually covers. Use
the repo's hermes_yaml wrapper (safe_dump) in test_skin_engine.py where
plain 'yaml' isn't a guaranteed dependency.
- backend-sync: built-in/default-named skins store CSS without shadowing
the palette; empty field clears the entry; reset clears the store
- context: style tag insert / same-tag replace / removal lifecycle, plus
mono-named and default-named user skin CSS application
- skin_engine: passthrough, whitespace strip, 32 KiB cap, empty default
- protocol: end-to-end resolve_skin() payload carries customCSS from YAML
ingestBackendSkin() deliberately skips skinToDesktopTheme() for 'default'
and BUILTIN_THEMES names so a user skin never shadows the desktop's
hand-tuned palette. That also dropped the skin's customCSS, contradicting
the documented precedence ('user skins in ~/.hermes/skins/ take precedence
over built-in skins with the same name'). Carry the CSS separately in a
atom (keyed by the name the desktop resolves, default →
nous) and merge it into the derived theme, so the palette policy is
untouched but the user's CSS still reaches applyTheme.
The web dashboard supports customCSS in theme YAML (PR #14776) but
the desktop app's skin pipeline dropped the field. Users who wanted
custom styling had to hack app.asar, which gets overwritten on every
update.
This adds customCSS passthrough through the full skin pipeline:
- HermesSkin (apps/shared) and DesktopTheme types gain customCSS?: string
- skinToDesktopTheme() passes skin.customCSS through
- applyTheme() injects a scoped <style id="hermes-desktop-custom-css">
tag on theme apply, and removes it when switching to a CSS-less theme
- SkinConfig gets custom_css field, read from YAML as "customCSS"
- _build_skin_config() caps at 32 KiB (same as the web dashboard)
- resolve_skin() emits "customCSS" in the gateway JSON-RPC payload
Users now put CSS in ~/.hermes/skins/<name>.yaml under the customCSS
key — it persists across updates because the skin dir is outside app.asar.
Closes#53013
Refs #53012
The desktop ships cwd_explicit provenance with session.create (#52589),
but the wire contract (extra=forbid) rejected the unknown key, so every
remote-mode create failed with 'out of sync (different versions)' and the
composer never accepted the draft (Desktop core E2E remote-topology).
Local-backend lanes never sent it (a bare new chat is detached, no cwd).
Regenerate the shared contract TS + OpenRPC from the updated registry.
approval.respond rode the generic 30s RPC deadline while the backend
honors an answer for the whole approvals.timeout window (default 300s).
A WebSocket write stalled behind a long LLM stream killed the client's
respond long before the backend would, surfacing the red
"request timed out: approval.respond" toast and apparently freezing
the session.
The RPC now carries an explicit 330s deadline (300s window + drain
margin), ambientRequestFor forwards per-call deadlines it was silently
dropping, and a deadline failure retries once — resolve_gateway_approval
pops the queue entry before committing, so a duplicate resolve is a
harmless resolved: 0.
Fixes#55433
A new chat bound to a named profile carried the desktop's app-global
workspace cwd (the launch profile's configured directory or the current
project scope) unconditionally, so a profile with its own terminal.cwd
was silently overridden on both create and resume.
The desktop now ships cwd_explicit provenance with the create RPC — true
only for a deliberate workspace pick (folder picker, an explicit lane or
tile target), false for the inherited default — and the gateway lets a
named profile's configured terminal.cwd win over a non-explicit client
cwd. An explicit pick still wins; a profile without a configured cwd
keeps the inherited workspace.
Fixes#52589
Co-authored-by: Sahil-SS9 <Sahil-SS9@users.noreply.github.com>
installWindowsSystemCaTrust only handled win32, so on macOS a remote
gateway fronted by a keychain-trusted private CA rendered fine but
every main-process Node https call failed with 'unable to get local
issuer certificate'. Generalize it to installSystemCaTrust covering
both stores tls.getCACertificates('system') can enumerate: the Windows
cert store and the macOS keychain (user + System roots, honoring the
SSL 'Always Trust' policy; Node >= 22.15). Linux stays excluded since
its 'system' store is the OpenSSL scan the default trust already
covers.
Fixes https://github.com/NousResearch/hermes-agent/issues/57241
The salvaged /background spec named the alias as canonical; the Python
registry's canonical is bg, so the catalog's exec placeholder (and the
canonical lookup) missed the local action and would have fallen through
to slash.exec. Name the spec /bg with /background as the alias, matching
how /btw sits in the local table. Also drops the /journey and /stop exec
assertions from the salvage (both contradict main's current routing).
The desktop routed /background through the slash worker, whose HermesCLI
prints the completion from a fire-and-forget thread after process_command
already returned - past the worker's stdout capture window - so the
result never reached the conversation that started the task (#97635).
Route the command to the gateway's prompt.background RPC (the TUI's
path) and render the background.complete event into the originating
session's transcript, mirroring the TUI's [bg <task_id>] system line.
Review findings on this branch:
- The skip keyed on status.supported, which is can_apply == (install_method
== "git"). That silenced docker/nix/apt backends too, which do return a
real command, and it could act on a stale status after switching remotes.
With the empty-command fix, a managed backend already lands on an honest
message-only dialog, so the skip isn't needed.
- Every backend manual state comes from the refusal branch, so the
apply.error "refused" flag always equalled isBackend. Stop writing a UI
flag into the error field; ManualView picks the command-less title from
isBackend.
- Normalise the backend command once instead of `|| null` / `|| undefined`.
When the connected backend refuses a dashboard update and returns a real
command (docker/nix installs), the manual dialog said "You installed
Hermes from the command line" -- untrue for a remote backend. Use
backend-aware copy instead.
Salvaged from #78958. The PR's backend half (returning
recommended_update_command() from the managed-externally refusal) is not
carried: that branch only fires for hosted /opt/data and pip/docker
container installs, where the recommended command is exactly the update
the guard blocks. The earlier commit in this series returns an empty
command there instead.
- Update everything no longer calls the backend updater when the backend
already reported supported=false (managed container, commit build); it
surfaces the backend's own reason and still runs the client leg.
- A command-less backend refusal is titled "Can't update from here"
instead of "Update from your terminal" (all locales).
Containerized dashboards refused updates with update_command set to the
prose 'managed outside dashboard'. Desktop renders update_command verbatim
as a copyable '$ <cmd>' line, so users got a fake command that fails with
'command not found: managed'.
- backend: managed-externally refusal and check return update_command=''
(no runnable command), matching the commit-build refusal.
- desktop: treat an empty update_command as 'no command' (message-only
manual view); fall back to 'hermes update' only when an older backend
omits the field. Previously '' || 'hermes update' also showed a wrong
command for commit builds.
`_lineage_ids` members were matched bare, so a kept row in another
profile whose stored id merely coincided with an incoming lineage was
evicted. Qualify members with the owning row's profile, matching the
identity and lineage keys in the same predicate.
mergeSessionPage's survivor filter kept any previous row whose id sat in the
keep set and that the incoming page did not return. The tip-rotation
lineage dedup (#43483) only matched through the lineage ROOT key, so
when a compression-chain reorganization minted a FRESH root id (manual
storage-format repair), an old SEGMENT row that was in the keep set (the
working/selected session at refresh time) produced the exact signal of a
legitimately-kept row: absent from the page, unmatched by lineage key.
It survived as a title-less ghost the backend never sent — the sidebar
rendered N+2 rows for a group the endpoints served as N.
Fix: the survivor filter now also drops a row whose id appears anywhere
inside an incoming row's `_lineage_ids` (the full chain the backend
projects, already served on list rows). That is absorption, not
staleness: a genuinely-kept row — pinned aged off the page, an
in-flight first turn — never has its id inside ANOTHER session's
lineage, so the protected survivor behaviors all hold (guards included).
No API change, no extra traffic; the data needed was already on the wire.
Regression tests: the reporter's exact unit repro (old segment in keep
after reorg mints a fresh root) and the pinned-aging-off guard with a
deep lineage on the incoming page.
The official repair-chains maintenance command from the report is a
separate feature (open PR #86743) and stays out of this fix.
In card (Inbox) mode the actions cluster holding the ⋮ SessionActionsMenu
renders INSIDE SidebarRowBody — the row button whose plain-click onClick
resumes. Radix portals the menu content, but React synthetic events still
bubble through the logical parent, so an Archive menu click also fired the
row's resume: the archive RPC raced a re-open of the chat it was removing,
and the queued resume could transiently restore the row. Flat rows are
unaffected (their actions render outside the row button via the shell
actions column).
Two halves, same bug class:
1. stopPropagation (click + pointerdown) on the actions container in card
mode. Container-level on purpose: every action owns its gesture; a future
child that needs row semantics moves outside the boundary.
2. upsertResolvedSession now refuses to recache a resolved row that raced an
archive/delete: while any identity (stored id, row id, lineage root) is
tombstoned, when the backend row itself is archived, or when the
tombstone lifecycle moved since the request started (ABA-safe — a failed
archive rolls the tombstone back while the by-id response is in flight).
Tombstone generations in store/session-removal make the ABA cycle
detectable; membership alone cannot. The resolved row is still returned,
so an explicit resume-by-id of archived history keeps working.
Component test drives the REAL Radix menu inside the REAL row: without the
stopPropagation, onResume fires twice (the reporter's exact symptom);
with it, onArchive fires once and onResume never.
Salvage of open PR #85166 by Jakub Wolniewicz, rebased onto current main:
tombstone generations live in store/session-removal (their home since the
projects/sessions store split), and the upsert guard keeps the hidden-row
off-list parking intact.
Co-authored-by: Jakub Wolniewicz <4850809+frizikk@users.noreply.github.com>
An empty repo_scan_roots silently expanded to a bounded scan of the
user's entire home directory on every Desktop launch, with no way to
restrict the traversal short of disabling discovery outright. Empty
roots are now a safe no-op: users must explicitly configure
desktop.repo_scan_roots for filesystem scanning, and session-derived
projects remain available. The default config comment documents the
opt-in.
Fixes#53328
Co-authored-by: John Kim Querobines <jkim.querobines@gmail.com>
The effort-badge change wraps the model name span in a container span
alongside the badge chips, so SPAN text queries that match on composed
textContent hit two elements. Require a leaf span (no descendant span)
in the affected model-menu-panel queries.
The model catalog menu appended the reasoning effort (and fast mode, and
variant tag) to the model name as plain tertiary text with only a
leading space, so a row read 'Qwen3.7 Max High' — the same model with a
High reasoning setting looked like a differently-named model.
Render each row-meta value as a discrete bordered chip beside the (now
independently truncating) name span, so the setting can't be mistaken
for part of the name.
Fixes https://github.com/NousResearch/hermes-agent/issues/51833
Co-Authored-By: PRATHAMESH75 <prathamesh290504@gmail.com>
The voice-conversation loop spoke every reply unconditionally: the per-turn
drive effect opened a live speech session as soon as a reply appeared,
regardless of the "Read replies aloud" setting. With the toggle off (and
voice.auto_tts false / tts.provider empty) a voice-chat turn still read the
reply aloud, and the mic could pick the speaker back up as phantom input.
Gate the loop at the same setting the toggle writes ($autoSpeakReplies,
seeded once from voice.auto_tts): when it is off, a completed reply is
consumed and the mic re-arms directly — speech recognition still works and
the reply stays text on screen; no TTS stream or sentence playback starts.
resolveGitBinary() only probed fixed candidate paths and PATH. A UGit
install keeps its Git-for-Windows copy under a versioned Electron app dir
(%LOCALAPPDATA%\UGit\app-<version>\resources\app\git\cmd\git.exe), which no
fixed candidate can name — and while the UGit installer adds that dir to
PATH, an Electron process launched from Explorer inherits the login-time
environment block, which can miss it. The update check's git spawn then
ENOENTs and "Check for updates" fails.
Extract the Windows candidate list into git-binary-candidates.ts with an
injectable fs, and enumerate the UGit app-* glob there (newest version
first, after the Hermes-bundled PortableGit and before the system-wide
defaults). resolveGitBinary() now selects from that list. Proven by a
unit test driving a fake filesystem — the reported layout resolves to the
UGit binary with nothing earlier on disk, and the test fails when the glob
is removed.
reportBackendContract() warned only when the backend reported a contract
LOWER than the GUI's required value. The reverse skew — a GUI build older
than the backend it drives — passed silently. That happens in practice to
any long-running desktop app that survives a backend update without
relaunching; the stale GUI then drives newer gateway code and fails
cryptically downstream.
Warn in both directions:
- backend contract < required (existing): "Backend out of date" toast
with the one-click backend update action, unchanged.
- backend contract > required (new): "Hermes app out of date" toast
pointing at the client update overlay (openUpdateOverlayFor on the
client target).
The new toast mirrors the existing skew toast's ergonomics: persistent
24h snooze on dismiss, immediate clear once the two sides align, and
snooze reset on alignment so a later skew warns right away. Each
direction dismisses the other's toast, so at most one skew warning is
ever live. i18n keys added to all shipped locales;
DESKTOP_BACKEND_CONTRACT docs in tui_gateway/server.py now describe both
directions.
Fixes#60542
Co-authored-by: Hermes Upgrade Staging <alex@vencounsel.com>
A lone workspace pane is stranded without its strip: Close still empties it
to a draft and + still opens a tab, so a chromeless workspace is a dead zone
for both handles. Treat any lone main pane as stranded, workspace included,
which also makes the auto-only lone-main clause unnecessary.
Approach from #107444 by @abundantbeing.
Co-authored-by: abundantbeing <beingsabundant@gmail.com>
Every launch lands on a lone workspace chat, and auto mode hid its strip,
so the session tab and its "+" were gone until the app-wide default was
set to always. A lone main tile now keeps its strip on auto; Hide tabs and
an app-wide "never" still hide it. The siblingMainZone input and the
$mainTileZoneCount store it fed are no longer needed and are removed.
After a QR apply the success toast only said the gateway was restarting,
and reset() wiped the bot name, so the Quick setup card fell back to the
"already configured, will replace" warning with no sign of which bot was
just connected.
The toast now reads "Connected: @bot · Telegram saved; gateway
restarting…", and the card keeps a Connected badge with the @bot handle
in place of the replace warning until the next setup starts.
Co-authored-by: wangtao <wangtao@wangtaodeMacBook-Pro.local>
A preview tab whose file was moved or deleted rendered "Preview
unavailable" with nothing to click, so the only way out was the strip's
close glyph. The file read/PDF error states and the missing-artifact state
now offer a Close button wired to the tile's own closeTabPane, the same
close the strip and Cmd+W run.
Adapts the onClose threading from #93194.
Co-authored-by: Axl Ibiza, MBA <andrexibiza@gmail.com>
Adapt frizikk's runtime-session-timer tests to main's branchStoredSession
routing (branch_stored create), HEAD's statusbar timer harness, and seed
$sessionStates so the runtime cache anchor is observable.
The onboarding wizard's Local / custom endpoint path hard-failed on
OpenAI-compatible SaaS that doesn't expose an OpenAI-shaped /v1/models
catalog (Cohere's compatibility endpoint, auth-gated gateways): the probe
came back reachable-but-empty and the wizard rejected the save with
'Start a model on that endpoint' — no way forward except hand-editing
config.yaml, even though the runtime already supports these endpoints
via discover_models: false + an explicit models: list.
Turn the empty-models case into a recoverable flow: the save returns
{ ok: false, needsModelInput: true } and the form reveals a manual
model-name input (hidden on the happy path). A typed name is persisted
verbatim through the same provider=custom + base_url assignment.
Fixes https://github.com/NousResearch/hermes-agent/issues/47006
Co-Authored-By: David Metcalfe <dmetcalfe@users.noreply.github.com>
A delegate child (source='subagent') is invisible in the sidebar
(_LISTABLE_CHILD_SQL), so when its id lands in the remembered-session
slot the next cold start resumes an orphan chat while the sidebar
highlights the parent: every message silently goes to the child
(#56983). The remember path had no source check, and the restore path
validated ownership only — a child row that reached a list slice
(messaging aggregator, optimistic insert) passed both.
Guard both directions, keyed on source (not parent_session_id — /branch
children carry it too and ARE user-facing):
- Remember: a routed delegate row remembers its parent instead; an
orphan child remembers nothing.
- Restore: a listed non-delegate row restores synchronously exactly as
before; an unlisted id resolves by id (the by-id endpoint serves
children the list omits), repairing a child to its parent, clearing
an orphan/foreign id, and keeping the remembered value when the
fetch itself fails.
Fixes#56983
Salvages #56988 (resolveRememberedSessionId + repair design, authored by baau)
Co-authored-by: baau <1347825413@qq.com>
Streamdown renders `1~10` / `~¥0.089` as GFM strikethrough pairs (two lone
tildes in one paragraph pair up and strike through the text between them),
and unknown HTML-shaped tokens like <tool_call>/<observation> are fed to
parse5 as unclosed tags, silently swallowing the rest of the message.
Escape lone tildes (preserving ~~strikethrough~~, ~~~ fences, autolink
paths, inline & fenced code) and escape unknown html-like tokens to
entities against a 55-element safe-tag allowlist, nested into the
rewriteProseSegment pipeline that already shields URLs/file links/math.
Includes preprocess unit tests and render-level DOM regression tests.
Closes#50871, #53953.
After the locate click, type now refuses unless the located editable is
document.activeElement, so characters are not delivered as page input when
focus stayed on body. The keystroke loop checks an abort signal between
characters; preview.act cancel (timeout or interrupt) and a local Stop both
set it, so queued keystrokes stop. A printable press on body/html is refused
unless allow_shortcut is set.