fmt(js): npm run fix auto-fix - #90408
Merged
Merged
Conversation
Contributor
૮ >ﻌ< ა ci reviewran on 86e6447 — fmt(js):
|
movitecc
pushed a commit
to movitecc/hermes-agent
that referenced
this pull request
Aug 20, 2026
* fix(desktop): repair missing Windows runtime
* fix(desktop): wrap toast titles so long errors stay readable
AlertTitle clamps to one line, so Desktop error toasts hide the rest of the message behind an ellipsis. Override that clamp, let the title wrap, and cap height so a huge error scrolls instead of covering the chat.
* test(desktop): keep long toast titles readable
Cover the wrap override and height cap so a one-line clamp cannot hide the rest of an error toast again.
* fix(desktop): stop re-entrant pin-sync reconcile from overflowing nanostores
The Desktop renderer crashes with `RangeError: Invalid array length` thrown
from `Array.push` inside nanostores' `notify()`. The shared `listenerQueue`
grows without bound because `reconcile()` is subscribed to BOTH `$sessions`
and `$pinnedSessionIds`, and `pullRemotePins()` mutates `$pinnedSessionIds`
(via `pinSession`/`unpinSession`), which fires `reconcile()` again
synchronously.
The existing `mirrored`/`pending`/`unconfirmed` fences only cover a *bounded*
single-toggle echo. They do not cover the *unbounded* oscillation that occurs
when two profiles share a session id with conflicting `pinned` flags (copied or
imported profile databases). A profile-blind pull then pins and unpins the same
durable id in one pass, re-firing `reconcile` forever until the queue overflows
and the renderer dies.
Two changes:
1. `rowsByPinId()` collapses the cross-profile session list to one
authoritative row per durable pin id, preferring the active gateway's
profile (the same tie-break `resolveLoadedRow` uses). `pullRemotePins()`
iterates the deduped rows, so a conflicting duplicate can no longer pin then
unpin the same id in a single pass.
2. A re-entrancy guard on `reconcile()` so a synchronous re-entry (from the
`$pinnedSessionIds` listener firing during `pullRemotePins`) returns
immediately instead of recursing.
Also fixes a latent TDZ `ReferenceError` in `session-unread.ts`: `isPlainRecord`
was declared after its first use through `persistentAtom`, so decoding a
persisted value could throw `Cannot access 'isPlainRecord' before
initialization`.
Regression tests cover the duplicate-id oscillation and the active-profile
tie-break.
* fix(desktop): scope session lookup to the active profile
Unscoped getSession hits the primary backend. A 404 then skipped the
active profile in the remaining probes, so chats on a non-default
profile never loaded.
Co-authored-by: Michael McAllister <michael@empowerlo.com>
* refactor(desktop): split src/hermes.ts into src/api/ domain modules
2,248 lines of gateway REST client become twelve modules by domain, with
hermes.ts left as a barrel so all 144 importers stay put. The import
graph is a star — every domain module imports only ./client, and client
imports nothing back — so there are no cycles.
The barrel names client's public exports rather than re-exporting it
wholesale. Splitting a module forces its private helpers into exports so
siblings can reach them, and export * would then republish them:
profileScoped, connectionScoped and capabilityScoped were private to
hermes.ts and have to stay that way, or a call site can assemble its own
request scope and drift from the api layer.
* feat(desktop): back window glass with Windows 11 system materials
Glass was macOS-only because it rode setVibrancy. Windows 11 22H2 has a
first-party equivalent in setBackgroundMaterial, so the mode now resolves
its backing per platform instead of per-OS-check: macOS keeps vibrancy,
Windows 11 gets DWM acrylic / tabbed / mica, and everything older stays on
Clear. No third-party native addon.
Two Windows-specific details the mapping has to respect. DWM only paints
the client area of a transparent window (electron#49443), so glass-capable
Windows chat windows are born transparent with the opaque themed
backgroundColor covering them while glass is off — a live Clear/Glass
toggle then needs no window recreate. And Windows exposes three backdrops
for four frost rungs, so the two heaviest both resolve to mica; the mapping
stays total so a frost saved on a Mac still renders.
Glass support is computed once from os.release() and shared: main uses it
for the persisted default and every window, preload publishes it to the
renderer so the UI can't offer a mode the window can't back.
* fix(desktop): keep recents when ALL-profiles scope has one profile
Grouping → Profile persists ALL even with a single profile. Recents
filtered that pool against the __all__ sentinel and emptied the list.
Cron and messaging already used filterSessionsByProfileScope; recents
now does too.
Co-authored-by: andyst-dev <150129844+andyst-dev@users.noreply.github.com>
* refactor(desktop): split gateway-event.ts into per-family handler modules
The monolithic if/else-if dispatcher becomes nine modules by event
family. The routing preamble runs once, then each handler consumes its
own types and reports whether it did, so dispatch stops at the first
taker. Families are mutually exclusive by type, so ordering between them
is inert; ordering within a family is unchanged.
Restores two things the extraction dropped against a moving base: the
layout.apply handler, and the multi-question clarify.request path. A
batch clarify was consumed and never parked, so the agent blocked on
clarify.respond with no card rendered — the existing tests passed
because they assert "exactly one clarify card", which is also true when
the request is dropped and only the tool.start row exists.
* refactor(desktop): split lib/chat-messages.ts into concern modules
Types, part builders, tool parts, hydration and reconciliation, behind a
barrel that keeps the @/lib/chat-messages path.
The folder was added without removing chat-messages.ts, so resolution
preferred the file and all its importers kept hitting the monolith while
the new modules sat dead. Deleting it surfaced a missing preset field on
GatewayEventPayload that layout.apply needs, hidden until the folder
actually resolved, and a completeOpenStreamParts helper copied into two
modules when only one calls it.
* refactor(desktop): extract IPC clusters from electron/main.ts
52 handlers move into five registrars — git, pet overlay, hud, fs and
terminal. Each takes injected deps (window handles, binary resolvers,
path hardening) following the existing electron/ module pattern rather
than closing over main.ts locals, and terminal-ipc returns its dispose
helpers so SSH teardown and app shutdown keep working.
* feat(desktop): scope the translucency controls to what each OS can do
The row offered one unlabelled 0-100 slider whose meaning changed with the
mode. Under Clear it is window opacity; under Glass it was never opacity at
all — it sets how much of the theme tint stays painted over the material.
Same track, same percent readout, two different things.
Glass now gets a labelled panel: Tint keeps the renderer lever, Fade is a
real native opacity on the ramp Clear uses, defaulting to 0 because fading
a glass window fades its text — the thing Glass exists to avoid. Frost
offers only the rungs the OS renders distinctly, so Windows shows three
instead of two buttons that composite identically; a frost saved on a Mac
highlights the button that renders the same backdrop rather than leaving
the picker blank, and is not rewritten.
Linux loses the row entirely, from the page and from settings search.
setOpacity is a documented no-op there and there is no material, so both
halves were dead — a lever that moved a number and changed nothing.
* refactor(desktop): give duplicated helpers one owner
Splitting the god files made a pile of copy-paste helpers visible and,
for the first time, fixable — sharing them previously meant importing a
god file. Hashing function bodies through the TypeScript AST found
twelve groups desktop-wide; production code is now at zero duplicates.
Each helper went to the module that already owns its concern:
firstStringField to lib/text, the two REST 404 predicates to
lib/gateway-rpc beside isMissingRpcMethod, useDebounced and
prefersReducedMotion to their hooks, the superseded-bootstrap guard to
electron/ssh-connection, the composer keyup handler to the trigger hook
that owns the rest of that state machine, and clampDataUrlReadMaxMb to
apps/shared, replacing a "keep these in sync" comment between two
copies.
Only helpers with no existing owner got a new file: lib/mcp-servers,
lib/audio-context, lib/keyed-timeouts, lib/pointer-drag, and the command
palette's status row. Error-shape predicates are the worst thing to
copy — when the backend changes how it reports a missing route, every
copy has to be found.
* fix(desktop): keep the preload bridge alive under the sandbox
Deciding whether the OS can back glass needs os.release(), but every
Hermes window runs its preload with sandbox: true, where require is a
polyfill limited to electron, events, timers and url. The node:os import
threw before contextBridge ran, so window.hermesDesktop was never defined
and the app booted straight into "Desktop IPC bridge is unavailable".
Main already computes both verdicts, so preload asks for them over a
synchronous channel instead. No reply degrades to no glass, which is an
ordinary opaque window rather than a page thinned over nothing.
* docs(desktop): repoint comments at the modules that now own the code
Comments naming gateway-event.ts, chat-messages.ts and hermes.ts as the
place to look, for files those symbols no longer live in.
* fix(desktop): sort translucency named exports for eslint
Perfectionist wants values before types in the glass/Windows barrel.
* test(desktop): share the duplicated fixtures
The same AST sweep over specs found fixtures maintained in parallel
across suites that have no reason to know about each other.
Twenty-one specs each mounted useMessageStream themselves and ten of the
harnesses were byte-identical; twenty now take renderMessageStream, with
overrides for the seams that genuinely vary. The SessionInfo builder was
spelled out field-by-field in seven specs, so a new backend field broke
seven files instead of one. Twenty specs carried their own inert
ResizeObserver and eleven repeated the animation-frame, CSS.escape,
scrollTo and WAAPI stubs the transcript needs to mount at all — split by
scope into src/test/jsdom for what any component might need and the
assistant-ui folder's own kit for the transcript. Plus the window-state
bridge, deferred, the external-store thread runtime, the manual
createRoot harness, and the per-folder caret, env-var, provider and
session fixtures.
Left alone on purpose: the store suites' makePrimary, where the vi.mock
harness around it is the actual duplication and cannot be hoisted out of
a hoisted factory; electron's deferred, where reaching into src/ from
the main process would invert the layering for eight lines; and the two
suites that compose another hook alongside the stream.
* chore: kick CI
* chore: kick CI
* fix(desktop): isolate composer submit to one visible surface
Review "Ask Hermes to open PR" was a window-level event that every mounted
composer claimed with `target === 'main'`, so one click shipped every open
session and project with dirty files. Bind the request to the visible
surface captured at click time.
Co-authored-by: unsupportedpastels <theoldwizard123@pm.me>
Co-authored-by: youtiaowei <youtiaowei@users.noreply.github.com>
* fix(desktop): send review agent-ship to the composer that opened it
The ship button always targeted `main`, so a tile Review still prompted
the workspace session. Remember the originating composer target with the
pane's cwd, capture the live surface at click, and toast if that chat
isn't on screen instead of dropping the click.
Co-authored-by: unsupportedpastels <theoldwizard123@pm.me>
Co-authored-by: youtiaowei <youtiaowei@users.noreply.github.com>
* fix(tui): stop the composer placeholder from sticking Terminal.app into dim
The placeholder hint and its synthetic cursor chip hand-rolled truecolor
escapes ([38;2;r;g;b / [48;2;r;g;b]) and wrote them raw past Ink's depth
layer. Legacy Terminal.app has no truecolor parser — it walks compound
params one by one, so the literal 2 in 38;2;… lands as SGR 2: dim ON,
with no 22m ever emitted. Every frame that painted the placeholder left
the terminal's dim attribute stuck, and subsequent cells rendered dimmed
until an unrelated bold span's 22m happened to clear it — text randomly
flipping dim and back, worst right after the composer empties.
Measured on a live resumed session (PTY capture, params interpreted the
legacy way): 1026 glyphs painted with stuck dim on main, 0 with the fix.
Route both helpers through Ink's own colorize, the same repair colorizeEcho
got for the fast-echo path (gray-accent bug) — the escape now downgrades
with the terminal's real color depth, and a 256-color terminal gets 38;5;N
it can actually parse.
Also harden hermes-ink's transitionAnsiCodes for compound SGRs: real tool
output ships [1;31m-style sequences whose endCode is [0m, dodging the
endCode-based weight detection — parse the params instead (skipping 38/48
extended-color arguments) so a compound bold→dim transition passes through
SGR 22 too.
* fix(tui): allow the ESC byte in the SGR param matcher
eslint no-control-regex rejects the CSI regex even though ESC is the
sequence we have to parse.
* fix(image_gen): Grok Imagine 2.0 no longer upscales by default — opt-in policy restored
ceabb030f added the Grok Imagine Image 2.0 catalog entry with upscale=True,
violating the Aug 2026 opt-in-only upscaling policy (f06c41522) and breaking
test_upscale_defaults_are_all_off on main, which reddened every PR's slice
12/12.
* fix(tour): an unanswered tour bridge no longer costs 45s per action
The renderer's `tour.request` handler ships in the desktop bundle, but the
tool is offered by the backend, and the two update on different clocks. A
desktop build older than the tour tool receives the event in a renderer with
no branch for it, so `tour.respond` never comes and the agent blocks for the
full 45s deadline — once per action the model tries. A single "give me a
tour" turn (targets, then narrate, then stop) stacked those waits into
minutes of dead air, which is what got reported against #89620.
Hold a session's first action to a deadline a working renderer cannot miss,
and let an unanswered probe mark the bridge unavailable for that session:
later calls return immediately with an error naming the actual fix instead
of stalling again. Once a client has answered, real actions get the full
deadline back, so a preview tour injecting into a live page still works and
one slow action no longer condemns a live client. The verdict lives on the
session record, so it dies with the session and a new one re-probes.
The same five-action sequence goes from ~225s of dead air to a single 10s
probe. Toolset gating is unchanged: removing the tool outright needs a
client capability declared at session.create, which prompt caching means
can only take effect for a new session.
* fmt(js): `npm run fix` on merge (#90140)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* test: drop the upscale-defaults-off catalog invariant
Grok Imagine Image 2.0 (ceabb030fb) intentionally ships upscale: True —
its 1k native output is sub-2MP. Per-entry defaults are a catalog
decision now; the blanket all-off invariant no longer reflects policy.
Per-call upscale=true/false override is unchanged.
* feat(skills): add --yes/-y flag to hermes skills uninstall
`do_uninstall` already accepted a `skip_confirm` parameter and the
slash-command handler already passed `skip_confirm=True`, but the CLI
argparse path never exposed a flag to reach it. This adds `--yes`/`-y`
to `hermes skills uninstall`, matching the existing pattern on `install`
and `reset`.
* test(skills): add CLI regression tests for skills uninstall --yes/-y flag
Covers the new --yes/-y flag on , asserting the
parsed value reaches do_uninstall(skip_confirm=True) via the real
main() -> cmd_skills -> skills_command dispatch path. Mirrors the
install-flag test pattern in test_skills_install_flags.py.
* chore: map contributor email for noahingh
* fix(docs): index every docs page in llms.txt, not a hand-picked 98
The section list decided membership as well as order, so it drifted as the
docs grew: 109 of 204 pages were absent from the index every LLM reads to
learn what Hermes does — Bot Mode, the desktop app, computer use, web search,
skins, Mixture of Agents, and 22 messaging platforms among them.
Enumerate the docs tree instead. SECTIONS now curates only which pages lead a
section; anything it does not name is absorbed under its path, and a page
matching no section lands in "More" rather than falling out. This also picks
up the three .mdx pages the .md-only glob never saw, points section landing
pages at the directory URL Docusaurus actually serves, and drops a curated row
still aimed at a guide moved to developer-guide/plugins in #59613.
Tests hold both directions against the filesystem rather than the enumerator,
so a page cannot go missing and a link cannot point at a page that moved.
* feat(skill): route hermes-agent's unknown-feature questions to llms.txt
The routing table listed 18 topics and had nothing to say about the rest of
the product, so an agent asked how to get bots to talk to each other answered
that it could not — while user-guide/bot-mode documented four ways to do it.
Point the catch-all at the published index, which is generated from the docs
tree on every build and so cannot fall behind the feature set. website/ is
never packaged, so the URL is the only complete self-knowledge a running
Hermes has; curl covers sessions where the web tools are disabled.
* ci: run the Python lane for docs and website script changes
llms.txt coverage is asserted in Python, but website/ sat on the Python skip
list, so a PR adding a docs page — or regressing the generator — went green
without ever running the test that checks the page is reachable. That is how
the index drifted to 53% coverage unnoticed.
* fix(update): show live Desktop update progress
* fix(update): resolve the project venv as venv or .venv
`uv venv` writes `.venv` while our installers write `venv`, and every venv
lookup in the update/repair paths hardcoded `venv`. On a `.venv` install
`_venv_scripts_dir()` returned None, so the Windows shim-lock preflight, the
quarantine, and the console-script verification all silently skipped
themselves — the update walked straight into the failure they exist to catch.
Adds `hermes_constants.project_venv_dir()` as the single resolver and routes
both `_venv_scripts_dir()` implementations plus the two VIRTUAL_ENV call
sites through it.
Refs #79542
* fix(update): re-run Windows updates off the console shim
`hermes update` launched as venv\Scripts\hermes.exe can never finish on
Windows. The launcher runs the interpreter with the shim as its script and
holds it open without FILE_SHARE_DELETE for the whole command, so the
quarantine rename is refused and uv fails to replace hermes.exe with
os error 32 — every time, with no Desktop, gateway or AV involved. The
concurrent-instance preflight cannot catch it because it excludes this
process and its ancestors by design.
Detect the shim from both the process ancestry and this process's own launch
paths (argv[0], __main__.__file__, the spec origin — the runpy/zipapp launch
puts <shim>\__main__.py there), intersected with the project venv's shims so
an unrelated hermes.exe never matches. When it matches, re-run the same
argv as `venv\Scripts\python.exe -m hermes_cli.main ...` and return, which
releases the shim before the child installs anything.
The hand-off sits ahead of the update lock so the child claims the marker
itself rather than adopting one the parent immediately releases, and any
failure falls through to the previous in-process behaviour with the manual
command printed.
Refs #88838, #89599, #86093
* fix(update): stop deferring shim renames to next boot
MOVEFILE_DELAY_UNTIL_REBOOT was the quarantine's last resort, and it is worse
than doing nothing. It writes to HKLM, so a non-elevated update — every
Desktop-driven one, and most terminal ones — gets ERROR_ACCESS_DENIED and
reports nothing. When it does succeed it frees nothing for the install
running right now, and the queued operation outlives that update: at the next
boot it moves aside whatever sits at the shim path, including a shim a later
repair just wrote.
Drops the fallback and sweeps entries older versions queued, matching only
our own <shim> -> <shim>.old.<stamp> pairs so unrelated installers keep
theirs.
Salvaged from #88121 by @fangliquanflq.
* fix(gateway): run Windows /update as a module, not through the shim
The Windows branch spawned the updater as `hermes.exe update --gateway`, so
the update held the very shim it had to replace and failed with os error 32.
Invoke it as `python -m hermes_cli.main update --gateway` under the same
interpreter the gateway already runs, which maps no shim.
Salvaged from #89970 by @Akloenx123.
* fix(update): one elapsed clock, served to the shim by both orchestrators
The shim is a shared page, but only windows.ps1 publishes a stage and an
elapsed count. On mac and Linux posix.sh publishes `running` with an empty
message and no clock, so the running branch rendered the h2 back into the
muted line ("Updating Hermes" twice) and started a clock in the browser,
losing "Hermes will open once done." on both platforms.
A clock started in the page measures when the window painted, not how long
the update has been running -- on posix that is the only clock there is, and
it reads zero after the desktop-exit wait has already burned 30s. That is the
hardcoded-milestone problem #75895 removed, in a new costume.
So: elapsed comes from the orchestrator or is not shown. serve-ui.py stamps
it per request from the hand-off start (a value written into the status file
would freeze between publishes, which are minutes apart -- exactly the stall
the line exists to disprove), matching what Windows' in-process listener
already does. posix.sh gets the stages it was missing, at the four gates it
genuinely waits on. Absent a stage the page keeps the settled copy, and an
old orchestrator that sends no clock simply shows no clock.
* test(update): cover the progress contract without a test hook in the updater
The self-test grew a branch that spawns a Python child so pytest could prove
progress advances during one. It doesn't need to: /progress is answered from
its own runspace, so the existing hold already blocks the main thread, and
the spawn only exercised Invoke-HermesStep, which nothing here changes.
The Windows test now asserts the invariant instead of the self-test's stage
string, and the posix half -- previously untested, and the half that broke --
gets real coverage: serve-ui.py's wire shape, and posix.sh driven end to end
with a stub `hermes` that reports which stage was on screen while it ran.
* test(update): cover the Windows shim self-lock class
Detection across every launch variant (argv[0], the zipapp __main__.py, the
main-module spec origin, the ancestor chain) plus the venv scoping that keeps
an unrelated hermes.exe from triggering a hand-off; the re-exec's argv, env
marker, loop guard and both fall-through paths; the pending-rename filter;
and the venv/.venv layout split.
Retires the reboot-deferred quarantine assertion along with the fallback.
Launch-variant cases from #89970 by @Akloenx123, pending-rename cases from
#88121 by @fangliquanflq.
* feat(config): per-provider reasoning_echo opt-in for custom providers
Add model.reasoning_echo (default false) and per-fallback-entry
reasoning_echo to preserve assistant reasoning_content when
replaying history to custom providers and OpenAI-compatible gateways
that proxy thinking-mode models (Kimi K3, GLM-5.2, DeepSeek, etc.)
but are not matched by the built-in host-based _REASONING_ECHO_RULES.
The flag is per-active-provider, not a global toggle:
- Primary: read from model.reasoning_echo at init and switch_model
- Fallback: set by try_activate_fallback from the fallback entry
- Restore: restore_primary_runtime copies the switch_model snapshot
Unlike PR #76019 global agent.reasoning_echo toggle, the
per-provider flag travels with the active provider — falling back to
a strict provider (Mistral, Groq, Cerebras) correctly strips
reasoning_content even when the primary had the flag enabled,
because the flag is False for the strict fallback.
Complements PR #27361 (dynamic detection) which fires after the first
API response; this PR covers turn-1 and history-replay-on-fresh-session
where dynamic detection has not fired yet.
Closes #76018
Refs: #27297, #27361, #76019
Signed-off-by: Yingliang Zhang <zhangyingliang@outlook.com>
* fix: add reasoning_echo_flag to init snapshot and switch rollback
Address review feedback on PR #76503:
1. Init-time primary snapshot (agent_init.py:2756) was missing
reasoning_echo_flag — after fallback recovery the flag was
restored as False even when model.reasoning_echo: true was set.
2. Switch transaction snapshot (agent_runtime_helpers.py:2284) was
missing _reasoning_echo_flag — a failed client rebuild during
switch_model would leave the old provider with the new provider
echo policy.
Both omissions now fixed. No test regressions (56 passed).
Signed-off-by: Yingliang Zhang <zhangyingliang@outlook.com>
* test: production-shape resolver + init-read coverage for reasoning_echo
Salvaged from #73811 per the consolidation triage on #76503, adapted to this
PR's per-active-provider model.reasoning_echo design.
Existing reasoning_echo tests hand-set _reasoning_echo_flag; none drives the
real config path init_agent uses:
load_config_readonly().get("model").get("reasoning_echo")
which is wrapped in `except Exception: False`, so a broken read would silently
disable the feature untested. This adds a temp-HERMES_HOME test that resolves a
named custom provider via the real resolve_runtime_provider (asserting
provider == "custom"), materializes the flag from a real config file, and
checks reasoning_content is preserved with the flag on and stripped with it off.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ab7R4NizLNbNyQZShtciKg
* chore: map contributor email for yingliang-zhang
* chore: map contributor emails
* refactor: extract duplicated load_config_readonly try/except into helper
The identical 6-line try/except block for reading model.reasoning_echo
from config appeared in both agent_init.py (init) and
agent_runtime_helpers.py (switch_model). Extracted into
AIAgent._read_reasoning_echo_from_config() static method — net -1 LOC.
* fix(update): run the Windows update hand-off unattended
The re-exec'd child inherits the console, so sys.stdin.isatty() still reported
a terminal and the update asked its local-changes question. By then the parent
shim had exited and the shell had taken the console back, so the prompt could
not be answered and the update sat there forever — worse than the lock it
replaced, because nothing recovers without closing the window.
Spawn the child with stdin closed. It then takes the same path the gateway and
Desktop updates take: honour updates.non_interactive_local_changes, which
stashes by default so nothing is lost, and keep going without asking.
* test(agent): give the MoA switch fake a reasoning_echo reader
663fa68cd4 added an unguarded `agent._read_reasoning_echo_from_config()` call
to switch_model's core field swap. The fake agent here carries only the
attributes switch_model touches, so the new call raises AttributeError inside
the rollback-protected block — every field the test asserts on gets restored
to its pre-swap value and all four cases fail on main with
`assert 'opencode-go' == 'moa'`.
Teach the fake the reader, matching the production AIAgent shape.
* feat(desktop): add an Appearance toggle that disables the intro splash
The wordmark and tagline on an empty chat had no off switch. Add an
Appearance row, Intro Splash, that hides it. The setting is on by
default, so the current experience does not change.
The splash is renderer chrome and no other Hermes surface can change
it, so the state stays local (localStorage) like the Chat Backdrop
toggle beside it. It does not mirror into gateway config.
Move the visibility condition out of the chat god-component into
shouldShowIntro(), next to the isRouteSessionMismatch() helper. The
tests prove that the toggle outranks every window and session clause:
off is off.
* fix(approval): route check_execute_code_guard through the CLI fall-through too
e37a0321eb fixed _run_approval_gate and check_all_command_guards: when
HERMES_EXEC_ASK (or a session platform marker) leaks into an interactive CLI
process with no gateway notify callback registered, those two functions now
prefer the registered CLI Dangerous Command panel over a silent
pending_approval nobody can see.
check_execute_code_guard — the whole-script gate for execute_code, a
separate function with its own copy of the same notify_cb-less
short-circuit — never got the same treatment. It doesn't even accept an
approval_callback parameter. In the same leaked-ask-mode-into-CLI scenario,
execute_code calls still silently drop into pending_approval with the panel
never shown, even though a CLI callback is registered.
Compute is_cli/approval_callback the same way the two fixed functions do,
and when _should_fall_through_to_cli_approval() says yes, run the same
hook-fire -> prompt_dangerous_approval -> hook-fire -> choice-branch
sequence _run_approval_gate's tail already uses, adapted to this function's
own message/persistence conventions (smart-denied session/permanent
suppression, denial-breaker addendum). Falls back to the existing
pending_approval behavior when no CLI callback is available.
Tests: 4 new cases in tests/tools/test_cli_approval_exec_ask_leak.py
mirroring the existing check_all_command_guards pair (approve/deny/timeout/
session-persistence). Mutation-verified: all 4 fail against the pre-fix code
and pass with it restored.
Neighbor suites: tests/tools/*approval* (291+ tests) and
tests/gateway/{test_approval_prompt_redaction,test_tui_approval_redaction,
test_plaintext_approval_routing,test_discord_exec_approval_content} +
tests/cli/test_cli_approval_ui.py all green. The 7 test_approval_mode_parity
/ test_nonrecursive_verification_artifact_cleanup failures seen in one full
batch run are pre-existing and independent of this change — confirmed by
re-running the identical batch with tools/approval.py stashed back to
pre-fix: the same 7 fail for the same reasons either way.
Note on an adjacent open PR: #65592 also touches check_execute_code_guard,
but an earlier, unrelated region of the function (adding an AST dangerous-
operation scanner to the "not is_gateway and not is_ask" auto-approve
branch). No semantic overlap with this fix's notify_cb-less branch; a small
rebase may be needed depending on merge order.
* fix(approval): align the execute_code CLI fall-through with its sibling guards
Follow-up to the salvaged fix. Three parity gaps in the new CLI branch:
- Timeout arm dropped the denial-breaker addendum that the same function's
gateway arm and check_all_command_guards' CLI tail both append, so a
tripped breaker went unreported on a timeout.
- Human deny called _record_denial(), advancing a tally scoped to guardian
LLM DENY verdicts. Neither sibling CLI tail does this, so three
deliberate user denials escalated to breaker hard-stop text.
- The platform-marker half of the leak (HERMES_SESSION_PLATFORM set, no
HERMES_EXEC_ASK) was unpinned; it reaches the same branch.
Adds a platform-marker regression test plus two breaker-parity guards, and
clears the process-global _denial_tally in the shared fixture so a leaked
tally can't bleed the escalated addendum into unrelated assertions.
* feat(tools): close_preview so the agent can dismiss the pane it opened
open_preview and read_preview could drive the page, but nothing could close
the pane. Same desktop_ui / session-source gate as the rest of the GUI tools.
* feat(desktop): close preview tabs on preview.close
Omit url to drop the whole rail; a url/path/label match closes that tab.
Background sessions still cannot yank the user's preview shut.
* fmt(js): `npm run fix` on merge (#90248)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* fix(dashboard): coalesce repeat gateway restarts for a short window
`_spawn_gateway_restart` already reuses an in-flight `hermes gateway
restart` child so a double-clicked button cannot start two racing
restarts. That guard evaporates exactly when it is needed most: the
child exits as soon as it has handed the restart to the supervisor (or
to the running gateway), long before the gateway is actually back, so a
stale cached dashboard frontend re-firing its own restart every few
seconds cleared the guard on every attempt and started a fresh restart
each time.
#89034 measured the result on an s6-supervised container: 77
`gateway-restart started` entries, 17 of them inside one minute. Each
one SIGHUPs a gateway that is still coming up, and killing it
mid-FTS5-write corrupted `state.db` ("database disk image is
malformed", 203x in agent.log) until the operator recreated the file by
hand.
Requests for the same profile within GATEWAY_RESTART_COOLDOWN_SECONDS of
the last spawn are now coalesced onto that spawn and logged, so a storm
produces one restart instead of one per request. The window is fixed
rather than health-gated on purpose: a gateway that never comes back
would leave a health-gated restart action permanently inert, which is a
worse failure than the flood it prevents. The cooldown state is kept
outside `_ACTION_PROCS` because completed action children are reaped out
of that table, and a guard that disappears when the child exits is the
bug being fixed.
Only the *frontend-flood* half of #89034 is addressed here. The s6
`finish` death-cap the report also asks for is a separate change to
`hermes_cli/service_manager.py` with a much larger blast radius, and is
left for a maintainer decision.
* feat(plugins): add OpenRouter Image API surface to openrouter image_gen backend
* feat(image-gen): route live-catalog models to the Image API; merge picker catalogs; docs
Follow-ups on top of the salvaged #82631 surface:
- _select_surface: an unknown model id found in the live /images/models
catalog now ROUTES to the dedicated Image API instead of only logging a
hint — without this, a model picked from the live picker that postdates
the curated snapshot would fall onto chat-completions and fail. Curated
defaults stay pinned to chat (no behaviour change for existing setups);
offline probes still fall back to chat. _HINTED_MODELS removed.
- list_models (OpenRouter): union of the live GET /images/models catalog
(43 models today) and the chat-completions image models, deduped,
defaults first; curated metadata wins for known ids, API names for the
rest. Nous Portal (no /images route) keeps its chat-only catalog.
Offline fallback: static chain + curated Image API snapshot.
- Tests updated/added: unknown-id routing (flipped from the hint-only
pinning test), non-catalog id stays on chat, merged-picker union/dedupe/
order, Nous exclusion.
- Docs: image-generation.md gains the OpenRouter Image API section and an
editing-support row.
Live-verified: picker lists 43 models; generation succeeded through the
dedicated API on google/gemini-3.1-flash-lite-image and on the previously
unreachable black-forest-labs/flux.2-klein-4b (config-selected, no kwarg).
* chore: map contributor email for Haik-G
* fix(codex): strip unsupported cache retention at wire
* chore: map contributor email for #89969 salvage
f4lko@pm.me -> thacid22. The email is linked to the thacid22 GitHub
account on the PR's commit, so check-attribution can resolve it once
the mapping file exists on the branch.
* test(codex): pin the retention drop to real endpoints
The salvaged compatibility test stubs `_is_codex_backend=lambda: False` on a
SimpleNamespace, so it proves the helper honors its own boolean but not that
the boolean is right for any real endpoint. A predicate change that widened
the drop onto retention-supporting hosts would keep it green.
Adds a parametrized test that builds a real AIAgent per base URL and asserts
the drop only fires for chatgpt.com/backend-api/codex, while api.meta.ai,
bedrock-mantle.*.api.aws, api.openai.com and a same-host/different-path
backend keep their supported 24h value. Also asserts prompt_cache_key
survives untouched on every endpoint, since retention and cache-key routing
are independent and the guard must not disturb caching.
Verified non-vacuous: relaxing the guard's condition to drop on every
endpoint fails 4 of the 6 cases (Meta, Bedrock, OpenAI, non-codex path).
Drive-by on the guard itself: drop the dead `None` default on the `pop` that
is already gated by an `in` check, and record why the predicate is resolved
via getattr -- run_codex_stream is driven with lightweight stand-in agents
that lack `_is_codex_backend`, so a bare call would raise AttributeError.
* fix(codex): strip nested extra_body retention at the consumer Codex wire
The wire guard only removed the top-level prompt_cache_retention kwarg, but
the OpenAI SDK merges extra_body into the outgoing JSON body, so a nested
extra_body.prompt_cache_retention reaches chatgpt.com/backend-api/codex just
the same and still triggers the non-retryable HTTP 400. Both injection
vectors are real and probe-verified: the Relay overlay's 'key not in
baseline' arm admits an interceptor-added extra_body, and
request_overrides={'extra_body': {...}} lands verbatim in build_kwargs
output.
Close the gap in the same helper: strip the nested field too (copy-on-write,
never mutating the caller's mapping), drop extra_body entirely when it
empties, and log the same warning. Compatible endpoints keep nested
retention untouched.
Mutation-verified: removing the extra_body leg fails both new nested tests.
Reported by egilewski's review on #89969.
* refactor(codex): name the dropped shape in the wire-guard warning
Review polish from the 3-angle pass on the final stack:
- The warning now says WHICH shape leaked (top-level, extra_body, or both).
Relay injects top-level while request_overrides typically inject via
extra_body, so the shape identifies the offending middleware when
debugging.
- Fold the 'always returns a fresh mapping' assertion into the parametrized
real-endpoint test (the caller mutates the result with stream=True, so the
copy contract is load-bearing on no-drop paths too) and drop the
SimpleNamespace stub test it strictly subsumes. The nested-preserve stub
stays: the parametrized test only exercises top-level retention.
* test(codex): cover nested retention entry paths
* fix(bot-mode): unwrap connections registry object so 'Create on' picker renders
host.connections() resolves the IPC handler hermes:connections:list, which
returns the registry OBJECT ({version, primary, connections: [...]}) — not a
bare array. CreateAgentDialog did setConnections(Array.isArray(value) ? value
: []), so on a multi-connection desktop the picker gate
(Array.isArray(connections) && connections.length > 1) never fired and the
'Create on' picker stayed hidden, making cross-machine bot creation
impossible despite the multi-connection feature being documented.
Any new agent is still created on the active gateway (unchanged behaviour);
the picker is the only path that regressed. The built-in Connections UI
(refreshConnectionsRegistry) consumes the same registry object, so the IPC
handler contract is left untouched.
Adds a regression test asserting the unwrap and that the IPC contract is
preserved. Plugin suite: 309/309 pass.
* fix(bot-mode): roster age, pulse, unread, and sort now see canonical Bot Chat activity
The canonical Bot Chat is hidden from session lists by design, so
profiles.list's last_session never advances when you message a bot there.
PR #88690 moved the roster PREVIEW to preferred_session but left every
activity signal on last_session — a bot you just messaged showed '6d ago',
never pulsed, never badged, and sorted below stale bots.
New botActivitySession(bot) helper returns the fresher of preferred_session
(the pinned Bot Chat, resolved precisely by the backend) and last_session
(newest visible conversation). All four activity sites key off it now:
- row age label (relativeTime)
- active-now pulse dot + activeBots strip
- unread watermark + activity toast preview
- roster recency sort (activityOf)
Older gateways without the preferred_session resolver degrade to
last_session exactly as before. Backend untouched.
Tests: extracted the real helper into the vm harnesses (no stub drift),
5 new behavior tests; sabotage-verified they fail against the old code.
* fix(agent): cap the ultra reasoning level at the wire vocabulary
Hermes' internal effort vocabulary extends the wire set with ultra
(documented by /reasoning as none..xhigh|max|ultra). OpenAI-compatible
wires — OpenRouter chief among them — accept exactly
max|xhigh|high|medium|low|minimal|none and reject the extension with
HTTP 400, so an ultra configured while the default model was Anthropic
worked (the Anthropic adapter maps its own levels) but leaked
untranslated the moment a per-job override pinned a non-Anthropic
model, failing every call for that job.
The wire-compat chokepoint for this transport previously mapped
ultra to max only for gpt-5.6; generalize the cap to every model.
* fix: widen reasoning-effort wire translation to sibling sites (#89503 class)
The chat_completions chokepoint fix (ultra->max for every model,
cherry-picked from #89509) has siblings with the same bug shape:
- codex.py: ultra->max was gated on gpt-5.6 only; now baseline for all
Responses-API models (backend-specific branches still override).
- Kimi top-level reasoning_effort: K3 accepts low/high/max only —
'medium' and upper-ladder levels were dropped to the medium default
(400s on K3, ladder inversion on K2). Full ladder mapped per family,
mirroring the kimi-coding plugin's K3 map.
- TokenHub: 'minimal' fell through to the 'high' default (asked least,
got most); full ladder now mapped onto low/medium/high.
- auxiliary_client Responses path: ultra->max alongside the existing
minimal->low clamp.
- custom provider plugin: ultra capped at max instead of forwarded
verbatim to GLM/vLLM/SGLang backends that reject it.
- copilot plugin: ad-hoc downgrade rules replaced with the shared
clamp_reasoning_effort_to_supported ladder walk so ultra/max resolve
to the strongest supported level instead of medium (#74295).
Sabotage-verified: new sibling-site tests fail 6/10 without the fixes.
* fix(tools): dispatch image/video FAL strictly on the stored hermes tools selection
Add read_selection()/selection_exists()/selection_error() to
tool_backend_helpers: one provider string per category ('nous' = managed
Nous Tool Gateway, vendor name = direct with the user's own credentials,
no key ever written = legacy credential autodetect). Legacy configs are
interpreted at read time only (use_gateway: true => nous); nothing is
migrated on disk, and the DEFAULT_CONFIG-seeded stt.provider: local is
treated as never-configured.
_resolve_managed_fal_gateway / _resolve_managed_fal_video_gateway now
switch on that string: 'nous' routes managed only (unentitled => error
naming the selection), a stored vendor routes direct only (missing
FAL_KEY => error naming FAL_KEY and the selection, no silent managed
reroute), and FAL_KEY presence no longer selects the route. Krea's
model-driven managed interception now requires no stored provider (or
the managed selection) instead of merely provider != krea, and the
image/video registries map the 'nous' selection to the FAL plugin.
* fix(web): honor the stored web backend selection; no silent backend swaps
_get_backend returns the stored web.backend verbatim (mapping the managed
'nous' selection to the firecrawl provider) — unknown names surface the
honest selection-naming error at dispatch instead of silently rerouting
through the credential ladder, which now runs only on never-configured
installs. _get_capability_backend no longer discards an explicit
search/extract backend when its availability probe fails. The firecrawl
client resolves strictly: 'nous' => managed gateway only (unavailable =>
selection-naming error), stored vendor => direct only (no FIRECRAWL key
=> error, never a silent managed fallback billed to Nous).
* fix(voice): route TTS/STT OpenAI audio on the stored selection, not credentials
Both _resolve_openai_audio_client_config resolvers now switch on the
stored provider string: 'nous' (or legacy use_gateway: true) => managed
openai-audio gateway only, erroring by selection name when unentitled —
the STT twin previously never read the stored gateway intent at all, so
a direct OPENAI_API_KEY silently overrode the Nous Subscription pick;
stored vendor => direct credentials only with a selection-naming error
on missing keys (no silent managed fallback); never-configured keeps the
legacy ladder. DEFAULT_CONFIG stops seeding stt.provider: local, and the
seeded value on existing configs is treated as no-selection so autodetect
keeps working for that installed base.
* fix(browser): strict cloud-provider selection; camofox becomes a selection
An explicitly stored browser.cloud_provider that names no registered
plugin now raises the honest selection-naming error instead of warning
and silently auto-detecting; the auto-detect walk (including the managed
gateway entitlement probe) runs only when no cloud_provider key was ever
written. The 'nous' selection routes to the Browser Use provider, whose
config resolver is now a strict switch: 'nous' => managed only, stored
vendor => direct BROWSER_USE_API_KEY only with a selection-naming error
when missing. Camofox is selected via browser.cloud_provider: camofox;
CAMOFOX_URL stays the server ADDRESS only and can no longer override an
explicit different selection (never-configured installs keep the legacy
env-var activation).
* fix(cli): persist one provider string per picker row; mirror strict routing in status
Every hermes tools row now writes exactly one selection value per
category — managed 'Nous Subscription' rows write 'nous', BYOK rows the
vendor name (including the historically-unset BYOK-FAL image row) — and
use_gateway is no longer written; fresh picks drop any legacy key so the
read-time shim cannot override them. The non-managed clear now resolves
the category from the row's own markers, covering plugin-injected rows
the TOOL_CATEGORIES loop missed. Setup-flow writers (managed defaults,
gateway enablement) store 'nous', and the feature-state mirrors in
nous_subscription.py compute per-category selections with the same
legacy interpretation so hermes status matches runtime: a stored vendor
selection pins direct (managed availability no longer lights it up) and
an explicit non-camofox selection beats a stray CAMOFOX_URL.
* test(tools): pin strict provider-string selection per category
New tests/tools/test_strict_provider_selection.py covers read_selection
semantics (legacy use_gateway interpretation, seeded stt local, empty
strings, browser.backend vs cloud_provider) and the three strict
behaviors per category: managed 'nous' selection wins over present
direct keys, a vendor selection with missing credentials raises the
selection-naming error with NO managed call, and never-configured
installs keep today's autodetect. Updated the tests that pinned the old
credential-first precedence (TTS resolver gateway override, STT silent
managed fallback, web invalid-backend reroute, video_gen picker writes).
Sabotage-verified: reverting the image FAL strict switch makes the new
managed-selection tests fail.
* fix(tools): honor raw stt.provider: local; finish _reconfigure_provider provider-string migration
Two real gaps the CI-red sibling tests exposed:
- read_selection() treated EVERY raw stt.provider: local as the legacy
DEFAULT_CONFIG seed and reported no-selection — but the seed never
reached config.yaml (save_config strips schema defaults), so a
picker- or hand-written local pick was silently discarded and the
autodetect ladder could route an explicit local user to cloud STT.
A raw 'local' is now a genuine selection; the merged-view ambiguity
note replaces the over-broad shim (mirror comment updated in
nous_subscription._selected_provider and _get_provider).
- _reconfigure_provider was half-migrated: the tts/stt/browser/web
branches and the managed-category fallthrough still wrote
use_gateway flags and vendor names for managed rows. They now write
the single provider string ('nous' for managed rows) and pop the
legacy key, matching _write_provider_config.
* test(tools): repin selector/picker tests to the provider-string contract
Update the sibling tests that pinned the old use_gateway-writing
contract: image/video selector and reconfigure rows now assert the
single provider string ('nous' managed / 'fal' BYOK) plus legacy-key
popping, the stt/video picker writes drop the use_gateway expectation,
the web_server managed-browser select asserts the persisted 'nous'
cloud_provider, and explicit-local STT pins no-cloud-fallback against
a stored raw-config selection.
* fix(sdk): return registered connection list
* test(sdk): cover connection registry list contract
* test(sdk): cover registry primary connection mapping
* fix(sdk): preserve primary in registered connections
* chore(sdk): remove unrelated session helper from #89893
* fix(bot-mode): accept both host.connections() shapes in the Create-on picker normalize
The SDK now returns the registry rows per its documented contract
(salvaged #89893), while desktops predating the SDK unwrap resolve the
raw registry envelope. The plugin normalize accepts both, so the picker
works across the transition; regression test updated to pin the
dual-shape normalize.
* feat: execution-discipline guidance now reaches all tool-capable models (config model.execution_guidance)
Un-fences OPENAI_MODEL_EXECUTION_GUIDANCE from the gpt/codex/grok substring
check and gives it its own injection gate, independent of
tool_use_enforcement, controlled by config.yaml `agent.execution_guidance`
(auto/true/false/list — same semantics as tool_use_enforcement). The "auto"
list (EXECUTION_GUIDANCE_MODELS) now also covers deepseek, kimi, qwen, glm,
minimax, mimo, and mistral.
Composio agentic-eval traces showed Hermes+DeepSeek/Kimi failing where
competitors passed: financial math done in prose, no read-back after
external writes, malformed identifiers "repaired", completeness claimed
despite count mismatches. The discipline block existed but those models
never received it.
The block is extended with compact clauses distilled from that analysis:
- external-write read-back (tool-call success is not task success; internal
file edits already confirmed by the tool are not re-verified)
- count reconciliation (declared totals/has_more are hard assertions)
- literal preservation (never normalize identifiers that fail a stated
format; lookup success does not validate a malformed token)
- retry-differently (empty/partial/suspiciously narrow results get a
broader retry before concluding)
- completion gated on verification (done = every named acceptance
criterion verified, never a plausible subset)
The todo tool description now encourages enumeration-as-checklist for
"all N items" tasks and gates completed status on verified work, never
intent.
Guidance is chosen once at session start keyed on model name, so the
system prompt stays byte-stable for the life of a conversation.
Supersedes/absorbs prior contributor proposals: #20588, #35087, #41874
(MiMo), #53847 (GLM tool-calls-as-text stall).
Co-authored-by: Mat-London <56627804+Mat-London@users.noreply.github.com>
Co-authored-by: intelac <8803887+intelac@users.noreply.github.com>
Co-authored-by: 6ylqq <51219463+6ylqq@users.noreply.github.com>
Co-authored-by: tauros1983 <267660491+tauros1983@users.noreply.github.com>
* feat: MCP tool results spill at 50K and carry upstream-elision warnings
Composio-style MCP servers return un-paginated 22-47K-char payloads that
sail under the generic 100K per-result spillover threshold, bloating
context and ballooning per-turn reasoning time on long conversations.
Competitors cap harder (OpenCode/pi 50KB, Claude Code 30K, Codex ~10K
tokens). Three changes:
- mcp_* tools spill at a tighter 50K default (BudgetConfig.mcp_result_size,
config-overridable via tool_budget.mcp_result_size_chars; pinned and
per-tool overrides still win; capped by the context-scaled default).
- The persisted-output preview now teaches recovery: page the saved file
with read_file or process with execute_code instead of re-requesting the
same data from the remote API.
- Untrusted/MCP string results are scanned (bounded, first 64KB) for
provider-side elision markers ('...N more items', "has_more": true,
'saved to sandbox', data_preview) and get ONE cache-safe incompleteness
notice appended at result-construction time, before untrusted wrapping —
so the model stops treating provider-elided enumerations as complete.
- Hard 2M-char allocation cap in mcp_tool.py (text, error, and
structuredContent paths) so a pathological multi-MB server payload is
bounded before it propagates, while ordinary large results reach
spillover intact. Distilled from #56060/#56072/#56511 (issue #56059);
supersedes their 50K lossy truncation with spillover-friendly semantics.
Docs: configuration.md spillover-budget section + cli-config.yaml.example.
Co-authored-by: Stoltemberg <215755014+Stoltemberg@users.noreply.github.com>
Co-authored-by: AlexFucuson9 <295703459+AlexFucuson9@users.noreply.github.com>
Co-authored-by: Tranquil-Flow <66773372+Tranquil-Flow@users.noreply.github.com>
* fix: read tool_budget config via load_config_readonly (config read guard)
* fix(desktop): stand the terminal overlay down when its tab loses focus
The persistent terminal is a position:fixed overlay that chases its slot's
rect, and the whole tracker — visibility included — was gated behind the
renderer pause. Switching tabs while the window is unfocused therefore left
the overlay parked over the zone at full opacity with pointerEvents:auto, so
the chat underneath was unreachable until something refocused the window.
Visibility is correctness rather than perf, so sample it on every wake even
while paused; the rect chase, which is the part that forces layout, stays
gated.
* test(desktop): cover the terminal overlay hiding on an unfocused tab switch
* feat: wall-clock run budget — wrap-up injection at 80% and deadline-scaled stale timeouts (agent.run_budget_seconds / --run-budget)
* feat: runtime stall guards — identical-call loop breaker and continue-intent recovery (agent.stall_guards)
Composio eval traces showed Hermes wasting turns re-issuing identical tool
calls (same tool, same args, same result — 3x/4x in one run) and ending
turns by announcing an action it never took. Two conservative, config-gated
guards (agent.stall_guards, default true):
- Identical-call loop breaker: ToolCallGuardrailController.observe_identical_call
tracks the consecutive streak of (tool, canonical args, result-hash); on
the 3rd identical call a compact one-line notice is appended to that tool
RESULT at construction time (cache-safe — tool results are append-only).
Never blocks the call. Pollers (process, *_get_result, *_poll) are exempt
via STALL_GUARD_REPEATABLE_TOOLS. Streak resets on any different call,
changed result, or new turn. Observed on the raw result before the
tool-loop warning suffix so its changing count can't defeat matching.
- Said-continue-but-stopped recovery: trailing_continue_intent() detects a
short reply ENDING on an announced next action ('Let me now…', 'I will
now…', 'Next, I…'); the conversation loop feeds it into the EXISTING
intent-ack continuation path (same interim-assistant + user-nudge
mechanism, same codex_ack_continuations cap of 2), preserving message
alternation — no parallel recovery machinery.
Config: agent.stall_guards in DEFAULT_CONFIG; docs in configuration.md;
unit tests for streak/allowlist/reset/gate and detector pos/neg cases.
* feat(desktop): back the HUD band with the same window material the app uses
The HUD asked for vibrancy directly and always with the 'hud' material —
one of the two rungs the macOS census rejected, because it collapses into
under-window on blur and so changed the frost the moment another app took
focus. It also ignored the translucency setting entirely: Glass off still
frosted, and Windows got nothing at all.
hudFrostFor is the mapping for a transparent window, beside vibrancyFor in
the shared module both processes read. Two gates give it its answer: the
renderer's report that the band actually covers the window, and the user's
Glass setting. Off resolves to no material rather than a resting one, since
a transparent window has no opaque page to hide an unwanted frost behind.
Windows 11 rides setBackgroundMaterial through the same call, so the HUD
follows the frost ladder on both platforms. Main self-diffs and keys the
latch to the window, so a Settings change re-frosts a live HUD, a tint drag
touches nothing native, and a HUD respawned on another profile is not
mistaken for the window that already carried the material.
* feat(desktop): paint the HUD band as the app's thread surface under Glass
The band wore its own card tint at a hardcoded 80/92%, so a HUD beside the
docked window read as a lookalike rather than the same surface, and the Tint
slider moved one and not the other. It now paints --ui-bg-chrome at
--translucency-glass-keep: one painter, one token, one lever.
That needed the setting and the surface rewrite to stop being one flag.
data-hermes-glass means "this window's field surfaces may be rewritten" and
is deliberately false in the HUD, which owns its own backgrounds; the new
data-hermes-glass-on means "the user's Glass setting is live" and is
published everywhere, along with the tint number the band reads.
The 0.5rem side inset drops to zero while glass is on. It exists to keep an
opaque sheet clear of the bar's corner controls, but the frost is the whole
window — an inset sheet left a hairline of bare untinted material down both
sides.
An open completion drawer now drops the frost along with the band it belongs
to. The drawer takes the band to 25% and blurs it while the native material
stayed at full strength, which is the same bare slab in a different
disguise. It mounts without a focus change, so it is observed rather than
passed in, coalesced to a frame because the shell mutates with every
streamed token.
* feat(desktop): fold the HUD's voice controls into one menu
Dictation, spoken replies, the wake word and start-conversation were four
separate icon buttons in a Spotlight bar a few hundred pixels wide — most of
the row spent on toggles that are set once and rarely touched. In the HUD
they collapse into a single menu; the docked composer has the width and
keeps them inline, same controls and same state.
The trigger is not a static glyph. It reports the loudest live voice state —
recording, transcribing, listening for the wake word, speaking replies — and
lights while any is on, because a folded menu that looked idle with the mic
open would be a worse trade than the space it saves. The three toggles are
checkbox rows that hold the menu open on select, so the state you just
changed is the state you can see.
The shared control class names move to a module of their own so the row and
the menus it renders can wear them without importing each other, and the
pressed-toggle tint stops being written out at each of its four sites.
* feat(desktop): move the HUD's way out onto the bar and drop the strip above it
The exit chip floated over the composer in a 26px transparent strip reserved
for it (--hud-chip-strip), hidden until you hovered the bar. Under glass that
strip is bare untinted material across the top of the HUD — a band of chrome
above the surface, present in every state, holding a control you cannot see.
It rides the composer's controls row now, next to send. That costs no
reserved space and takes about 120 lines of CSS with it: the chip needed its
own placement, hover reveal, leave-hold, and an opaque card to stay legible
over an unknown desktop. None of that applies to a button on the bar, which
is already our surface — the problem was the placement, not the control.
Trade-off worth naming: the way out is now always visible in the HUD rather
than revealed on hover. It is one more permanent glyph on a Spotlight bar, in
exchange for an escape hatch that no longer depends on discovering it.
* fmt(js): `npm run fix` on merge (#90384)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* fix(desktop): bound the profile-activation half of a Bot Chat wake
host.openSession awaited ensureGatewayProfile with no deadline. That await
gates waitForFocusedSessionHydration, which arms the only timer on the path,
so a profile dial that never settles left the open pending for the life of the
window: the pane froze with no error, no Retry and - the part that made this
hard to recognise - no timeout either. The gateway log signature is a bare
`ws accepted` with no matching `ws closed`.
Bound the activation with its own copy of the wake budget rather than folding
it into the hydration one. A cold profile backend can legitimately spend most
of the hydration budget painting a large transcript, and that race is already
tight enough to lose, so charging activation to the same clock would trade a
wedge for a regression. The timeout reuses the hydration message prefix on
purpose - openSession keys the core stranded-session surface off it - and the
[bot-wake] support log now names which phase expired, so a stuck dial is not
read as a slow transcript.
Scoped to callers that passed awaitHydration. A plain open never asked for a
deadline and has nowhere to render one, so its behaviour is unchanged.
Two existing tests counted microtask ticks between the call and the core open.
The bounded activation adds a tick, so they now flush a macrotask instead,
which asserts the same thing without depending on the await count.
Refs #89556
* fix(desktop): move the hydration-timeout retry into host.openSession
A review of the previous commit found that retrying at the plugin layer
(openStoredBotChat catching and re-calling host.openSession) didn't fix
the reported bug: host.openSession's own catch block unconditionally
calls setResumeExhaustedSessionId on a hydration timeout before
rethrowing, and only an explicit resumeSession() (the manual Retry
button) clears that latch for the currently-routed session. A
plugin-side retry is a different code path that can hydrate the
transcript fine while the full-screen "Couldn't load this session"
overlay stays latched over it.
host.openSession now takes a retryHydrationTimeoutOnce option and
retries the open+hydration-wait internally, before the latch is ever
set, so a successful retry never arms the overlay. openStoredBotChat
just opts in via that option.
* fix(desktop): keep Sessions workspace when opening a Bot Chat
Bot Mode passed keepAllProfilesScope:false, which re-homed the sidebar
onto the bot profile. That profile forever-chat is hidden, so Sessions
and the roster looked empty. Opening a bot is navigation, not a workspace
switch. Also restore all-profiles when the bot backend is already live.
Related: #89789
* fix(desktop): keep chrome API home when opening a Bot Chat
Opening a plugin/Bot Mode session is navigation, not a workspace switch.
keepAllProfilesScope (default true) now dials the named backend without
moving $activeGatewayProfile or setApiRequestProfile. Session-owned RPCs
still route to the session owner. Pass false to switch chrome and collapse
the Sessions sidebar.
* fix(desktop): keep the two-argument call shape for session RPCs without a deadline
Threading timeoutMs/signal through requestForSessionProfile and
requestGatewayForProfile handed every session-scoped RPC a trailing
`undefined, undefined`. Only the plugin host bridge actually supplies those,
so the rest of the app's calls changed observed arity for no reason — and the
resume/activate paths assert on the exact call shape.
Forward the deadline args only when the caller set them; the plugin bridge
keeps the full four-argument route it needs.
* style(desktop): prettier
* fmt(js): `npm run fix` on merge (#90408)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
---------
Signed-off-by: Yingliang Zhang <zhangyingliang@outlook.com>
Co-authored-by: Gille <4317663+helix4u@users.noreply.github.com>
Co-authored-by: xxxigm <tuancanhnguyen706@gmail.com>
Co-authored-by: dcdexhome <dcdexhome@gmail.com>
Co-authored-by: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com>
Co-autho…
lisajlau
pushed a commit
to lisajlau/hermes-agent
that referenced
this pull request
Aug 20, 2026
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Auto-generated by the
auto-fix lint issues & formattingworkflow. Auto-merges (squash) once CI passes. If CI fails ormainmoves, the PR is auto-closed and the branch deleted — the next run re-applies on the current state.