`_prepend_path` inserted the resolved command's directory only when it was
absent from the child's PATH. The Hermes installer appends its managed Node
dir to the user PATH, so for anyone with a system Node (<22.12) earlier on
PATH the check no-oped and the managed dir stayed behind it. npm lifecycle
children (`node install.js`) then resolved the older system Node and failed
with ERR_REQUIRE_ESM even though Hermes had provisioned a compatible runtime.
Strip every existing case/trailing-separator variant of the directory first,
then prepend it, so the canonical entry is the one that wins and PATH does
not grow duplicates.
Fixes#82309
A manual composer pick is sticky by design (d595e636c8), so a persisted
`nvidia` pick kept riding session.create after config.yaml moved to
`custom:nvidia`. The gateway honors the body's provider over config, built the
NATIVE provider, and silently dropped the custom entry's `extra_body`
(`thinking: {type: adaptive}`), disabling adaptive reasoning.
The catalog aliases `custom:<key>` with the bare config key (#87035): the two
spellings name the SAME endpoint, so a bare pick is a stale spelling, not a
distinct choice. refreshCurrentModel() now reseeds it from the profile default
when that default is the entry's `custom:<key>` form. A pick that already names
a provider class stays sticky, and an unrelated manual pick is never clobbered.
Fixes#81922
The resume-failure latch ("this window is stranded, retry it") armed only
when the cached sessions-list row reported message_count > 0. That row is a
cache of backend truth and lags the two flows that report a vanished thread:
after a sleep/wake reconnect the list can still carry the respawned
backend's session at 0 rows, and a context-compression tip can show 0 rows
while the stored transcript is intact. Either way the cold resume painted an
empty thread with an ACTIVE runtime and no latch at all — no auto-retry, no
error surface, just a silently blank chat that looks like lost history.
The resume RPC is authoritative and always reports the stored transcript
size (tui_gateway fills message_count from state.db even when
messages_omitted), so treat it — and a non-empty REST page — as the other
rungs of the same ladder. A resume that paints nothing now arms the retry
latch whenever ANY source says the session has a transcript, so the window
recovers through use-route-resume's bounded retry (and surfaces an explicit
error + manual Retry once exhausted) instead of going silently blank.
Fixes#82806Fixes#83154
DELETE /api/sessions/{id} removed the DB row but never passed the
profile's sessions dir to SessionDB.delete_session, so the on-disk
transcript artifacts survived the UI delete: legacy session_<id>.json
snapshots (which can carry plaintext secrets) and the gateway's
request_dump_<id>_*.json dumps. The CLI delete path threaded the
directory all along; the endpoint was the outlier.
Also sweep the legacy session_<id>.json snapshot name in
SessionDB._remove_session_files so deletes and prunes clear it from
installs whose older builds wrote it.
Fixes#60207
Co-authored-by: izumi0uu <izumi0uu@gmail.com>
Mark the win32 rows with platforms("windows") instead of patching
sys.platform: the repo runs host-specific behaviour on that host, and the
Windows Python-tests lane already imports this file.
Review follow-ups on the Windows skip:
- Most people who hit the boot loop launched Desktop from the Start menu and
never open a terminal. The in-app update also rebuilds the app: the
Windows shim waits for Desktop to exit, and the already-up-to-date path
still completes with desktop=True. The notice and updating.md now name
Update now in Settings -> About next to `hermes desktop`.
- cmd_gui took the skip's None as "no launchable app was found". A build
that fails raises instead of returning None, so None there only means the
skip, and `hermes desktop` now reopens the app that was kept.
Tests fold to the two invariants, each red when its half of the fix is
reverted: the stop spares its ancestor Desktop on every platform, and a
Windows packaged build under its own Desktop is skipped (now driven through
the real _desktop_ancestor_in with a fake process tree, instead of a stub).
An update interrupted after its dependency sync leaves source-completion-
pending, and the next Desktop launch finishes it from the Desktop's own
backend (venv_sync._finish_source_update). On Windows the tail's desktop
build reached _stop_desktop_processes_locking_build, which spared ancestor
Desktops only on POSIX, so it terminated the Desktop it was running under.
The pipes every child logs through went with it, the whole tree died before
the tail could clear its markers, and every launch repeated it (#123499).
Stopping an ancestor can never free the exe lock for the process doing the
rename: that process dies first. The ancestor spare now applies on every
platform, and build_prepared_desktop skips a Windows packaged build running
under its own Desktop with a notice, since its promotion could not succeed
while that Desktop holds the lock. The tail finishes and clears its markers;
the app's content stamp stays stale, so `hermes desktop` run outside the
app rebuilds it.
Closes#123499.
The receipt key hashes the npm version. Cover the transition from the
child-probe lane to the manifest lane: an install completed under the
probed version must still be recognized as complete once the version is
read from npm's package.json (same string for standard npm), and that
reuse must hold when `--version` can no longer spawn — the #123933 lane
on a Windows Job Object — without running npm ci again. A changed npm
version still invalidates the receipt. Runs without symlinks so Windows
CI exercises it too.
Co-authored-by: JoaoMarcos44 <joaomarcosdias444@gmail.com>
npm_execpath can point through a symlink, and the manifest sits beside
the resolved CLI, never beside the link: resolve the realpath before
looking for package.json. Layouts without a readable manifest now fall
back to the pre-fix child probe instead of aborting with ENOENT.
Move the regression test to tests-js/node-deps.test.mjs (the module's
own suite) and cover the symlinked-execpath and fallback lanes there.
The npm version probe spawned node-under-node before the reuse
short-circuit, so on Windows a Job-Object EBUSY spawn failure aborted
the whole dependency preparation even when the install was already
complete — leaving the pending-completion marker behind and turning
every launch into the same doomed completion pass (#123933).
npm's own package manifest states its version without any process
creation, so the probe lane can no longer fail: the install-receipt
key keeps its exact value, engine checks still run, and completed
installs reuse with zero spawns.
projects.db is written by the CLI (hermes projects create), other
Desktop windows, and workspace-settings flows — processes that never
touch this gateway's transports, exactly like state.db. The change
watcher had no projects.db signature, so those writes left the Desktop's
project list and folder tree stale until an unrelated refresh (#53046,
Add a projects.changed watch (2s interval, newest mtime across
projects.db + WAL for the watcher home and every served sibling profile,
mirroring the sessions.changed contract), register the event in the
gateway contract (generated TS/OpenRPC refreshed), and route it through
the desktop lifecycle handler into a $projectsChangeTick that
refreshes both the projects list and the sidebar tree.
Fixes#53046Fixes#56757
Co-authored-by: LeonSGP43 <LeonSGP43@users.noreply.github.com>
DELETE /api/sessions/<id> removed only the state.db rows: the durable
channel->session routing index (gateway_routing table + sessions.json
mirror) survived, so the next Discord/Telegram message routed to the SAME
id and resurrected the deleted row, and the on-disk .json/.jsonl
transcripts plus request_dump files were never scrubbed because
sessions_dir was not passed to delete_session (#42422).
- SessionStore.remove_by_session_id: drop every entry pointing at the id
(one channel can hold several) and persist the drop to both durable
copies; the index is written back by the gateway process, so removing
only DB rows elsewhere is undone by the next whole-index save.
- The API delete handler now passes the request-scoped sessions_dir and
clears the routing entries through the runner's SessionStore.
- Deletes made out of the gateway process self-heal at routing time via the
stale-route guard once a missing row counts as ended.
Fixes https://github.com/NousResearch/hermes-agent/issues/42422
_is_session_ended_in_db answered False when the row was missing from a
readable owning DB, so a session hard-deleted out of the gateway process
(CLI/TUI/desktop local delete) kept its live channel->session route: the
next inbound message resolved the deleted id and run_agent's INSERT OR
IGNORE re-created the row with its old content — the deleted conversation
resurrected (#42422). A missing row is now the same verdict as an ended
one: the stale-route self-heal drops the entry, recovery finds nothing,
and the peer mints a fresh session. DB errors still answer False.
A `terminal(background=true, heartbeat=N)` tick queued a notification every N seconds
whether or not the process had printed anything, and every queued event costs the owning
session a full model turn. On Desktop and the TUI that turn painted the wake as a user
bubble ("[Background process ... heartbeat #9 ... (no new output since the last
heartbeat)]") followed by the model's "Still running normally." — over and over, for a
process whose row on the status stack already said it was running — and while the wake
held the session's turn, the user's own prompt sat queued behind it.
- `ProcessRegistry._emit_heartbeat` skips a tick with no new output. The sequence counts
delivered beats only; the "(no new output)" placeholder in the formatter is gone.
- TUI/Desktop type heartbeat rows `display_kind: hidden` (the kind both clients and the
transcript preview already honour); the CLI paints a one-line receipt and persists the
row hidden, so reopening the session in Desktop shows only the agent's reply.
- Desktop hydration drops heartbeat rows persisted by older backends the same way.
- `display.background_process_notifications: off` is honored by the TUI/Desktop poller and
the CLI drain, not just the messaging gateway. `off` mutes process-driven wakes only:
a finished `delegate_task(background=true)` still lands.
Supersedes #123123 (cherry-picked; scoped so `off` keeps subagent results) and #119202
(cherry-picked; `heartbeat: 0` is schema-valid so models that materialize every field
stop tripping the foreground guard).
The documented `off` mode gated only the gateway's completion injection
(#9290); the CLI drain never consulted the key, so setting it silently did
nothing on the CLI — leaving no escape hatch against the post-Ctrl+C
notification turn restarts (#123114).
Mirror the gateway semantics: the drain still claims and acknowledges
completion events (durable rows converge instead of replaying on restart),
but the turn-starting `_pending_input` injection is suppressed.
Fixes#123114
(cherry picked from commit d009992c9e64b6083951b27a8261d138ae669ccb)
The picker latched its manual/free-text form from useState(!isKnown) at
first paint, before the async model.options read had resolved — so a bot
whose saved provider was known to the gateway still got free-text inputs
on the first Edit open, and the dropdown appeared only on the second
open, from the cached catalog. The latch is now the user's explicit
choice only (null = no choice yet); the form derives from the LIVE
catalog state on every render.
Fixes https://github.com/NousResearch/hermes-agent/issues/121875
The composer's @ popover listed every named profile twice: the Bot Mode
roster tags a bot by the slug of its friendly title (@john for a profile
dir named john-2), while the gateway's own @ completions list the same
backend profile by raw name (@john-2). use-at-completions deduplicated
by display text, so both labels for one routable identity survived and
the merged list truncated arbitrarily at slice(0, 8).
A composer.atCompletions row can now declare `handles` — other @texts
that resolve to the same target. The merge drops a gateway row whose
handle is claimed by a contributed row, keeping the contributed label
(title slug, connection meta). Bot Mode claims the raw profile name for
its local rows only; a remote row never claims a name the local gateway
might list as a different, local bot.
A visible-models allowlist persisted before the known-models snapshot
existed was adopted with the entire current catalog marked as already
judged, so a model present in the catalog but absent from the old
allowlist stayed hidden through every later refresh. Adoption now
records only the curated defaults the old allowlist actually contained;
the ones it omitted stay unknown and the default rule re-admits them.
Explicit hide-all sentinels are honoured, and a deliberately hidden
default resurfaces once at worst — the next save records the re-hide.
Fixes the adoption half of
https://github.com/NousResearch/hermes-agent/issues/122053 — stores
already seeded by the previous adoption behavior still need the
recovery action proposed in
https://github.com/NousResearch/hermes-agent/pull/122055.
Rebase fallout from main's picker/input cluster: the pointer-takeover test
matched the composed 'Gemini 2.5 Flash' text, but the -flash variant now
renders as its own chip, so the row's name span reads 'Gemini 2.5'. Target
the name span and walk up to the sub trigger, like the adapted badge tests.
YAML parses a bare `0` as int, which hit neither the bool nor the str
branch of _desktop_launch_options, so the unquoted off-switch silently
kept the tree on. Accept numeric scalars explicitly (bool checked first —
it is an int subclass), and add `disabled`/`enabled` to all three word
lists (launcher _A11Y_OFF_WORDS, readDesktopLaunchConfig, RENDERER_
ACCESSIBILITY_OFF_WORDS) so both launch paths accept the same
vocabulary.
Rebase fallout: main's badge-chip tests assumed the effort chip directly
follows the name span, and matched the composed 'Gemini 2.5 Flash' text.
The -flash variant chip now sits between name and effort badge, and the
name no longer contains the variant. Find the truncating name span by
class instead of previousElementSibling, assert the name stays free of
variant and effort, and match the leaf 'Gemini 2.5' text.
vi.spyOn was handed a freshly-constructed object literal, so the #118156
hook test never put 'just-archived' in the keep set — it passed with the
production fix deleted (vacuous). Hoist a module-scope mock of
getRecentlySettledSessionIds instead (same pattern as the session-removal
mock above it): four-cell control run, the test now fails on unfixed code
and passes on fixed code.
The opt-out was bridged only by the hermes desktop launcher; a packaged app
started from its own entry never saw it. main.ts already reads config.yaml's
desktop block before ready (readDesktopLaunchConfig), so parse the key there
and set HERMES_DESKTOP_RENDERER_ACCESSIBILITY=0 when it is false.
Chromium only builds the renderer's accessibility tree when it detects
an assistive technology — and dictation tools that insert text through
the OS accessibility APIs (Wispr Flow etc.) do not register the way a
screen reader does, so the tree never appeared: the window exposed only
its chrome and the contenteditable composer was unreachable. No
setAccessibilitySupportEnabled call existed anywhere in the electron
tree, on any platform.
- apps/desktop/electron/renderer-accessibility.ts (new): the
platform/env decision in a dependency-free module (app object
injected) — ON by default on darwin/win32 where the typed API exists,
opt-out via HERMES_DESKTOP_RENDERER_ACCESSIBILITY=0. Linux is untouched
(ATK flow needs no manual enable; the API is not typed there).
- main.ts calls it right after app ready (the API's requirement).
- hermes desktop launcher: `desktop.renderer_accessibility: false` in
config.yaml bridges to the env var — only the opt-out is bridged, so
the default never depends on the launcher path. Same shape as
desktop.disable_gpu. config_defaults documents the key.
Family note: the same seam on Windows is #92607 (Wispr Flow there);
this one enablement should resolve both platforms, but the Windows leg
is unverified on this machine, so #92607 stays open. The dictation
keybind half of #118271 already landed separately in 3cc24b0130
(composer.dictate action; rebindable-key ask tracked by #112118/#71658,
left alone).
Tests: 4 electron-side unit tests (default-on per platform, Linux skip,
opt-out words + explicit env precedence, injected app switch flips
exactly when enabled); Python side: 12-case normalization parametrize +
env-bridging precedence test (ON sets nothing, opt-out bridges, explicit
env wins) in test_gui_command.py; _desktop_launch_options signature
consumers updated. All green; the remaining lucide-react TS2307 in
onboarding setup.tsx is pre-existing on base (missing dep of that
workspace file, untouched here).
Fixes#118271
The row is an inline-flex container, but the input was only ever
content-sized (`field-sizing: content`, no flex grow), so inside a
stretched row — every w-full settings page, the flex-1 connector filter
bars — the input kept text width and the clear button trailed the typed
text at whatever offset the value happened to be, instead of sitting at
the row's right edge.
`flex-1` joins the input's classes. `field-sizing: content` stays: it
governs the row's *preferred* size (a width-less caller like the sessions
sidebar keeps its compact, value-width row) while `flex-1` + `min-w-0`
govern what happens when the caller stretches the row — the input
absorbs the free space and the clear button (plus any trailing action)
pins to the edge. Long queries still scroll inside the field.
Layout verified in a real browser across the four caller shapes:
settings w-full (clear delta 351px -> 2px from the right edge), command
centre max-w-[40vw], kanban flex-wrap header, and the width-less
sidebar row — compact sizing unchanged in the width-less cases.
RED→GREEN on origin/main: the flex-child test fails at base (no flex-1)
and passes after; clear wiring and empty/loading cases covered alongside.
Fixes#119204
Archiving a session removed it optimistically, but it came back minutes
later with the backend never un-archiving it (state.db still archived=1).
Two renderer paths let the row back in:
1. mergeSessionPage's survivor filter honored `hidden` and the keep set,
never the tombstones — and the keep set reliably names a just-archived
chat, because the 30s settle grace keeps it "recently settled" and the
optimistic drop only clears $sessions, not a `previous` slice a slower
refresh (messaging "Load more") captured earlier. Once ANY path put
the row back into $sessions, the survivor filter kept it there across
every later refresh.
2. The tombstone guard on incoming rows expires: applyProjectTreePayload
prunes a tombstone as soon as the mutation RPC lands and the tree
payload omits the id — correct for the tree overlay, but it also
removes the last guard a stale recents page has to pass, so a page
read before the archive commit could re-inject the row after the
prune.
The survivor filter now consults $removedSessionIds directly (bare id +
lineage root, the same match dropTombstoned applies to incoming rows),
re-reading the set at merge time so a removal landing mid-refresh is
still honored. A failed RPC untombstones immediately, so nothing is
filtered on non-destructive paths.
RED→GREEN on origin/main: mergeSessionPage keeps a keep-listed tombstoned
row at base and drops it after; the hook-level race test (row seeded in
$sessions + settle-grace keep + tombstone) passes only with the filter.
Fixes#118156
models.dev carries both `deepseek-flash` (the alias) and
`deepseek-v4.1-flash` (the full id) for the provider, so the picker lists
two rows for two distinct ids. prettifyBase() mapped them onto the SAME
label: the alias hit the `deepseek-flash` hardcode ("DeepSeek V4.1 Flash",
added in #117646) while the full id fell through to naive title-case
("Deepseek V4.1 Flash"). Neither id matched VARIANT_TAGS, so both rows
rendered tagless and read as one model listed twice with inconsistent
casing.
- `-flash` joins `-fast`/`-thinking`/`-preview`/`-latest` as a variant
tag, so the alias renders "DeepSeek + Flash" and the full id
"DeepSeek V4.1 + Flash" — the same disambiguation `…-4.8` vs
`…-4.8-fast` already uses.
- The alias hardcode is gone; it was the collision's source.
- A vendor-word pass fixes the casing gap that produced "Deepseek"
("GLM", "MiniMax", "MiMo", "ERNIE", "OpenAI" included), so generated
labels match the vendor's own spelling.
RED→GREEN on origin/main: the new model-status-label cases fail at base
(both ids → { name: 'DeepSeek V4.1 Flash', tag: '' }) and pass after.
Sibling menu tests updated to the corrected labels — the catalog row
locates by name with Flash as meta, and the search-fold case asserts an
id-style query still finds the row through the id fold.
Fixes#118083
_install_startup_entry swallows the .cmd unlink failure, so a locked legacy
launcher left both entries firing at logon while reconcile reported a
migration and doctor --fix counted it fixed (#80569).
hermes update now runs the autostart reconcile when it refreshes the
launcher scripts, and hermes doctor reports a Startup entry that
duplicates the Scheduled Task (removing it under --fix), so installs
made before the install-time fix converge too.
Co-authored-by: David Metcalfe <80915+DavidMetcalfe@users.noreply.github.com>
A successful Scheduled Task install returned without removing an
existing Startup-folder Hermes_Gateway.vbs or legacy .cmd, and the
fallback path wrote a Startup entry even while a task was still
registered. Both fire at logon, so the gateway launched twice.
install() now removes Startup entries after the task registers, the
fallback is skipped while a task exists, and reconcile_autostart_launchers()
converges an existing install to one mechanism.
Co-authored-by: David Metcalfe <80915+DavidMetcalfe@users.noreply.github.com>
conhost blocks every write to a console while a selection is active. The
hand-off replays buffered child output through Write-HandoffLog after
`hermes update` exits, so a selection in a visible hand-off console held the
result, marker cleanup and relaunch until the user pressed Esc (#103222).
Turn QuickEdit off on the console input buffer for the run (restored on
exit), and skip the console echo while a selection is in progress. The log
file still gets every line.
27df3b8847 moved the hand-off spawn into updater/checkout.ts and hard-coded
`detached: true`, dropping the wrapper's own `detached: false`. With
DETACHED_PROCESS the cmd.exe wrapper owns no console, so `start /b` makes
PowerShell allocate a new visible one, and a QuickEdit selection in it
stalls the hand-off before marker cleanup and relaunch (#103222). Spawn the
wrapper the way wrapHandoffForDetachedConsole describes it.
A failed receipt check after a zero-exit update means the updated Desktop build
could not be read, not that the install is damaged. The old copy told users to
repair the installation and review antivirus quarantine, which is destructive
advice for a healthy install (#107685). Say what happened and give the one
non-destructive recovery step.
On macOS `hermes desktop` launched the release/ bundle even when an
installed Hermes.app existed, so the app ran from two paths (two Dock
icons, TCC grants keyed to a different bundle) while the one Finder opens
stayed stale. Refresh the installed copies first and launch the one that
now matches the checkout build; release/ stays the fallback.
`hermes update` only rebuilt Desktop when apps/desktop/release/ or dist/
existed, so an installed Hermes.app that only the checkout update refreshes
(a bootstrap build) never got newer once release/ was gone or never built.
Count such bundles as a Desktop to keep current. Ownership is read from the
bundle's install-stamp.json: `updateMechanism: self` (or a stamp older than
the field). Self-updating releases and commit builds are never rebuilt or
copied over, and only the checkout an installed app actually runs (the one
under the default Hermes home) claims it. The post-build install now uses
the same set.
latent-spaces/brag (MIT) turns the project you just built into a short
launch video with music, motion and share copy. It ships two skills:
/brag, the Hyperframes workflow with a bundled music and SFX library,
and /brag-slim, a single SKILL.md where the model builds the whole video
with local tools. /brag hands off to its bundled copy of brag-slim on
Claude Opus 5.5.
Both follow the impeccable/archify pattern: catalog stubs whose
metadata.hermes.upstream pointer makes
`hermes skills install official/creative/<name>` pull the live tree
through OptionalSkillSource._fetch_from_upstream. Nothing is vendored.
The brag stub documents that its Hyperframes path loads HeyGen's
hyperframes-* domain skills by name, and that the skills guard blocks
four of the five today (hyperframes-creative scores dangerous).
brag-slim has no such dependency.
Docs: two generated pages, two catalog rows, two sidebar lines.
Credit: Shunit Haviv Hakimi (shunithaviv), upstream author.
TUI/Desktop sessions that end via _finalize_session never cleared the
approval session state, so session-persistent execute_code kernels
(owner = approval session key) stayed alive until the NEXT execute_code
in the same process ran the lazy idle sweep in _acquire_kernel.
The gateway's /stop and /new paths already call approval.clear_session
(approval.py:264) at the same boundary, killing the owner's kernels with
the session. Align the TUI path: when the TUI owns the session lifecycle
(not a viewer tab over a gateway-owned session), clear_session(session_key)
on finalize so a finished conversation cannot leak a live interpreter.
Fixes the class: any end_reason that tears down a TUI-owned session
(tui_close, ws_orphan_reap, idle_timeout, lru_evict, tui_shutdown) now
reaps its kernels.
Two pastes of the same captioned screenshot strip to identical tolerant
captions, so findIndex folded the second paste's error onto the FIRST paste's
settled reply, and the tail prompt match dropped the second paste's prompt as
already-represented by the first's committed row. Pair the n-th local
captioned paste with the n-th stored one; an unpaired ordinal keeps the
conservative preserve path.
The tolerant sameAttachmentTurn arm was an unparenthesised || operand, so a
rowId-bearing optimistic row could be swallowed by a committed row it provably
is not — pasting the same captioned screenshot twice dropped the second,
genuine turn. Gate both arms on !conflictingTranscriptIdentity, matching the
comment's invariant; the rowId-less paste keeps matching tolerantly.
Two captionless pasted-attachment turns strip to the same empty tolerant
caption, so sameAttachmentTurn matched ANY markers-only stored row against
ANY captionless local row and reconciliation folded one turn's error onto
another paste's reply. Empty now matches nothing: such rows take the
conservative preserve path. Regression test covers the two-paste shape.
The branch-render guard landed earlier (78b133127d); what remained was
provenance: a projected continuation tip rendered as a brand-new
session. list_sessions_rich now stamps continuation_kind='compression'
on projected rows, StoredSessionRow carries it (contracts regenerated),
and the sidebar session row labels continuations with their lineage
root instead of a fresh-conversation affordance. The live tip stays
listable while naming its parent; sealed chain segments stay hidden.
Co-authored-by: wave-2 worker D <wave2d@hermes-triage>
Pasted-attachment user rows carry no rowId until hydration, so
hydratedIdFor returned undefined and matchesTailUserInNext required
exact text+refs — the conservative append duplicated the turn. Match
on attachment identity (normalized refs + text) when the rowId is
absent, reusing the new attachment-turn helpers.