Desktop had no way to read or change the shared-metrics opt-ins. The new
profile-scoped RPCs read/write telemetry.shared_metrics.{enabled,send} in the
focused profile's config.yaml through the one config writer, keep the setup
wizard's invariant (send is forced off unless collection is on) and reconcile
the consent windows on every change via the wizard's own helper. first_run
answers also record the desktop setup-completed metric (lazy import until the
events module lands).
- Docs: a Decision-data metrics section mapping each metric to the product question it answers;
skill-load and snapshot paragraphs describe the new public-name and install-age fields.
- setup consent copy names the new data classes (session length, token totals, command and
catalog names, bucketed setup counts).
- The real-CLI smoke now asserts the session summary, token sums, TTFT bucket and milestones in
both the SQLite store and the exported, schema-validated package.
- A profile with no sessions yet is a brand-new install (lt_1h), not unknown: the first task's
milestone and the first snapshot fire before state.db has a session row.
- Two invariant tests: session summaries + once-per-install milestones; token sums per model and
auxiliary task with private task names collapsed.
Adds the contract, store and runtime side of the decision-data extension to shared metrics v3:
per-session summaries, once-per-install milestones, token sums per model/provider (primary and
auxiliary by task), time-to-first-token on model routes, bundled skill names on skill loads, and
install age / main provider / terminal backend / messaging platform count / language on the
daily snapshot. Process-level facts (setup, slash commands, compression, model switches,
fallbacks, extension installs) get a call-site API in shared_metrics_events. Every new
dimension is a closed enum, a bucket or a public catalog name.
The fleet rollups could say that tasks and tools failed, but not why, on which
model, on which messaging platform, or which features installs actually use.
- model_route rows gain call_role, outcome and error_class (the classifier's
FailoverReason for the last failed attempt; a success row keeps the error it
recovered from).
- task_run rows gain platform (built-in gateway platform, plugin, or none);
task_run.finished gains failure_class from a closed set.
- new hermes.tool.usage.count: tool_name (only names from the static
toolsets.TOOLSETS snapshot; mcp/plugin otherwise), outcome, error_class.
- new hermes.install.snapshot, latched to once per rolling 24h like
client.active: memory provider plus bucketed counts of MCP servers,
plugins, skills, cron jobs and profiles; no names.
- package schema v3; v2-shaped pending counters still validate and drain.
- consent copy and developer docs list the new categories; the relay smoke
asserts v3 shapes and disables title generation (its third model request
broke the smoke's request-count check on main).
* feat(vercel): start fresh sandboxes from a managed image instead of the deprecated runtime
Vercel deprecated Sandbox runtimes (node24/node22/python3.13) in Aug 2026 in favour of
images, and rejects runtime+image together and runtime with a snapshot source. New
terminal.vercel_image (default vercel/sandbox/universal:latest, Node 24 + Python 3.14)
picks the image for fresh sandboxes; a pinned terminal.vercel_runtime still works, wins
over the image and logs a deprecation warning; snapshot restores send neither.
Setup wizard prompts for the image, dashboard exposes both keys, status/config show the
effective choice, TERMINAL_VERCEL_IMAGE bridges config to the tool like its siblings.
* feat(config): migration 49 drops the seeded node24 Vercel runtime pin
Every pre-49 config.yaml carries terminal.vercel_runtime: node24 (the template default) and
the setup wizard mirrored it into .env as TERMINAL_VERCEL_RUNTIME. Both are the default
copied, not a choice, so the migration drops them and fresh sandboxes follow vercel_image;
node22 / python3.13 pins are the user's and survive. Persisted sandboxes are unaffected:
a snapshot restore never sends a runtime or an image.
* fix(modal): keep persistent-sandbox snapshots past the SDK's 30-day TTL
modal>=1.5 gives Sandbox.snapshot_filesystem() a default ttl of 30 days, so an idle
persistent Modal sandbox silently lost its filesystem and restarted from the base image.
Pass ttl=None (retain until deleted) and bump the modal extra from 1.3.4 (no ttl
parameter; legacy RPC) to 1.5.5 so the kwarg exists on every install.
* fix(modal): drop the dead modal.Mount credential-mount block
modal.Mount left the public API in modal 1.0, so _modal.Mount.from_local_file raised
AttributeError into the surrounding except on every sandbox start and the block never
mounted anything. The FileSyncManager created right after already uploads the same
credential, skills and cache files (iter_sync_files), so delete the duplicate; the test
fake stops exporting a Mount the real SDK does not have.
* chore: retrigger CI (zero-job dispatch failure, auto-heal)
Issue #63125 asks for the cached vs uncached read rate at a glance in
the model picker. The backend already ships it: _apply_pricing fills
ModelPricing.cache from input_cache_read, and the CLI picker renders it
as a third column — the catalog menu was the only surface dropping it.
- ModelPrice appends the cached rate (dim, `·$0.16`) next to the
uncached input/output pair; its hover title names it, and the row's
tooltip gains the cached-read line. Uncached read stays the inline
`input` rate, so the cached-vs-uncached comparison reads at a glance.
- The hardcoded "free" label and English-only tooltip move to
shell.modelMenu copy (free / cacheRead / priceTitle) so the row
localizes; fr/de/es are completeness-checked, so all locales that
carry the modelMenu block get the new keys.
- A collapsed `-fast` family now falls back to the fast sibling's price
when the base id is unpriced (the sharing the comment already
claimed — review point on #84831).
Refs #63125
Review follow-ups on #84831:
- Treat null input/output as unknown: render nothing rather than
"null/null" when a provider reports a non-free model without rates
(_apply_pricing ships "" for unknown, but the wire type is
string | null, so guard both).
- Only show the sale tag for a positive discount_percent, so a negative
value can't render as a double-minus.
- Fallback title uses "—" for a missing half instead of "undefined".
- New test: no price span at all when the provider carries no pricing
for the model.
The model.options payload has carried per-model ModelPricing for providers
that support live pricing (Nous Portal included) since the picker requested
it, but the catalog menu never displayed it. Add a compact price label to
each model row: input/output $/Mtok, a green "free" label for free-tier
models, and a sale tag when the portal reports a discounted list price.
Verified against a live payload probe: the nous provider ships 35 pricing
entries (e.g. anthropic/claude-sonnet-5 -> $1.60/$8.00, 20% discount).
The warm_command/release_command hook thread was a bare threading.Thread, so under
the multiplexer resolve_passthrough_value() saw no secret scope, raised
UnscopedSecretError, and the best-effort hook swallowed it at debug level: every
command provider with a non-empty env_passthrough silently never warmed or released.
Bind the thread with ctx_bound (copy_context().run), the same seam the keep-warm
timer already uses. Regression test: the hook thread resolves the bound profile's
passthrough value.
Docs (review minor): the multiplex isolation table now names per-profile slash-command
gating and the fail-closed empty-admin policy for a served profile with no cached config.
The #125530 guard let any load whose TARGET was the launch home through while a
routed override was active. Inside a foreign profile's turn that load ends in
_reapply_terminal_config_bridge(launch), whose read_raw_config()/load_config_readonly()
follow the override, so the ROUTED profile's terminal.cwd was bridged into the shared
TERMINAL_CWD and the launch profile's next turn and cron job ran in the other tenant's
workdir (tests/e2e/core/tenancy/test_two_tenant_gateway.py red on CI). Require both the
target and the active home to be the launch home; the launch profile's own scoped bodies
(override naming the launch home) still load, a routed profile's .env still never does.
tests/agent/test_skip_background_review.py: the fixture pointed HERMES_HOME at a named
profile dir that never existed and built an AIAgent inside it; that only worked because
setup_logging() used run_agent's import-time home. It now logs under the ACTIVE home, and
mkdir_under_hermes_home() refuses to materialize a missing named profile by design
(load_config()'s ensure_hermes_home() and SessionDB refuse the same home, #94590), so the
test creates the profile dir. No assertion changed.
The salvaged test wrote the live process home's .env; the replacement uses two tmp homes (launch loads, routed does not, launch loads again) under set_multiplex_active(True).
policy_for_runner_source(runner, source) lives in the topical module instead of a 50-line mixin helper. Authorization follows the transport (bot-owning) profile the identity already names as authorization_home: a turn routed to profile B through the launch bot is gated by the launch bot's allow_admin_from, and a bot configured only in B is gated by B's. A served profile with no cached config fails closed (floor only). Drops the salvage's "launch gated + routed ungated => fail closed" rule: each profile's own config is authoritative for its own bots.
Same behaviour as the salvaged helpers (stat before, chown as root + chmod after, best-effort) without two module-level helpers; tests pin the chown hand-back and the refused-chown path.
setup_logging(hermes_home=get_hermes_home()) is the whole fix; the FileNotFoundError fallback chain guarded a home that is being served (it exists), and the two extra tests re-tested it. Keep the A->B routing invariant and the no-bare-handler guard.
The multiplex guard in load_hermes_dotenv() skipped the dotenv load whenever
a routed profile-home override was active AND multiplexing was on, even when
the load's target home IS the process launch home. Under the desktop/serve
backend the launch profile's own scoped bodies bind an override naming the
launch home (tui_gateway.model_switch._profile_runtime_scope_tokens(None)),
so its .env never loaded: 'No .env file found. Using system environment
variables.' and launch-profile-only credentials (e.g. an OPENROUTER_API_KEY
backing fallback_providers) were invisible to the process, silently breaking
failover.
Restrict the skip to loads whose target is a FOREIGN (routed) profile home,
using the same override-immune launch-home comparator _reapply_terminal_config_bridge
already uses in this file. The routed profile's .env is still never copied
into the shared os.environ (isolation intent of 1aa62ceb45 preserved).
(cherry picked from commit 0c71a329f48195a5c242d09ca6e92bb1c2842640)
The stdlib rollover renames `agent.log` and opens a fresh one, so the
replacement is owned by whichever process wrote the record that crossed
`maxBytes`. With a rotating handler per profile, that is usually the
long-lived gateway, so a profile's log that a worker uid owned becomes
root-owned — and every later worker open of that path fails EACCES,
permanently, from what should be a routine rotation (#120151).
The failure surfaces as a model-connection error and is parked by
`check_respawn_guard` on a branch with no cooldown, so it never retries
even after ownership is repaired.
Snapshot the file's owner and mode before the rollover and hand them back
to the new file afterwards. Only root may chown to another uid, so for an
unprivileged process this is a no-op — it never changed the owner to begin
with — and on Windows, which has neither chown nor a meaningful uid, the
snapshot reads as "nothing to preserve". Every failure path is best-effort:
a log that cannot be chowned is still a working log.
(cherry picked from commit 379b099807146af67b7d655a4685c22336e05fab)
A Desktop serve backend builds agents for several profiles inside
set_hermes_home_override(); _setup_logging passed run_agent's
import-time _hermes_home freeze, so setup_logging saw a home it already
served, never adopted the profile, and every profile's records landed in
the launch profile's agent.log. Pass the active-scope home instead.
A home that cannot host logs (missing/tombstoned named profile —
mkdir_under_hermes_home refuses to materialize it on purpose) falls back
to the process launch home, then disables file logging with a stderr
notice: logging setup must never take agent construction down.
(cherry picked from commit 76bcb71bae400e764fb0e04c28e610a3d1b5d4ca)
Contract: under multiplex, a load whose target IS the process launch home
must still populate os.environ from the launch home's .env (its scoped bodies
bind an override naming it), while a FOREIGN routed home's .env is still
never copied into the shared process environment.
(cherry picked from commit dfba1487d9e5444da5803c1e186f16caf1b3b956)
run_command_provider forwarded declared env_passthrough keys from os.environ,
which under the multiplexer is the LAUNCH profile's .env. A served profile's
command TTS/STT subprocess (voice-note transcription, speech, warm/release
hooks) got the launch profile's credential and never its own. Resolve each key
with resolve_passthrough_value, as the terminal and code-execution spawns do.
(cherry picked from commit 22a299d5f562e1d174fa5d37590cd46361a1383a)
65ff3ad353 made a bare legacy {pid} record non-adoptable (a live PID alone is not
evidence of the managed router) and moved the other tests in this file onto
_write_current_process_state, but left the racing-callers fake publishing the legacy
shape, so the second caller could never adopt the first's state and main's Run tests
lane went red on every PR rebased past it (main's own CI could not start at the time).
Publish the modern record the real supervisor writes.
Review finding 2: `_or_default` in `get_active_profile_endpoint` answered
"default" for `current` when `get_active_profile_name()` raised — exactly the
value that lets the SPA's `shouldAdoptActiveProfile` retarget a dashboard to
the sticky active profile. A dashboard that cannot name its own home must not
read as the machine dashboard, so the failure fallback for `current` is now
"custom" (the same adoption-refusing answer the function itself gives an
unresolvable home). The empty case keeps `or "default"`, and `active` keeps
"default" on failure: "custom" there would itself trigger adoption of a
non-existent profile when `current == "default"`.
Review finding 1 (docs half): hub actions targeting `default` from the
machine dashboard now take the same scrubbed, HERMES_HOME-pinned environment
as every named-profile action. Kept on purpose (one env contract per hub
target); the user-visible rule — children run with the target profile's own
.env and secret sources, not the dashboard process environment — is now in
web-dashboard.md. The PR body carries the explicit behaviour-change note.
The #120688 fix makes _own_profile_selector name the default home as the
literal "default" selector and _profile_cli_args emit ["-p", "default"]
for it, so every unnamed lifecycle verb spawned from the default home
carries an explicit selector (test_gateway_unnamed_default_home_verbs).
Two existing tests pinned the old bare argv on the shared spawn site
(_restart_gateway_after -> _spawn_gateway_restart -> _gateway_subcommand):
- test_dashboard_admin_endpoints::test_enable_platform_starts_gateway_restart
(POST /api/webhooks/enable auto-restart): expected argv is now
["-p", "default", "gateway", "restart"]; the test's subject (the save
is authoritative, restart_started/pid reported) is unchanged.
- test_web_server::test_telegram_onboarding_apply_reports_restart_failure_after_save
(Telegram onboarding apply auto-restart): same argv update inside the
failing-spawn stub; the test's subject (a failed spawn is reported
after the save, not raised) is unchanged.
`hermes -p default gateway restart` is accepted by hermes_cli/gateway.py:
_apply_profile_override pins HERMES_HOME to the root (skipping the
sticky active_profile), profile_lifecycle and
_guard_named_profile_under_multiplexer both pass "default" through, and
the CLI's own refusal text prescribes exactly that argv as the host
remedy. No other test in tests/ asserts a bare ["gateway", ...] argv
produced by these spawn sites (the remaining hits are mocks, hand-built
argv, or [-2:] suffix checks that still hold).
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.
The salvaged fixture set HERMES_HOME under tmp_path and ran the TestClient lifespan, which
read the checkout manifest and tripped the home-I/O guard; the unknown-profile case folds
into the one invariant test (the non-Nous branch is covered by test_web_unknown_profile_404).
GET /api/model/recommended-default?provider=nous returned before the
route entered the profile scope, so ?profile=<name> got the launch
profile's Nous tier, Portal URL and recommendation caches, and an
unknown profile answered 200 instead of the scope's 404. The Nous
branch now runs under _config_profile_scope like the other providers
and lets the scope's HTTPException through.
Each route's catch-all or fallback swallowed the profile scope's 404: the
GETs answered 200 with default or empty data (local-only targets, static
deliver options, an empty model, relay voice mode, no installed marks) and
POST /api/ops/debug-share answered 500. HTTPException now passes through
ahead of each catch-all, as in the rest of this change; for the hub skills
catalog the pass-through sits in _installed_hub_identifiers, so the search
and sources routes that share it keep their existing 404.
The unknown-profile test gains a row per GET route; the endpoint list in
web-dashboard.md names them.
_profile_scope holds _SKILLS_PROFILE_LOCK for its whole body. The Nous,
xAI and MiniMax savers enter it and then take _oauth_sessions_lock; the
Codex saver took the session lock first and then the scope. Two sign-ins
finishing at once could each hold one lock and wait on the other. Enter
the scope first in the Codex saver too, so there is one lock order.
The dashboard device-code pollers looked the owning profile up by session id
only after the provider answered. A cancel (DELETE) or the 15-minute session
sweep had removed the registry entry by then, so the lookup returned None and
the tokens were saved into the dashboard's launch profile; the xAI and MiniMax
pollers also never re-checked for a cancel, so a cancelled login still saved
and made xai-oauth the default profile's active provider. The Codex path had
the same bug (IA-01).
All three pollers (Nous, MiniMax, xAI) now take the profile from their own
session dict, and MiniMax and xAI check "cancelled" in the same critical
section as the save, like Nous and Codex. The by-id lookup helper is gone.
gateway/status.py::_command_line_belongs_to_profile and
hermes_cli/gateway.py::_matches_current_profile rejected any -p/--profile flag on the
default-home branch, so a default gateway started with an explicit --profile default
(hand-written launchd plist mirroring the named-profile shape) was reported stopped and
could not be matched for stop/restart. Only a flag naming ANOTHER profile disqualifies.
Co-authored-by: Sahilvishnaliya <Sahilvishnaliya@users.noreply.github.com>
Keep the hub-install selector test (argv + HERMES_HOME pin on a pooled named-profile
serve) and the unnamed-verb test; drop the case-variant/ambient change-detectors and
the token-scrub assertion, which no longer holds since host_gateway_child_env() became
the env builder for -p default gateway verbs.
An unnamed gateway lifecycle verb from a dashboard running on the default
home spawned a selector-less child (hermes gateway restart). The child
re-read the sticky active_profile and could restart another profile's
gateway, and it took the unscrubbed env branch so the default profile's
token survived into the child. _own_profile_selector now resolves the
default home to the literal default selector instead of None, so every
lifecycle argv carries -p and the named-target env scrub applies.
Adds tests/hermes_cli/test_gateway_unnamed_default_home_verbs.py covering
the pinned argv, the child home resolution under a sticky active_profile,
and the token scrub.
_profile_cli_args folded "default" into the no-override early return
alongside "current", so a hub action scoped to the default profile
spawned selector-less argv. The child hermes process then resolved the
ambient HERMES_HOME (the launch profile under a pooled -p X serve, or
the sticky active_profile) and installed/toggled into the wrong
profile; it also skipped the named-profile credential scrub.
"default" is a real named target everywhere else (profile list,
_resolve_profile_dir, -p default already emitted for multiplexed
restarts), so only "" and "current" now mean ambient.
fr/de/es overlays are completeness-checked against the en key set, so the
new updates.copyFullLog key must ship there or catalog-completeness fails;
ar/ru carry the neighbouring moreChanges key, so they translate too.
Swap the hand-rolled copied-state button for the shared CopyButton
primitive (haptics, aria-label, error path, unmount cleanup included).
The update overlay previously showed at most 6 commit subjects from the
changelog, with no way to access the full list or copy it for translation.
Changes:
- Increase git log limit from 40 to all commits (remove -n 40)
- Add 'Copy full changelog' button that copies all commits as formatted
text (type(scope): subject - author) to clipboard
- Use parseCommitHeader to reconstruct full convention commit format
including scope and author
- Add i18n keys: en 'Copy full changelog', zh '复制完整更新日志'
Closes#48654
The first-pick bug was the Select's controlled value already being the
Custom sentinel, which the value swap fixes on its own; the Input's
autoFocus handles focus once it mounts, so the rAF refocus effect is not
needed. Extend the regression test to type into the field and to check
that clearing it and blurring returns to the dropdown.