Commit Graph
3012 Commits
Author SHA1 Message Date
nesquena-hermes 19908b5db4 Merge PR #7720: scope MCP status, inventory and /reload-mcp to the request profile 2026-09-24 09:58:07 +00:00
nesquena-hermes 10faa69155 Merge PR #6746 from happy5318/fix/mobile-enter-newline 2026-09-24 08:05:12 +00:00
tancouandClaude Fable 5.1 dbf0179c53 fix(mcp): report runtime_scope on /api/notes/sources and surface it in the Notes drawer
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>
2026-09-24 09:53:02 +02:00
tancouandClaude Opus 5 90b814f673 fix(mcp): scope MCP status, inventory and /reload-mcp to the request profile
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>
2026-09-24 09:53:02 +02:00
happy5318 bbebe23f7e fix(mobile): preserve tablet hardware-keyboard Enter behavior 2026-09-24 15:26:06 +08:00
nesquena-hermes 16acb63a6d fix(notifications): auto-request 'default' permission at most once per page
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.
2026-09-24 03:15:09 +00:00
nesquena-hermes 1c4072228e Merge PR #7493 into stage/0924-a 2026-09-24 03:15:02 +00:00
nesquena-hermes 0b8d6d6fd8 Merge PR #7628 into stage/0924-a
# Conflicts:
#	static/messages.js
2026-09-24 03:15:02 +00:00
nesquena-hermes 57eb9fc1f4 Merge master into stage/7765-subagent-project
# Conflicts:
#	CHANGELOG.md
2026-09-23 21:56:19 +00:00
nesquena-hermes 3f85d3bbc4 Merge PR #7765 into stage/7765-subagent-project 2026-09-23 18:23:46 +00:00
nesquena-hermes d7530ab90a Merge PR #7779 into stage/7779-cron-provider-pin 2026-09-23 18:23:39 +00:00
carlotestor 4632ab65e9 Merge remote-tracking branch 'origin/master' into HEAD 2026-09-23 16:34:03 +00:00
carlotestor 35921eccbb fix(sidebar): scope project inheritance to subagents, resolve lineages once
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.
2026-09-23 15:28:47 +00:00
Cushing Whitney e1f90aed09 fix(cron): preserve provider-only pin on untouched save; honor explicit clear
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
2026-09-23 14:10:22 +00:00
carlotestor 35c9b8ae96 fix(sidebar): Unassigned chip resolves child projects through reference rows
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.
2026-09-23 12:59:36 +00:00
webtecnica 4b791bd820 fix(stream): require a current live assistant row to seed the replay floor
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)
2026-09-23 09:29:00 -03:00
webtecnica 03013606fe Merge remote-tracking branch 'origin/master' into fix/7640-transparent-replay-body 2026-09-23 09:20:31 -03:00
nesquena-hermes aebcd96ff8 Merge PR #7730 into stage/7730-first-visit-locale 2026-09-23 10:22:45 +00:00
carlotestor d04737ec87 fix(sidebar): show delegated subagents inside their parent's project
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.
2026-09-23 09:25:33 +00:00
nesquena-hermes 86d5b36898 Merge master (exp-v0.52.349) into stage/7708-media-backtick
# Conflicts:
#	CHANGELOG.md
2026-09-23 08:50:25 +00:00
nesquena-hermes a028961133 Merge PR #7708 (contributor head 6455aa9b2b) 2026-09-23 06:22:22 +00:00
happy5318 6455aa9b2b fix(media): two-pass MEDIA: scan (pre-pass for wrapped, bare class without backtick exclusion) (#7708 round 2)
#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
2026-09-23 12:03:33 +08:00
Frank Song bbca954c5c fix: gate idle recovery on exact runtime status
(cherry picked from commit 35597715018e598bf24f0b708cbd32b039533a99)
2026-09-23 11:57:31 +08:00
Frank Song ca2153ec54 fix: bound sidebar idle recovery for missing terminal events
(cherry picked from commit 1be0e114b32eefe14f94837ed3cf78aadc14ca9b)
2026-09-23 11:57:31 +08:00
Frank Song 5dcda6f537 fix: preserve terminal handoff across idle sidebar refresh
(cherry picked from commit 85f1c2d34b62b0ade85f51ed2cf8c1cf56f5ab9e)
2026-09-23 11:57:31 +08:00
happy5318 8d0cdaefbe fix(i18n): preserve explicit saved English on first-visit bootstrap
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
2026-09-23 11:21:51 +08:00
nesquena-hermes 394ff790e5 Merge master (exp-v0.52.344) into stage/7436-reconnect-replay
# Conflicts:
#	CHANGELOG.md
2026-09-23 03:21:30 +00:00
nesquena-hermes c5663e86a7 Merge PR #7436 from atchisonbrent: skip unchanged tool-card replay on reconnect 2026-09-23 02:29:39 +00:00
nesquena-hermes a6d6b3da85 Merge PR #7374 from Thireus: fix duplicate assistant answer render (#2051) 2026-09-23 02:28:29 +00:00
nesquena-hermes 087a2df4a2 Merge PR #7716 into stage/rm-343-batch 2026-09-23 00:43:14 +00:00
happy5318 b471e7d1b8 fix(i18n): fall back to browser language on first visit (#7622)
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.
2026-09-23 07:59:27 +08:00
nesquena-hermes 0378ed4e34 fix(routes): preserve live cards on cross-profile 409 collisions
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 *_).
2026-09-22 10:21:22 +00:00
nesquena-hermes e6592f41af fix(login): restore i18n placeholders and keep passkey-only logins working
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.
2026-09-22 09:57:37 +00:00
happy5318 2330faa0e4 fix(media): MEDIA: token regex strips markdown backtick on 7 capture sites (#7359)
## 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.
2026-09-22 17:24:29 +08:00
happy5318 a26bc45f3c fix(session-stream): stop the hidden-tab poll on 404/410 so deleted sessions don't loop forever (#7299)
## 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.
2026-09-22 17:03:15 +08:00
nesquena-hermes 30fb4edc1f fix(i18n): stop double-rendering the skip-all lightning icon (#7701)
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.
2026-09-22 06:17:58 +00:00
Thireus ☠ 9d0c629b55 fix(ui): stop duplicating the final answer and a picker row (#2051)
The interrupt notice, and with it any turn-final assistant message, could be rendered twice through the compact Worklog fold (#6948). _assistantMessageBelongsInWorklog() folded on the _live marker before it reached the rule that protects a turn's final answer, but the caller only consults that predicate once the turn has settled, so a marker seen there is a leftover of the live-snapshot projection rather than an ongoing stream: the answer was hidden inline, echoed into the Worklog as a .wl-reason row, and the turn was left with no visible content, which the #3875 blank-turn fail-safe then revealed as two copies. A turn-final message with visible text is now kept out of the fold; every other _live message folds exactly as before.

Two nearby paths could produce the same duplicate on their own, so both are tightened: the settled Worklog echoes an anchor's prose only when that anchor was actually folded into it (assistant-segment-worklog-source), which is the proof the group construction already used, and the blank-turn fail-safe materializes a deferred Worklog (#5839) before judging it empty, so it no longer un-hides the folded segments and then renders the deferred rows beside them.

The composer picker listed one configured model twice because the provider-qualified spelling of its badge key was not recognised as an alias. _normalizeConfiguredModelKey() strips only one leading slash segment (#3360 keeps vendor_a/x and vendor_b/y/x distinct), so vendor/model and custom/vendor/model normalise to different keys and renderModelDropdown() synthesised a second row. _isEquivalentConfiguredModelEntry() now accepts <provider>/<model> under the same proof the @<provider>: rule uses - the badge declares the provider, the key starts with that provider's prefix, and an existing row from that provider normalises equal to the remainder - so only a duplicate of a row the catalog already produced can be dropped.

Adds tests/test_issue2051_duplicate_interrupt_notice.py and tests/test_issue2051_duplicate_model_picker_entry.py.
2026-09-21 10:38:40 +01:00
Thireus ☠ 166ca0ec05 fix(ui): build message blocks as nodes, not from an HTML string (#2051)
A settled assistant answer could render TWICE inside a single `.assistant-segment` -- `msg-body, msg-foot, msg-body, msg-foot`, the two copies byte-identical -- while the session data, the server responses and the emitted markup were all correct. It survived reloads, was unreproducible on a clean machine, and could not be explained by any render path.

The cause is outside the app. Privacy / anti-fingerprinting extensions wrap the DOM insertion APIs so they can re-scan the document after every mutation. The reporter identified CanvasBlocker 1.12 in their own profile; its `lib/iframeProtection.js` -> `protectDOMModifications()` wraps `appendChild`, `insertBefore` and `replaceChild` on Node.prototype, `append`, `prepend`, `insertAdjacentElement`, `insertAdjacentHTML`, `insertAdjacentText` and `replaceWith` on Element.prototype, and the `innerHTML` and `outerHTML` setters, and installs them with `exportFunction`, so they still stringify as `[native code]` and cannot be told apart from the real thing by the page.

A wrapper that ends up executing the wrapped call twice is harmless for node-based insertion but not for string-based insertion. Measured on the reporter's exact 2342-char emit string, in Chromium 152 and Firefox 154: a doubled `insertAdjacentHTML` rendered 2 bodies before this change and 1 after, while a doubled `appendChild`, `insertBefore`, `append` or `innerHTML` setter rendered 1 body both before and after.

Only the HTML-string path multiplies: `insertAdjacentHTML` parses and appends a fresh copy every time it runs, whereas appending a node MOVES it (a second call is a no-op) and an `innerHTML` setter REPLACES (a second call is idempotent). That is why exactly this one construct, of all the wrapped APIs, produced the duplicate -- and why switching to a different insertion API is a real fix rather than a workaround for one extension.

Parse into a `<template>` and move the resulting nodes in. The DOM is byte identical -- verified by comparing `innerHTML` of both constructions on a full session render in both engines -- but every step is now idempotent, so the renderer no longer depends on the insertion API being executed exactly once. Three call sites, one helper, with a string-path fallback for any environment without `<template>`.

The live-turn path (`ensureAssistantRow`) is deliberately left alone: it builds with `document.createElement` + `appendChild`, which the measurements above show is already immune.

Reproduction, with no extension needed: `const o = Element.prototype.insertAdjacentHTML; Element.prototype.insertAdjacentHTML = function (p, h) { o.call(this,p,h); o.call(this,p,h); };` then open any session -- before this commit every assistant message renders twice.
2026-09-21 10:38:40 +01:00
Thireus ☠ 8d3c405d68 fix(ui): stop rendering a settled assistant answer twice (#2051)
Two independent paths could put a second copy of an already-settled assistant answer on screen; the stored data always held exactly one row.

1. Dead live-turn node. The assistant row is persisted a few ms before the stream's terminal event clears S.activeStreamId, so the #6948 preserve guard still treats a finished #liveAssistantTurn node as live. Both live-turn insertion paths (renderMessages()'s re-attach and restoreLiveTurnHtmlForSession()) have one branch that APPENDS a turn the settled rebuild did not produce; the leftover node keeps id=liveAssistantTurn, is re-preserved on every later render, and the duplicate survives until a reload.

2. Settled activity scene. A process_prose row whose text IS the final answer is suppressed by the transparent-stream renderer but rebuilt as a second .assistant-segment by the compact-worklog row builder. The existing near-equality matcher never fires for short answers because of its absolute shorter>=80 floor (the reported reproduction is a 35-char greeting).

Dropping a live node is now an OWNERSHIP proof, never a text comparison: the transcript must end with a settled assistant message carrying this stream's id (_anchor_stream_id / scene identity), and the node must contain nothing the settled rebuild could not have produced (no unpersisted tool card, reasoning row, or second live segment). Unknown node shapes fail closed and keep the turn, so a genuinely live turn - including one whose text matches an earlier answer - is never dropped. The settled-scene half compares raw markdown at the single chokepoint both settled renderers share.

Adds tests/test_issue2051_duplicate_assistant_answer.py: four tests that fail on the pristine tree and pass with this change, including a wiring test that fails loudly if either append branch loses its guard.

Fixes #2051
2026-09-21 10:38:39 +01:00
Anthony Ruiz e3db8f7e01 fix: settle steer on every terminal path and preserve recovery drafts 2026-09-20 17:07:58 +00:00
Anthony Ruiz 1ee4fc1601 fix: resolve durable compressed sessions before WebUI resume 2026-09-20 17:07:58 +00:00
happy5318 8be686ee75 fix(mobile): soft-keyboard Enter inserts newline, not send
iOS Safari reports matchMedia('(any-pointer:fine)') as true on iPhone,
so _hasFinePointerCoexisting() wrongly vetoed the touch-device default
and plain Enter fell through to send().

- boot.js: _isTouchOnlyDevice() makes phone/tablet UA authoritative,
  no longer vetoed by any-pointer:fine; iPadOS-as-Macintosh detected
  via maxTouchPoints; media-query pair is fallback for non-phone touch
  devices. Composer handler reads live window._sendKey on every keydown
  so Settings changes take effect without reload.
- index.html: capture-phase interceptor removed; boot.js handler alone
  covers the mobile newline case (initial _sendKey already synced from
  localStorage synchronously, closing the async race).
- tests/test_send_key_preference_live_update.py: Playwright regression
  test verifies all three send-key modes and all transitions work after
  runtime preference change.
2026-09-20 22:11:03 +08:00
webtecnica 813b293c82 fix(commands): remove duplicate top-level const breaking messages.js load
static/commands.js and static/messages.js both declared
const _BACKEND_EXECUTED_AGENT_COMMANDS at file scope. static/index.html
loads them consecutively as classic defer scripts in the same global
lexical realm, so the second declaration threw
SyntaxError: Identifier '_BACKEND_EXECUTED_AGENT_COMMANDS' has already
been declared, aborting messages.js entirely.

The constant was unused in both files (a duplicate authority mirror, not
a genuinely shared source), so both declarations are removed. The
existing three-way parity test is preserved, and a new regression
evaluates commands.js followed by messages.js in one shared
classic-script vm realm, asserting both files evaluate cleanly and the
dispatch consts of both files are bound.
2026-09-20 11:59:09 +00:00
webtecnica ff06f6d032 fix(commands): exact cross-authority parity for announced slash commands 2026-09-20 11:59:09 +00:00
webtecnica a85c1d1f62 fix(commands): exact cross-authority parity for announced slash commands (#6951) 2026-09-20 11:59:09 +00:00
webtecnica d03891a307 fix(commands): autocomplete only announces webui-dispatchable slash commands (#6951) 2026-09-20 11:59:09 +00:00
webtecnica da4e4ef316 fix(picker): catalog is authority for @custom labels; keep plain-lane colon models whole
The #7240 fix peeled the leading @custom: segment on first-colon split, but a
plain-lane custom id whose model carries a tag/variant colon
(@custom:ollamacloud/qwen3.5:397b) was still misread as slug=ollamacloud/qwen3.5,
rendering only "397b". Two changes:

- _dynamicModelLabels (the backend-populated catalog) is now consulted BEFORE
  the string peel: the backend knows the true provider/model split and ships an
  exact m.label per routing id, so catalogued ids — including plain-lane colon
  models and collision cases — render verbatim.
- The legacy string fallback now treats a slash-bearing first segment as the
  model itself (a provider slug is a config key or host:port authority and
  never contains '/'), mirroring the '/' means routable rule api/config.py
  applies when building ids.

Tests: add plain-lane @custom:ollamacloud/qwen3.5:397b controls, a
post-catalog dynamic-label control, and a collision case proving labels stay
distinct by full routing id. Driver now accepts an optional pre-hydrated
catalog map.
2026-09-20 06:33:58 +00:00
webtecnica e71c3e1f97 fix(picker): stop truncating model names with colons 2026-09-20 06:33:58 +00:00
Anthony Ruiz 8fe3325e12 fix(ui): keep current summary visible next to stale compaction markers
The settled current-summary card was only created when no compaction
marker was loaded at all. A transcript with older (stale) markers plus a
newer `compression_anchor_summary` that no loaded marker references
therefore lost the current summary, which master still rendered.

Decide the fallback from `_latestCompressionReferenceMessage()`
(`referenceMessageRawIdx < 0`) instead of marker presence, and feed that
decision into `_selectCompactionCardPlacements()` before any card node is
built so the fallback is the single preserved-task owner and marker cards
never attach the task list a second time.

Add a render-level regression that executes the real `renderMessages()`
over 220 messages, three stale markers, one preserved task-list message
and a newer summary: the current summary is visible and exactly one task
owner is rendered, in full, virtualized and server-truncated windows.
2026-09-20 03:48:19 +00:00
Anthony Ruiz 44b2a57109 fix(ui): preserve all pre-window compaction cards 2026-09-20 03:48:19 +00:00