fetchJSON now throws ApiError with a humanized message, so the
widget's legacy `msg.startsWith("401:")` check never matched and a
401 from /api/auth/me rendered "auth status unavailable" instead of
nothing. Read the status off the error object.
Part of GH issue 66223 (loopback /api/auth/me).
RED on main: the expanded session transcript renders assistant
reasoning with literal <thinking>/<action>/<result> wrapper tags.
Keeps the user-role invariant (literal tags stay on the Markdown path).
Only assistant content with known reasoning tags uses StructuredReasoning.
User and tool messages with literal <action>/<result> tags stay on Markdown.
Voice replies had to come from the session's main model, so a heavy model made every spoken
answer slow (#83894). A new auxiliary slot, voice_chat, picks the model for voice turns:
- CLI/TUI voice mode, the Desktop voice conversation (prompt.submit voice_turn) and messaging
voice notes mark the turn; agent/voice_turn_route.py binds the route after the prompt, row and
preflight compaction were settled against the main model and undoes it before memory sync and
background review, so the next typed turn and the review run on the main model.
- A turn larger than the voice model's compression threshold stays on the main model with a
one-time notice: switching to voice never compacts the conversation.
- Usage lands in session_model_usage under task voice_chat; it never rewrites the session's
recorded model/route, so resume keeps the model the user picked.
- Slot appears in Desktop Settings -> Models, the dashboard, and `hermes model`; GPT-Live
delegations keep using the session model.
web/tsconfig.json is a solution file (files: [], references), so 'tsc -p . --noEmit' checked nothing and CI's web check passed while the build's 'tsc -b' failed on four errors in ChatSessionList.test.tsx (#134100). The typecheck script now runs 'tsc -b . --noEmit' like hermes-ink, and the test types the act-environment global and the Button mock's extra props.
#134100 added a test that assigns globalThis.IS_REACT_ACT_ENVIRONMENT directly
(TS7017) and destructures ghost/outlined/size from a plain ButtonHTMLAttributes
prop type (TS2339). Vitest strips types, so the test passes, but `tsc -b` in the
web build fails, which breaks every Docker image build and the Desktop update
E2E seed on main. Cast globalThis like the sibling tests do and widen the mock
prop type with the three props it consumes.
Offset ChatPage.tsx growth (the isActive prop for ChatSessionList) per the
code health ratchet: the floating copy-last response button moves verbatim
into web/src/lib/chat-copy-last-button.tsx. No behaviour change.
A native/DOM paste on the chat terminal (context-menu paste, middle-click,
or any Ctrl+V route that bypasses the keydown interception) fell through to
xterm's own paste listeners. xterm clears the hidden textarea during the
paste dispatch, but the browser's default action — inserting the pasted
text into that textarea — runs after dispatch, so a stale value survives
and the next typed character is duplicated (#52471: "when" → "whenn").
The host capture-phase paste handler now cancels the native paste for
plain text and delivers it via term.paste() exactly once — the same clean
route the Ctrl/Cmd+V keydown interception already uses. Image pastes keep
their upload path and are never routed as text.
Supersedes #52477 (credit: @liuhao1024 — same interception, extended with
the image route preserved and regression coverage).
Fixes#52471
Backend listings now emit broken_link: true placeholder entries with
nullable mtime for dangling symlinks. Update ManagedFileEntry to match,
render broken links with an Unlink icon and a destructive badge, disable
download for them, and keep delete enabled so users can remove the dead
link from the UI.
The chat terminal loaded @xterm/addon-webgl for every wide host with
nothing but a constructor try/catch. Three failure classes escaped it:
- Safari's WebGL atlas garbles Unicode box-drawing glyphs (#18773)
- software rasterizers (llvmpipe/SwiftShader) advertise WebGL but regl
throws '(regl) webgl not supported' from inside the addon (#45520)
- the per-glyph atlas cannot shape complex scripts, so Bengali
conjuncts render broken (#58685)
Gate the addon behind a pre-flight probe (real context + non-software
unmasked renderer, released immediately) and a Safari UA check, both in
a new pure module; when shaped-script text arrives over the PTY, drop
the live addon so xterm re-renders through the DOM renderer, whose text
shaping handles conjuncts. Context loss already disposed the addon.
ChatPage.tsx crossed its FILE_LINES cap and the ChatPage function its
FUNC_LINES cap once the #36784 accessibility lines landed. Move the
module-level theme/font-tier helpers to web/src/lib/terminal-appearance.ts
(the ratchet's sanctioned remedy — moved code keeps its cap) and tighten
the new comments. No behaviour change: same helpers, same values.
Replace the static source-text assertions with behavioural tests that
render ChatPage against a capturing FakeTerminal and observe what
assistive technology observes: the Terminal is constructed with
screenReaderMode: true, the host div is a labelled role=region, and the
single registered key handler lets Tab escape (WCAG 2.1.2) while other
keys still reach the terminal. Proven RED on base, GREEN with the fix.
Teknium's review of the original PR flagged that the new
`term.attachCustomKeyEventHandler` at line 540 silently overwrote
the clipboard handler at line 464 — xterm.js stores a single
`_customKeyEventHandler` per Terminal
(node_modules/@xterm/xterm/src/browser/CoreBrowserTerminal.ts:914,
`this._customKeyEventHandler = customKeyEventHandler;`) and assigns
overwrites later registrations, so the Tab escape branch never
executed.
The rework folds the Tab branch into the existing handler at the
top (right after the type guard) so only one handler is registered.
The ARIA region and screenReaderMode additions remain untouched.
Adds web/src/pages/ChatPage.a11y.test.ts — regression test asserting
that `screenReaderMode: true` is set, the host div carries
`role="region"` + `aria-label=`, exactly one
`term.attachCustomKeyEventHandler(` call exists, and the single
handler contains the Tab-escape branch + keydown guard. Verified the
test fails on the original PR diff (catches the regression) and
passes on the rework.
Closes inline review thread at #59091.
The web UI chat composer uses xterm.js, which renders to a canvas
invisible to VoiceOver/NVDA. Add screenReaderMode: true to create
the offscreen textarea screen readers need, a Tab escape handler to
prevent keyboard traps (WCAG 2.1.2), and an ARIA region label on
the host div.
The logout handler used a hardcoded `/login` path for the post-logout
redirect. When the dashboard is served under a URL prefix (via
X-Forwarded-Prefix), this navigates to the wrong path, breaking the
logout flow for reverse-proxied deployments.
Use `${BASE}/login` to match the prefix-aware pattern already used
by the API calls in the same file.
The sidecar now refills its redial budget only after a connection has
stayed open for the grace window; the events feed on the same component
still reset `attempt = 0` on every `open`. A /api/events socket that opens
and dies within a second therefore never reached
EVENTS_MAX_RECONNECT_ATTEMPTS — the same unbounded churn, minus the
agent boot. One shared HEALTHY_OPEN_GRACE_MS now gates both ladders; the
banner still clears on open, since a flap re-surfaces it on the next drop.
Tests: a brief open keeps the ladder on its next rung (2s, not 1s); forty
open-then-close cycles still hit the cap and surface the gave-up banner;
the existing "resets after reconnect" case now waits out the grace window.
Stacked on #129397. Clearing the pending redialTimer on open prevents the backoff timer from closing a just-established connection. The 3 regression cases align with the 10s grace period from #129397. Tests: ChatSidebar 24/24 + ChatPage 15/15.
(cherry picked from commit ee043461a307a68ed0cbe3a5a5ffde7adfb10e7b)
For a partial run (committed, exit 1 after record_user_action) the toast said
only "Action failed (exit 1)" and dropped the receipt's recovery instruction;
on exit 0 it named follow-ups from whatever receipt the status route attached,
which is the latest one when this action has none.
Keep the update POST's action_id and read the debt through the shared
updateDebt (committed success|partial, matching action_id only). The toast
composes it with every verdict: follow-ups keep the rerun remedy, a user
action shows the producer's own instruction verbatim (never "re-run hermes
update", which does not restore a parked stash).
`actionFinishedOwed` was added to the English catalog and the shared type only, so `tsc -b`
(what the Docker image and nix builds run) failed on the 15 locales typed against the full
shape, and Arabic fell back to English. Adds the translated string to all 16 catalogs.
Under C3 a run whose code committed is a success (exit 0) even while a
post-commit step is owed, but the dashboard status summary and the managed SSH
observer dropped the receipt's followups/user_action, so every client outside
the local shell hand-off reported plain success. Both summary protocols now
carry followups and user_action; the Desktop backend apply and managed SSH
result keep their C3 success/restore status but name each owed step and its
remedy, and the web dashboard toast does the same. The Desktop receipt
fallback also requires the receipt's action_id to match the started action
(when the backend reports one), so another action's run cannot certify it.
Offsets the code-health ratchet: the oversized locale files and types.ts
may only shrink, and the re-ask line would have grown eight of them. Same
shape as the model-menu move; web api.ts keeps its line count by
collapsing one signature.
Before the type-ahead fix, an Enter pressed while `hermes chat` started
could save "No thanks" before the offer was on screen, and every surface
then treated the profile as answered. Every answer now also writes
`telemetry.shared_metrics.offer_version`. An "off" answer without it reads
as `reask`: undecided, so the CLI, Desktop strip and dashboard banner offer
it once more with a one-line reason. Any response settles it, including
Esc in the terminal and the banner close, which keep "No thanks", so
nobody is asked a third time. Opt-in answers were always deliberate picks
and are never re-asked.
Plugins can now call ctx.register_automation_blueprint(key, *, title,
description, schedule_template, prompt_template, category, slots,
deliver_default, skills, tags). The fields mirror the built-in
AutomationBlueprint, and slots are BlueprintSlot field dicts, so the call
also crosses the plugin-host boundary.
- One lookup, cron.blueprint_catalog.list_blueprints() / get_blueprint(),
merges the built-ins with the active profile's plugin blueprints at call
time. /blueprint (CLI/TUI/messengers), /api/cron/blueprints and the
instantiate route all use it. Each dashboard route resolves inside the
?profile= scope, off the event loop.
- Keys are namespaced <plugin>:<key>, so a plugin cannot shadow a built-in.
A ':' in the key, a duplicate key, an unknown placeholder, a bad slot type
or a schedule that does not parse is warned and ignored at registration.
- Catalog entries carry source/plugin. Desktop fetches the catalog for the
profile it creates jobs in, keyed per profile, and shows the plugin name
on the blueprint row and in the "Start from" select. The dashboard shows
a plugin badge.
- A created job is an ordinary cron job with the prompt and schedule already
filled in, so it keeps running if the plugin is later disabled.
- The ctx content registrars (register_skill, now joined by
register_automation_blueprint) move to hermes_cli/plugins_content.py.
The dashboard's blueprint types move to web/src/lib/automation-blueprints.ts.
Closes#132373. Requested by @enckequity.
No behavior change. Keeps every touched file and function at or below
its value on main:
- agent/auxiliary_task_config.py: read-time `auxiliary.<task>`
resolution (plugin defaults, inherit_from, whole-route pin); still
re-exported as agent.auxiliary_client._get_auxiliary_task_config.
- hermes_cli/plugins_aux_tasks.py: register_auxiliary_task validation
and entry shape.
- web/src/lib/api-aux.ts: /api/model/auxiliary wire types.
- Desktop: aux row list, label resolver, deep-link highlight, route
summary, Follow button and the set-to-main route in
settings/aux-task-rows.tsx; one assignAuxiliary callback replaces
setAuxiliaryToMain. Built-in aux slot labels move from each locale
into <locale>_aux_tasks.ts (types_aux_tasks.ts holds the shape).
Plugin aux rows (#40880) now carry `inherit_from` and, for inheriting
tasks, an `effective` {provider, model, base_url} resolved inside the
request's profile scope. Desktop Settings → Models and the dashboard
Auxiliary Tasks modal render an unpinned inheriting row as
"inherits Compression · <provider · model>" and offer "Follow <base>"
on a pinned one (writes provider auto, which resumes inheritance).
The dashboard test now writes plugin manifests/config as JSON (no yaml
dependency in the test env) and covers profile A/B isolation of the
same plugin slot: listing, effective route, pin persistence, follow and
base-change propagation each stay in their own profile.
`PluginContext.register_auxiliary_task` already lists a plugin task in the `hermes model`
picker and layers its defaults into `auxiliary.<key>`, but every REST/UI surface behind the
Models page enumerated only the built-in `_AUX_TASK_SLOTS`:
- `GET /api/model/auxiliary` never listed the task
- `POST /api/model/set` (scope=auxiliary) rejected it: `400 unknown auxiliary task`
- `__reset__` and the empty-task broadcast skipped it; `_stale_aux_pins` ignored it
- Desktop Settings → Models and the dashboard ModelsPage hardcoded the built-in list
So a plugin's side model could only be pinned by hand-editing config.yaml per profile.
Fix, at the existing seams:
- `web_server_config._aux_task_slots()` = built-ins + plugin keys from
`get_plugin_auxiliary_tasks()`, fail-soft; every consumer in that module uses it.
- `GET /api/model/auxiliary` reads plugin tasks INSIDE `_profile_scope`, so a request for
profile B enumerates B's plugins (the plugin manager keys on the same context-local home) —
the profile-scope gap flagged on #40922. Built-in rows are byte-identical; plugin rows are
appended with `label`/`hint`/`plugin` from the registration.
- Desktop `model-settings.tsx` and dashboard `ModelsPage.tsx` render served plugin rows after
the built-ins using the server label/hint (pure `auxTaskRows`; no new i18n strings, no-op on
older backends).
Tests: real plugin loaded from a temp HERMES_HOME through PluginManager discovery, two
profiles in one process (listing scoped per profile; foreign task still 400), assignment /
broadcast / reset / stale-pin over a plugin slot, discovery failure leaves built-ins intact —
all red on main. Vitest covers the pure row merge and the rendered plugin row.
Fixes#40880. Salvages #40922.
Co-authored-by: Tranquil-Flow <tranquil_flow@protonmail.com>
Allowlists (*_ALLOWED_USERS, LINE groups/rooms, SimpleX groups) rendered as a
single empty input with a redacted "«redacted:…»" placeholder, so a saved list
looked like a hidden secret and could only be replaced wholesale.
- Backend: messaging env rows carry is_list, and list rows carry their plain
value (IDs are not secrets; password fields still only leave redacted).
- Desktop + dashboard: one visible text box per entry, x to remove, "+ Add
another" (Enter also adds); a pasted comma list splits into boxes. Saved as
the same comma-separated value the gateway already parses; removing every
entry and saving clears the key.
- Desktop help copy for the allowlist fields says "one per box" in all locales.
The shared model picker always loaded /api/model/options, which is the
main chat model. Opening Set Auxiliary: Vision therefore showed
current: glm-5.3 even after Vision was already pinned to Qwen.
Next to macOS 26's own icons the flat plate + girl reads better than the
ringed tile, so the ring is off in every output: the tiles' inward stroke,
the layered icon's border layer and its targets. BORDER_ENABLED keeps the
ring code one flag away.
Without a ring the girl's lowest nodes are dragged past the tile edge
(EDGE_OVERSHOOT) so the outline crops her instead of leaving a gap.
Clear light/dark and Tinted light/dark now come from one grayscale mono
layer with Liquid Glass on: the parts of the girl that are black in light
mode become a near-black frosted ink (0.13 @ 90%), her white parts stay
white, each pixel painted exactly once so materials never stack. The art
layer hides under "tinted" and the mono layer shows only there.
Mac girl at 1.06x (between main's 1.12x and the layers' 1.0x), for the icns
and the layered icon alike. Chosen from a six-appearance matrix of real
IconServices renders next to Finder.
When renaming a session from the ChatHeader in Desktop, the session's
owning profile was omitted from SessionActionsMenu, causing rename calls
to omit profile parameters and fall back to the default profile database.
On non-default profiles (e.g. personal), this caused a 404 from the server
while the desktop UI optimistically displayed the new name locally.
Pass the active session's profile from ChatHeader to SessionActionsMenu,
resolve the session profile from the $sessions store when omitted in
renameSessionPreferringRpc, ensure REST renameSession falls back to
profileScoped, and propagate profile in web search and sessions page rename.
Fixes#103903
One answer per profile, asked once, with the Desktop strip's three equal
choices (Send to Nous / Local only / No thanks) now offered by:
- `hermes setup`: at the end of every flow (Quick, Full, Blank Slate,
Portal, --quick), via the setup-completed record they all funnel into.
- `hermes` / `hermes --tui`: once before an interactive chat on a profile
that never answered. Skipped for -q, piped/JSON output, spawned actions
(HERMES_NONINTERACTIVE), Desktop-hosted panes and managed installs.
- Web dashboard: a banner for the profile being managed, backed by
GET/PUT /api/shared-metrics/consent.
"No thanks" is the terminal default so Enter never opts anyone in; Esc and
the banner's X leave the question open.
Also fixes the answer that could not stick: a CLI "no" equals the shipped
defaults, so save_config stripped it and every surface (Desktop strip
included) kept asking. The answer is now written to config.yaml directly,
and `decided` has one definition shared by the RPC, the CLI and the router.
React 19 only ships `act` in its development build. The jsdom suites in
`web/` drive components through `act()`, so when vitest inherits an
ambient `NODE_ENV=production` — the shell that launched the app, a
packaged CI step, an editor terminal — `React.act` resolves to undefined
and every `createRoot` test in the project fails with
`TypeError: act is not a function`.
Pin the test worker to `NODE_ENV=development` via the project's
`test.env` so the suite no longer depends on the ambient environment.
App runtime and production builds are unaffected; this only applies to
the vitest worker.
Same class of failure as #77409 (and the same fix shape as #77421), one
workspace over: that issue and PR cover `apps/desktop/vitest.config.ts`,
while `web/vitest.config.ts` has no equivalent guard.
Before (clean main, NODE_ENV=production):
Test Files 3 failed | 33 passed (36)
Tests 43 failed | 232 passed (275)
After:
Test Files 36 passed (36)
Tests 275 passed (275)
The gave-up-banner test was pasted twice when the budget-survival commit
(a5e0c9e5c0) was assembled: one copy at describe scope, and a second copy
inside the body of 'times out a stalled URL request and retries'. Vitest
rejects calling it() inside a test ('Calling the test function inside
another test function is not allowed'), which is exactly what failed the
JS & TS checks run on a5e0c9e5c0. Remove the nested copy; the describe-
scope copy and the stalled-URL test are unchanged and pass 20/20.
The attempt counter lived in the [gw] effect closure, so every redial —
which bumps version and re-runs the effect — reset it to zero and the
five-attempt budget never exhausted. The counter and the gave-up flag
now live in component refs (reset on a successful open and on scope
switches), and exhausting the budget surfaces a one-time banner
('gateway sidecar disconnected — gave up after 5 attempts, use
Reconnect'), mirroring the events feed's give-up contract; the banner
clears on the next successful open.
A dashboard service restart closes both web sockets with 1012 (Service
Restart) — a CLEAN close. The PTY pane's reconnect guard only retried
dirty closes and 1001/1006, so 1012 fell through to the '[session
ended]' path and the pane stranded until a manual relaunch (server logs
in the issue show a 23-minute dead window). The JSON-RPC sidecar had no
redial owner at all: GatewayClient deliberately delegates reconnect
policy upward, but ChatSidebar only dialed once.
- ChatPage: the transient-drop guard now also retries 1012 and 1013 —
both mean 'the server is coming right back', so the same ?attach=
token reattaches to the still-living PTY.
- ChatSidebar: the sidecar gets a redial owner with the same bounded
backoff the PTY pane uses (250ms * 2^n capped at 3s, 5 attempts,
reset on open, cancelled on unmount/scope switch; the manual
Reconnect affordance stays the fallback after the cap).
Reported recovery goes from a manual app relaunch (~23 min stranded in
the log sample) to an automatic redial ~6.5s after the service returns.
shouldAdoptActiveProfile() aligned the switcher to the machine-global active_profile
whenever it differed from the serving profile, so a dashboard hosted by a named profile
(hermes -p demo serve) silently retargeted its banner, chat TUI env and model to another
agent on every load. Only the default-hosted machine dashboard aligns now; "custom"
(unnamed HERMES_HOME) never does.
Co-authored-by: ipriyaaanshu <ipriyaaanshu@users.noreply.github.com>