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.
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.
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.
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.
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.
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>
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.
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).
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.
Change-detectors, tautologies, source-reading tests, redundant duplicates,
mock-echo tests and dead/unrunnable tests. Per-test rationale in the lane
ledger (category + reason for every removal).
A fresh dashboard /chat always spawned the TUI in the dashboard process's
launch directory, so from a phone or any browser there was no way to aim
a new session at a specific repository. Amp's runners now serve many
directories and the web composer offers a picker of the runner's projects
and discovered git checkouts; this ports that mechanism onto the surface
Hermes already has: the dashboard is the phone/web front, the host's
projects.db + repo-discovery cache are the served directories.
- GET /api/chat/workspaces: the profile's projects (with folders) and
discovered repos (session-derived + scanned), default_cwd, home;
?scan=1 rescans desktop.repo_scan_roots on the host, so headless
installs (no Desktop to populate the cache) discover repos too.
- /api/pty?cwd=<dir>: validated (existing directory, fail-closed 400
via the PTY error path) and forwarded to the TUI child as HERMES_CWD
(self-spawned gateway cwd) + HERMES_TUI_CWD (explicit cwd on
session.create for the dashboard's in-memory gateway, whose own cwd
is the launch dir). Resumed sessions ignore it.
- ui-tui: session.create carries cwd when HERMES_TUI_CWD is set, so
/new inside a dashboard chat stays in the picked workspace too.
- web: workspace selector in the Chat rail above "New chat" (projects,
repos by recency, Other path…, rescan), remembered per profile in
localStorage; 17 locales.
- docs: web-dashboard.md rail + REST sections.
Live E2E: real `hermes dashboard` under a scratch HOME with two git
repos under desktop.repo_scan_roots -> /api/chat/workspaces?scan=1
lists both; /api/pty?cwd=repo-a -> TUI status bar shows ~/code/repo-a;
/api/pty?cwd=<missing> -> "Working directory does not exist" + close.
Annotated-tag pins (F8): a catalog `sha` recorded as `git rev-parse <tag>` names
the TAG object, while HEAD can only ever be the commit it points at. The scan
trust check compared HEAD against the unpeeled sha (so every tag-pinned entry
lost the reviewed-pin bypass and prompted on caution findings) and the sidecar
recorded the peeled commit, so `update_available` was true forever and every
`update` re-installed. The installer now peels the pin (`<sha>^{commit}`) for
trust, records `pin` on the catalog block only when the checkout satisfies it
(empty for an off-pin `--ref` install), and every at-pin check goes through
`at_catalog_pin(sidecar, entry_sha)` (repin, dashboard payload, TUI rows).
Re-pin consent (F10): `hermes plugins update` on a catalog install replaced the
tree without asking, even when the new pin declared new tools, hooks, Python
dependencies, host capabilities or a Desktop half. `repin_catalog_plugin` now
diffs the installed manifest against the staged clone BEFORE anything moves
(`_install_plugin_core(before_swap=...)`) and, on a widening:
- CLI: prints the delta and asks y/N (non-interactive → not applied, fail
closed); after a changed re-pin it runs the same `_run_capability_consent`
grant path as the git-pull `update`.
- `plugins.manage update` RPC and the dashboard REST route answer
`{ok: false, consent_required: true, delta, delta_lines}` with nothing
changed; a retry with `accept_capabilities: true` applies it. Desktop shows
the delta in its confirm dialog; the web dashboard uses `window.confirm`.
- Gateway contract regenerated (`accept_capabilities` param; `consent_required`,
`delta`, `delta_lines`, `error` result fields).
Catalog audit findings F8 and F10 (low severity, no issue filed).
Plugin enable/disable from the dashboard writes config.yaml only — running
gateway/TUI processes scan plugins once at start and never pick the change
up. The CLI already prints 'Takes effect on next session.' but the dashboard
showed a bare success toast, so a saved toggle was indistinguishable from a
failed one (field report in #54941).
- enable/disable endpoints annotate success responses with
restart_required (true when state changed, false on no-op), derived at
the endpoint layer so it composes with #39642's canonical-key fix
- PluginsPage toast tells the user the toggle takes effect for new
sessions and points at the existing Restart Gateway action
- new pluginsPage.toggleTakesEffectNextSession i18n key in all 16 locales
(Translations type enforces parity)
Scope note: full hot-reload stays with #54941/#48918 and the force-reload
fixes (#60037/#60051/#60267); this only removes the silent-no-op UX.
The destructive-route file covered 3 of the 12 gated routes and faked the
gate itself (`monkeypatch` on `is_multiplex_active`), which left the boot
wiring the whole 400 branch rests on untested.
* All 12 destructive/privileged routes are parametrized through the refusal
case, and the refusal now asserts the ABSENCE of the effect (no spawn, no
session store opened, no file touched) instead of the detail wording.
* Multi-profile hosting is armed for real: `activate_multi_profile_hosting_eagerly()`
(the boot probe) for the refusal cases, plus a test that a cross-profile
request is itself the lazy activation. An autouse fixture enters each test
single-profile and restores the process-global flag and frozen launch-env
snapshot, so the one-way flip cannot leak into the single-profile-host test.
* New coverage for the scoping that had none: PUT /api/dashboard/plugin-providers,
POST /api/local-models/quickstart, memory-provider readiness judged inside the
write scope, and one invariant each for status/actions/config_env/memory_providers.
* web: ?profile= on /api/credentials, the serving-profile fallback for
getManagementProfile/fetchJSON, authedFetch going through withManagementProfile,
and initialProfileScope's URL > bootstrap > serving precedence.
Every new assertion was proven red against the PR's own previous head
(ac3d794) or, for the scoping introduced there, against the pre-PR base.
Round 2 of the REST profile-scope pass. A call-graph audit (AST over every
router plus their imported helpers, following functools.partial bindings,
closures and callbacks handed to to_thread/_spawn_job) found the writes a
signature grep cannot see, and the SPA/Desktop callers that never named a
profile at all.
Handlers that reached a config write through indirection:
* PUT /api/dashboard/plugin-providers wrote memory.provider + context.engine
through functools.partial(_write_config_value, ...) — the SAME key
PUT /api/memory/provider scopes — into the launch profile's config.yaml.
* POST /api/local-models/quickstart is `activate` plus a download; only
`activate` had been scoped, so _set_runtime_enabled/_assign_default landed
in the launch profile.
* POST /api/local-models/runtime/install regenerates launch presets under
get_hermes_home().
* GET /api/model/recommended-default lazily PERSISTS discovered custom-provider
models via build_models_payload -> _save_discovered_models_to_config.
Policy gaps:
* POST /api/ops/hooks is now gated like DELETE: writing an arbitrary command
into `hooks:` and, with approve, into the consent allowlist is strictly more
privileged than removing one.
* POST /api/sessions/prune (non-dry-run), DELETE /api/sessions/empty and
POST /api/sessions/bulk-delete join the same destructive class.
* PUT /api/memory/provider ran its readiness check OUTSIDE the scope, so it
judged the launch profile and could write a broken setting into another.
Callers:
* /api/credentials was missing from the SPA's PROFILE_SCOPED_PREFIXES, so the
credential-pool delete button 400'd unconditionally; so were
/api/dashboard/plugin-providers, /api/local-models and the model route above.
* The management scope was empty until the switcher resolved, so every
destructive route 400'd in working UI on any host with a second profile
directory. The backend now injects the profile it itself serves
(__HERMES_DASHBOARD_PROFILE__) — and only when that name provably resolves
back to its own home, so the fallback can never retarget another profile.
* authedFetch went around the scope entirely; the Desktop /api/ops callers
(doctor, security-audit, backup, debug-share) sent no profile while Electron
pins that family to the shared primary backend.
One backend process now serves every profile, but a handful of REST handlers
resolved get_hermes_home() directly and ignored the profile the request named:
POST /api/memory/reset wiped the LAUNCH profile's MEMORY.md/USER.md no matter
which profile the switcher pointed at, and the same held for the curator state
file, webhook subscriptions, shell hooks + their consent allowlist, checkpoints,
backups/imports, the credential pool and the dashboard's own theme/font/plugin
preferences. Per-profile backend processes used to mask it.
* Every one of those handlers now takes `?profile=` and runs inside the
existing `_config_profile_scope` / `_profile_scope` seam; action-spawning
routes pass `-p <profile>` to the child via `_profile_cli_args`.
* Destructive routes (memory reset, webhook delete, hook delete, checkpoint
prune, ops import/import-upload, credential-pool delete, curator run) refuse
an UNNAMED profile with 400 while the process hosts more than one profile
(`is_multiplex_active()`, decided at boot). A genuinely single-profile host
keeps the old meaning, so plain `hermes serve` + curl is unchanged.
* Callers updated in the same change: the Desktop main-process routing table
(`LOCAL_PRIMARY_SCOPED_ROUTES` + the /api/webhooks and /api/ops families) so
a fixed handler reaches the shared backend WITH the profile query instead of
a per-profile backend that no longer exists, and the dashboard SPA's
`PROFILE_SCOPED_PREFIXES`.
Branch semantics kept where main and PM disagree: update_cmd_deps.py,
constraints-termux.txt, the Electron update-api-check module and the
post-swap hand-off test stay deleted; the pending-fleet-restart catch-up
and the local_runtime tag/download ladder stay retired (PM owns engines).
Ported from main onto the branch's shape: profile_scoped_chore for the
auto-archive and plugin-update housekeeping chores, the local-runtime
cross-process boot lock and residency cap, the checkpoint tmp_pack sweep,
the cua daemon-liveness status probe, the remote-served Desktop update
flag (posix.sh / windows.ps1), sign-in for env-pinned remote gateways
(urlDisabled on RemoteSetupFields), the uvloop extra split (uvicorn
without [standard]), and the umask-scoping spawn test.
uv.lock regenerated with pm.build_env --lock-only; new utf-8 reads from
main switched to utf-8-sig (check-windows-footguns).
Keep the duplicate-tab case (a cloned sessionStorage token already claimed
by a live document mints its own) and the reload case (a released token is
reused); the once-per-document lock accounting, explicit rotation and the
no-Web-Locks fallback are implementation details of the same module.
Two tabs on the dashboard /chat collapsed into one session: ChatPage kept its ?attach= token in localStorage, which every tab on the origin shares, and the PTY registry maps one token to exactly one PTY — so the second tab closed the first one with 4409 and both showed one conversation/model.
The token now lives per tab in sessionStorage and is claimed with a Web Lock, so a second tab (including a Chrome "Duplicate tab", which clones sessionStorage) mints its own instead of sharing one; a reload finds its own token free again and still reattaches to the living PTY.
PtySessionRegistry.attach_or_spawn also spanned awaits, so two connections racing one token both forked a PTY: the token then mapped to whichever registered last while the other tab's live session fell out of the registry (never reaped, invisible to close_all), and a reattach landed on the other tab's terminal. The get-or-spawn decision is serialized now.
Issue #115304.
One test walks a word through all three Android/Gboard double-send paths
(duplicate compositionend, compositionend trailing onData, onData echo /
strict extension) and asserts a single delivery; the other proves real
repeats outside the echo window and unrelated text still go through.
`hermes dashboard` from a named profile re-execs as `-p default dashboard --open-profile P`,
but `--open-profile` only reached the one URL `_maybe_open_browser()` opened. A `/chat?resume=<id>`
deep link without `?profile=` initialised `ProfileProvider` with the empty (launch) scope, so the
embedded chat ran in the default profile — no MCP servers, wrong model/skills — while the switcher
showed P. No error, no indication.
The server now records `initial_profile` on `app.state` and injects it into the SPA bootstrap as
`window.__HERMES_INITIAL_PROFILE__` (escaped for the script context); the Vite dev proxy forwards
it. `ProfileProvider` uses it only when the URL carries no `profile` param: an explicit
`?profile=` (including an explicit empty one) still wins, and the sticky-active-profile alignment
no longer replaces a launch-preselected scope.
Closes#73085. Salvage of #73260 (cherry-pick of 99e341cdef0 resolved onto the split
web_server_dashboard.py; start_server assertion dropped as a change-detector).
#114309 expects the dashboard, like `hermes cron status`, to say "scheduler last
ticked X hours ago" when a stopped ticker leaves next runs stranded in the past.
The Cron page only had the per-row `Overdue since` label and no way to date the
outage: GET /api/cron/jobs carried nothing about the ticker.
Every job the dashboard cron endpoints return now carries
`scheduler_heartbeat_age_s` — its own profile's ticker heartbeat age read inside
the same store scope as the job list (None when it cannot be dated) — and the Cron
page renders "Scheduler last ticked 7h ago — jobs that came due since then have not
fired" above the list when the oldest heartbeat among jobs expected to fire is
past the CLI's STALE_AFTER threshold (three missed 60s iterations plus slack).
Paused, disabled and completed jobs never raise the banner, matching the overdue
label's rule. The field is additive, so the response shape and every existing
consumer are unchanged.
Tests: one router test lists two profiles with one stale heartbeat file and checks
each job reports its own age (None for the profile without a heartbeat); one vitest
pins the stale-age selection and the relative label.