Skip to content

fix: consolidate client disconnect handling — single source of truth for error tuple - #3210

Closed
someaka wants to merge 6 commits into
nesquena:masterfrom
someaka:disconnect-handling-fix
Closed

someaka wants to merge 6 commits into
nesquena:masterfrom
someaka:disconnect-handling-fix

Conversation

@someaka

@someaka someaka commented May 30, 2026

Copy link
Copy Markdown
Contributor

What

Harden the per-response write path against client disconnects. Long-lived SSE connections, tab closes, mobile network switches, and Tailscale half-closed sockets currently produce spurious 500 tracebacks when the client vanishes mid-response.

Changes

api/helpers.py:

  • Added _CLIENT_DISCONNECT_ERRORS tuple — the single source of truth for disconnect error types (BrokenPipeError, ConnectionResetError, ConnectionAbortedError, TimeoutError, OSError). OSError covers ssl.SSLError (subclass) and socket-level errno 32/54/104/110.
  • Added _safe_write(handler, body) — wraps end_headers() + wfile.write() with disconnect error handling and debug-level logging.
  • j() and t() now use _safe_write() instead of raw end_headers() + wfile.write().

api/routes.py:

  • Removed duplicate _CLIENT_DISCONNECT_ERRORS definition — now imports from api.helpers for single source of truth.

server.py:

  • do_GET and the route dispatch block now catch _CLIENT_DISCONNECT_ERRORS (imported from helpers) instead of the narrower 3-tuple (BrokenPipeError, ConnectionResetError, ConnectionAbortedError).
  • The 500-fallback j() call is wrapped in its own try/except so a disconnect during error generation cannot cascade into a second traceback.

What this does NOT change

  • QuietHTTPServer.handle_error() — already exists on master (server.py:204-220) and handles the server-loop layer. This PR covers the per-response layer only.

Testing

  • Added tests/test_1694_terminal_cleanup_ownership.py for terminal cleanup ownership assertions.
  • Manual verification: SSE connections, tab closes, and network switches no longer produce tracebacks.

Ed and others added 4 commits May 30, 2026 22:35
Extract _safe_write() helper that wraps end_headers() + wfile.write()
in try/except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError,
TimeoutError, ssl.SSLError).  Both j() and t() now use _safe_write()
instead of raw wfile calls.

Fixes cascading BrokenPipeError + SSL BAD_LENGTH crash when a client
disconnects mid-response and the error handler tries to write a 500
status through the same broken socket.
- api/helpers.py: _safe_write() now logs disconnects at debug level
  instead of silently passing. No more invisible errors.

- server.py: Restructure exception handlers to catch
  _CLIENT_DISCONNECT_ERRORS first, then Exception. Remove the
  isinstance() filter inside except Exception (LBYL anti-pattern).
  The 500-response fallback now catches _CLIENT_DISCONNECT_ERRORS
  separately (expected) and logs unexpected failures via
  traceback.print_exc() instead of bare except Exception: pass.

- tests/test_broken_pipe_cascade.py: Add coverage for SSL/Timeout
  disconnect routing and 500-response safety (both disconnect
  survival and unexpected error logging).
@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Pulled the branch into a read-only worktree and read the full diff against origin/master (api/helpers.py, server.py, and the test file — read-only, not executed).

The core idea is good: factoring the response-write path through _safe_write() and wrapping the 500-fallback j() so a disconnect during error generation can't cascade into a second traceback. The nested handler in do_GET/_handle_write is the right shape:

except Exception:
    print(f'[webui] ERROR {self.command} {self.path}\n' + traceback.format_exc(), flush=True)
    try:
        j(self, {'error': 'Internal server error'}, status=500)
    except _CLIENT_DISCONNECT_ERRORS:
        pass

Two things to reconcile before merge.

1. The PR description overclaims the handle_error part

The body says it "Added QuietHTTPServer.handle_error()", but that already exists on master (server.py:204-220) and this diff doesn't touch it. Master's version already swallows ConnectionResetError, BrokenPipeError, ConnectionAbortedError, TimeoutError plus OSError errnos 32/54/104/110. Worth correcting the description so reviewers know the server-loop layer is unchanged — the new coverage here is purely the per-response _safe_write + 500-fallback wrapping.

2. Duplicate _CLIENT_DISCONNECT_ERRORS with diverging membership

api/routes.py:48 already defines its own tuple:

_CLIENT_DISCONNECT_ERRORS = (
    BrokenPipeError, ConnectionResetError, ConnectionAbortedError,
    TimeoutError, OSError,
)

The new one in api/helpers.py is the same minus OSError plus ssl.SSLError. Since ssl.SSLError subclasses OSError, the routes.py tuple already catches SSL errors via the broad OSError arm, while the new helpers.py tuple enumerates ssl.SSLError explicitly but drops the bare OSError catch-all. Now server.py imports the helpers one and routes.py keeps its own — two tuples with different semantics for "what counts as a disconnect." That will drift. Suggest defining it once (in helpers.py) and having routes.py import it, picking one membership policy. If you intend to keep the broad OSError arm, add it to the helpers tuple; if you want to be narrow, drop OSError from routes too — but make it a single source of truth.

Minor

github-search-report.md (53 lines, dated 2026-05-26) reads like research scratch output. Probably belongs in the PR description or a gist rather than committed to the repo root.

Net: the response-write hardening is a reasonable incremental improvement over the existing handle_error layer. Fix the description claim and de-duplicate the error tuple, and this is in good shape. I did not run the test suite (cron policy is read-only inspection of PR branches).

Address review feedback from @nesquena-hermes on PR #3210:

1. Deduplicate _CLIENT_DISCONNECT_ERRORS:
   - Single authoritative definition in api/helpers.py
   - api/routes.py now imports from api.helpers instead of defining
     its own copy with different membership
   - Unified tuple uses OSError (covers ssl.SSLError since it
     subclasses OSError) — broad socket-level disconnect coverage

2. Remove github-search-report.md:
   - Research scratch output that doesn't belong in the repo root
   - Content belongs in PR description or a gist

3. Docstring improvement:
   - Added comment explaining why OSError covers ssl.SSLError
   - Documents the errno-level socket errors caught by OSError
@someaka someaka changed the title fix: eliminate silent failures in client disconnect handling fix: consolidate client disconnect handling — single source of truth for error tuple May 31, 2026
@someaka

someaka commented May 31, 2026

Copy link
Copy Markdown
Contributor Author

Thank you for the thorough review. All three points addressed in 8308ba34:

1. PR description — fixed

Updated the title and body. Removed the false handle_error() claim. The PR now accurately describes the per-response _safe_write() + 500-fallback wrapping, and explicitly calls out that QuietHTTPServer.handle_error() is unchanged on master.

2. _CLIENT_DISCONNECT_ERRORS — consolidated

Single source of truth established:

  • Definition: api/helpers.py — now uses OSError (which covers ssl.SSLError since it subclasses OSError). Added a docstring comment explaining the inheritance relationship and listing the specific errno values caught (32 EPIPE, 54 ECONNRESET, 104 ECONNABORTED, 110 ETIMEDOUT).
  • Consumer: api/routes.py — removed the 13-line duplicate definition, added _CLIENT_DISCONNECT_ERRORS to the existing from api.helpers import (...) block at line 1033.
  • Consumer: server.py — already imported from helpers, unchanged.

The tuple membership is now identical everywhere because there's only one definition.

3. github-search-report.md — removed

Deleted from the branch. It was research scratch from issue/PR discovery — belongs in a gist or the PR description, not the repo root.


Net diff: 3 files changed, 6 insertions, 68 deletions. The error tuple is now defined once and imported everywhere.

… tuple

OSError is too broad — it masks real errors like file-not-found.
ssl.SSLError specifically catches SSL-level disconnects without
swallowing unrelated OSError subtypes.

Closes the test_excludes_broad_oserror CI failure.
@someaka

someaka commented May 31, 2026

Copy link
Copy Markdown
Contributor Author

Fixed all CI failures:

  1. **** — Changed OSErrorssl.SSLError in the tuple. OSError was too broad (masks real errors like file-not-found); ssl.SSLError specifically catches SSL-level disconnects.

  2. **** — Updated source-assertion test to check for centralized _CLIENT_DISCONNECT_ERRORS instead of the literal except clause that server.py no longer has.

  3. 3 × test_do_get_skips_500_on_* — Added check_auth bypass (matching the pattern already used by test_do_get_sends_500_on_real_error). Without it, auth returns 401 before the patched route handler runs, causing send_response.assert_not_called() to fail.

Full suite: 6931 passed, 0 failed.

@someaka

someaka commented May 31, 2026

Copy link
Copy Markdown
Contributor Author

Fixed all CI failures:

  1. Changed OSError to ssl.SSLError in the disconnect tuple — OSError was too broad, ssl.SSLError specifically catches SSL-level disconnects
  2. Updated source-assertion test to check for centralized _CLIENT_DISCONNECT_ERRORS instead of the literal except clause
  3. Added check_auth bypass to 3 broken_pipe tests that were missing it (auth returned 401 before the patched route handler ran)

Full local suite: 6931 passed, 0 failed.

nesquena-hermes pushed a commit that referenced this pull request May 31, 2026
… _joplin_api_get

Codex+Opus pre-release gate both flagged: TimeoutError is now in the
consolidated _CLIENT_DISCONNECT_ERRORS dispatch set, so a bare socket-connect
TimeoutError from Joplins urlopen(timeout=8) — which is NOT always URLError-
wrapped — would escape _handle_notes_search and be swallowed by the dispatch
disconnect handler as a fake client disconnect (silent empty response, no log).
Catch (URLError, TimeoutError) at the route so it surfaces as a clean
"not reachable" ValueError -> JSON error. Adds a regression test.

Co-authored-by: someaka <someaka@users.noreply.github.com>
nesquena-hermes pushed a commit that referenced this pull request May 31, 2026
Address review feedback from @nesquena-hermes on PR #3210:

1. Deduplicate _CLIENT_DISCONNECT_ERRORS:
   - Single authoritative definition in api/helpers.py
   - api/routes.py now imports from api.helpers instead of defining
     its own copy with different membership
   - Unified tuple uses OSError (covers ssl.SSLError since it
     subclasses OSError) — broad socket-level disconnect coverage

2. Remove github-search-report.md:
   - Research scratch output that doesn't belong in the repo root
   - Content belongs in PR description or a gist

3. Docstring improvement:
   - Added comment explaining why OSError covers ssl.SSLError
   - Documents the errno-level socket errors caught by OSError
nesquena-hermes pushed a commit that referenced this pull request May 31, 2026
… _joplin_api_get

Codex+Opus pre-release gate both flagged: TimeoutError is now in the
consolidated _CLIENT_DISCONNECT_ERRORS dispatch set, so a bare socket-connect
TimeoutError from Joplins urlopen(timeout=8) — which is NOT always URLError-
wrapped — would escape _handle_notes_search and be swallowed by the dispatch
disconnect handler as a fake client disconnect (silent empty response, no log).
Catch (URLError, TimeoutError) at the route so it surfaces as a clean
"not reachable" ValueError -> JSON error. Adds a regression test.

Co-authored-by: someaka <someaka@users.noreply.github.com>
nesquena-hermes added a commit that referenced this pull request May 31, 2026
Release stage-batchB2 → v0.51.182 (consolidated client-disconnect handling #3210)
@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Merged and shipped in v0.51.182 (Release FB, stage-batchB2). Thanks @someaka — and thanks for being so responsive through review (the OSErrorssl.SSLError narrowing, the scratch-file removal, and the source-assertion test update were all spot on).

I rebased your branch onto fresh master (clean, 6 commits) and ran it through the full pre-release gate (test suite + Opus advisor + the Codex regression gate). Both advisors independently flagged one real edge case I then fixed inline before shipping, with Co-authored-by credit to you:

  • TimeoutError at dispatch scope: since _CLIENT_DISCONNECT_ERRORS now includes TimeoutError, a bare socket TimeoutError from the Joplin integration's urlopen(timeout=8) (which isn't always URLError-wrapped) could escape _handle_notes_search and be swallowed by the dispatch-level disconnect handler — a silent empty response instead of a logged error. Keeping TimeoutError in the disconnect set is correct (write-timeouts on a slow client genuinely unwind to dispatch, since Handler.timeout=30), so the right fix was at the route: _joplin_api_get now catches (URLError, TimeoutError) → clean "not reachable" ValueError. Added a regression test.

Codex re-confirmed SAFE TO SHIP after the fix (and verified no other request-thread urlopen/socket-timeout path has the same leak). Full suite: 7016 passed. Nice cleanup — consolidating the disconnect tuple + the _safe_write wrap around the 500 path closed a real cascade hole.

@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Shipped in v0.51.182 — your commits were rebased onto fresh master and merged via the release branch (with the one inline TimeoutError follow-up noted above). Closing as merged.

mysoul12138 added a commit to mysoul12138/hermes-webui that referenced this pull request May 31, 2026
…ction

Upstream: 68 commits (v0.51.169..v0.51.182).

Conflict resolution:
- api/config.py: replaced our bare_prefixes with upstream thinking_bare_prefixes
  (PR nesquena#3202 — more precise, supports dot-separated model names)
- api/routes.py: removed duplicate /api/sessions handler (upstream added
  _session_attention_summary for attention badges; integrated into
  session_routes.py)

Our PRs merged upstream:
- nesquena#3172: cron sessions bypass CLI_VISIBLE_SESSION_LIMIT
- nesquena#3174: getModelLabel() fallback in _formatSessionModelWithGateway

New upstream features absorbed:
- Session attention badges + sound (nesquena#3203, nesquena#3195)
- Sidebar session tooltips
- Client disconnect handling (nesquena#3210)
- Agent cache lifecycle management (nesquena#3215, nesquena#3191)
- Pin quota management

Test fixes: monkeypatch targets updated for 3 tests that patch
session_routes.py dependencies at their new import locations.
eleboucher pushed a commit to eleboucher/homelab that referenced this pull request Jun 1, 2026
…➔ 0.51.197) (#749)

This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
| [ghcr.io/nesquena/hermes-webui](https://github.com/nesquena/hermes-webui) | patch | `0.51.157` → `0.51.197` |

---

### Release Notes

<details>
<summary>nesquena/hermes-webui (ghcr.io/nesquena/hermes-webui)</summary>

### [`v0.51.197`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051197--2026-06-01--Release-FQ-stage-batch9--stop-agent-replaying-editedundone-messages)

[Compare Source](https://github.com/nesquena/hermes-webui/compare/v0.51.196...v0.51.197)

##### Fixed

- Editing or undoing a message no longer lets the agent replay the original pre-edit content from `state.db`: the truncation-watermark filter now also skips replaced/stale rows whose timestamp sorts *below* the watermark, and `POST /api/session/truncate` truncates `context_messages` in sync with `messages` so the agent's context matches the visible transcript after Edit/Regenerate. The earlier `_clamp_context_to_watermark()` approach (which turned the watermark into a permanent ceiling that dropped every new turn) is removed. Closes [#&#8203;2914](https://github.com/nesquena/hermes-webui/issues/2914) ([#&#8203;3102](https://github.com/nesquena/hermes-webui/issues/3102), [@&#8203;AlexeyDsov](https://github.com/AlexeyDsov)).

### [`v0.51.196`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051196--2026-06-01--Release-FP-stage-batch8--file-manager-external-sessions--artifacts-tool-metadata--edge-toggle-icon--type-hints)

[Compare Source](https://github.com/nesquena/hermes-webui/compare/v0.51.195...v0.51.196)

##### Fixed

- File manager (folder download, raw file fetch, and related handlers) now falls back to a `state.db` lookup for sessions created by Telegram/CLI rather than the WebUI, resolving them against the active WebUI workspace instead of returning a 404. Closes [#&#8203;3280](https://github.com/nesquena/hermes-webui/issues/3280) ([#&#8203;3314](https://github.com/nesquena/hermes-webui/issues/3314), [@&#8203;Sanjays2402](https://github.com/Sanjays2402)).
- Artifacts tab now detects files from structured `tool_calls` (OpenAI format) and `tool_use` content blocks (Anthropic format) on messages, not just text-mined diff fences, so artifacts surface even when `S.toolCalls` is cleared after a reload; display paths are trimmed of the workspace prefix ([#&#8203;3329](https://github.com/nesquena/hermes-webui/issues/3329), [@&#8203;mysoul12138](https://github.com/mysoul12138)).
- Workspace panel edge-toggle chevron now points left (toward the panel it reveals) instead of right ([#&#8203;3318](https://github.com/nesquena/hermes-webui/issues/3318), [@&#8203;xz-dev](https://github.com/xz-dev)).

##### Internal

- `api/state_sync.py` now uses `Optional[T]` annotations for parameters defaulting to `None` instead of the implicit `T = None` form ([#&#8203;3323](https://github.com/nesquena/hermes-webui/issues/3323), [@&#8203;kuishou68](https://github.com/kuishou68)).

### [`v0.51.195`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051195--2026-06-01--Release-FO-stage-batch7--hide-attachment-path-markers-in-chat-UI)

[Compare Source](https://github.com/nesquena/hermes-webui/compare/v0.51.194...v0.51.195)

##### Fixed

- Uploaded image attachment path context (`[Attached files: …]`) remains available to the agent in the stored message, but the chat transcript, sidebar display title, and server-derived provisional titles no longer show the raw path suffix to the user ([#&#8203;3296](https://github.com/nesquena/hermes-webui/issues/3296), [@&#8203;AJV20](https://github.com/AJV20)).

### [`v0.51.194`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051194--2026-06-01--Release-FN-stage-batch6--profiles-config-import-cycle-fix)

[Compare Source](https://github.com/nesquena/hermes-webui/compare/v0.51.193...v0.51.194)

##### Fixed

- Profile startup now shares platform-default Hermes home resolution through a small `api/paths.py` helper instead of importing the full `api.config` module from `api.profiles`, so importing profiles before config no longer hits a latent circular-load that silently skipped active-profile initialization. Closes [#&#8203;3283](https://github.com/nesquena/hermes-webui/issues/3283) ([#&#8203;3303](https://github.com/nesquena/hermes-webui/issues/3303), [@&#8203;AJV20](https://github.com/AJV20)).

### [`v0.51.193`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051193--2026-06-01--Release-FM-stage-batch5--ctl-dotenv-opt-out--workspace-inline-open--gateway-reply-polish)

[Compare Source](https://github.com/nesquena/hermes-webui/compare/v0.51.192...v0.51.193)

##### Fixed

- `ctl.sh` now honors `HERMES_WEBUI_NO_DOTENV=1`, letting tests and scripted launches opt out of repo-local `.env` loading so host-specific `HERMES_WEBUI_STATE_DIR` values do not make the ctl test suite flaky. Closes [#&#8203;3246](https://github.com/nesquena/hermes-webui/issues/3246) ([#&#8203;3304](https://github.com/nesquena/hermes-webui/issues/3304), [@&#8203;AJV20](https://github.com/AJV20)).
- Workspace **Open in browser** now opens HTML files inline (with the same `inline=1` + CSP sandbox isolation as the file preview) instead of forcing a download, and uses `noopener` for the new tab ([#&#8203;3305](https://github.com/nesquena/hermes-webui/issues/3305), [@&#8203;xz-dev](https://github.com/xz-dev)).
- Gateway-backed chat now carries the same WebUI final-answer polish guidance as the in-process chat paths, so terse scratchpad fragments such as "Need script" are not encouraged as visible assistant replies ([#&#8203;3301](https://github.com/nesquena/hermes-webui/issues/3301), [@&#8203;AJV20](https://github.com/AJV20)).

### [`v0.51.192`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051192--2026-05-31--Release-FL-stage-batch4--per-model-contextlength-default-only-guard)

[Compare Source](https://github.com/nesquena/hermes-webui/compare/v0.51.191...v0.51.192)

##### Fixed

- A global `model.context_length` cap (set in config for the default model, e.g. 232000) no longer silently shrinks **non-default** models' real context windows. The cap is now applied only when the session model equals `model.default`; other models (e.g. a 1M-context variant) keep their real metadata window. The guard is applied consistently across the session context-length resolver (`api/routes.py`), the per-turn persistence path, and the live SSE usage payload, and the auto-compress `threshold_tokens` is rescaled to the real cap so the context-window indicator and compression trigger reflect the actual window. The live-usage perf path caches the resolved per-model window once per stream (it runs \~10×/sec during streaming) so non-default-model streams don't take a config/metadata lookup on every metering tick. Backend-only; default-model sessions are unaffected. Closes [#&#8203;3256](https://github.com/nesquena/hermes-webui/issues/3256) ([#&#8203;3263](https://github.com/nesquena/hermes-webui/issues/3263), [@&#8203;allenliang2022](https://github.com/allenliang2022)).

### [`v0.51.191`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051191--2026-05-31--Release-FK-stage-batch3--skills-detail-markdown-styling--launchd-duplicate-start-guard)

[Compare Source](https://github.com/nesquena/hermes-webui/compare/v0.51.190...v0.51.191)

##### Fixed

- Skills detail view now renders `SKILL.md` markdown with the same `.preview-md` typography used by Memory and Notes, instead of unstyled `renderMd()` output. Linked markdown skill files opened from the detail view use the same wrapper plus scoped post-render `highlightCode` / KaTeX enhancement ([#&#8203;3284](https://github.com/nesquena/hermes-webui/issues/3284), [@&#8203;pamnard](https://github.com/pamnard)).
- `ctl.sh start` now refuses to launch a second WebUI instance when a launchd-managed job already owns it (macOS), instead of racing the launchd instance into repeated `Address already in use` churn on port 8787. The guard is macOS/launchd-only, no-ops on every non-launchd path, and can be overridden with `HERMES_WEBUI_CTL_ALLOW_LAUNCHD_CONFLICT=1`; `docs/supervisor.md` documents launchd as the single source of truth. Closes [#&#8203;3289](https://github.com/nesquena/hermes-webui/issues/3289) ([#&#8203;3291](https://github.com/nesquena/hermes-webui/issues/3291), [@&#8203;andrewkangkr](https://github.com/andrewkangkr)).

### [`v0.51.190`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051190--2026-05-31--Release-FJ-stage-batch2--Windows-upgrade-state-stranding-hotfix--gateway-banner--quiet-tool-previews)

[Compare Source](https://github.com/nesquena/hermes-webui/compare/v0.51.189...v0.51.190)

##### Fixed

- **Windows upgrade no longer strands WebUI state (data-loss-class, priority).** v0.51.134 moved the Windows default Hermes home from `%USERPROFILE%\.hermes` to `%LOCALAPPDATA%\hermes` without a migration, so upgrading users opened an empty app (sessions, pins, UI settings appeared lost — actually just at the address the new build no longer read). `_platform_default_hermes_home()` now prefers the legacy `%USERPROFILE%\.hermes` **only** on the exact post-upgrade fingerprint (legacy holds real `webui/` state AND the new location is not yet established), and `api/profiles.py` delegates to the same resolver so the two can never drift. Non-destructive and self-healing — no files are moved; affected users recover on next launch with no action. Markers key on WebUI-owned `webui/` state only (not agent `auth.json`/`config.yaml`) so a long-time agent user installing WebUI fresh isn't wrongly diverted. Closes [#&#8203;2905](https://github.com/nesquena/hermes-webui/issues/2905) ([#&#8203;3279](https://github.com/nesquena/hermes-webui/issues/3279)).
- **"Gateway not configured" banner on two-container Docker first deploy.** `GET /api/gateway/status` treated all `alive is None` health payloads as unconfigured-unless-`identity_map`, so a freshly-started gateway that hadn't ticked `updated_at` yet (and had no conversations) reported "not configured." A stale-but-**running** gateway (reason `gateway_stale_running_state`, or a `gateway_state == "running"` detail) now reports `configured = True`; a stale-**stopped** gateway deliberately still falls through to the `identity_map` signal so a stopped root gateway reads as "not configured" per [#&#8203;1944](https://github.com/nesquena/hermes-webui/issues/1944). Closes [#&#8203;3194](https://github.com/nesquena/hermes-webui/issues/3194) ([#&#8203;3279](https://github.com/nesquena/hermes-webui/issues/3279)).
- Collapsed tool-call previews stay quiet: instead of falling back to raw result JSON (which made tool-heavy turns look like debug logs), a settled collapsed tool card now shows a compact argument summary (with verbose/secret-bearing keys like `content`/`patch`/`api_key`/`token` excluded) or a short status, keeping the full result inside the expandable detail body ([#&#8203;3267](https://github.com/nesquena/hermes-webui/issues/3267), [@&#8203;ai-ag2026](https://github.com/ai-ag2026)).

### [`v0.51.189`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051189--2026-05-31--Release-FI-stage-batch1--ruff-lint-gate--SSE-refresh-dedupe--tooltip-i18n)

[Compare Source](https://github.com/nesquena/hermes-webui/compare/v0.51.188...v0.51.189)

##### Added

- **Forward-looking Python lint gate (ruff).** A curated `[tool.ruff]` ruleset (E9 syntax/runtime + F pyflakes + B bugbear — high-signal correctness rules, no style/formatting families) now gates **new and changed Python code** in CI (`tests.yml` `lint` job) and as part of the maintainer pre-release pre-gate. It is the Python twin of the existing ESLint runtime guard for `static/*.js`. Crucially it is **line-scoped** (`scripts/ruff_lint.py --diff`): it flags violations only on lines a change adds or modifies, so it keeps incoming code clean **without** reformatting the existing tree's cosmetic backlog (a separate, deferred, maintainer-run decision). `tests/test_ruff_forward_lint.py` additionally holds the whole tree free of E9 errors and skips cleanly when ruff isn't installed. See TESTING.md > "Python lint gate (ruff)". Closes [#&#8203;3273](https://github.com/nesquena/hermes-webui/issues/3273) ([#&#8203;3275](https://github.com/nesquena/hermes-webui/issues/3275)).

##### Fixed

- Gateway SSE reconnect no longer triggers a phantom "new dialog created" sidebar refresh: the initial sessions snapshot pushed on every reconnect is now compared against the current gateway-session set and `renderSessionList()` is skipped when nothing changed ([#&#8203;3270](https://github.com/nesquena/hermes-webui/issues/3270), [@&#8203;PINKIIILQWQ](https://github.com/PINKIIILQWQ)).
- Raw-audio recording mic tooltip now uses a recording-specific i18n key instead of the dictation "Stop" label; sidebar lineage/child tooltip suffixes are localized across the locale catalog; and the localized read-only title hover hint for imported sessions is restored. Closes [#&#8203;3242](https://github.com/nesquena/hermes-webui/issues/3242), [#&#8203;3214](https://github.com/nesquena/hermes-webui/issues/3214) ([#&#8203;3272](https://github.com/nesquena/hermes-webui/issues/3272), [@&#8203;ai-ag2026](https://github.com/ai-ag2026)).

### [`v0.51.188`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051188--2026-05-31--Release-FH-stage-batchH--configured-runner-client-boundary-default-off)

[Compare Source](https://github.com/nesquena/hermes-webui/compare/v0.51.187...v0.51.188)

##### Added

- **Configured runner-client boundary** for the `runner-local` runtime adapter (RFC `hermes-run-adapter-contract`, tracking [#&#8203;1925](https://github.com/nesquena/hermes-webui/issues/1925), Slice 4c/4d). When — and only when — an operator sets `HERMES_WEBUI_RUNNER_BASE_URL`, WebUI delegates the agent run to that external/supervised runner over a small JSON HTTP client (start / observe / status / cancel / approval / clarify / queue / goal) and bridges the runner's events through the existing SSE stream route, instead of owning the run in the main WebUI process. **Default-off and fully reversible:** with no endpoint configured, the factory preserves the existing bounded "runner-local not configured" path and the live in-process streaming path is unchanged — no behavior change for existing users. New `api/runner_client.py` (`HttpRunnerClient` + `runner_client_configured()`) plus additive `_runner_*` SSE-bridge helpers in `api/routes.py`; the legacy `_run_agent_streaming` control flow is untouched ([#&#8203;3073](https://github.com/nesquena/hermes-webui/issues/3073), [@&#8203;AJV20](https://github.com/AJV20)).

### [`v0.51.187`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051187--2026-05-31--Release-FG-stage-batchG--workspace-preview-persistence--scroll-intent-window)

[Compare Source](https://github.com/nesquena/hermes-webui/compare/v0.51.186...v0.51.187)

##### Fixed

- Workspace file preview no longer closes when a chat response finishes and the UI refreshes the workspace file tree. Background `loadDir('.')` on stream `done` now preserves an open preview instead of always calling `clearPreview()`, and reloads the open file when a write/edit tool touched that path during the turn (skipping reload while the preview has unsaved local edits) ([#&#8203;3262](https://github.com/nesquena/hermes-webui/issues/3262), [@&#8203;pamnard](https://github.com/pamnard)).
- During streaming, scrolling up to read earlier content no longer snaps back to the bottom after a brief pause: the upward-scroll intent window was widened from 450ms to 2000ms so DOM-layout changes from the markdown parser / tool-card insertions are still recognized as co-occurring with user intent and don't re-pin the view. Downward scroll, the scroll-to-bottom button, and trackpad-momentum protection are unaffected ([#&#8203;3250](https://github.com/nesquena/hermes-webui/issues/3250), [@&#8203;emanon312](https://github.com/emanon312)).

### [`v0.51.186`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051186--2026-05-31--Release-FF-stage-batchF--update-checker-ff-reachability-fall-through--utf-8-git-output-test-coverage)

[Compare Source](https://github.com/nesquena/hermes-webui/compare/v0.51.185...v0.51.186)

##### Fixed

- Agent self-update no longer advertises or applies unreachable release tags when the checkout tracks `main` past an older tag but the newest published tag lives on a divergent side branch (for example `v2026.5.29` → `v2026.5.29.2`). The update checker and apply path now fall through to the configured upstream branch when `git pull --ff-only <latest-tag>` cannot fast-forward, matching the existing [#&#8203;2653](https://github.com/nesquena/hermes-webui/issues/2653)/[#&#8203;3140](https://github.com/nesquena/hermes-webui/issues/3140) release-vs-branch routing ([#&#8203;3257](https://github.com/nesquena/hermes-webui/issues/3257), [@&#8203;pamnard](https://github.com/pamnard)).
- Added regression coverage pinning `_run_git()`'s UTF-8 decoding (`encoding='utf-8'`, `errors='replace'`) and its defensive `None`-stdout guard, so version detection cannot crash on non-UTF-8 Windows console output ([#&#8203;3254](https://github.com/nesquena/hermes-webui/issues/3254), [@&#8203;zapabob](https://github.com/zapabob)).

### [`v0.51.185`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051185--2026-05-31--Release-FE-stage-batchE--clarify-card-bug-fix-batch-identical-prompt-dedup--autofill-guard--GBK-startup-crash)

[Compare Source](https://github.com/nesquena/hermes-webui/compare/v0.51.184...v0.51.185)

##### Fixed

- The clarify popup could remain visible with stale input state after sending a response when the next queued clarification prompt was text-identical to the previous one. The dedupe signature now includes the prompt's `clarify_id`, so a newly queued identical prompt is treated as new instead of being mistaken for the one already answered ([#&#8203;3245](https://github.com/nesquena/hermes-webui/issues/3245), closes [#&#8203;3241](https://github.com/nesquena/hermes-webui/issues/3241)).
- Chrome's password manager could autofill the clarify-card input with saved credentials (typically a provider base URL), causing a phantom "Clarification closed" toast on every session completion and injecting the saved URL into the main composer. The clarify input now carries `autocomplete="off"` plus a `readonly` guard that is lifted only when an actual clarification prompt is shown (or on focus), so the browser's heuristic autofill skips it ([#&#8203;3247](https://github.com/nesquena/hermes-webui/issues/3247)).
- Prevent a server startup crash on non-UTF-8 Windows locales (e.g. Chinese GBK codepage): `_run_git()` now decodes git subprocess output as UTF-8 with `errors='replace'` and defensively guards against `None` streams, instead of letting a `UnicodeDecodeError` on binary `git diff --binary` output leave `stdout=None` and take down `import api.updates` with an `AttributeError` ([#&#8203;3249](https://github.com/nesquena/hermes-webui/issues/3249)).

### [`v0.51.184`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051184--2026-05-31--Release-FD-stage-batchD--raw-audio-upload-mode--scroll-preserve--non-POSIX-test-skip)

[Compare Source](https://github.com/nesquena/hermes-webui/compare/v0.51.183...v0.51.184)

##### Added

- Optional **raw audio upload mode** (Settings → Sound, off by default): when enabled, the composer mic button sends the recorded audio as a file attachment instead of transcribing it locally, so you can use external STT, raw-audio/emotion analysis, or multimodal models. Classic push-to-talk dictation is unchanged when the toggle is off. The mic tooltip reflects the active mode; localized across all 12 locales ([#&#8203;3169](https://github.com/nesquena/hermes-webui/issues/3169)).

##### Fixed

- Transcript scroll position is now preserved during same-session CLI/gateway import SSE refreshes (and the active session's metadata is synced from the refreshed transcript), instead of jumping to the bottom on each refresh ([#&#8203;3237](https://github.com/nesquena/hermes-webui/issues/3237)).

##### Changed

- `tests/test_terminal_process_cleanup.py` (POSIX terminal coverage that imports `fcntl` at module load) is now skipped at collection time on non-POSIX hosts instead of erroring ([#&#8203;3235](https://github.com/nesquena/hermes-webui/issues/3235)).

### [`v0.51.183`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051183--2026-05-31--Release-FC-stage-batchC--inline-file-media-artifacts--apimedia-state-file-confinement)

[Compare Source](https://github.com/nesquena/hermes-webui/compare/v0.51.182...v0.51.183)

##### Fixed

- Render assistant-emitted `file://` image artifact links inline through the authenticated `/api/media` route, so browser clients can view generated local images instead of seeing unusable server-local file paths. Only bare (line-start or whitespace-delimited) `file://` URLs are rewritten — `[label](file://...)` markdown anchors keep the normal link path ([#&#8203;3219](https://github.com/nesquena/hermes-webui/issues/3219)).

##### Security

- `/api/media` now hard-denies WebUI state and secret/config files even when they fall under an allowed root (the WebUI state dir, `settings.json`, `state.db`, `auth.json`, `auth.lock`, `config.yaml`, `.env`, signing/PBKDF2 keys, the `sessions`/`memories`/`profiles` state subdirs). Previously the whole Hermes home was an allowed root, so an authenticated session viewing attacker-influenced agent output that emitted a `file://`/`MEDIA:` link to such a file could fetch it. Hardened the boundary at the route for every entry path (bare `file://`, markdown anchors, and `MEDIA:` tokens), closing [#&#8203;3234](https://github.com/nesquena/hermes-webui/issues/3234).

### [`v0.51.182`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051182--2026-05-31--Release-FB-stage-batchB2--headless-browser-smoke-gate--consolidated-client-disconnect-handling)

[Compare Source](https://github.com/nesquena/hermes-webui/compare/v0.51.181...v0.51.182)

##### Added

- CI now runs a headless browser smoke test (`tests/browser_smoke.py`, `.github/workflows/browser-smoke.yml`) on every PR and push: it boots the real server agent-free and loads the key pages in Chromium, failing on any console error or uncaught JS exception. This catches the runtime-JS brick class (e.g. a `const` reassigned at runtime as in [#&#8203;3162](https://github.com/nesquena/hermes-webui/issues/3162), or a `function`/`window` name collision as in [#&#8203;2715](https://github.com/nesquena/hermes-webui/issues/2715)/[#&#8203;2771](https://github.com/nesquena/hermes-webui/issues/2771)) that `node --check`, ESLint, and the mocked test suite cannot see because they only manifest when a real browser executes the page. The smoke is credential-free — it strips `*_API_KEY` from the environment and drives no real model ([#&#8203;3231](https://github.com/nesquena/hermes-webui/issues/3231)).

##### Fixed

- Client disconnects during response writes (browser tab close, SSE reconnect races, mobile network switches, half-closed sockets) are now handled through a single `_CLIENT_DISCONNECT_ERRORS` set and a `_safe_write` helper instead of ad-hoc per-call-site `try/except`, so an expected disconnect no longer surfaces as a misleading server 500 in the logs. The error-response path is itself wrapped so a disconnect while sending a 500 is swallowed quietly rather than cascading. A bare `TimeoutError` from the Joplin notes integration's `urlopen` is now converted to a clean "not reachable" error at the route rather than escaping to the dispatch-level disconnect handler ([#&#8203;3210](https://github.com/nesquena/hermes-webui/issues/3210)).

### [`v0.51.181`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051181--2026-05-30--Release-FA-stage-batchA--agent-cache-eviction-teardown--streaming-finalize-race)

[Compare Source](https://github.com/nesquena/hermes-webui/compare/v0.51.180...v0.51.181)

##### Fixed

- Cached agents evicted after session-identity mismatches, unsafe runtime refresh, credential self-heal, or skipped compression migration now go through the normal session-boundary teardown path, committing pending memory and closing provider/session resources instead of silently dropping the cache entry ([#&#8203;3218](https://github.com/nesquena/hermes-webui/issues/3218), closes [#&#8203;3215](https://github.com/nesquena/hermes-webui/issues/3215)).
- Assistant streaming text is no longer lost when a stream completes while you have switched to a different session tab: the SSE `done` handler now sets the stream-finalized flag immediately (before the fade window), so a `stream_end` event arriving mid-fade can no longer trigger `_restoreSettledSession()` and overwrite the live messages with a stale server snapshot ([#&#8203;3201](https://github.com/nesquena/hermes-webui/issues/3201), closes [#&#8203;3195](https://github.com/nesquena/hermes-webui/issues/3195)).

### [`v0.51.180`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051180--2026-05-30--Release-EZ-stage-batch62--sessionagent-cache-ownership-hardening)

[Compare Source](https://github.com/nesquena/hermes-webui/compare/v0.51.179...v0.51.180)

##### Fixed

- Guard session and agent caches against compression/continuation id drift: `GET /api/session` now evicts a cached `Session` whose own `session_id` no longer matches the requested key (instead of trusting the LRU), the background title-update/refresh paths only adopt a cached session when its identity matches, and the compression checkpoint migration no longer re-stores a stale object under the old lineage id. Prevents a stale cached object from making `/api/session?session_id=<tip>` return an older transcript segment, which looked like a disappeared session ([#&#8203;3191](https://github.com/nesquena/hermes-webui/issues/3191)).
- Evicted cached agents are now torn down cleanly at the WebUI session boundary: pending session memory is committed first, and only if the lifecycle entry is clean afterward does the agent get unregistered and its memory provider shut down via `shutdown_memory_provider(messages)` (closing provider-owned clients such as Hindsight's aiohttp session) before the session DB is closed — instead of leaking those resources until garbage collection ([#&#8203;3166](https://github.com/nesquena/hermes-webui/issues/3166)).

### [`v0.51.179`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051179--2026-05-30--Release-EY-stage-batch61--custom-provider-reasoning-efforts--clearer-sidebar-tooltips)

[Compare Source](https://github.com/nesquena/hermes-webui/compare/v0.51.178...v0.51.179)

##### Fixed

- Reasoning effort selector now appears for thinking-capable models served through custom API aggregators (New API, One API, etc.) that use non-standard model naming — bare names like `deepseek-v4-flash` or dot-separated `moonshotai.kimi-k2.5` rather than the OpenRouter-style `vendor/model` slash format. The heuristic now also strips a dot-vendor prefix and recognizes a `thinking`/`reasoning` token anywhere in the model name; plain non-reasoning models stay hidden as before ([#&#8203;3202](https://github.com/nesquena/hermes-webui/issues/3202)).
- Sidebar session row tooltips now explain the fork, prior-turn, child-session, and running/unread status badges, and hovering a truncated chat title shows the full title instead of the old "Double-click to rename" hint ([#&#8203;3203](https://github.com/nesquena/hermes-webui/issues/3203)). The localized pending-approval/clarify attention tooltip retains precedence over the generic running/unread state tooltip on the status dot, and the fork tooltip keeps its localized "Forked from" base.

### [`v0.51.178`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051178--2026-05-30--Release-EX-stage-batch60--parallel-sharded-CI-test-runs)

[Compare Source](https://github.com/nesquena/hermes-webui/compare/v0.51.177...v0.51.178)

##### Changed

- CI: the test suite now runs in 3 parallel shards per Python version (9 jobs total) via `pytest-shard`, cutting wall-clock test time roughly in half (slowest shard \~70s vs \~180s sequential). To make sharding safe, several tests that asserted a pristine default while a sibling test mutated shared process/server state were fixed to establish their own preconditions: onboarding-completed flag reset (`test_onboarding_mvp`), password-hash cache invalidation (`test_issue693_system_health_panel`), authoritative sessions-file path (`test_auth_session_persistence`), and — the root cause of the worst leak — `test_profile_env_isolation` no longer deletes + re-imports `api.profiles` (which poisoned the module's cached base-home global for every later test); it now points the cached path via `monkeypatch.setattr`. A conftest fixture also restores `HERMES_HOME`/`HERMES_BASE_HOME` after each test as defense-in-depth. Completes the test-sharding half of [#&#8203;3197](https://github.com/nesquena/hermes-webui/issues/3197) (the Docker-cache half shipped in v0.51.177).

### [`v0.51.177`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051177--2026-05-30--Release-EW-stage-batch59--Docker-smoke-test-layer-caching)

[Compare Source](https://github.com/nesquena/hermes-webui/compare/v0.51.176...v0.51.177)

##### Changed

- CI: the Docker smoke-test workflow now builds the image once and caches its layers via the GitHub Actions cache (`type=gha`), then each compose variant restores from that cache instead of rebuilding from scratch — saving \~1-3 minutes per variant. The image is still built from the PR's local Dockerfile (`load: true`), so PR changes are tested, not the released image. (Partial adoption of [#&#8203;3197](https://github.com/nesquena/hermes-webui/issues/3197) — the Docker half; the test-sharding half is deferred pending test-suite shard-safety work.)

### [`v0.51.176`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051176--2026-05-30--Release-EV-stage-batch58--sidebar-attention-indicators)

[Compare Source](https://github.com/nesquena/hermes-webui/compare/v0.51.175...v0.51.176)

##### Added

- Sidebar session rows now surface pending approval and clarify work with a color-coded status dot (red for approvals, amber for clarifies) plus a matching left rail and tinted background, so inactive conversations that need a permission decision or an answer are easy to spot at a glance. A distinct two-tone attention sound also plays for approval/clarify prompts, separate from the completion sound ([#&#8203;3190](https://github.com/nesquena/hermes-webui/issues/3190)).

### [`v0.51.175`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051175--2026-05-30--Release-EU-stage-batch57--internal-conversation-links)

[Compare Source](https://github.com/nesquena/hermes-webui/compare/v0.51.174...v0.51.175)

##### Added

- Internal conversation links: a "Copy conversation link" action copies a Markdown reference (`session://<id>`) for any conversation, and `session://` references render as same-origin in-app links that open the target conversation without a full page reload ([#&#8203;3179](https://github.com/nesquena/hermes-webui/issues/3179)). Links are sanitized through the existing safe-URL allowlist (rewritten to `/session/<id>`, label escaped, sid URL-encoded — verified against quote-breakout and script-injection payloads).
- Conversation filtering now recognizes pasted session references directly: raw session IDs, `session://...` references, `/session/...` URLs, and Markdown session links surface the target conversation while preserving normal content-search hits ([#&#8203;3179](https://github.com/nesquena/hermes-webui/issues/3179)).

### [`v0.51.174`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051174--2026-05-30--Release-ET-stage-batch56--CLIgateway-session-usage-in-Insights)

[Compare Source](https://github.com/nesquena/hermes-webui/compare/v0.51.173...v0.51.174)

##### Added

- The Insights page now includes CLI and gateway sessions (Telegram, Discord, cron, TUI) from the Hermes agent's `state.db` in usage totals, model breakdown, and daily activity — not just WebUI-native sessions ([#&#8203;3189](https://github.com/nesquena/hermes-webui/issues/3189)). WebUI sessions are de-duplicated so they are counted once, not double-counted against their `state.db` row.

### [`v0.51.173`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051173--2026-05-30--Release-ES-stage-batch55--Windows-pathjournal-safety--pin-quota-snapshot-fix--tool-card-paging-anchor--sidebar-dedupe--quieter-tool-cards)

[Compare Source](https://github.com/nesquena/hermes-webui/compare/v0.51.172...v0.51.173)

##### Fixed

- Windows native runs now skip the POSIX-only turn-journal directory fsync instead of raising `AttributeError` for missing `os.O_DIRECTORY` on every submitted turn ([#&#8203;3170](https://github.com/nesquena/hermes-webui/issues/3170)).
- `/api/media` now treats Windows cross-drive `commonpath()` comparisons as non-matches instead of 500ing when media paths and allowed roots live on different drives ([#&#8203;3171](https://github.com/nesquena/hermes-webui/issues/3171)).
- Hidden pre-compression snapshots no longer keep stale pin state or count toward the visible pinned-session quota ([#&#8203;3181](https://github.com/nesquena/hermes-webui/issues/3181)).
- Tool-call cards stay anchored when scrolling back through paginated history; legacy session-level tool-call indices are rebased to the returned message window and the browser refreshes tool-call anchors whenever a larger history window is loaded ([#&#8203;3120](https://github.com/nesquena/hermes-webui/issues/3120)).
- Avoid duplicate sidebar rows when a compressed session completes after both the preserved snapshot id and continuation id are already present in the session list.

##### Changed

- Restored the legacy compact tool-call card chrome by removing the persistent "Tool output" badge and returning the left rail to the muted border treatment. This keeps tool activity visually quieter while preserving the existing collapsible tool details.

### [`v0.51.172`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051172--2026-05-30--Release-ER-stage-batch54--model-label-fallback--dev-cache-bust-hash--tilde-workspace-completion--cron-project-chip-sessions)

[Compare Source](https://github.com/nesquena/hermes-webui/compare/v0.51.171...v0.51.172)

##### Fixed

- Session model labels now fall back to the friendly `getModelLabel()` form instead of the raw model id when gateway routing info is unavailable ([#&#8203;3174](https://github.com/nesquena/hermes-webui/issues/3174)).
- Dev-build cache-busting now includes a short hash of the tracked dirty diff, so local asset URLs change on each edit instead of staying at a constant `-dirty` suffix ([#&#8203;3159](https://github.com/nesquena/hermes-webui/issues/3159)).
- Workspace path autocomplete now preserves `~/` suggestions while browsing under the user's home directory ([#&#8203;3173](https://github.com/nesquena/hermes-webui/issues/3173)).
- CLI-sourced cron sessions that were squeezed past the default sidebar window now stay addressable under their project chip via a dedicated cron-only lookup pass ([#&#8203;3172](https://github.com/nesquena/hermes-webui/issues/3172)).

### [`v0.51.171`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051171--2026-05-30--Release-EQ-stage-batch53--tool-output-card-badge--Neon-opt-in-skin)

[Compare Source](https://github.com/nesquena/hermes-webui/compare/v0.51.170...v0.51.171)

##### Added

- Tool-call output cards now carry a persistent "Tool output" badge and accent rail so tool output stays visually distinct from final assistant responses without requiring hover ([#&#8203;2867](https://github.com/nesquena/hermes-webui/issues/2867)).
- New opt-in "Neon" cyberpunk skin (dark-first, purple/cyan accents). Default-off; select it from the skin list like Catppuccin or Nous.

### [`v0.51.170`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051170--2026-05-30--Release-EP-stage-batch52--run-aware-SSE-replay-cursors)

[Compare Source](https://github.com/nesquena/hermes-webui/compare/v0.51.169...v0.51.170)

##### Fixed

- SSE run-journal replay cursors are now run-aware: a stale `after_seq` from an interrupted prior stream can no longer suppress replay events in a newer stream whose sequence numbers reset from 1. The reconnect cursor now carries a run-scoped `after_event_id` (`run_id:seq`) and the server ignores it when the run id differs, falling back to same-run `after_seq` dedupe ([#&#8203;3124](https://github.com/nesquena/hermes-webui/issues/3124)).

### [`v0.51.169`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051169--2026-05-30--Release-EO-stage-batch51--skill-toggle-profile-scoping--update-tag-filter--Docker-docs)

[Compare Source](https://github.com/nesquena/hermes-webui/compare/v0.51.168...v0.51.169)

##### Fixed

- Docker docs now explain host-localhost URLs (`host.docker.internal` / `host.containers.internal`) and the `sudo docker compose` `$HOME=/root` bind-mount pitfall for users whose WebUI cannot reach host APIs or see `~/.hermes` ([#&#8203;3012](https://github.com/nesquena/hermes-webui/issues/3012), [#&#8203;3006](https://github.com/nesquena/hermes-webui/issues/3006)).
- Skills panel disabled/enabled state and toggle writes now resolve `config.yaml` from the active WebUI profile instead of the process default Hermes home or startup config override ([#&#8203;3066](https://github.com/nesquena/hermes-webui/issues/3066)).
- Update checks no longer advertise a newer release tag when a main-tracking checkout already contains that tag; the banner now falls through to the branch comparison path instead of offering an update that cannot fast-forward ([#&#8203;3140](https://github.com/nesquena/hermes-webui/issues/3140)).

### [`v0.51.168`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051168--2026-05-30--Release-EN-stage-batch50--hotfix-mobile-Failed-to-load-conversation-messages)

[Compare Source](https://github.com/nesquena/hermes-webui/compare/v0.51.167...v0.51.168)

##### Fixed

- Fixed a `TypeError` in `_ensureMessagesLoaded` that surfaced as a "Failed to load conversation messages" toast on mobile after most messages: a `const msgs` binding was reassigned by the [#&#8203;3018](https://github.com/nesquena/hermes-webui/issues/3018) ephemeral-field carry-forward (introduced v0.51.161), which throws at runtime. Changed to `let`. Mobile triggered it most because SSE/visibility events fire the session-reload path more aggressively ([#&#8203;3162](https://github.com/nesquena/hermes-webui/issues/3162)).

##### Added

- Static JS runtime-error lint guard (`eslint.runtime-guard.config.mjs` + `tests/test_static_js_runtime_lint.py`): a curated, zero-false-positive ESLint check (`no-const-assign`, `no-import-assign`) over `static/**/*.js` that catches the brick-class of runtime errors `node --check` and source-presence tests miss. Runs in the test suite when ESLint is present and skips gracefully otherwise. See `TESTING.md` > "Static JS runtime lint".

### [`v0.51.167`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051167--2026-05-30--Release-EM-stage-batch49--iOS-style-swipe-actions-for-touch-devices--session-list-FLIP-reflow)

[Compare Source](https://github.com/nesquena/hermes-webui/compare/v0.51.166...v0.51.167)

##### Added

- Touch devices now support iOS-mail-style swipe actions on session rows: swipe left to reveal a Delete action, swipe right to reveal Archive (Restore for already-archived sessions). Swipe is gated to touch/coarse-pointer input, so desktop click, context-menu, and drag behavior are unchanged. Delete still routes through the existing confirmation dialog. The session list also gains FLIP-based reflow animation when rows are archived, deleted, or reordered, honoring `prefers-reduced-motion`.

### [`v0.51.166`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051166--2026-05-30--Release-EL-stage-batch48--shared-OpenCode-runtime-key--cron-project-chip-sessions)

[Compare Source](https://github.com/nesquena/hermes-webui/compare/v0.51.165...v0.51.166)

##### Fixed

- OpenCode provider key lookup now honors the shared `OPENCODE_API_KEY` fallback for both Zen and Go runtime paths, matching model-picker detection when provider-specific keys are absent ([#&#8203;3145](https://github.com/nesquena/hermes-webui/issues/3145)).
- Agent-side cron sessions imported from state.db now remain available to their assigned project chip as `default_hidden` rows instead of being filtered out before project reveal logic can see them ([#&#8203;3134](https://github.com/nesquena/hermes-webui/issues/3134)).

### [`v0.51.165`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051165--2026-05-30--Release-EK-stage-batch47--stop-EventSource-reconnect-storm-on-long-lived-SSE-streams)

[Compare Source](https://github.com/nesquena/hermes-webui/compare/v0.51.164...v0.51.165)

##### Fixed

- The session-events and gateway SSE streams no longer emit `Connection: close`, which browsers interpreted as a signal that the EventSource lifecycle had ended — triggering an instant reconnect loop that thrashed the session list roughly once per second and forced repeated re-renders / scroll-to-bottom ([#&#8203;3103](https://github.com/nesquena/hermes-webui/issues/3103), regression from the HTTP/1.1 keep-alive change in [`598fd4f`](https://github.com/nesquena/hermes-webui/commit/598fd4ff)). Finite responses that lack a `Content-Length` (e.g. the on-the-fly workspace ZIP download) keep `Connection: close` for unambiguous HTTP/1.1 message framing.

### [`v0.51.164`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051164--2026-05-30--Release-EJ-stage-batch46--passive-performance-hardening-refresh-coalescing--draft-save-dedup--activity-placeholders--bounded-restart-safety-wait)

[Compare Source](https://github.com/nesquena/hermes-webui/compare/v0.51.163...v0.51.164)

##### Fixed

- Coalesced duplicate in-flight sidebar/project refreshes and suppressed overlapping approval/clarify fallback polls, reducing repeated passive requests while preserving latest-refresh-wins sidebar state.
- Skipped duplicate composer-draft writes when the normalized autosave payload is unchanged, avoiding redundant full session JSON rewrites during debounced input/focus churn.
- Refined empty Activity waiting placeholders to distinguish stream creation, first-token wait, post-tool model wait, and running-tool wait states.
- Self-update restart safety now checks active agent runs as well as open SSE streams and waits for in-flight work before re-exec, avoiding update-triggered interruption when a run outlives its browser stream. The wait is bounded (300s) with a logged fallback to re-exec so a long-running or stuck agent run cannot soft-jam the self-update indefinitely.
- Documented the WebUI prefill context budget in README and architecture notes so operators can keep new-browser-turn startup context compact.

### [`v0.51.163`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051163--2026-05-30--Release-EI-stage-batch45--session-duplicatebranch-field-propagation)

[Compare Source](https://github.com/nesquena/hermes-webui/compare/v0.51.162...v0.51.163)

##### Fixed

- Duplicating or branching a session now carries over the fields that were previously dropped, so the copy behaves identically to the original until further edits: `truncation_watermark`, model-facing `context_messages` (deep-copied for independence), gateway routing + routing history, context-engine state, cache-token counters, composer draft, LLM-title flag, and per-session settings (model provider, project, personality, toolsets, context length, threshold). Compression anchors and last-prompt-token counts are intentionally not carried so the copy re-derives them. Prevents data-loss scenarios where, e.g., editing a message in a duplicated session would drop messages during state.db merge.

### [`v0.51.162`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051162--2026-05-30--Release-EH-stage-batch44--conversation-filter-clear-button--code-only-title-language-regression-coverage)

[Compare Source](https://github.com/nesquena/hermes-webui/compare/v0.51.161...v0.51.162)

##### Added

- Conversation filtering now shows a clear button inside the search field whenever text is present, letting users clear the filter with one click.

### [`v0.51.161`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051161--2026-05-29--Release-EG-stage-batch43--3-PR-live-display-fixes-jump-to-question-on-intermediate-assistant-messages--per-turn-usage-badge-persistence--stale-unreadcompression-timertool-card-dedup)

[Compare Source](https://github.com/nesquena/hermes-webui/compare/v0.51.160...v0.51.161)

##### Fixed

- The jump-to-question button now appears on every assistant message that has a resolvable question target, not only the turn-final one. Multi-step turns (tool call → assistant → tool call → assistant) previously stripped the navigation affordance from intermediate assistant bubbles.
- Per-turn ephemeral fields (`_turnUsage`, `_turnDuration`, `_turnTps`, `_gatewayRouting`) are now carried forward when a session refresh replaces the in-memory message list with fresh server data, so the usage badge / duration / gateway-routing pill no longer flash and disappear after a compaction restore, external active-session poll, or SSE error recovery ([#&#8203;3018](https://github.com/nesquena/hermes-webui/issues/3018)).
- The sidebar unread dot no longer sticks on a session after it has been viewed: syncing the viewed count now clears any stale completion-unread marker, and an actively-viewed session syncs its count instead of being flagged unread on tab switch ([#&#8203;3020](https://github.com/nesquena/hermes-webui/issues/3020)).
- The auto-compression card's elapsed timer is now cleared on completion/error, so a replaced card is no longer treated as a still-running compression; a background-session completion no longer kills the active session's compression timer ([#&#8203;2973](https://github.com/nesquena/hermes-webui/issues/2973)).
- Tool cards no longer duplicate the result text in both the header and the detail row: the completed tool result is routed to the detail snippet (falling back to the header only when no progress text was streamed), and the detail row is suppressed when the snippet equals the header preview.

### [`v0.51.160`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051160--2026-05-29--Release-EF-stage-batch42--3-PR-low-risk-cleanup-OpenCode-shared-key-detection--skills-panel-profile-aware-disabled-read--session-index-metadata-refresh-perf)

[Compare Source](https://github.com/nesquena/hermes-webui/compare/v0.51.159...v0.51.160)

##### Fixed

- Detect a shared `OPENCODE_API_KEY` as enabling both OpenCode Zen and OpenCode Go provider groups, matching Hermes Agent bridge environments that expose one OpenCode credential.
- The skills panel now reads each skill's disabled state from the active WebUI profile's `config.yaml` (checking `skills.platform_disabled.webui` then falling back to `skills.disabled`) instead of the process-global `HERMES_HOME`, so non-default profiles show the correct enabled/disabled state and stay consistent with the skill-toggle write path.

##### Changed

- WebUI session-sidebar metadata refresh is faster on large session directories: the persisted session-id listing is cached by directory mtime instead of re-globbing under the sessions lock on every `/api/sessions` poll, metadata-only loads skip the per-row session-index read when the sidecar already carries an authoritative `message_count`, and only runtime/lineage-shaped rows are overlaid with fuller sidecar metadata (historical transcripts are no longer scanned on every refresh).

### [`v0.51.159`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051159--2026-05-29--Release-EE-stage-batch41--5-PR-low-risk-cleanup-Gateway-tool-progress-forwarding--shutdown-diagnostics--CLI-snippet-limit-parity--numpad-Enter-submit--sync-chat-notes-guardrail)

[Compare Source](https://github.com/nesquena/hermes-webui/compare/v0.51.158...v0.51.159)

##### Changed

- WebUI's durable-notes guardrail now also applies to sync chat and explicitly asks agents to leave external notes and durable memory unchanged unless a turn contains an explicit capture or reusable durable signal; durable note writes should be summarized back to the user.

##### Fixed

- Gateway-backed browser chat now forwards Hermes Gateway `hermes.tool.progress` SSE events into WebUI's live tool/activity stream, so Gateway runs no longer appear idle while server-side tools are running.
- WebUI now logs structured shutdown diagnostics when the server exits or `/api/shutdown` is called, including active stream IDs to help diagnose interrupted turns after restarts.
- The chat composer now treats the numeric keypad Enter key as a submit shortcut even when the send-key preference is set to Ctrl/Cmd+Enter, while preserving regular Enter-as-newline behavior in that mode.
- The CLI tool-result snippet limit in the browser now matches the backend (`_TOOL_RESULT_SNIPPET_MAX = 4000`), so longer non-diff CLI tool output is no longer truncated to 200 characters before reaching the tool card.

### [`v0.51.158`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051158--2026-05-29--Release-ED-stage-batch40--5-PR-low-risk-cleanup-numpadkeyboard-composer-fixes--Joplin-search-auth--provider-qualified-model-preservation--SSE-fallback-poll-throttle--assistant-reply-polish)

[Compare Source](https://github.com/nesquena/hermes-webui/compare/v0.51.157...v0.51.158)

##### Changed

- WebUI chat instructions now explicitly prevent terse scratchpad/planning fragments from appearing in visible assistant replies, while still allowing clear user-facing progress updates during tool-heavy work.

##### Fixed

- The chat composer no longer forces mobile newline-on-Enter behavior on touch-primary devices that also have a fine pointer present (tablet plus Bluetooth keyboard, detachable Surface, iPad plus Magic Keyboard), so Enter submits with desktop semantics when a real keyboard is in the picture.
- The active-session external-refresh fallback poll now fires every 30 s instead of every 5 s. The SSE session-events stream already pushes invalidations in real time, so the poll is only a fallback; the slower interval removes visible scroll jitter and a network/CPU floor on long sessions.
- The model picker no longer fuzzy-matches a provider-qualified model id (`@provider:model` or slash-qualified `vendor/model`) to a nearby curated sibling once exact lookup fails, preserving the raw typed value so uncatalogued models stay routable instead of silently snapping to a different model.
- Joplin notes search now keeps the `Authorization` header and adds a query-token compatibility shim only for Web Clipper `/search` calls, covering clipper builds that return HTTP 403 for header-only search auth while keeping other Joplin API URLs token-free.

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these updates again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xMDEuMSIsInVwZGF0ZWRJblZlciI6IjQzLjEwMS4xIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6WyJyZW5vdmF0ZS9jb250YWluZXIiLCJ0eXBlL3BhdGNoIl19-->

Reviewed-on: https://git.erwanleboucher.dev/eleboucher/homelab/pulls/749
SysAdminDoc pushed a commit to SysAdminDoc/hermes-webui that referenced this pull request Jun 26, 2026
Address review feedback from @nesquena-hermes on PR nesquena#3210:

1. Deduplicate _CLIENT_DISCONNECT_ERRORS:
   - Single authoritative definition in api/helpers.py
   - api/routes.py now imports from api.helpers instead of defining
     its own copy with different membership
   - Unified tuple uses OSError (covers ssl.SSLError since it
     subclasses OSError) — broad socket-level disconnect coverage

2. Remove github-search-report.md:
   - Research scratch output that doesn't belong in the repo root
   - Content belongs in PR description or a gist

3. Docstring improvement:
   - Added comment explaining why OSError covers ssl.SSLError
   - Documents the errno-level socket errors caught by OSError
SysAdminDoc pushed a commit to SysAdminDoc/hermes-webui that referenced this pull request Jun 26, 2026
SysAdminDoc pushed a commit to SysAdminDoc/hermes-webui that referenced this pull request Jun 26, 2026
…Error in _joplin_api_get

Codex+Opus pre-release gate both flagged: TimeoutError is now in the
consolidated _CLIENT_DISCONNECT_ERRORS dispatch set, so a bare socket-connect
TimeoutError from Joplins urlopen(timeout=8) — which is NOT always URLError-
wrapped — would escape _handle_notes_search and be swallowed by the dispatch
disconnect handler as a fake client disconnect (silent empty response, no log).
Catch (URLError, TimeoutError) at the route so it surfaces as a clean
"not reachable" ValueError -> JSON error. Adds a regression test.

Co-authored-by: someaka <someaka@users.noreply.github.com>
SysAdminDoc pushed a commit to SysAdminDoc/hermes-webui that referenced this pull request Jun 26, 2026
Release stage-batchB2 → v0.51.182 (consolidated client-disconnect handling nesquena#3210)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants