* fix(dashboard): make MoA presets modal opaque and readable
Card defaults to bg-background-base/80 glass, so the Mixture of Agents
dialog let the Models page bleed through — especially on Cyberpunk/mobile.
Portal an opaque dialog shell above the z-2 dashboard column, and ignore
Escape while the nested model picker is open.
* test(web): lock dashboard modal shell to opaque panel classes
Guard the MoA/dialog shell contract so glass Card defaults cannot
quietly return to modal panels, and Escape stays picker-aware.
* fix(dashboard): only open the chat PTY once the chat tab is active
The dashboard mounts ChatPage persistently (hidden with CSS) on every route
so the embedded chat PTY survives tab switches. But the PTY-connect effect
never checked whether the chat tab was active, so it opened `/api/pty` on
mount for ANY dashboard page. On a source/RPi install that spawns the whole
TUI + agent bootstrap (`Installing TUI dependencies…` → `npm install`) merely
by loading /sessions, /system, etc. — work the user never asked for, and the
trigger behind "dashboard loses custom themes on /chat load".
Gate the connect effect on a sticky activation latch: the PTY is not spawned
until the chat tab has been active at least once, and stays connected across
later tab switches so the persistence UX is preserved.
* test(dashboard): cover chat PTY activation latch
Asserts the invariant behind the fix: activation is sticky. It stays false
while the chat tab has never been active (so the persistently-mounted,
hidden ChatPage never opens /api/pty), flips true when the tab activates,
and stays true after the user navigates away (PTY persistence).
The CI change classifier routes package.json / package-lock.json /
.ts/.tsx changes to the frontend lane, not the Python lane. Four
Python tests asserted about these JS-side artifacts, so a PR touching
only those files would skip the Python suite — the regression goes
green on the PR and red on main (where the classifier fails open).
Ported all four to vitest so they run in the correct CI lane:
tests/test_package_json_lazy_deps.py
→ apps/desktop/electron/package-json-lazy-deps.test.ts
(camofox is lazy, agent-browser is eager, lockfile clean)
tests/test_desktop_electron_pin.py
→ apps/desktop/electron/desktop-electron-pin.test.ts
(electron dep is exact, matches build.electronVersion, lockfile agrees)
tests/test_assistant_ui_tap_compat.py
→ apps/desktop/electron/assistant-ui-tap-compat.test.ts
(@assistant-ui cluster shares one tap version + semver helper)
tests/test_dashboard_sidecar_close_on_disconnect.py
→ web/src/lib/chat-sidebar-session-params.test.ts
(sidecar session.create opts into close_on_disconnect + profile)
The ChatSidebar test was regex-matching .tsx source text (the
source-reading anti-pattern). Extracted sidecarSessionCreateParams()
from the component's effect into an exported pure function so the
test calls real code instead of pattern-matching a string.
Verified: 8 electron tests + 76 web tests pass; both typecheck clean.
- Fix no-useless-escape in i18n/ko.ts and i18n/uk.ts (remove backslash
escapes inside single-quoted strings)
- Add eslint-disable-next-line for no-control-regex in pty-mobile-input.ts
(terminal data legitimately contains control characters)
- Configure react-refresh/only-export-components with allowConstantExport
so context providers that export hooks don't trigger the rule
- Downgrade react-hooks v7 rules (set-state-in-effect, refs,
preserve-manual-memoization, static-components) from error to warn —
these are real concerns but the existing code uses common patterns
(data loading on mount, ref-as-instance-var) that need careful refactoring
Follow-up hardening on top of #64158 (@DavidMetcalfe):
Backend (the root-cause fix):
- hermes_cli/moa_config.py: add validate_moa_payload() — strict write-time
counterpart to the deliberately tolerant normalize_moa_config(). Flags
half-filled slots, empty reference lists, recursive moa slots, naming the
exact preset/slot.
- hermes_cli/web_server.py: PUT /api/model/moa validates before normalizing
and returns 422 with the specific problems instead of silently swapping the
user's preset for hardcoded defaults (#64156). Also declares
fanout / reference_max_tokens / reasoning_effort on the Pydantic payload so
client round-trips no longer erase hand-set values.
Desktop:
- Replace sanitize-then-send with hold-while-incomplete: the debounced
autosave is deferred (not repaired) while any slot is half-filled, and
flushes once the model pick completes the edit. Mid-edit UI state is never
repainted by a save response (generation guard covers held edits too).
- updateMoaSlot only clears the model when the provider actually changed.
- Explicit preset ops (set default / add / delete) cancel the pending
autosave and invalidate in-flight responses so the two writers can't race.
- Stable row keys (preset+index) so mid-edit rows don't remount; cleared
model shows the 'Model' placeholder instead of vanishing.
Both TS clients' MoaConfigResponse types now declare the round-tripped
fields (fanout, reference_max_tokens, reasoning_effort).
Tests: 12 new backend unit tests (validate_moa_payload contract incl.
validate/normalize agreement), 3 new web_server endpoint tests (422 on
half-filled ref/aggregator, fanout round-trip), 3 new desktop vitest cases
(autosave held while half-filled, flush on completion, same-provider
reselect no-op). E2E validated against a live TestClient with isolated
HERMES_HOME: bug sequence now 422s with config untouched.
Fixes#64156
Dashboard Chat is an xterm mirror of a TUI inside the gateway, so
server-side clipboard.paste never sees the browser clipboard. Upload
pasted/dropped images to the profile's images/ dir (same place
clipboard.paste / image.attach use), then drive /image over the PTY.
Uses a dedicated /api/chat/image-upload endpoint (magic-byte check,
25MB cap, profile scope) instead of relative managed-files uploads that
400 on local dashboards without a locked root. Ctrl/Cmd+Shift+V also
tries clipboard.read() for images before falling back to text, since
preventDefault on that chord suppresses the DOM paste event.
Salvages #57912 (client composition + /image PTY drive) and folds in
#48563's upload endpoint + drop path.
Co-authored-by: bird <6666242+bird@users.noreply.github.com>
Co-authored-by: tt-a1i <53142663+tt-a1i@users.noreply.github.com>
Follow-up hardening on the salvaged NS-591 mobile-chat reconnect fix, from
review findings:
- Guard the page-resume reconnect against the async socket-open window:
a connectInFlightRef is set synchronously before the ticket-URL await so
a visibilitychange/focus fired during that gap (wsRef still null) can't
spawn a redundant second socket. Threaded through
shouldReconnectPtyOnPageResume as connectInFlight.
- Recover a socket wedged in WS_CONNECTING (half-open mobile socket after a
radio handoff — the NS-591 scenario) via a PTY_CONNECTING_TIMEOUT_MS
force-close so onclose routes into scheduleReconnect. Cleared on
open/close/effect-cleanup.
- Avoid collapsing legitimate single-letter reduplication ("a a") in the
mobile duplicate-final-word heuristic (>=2-char guard).
- Extract the 350ms replacement window and 1000ms resume throttle to named
exported consts; drop the dead WS_CONNECTING term from the resume
predicate's final expression.
Adds tests for the in-flight guard and the single-letter reduplication case.
Two review fixes on the mobile input normalization path:
- updatePtyInputLine appended the printable payload of escape sequences
(the '[D' of a left-arrow) to the tracked line, and after any cursor
movement the flat tracker no longer matched the visual line — the
DELETE-repeat replacement could then be computed against a stale
snapshot. Any chunk containing ESC now resets the tracker, disarming
replacement normalization until a cleanly-tracked line starts.
- Move the SGR mouse-report filter ahead of the blocked-input check so
scrolling a disconnected terminal doesn't print the reconnect notice.
The Keys page only rendered env vars present in a catalog (OPTIONAL_ENV_VARS
or the provider catalog); any other key a user set in .env was invisible, and
there was no way to add an arbitrary env var from the GUI (e.g. to inject a
var a skill or MCP server needs).
Backend: GET /api/env now also emits a row for every on-disk .env key that
isn't in any catalog, flagged category="custom" + custom=true and
password-masked (an unrecognised key could hold anything, so it's redacted and
reveal-gated like any secret). Channel-managed credentials stay excluded. The
write (PUT /api/env) and reveal (POST /api/env/reveal) paths already handle
arbitrary keys, with the existing env-name guard + denylist (PATH, LD_PRELOAD,
PYTHONPATH, …) enforced server-side — no new write surface.
Frontend: a new "Custom Keys" section lists those custom rows and carries an
add-a-key form (client-side name validation mirroring the backend regex; the
new row reuses the normal edit/save flow, so on save it round-trips back from
the backend as a durable custom row). i18n added for en + zh + types.
Tests: behavior-contract coverage that an unknown .env key surfaces as a
masked custom row and a catalogued key does not — verified to fail on the
pre-fix backend.
The shared websocket package is a web file: dependency but was excluded
by .dockerignore and never copied into the Docker build context. Also fix
tsc -b errors: expose buildWsUrl on api and drop the GatewayClient state
getter that conflicted with the shared base class.
The Electron desktop app and the web dashboard each carried their own
copy of the tui_gateway JSON-RPC WebSocket client plus near-identical
auth'd WS-URL construction. The dashboard's copy was the historical
source of the "is the dashboard required to run the desktop app?"
confusion, since the two surfaces looked coupled.
Consolidate the genuinely shared transport into the existing
framework-agnostic `@hermes/shared` package so both surfaces consume it
independently — neither app depends on the other:
- Move `resolveGatewayWsUrl` + `GatewayReauthRequiredError` (single-use
OAuth ticket re-mint vs long-lived token fallback) into
`@hermes/shared`; desktop now imports them directly.
- Add `buildHermesWebSocketUrl`, one base-path/scheme/auth-aware URL
builder, and route every dashboard WS endpoint through it
(`/api/ws`, `/api/events`, `/api/pty`, plugin WS URLs).
- Reduce the dashboard `GatewayClient` to a thin subclass of the shared
`JsonRpcGatewayClient`, deleting ~210 lines of duplicated pending-call
/event-dispatch/connect plumbing while keeping its dashboard-specific
ticket-vs-token auth selection.
- Drop the stale "start it with --tui" chat banner, which implied the
dashboard flag was required.
Behavior is preserved on both surfaces; the dashboard additionally
inherits the shared client's 15s connect timeout (previously
desktop-only), so a hung connect now fails fast instead of pinning the
composer in "connecting".
Collapse the three near-identical optional-text helpers
(optionalText/optionalBaseUrl/listToText) into one optionalText with a
strip-trailing-slash flag, route listToText + toolsets through the
existing splitCronList, and replace the repeated
typeof x === 'string' ? x : '' ladders with a single asString helper.
Behavior-identical; all 16 vitest cases pass.
* feat(moa): expose MoA presets as selectable virtual models
Reconstructed onto current main (PR #46081's base had diverged with no common
ancestor, marking the PR dirty so CI never dispatched). MoA is now a virtual
provider: each named preset is a selectable model under provider 'moa', and the
preset's aggregator is the acting model that answers and calls tools.
Reference models fan out in parallel via a bounded ThreadPoolExecutor (the same
batch pattern delegate_task uses) — all references dispatched at once, collected
when every one finishes, then handed to the aggregator. Output order is
preserved, failures and the MoA-recursion guard stay isolated per reference.
- Removed the old mixture_of_agents model tool and moa toolset.
- Added moa as a virtual provider in the provider/model inventory.
- /moa is shortcut behavior over model selection (default preset / named preset
/ one-shot prompt).
- Dashboard + Desktop manage named presets; presets appear in model pickers.
- Parallel reference fan-out in agent/moa_loop.py with regression test.
* fix(moa): thread moa_config through _run_agent to _run_agent_inner
The reconstructed gateway MoA wiring declared moa_config on _run_agent (the
profile-scoping wrapper) and used it inside _run_agent_inner, but the wrapper
never forwarded it — _run_agent_inner had no such parameter, so the runtime hit
NameError: name 'moa_config' is not defined on the compression-failure session
sync path. Add moa_config to _run_agent_inner's signature and forward it from
both wrapper call sites (multiplex and non-multiplex). Caught by
tests/gateway/test_compression_failure_session_sync.py on CI shard test(4).
* fix(moa): classify moa as a virtual provider in the catalog
The moa virtual provider has no PROVIDER_REGISTRY/ProviderProfile entry, so
provider_catalog() fell through to the default auth_type="api_key" with no
env vars — tripping two catalog invariants:
- test_provider_catalog: api_key providers must expose a credential env var
- test_provider_parity: every hermes-model provider must be desktop-configurable
moa already declares auth_type="virtual" in HERMES_OVERLAYS; consult that
overlay as an auth_type fallback so the catalog reports moa as virtual (no real
credential, no network endpoint). Exempt virtual providers from the desktop
parity union check the same way 'custom' is exempt — derived from the catalog,
not a hardcoded slug, so future virtual providers are covered too.
The web dashboard only showed a read-only "Reasoning" capability badge
with no way to set the effort level — unlike the desktop app, which has
an effort radio in its composer model menu. This adds a picker so the two
surfaces reach parity.
- ReasoningPicker: a Select rendered in the chat sidebar, gated on the
effective model's supports_reasoning capability (from /api/model/info).
Reads/writes agent.reasoning_effort via the existing config REST
endpoints (read-modify-write, the dashboard's single-key save pattern),
so the value lands in the config the agent boots a fresh chat from.
Options mirror the desktop: Off/Minimal/Low/Medium/High/Max.
- ChatSidebar: capture supports_reasoning from the model-info fetch and
render the picker; on change, show the same 'apply on /new or reload'
notice the model switch uses.
- reasoning-effort.ts: DOM-free helpers (normalizeEffort + options) so the
node-env vitest harness can cover the resolution logic, plus tests.
The Chat-tab session switcher rendered rows in the API's default
order="created" (original start time) while each row displays
last_active — so a session you just messaged in could sit below an
older one, and the list looked unsorted against its own timestamps.
Pass order="recent" from ChatSessionList so the switcher sorts by
latest activity across the compression chain (most-recently-used at
top, ChatGPT-style; long conversations that auto-compressed into a new
continuation id stay on the first page). Adds an optional, defaulted
`order` arg to api.getSessions; the paginated Sessions page keeps the
stable created order.
The dashboard's FastAPI server and a terminal CLI are separate processes
sharing one SQLite session DB; there is no inter-process push channel.
The Sessions page polled the 50 newest sessions every 5s for the
"overview" card but only re-fetched the paginated sessions list on page
change or delete, so a session started in a terminal never appeared in
the list until the user navigated.
Reuse the existing 5s overview poll as a change signal: when the head
session id changes, silently reload the current page (no loading
spinner flicker, no scroll/reset of expanded rows or bulk selection,
which are keyed by id). The detection logic is extracted into a pure
shouldRefreshSessions() helper with unit tests. Adds a minimal vitest
setup for web/ (test script + config).
The dashboard MCP catalog only showed name/description/transport and a
non-clickable source. Users couldn't see what an entry connects to or runs
before installing — the exact detail the docs trust model tells them to vet.
- /api/mcp/catalog now returns transport target (url, or command+args),
auth_type, git install source/ref + bootstrap commands, default-enabled
tool hint, and post-install guidance per entry.
- McpPage renders the endpoint URL (http) or command+args (stdio), the git
install source/ref, a collapsible bootstrap-commands list, setup notes,
and the source as a clickable link when it's a URL.
- Docs: drop the 'uv pip install -e .[mcp]' quick-start step (Hermes does
not support pip installs; MCP ships with the standard install) and note
the dashboard now surfaces this detail.
- Strengthen the catalog endpoint test to assert the new inspection fields.
* fix(dashboard): stream file uploads via multipart instead of base64 JSON
The dashboard file manager uploaded files (including backup/restore zip
archives) by reading them client-side with FileReader.readAsDataURL and
POSTing a base64 data URL inside a JSON body to /api/files/upload. For a
large backup this (a) inflates the payload ~33%, (b) buffers the whole
file plus its decoded copy in memory, and (c) reliably trips an upstream
proxy body-size/timeout limit, surfacing as a 502 with the upload
appearing to hang indefinitely (NS-501). Dashboard-only hosted users have
no shell fallback to place the archive, so backup restore was unusable.
Add a streaming multipart endpoint POST /api/files/upload-stream
(UploadFile + Form) that reads the request body in 1 MiB chunks straight
to a sibling temp file, enforces the existing 100 MB size cap as it
streams (413 on overflow, before buffering the whole file), and
atomically renames into place so a partial/aborted/over-limit upload
never clobbers an existing file. The frontend api.uploadFile now sends
multipart/form-data (raw bytes, no base64, browser-set boundary) and
FilesPage passes the File object directly; the dead readAsDataUrl helper
is removed. The legacy base64 JSON endpoint stays for backward compat.
FastAPI's UploadFile/Form require python-multipart, which is NOT pulled in
by fastapi itself, so it is added to the base deps, the [web] extra, and
the tool.dashboard lazy-install set (kept in sync).
Validated: 5 new endpoint tests (roundtrip, multi-chunk >1 MiB,
over-limit 413 without clobbering + no temp-file leak, overwrite=false
conflict, forced-root traversal containment); existing base64 tests still
pass; web typecheck + vite build clean; and a real uvicorn server E2E
(5 MB multipart upload -> HTTP 200 in 0.21s, exact byte match) plus a
30 MB TestClient roundtrip confirm constant-memory streaming end to end.
Reported via beta (NS-501).
* build(deps): regenerate uv.lock for python-multipart (NS-501)
CI ran uv lock --check / uv sync --locked which failed because the
python-multipart dependency add was not reflected in uv.lock. Regenerate
the lockfile (resolves to 0.0.20, matching the [web] extra pin) after
merging current main.
Two halves of the same community report (dashboard Profile Builder):
1. A fresh dashboard/CLI-created profile got no .env file unless cloned,
so it silently inherited API keys and messaging tokens from the shell
environment / root install. create_profile() now seeds a placeholder
.env (0600) for non-clone profiles, matching the SOUL.md seeding.
2. The Channels endpoints (/api/messaging/platforms GET/PUT/test) were
not profile-scoped: they read/wrote the dashboard process's own .env
via load_env()/save_env_value() regardless of the global profile
switcher. They now accept the standard optional profile param (body
beats query on the PUT, matching other scoped writes) and run inside
_profile_scope(). When scoped, the payload no longer falls back to
os.environ or load_gateway_config()'s env-override layer — both carry
the ROOT install's credentials and would misreport them as the
profile's. /api/messaging/platforms added to PROFILE_SCOPED_PREFIXES
so the sidebar switcher scopes the Channels page automatically.
* docs: finish Automation Blueprints terminology rebrand
Replace leftover "Automation Templates" wording from the Cron Recipes
rebrand, rename the copy-paste cookbook guide to Automation Recipes, and
point the marketing gallery link at the blueprints catalog.
Co-authored-by: Cursor <cursoragent@cursor.com>
* docs: use Automation Blueprints instead of Recipes in guide
Rename the cookbook guide from automation-recipes to
automation-blueprints so sidebar and copy match the product term.
Co-authored-by: Cursor <cursoragent@cursor.com>
* docs: rename automation-blueprints-catalog to automation-blueprints
Drop the -catalog suffix from the reference page slug and title, and
move the copy-paste cookbook to automation-blueprint-examples so the
main Automation Blueprints doc is unambiguous.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Revert "docs: rename automation-blueprints-catalog to automation-blueprints"
This reverts commit 605f1eeab5.
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Review fixes for the Cron Recipes stack before release:
- hydration-move: */90 in the cron minute field silently wraps to hourly
(croniter-verified) — 90/120-minute options never fired at their stated
cadence. Replaced with an hour-field step (0 9-17/2 * * 1-5) and an
interval_hours slot whose options (1/2/3h) all fire as labeled.
- fill_recipe: reject unknown slot names. A typo'd 'tiem=07:15' used to
silently create the job at the 08:00 default; now it 422s on the dashboard
form and errors on the slash/deep-link paths with the valid slot list.
- deliver slot: non-strict enum (options are suggestions, scheduler
validates downstream) so slack/whatsapp/etc. users aren't locked out;
GET /api/cron/recipes rewrites its options from cron_delivery_targets()
so the dashboard form only offers configured platforms; help text no
longer claims dashboard-created jobs deliver to 'the chat you set this
up from' (the endpoint strips origin — they go to the home channel).
- gateway: success/accept messages no longer point at /cron (cli_only);
surface-aware hint instead. Conversational fill now sends the
'Setting up X — I'll ask you a couple of things…' ack before the agent
turn, matching the CLI experience.
- important-mail catalog entry: reference the urgency classifier by module
path (python3 -m cron.scripts.classify_items) instead of baking an
absolute host path into the job prompt — stale after relocation and
nonexistent on remote terminal backends. cron/scripts is now a real
package and ships in the wheel (pyproject packages.find).
- export_recipe: interval schedules round-trip again — parse_schedule
stores 'minutes' but the renderer only read 'seconds', so every interval
job exported as the silent '0 9 * * *' fallback.
- skills_hub install: say so when a recipe suggestion is dropped
(latched dedup or pending cap) instead of printing nothing.
Targeted tests: 58 cron/recipe + 261 web_server pass; E2E-validated all
14 recipes fill+parse, hydration cadences via croniter, typo rejection on
slash + endpoint paths, surface-aware hints, and interval export round-trip.
A 'recipe' is a one-place definition of an automation that every surface
renders natively. The slot schema (cron/recipe_catalog.py) is the single
source of truth; four renderers consume it, and all paths end at the same
cron.jobs.create_job — no second job engine.
Form where there's a screen, conversation where there's a chat line:
- Dashboard / GUI app: a Recipes sub-tab on the Cron page renders each
recipe's typed slots as a form (time-picker, enum dropdown, free-text);
submit POSTs /api/cron/recipes/instantiate which fills + creates the job.
- CLI / TUI / messengers: /cron-recipe lists the catalog, shows a recipe's
fields, or fills + creates from a pasted 'key slot=val' command. The shared
handler (hermes_cli/cron_recipe_cmd.py) names any missing/invalid slot so
the agent can ask a targeted follow-up.
- Docs: a generated Cron Recipes catalog page (website, .mdx + React cards)
shows each recipe with a copy-paste command and a 'Send to App' button.
- Desktop: a hermes:// URL scheme (Electron single-instance lock +
setAsDefaultProtocolClient + open-url/second-instance) routes
hermes://cron-recipe/<key>?slot=val into the chat composer pre-filled.
Typed slots (time/enum/text/weekdays) with defaults: users never type raw
cron — recipes parameterize time-of-day and weekday sets and translate to
cron expressions; a free-text 'schedule' slot is the full-flexibility escape
hatch. Consent-first throughout: nothing schedules without an explicit submit
or send.
Core:
- cron/recipe_catalog.py — CronRecipe + RecipeSlot, 5 curated recipes,
recipe_form_schema / recipe_slash_command / recipe_deeplink /
recipe_catalog_entry renderers, fill_recipe (validate + translate to
create_job kwargs).
- hermes_cli/cron_recipe_cmd.py — shared /cron-recipe handler (CLI + TUI +
gateway never drift). CommandDef + dispatch in commands.py / cli.py /
gateway/run.py.
Dashboard: GET /api/cron/recipes + POST /api/cron/recipes/instantiate
(web_server.py), CronRecipes.tsx gallery+form, Segmented sub-tab on CronPage,
api.ts methods + types.
Desktop: hermes:// scheme end to end (main.cjs deep-link router + ready-queue,
preload onDeepLink/signalDeepLinkReady, global.d.ts types, desktop-controller
composer prefill, electron-builder protocols key).
Docs: extract-cron-recipes.py generator wired into prebuild.mjs,
cron-recipes-catalog.mdx + CronRecipesCatalog React component, sidebar entry.
Generated index json gitignored like skills.json.
Tests: 23 core (catalog/slots/schedule-resolution/validation/renderers/command
handler/generator) + 5 web_server endpoint tests. E2E verified end to end:
slot fill -> create_job -> persisted job with correct schedule/deliver/origin.
The Config page read config_path from /api/status, which is machine-global
and always reports the profile the dashboard process was started under.
After switching profiles with the global switcher, the header kept showing
the old profile's path (e.g. /root/.hermes/profiles/worker_1/config.yaml)
even though reads/writes correctly targeted the new profile.
Fix: /api/config/raw now returns the resolved path alongside the YAML
(resolved inside _profile_scope, so it follows ?profile=). ConfigPage
prefers that scoped path and only falls back to /api/status for old
servers. ProfileKeyedRoutes already remounts the page on switch, so the
header refreshes immediately.
Headless/VPS users (dashboard-over-Tailscale, no comfortable SSH) could
list/toggle/install skills and create/edit cron jobs, but not author a
custom skill or link one to a cron job — the UI set WHEN a job runs, but
not WHICH skill it uses.
- Skills page: 'New skill' button + per-row edit pencil open a SKILL.md
editor dialog (frontmatter + body, server-side validation via the same
_create_skill/_edit_skill path as the agent's skill_manage tool).
- New endpoints: GET /api/skills/content, POST /api/skills,
PUT /api/skills/content — all profile-scoped via _profile_scope(),
which now also retargets tools.skill_manager_tool's import-time
SKILLS_DIR binding.
- Cron page: skills multi-select in both create and edit modals (parity
with hermes cron --skill / edit --add-skill); CronJobCreate gains a
skills field; job cards show an attached-skills badge. update_job
already accepted skills in updates.
- Tests: 17 new endpoint tests (content read, create/edit validation +
profile scoping + auth gate, cron skills round-trip).
Two beta-reported dashboard bugs:
1. Models page: 'Use as -> Main model' on an analytics card sends
entry.provider, which falls back to the model's VENDOR prefix
(modelVendor('anthropic/claude-opus-4.6') == 'anthropic') when the
session row has no billing_provider. That persisted
provider: anthropic + default: anthropic/claude-opus-4.6 — a
vendor-prefixed OpenRouter slug on the NATIVE Anthropic provider.
New sessions then 400 against api.anthropic.com and the user reads
it as 'changing models does nothing'. Unknown vendors (moonshotai,
poolside, ...) were worse: a provider that can never resolve
credentials.
Fix: _normalize_main_model_assignment() at the single write
chokepoint — maps non-provider vendor names back to the user's
current aggregator (else openrouter), and runs the model through
normalize_model_for_provider() so the persisted name matches the
target provider's API format. Wired into both /api/model/set and
the profile-scoped _write_profile_model.
2. System page: 'Restore from backup' spawns hermes import with
stdin=DEVNULL, so the CLI's interactive 'Continue? [y/N]' overwrite
prompt hits EOF and auto-aborts whenever a config already exists
(always, when the dashboard is running). Fix: ConfirmDialog in the
dashboard owns the consent, then the endpoint passes --force so the
restore runs non-interactively.
Validated live: dashboard on a temp HERMES_HOME, repro'd both failure
modes pre-fix (vendor-slug write verified via config.yaml + tui
session.create; import 'Aborted.' in action-import.log), then verified
post-fix (normalized writes, modal -> --force -> restored marker file).