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>
Profiles > Manage skills & tools goes to /skills?profile=X. The path and the param change in the same navigation, so the effect that adopts the URL param and the effect that writes state back to the URL both ran with the old state. The write-back won, and the page stayed on the old profile with the URL rewritten to match it.
A changed ?profile= is now adopted during render, before effects run, so the URL sync effect only ever sees the new state.
selectedSessionsSkippedActive is required in Translations, but only en.ts
had it. Every locale typed `: Translations` failed tsc -b with TS2741, which
breaks scripts/build/web.mjs. Add a translation next to
selectedSessionsDeleted in each of them (ar.ts goes through defineLocale and
falls back to English).
useToast holds one toast and only supports success|error, so the skipped
toast replaced the success toast in the same tick and the deleted count was
lost. Show a single toast instead: success with the count when nothing was
skipped, otherwise one error toast that carries both counts. Also drop the
duplicated comment.
The browse picker swallowed SessionActiveWriteGuardError into a generic
"Delete failed.", and the dashboard bulk delete only reported the deleted
count, silently keeping rows a live turn owns. Surface both: the picker
flashes that the session is active, and SessionsPage shows a toast with the
skipped_active count (new en key; other locales fall back to English via
defineLocale). Also move the api_server import into its sorted slot.
delete_sessions(exclude_active_write_guards=True) dropped guarded rows
silently: the web bulk-delete endpoint returned only a count and the
dashboard removed every selected row optimistically, so refused rows
reappeared on the next reload with no explanation.
The store now appends refused ids to an optional skipped_ids list inside
the same write transaction, the endpoint returns them as skipped_active,
and SessionsPage keeps those rows listed. Also hoists the
SessionActiveWriteGuardError imports to module top (hermes_state_errors
is stdlib-only) and drops the assertion-less lineage comment in the test.
User installs failed with 'resvg-py is missing' because the web/desktop
source builds rendered icons on whatever python was on PATH. The default
brand outputs are now committed; source_build, apps/desktop build.mjs and
the npm/docusaurus pre-hooks consume them directly. Flavored release
bundles (canary/commit) still render into their own product dir.
icons-freshness-check now regenerates and fails on any byte diff.
xterm measures its cell at open() and only re-measures on a fontFamily/
fontSize change or a grid resize. JetBrains Mono is font-display: swap and
usually still loading when /chat mounts, so the grid kept fallback metrics
until a sidebar toggle resized the host. Wait on document.fonts for the
bundled faces, then re-measure, refit, and clear the WebGL atlas. Applied
to the /chat terminal and the Hermes Console modal.
Fixes#92899
Inside the official image every named profile has an s6 slot that the container's boot
registers DOWN (multiplex-only). `implicit_multiplex_blocker` still called
`_host_supports_migration`, whose s6 branch refused unconditionally ("Restart the container"),
so a Hermes Cloud host with the key UNSET booted standalone after an in-place update and every
other profile's bot went silent, while an explicit `true` bypassed the guard and worked. The
guard was vetoing a second gateway that could not exist.
- Only a named slot that is UP is a blocker; registered-down slots never veto the default.
`hermes gateway migrate --multiplex` (and the `hermes update` hook) now fold an UP slot
in-process: `s6-svc -d` + `down` file, root slot (re)started, no container restart. Boot
and migration share ONE fold rule (`gateway_multiplex_s6.fold_named_slot_intent`).
- A multi-profile host that resolves standalone on a guard prints a boxed warning at gateway
start, in the `hermes update` summary, in `hermes gateway status`, and the dashboard
`/api/status` carries `multiplex_standalone_reason` with a banner. Single-profile installs
are not warned.
- The resolved unset→on default is written as `gateway.multiplex_profiles: true` into the
default profile's config.yaml (comment-preserving writer, once, never on a guard refusal).
exposePluginSDK() is the runtime API third-party dashboard plugins consume
from window.__HERMES_PLUGIN_SDK__; nothing else exercises it. Restored
web/src/plugins/registry.test.ts (#50547): Dialog family, ConfirmDialog and
Toast on components; useToast/useConfirmDelete (and the React hooks) on hooks.
Use a per-test byte sequence instead of repeating one random byte across the token. Keep the sequence shared across simulated tabs so duplicate-tab isolation does not depend on a random collision, while preserving the reload invariant.
The analytics routes answer a corrupt state.db with a 503 whose detail is an
object, {error: "state_db_corrupt", message: "state.db corrupt — run `hermes
doctor` ...", path}. extractDetail only accepted a string or a validation list
under detail/message/error, so it returned null and apiErrorFromResponse fell
back to humanizeStatus(503): "The Hermes service is not ready yet. Try again in
a moment." The Models page (every visit) and the Analytics page (with token
analytics on) told the operator to wait, which never helps, and dropped the
one instruction that does.
extractDetail now reads the string `message` of an object value, so any
structured detail surfaces its sentence, not its machine code. String and
validation-list details are unchanged, and an object without a message still
falls back to the status sentence.