_scheduleMessageVirtualizedRender now takes (force, request), so the two
structural probes in test_issue4346_vscroll_footer_jitter.py matched nothing.
renderMessages() calls _resetMessageVirtualMeasurementBurst(), which node
harnesses that extract renderMessages() alone (test_compaction_current_summary_fallback.py)
don't define; guard it with typeof like the file's other optional helpers.
Review follow-up on #7720 (Greptile P2): the external Notes drawer reads the
same profile-bound MCP inventory as the MCP panel but dropped the
``runtime_scope`` label, so a profile whose runtime scope could not be
confirmed rendered its note sources as inactive/configured with no
explanation. ``/api/notes/sources`` now returns ``runtime_scope`` and the
drawer shows the existing ``mcp_runtime_scope_unavailable`` notice (same key,
no new locale strings) when it is ``unavailable``.
Tests: the skewed-routing unit test now also asserts the notes response
carries ``runtime_scope: "unavailable"`` with its sources inactive.
docs/troubleshooting.md mentions the drawer next to the panel.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Two profiles configuring a same-named MCP server shared one connection and
the MCP settings panel showed another profile's runtime ("Active, N tools"
next to an empty inventory); /reload-mcp stopped every profile's servers and
rediscovered from the process-default config.
- api.profiles._set_hermes_home() is the single writer of the process-profile
home: it records get_process_profile_home() and hands it to Hermes Agent's
hermes_constants.pin_process_hermes_home when available, at startup and on
switch_profile(process_wide=True), so the per-turn HERMES_HOME mirror no
longer makes every turn look like the process profile (bare-name,
cross-profile MCP keys) and the anchor cannot drift from HERMES_HOME.
- Run get_mcp_status/registry reads and /reload-mcp inside
api.mcp_runtime.mcp_runtime_scope(): request profile's Hermes-home override
and secret scope (root included), no os.environ mutation, restored on exit.
- One ledger predicate, ledger_key_serves_view() (owned via
_server_scope_keys/key scope, or adopted via _server_tool_scopes), is shared
by status, inventory and the reload summary. The Agent's launch-profile view
is process-wide and name-indexed, so filter_runtime_status_to_view()
rebuilds connected/tools/status/error from the entries serving the profile:
the root profile never shows a routed profile's connection (nor lets its own
parked failed-spawn task borrow a routed profile's live same-named one).
- Inventory lists only tools in the profile's own registry slot for servers it
configures; responses carry runtime_scope.
- Fail closed when the agent's routing does not match the resolved profile:
hide runtime data (runtime_scope "unavailable", shown as a notice in the MCP
panel, 14 locales) and refuse /reload-mcp.
- /reload-mcp shuts down only the profile's own ledger names (parked
included), drops its own connect cooldown/error entries so a failed spawn is
retried (the wildcard cleared every cooldown; a scoped shutdown only clears
the live keys it tears down), rediscovers under the profile's home and
counts live connections only. The bare wildcard remains for agents without
profile-scoped MCP.
- Signature introspection is centralized (accepts_keywords) and falls back to
the bare call instead of blanking every status; ledger key helpers resolve
to the Agent's tools.mcp_tool_scope when present; tools.mcp_tool is
resolved by full name so stubbed agents still work.
- ARCHITECTURE.md §4.10 and docs/troubleshooting.md document the boundary.
- Tests: agent-free contract (models adoption, parked tasks, cooldowns and the
process-wide root view) and real-Agent integration (fake stdio MCP server,
skips without hermes-agent + mcp SDK).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A pending approval/clarify prompt re-surfaces on every 1.5s poll tick, and
with permission still 'default' (second browser/device) each tick issued a
non-gesture Notification.requestPermission() and toasted 'notifications
denied'. Auto requests are now made once per page; the explicit Send-test
path still always asks. Release-gate hardening on top of #7493.
Each approval/clarify fallback poller captures its own interval handle; a 409
(or finally) from a request issued by a replaced poller no longer stops or
clears the in-flight guard of the poller that superseded it.
- /api/git-info falls back to state.db metadata for sessions without a WebUI
sidecar (CLI, delegated subagents), mirroring /api/list, instead of 404ing.
- Approval and clarify fallback pollers stop on session_profile_mismatch 409
instead of re-requesting every 1.5s/3s; live cards stay standing.
- Drop <link rel=preload> for pwa-startup.js: the preload scanner resolves it
before the dynamic <base href> is written, so on /session/<id> it fetches
/session/static/... while the script loads /static/..., a wasted duplicate
request plus a 'preloaded but not used' warning on every load.
Forks and other child sessions keep their own project_id, so a fork moved to
No project stays Unassigned. A per-render memoized, cycle-safe resolver is
shared by partitioning, reference scoping and the Unassigned chip.
Editing a cron job with the model picker at "Default" sent provider: null
unconditionally, wiping provider-only pins (provider set, no model — the
pattern for self-hosted LLM endpoints). The combined picker cannot express
that state, so such jobs always rendered as "Default" and were silently
re-resolved to the bare global default on any save, breaking dispatch.
#4030 deliberately made clearing the picker mean "no overrides"; that
contract is preserved for explicit clears and model+provider jobs, while
provider-only pins survive saves where the picker was never touched.
- _cronProviderForClear(prevDetail, pickerTouched) decides the provider
submitted on clear: touched picker -> null; untouched + no model on the
job -> prev.provider.
- _cronModelPickerTouched is set by a change listener added when the picker
populates, and reset at openCronEdit/openCronCreate/duplicateCurrentCron.
- Tests: delegation + flag lifecycle locked in, and the helper driven
through node for provider-only / model-pinned / no-override / no-snapshot
inputs (6 passed).
Refs: #4030
The chip check now reuses the ancestor map built by _partitionSidebarSessionRows,
so a child whose project parent is only a hidden reference row is not counted
as unassigned. Document project inheritance for subagent sessions in README.
The replay-authority predicate accepted any non-empty assistant message,
but a new turn copies the full transcript into INFLIGHT.messages. In an
established conversation that content is the previous turn's reply, so
`canSeed` came back true with both stale cursor halves preserved and the
server kept skipping the range that rebuilds the body.
Message-based evidence now counts only from a current `_live` assistant
positioned after the latest user boundary; a historical assistant row
fails closed to floor 0 and an empty event id.
Refs #7640 (blocker on #7651)
Subagent child sessions come from state.db and never carry a project_id,
so the project filter dropped them. Resolve a child's project from its
nearest ancestor for the project filter, reference scoping and the
Unassigned chip.
#7708 re-gate (9/22 23:47), both findings:
**Finding 1 (SILENT)**: A bare path that contains a backtick
(MEDIA:/tmp/report`final.png) is truncated at the first backtick,
so the server stops authorizing it. The round-1 fix traded one bug
for another: adding the backtick to the bare-token exclusion set
dropped the inline-code closing backtick (good) but truncated any
bare path with a backtick in the filename (bad — POSIX and Windows
both allow backticks).
**Finding 2 (SILENT)**: TTS now reads filename fragments aloud
(MEDIA:/tmp/report(1).png -> 'a file).png') because the
parenthesis-exclusion was added to a site that should not need it.
Inline code is already stripped at the top of the TTS flattener.
**Fix shape** (maintainer-suggested two-pass scan):
1. Pre-pass: `MEDIA:([^`\s]+)` -> MEDIA:$1 — strips the
inline-code backticks off the wrapped form so the bare class
below sees a plain MEDIA:path.
2. Bare class: MEDIA:([^\s\)\]]) (original, backtick
removed from the exclusion set) — captures the full filename
even when the path itself contains a backtick.
3. TTS: restore the original MEDIA:[^\s]+ (no paren/bracket
exclusion either). Inline code is stripped at the top of the
flattener so the closing backtick of an inline-code wrapper has
already been eaten by the time the TTS regex scans the text.
Applied to all 7 capture sites:
- api/routes.py:20453 (_MEDIA_TOKEN_RE, findall site 20513)
- api/media_snapshots.py:507 (media_re, findall site 533)
- static/ui.js:7695 (linkifier, replace site)
- static/ui.js:9142 (TTS flattener — separate shape)
- static/messages.js:4848 (chunk finalize, exec)
- static/messages.js:4896 (combined walk, exec loop)
- static/messages.js:4922 (partial tail, exec)
**Tests** (24 total, all green):
- 11 source-shape guards: each JS/PY site must carry the bare-token
class literal AND the wrapped-form pre-pass literal
- 4 behaviour tests against the live source: wrapped form strips
closing backtick, bare form captures plain path, bare form with
backtick in filename captures the full path (the new regression),
trailing paren is still excluded
- 2 round-1 regression guards documenting the trade-off that drove
the two-pass compose
- 1 composed two-pass test (pre-pass + bare class on 4 inputs)
- 2 node --check syntax guards
- 1 site-count guard: 5 JS + 2 PY = 7 capture sites, all guarded
**Verification**:
- pytest tests/test_issue7359_media_backtick_strip.py -v -> 24/24 pass
- python3 scripts/ruff_lint.py --diff origin/master -> 'no new
violations on added/modified lines. OK.'
- node --check on the two modified JS files -> both OK
Closes#7622.
Round-3 follow-up to the maintainer review at
https://github.com/nesquena/hermes-webui/pull/7730#issuecomment-5786493511.
The round-2 'primary === "en"' heuristic overrode users who had
genuinely picked English: when the server-stored 'language' was 'en'
the resolver treated it as the schema default and skipped it, falling
through to localStorage / the browser hint. This round addresses all
three maintainer findings:
1. BRICK — server stops injecting the schema default. Drop
'"language": "en"' from api/config.py:_SETTINGS_DEFAULTS so a
fresh install returns settings['language'] == None. Existing
settings.json files that already carry 'language: "en"' still
report 'en' (the field is just absent from the defaults dict; the
per-file load path is unchanged). 'language' is added back to
_SETTINGS_ALLOWED_KEYS so the Settings modal save path still
validates and persists explicit user picks (the BCP-47 regex is
unchanged).
2. CORE — browser-language detection is now in one guarded helper.
static/i18n.js exposes _detectBrowserLanguageHint(), used by
loadLocale(), boot.js, and panels.js. A throwing navigator
accessor in some embedded webviews can no longer abort boot or
settings hydration before the model/provider/plugin/extension
sections are populated.
3. CI RED — restore the test shape. boot.js and panels.js call
resolvePreferredLocale(s.language, localStorage.getItem('hermes-lang'),
_detectBrowserLanguageHint()) inline so
tests/test_language_precedence.py::test_boot_and_settings_panel_use_shared_locale_precedence
matches the expected argument list. The TestComposedResolverPrecedence
tests in tests/test_issue7622_initial_locale_from_browser.py are
re-pinned to the round-3 contract (server primary is None for a
fresh install; an explicit 'en' primary from a user-saved file
beats a non-English browser hint).
Tests (22/22 i18n-related pass; 833/833 settings+i18n+locale pass; ruff
forward-gate --diff origin/master is clean):
- test_language_precedence.py: 8/8 (added guarded-helper, server-default,
composed-resolver, and cross-file first-visit tests)
- test_issue7622_initial_locale_from_browser.py: 14/14 (re-pinned to
round-3 contract; added explicit-saved-English regression test)
- test_login_locale.py and friends: unchanged behaviour for the
save/load round trip
Closes#7622.
The renderer's `loadLocale()` was strictly a localStorage-or-English
fallback — non-English speakers had to dig into Settings after every
fresh install. This change consults `navigator.languages[0]` (or
`navigator.language` when the ordered list is missing) ONLY when no
stored preference exists, so:
- first visit on a zh-CN browser → UI lands on `zh` immediately
- first visit on an en-US browser → still `en` (regression guard)
- first visit on a kl-GL browser (unsupported) → falls back to `en`
- any second visit (or after the user picked a language in
Settings) → the stored value wins, the browser hint is never
consulted again for that browser profile
Tests (8/8 pass): a node driver loads the real `static/i18n.js`,
mocks `localStorage` and `navigator`, and asserts the language
that `loadLocale()` ultimately passes to `setLocale(lang)`. The
test covers the four core happy/sad paths plus two pinning tests for
the 'stored wins' contract.
`resolveLocale()` already strips the region subtag (zh-CN → zh,
en-US → en, fr-FR → fr), so the `navigator.language` value is
reduced to a key in `LOCALES` without any extra work in this PR.
The fix is strictly additive: `loadLocale()`'s precedence chain
becomes (navigator hint) → (stored) → 'en' on first visit, and
remains (stored) → 'en' on every subsequent visit.
Gate follow-up for #7710. Three front-end handlers treated EVERY 409 as a
domain-specific stale/expired signal, so the new session_profile_mismatch
response would have:
- messages.js: hidden a LIVE clarify card and mislabelled it expired
- ui.js: retired a LIVE compression-recovery card with a false note
- commands.js: rendered a false 'Compression failed' error state
Each now routes through _sessionProfileMismatchFromError() first and leaves
the card standing. Also captures the status in the import_cli test mock so a
wrong status can no longer pass (the mock swallowed it into *_).
Gate follow-up for #7056. Two defects in the conditional password form:
1. The {{LOGIN_INVALID_PW}} / {{LOGIN_CONN_FAILED}} .replace() calls were
deleted with the moved block, but those placeholders live on the <form>
tag which ALWAYS renders. login.js reads data-invalid-pw back, and the
literal is non-empty so its || fallback never fired: a wrong password
displayed the raw text {{LOGIN_INVALID_PW}} in all 14 locales, even with
password auth ENABLED. Proven by a clean-master control (test_login_locale
3/3 on master, 2/3 on the PR).
2. is_password_auth_enabled() also gated the PASSKEY button, but passkeys are
an independent auth method (auth.py is_auth_enabled, and settings expose
passwordless_enabled = passkeys AND not password). A passwordless-passkey
instance lost its only login control. login.js additionally bailed at
'if (!form || !input) return' before ever wiring the passkey handler.
## Thinking Path
When an assistant emits `` `MEDIA:/path` `` wrapped in inline-code markdown, the
shared character class ``[^\s\)\]]+`` across all seven MEDIA capture sites
greedy-matched the closing backtick into the path. The resulting
``/api/media?path=...%60`` URL 404'd on the legitimate file (issue #7359).
The same drift between frontend linkification and backend allowlist means
even if the URL is constructed with the trailing backtick, the session
allowlist rejects the backtick-polluted path — the user gets a broken
download and a 404 body saved as ``.json``.
Verified the issue's repro: 6 sites share ``[^\s\)\]]+`` (the
preview-flattener at ui.js:9142 is ``[^\s]+`` and is a 7th site, also
covered). One additional site, ``messages.js:4896`` (combined walk),
was missing from the original 6-site list in the issue body — the
suggested fix shape applies to all seven.
The fix is a one-character-set widening: add `` ` `` to the excluded
class on every site so the capture stops at the markdown delimiter
instead of including it.
## What Changed
All seven sites: ``[^\s\)\]]+`` → ``[^\s\)\]\`]+`` (JS) and the
Python equivalent ``MEDIA:([^\s\)\]\`]+)``:
1. ``static/ui.js:7695`` — linkifier regex (the front-door capture).
2. ``static/ui.js:9142`` — preview-text flattener (also drops ``)`` and
``]`` for symmetric handling; was ``[^\s]+`` which would still pull
the backtick into the preview).
3. ``static/messages.js:4848`` — streaming chunk finalize.
4. ``static/messages.js:4896`` — streaming combined walk (the
issue-missing 7th site).
5. ``static/messages.js:4922`` — streaming partial tail.
6. ``api/routes.py:20453`` — ``_MEDIA_TOKEN_RE`` (the session allowlist).
7. ``api/media_snapshots.py:507`` — ``media_re`` (settle-time snapshot
capture).
## Why It Matters
A user clicking an inline-code-wrapped MEDIA token gets a 404 body saved
as ``.json`` instead of the file they asked for. On Android Chrome (and
desktop Chrome), the 404 silently downloads as ``skills.zip.json`` with
no UI signal that the original was missing. The backend allowlist and
the frontend linkifier must agree, so the fix widens the character
class on both sides.
## Verification
- ``tests/test_issue7359_media_backtick_strip.py``: 17 passed (new).
- 7 site-shape assertions (parametrized over the seven sites).
- ``_MEDIA_TOKEN_RE`` behaviour: strips trailing backtick from inline-code
payload, preserves bare-token capture, preserves trailing-``)`` exclusion.
- ``media_re`` behaviour: same three.
- pre-fix class round-trip: ``\`MEDIA:/a.zip\``` → ``["/a.zip\`"]``
(the reported bug); post-fix → ``["/a.zip"]``.
- ``node --check`` on the two modified JS files.
- ``pytest tests/ -k 'media and not 7359'``: 291 passed, 1 skipped,
0 failed — no existing media test regresses.
- ``ruff check tests/test_issue7359_media_backtick_strip.py``: clean.
- ``scripts/ruff_lint.py --diff origin/master`` curated gate: 0 new
findings on added/modified lines.
## Contract Change
No API contract change. UI rendering may now display
``somefile.zip`` (a file) where it previously displayed
``somefile.zip\``` (literal backtick in the preview); both already
displayed correctly in the bare-token case, and the inline-code path
was the broken one.
## Risks
- Sites that depended on the backtick being included in the path would
regress, but no consumer in this repo treats a trailing `` ` `` as
part of a MEDIA path — the original six-site fix shape was
maintainer-recommended and the 7th site matches the same pattern.
- The preview flattener at ``ui.js:9142`` previously used a wider class
(``[^\s]+`` only, no ``)``/``]``) so it would have matched a path
containing a literal ``)``. The fix tightens that to match the
other six sites; if anyone relied on the looser class the rendering
would now use ``a file`` for the surrounding paragraph. Verified no
test in the suite exercises a MEDIA token containing ``)``.
## Model Used
Hermes Agent (step-5-preview) with pytest, ruff, and node. Tests are
executed against the live compiled regexes (for the two Python sites)
and against the literal source text (for the five JS sites), so a
revert on any of the seven sites surfaces as a source-shape failure.
## Related
- #7359 — original issue (KimSpecht, 2026-08-29) with the repro and
six-site list.
- #7334 — pre-existing PR (angelusbr, 2026-08-27) that covers three
sites; this PR supersedes it by covering all seven and following the
maintainer-recommended shape from the 9/20 comment.
- Maintainer's 9/20 comment on #7359 named #7334 as "the correct
existing implementation lane"; if #7334 lands first, this PR is
closeable as duplicate and the maintainer's ``sprint-candidate``
label can move. Credit goes to angelusbr for the early character-class
idea regardless of which PR merges.
## Thinking Path
When a tab is hidden, Hermes WebUI replaces the persistent
per-session SSE connection with a lightweight ``GET /api/session/status``
poll every six seconds. If the tracked session has been deleted
or no longer exists in the active state directory, the endpoint
returns ``404`` — but the client previously converted every non-2xx
response to ``null`` and kept the interval alive forever. This
produces an invisible stream of permanent 404 requests from stale
background tabs (one tab generates ~10 requests per minute when
browser timer throttling allows it; multiple stale tabs multiply
the noise).
The fix narrows the stop condition to the truly terminal responses
(``404 Not Found`` and ``410 Gone``) and calls
``_stopHiddenActiveStreamPoll()`` so the interval and the bound
``_sessionStreamHiddenPollSid`` are cleared together. Transient
failures (5xx, rate-limit, network error) keep polling because they
do not necessarily mean the session is gone; a later tick may catch
a server-initiated turn starting while the tab remains hidden.
## What Changed
``static/messages.js`` — ``_startHiddenActiveStreamPoll()``'s fetch
chain now branches on ``r.status`` before the ``r.ok ? r.json() : null``
fallback:
```js
fetch(...)
.then(r => {
// #7299: 404 Not Found / 410 Gone are TERMINAL for this
// session-owned poll. ... only the missing
// session itself is terminal.
if ((r.status === 404 || r.status === 410) && _sessionStreamHiddenPollSid === sid) {
_stopHiddenActiveStreamPoll();
return null;
}
return r.ok ? r.json() : null;
})
.then(d => { ... });
```
The pre-existing ``r.ok ? r.json() : null`` fallback is preserved
for any non-2xx, non-terminal response, so a 5xx / rate-limit /
network error still produces ``null`` and the next tick re-fires.
A regression that broadened the stop path to all non-2xx would
re-introduce the original bug in the opposite direction (the poll
would die on a single 503 and never recover).
## Why It Matters
A stale background tab with a deleted session could hold an
interval running for hours, firing a 404 every six seconds. The
user sees nothing because the tab is hidden; the server sees
inbound requests that are guaranteed to fail; the request log
fills with noise that an operator has to triage. The fix is
mechanical — the server already returns the right status code, the
client just needs to honor it.
## Verification
- ``tests/test_issue7299_hidden_poll_stops_on_404.py`` (new, 5 tests):
- **404 stops the poll** — source-shape check on the response
handler; a silent reversion to the pre-fix
"convert all non-2xx to null and keep polling" form fails
the suite.
- **410 stops the poll** — same shape, separate code branch
so a future refactor cannot satisfy the 404 check with a
single OR expression.
- **bound sid guards the stop** — a stale fetch's response
landing after a session switch must not tear down the *new*
session's poll.
- **5xx / rate-limit / network errors keep polling** — the
``r.ok ? r.json() : null`` fallback must remain.
- **6000ms interval + immediate-fire preserved** — the fix is
in the response handling, not the cadence.
- ``tests/test_hidden_tab_server_initiated_turn.py`` — pre-existing
tests for the hidden-tab poll (8 tests). The 4 that use
``MESSAGES_JS[start:start + N]`` slicing had their window widened
(2400/3200 → 8000) to cover the new 404/410 branch.
- 33 tests pass across the related session/stream/hidden files
(``test_hidden_tab_server_initiated_turn``,
``test_issue7299_hidden_poll_stops_on_404``,
``test_issue3718_live_models_custom_probe``,
``test_issue6419_pending_user_reconnect_order``,
``test_session_search_bfcache_822``) — no pre-existing test
is broken.
## Contract Change
None. The server already returned the right status code
(``404`` for a missing session); the fix is purely about the
client honoring it. No new public API, no breaking change
to the SSE lifecycle, no change to the visible-tab path.
## Risks
- A regression that broadens the stop path to all non-2xx would
re-introduce the original bug in the opposite direction (the
poll would die on a single 503 and never recover). The new
``test_hidden_poll_preserves_transient_failure_retry`` pins
this contract.
- The 404/410 stop path checks the bound
``_sessionStreamHiddenPollSid === sid`` before stopping, so a
stale fetch's response landing after a session switch cannot
tear down the *new* session's poll. The new
``test_hidden_poll_404_stop_guards_on_bound_sid`` pins this
contract.
## Related
- #7299 — original issue with the wire evidence and the
expected-vs-actual contract.
The approval card's Skip-all button drew its icon twice: once from the
.approval-btn-icon span in static/index.html and again from the leading
glyph baked into the approval_skip_all translation in 14 of 15 locales.
pt already shipped without the prefix, which is the correct shape.
Strip the glyph from every locale value so the markup span is the single
icon source, matching Allow once / Allow session / Always allow / Deny,
which all pair an icon element with a glyph-free label.
test_vietnamese_locale.py asserted the old prefixed string verbatim; its
expectation is updated to the corrected value.