diff --git a/.env.docker.example b/.env.docker.example index a2349bdf8b6..d0db0ecc0e3 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -47,6 +47,37 @@ GID=1000 # the agent. # HERMES_WEBUI_PASSWORD=change-me-to-something-strong +# ────────────────────────────────────────────────────────────────────────── +# Additional security / networking / limit knobs — advanced +# ────────────────────────────────────────────────────────────────────────── +# The WebUI process reads more HERMES_WEBUI_* variables than the compose +# files forward by default. To use any of these in Docker, uncomment it here +# AND add a matching passthrough line under `environment:` in your compose +# file (e.g. `- HERMES_WEBUI_SESSION_TTL=${HERMES_WEBUI_SESSION_TTL:-}`). +# Full descriptions live in .env.example. +# +# Auth cookie lifetime in seconds (default 2592000 = 30 days): +# HERMES_WEBUI_SESSION_TTL=2592000 +# +# Force the Secure cookie flag when serving HTTPS via a reverse proxy: +# HERMES_WEBUI_SECURE=1 +# +# Allowed public origins (scheme required) when behind a reverse proxy: +# HERMES_WEBUI_ALLOWED_ORIGINS=https://myapp.example.com +# +# Trust reverse-proxy forwarded headers (only enable behind a proxy YOU run): +# HERMES_WEBUI_TRUST_FORWARDED_HOST=1 +# HERMES_WEBUI_TRUST_FORWARDED_FOR=1 +# HERMES_WEBUI_TRUST_FORWARDED_PROTO=1 +# +# Enable the passkey/WebAuthn login surface (default off): +# HERMES_WEBUI_PASSKEY=1 +# +# Archive extraction cap (MB) and "download folder as ZIP" caps: +# HERMES_WEBUI_MAX_EXTRACTED_MB=2048 +# HERMES_WEBUI_FOLDER_ZIP_MAX_MB=1024 +# HERMES_WEBUI_FOLDER_ZIP_MAX_FILES=50000 + # ────────────────────────────────────────────────────────────────────────── # Permission handling for bind-mounted .hermes — advanced # ────────────────────────────────────────────────────────────────────────── diff --git a/.env.example b/.env.example index 27cd9b3d9ca..3a3d4c13bac 100644 --- a/.env.example +++ b/.env.example @@ -32,3 +32,120 @@ # Display name for the assistant in the UI (default: Hermes) # HERMES_WEBUI_BOT_NAME=Hermes + +# ── Security & remote access ───────────────────────────────────────────────── + +# Password for the login page. REQUIRED if you bind to anything other than +# 127.0.0.1 — without it, anyone who can reach the port can run commands. +# HERMES_WEBUI_PASSWORD=change-me-to-something-strong + +# Enable the passkey/WebAuthn login surface (default: off). +# Alternative: set `webui_passkey_enabled: true` in the profile's config.yaml. +# HERMES_WEBUI_PASSKEY=1 + +# Auth cookie lifetime in seconds (default: 2592000 = 30 days). +# Clamped to [60, 31536000]. Also settable as session_ttl_seconds in settings.json. +# HERMES_WEBUI_SESSION_TTL=2592000 + +# Force the Secure flag on auth cookies: 1/true or 0/false. +# Unset = auto-detect (direct TLS socket, or a trusted X-Forwarded-Proto). +# HERMES_WEBUI_SECURE=1 + +# Override the auth cookie name (default: derived internally). +# HERMES_WEBUI_COOKIE_NAME=hermes_webui_session + +# Extra allowed origins for CSRF/origin checks when serving behind a reverse +# proxy or on a public hostname. Comma-separated; each entry MUST include the +# scheme. Entries without a scheme are ignored with a warning. +# HERMES_WEBUI_ALLOWED_ORIGINS=https://myapp.example.com:8000 + +# Serve HTTPS directly (both must be set; otherwise plain HTTP). +# HERMES_WEBUI_TLS_CERT=/path/to/fullchain.pem +# HERMES_WEBUI_TLS_KEY=/path/to/privkey.pem + +# Extra sources appended to the Content-Security-Policy (space-separated). +# HERMES_WEBUI_CSP_CONNECT_EXTRA=https://api.example.com wss://api.example.com +# HERMES_WEBUI_CSP_FRAME_EXTRA=https://embed.example.com + +# ── Reverse-proxy header trust (opt-in; only enable behind a proxy YOU run) ─── +# By default these forwarded headers are NOT trusted, because any direct client +# could forge them. Enable (1/true/yes/on) only when a reverse proxy you control +# sets them. TRUST_FORWARDED_HOST: honor X-Forwarded-Host/X-Real-Host in origin +# checks. TRUST_FORWARDED_FOR: honor X-Forwarded-For for the client IP. +# TRUST_FORWARDED_PROTO: honor X-Forwarded-Proto for HTTPS detection. +# HERMES_WEBUI_TRUST_FORWARDED_HOST=1 +# HERMES_WEBUI_TRUST_FORWARDED_FOR=1 +# HERMES_WEBUI_TRUST_FORWARDED_PROTO=1 + +# ── Trusted-header auth / reverse-proxy SSO (opt-in; OFF by default) ────────── +# Delegate authentication to a reverse proxy you run (Authelia, oauth2-proxy, +# corporate SSO, …) that authenticates the user and forwards their identity in a +# header. When HERMES_WEBUI_TRUSTED_AUTH_HEADER is set, the WebUI treats a request +# as that identity — but ONLY when the request's un-spoofable raw socket peer is +# an allowlisted proxy (HERMES_WEBUI_TRUSTED_PROXY_CIDRS below). With no header +# set, this path is disabled and normal password/passkey auth applies. +# +# ⚠️ SECURITY: HERMES_WEBUI_TRUSTED_PROXY_CIDRS MUST list the EXACT IP(s) of your +# proxy — e.g. 10.0.0.5/32 or 127.0.0.1/32. NEVER use 0.0.0.0/0 or a broad range: +# any client whose peer IP falls in the range could forge the identity header and +# log in as anyone. If the allowlist is empty/malformed, the header is ignored +# (fails closed). Loopback is trusted by default. Set the CIDR to your proxy only. +# HERMES_WEBUI_TRUSTED_AUTH_HEADER=Remote-User +# HERMES_WEBUI_TRUSTED_PROXY_CIDRS=10.0.0.5/32 +# Optional: map a forwarded groups header to WebUI profiles (JSON object), and a +# logout URL to redirect to after clearing the local session (SSO single-logout). +# HERMES_WEBUI_TRUSTED_GROUPS_HEADER=Remote-Groups +# HERMES_WEBUI_GROUP_PROFILE_MAP={"admins":"default","team-a":"projecta"} +# HERMES_WEBUI_TRUSTED_AUTH_LOGOUT_URL=https://sso.example.com/logout + +# ── Uploads & limits ───────────────────────────────────────────────────────── + +# Where chat attachments are stored (default: /attachments). +# Attachments are transient agent context, kept out of the workspace by default. +# HERMES_WEBUI_ATTACHMENT_DIR=~/.hermes/webui/attachments + +# Cap on total bytes an uploaded archive may expand to (zip-bomb guard). +# Default derives from the upload size limit; override in MB. +# HERMES_WEBUI_MAX_EXTRACTED_MB=2048 + +# Caps for the "download folder as ZIP" feature in the workspace browser. +# HERMES_WEBUI_FOLDER_ZIP_MAX_MB=1024 +# HERMES_WEBUI_FOLDER_ZIP_MAX_FILES=50000 + +# Requests slower than this many seconds get a stack-dump diagnostic in the log +# (default: 5.0; set 0 to log every request's timing). +# HERMES_WEBUI_SLOW_REQUEST_SECONDS=5.0 + +# ── Onboarding ─────────────────────────────────────────────────────────────── + +# Skip the first-run onboarding wizard entirely. +# HERMES_WEBUI_SKIP_ONBOARDING=1 + +# ── Advanced integrations ──────────────────────────────────────────────────── + +# Script whose stdout prefills new-session context (legacy generic hook; the +# WebUI dynamic-recall hook in config.yaml takes precedence when configured). +# HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT=/path/to/script +# HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT_TIMEOUT=5 +# HERMES_WEBUI_PREFILL_CONTEXT_MAX_CHARS=12000 + +# Extra sources for the notes drawer (see docs for the expected format). +# HERMES_WEBUI_EXTERNAL_NOTES_SOURCES= + +# Directory scanned for WebUI plugins (default: ~/.hermes/plugins). +# HERMES_WEBUI_PLUGINS_DIR=~/.hermes/plugins + +# API key used when polling the Hermes gateway health endpoint. +# HERMES_WEBUI_GATEWAY_API_KEY= + +# Override the Claude Code projects directory scanned by the CLI session +# bridge (default: auto-discovered ~/.claude/projects). +# HERMES_WEBUI_CLAUDE_PROJECTS_DIR=~/.claude/projects + +# Override the Codex CLI home scanned by the CLI session bridge and model +# catalog merge (default: ~/.codex). +# CODEX_HOME=~/.codex + +# Path to the LLM wiki root shown in the wiki panel (default: resolved from +# HERMES_HOME's wiki env file). +# WIKI_PATH=/path/to/wiki diff --git a/.github/workflows/browser-smoke.yml b/.github/workflows/browser-smoke.yml index b0b2eb11f19..d3c83753913 100644 --- a/.github/workflows/browser-smoke.yml +++ b/.github/workflows/browser-smoke.yml @@ -8,6 +8,12 @@ name: Browser smoke # # No secrets, no credentials: the server boots agent-free and the smoke script # strips every *_API_KEY from the environment before launch. +# +# Docs-only fast path: `browser-smoke` is a REQUIRED status check in the master +# ruleset, and a skipped required check reports as pending forever (auto-merge +# would hang). So on a docs/CHANGELOG/README-only change we keep the job running +# and reporting green but short-circuit the expensive browser install + smoke. +# Detection FAILS SAFE: any uncertainty or any non-doc path -> full smoke runs. on: pull_request: @@ -16,13 +22,85 @@ on: branches: [master] jobs: + changes: + runs-on: ubuntu-latest + outputs: + docs_only: ${{ steps.detect.outputs.docs_only }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Detect docs-only change set + id: detect + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + # Fail-safe: ALWAYS exits 0 and ALWAYS emits docs_only (default 'false' = + # run full smoke), so a detection hiccup can never wedge the required + # browser-smoke context into perpetual pending. + docs_only="false" + emit() { echo "docs_only=$docs_only" >> "$GITHUB_OUTPUT"; } + trap emit EXIT + set -uo pipefail + # STRICT ALLOWLIST — docs iff doc extension (*.md/.markdown/.rst) or an + # extensionless doc basename. NOT docs: *.txt (requirements.txt!), anything + # under docs/ that isn't a doc extension, and code files merely NAMED + # README/CHANGELOG. See tests.yml for the full rationale. + is_docs() { + f="$1"; low="$(printf '%s' "$f" | tr '[:upper:]' '[:lower:]')"; base="${f##*/}" + case "$low" in + *.md|*.markdown|*.rst) return 0 ;; + esac + case "$base" in + LICENSE|README|CHANGELOG|NOTICE|AUTHORS|CONTRIBUTING) return 0 ;; + esac + return 1 + } + if [ -n "${BASE_SHA:-}" ] && [ -n "${HEAD_SHA:-}" ]; then + git fetch --no-tags --depth=1 origin "$BASE_SHA" 2>/dev/null || git fetch --no-tags origin master || true + RANGE="$BASE_SHA...$HEAD_SHA" + else + RANGE="${{ github.event.before }}...${{ github.sha }}" + fi + # --no-renames so a rename reveals BOTH sides (a renamed-away code file is visible). + if ! FILES="$(git diff --name-only --no-renames "$RANGE" 2>/dev/null)"; then + echo "Could not diff $RANGE — running full smoke (fail-safe)."; exit 0 + fi + if [ -z "$FILES" ]; then + echo "Empty diff — running full smoke (fail-safe)."; exit 0 + fi + echo "Changed files:"; echo "$FILES" + result="true" + while IFS= read -r f; do + [ -z "$f" ] && continue + if ! is_docs "$f"; then + echo "Non-docs path -> full smoke required: $f" + result="false" + break + fi + done <<< "$FILES" + docs_only="$result" + echo "docs_only=$docs_only" + browser-smoke: + needs: changes + # Belt-and-suspenders (see tests.yml): run even if `changes` errored/was skipped + # so this required context can never wedge; step guards degrade to full smoke. + if: ${{ always() }} runs-on: ubuntu-latest timeout-minutes: 10 steps: + - name: Docs-only short-circuit + if: needs.changes.outputs.docs_only == 'true' + run: echo "Docs-only change set — skipping browser smoke (no page-executable change)." + - uses: actions/checkout@v4 + if: needs.changes.outputs.docs_only != 'true' - name: Set up Python + if: needs.changes.outputs.docs_only != 'true' uses: actions/setup-python@v5 with: python-version: '3.12' @@ -32,6 +110,7 @@ jobs: **/pyproject.toml - name: Install dependencies + if: needs.changes.outputs.docs_only != 'true' run: | python -m pip install --upgrade pip pip install "pyyaml>=6.0" playwright @@ -39,4 +118,5 @@ jobs: python -m playwright install --with-deps chromium - name: Run browser smoke + if: needs.changes.outputs.docs_only != 'true' run: python tests/browser_smoke.py diff --git a/.github/workflows/conversation-lifecycle.yml b/.github/workflows/conversation-lifecycle.yml new file mode 100644 index 00000000000..fe9fd1c5628 --- /dev/null +++ b/.github/workflows/conversation-lifecycle.yml @@ -0,0 +1,69 @@ +name: Conversation lifecycle (informational) + +# Scoped to the code this browser gate actually exercises — the chat +# render/streaming surface — so it doesn't spin up playwright+chromium on +# docs-only or unrelated changes. If none of these paths change, the run is +# skipped entirely (GitHub treats a path-filtered no-match as "not triggered"). +on: + pull_request: + branches: [master] + paths: + - 'static/**' + - 'api/**.py' + - 'server.py' + - 'tests/browser_conversation_lifecycle.py' + - 'requirements*.txt' + - 'pyproject.toml' + - '.github/workflows/conversation-lifecycle.yml' + push: + branches: [master] + paths: + - 'static/**' + - 'api/**.py' + - 'server.py' + - 'tests/browser_conversation_lifecycle.py' + - 'requirements*.txt' + - 'pyproject.toml' + - '.github/workflows/conversation-lifecycle.yml' + +jobs: + live-to-final: + runs-on: ubuntu-latest + timeout-minutes: 10 + continue-on-error: true + strategy: + fail-fast: false + matrix: + scenario: [normal, terminal-error] + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: 'pip' + cache-dependency-path: | + **/requirements*.txt + **/pyproject.toml + .github/workflows/conversation-lifecycle.yml + + - name: Install browser gate dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt playwright + python -m playwright install --with-deps chromium + + - name: Run conversation lifecycle gate + env: + LIFECYCLE_ARTIFACT_DIR: lifecycle-artifacts + LIFECYCLE_SCENARIO: ${{ matrix.scenario }} + run: python tests/browser_conversation_lifecycle.py + + - name: Upload failure artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: conversation-lifecycle-failure-${{ matrix.scenario }} + path: lifecycle-artifacts + if-no-files-found: ignore diff --git a/.github/workflows/docs-ci.yml b/.github/workflows/docs-ci.yml new file mode 100644 index 00000000000..5ba7f86860f --- /dev/null +++ b/.github/workflows/docs-ci.yml @@ -0,0 +1,116 @@ +name: Docs CI + +# Lightweight checks for documentation changes. Intentionally MINIMAL (Nathan, +# 2026-07-18): NOT a Markdown style linter — no line length, heading levels, list +# markers, or cosmetic rules. It runs only two things, and only when docs change: +# 1. critical_markdown_check.py — flags ONLY a rendering break (a link whose +# destination token is split across a newline, or an unclosed inline link). +# 2. lychee link check — flags broken/dead links (informational, non-blocking). +# +# Runs on docs paths only; does NOT gate code PRs. This job is NOT a required +# status check — it's an informational quality gate; promote it via the ruleset +# later if desired. +# +# SECURITY: changed filenames on a fork PR are UNTRUSTED. They are never +# interpolated into `run:` source via ${{ }} (which Bash would re-evaluate, +# executing $(...) in a crafted filename). The file list is passed through an +# `env:` variable and referenced as "$DOC_FILES", and lychee is handed a FIXED +# glob rather than an attacker-controlled explicit list. + +on: + pull_request: + branches: [master] + paths: + - '**/*.md' + - '**/*.markdown' + - '**/*.rst' + - 'docs/**' + - 'CHANGELOG*' + - '**/README*' + push: + branches: [master] + paths: + - '**/*.md' + - '**/*.markdown' + - '**/*.rst' + - 'docs/**' + - 'CHANGELOG*' + - '**/README*' + +# Read-only; no secrets needed. Fork PRs already get a read-only token, but pin it. +permissions: + contents: read + +jobs: + docs-ci: + runs-on: ubuntu-latest + timeout-minutes: 8 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Resolve changed doc files (NUL-safe, untrusted names) + id: docs + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -uo pipefail + # Compute the changed markdown/doc files and write them to a file, ONE PER + # LINE. We never echo untrusted names into another shell; downstream steps + # read this file. Fall back to all tracked docs if the diff can't compute. + if [ -n "${BASE_SHA:-}" ] && [ -n "${HEAD_SHA:-}" ]; then + git fetch --no-tags --depth=1 origin "$BASE_SHA" 2>/dev/null || git fetch --no-tags origin master || true + RANGE="$BASE_SHA...$HEAD_SHA" + else + RANGE="${{ github.event.before }}...${{ github.sha }}" + fi + : > doc_files.txt + if CHANGED="$(git diff --name-only --no-renames --diff-filter=ACMR "$RANGE" 2>/dev/null)"; then + printf '%s\n' "$CHANGED" | grep -Ei '\.(md|markdown|rst)$' > doc_files.txt || true + fi + if [ ! -s doc_files.txt ]; then + git ls-files '*.md' '*.markdown' '*.rst' > doc_files.txt 2>/dev/null || true + fi + # Keep only files that still exist (skip deletions), preserving exact names. + : > doc_files_existing.txt + while IFS= read -r f; do + [ -n "$f" ] && [ -f "$f" ] && printf '%s\n' "$f" >> doc_files_existing.txt + done < doc_files.txt + mv doc_files_existing.txt doc_files.txt + echo "Doc files to check:"; cat doc_files.txt + echo "count=$(wc -l < doc_files.txt)" >> "$GITHUB_OUTPUT" + + - name: Critical Markdown check (rendering-breaks only, not style) + if: steps.docs.outputs.count != '0' + run: | + set -uo pipefail + # Read the NUL-safe-ish file list (one path per line) into an array. Names + # are passed as ARGUMENTS to python, never evaluated as shell. + mapfile -t FILES < doc_files.txt + if [ "${#FILES[@]}" -eq 0 ]; then echo "No doc files."; exit 0; fi + python3 scripts/critical_markdown_check.py "${FILES[@]}" + + # Broken-link check. Handed a FIXED glob (not the attacker-controlled list) so + # no untrusted filename ever reaches lychee's `eval`. Over-checking docs is + # harmless. Informational only: continue-on-error keeps it from blocking. + - name: Broken-link check (lychee) + uses: lycheeverse/lychee-action@v2 + with: + args: >- + --no-progress + --max-concurrency 4 + --accept 200,206,301,302,303,307,308,401,403,429 + --timeout 20 + --max-retries 2 + "**/*.md" + "**/*.markdown" + "**/*.rst" + fail: true + continue-on-error: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c0f65df5726..028cf68b010 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,7 @@ on: push: tags: - 'v*' + - 'exp-v*' jobs: release: @@ -15,11 +16,22 @@ jobs: steps: - uses: actions/checkout@v4 + # Check whether this is a stable or experimental release + - name: Detect release channel + id: channel + run: | + if [[ "${{ github.ref }}" == refs/tags/v* ]]; then + echo "is_stable=true" >> "$GITHUB_OUTPUT" + else + echo "is_stable=false" >> "$GITHUB_OUTPUT" + fi + # Create GitHub Release from tag with auto-generated notes - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: generate_release_notes: true + prerelease: ${{ steps.channel.outputs.is_stable == 'false' }} # Set up multi-arch build (QEMU + Buildx) - uses: docker/setup-qemu-action@v3 @@ -41,7 +53,9 @@ jobs: images: ghcr.io/${{ github.repository }} tags: | type=match,pattern=v(\d+\.\d+(?:\.\d+)?),group=1 - type=raw,value=latest + type=match,pattern=exp-v(\d+\.\d+(?:\.\d+)?),group=1 + type=raw,value=latest,enable=${{ steps.channel.outputs.is_stable == 'true' }} + type=raw,value=experimental,enable=${{ steps.channel.outputs.is_stable == 'false' }} # Build and push multi-arch image (amd64 + arm64) - name: Build and push Docker image diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2f91d35be57..a34c5e8c3cc 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,28 +6,120 @@ on: push: branches: [master] +# Docs-only fast path: a CHANGELOG/README/docs-only PR does not need the full pytest +# matrix or the JS/lint runtime guards — nothing executable changed. But the `test (…)` +# shards and `lint` are REQUIRED status checks in the master ruleset, and a *skipped* +# required check reports as pending forever (auto-merge would hang). So we keep the jobs +# running and reporting green, but SHORT-CIRCUIT their expensive work when every changed +# path is documentation. Detection FAILS SAFE: if anything is uncertain (no base to diff, +# any non-doc path touched), docs_only=false and the full suite runs. jobs: - # Forward-looking Python lint gate (ruff). The Python twin of the ESLint runtime - # guard. Runs the curated [tool.ruff] ruleset (E9 + F + B) but only on lines this - # PR adds/modifies vs the merge-base — so it keeps NEW code clean without demanding - # a reformat of the existing tree's cosmetic backlog (#3273). Fast, fails early. + changes: + runs-on: ubuntu-latest + outputs: + docs_only: ${{ steps.detect.outputs.docs_only }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Detect docs-only change set + id: detect + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + # Fail-safe: this step ALWAYS exits 0 and ALWAYS emits docs_only (default + # 'false' = run the full suite). If the `changes` job ever failed, the + # required test/browser jobs (needs: changes) would be SKIPPED and their + # required contexts would never report — wedging the PR in perpetual + # pending. So the detector can never fail; worst case it over-runs CI. + docs_only="false" + emit() { echo "docs_only=$docs_only" >> "$GITHUB_OUTPUT"; } + trap emit EXIT + set -uo pipefail + # STRICT ALLOWLIST — a path counts as "docs" ONLY if it is unambiguously + # documentation. Everything else runs the full suite (fail-open to safety). + # A file is docs iff: + # * it has a doc extension: *.md / *.markdown / *.rst, OR + # * its basename is an extensionless doc file (LICENSE/README/CHANGELOG/…). + # NOT docs (deliberately): *.txt (requirements.txt controls deps!), anything + # under docs/ that isn't a doc extension (docs/Makefile, docs/build.py, + # docs/*.json), and any code file whose NAME merely contains README/CHANGELOG + # (scripts/CHANGELOG_stamp.py, static/README_renderer.js). Extension/type + # wins over name, and a non-doc extension is never docs. + is_docs() { + f="$1"; low="$(printf '%s' "$f" | tr '[:upper:]' '[:lower:]')"; base="${f##*/}" + case "$low" in + *.md|*.markdown|*.rst) return 0 ;; + esac + case "$base" in + LICENSE|README|CHANGELOG|NOTICE|AUTHORS|CONTRIBUTING) return 0 ;; + esac + return 1 + } + + # Resolve the changed-file list. PR event = base..head; push = before..after. + if [ -n "${BASE_SHA:-}" ] && [ -n "${HEAD_SHA:-}" ]; then + git fetch --no-tags --depth=1 origin "$BASE_SHA" 2>/dev/null || git fetch --no-tags origin master || true + RANGE="$BASE_SHA...$HEAD_SHA" + else + RANGE="${{ github.event.before }}...${{ github.sha }}" + fi + + # --no-renames so a rename shows BOTH sides: `git mv src/app.py docs.md` must + # reveal the src/app.py deletion (a code change) instead of collapsing to the + # docs.md destination and being mis-classified as docs-only. + if ! FILES="$(git diff --name-only --no-renames "$RANGE" 2>/dev/null)"; then + echo "Could not diff $RANGE — running full suite (fail-safe)."; exit 0 + fi + if [ -z "$FILES" ]; then + echo "Empty diff — running full suite (fail-safe)."; exit 0 + fi + + echo "Changed files:"; echo "$FILES" + result="true" + while IFS= read -r f; do + [ -z "$f" ] && continue + if ! is_docs "$f"; then + echo "Non-docs path -> full suite required: $f" + result="false" + break + fi + done <<< "$FILES" + docs_only="$result" + echo "docs_only=$docs_only" + lint: + needs: changes + # `lint` is not currently a required check, but add always() anyway so it can + # never wedge if it's promoted to required later — same rationale as the test/ + # browser-smoke jobs; a failed `changes` degrades to running the full lint. + if: ${{ always() }} runs-on: ubuntu-latest steps: + - name: Docs-only short-circuit + if: needs.changes.outputs.docs_only == 'true' + run: echo "Docs-only change set — skipping lint/runtime guards (nothing executable changed)." + - uses: actions/checkout@v4 + if: needs.changes.outputs.docs_only != 'true' with: # Need history so the gate can diff against the merge-base with master. fetch-depth: 0 - name: Set up Python + if: needs.changes.outputs.docs_only != 'true' uses: actions/setup-python@v5 with: python-version: '3.12' - name: Install ruff + if: needs.changes.outputs.docs_only != 'true' run: pip install ruff - name: Ensure origin/master ref is available for the diff gate + if: needs.changes.outputs.docs_only != 'true' run: git fetch --no-tags --depth=1 origin master || true # Whole-tree Python syntax gate. The Python analog of the JS `node --check` @@ -39,13 +131,15 @@ jobs: # file:line, before the matrix suite runs. Whole-tree + unconditional so it # cannot be bypassed by the diff scope. (#4641) - name: Python syntax gate (whole tree — fails fast on any unparseable .py) + if: needs.changes.outputs.docs_only != 'true' run: python3 -m compileall -q api server.py bootstrap.py mcp_server.py tests scripts - name: Ruff forward gate (new/changed lines only) + if: needs.changes.outputs.docs_only != 'true' run: python3 scripts/ruff_lint.py --diff origin/master - name: Ruff whole-tree report (informational — never blocks) - if: always() + if: needs.changes.outputs.docs_only != 'true' && always() run: python3 scripts/ruff_lint.py --all # Static-JS runtime-error guards. These catch brick-class bugs that throw only @@ -56,20 +150,31 @@ jobs: # scope, catching a function defined nested but called from a sibling # scope (#3696 — ReferenceError: _sessionAttentionState is not defined). - name: Set up Node for ESLint runtime guards + if: needs.changes.outputs.docs_only != 'true' uses: actions/setup-node@v4 with: node-version: '20' - name: Install ESLint + if: needs.changes.outputs.docs_only != 'true' run: npm install --no-save eslint@^10 - name: ESLint runtime-error gate (no-const-assign / no-import-assign, #3162) + if: needs.changes.outputs.docs_only != 'true' run: npx eslint --no-config-lookup -c eslint.runtime-guard.config.mjs "static/**/*.js" - name: Scope / undefined-reference gate (#3696) + if: needs.changes.outputs.docs_only != 'true' run: python3 scripts/scope_undef_gate.py test: + needs: changes + # Belt-and-suspenders: run even if `changes` errored/was skipped. A required job + # that gets SKIPPED reports its context as pending forever (wedge). With always(), + # the job runs regardless; the per-step `if: … != 'true'` guards then treat a + # missing/empty docs_only (which is what a failed `changes` yields) as "run full + # CI" — so a detector failure degrades to running everything, never to a wedge. + if: ${{ always() }} runs-on: ubuntu-latest strategy: # Don't cancel the other shards/versions when one fails — we want the full @@ -87,9 +192,15 @@ jobs: shard: [0, 1, 2, 3, 4] steps: + - name: Docs-only short-circuit + if: needs.changes.outputs.docs_only == 'true' + run: echo "Docs-only change set — skipping pytest shard ${{ matrix.shard }} (no executable change)." + - uses: actions/checkout@v4 + if: needs.changes.outputs.docs_only != 'true' - name: Set up Python ${{ matrix.python-version }} + if: needs.changes.outputs.docs_only != 'true' uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} @@ -100,9 +211,11 @@ jobs: **/pyproject.toml - name: Install dependencies + if: needs.changes.outputs.docs_only != 'true' run: | python -m pip install --upgrade pip - pip install "pyyaml>=6.0" pytest pytest-timeout pytest-asyncio pytest-shard + # Office parsers stay optional at runtime; CI installs them explicitly for the workspace preview tests. + pip install "pyyaml>=6.0" pytest pytest-timeout pytest-asyncio pytest-shard python-docx openpyxl python-pptx playwright # ruff is installed so tests/test_ruff_forward_lint.py runs its E9/F821 # tree-clean assertions in-suite (mirrors how eslint is available for # tests/test_static_js_runtime_lint.py). If install fails the test @@ -116,5 +229,24 @@ jobs: # importorskip and the matrix stays green. pip install mcp || echo "mcp install failed — test_mcp_server.py will importorskip" + - name: Get Playwright version + if: needs.changes.outputs.docs_only != 'true' + id: pw-version + run: echo "version=$(pip show playwright | grep '^Version:' | cut -d' ' -f2)" >> "$GITHUB_OUTPUT" + + - name: Cache Playwright browsers + if: needs.changes.outputs.docs_only != 'true' + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ steps.pw-version.outputs.version }} + restore-keys: | + ${{ runner.os }}-playwright- + + - name: Install Playwright browsers + if: needs.changes.outputs.docs_only != 'true' + run: python -m playwright install --with-deps chromium + - name: Run tests (shard ${{ matrix.shard }} of 5) + if: needs.changes.outputs.docs_only != 'true' run: pytest tests/ -v --timeout=60 --shard-id=${{ matrix.shard }} --num-shards=5 diff --git a/.gitignore b/.gitignore index 34d95bc4c88..6e91264d41f 100644 --- a/.gitignore +++ b/.gitignore @@ -28,9 +28,16 @@ copilot-instructions.md # Generated screenshots and transient artifacts screenshot-*.png full-UI.png +# Playwright MCP scratch — directory + ad-hoc PNGs the agent drops while +# debugging browser sessions. /final-check.png is the convention used by the +# local agent loop for a one-off end-of-task verification grab; ignore +# either way so they never sneak into a commit. +.playwright-mcp/ +final-check.png # Version file written by Docker/CI build — generated, never committed api/_version.py +api/_scm_version.py # OS files .DS_Store diff --git a/AGENTS.md b/AGENTS.md index c5aebbffeec..5d094ee36fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,6 +69,43 @@ Follow that checklist's safety rules: the manual verification performed. - For runtime, streaming, recovery, replay, compression, or sidebar metadata changes, name the state layer being mutated and prove the relevant invariant. +- For Docker build changes in `docker_init.bash`, mirror directory exclusions + in both the `rsync` and `cp -a` paths — `/opt/hermes` may contain subdirectories + with restricted permissions (e.g. `.playwright/`). + +## Before you open a PR — the change guidelines + +Read [`docs/GUIDELINES.md`](docs/GUIDELINES.md) in full before non-trivial work. It is the +distilled set of habits that get a change merged in one review round instead of several. The +compressed form: + +1. **Fix the class, not the instance.** A bug usually has siblings — other call sites, backends, + companion endpoints, layouts, exit paths. Find them all and fix the shared chokepoint, or name + the ones you left out of scope. +2. **Trace one authoritative value end-to-end** (`input → normalize → decision → action → persist → + cleanup`); the code that *decides* and the code that *acts* must use the same resolved value. +3. **When you can't confirm something, fail closed and say so.** Never take the permissive branch on + uncertainty; never report a failure as success. "Unknown" is not "allowed." +4. **Enumerate the state-space before editing** — entry point, backend, item count (0/1/many), every + lifecycle exit (success/error/cancel/replace/teardown), auth on/off, concurrency, hostile input — + and cover each or mark it out of scope. Most redo rounds are one un-considered dimension. +5. **Assume inputs and check-then-use gaps are adversarial** — validate at the point of use (hold a + handle, don't re-resolve a path), scope caches by complete identity, handle crafted input. +6. **A test must fail before your fix and pass after it.** Assert observable behavior, not a source + string or a mock of the thing under test; use multiple items if selection is what's being tested. +7. **Name the owner of every piece of state and prove it's released on every exit** (success, error, + cancel, replace, shrink, teardown) — not just the happy path. +8. **Fallbacks/defaults are contracts — extend the mechanism, don't copy it.** Editing N parallel + blocks identically means you missed a chokepoint (e.g. new copy goes in the `en` locale only). +9. **The diff is the task and nothing else.** Extras go in the PR description, not the diff; run the + affected + neighboring tests before opening. +10. **A visible control costs attention on every visit** — place it by frequency of use and by where + mainstream chat apps put the equivalent, not by where your diff already is; verify with + before/after images at desktop and narrow widths. + +Show the work in the PR body: the siblings you found, proof the test failed before the fix, the +verification run, before/after images for visible changes, and an explicit list of what you could +not verify. ## Local state and secrets diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c83db62bc8d..f657aad0c9d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -7,8 +7,8 @@ > > Keep this document updated as architecture changes are made. -> Current shipped build: `v0.51.192` (May 31, 2026). -> Automated coverage: ~7,150 tests via `pytest tests/ --collect-only -q`. CI runs on +> Current shipped build: `v0.51.792` (July 1, 2026). +> Automated coverage: ~11,500 tests via `pytest tests/ --collect-only -q`. CI runs on > Python 3.11, 3.12, and 3.13 (3 parallel shards each) against every PR, plus a ruff > lint gate, a headless browser smoke test, and a Docker smoke test. > @@ -50,7 +50,7 @@ actions. The topbar remains focused on conversation context and the workspace/fi bootstrap.py One-shot launcher: optional agent install, deps, health wait, browser open. start.sh Thin wrapper around bootstrap.py for shell-based startup. ctl.sh Daemon lifecycle wrapper (start/stop/restart/status/logs) for homelab installs. - pyproject.toml Tooling config (ruff lint gate). NOT a packaged distribution. + pyproject.toml Standard build metadata plus the Ruff lint gate; source-checkout launch surface still centers on bootstrap.py / start.sh / ctl.sh. Dockerfile python:3.12-slim container image docker-compose.yml Compose config with named volume and optional auth .dockerignore Excludes .git, tests/, .env* from Docker builds @@ -86,7 +86,7 @@ actions. The topbar remains focused on conversation context and the workspace/fi sw.js Service worker: offline shell cache, version-pinned assets tests/ conftest.py Isolated test server/state fixtures - ~700 test files ~7,150 tests collected via pytest (run `pytest --collect-only -q` for exact) + ~1,150 test files ~11,500 tests collected via pytest (run `pytest --collect-only -q` for exact) test_regressions.py Permanent regression gate CONTRIBUTING.md Contributor workflow and PR expectations. ROADMAP.md Feature and product roadmap document. @@ -718,7 +718,7 @@ Current structure: ui.js, workspace.js, sessions.js, messages.js, panels.js, commands.js, boot.js tests/ conftest.py Isolated test server/state fixtures - 488 test files 5303 tests collected + ~1,150 test files ~11,500 tests collected test_regressions.py Permanent regression gate Route extraction to api/routes.py completed in Sprint 11. server.py remains a @@ -818,7 +818,7 @@ Optional password gate for non-SSH-tunnel deployments. ### Phase I: Test Infrastructure -- COMPLETE -5303 tests across 488 test files + regression gates. The pytest fixture derives +~11,500 tests across ~1,150 test files + regression gates. The pytest fixture derives an isolated port and state directory from the repo path unless `HERMES_WEBUI_TEST_PORT` / `HERMES_WEBUI_TEST_STATE_DIR` pin them explicitly. Production data never touched. @@ -1307,6 +1307,8 @@ Complete list of all HTTP endpoints as of Sprint 1 (v0.3). /api/sessions List of all session compact() dicts, sorted by updated_at /api/list ?session_id=X&path=. -> directory listing for session workspace /api/file ?session_id=X&path=rel -> file content (text, 200KB limit) + /share/ Public read-only HTML shell for a sanitized shared transcript snapshot + /api/share/ Public JSON payload for a sanitized shared transcript snapshot /api/chat/stream ?stream_id=X -> SSE stream. Long-lived. Emits token/tool/ approval/done/error events. /api/chat/stream/status ?stream_id=X -> {"active": true/false, "stream_id": X} @@ -1327,6 +1329,8 @@ Complete list of all HTTP endpoints as of Sprint 1 (v0.3). -> {"stream_id", "session_id"}. Starts agent daemon thread. /api/chat (fallback, sync) {"session_id", "message", "model"?, "workspace"?} -> blocks until agent finishes. Returns full result. + /api/share/create {"session_id"} -> creates or refreshes a public read-only snapshot link + /api/share/revoke {"session_id"} -> revokes the current public snapshot link /api/approval/respond {"session_id", "choice": once|session|always|deny} -> {"ok": true, "choice": choice} diff --git a/CHANGELOG.md b/CHANGELOG.md index b87f860a03c..0638ecb41a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,178 +3,1254 @@ ## [Unreleased] +### Changed + +- **The repository is now pip-installable with standard packaging metadata.** `pyproject.toml` gains a `[build-system]` + `[project]` section (setuptools + setuptools-scm) so packagers and distros can `pip install .` and get a proper wheel (`api` + `static` bundled). The normal `bootstrap.py` / `start.sh` / `ctl.sh` source-checkout launch path is unchanged. setuptools-scm writes its version to a separate `api/_scm_version.py` (not the Docker-owned `api/_version.py`), and the runtime version detector reads it as an explicit fallback (normalizing the PEP 440 value to a `v…` form) so an installed wheel reports a real version instead of `unknown` — Docker and git-checkout version resolution are byte-identical to before. Thanks @rodboev. (#6337, #2695) + +- **Transparent Stream can now hide its per-event timestamp chips.** A new opt-in setting (Settings → the Transparent Stream activity area) lets you suppress the small per-event timestamp chips inside Transparent Stream while keeping the assistant response footer time visible — narrowing the visual noise without removing timing information entirely. Default is unchanged (chips shown). Thanks @rodboev. (#6130, #6099) + +- **Internal: removed a dead `rowIndex` parameter from the settled-scene row projector.** `attachLiveStream`'s `pushRow` helper carried an unused positional index left over from before final-segment eligibility moved to a row-identity `WeakSet`; dropping it is a pure no-op refactor (differential execution confirms byte-identical settled scenes — ordering, dedupe, final-prefix suppression, and sequence assignment all unchanged). Thanks @webtecnica. (#6258) + +- **Internal: clarified the transcript-virtualization boot comment (opt-in default retained).** The comment above the boot-time `_virtualizeTranscript` apply now records that the #4346 Phase B footer-jitter fix resolved the original scroll-up flicker root cause, while virtualization stays opt-in (default OFF) until battle-tested further. Zero behavior change — the apply predicate, settings-load fallback, checkbox, and server default all remain opt-in/OFF and in agreement. Thanks @webtecnica. (#6318, #6155) + +- **Docs: reconciled the assistant-reply lifecycle RFCs/contracts with shipped behavior.** The Stable Assistant Turn Anchors and Live-to-Final RFCs, `docs/CONTRACTS.md`, and the Phase 0 architecture inventory were updated from "proposed/scaffold" framing to "accepted/implemented" to match what actually ships (single assistant-turn owner projecting one `activity_scene_v1` into Compact Worklog / Transparent Stream / Final answer). Documentation-only — no runtime behavior or CI-gate change; the one code touch is a corrected comment header in `static/assistant_turn_anchors.js`. Remaining hardening stays tracked under #3400. Thanks @franksong2702. (#6144) + +- **The busy-time send behavior is now called "Default message mode," and new installs default to Steer.** The Settings → Preferences control formerly labeled "Busy input mode" is renamed to "Default message mode," and a fresh install now defaults to **Steer** (inject a mid-turn correction without interrupting) instead of Queue. Your existing choice is preserved — if you ever saved settings, your current mode (Queue/Interrupt/Steer) is migrated as-is and unchanged; only never-configured installs pick up the new Steer default. The saved preference still survives a reload or a brief server outage (the localStorage mirror from the previous release is intact). Thanks @rodboev. (#5162, #5145) + +### Fixed + +- **Alias-prefixed models now route to the right provider.** When you pick a namespaced model whose prefix is a provider *alias* (e.g. `z-ai/glm-5.2`, where `z-ai` is an alias of the canonical `zai`) under a different active provider, the WebUI resolver now canonicalizes the prefix before deciding whether to cross-route through OpenRouter — so it reaches OpenRouter instead of falling back to the stale configured provider (which produced auth/404 failures). The comparison canonicalizes both sides, so a model selected under an alias of its *own* provider is correctly treated as same-provider (not cross-routed), and OpenRouter selections like Qwen/xAI keep routing to OpenRouter. Thanks @webtecnica. (#6148, #6131) + +- **Per-model reasoning-effort metadata is now honored by the WebUI reasoning picker.** When resolving which reasoning modes a model exposes, a `models..reasoning_efforts` list (as Hermes Agent stores under `custom_providers[].models`) now takes precedence over the provider-wide `reasoning_efforts` list, falling back to the provider list and then heuristics. This lets a model with its own declared modes (e.g. an unrecognized custom model) expose them without a provider-wide override. Configs that only set a provider-level list are completely unaffected — output is byte-identical to before. Thanks @CharlesMcquade. (#6107) + +- **Stream teardown no longer leaks per-stream runtime state on long-running servers.** When a stream ended, its `STREAM_SESSION_OWNERS` owner mapping and (for gateway turns) the `AGENT_INSTANCES` entry were retained, so a high-volume, long-lived server slowly accumulated dead per-stream bookkeeping. Teardown now unregisters the stream owner and drops the gateway agent instance. The offline-frame buffer is deliberately **not** cleared on teardown so a late reconnect can still replay buffered `token`/`done`/`stream_end` frames; buffers remain bounded (8,192 frames) and channels are reclaimed once outstanding reconnect references exit. Thanks @webtecnica. (#6389, #6351) + +- **Japanese UI is fully translated again.** The Japanese (`ja`) locale had drifted to English fallback across recent features (workspace panel, session sharing, extensions gallery, cron sentence-builder, composer, settings, and update flows); it is now refreshed to parity with the current English keys — 180 strings — so Japanese users no longer see a mixed-language UI. Thanks @koshikai. (#6403) + +- **Reloaded tool-call turns render with the same unified Worklog as live turns.** Historical transcripts using ID-linked tool calls (OpenAI-shape `assistant.tool_calls[].id` ↔ `role:tool.tool_call_id`) previously rendered through the legacy renderer after a reload or session switch, so a reloaded turn could look different from how it appeared live. Unambiguous, complete ID-linked turns now hydrate into the unified Assistant Turn Anchor before render, matching the live presentation exactly while failing closed to the existing renderer for any richer or ambiguous case. Thanks @franksong2702. (#6222, #6220) + +- **New chat: you can now hide the entire welcome panel, not just the suggestions.** A new opt-in Settings → Preferences toggle, "Hide new-chat welcome panel," hides the whole empty-state block (logo, heading, subtitle, and suggestion buttons) on an empty new conversation while keeping the composer available and focused. It extends the existing "hide new-chat suggestions" preference with the same persisted, hot-applied pattern; default is unchanged (panel shown). Thanks @vaidu-ai. (#6183, #6160) + +- **Workspace Artifacts pane: long filenames are fully readable and long parent directories stay inside the drawer.** A long artifact filename used to be ellipsized (so you couldn't tell files apart), and an unusually long immediate-parent directory could expand the Artifacts drawer horizontally and bleed past its edge. The list now shows the filename first with parent-preserving path truncation, and the directory tail is width-capped (`min-width: 0` + `overflow: hidden` + `text-overflow: ellipsis`) so it clips inside the card instead of blowing out the drawer. Adds behavioral/locale coverage for the artifact path lifecycle. Thanks @rodboev. (#6078, #6067) + +- **The Kanban "New task" modal is reachable on phones again.** On a narrow viewport, a tall create-task modal was vertically centered, pushing its top (the title and first input) above the top of the screen with no way to scroll back to it. The mobile overlay now uses `align-items: safe center` and `overflow-y: auto`, so an overflowing modal keeps its top on-screen and scrolls as needed; desktop and wider layouts are unchanged. Thanks @jpalazz2. (#6301) + +- **Concurrent chats under different profiles can no longer cross the profile boundary mid-turn.** In a single WebUI process, two chats streaming at once could resolve different Hermes profiles, but agent construction and later prompt/config/skill reads still depended on process-global `HERMES_HOME` and globally-patched skill-module paths after the env lock was released — so a second profile's turn could overwrite those globals mid-first-turn and bleed one profile's `SOUL.md`/skills/config into another's chat. The resolved profile home is now bound through the agent's per-thread context-local override for the entire streaming turn (and detached worker scope) and reset in the run's terminal `finally`, so concurrent profiles stay isolated even if the process globals are clobbered mid-run. Checkpoint saves skip the skill lock (no deadlock) and the capability check rejects globally-patched module state (no leak), with a serialized snapshot/patch/restore fallback that keeps older agents safe. Thanks @starship-s. (#5877, #5567, #2023) + +- **The consolidated Kanban board now fills the panel's vertical space.** In consolidated view (a single board, not split into per-profile lanes), the columns were previously clamped to a short fixed height, leaving a large empty gap below the board. The consolidated board now grows to the full panel height and scrolls as one tall surface; the per-profile lane view keeps its existing height clamp and contained scrolling. CSS-only. Thanks @jpalazz2. (#6308) + +- **Long-running installs use less memory: the default resident-session cache cap is lowered from 300 to 100.** The in-memory `SESSIONS` LRU (compact session objects) is a dominant contributor to WebUI RSS climbing over hours of continuous use; the shipped no-override default is now tuned for the common single-user install. Larger deployments can still raise it via `webui.sessions_cache_max` in `config.yaml` (preferred) or the `HERMES_WEBUI_SESSIONS_MAX` env fallback. Eviction safety is unchanged — only clean, persisted, non-active sessions are ever dropped, and an evicted session lazily reloads from its JSON sidecar on next access (no data loss). Thanks @rodboev. (#6362, #6351) + +- **Completed image tool-results are compacted from persisted history, cutting long-session memory and save/load cost.** After a turn finishes, base64 image parts in persisted *tool* results are replaced with a small `[screenshot]` text placeholder, so JSON sidecar saves/loads and session API responses no longer scale with image bytes (the active model still gets the full image while the tool call is running). The transform preserves every non-image part in place (text, documents, provider-specific parts) without flattening or reordering, only touches `role: tool` messages (user attachments are untouched), runs on all live and settled persistence writebacks, and is idempotent. Thanks @sjungwon03. (#6315, #1217) + +- **Large embedded images no longer slow down API response redaction.** Native raster image data URIs (`data:image/png|jpeg|gif|webp|bmp;base64,…`) are skipped by the credential-redaction pass instead of being scanned byte-by-byte on every response — a multi-megabyte image is opaque binary, not text a credential regex should rewrite. The fast path only applies after the *entire* base64 payload is validated (canonical round-trip + structural walk), so a string that merely starts with an image header but carries trailing or appended text still goes through full redaction — a credential can never ride along in an image's tail. Non-image strings, and sibling secrets in the same payload, are unaffected. Thanks @inch772. (#6311) + +- **Gateway-backed sessions can once again send with a MoA (Mixture-of-Agents) default model.** A legitimate gateway-default MoA send was being rejected as if it were a disallowed per-request override. The chat-start handler now distinguishes the two: a send is allowed only when the *configured* provider is MoA, the resolved model matches the configured default, and no explicit picker override was made — all evaluated against one request-owned config snapshot captured atomically, so a concurrent profile/config change can't flip the decision mid-request. Explicit `moa_config` overrides on gateway sessions still return 409. Thanks @rodboev. (#5869, #5853) + +- **Content-search and sidebar polling are faster: run-journal summaries are now cached.** Repeated reads of an unchanged run journal reuse a cached summary instead of re-parsing the whole file, keyed on a complete filesystem signature (device, inode, size, mtime, and ctime) so the cache is always dropped the instant the journal is appended to, replaced, truncated, or recreated — including a same-size rewrite that preserves mtime. Bounded LRU; a first-write race can never pin a stale empty result. Thanks @sjungwon03. (#6291) + +- **Live Stream: a settled Worklog now shows the side effects a turn performed (memory/skill saves).** The `state_saved` SSE event (memory + skill saves) was classified as an Assistant-Turn-Anchor side effect but the renderer-neutral scene projection discarded it, so the browser only flashed a toast and a reloaded Worklog lost that record. Anchor `artifacts`/`side_effects` are now projected into the settled `activity_scene_v1`. Purely additive — turns with no side effects render byte-identical (side-effect-only turns still don't force a Worklog), and the change is compatible with the stable-run-identity plumbing. Thanks @franksong2702. (#6204, #3400) + +- **Kanban board columns are now scrollable on mobile.** On a phone, a touch-drag inside a Kanban column was trapped in the column's nested scroller, so you couldn't scroll the board. The mobile layout now lets the column overscroll chain to the page/section, restoring natural scrolling; desktop and the pull-to-refresh behavior are unchanged. Thanks @jpalazz2. (#6306) + +- **Live Stream: Assistant Turn Anchors now carry a stable run identity through the settled scene.** The activity-scene projection now threads a stable `run_id` (independent of the transport `stream_id`) through Anchor scenes and client hydration, so a settled/reloaded Worklog keeps a consistent identity and can't mis-own or duplicate rows if a producer ever emits `run_id != stream_id`. Behavior-preserving for current writers (which still emit `run_id == stream_id`) — verified byte-identical live/settle/reload render, with malformed/conflicting identities falling back to the transport identity. Thanks @franksong2702. (#6201, #3400) + +- **Content search no longer evicts your open sessions from the cache.** A content search scans many sessions, and each scan used to promote/cold-load sessions into the in-memory LRU — evicting the sessions you actually have open. Search now reads through a scan-only accessor that keeps the same disk-freshness, journal-recovery, and state.db reconciliation (so recent content stays searchable) but doesn't disturb the working-set cache. Thanks @ai-ag2026. (#6084, #6083) + +- **Watching the same terminal from two tabs no longer splits the output stream.** Terminal output is now broadcast to every subscribed viewer instead of draining from one shared queue, so a second tab (or a reconnect) sees the full stream rather than a random half. SSE responses carry event `id:`s and a reconnecting viewer replays only events after its `Last-Event-ID` cursor (fresh viewers still get the full bounded backlog). Thanks @ai-ag2026. (#5836) + +- **A turn that ends in an error no longer shows impossible "still running" tool work or a wrong duration.** On a terminal error, the settled (and reloaded) Worklog now freezes the turn's duration before cleanup and seals any still-projected tool rows as done, so a settled/reloaded transcript can't disagree with the terminal state. Successful and cancel paths are unchanged. Thanks @webtecnica. (#6323, #6309) + +- **Terminal-error Worklog settlement is hardened further (follow-up to the fix above).** The inline turn-duration freeze is consolidated into a single `_terminal_turn_duration()` helper that returns *no* duration (rather than a manufactured `0s`) for a missing, non-finite, negative, or otherwise invalid timer origin — including a wildly out-of-range start value — so an invalid origin can never abort the terminal-error settlement path before the error message is persisted. The still-projected-tool-row sealing is now applied identically on both the frontend and the backend anchor-scene projector (a stream-owned running row without a settled assistant index seals to completed), and transcript-owned historical rows are explicitly left untouched. Thanks @franksong2702. (#6312, #6309) + +- **Docker: experimental builds are now published to ghcr.** The image release workflow previously fired only on stable `v*` tags, so Docker/Portainer users couldn't track the experimental channel at all. It now also builds on `exp-v*` tags, publishing a floating `:experimental` tag (and marking the GitHub release as a prerelease) while the stable `:latest` / `v*` path is unchanged. Thanks @webtecnica. (#6329, #6298) + +- **A gateway command-approval card with no `approval_id` is now respondable.** When a gateway `approval.request` omits both `approval_id` and the legacy `id`, WebUI now synthesizes a non-empty `uuid4().hex` id instead of leaving it blank, so the approval card can be answered (matching the `#527` stable-uuid design). The downstream respond path resolves by `run_id` independently, so the synthetic id causes no mismatch; a provided or legacy id is preserved verbatim. Thanks @webtecnica. (#6168, #6008) + +- **The update check no longer lets an unexpected exception escape the route.** `POST /api/updates/check` now wraps `check_for_updates()` in a defensive guard that returns a clean `500` (and logs) instead of propagating, giving the endpoint the same exception protection as its siblings. Defensive hardening only — it does not change the success path. Thanks @webtecnica. (#6180) + +- **Test-infra: state-dir probe subprocesses can no longer touch real user state.** The `#4449` state-dir contract test spawned import-only probes that inherited the real user home, so an `import api.config` could reconcile pytest workspace defaults into the actual `settings.json`. The probes now pin `HOME`/`USERPROFILE`/`LOCALAPPDATA` to fixture-owned paths and clear inherited redirect vars, with byte-for-byte sentinel coverage (incl. native Windows paths). Test-only. Thanks @pxxD1998. (#6305) + +- **A background terminal process that finishes after a WebUI restart now wakes its original session.** A `terminal(background=True, notify_on_complete=True)` child can outlive a WebUI restart, but the restarted WebUI previously started only the completion-queue drain and never recovered the checkpointed process registry — so its in-memory registry and session index started empty and the eventual `proc_*` completion had nowhere to route. WebUI startup now recovers checkpointed core background processes and rebuilds the session index (validating the owning session still exists) before the drain begins. Recovery is one-shot, lock-serialized, and fail-soft — a missing agent or corrupt checkpoint degrades gracefully without blocking startup. This complements the async-delegation delivery fix below; the ordinary `proc_*` and durable `async_delegation` paths stay separate. Thanks @allenliang2022. (#6287) + +- **The test suite's server fixture no longer fails on native Windows without symlink privilege.** The session-scoped test server seeds a `skills` directory by symlinking the real one; on native Windows without `SeCreateSymbolicLinkPrivilege` (Developer Mode off / non-admin) that raises `WinError 1314` and broke the whole suite. It now falls back to a read-only-preserving copy on that specific error, leaving the symlink path unchanged everywhere else. Test-infra only. Thanks @rodboev. (#6276, #6240) + +- **Async delegation (`delegate_task`) results now wake the exact browser tab that started them, exactly once, even across a WebUI restart.** Two problems on the async-completion delivery path are fixed together. (1) *Misroute:* a detached completion was routed by a mutable session-key index, so a result could wake the wrong session; it now routes by the immutable `origin_ui_session_id` return address captured from the commissioning turn, which is authoritative over the index on both delivery paths (the background wakeup and the next-turn drain). (2) *Duplicate on restart:* the completion is now delivered through a durable claim/complete/release lifecycle and acknowledged only after the wakeup turn is accepted, so the restart-restore sweep no longer re-enqueues an already-delivered result. Every failure path (formatter, routing, turn-start, ACK) releases the claim and keeps the result retryable rather than dropping it, and an unrouteable async event is handed to a bounded retry instead of being silently stranded. Thanks @carlotestor (#6185) and @sysophelper-droid (#6159). (#6185, #6159, #6002, #6225) + +- **The Artifacts tab now shows each artifact's file name first, with the directory path secondary.** Artifact rows previously rendered the full path on one line, so the file name (the part you scan for) was truncated at the end of a long path. The name now renders prominently with the directory shown smaller and muted beneath it. Thanks @webtecnica. (#6161) + +- **A session row's streaming indicator now reflects that session's own activity, not a child session's.** The sidebar "streaming" state was derived from a combined flag that was also true when a *child* (subagent) session was streaming, so a row could show as busy on its child's behalf. It now keys off the session's own streaming state. Thanks @webtecnica. (#6165) + +- **`prefers-reduced-motion` now also suppresses the message-row color transition.** Message rows were missing from the reduced-motion transition-disable rule, so a color change (e.g. on theme or state change) still animated for users who asked for reduced motion. `.msg-row` is now included. Thanks @webtecnica. (#6166) + +- **Deliberately picking a model whose bare id also exists under the default provider no longer reverts to the default on re-render.** When a model id (e.g. `glm-5.2`) is offered by both the default provider and another provider, and the provider hint was momentarily unavailable during a dropdown rebuild, the picker's normalized-match fallback snapped to the first (default) group — silently reverting a deliberate non-default pick. The resolver now returns no match for an ambiguous bare id with no provider hint, so the caller injects the correctly provider-scoped option instead of asserting the wrong provider; hinted, uniquely-named, and exact-match lookups are unchanged. Thanks @webtecnica. (#6199, #6195) + +- **Model-emitted base64 images now render as images instead of dumping raw base64 text.** A `data:image/*` URI in an assistant message — whether as `![alt](data:image/png;base64,...)`, a raw ``, or a `MEDIA:data:...` reference — previously rendered as a wall of base64 text (or was swallowed entirely), and `![alt](file:///x.png)` produced a stray `!` anchor. Data-image URIs now render inline under a strict allowlist (raster formats plus base64-only SVG, a safe payload charset, and a 2 MB cap), and `![](file://)` routes through the media pipeline. Every other `data:` scheme (`data:text/html`, etc.) stays inert text and is never embedded; the sanitizer permits `data:` for `` only, and the streaming renderer enforces the same predicate so an image can't render mid-stream then vanish at settle. Base64 SVG renders via `` (scripts can't execute in that context). Thanks @ai-ag2026. (#6209) + +- **A single configured model no longer appears multiple times in the model picker.** A configured model was registered under its bare id, `provider/model`, AND `@provider:model` forms, so the picker treated equivalent routing ids as separate entries and showed the same model more than once. Dedup now collapses only genuinely-equivalent entries (same normalized id **and** same provider), so two different providers offering the same bare model id (e.g. both offer `gpt-4o`) stay distinct while a truly-one-model-registered-three-ways case collapses to one badge — applied to both the live and static catalog builders. A picked provider badge resolves, sends, and persists as that provider (no snap-back to a first-match provider on a bare-id collision), and a colon-bearing model id (e.g. `model-a:free`) synthesized as a missing-catalog fallback reads its authoritative `data-provider` rather than mis-parsing at the last colon. Thanks @happy5318. (#6221, #3691) + +- **Reopening a WebUI tab after a large active session is fast again, even when the session has few messages but large tool outputs.** The reconnect display-path tail optimization only fired based on message *count*, so a session with a handful of messages but multi-MB tool-call JSON forced a slow full-scan merge (bootstrap could take tens of seconds). The optimization now also fires when the session sidecar file exceeds 500 KB, regardless of message count — the same messages are returned (verified: identical message set, count, offset, and truncation metadata), just via the fast path. Thanks @webtecnica. (#6260) + +- **OIDC allowlist values containing spaces (e.g. a group named `Hermes Users`) are no longer split into separate entries.** The allowlist parser split comma-separated values on all whitespace, so a multi-word group/user name became two spurious entries. Allowlist parsing now splits only on commas/newlines (multi-word values stay intact and match their exact signed claim — strictly tightening, no security loosening), while OAuth **scope** parsing keeps its space-delimited behavior per RFC 6749 §3.3. A blank allowlist entry no longer bricks an OIDC-only deployment. Thanks @webtecnica. (#6244) + +- **`/sessions` and `/resume` typed in the composer now open the session browser instead of being sent as chat text.** Both commands appeared in the slash-command autocomplete (they're exposed by `/api/commands`) but the WebUI send-time dispatch didn't handle them, so they were posted to the agent as plain text. They now open the native WebUI session browser and clear the composer — on desktop and (via the mobile-aware opener) on phone-width layouts. Thanks @webtecnica. (#6245, #6224) + +- **Z.AI GLM models now advertise the correct reasoning controls per version.** The WebUI offered the full `reasoning_effort` ladder (max/xhigh/high/medium/low/minimal) for every GLM model, but per Z.AI's API only GLM-5.2+ supports the effort ladder — GLM-4.5 through 5.1 support the binary `thinking` on/off toggle, and GLM-4.7 forces thinking on (not configurable). Reasoning controls are now gated correctly: GLM-5.2+ keeps the full ladder + None, GLM-4.5/4.6/5.0/5.1 get a two-way On/None thinking toggle (no effort levels), and GLM-4.7 shows forced thinking with no toggle. All three surfaces (config status, effort resolution, composer chip) agree across model tiers, provider aliases, and namespaces. Thanks @rh-id. (#6219) + +- **Folder downloads now work when the WebUI is served under a subpath.** The file-tree "Download" context-menu action built an absolute `/api/folder/download?...` URL, which broke behind a reverse proxy that mounts the app under a base path (e.g. `/hermes/`). It now resolves the download URL against `document.baseURI` (matching the pattern the other subpath-aware download paths already use), so it works under any base path and is unchanged on root deployments. Thanks @steezypunk. (#6227) + +- **Reloading an active turn now prefers the durable run-journal over a stale browser cache, and never truncates recovered tool arguments.** On a hard reload/recovery of a live turn, `loadSession()` could keep a stale local cache as the Worklog authority over the server's newer run-journal snapshot — or, worse, mistake a legacy cache entry with no stream identity for the current stream and suppress replay of a newer run's events. Recovery now selects by stream identity first, then run-journal sequence (`_selectLiveRecoveryInflight`), and carries a run-aware `after_event_id` cursor through the whole reconnect pipeline (server-snapshot hydration, in-memory INFLIGHT, localStorage compaction, session-switch persistence, SSE reconnect), resetting the cursor on stream-ownership change. The durable snapshot also preserves full tool-call arguments (previously truncated to 120 chars) so a recovered command/input is never silently shortened, while a shared content-aware bound (`bound_run_journal_snapshot_args`) caps pathological payloads so the snapshot can't balloon. Fixes #6196. Thanks @franksong2702. (#6197, #6196) + +- **The "Compressing context" card no longer flashes on turns that aren't actually compressing.** The card was driven by a broad status match (anything containing `compressing`), so skip/cooldown/defer notices and post-compression summaries (`Compressed X → Y`) could paint a phantom compression card mid-turn. A dedicated `_is_agent_compression_start_status()` now recognizes only the five genuine start-of-compression markers the Hermes agent emits, and the transcript renderer no longer lets an unrecognized `terminal_status` row paint a compression anchor. Real compression turns still show the card unchanged; verified against every current agent compression emitter. Thanks @carlotestor. (#6184) + +- **Transparent Stream no longer shows the start of the final answer twice after a multi-segment turn settles.** In a turn with interim assistant prose interleaved with tool calls, the beginning of the final answer could render twice after the live→settled transition — once as a stale live-token accumulator snapshot, once as the real assistant segment — persisting until reload. The earlier suppression gate (#5758) computed its "after the last tool row" boundary over the combined row list, where settled per-message tool rows re-list tools that ran earlier in the turn, pushing the boundary past the final segment's accumulator so the dedupe never fired. Final-segment eligibility is now judged against the live projection's own chronology (a live-prose row belongs to the final segment only when no *projected* tool row follows it), so the stale prefix is dropped while pre-tool narration and every tool/thinking row are preserved. Thanks @ai-ag2026. (#6189, #5749) + +- **The frontend now reads the `msg_limit` ceiling from `/api/session` metadata instead of hand-mirroring the constant.** Following the ceiling clamp shipped in exp-v0.52.98, the backend advertises its `_MAX_MSG_LIMIT` as `_msg_limit_max` in every `/api/session` response, and the frontend reads it dynamically (`_msgLimitMax`, a module-scope value defaulting to the static fallback), so the two can no longer drift and the mirrored constant's drift-guard test is no longer needed. Older backends that omit the field fall back to the built-in default, so mixed-version deployments are unaffected. Thanks @webtecnica. (#6214, #6177) + +- **`GET /api/session?msg_limit=` is now bounded by a server-side ceiling, and the frontend paginates around it without dropping rows.** A client could request `msg_limit=1000000` (or an outline-jump path asked for `9999`) and force the server to assemble and serialize an unbounded message payload. The backend now clamps `?msg_limit=` to `[1, 500]` via a dedicated `_parse_msg_limit()` helper and sets the existing `_messages_truncated` signal when it clamps; the bare no-`msg_limit` path (branch/undo/jump-to-start) still returns the full transcript. The frontend mirrors the ceiling: `_loadOlderMessages` grows its tail window below the ceiling and switches to bounded `msg_before` backward paging once the server would clamp, and the outline jump uses the bare full-transcript path instead of the old `msg_limit=9999` hack. Two silent row-loss regressions found during review were fixed before ship: a raw-row-heavy `msg_before` page that textually repeated the current tail could be misclassified as the cumulative tail and wholesale-replace it (losing older rows), and a same-session refresh above the ceiling could shrink an already-loaded >500-row transcript to the last 500 — both now route through the correct paging/full-transcript path. Thanks @rh-id. (#6152, #6154, #6177) + +- **Background subagents no longer fail to persist when the parent's cached agent is reused mid-turn.** A parent session's `SessionDB` handle is shared by reference with the background subagents it spawns (`delegate_tool`), but the cached-agent reuse path unconditionally closed that handle when the parent got a new turn / server-side wakeup — so every still-running child then failed `append_message` with `'NoneType' object has no attribute 'execute'` (observed 600+ times under one parent). The reuse path now keeps a still-open handle (closing only the unused fresh one, preserving the #1421 FD-leak fix) and only replaces a dead/missing one. The credential self-heal path was hardened the same way: if rebuilding the handle fails it degrades cleanly to a lazy reinit instead of reusing a closed handle. Thanks @carlotestor. (#6143) + +- **The CLI/cron session-list projection cache is now bounded.** `_CLI_SESSIONS_CACHE` was an unbounded plain dict of deep-copied session-list projections whose fingerprint-folded key advances on every streamed message, so orphaned heavy copies accumulated on a long-lived server until the next structural clear. It's now an `OrderedDict` capped at 8 entries with drop-oldest LRU eviction (recency refresh on hit), mirroring the existing `_CLAUDE_CODE_PARSE_CACHE` / `_SIDECAR_METADATA_CACHE` pattern; TTL stays the freshness control. Thanks @rh-id. (#6140) + +- **Models selected from a list-shaped custom-provider allowlist now route to the right provider.** A `custom_providers[].models` allowlist can be written as either a mapping or a list, and the model picker accepted both — but runtime routing only read mapping keys, so a model chosen from a *list*-shaped allowlist could silently stay on the active/default provider instead of its own. The supported-model-ID extraction is now centralized in one helper used by both catalog construction and routing, so the picker and runtime can no longer drift. Existing mapping-shaped and list-of-strings configs are unaffected. Thanks @franksong2702. (#6127, #6121) + +- **Stale sidebar approval attention badge now clears once its gateway approval has drained.** A parent session could keep showing the red approval "attention" dot in the sidebar after the underlying gateway approval was already resolved/gone — the live gateway queue drained but the sidebar summary kept the mirrored approval alive. `_session_attention_summary()` now reconciles the gateway pending-mirror before counting attention (the same self-healing repair the approval-pending route already does), so the badge clears correctly. Live approvals stay visible and actionable and the clarify fallback is unchanged. This is the WebUI read-path mitigation; the child-session context-propagation root cause stays tracked in #6100. Thanks @rodboev. (#6117, #6100) + +- **The mobile composer model picker no longer opens off-screen.** On phones the model dropdown could open below the fold (looking like it "won't open", issue #6080) because it lived inside `.composer-footer`, whose `container-type` (and, under the Geist Contrast skin, `backdrop-filter`) made the footer the containing block for the dropdown's fixed positioning — so it anchored to the bottom-of-screen footer instead of the viewport. The picker now reparents to `` while open (the same idiom the profile dropdown already uses), so its fixed positioning resolves against the visual viewport on every skin; it flips above/below the anchor by available space, clamps below the titlebar, and re-positions as the on-screen keyboard / URL bar move the visual viewport. Desktop behavior is unchanged. Thanks @webtecnica. (#6105, #6080) + +- **Approval and clarify popups no longer collapse to tiny blank windows on very narrow screens.** On very narrow viewports the clarify card could shrink to an unreadable sliver; it now gets a `min-width` and `overflow:visible` matching the approval card. Thanks @webtecnica. (#6104, #6094) + +- **Stream event timestamps no longer break the row layout on narrow foldable screens.** On screens under 360px the per-event timestamps could spill out of tool/thinking card headers; the headers now clip overflow so the timestamp layout holds. (Tool names stay visible — an earlier draft hid them at ≤360px, which lost tool identity; the header clip alone prevents the spill.) Thanks @webtecnica. (#6106, #6099) + +- **Streaming sidebar polls no longer re-run the expensive CLI/cron projection every few seconds.** The per-stream cache TTL was pinned at 30s while the frontend's streaming sidebar poll cadence could approach it, so the state.db CLI/cron projection (and its ~200 sidecar reads) could be re-paid on nearly every poll during a live turn (the #4842/#4808 high-CPU `get_cli_sessions` case). The streaming TTL is now held strictly above the sidebar poll interval (45s) and paired with the stable streaming cache key, so repeated polls reuse the projection; a new test asserts the TTL stays above the poll cadence so this can't silently regress. Thanks @starship-s. (#6102, #4842) + +- **Delegated subagent sessions in the sidebar now show a meaningful title instead of "Subagent Session".** Generic delegated child sessions previously all displayed the placeholder "Subagent Session" in the conversations list, making them indistinguishable. Their sidebar title is now derived (view-only) from the child's first user message during the existing batched sidebar-metadata pass — without mutating the stored session title, so the read-only boundary is preserved. Only generic "Subagent Session" placeholders are affected; custom titles are left untouched. Thanks @rodboev. (#6056, #6033) + +- **Concurrent remote-gateway health checks no longer stampede the gateway.** When many requests needed a remote-gateway health probe at once, each fired its own probe (a thundering herd). Probes are now single-flighted: one "leader" thread probes while latecomers wait (bounded) and share the result, guarded by a single condition/lock with a `finally` that always releases waiters even if the probe errors. Thanks @ai-ag2026. (#5798, #5455) + +- **Appending run-journal events no longer gets slower as a session's journal grows.** The next sequence number was recomputed by scanning existing entries on every append (O(n²) over a session's lifetime); it's now cached per journal path (guarded by a dedicated lock, evicted on journal delete), making each append O(1). Thanks @ai-ag2026. (#5799) + +- **The login page's connectivity retry timer is now cleared correctly.** The retry poller is started with `setInterval` but was cleared with `clearTimeout`, so once the server came back the interval kept firing (a zombie timer) instead of stopping and reloading once. It now uses `clearInterval`. Thanks @ai-ag2026. (#5801) + +- **A background wakeup no longer eats the assistant reply that preceded it.** When a background-process wakeup prompt arrived after an assistant response, the response could disappear from the interactive transcript (it remained in the DB and export). Background wakeups now render as their own distinct "Background wakeup" status row aligned to the message rail — with attachment support — leaving the prior reply intact. Thanks @Isla-Liu. (#5766) + +- **Service-tier (fast-mode) support is now derived from the agent's model metadata instead of a hardcoded list.** The service-tier control appears for a model only when the agent reports it supports the fast tier, so newly-supported models light up automatically and unsupported ones don't show a dead control. Falls back safely (no control) when the metadata is absent. Thanks @starship-s. (#5762, #4536) + +- **Live reasoning no longer briefly renders twice while a reply streams.** The streaming path updated the reasoning display through two renderers at once (the anchored reasoning block and the live thinking card), which could momentarily show the reasoning twice. It now routes through a single renderer — the live thinking card only updates when the anchored path didn't handle it. Thanks @rodboev. (#5773, #5720) + +- **A wide or tall code block in the chat no longer thrashes the surrounding layout while a reply streams.** Code blocks now use CSS `contain: content`, so a growing/overflowing `
` is isolated to its own rounded container (with its own horizontal scroll) instead of reflowing the message column during streaming. Thanks @rodboev. (#5770, #5760)
+
+- **Workspace switcher is now navigable with a screen reader.** The workspace switcher exposes proper roles/labels and announces the "New Chat" workspace state transiently, and the closed workspace dropdown is kept out of screen-reader navigation so it isn't read while collapsed. Visual layout and click/keyboard behavior are unchanged. Thanks @rodboev. (#5721, #5671)
+
+- **A background child stream no longer bumps its parent session to the top of the sidebar.** When a child (branched/background) stream was active, its activity re-sorted the parent session's sidebar time-bucket, yanking the parent around while you were working elsewhere. The sidebar sort no longer keys the parent's bucket off child-stream activity, so parents stay put. Thanks @rodboev. (#5729, #5699)
+
+- **The embedded terminal now recovers from a dropped connection instead of silently freezing.** A transport-level failure on the terminal's output stream (session expired, gateway restarted, network drop) fired an EventSource `error` with no handler, so the terminal froze with no feedback. It now shows a "connection lost, reconnecting…" / "disconnected" line, lets the browser auto-reconnect a recoverable connection, and tears down a permanently-closed one so a restart can reconnect — notifying once per outage so a flapping connection can't flood the pane. Thanks @ai-ag2026. (#5786)
+
+- **The OpenCode Go model picker now lists the current flat-rate catalog.** The static `opencode-go` model snapshot was stale; it's been re-synced from the public opencode.ai/go docs + models endpoint (adds MiniMax M3, Kimi K2.7 Code, GLM-5.2, Qwen 3.7 Max/Plus, and more), keeping preview/free-only Zen models out of the Go picker. Thanks @ai-ag2026. (#5761)
+
+- **Passkey login no longer risks a hung or mis-framed response on keep-alive connections.** The passkey-login success `200` was written without a `Content-Length` header, so a client on a persistent connection could wait for a body boundary that never came or mis-frame the next response. The response now sends an explicit `Content-Length` matching the body's byte length. Thanks @ai-ag2026. (#5785)
+
+- **Deleting a session now evicts its run-journal writer locks instead of leaking them.** The per-run-journal writer-lock cache (`_WRITER_LOCKS`) kept an entry after a session's journal was deleted, so the cache grew unbounded, and a later journal reusing the same path could be handed a stale lock. `delete_run_journal` now evicts that session's lock-cache entries — but only after confirming the directory was actually removed, so a Windows/permission transient that leaves files (locks still live) never has its lock wrongly dropped out from under a writer. Thanks @ai-ag2026. (#5784)
+
+- **The background reaper and drain daemon threads can no longer be started twice under a race.** `start_session_channel_reaper` and `start_drain_thread` used a check-then-act (`is_alive()` then create) with no lock, so two concurrent callers could both see "not running" and each spawn a thread — the loser's thread was never referenced by the module and ran forever, un-joinable. Both start paths now perform the liveness check, thread creation, and global assignment atomically under a dedicated lifecycle lock. Stop/join still run outside the lock, so there's no deadlock. Thanks @ai-ag2026. (#5783)
+
+- **Session-lineage reads and the gateway session-list watcher now open the agent's `state.db` read-only.** Reading session lineage/parent-child metadata and the watcher's cheap change-fingerprint opened a *writable* SQLite handle on the live, potentially multi-GB WAL `state.db` while the agent was streaming into it — adding needless checkpoint/lock surface to a pure read. These three read paths now open a read-only (`mode=ro`) connection, falling back to a writable one (with a warning) only if the read-only open fails on an exotic filesystem, and raising rather than letting the fallback create an empty "ghost" `state.db` at a missing path. Same rationale as the #5455 session-listing read-only path. Thanks @ai-ag2026. (#5780, #5455)
+
+- **The MCP servers/tools list now reflects the active profile instead of the default one.** When viewing MCP servers or tools while a non-default profile was active, the list was read from the ambient (default) profile's config rather than the profile you were actually in, so it could show the wrong profile's MCP servers. Both the server-list and tool-list reads are now scoped to the active profile's config home, matching where the MCP write handlers save. Deployments that point config at an external path via `HERMES_CONFIG_PATH` now also read that override consistently on the active-home path. Thanks @rodboev. (#5772, #5619)
+
+- **Mobile/touch: the chat no longer jumps backward while a reply streams and you're scrolled up reading history (non-virtualized transcripts).** Companion to the desktop and virtualized fixes (#5637/#5638/#5742) for the one code path they didn't cover: with transcript virtualization turned off (#4325 opt-out), a re-render rebuilt every row from scratch, and a fresh off-screen tall user row under `content-visibility: auto` (active on coarse-pointer devices) reserved only its flat placeholder height instead of its real height — so `scrollHeight` shrank, the browser clamped `scrollTop`, and the viewport jumped toward the top. The rebuild now reserves each user row's real height, measured from the still-laid-out rows just before the wipe (with a CJK-aware estimate as the backstop for never-painted rows and a `max(remembered, estimate)` floor so a partial paint can't under-reserve). Desktop is unchanged (the placeholder is inert there). Thanks @allenliang2022. (#5751, #5744)
+
+- **Desktop: the reading position no longer jumps up while a streaming reply grows above it.** When you scrolled up to read earlier history during an active streaming reply, each live activity refresh restored a stale absolute scroll offset (captured before the reply grew content above the viewport), yanking the reader up by a few hundred pixels. The desktop restore now holds the reader on the same content by realigning to a surviving above-viewport row's measured offset (the app's own `scrollTop += delta` idiom) rather than the stale absolute value — with a top-spacer-delta fallback when the anchor row is gone, and a raw fallback when neither is measurable. Content growing *below* a scrolled-up reader no longer drifts them down either. Touch/tablet behavior is unchanged (the browser's native scroll anchoring holds there). Thanks @allenliang2022. (#5742, #5637)
+
+- **Cache-hit rate and per-session usage counters now populate correctly in Insights.** WebUI-tracked usage counters (prompt-cache read/write tokens and API-call count) were kept only in the in-memory session sidecar and never mirrored to `state.db`, so the Insights/reporting views that read from `state.db` showed a false 0% cache-hit rate and missing per-session call counts. Those counters are now synced to `state.db` at every usage-sync site. The API-call count uses a monotonic floor (`max(incoming, existing)`) so a rebuilt agent (server restart, session-cache eviction) whose in-memory counter reset to 0 can never clobber the durable running total downward. Thanks @kosta963. (#5463)
+
+- **A normal message no longer gets stuck after the server restarts mid-reply.** If the server restarted or crashed while a reply was streaming, the browser could hold a stale "streaming" state and misroute your next normal send into the steer path, where it failed and left the composer stuck busy. The client now recognizes a proven-dead stream and clears that stale busy state (also clearing the leftover status line, elapsed timer, and streaming badge) so you can send again — while preserving your live turn, draft text, and staged files when steering is merely temporarily unavailable. Thanks @rodboev. (#5723)
+
+- **The message composer no longer hides behind the on-screen keyboard on iPad.** On touch tablets (iPad Safari), the soft keyboard shrinks the visual viewport but not the layout viewport, so the bottom-docked composer was left covered by the keyboard. The composer now lifts to sit just above the keyboard, tracking it as it shows and hides, and drops back to normal when the keyboard closes. It's a no-op on desktop, on devices with a hardware keyboard/trackpad, and while the page is pinch-zoomed. Thanks @rodboev. (#5701)
+
+- **Turns no longer fail with an HTTP 400 from strict providers on empty tool-call history.** Some stored assistant messages carry an empty `tool_calls: []` array, which DeepSeek v4 and newer OpenAI models reject outright ("empty array. Expected an array with minimum length 1"). The model-facing history sanitizer now drops an empty `tool_calls` key on every path that builds the API payload, so a session that accumulated such a message can still send. Populated tool-call chains and their linked results are untouched. Thanks @Swanzb. (#5737)
+
+- **Switching profiles no longer occasionally sticks a session to the wrong provider.** When you switched profiles or tabs while the model dropdown was mid-rebuild, the app could read the *previous* profile's selected model and stamp its provider (e.g. `ollama`) onto a model that doesn't belong to it. That wrong provider got saved to the session and re-sent on every turn, bricking it with a "Provider 'X' is set… but no API key was found" error for a provider the session never used. The provider is now resolved from the dropdown option whose value actually matches the model being sent, so the mismatch can't happen. Credit @b3nw for the root-cause trace. (#5567)
+
+- **Clicking a session link that belongs to another profile now switches to that profile instead of failing.** A valid `session://` deep link pointing at a session owned by a different Hermes profile used to look identical to a deleted session — the UI showed "Session not available in web UI" and self-healed away. Now the WebUI recognizes a valid-but-wrong-profile session, offers to switch to the owning profile, and retries the load once; truly missing/deleted sessions still self-heal as before. No foreign-profile transcript is ever returned — the mismatch response carries only the owning profile name. Thanks @harcek. (#5419)
+
+- **Windows: git operations no longer leave hidden console windows piling up.** On Windows, every workspace git command (`_run_git`) and git-config probe spawned a short-lived `git.exe` whose console window could flash and accumulate as orphaned `conhost.exe` processes over a long session. Those subprocess calls now pass `CREATE_NO_WINDOW` so no window is created. Safe no-op on macOS/Linux, and it uses a small in-module helper (mirroring the update path) rather than a hard dependency on the optional agent package, so a standalone WebUI keeps full git functionality. Thanks @jin89ho. (#5692)
+
+- **The agent's closing summary now shows when it hits the iteration limit.** When a turn exhausted its tool/iteration budget, the agent produces a graceful closing message (a summary of what it got done, or a fallback like "I reached the iteration limit…"), but the WebUI showed a bare `tool_limit_reached` error card instead — most visible with reasoning-only models (extended-thinking Claude, DeepSeek, Codex Responses) whose summary arrives empty. The closing text is now surfaced as a proper assistant message. Purely additive and guarded — it never fires on a turn that already produced a final answer, never double-injects, and degrades to the old behavior when no fallback text is available. Thanks @claw-io (co-authored by @b3nw). (#5717, #5494)
+
+- **The workspace file tree no longer jumps to the top when you expand a folder.** Every folder expand/collapse, breadcrumb navigation, refresh, and hidden-files toggle re-rendered the tree by wiping its container, which collapsed its height and made the browser reset the scroll position to the top — teleporting you away from where you were in a long tree. The scroll position is now captured before the re-render and restored after. Reported by @claw-io. (#5664, #5657)
+
+- **Self-update recovers when a `.git/index.lock` is blocking it.** If "Update Now" failed because a stale `.git/index.lock` was present (a previous git process left it behind), the WebUI gave no in-UI way out — you were stuck on an un-updatable install. The update banner now detects that specific failure, shows the exact `rm -f …/.git/index.lock` command to run on the host, and offers a "Retry update" button that re-runs the normal update once the lock is gone. The server never deletes anything under `.git` itself (fail-closed — git's lock can't be safely auto-cleared without racing), so this is purely a detect-and-guide recovery. Thanks @b3nw. (#5688, #5687)
+
+- **Profile list no longer serves stale rows after a change.** The session-load perf pass (v0.51.908) bumped the profile-list cache TTL to 60s, but profile-row mutations (default model / providers / skills / gateway config) don't invalidate that cache, so a changed profile could show stale details for up to a minute. Reverted the TTL to 4s — frequent enough that mutation-to-refresh staleness is negligible, while rapid dropdown re-opens stay cheap. Thanks @Kopamed. (#5696)
+
+- **Faster session loads on long conversations.** Opening a session with thousands of messages was taking multiple seconds because `Session.compact()` re-walked the whole message list on every response (an O(N) user-message count plus a full reverse scan for the last-message timestamp), and the slow-request watchdog didn't cover the `/api/session` path. The user-count and last-message-timestamp lookups are now bounded (a tail-window scan with an exact full-scan fallback), and slow-request instrumentation covers the session-load path so future regressions are caught. Multi-second loads on large sessions drop to sub-second. Thanks @Kopamed. (#5696, #5455)
+
+- **Sidebar conversation grouping stays stable across refreshes.** Fork / compaction / delegated-subagent clusters could visibly reshuffle on every sidebar refresh — the same group flipping between "N children" and "N prior turns," message counts and nesting jumping around — because the lineage-report cache was keyed only by the group root and reused stale grouping after the authoritative tip or segment count changed. The cache key now includes the authoritative lineage tip and evicts on a segment-count mismatch, and the backend exposes a stable parent-lineage-tip id so grouping is deterministic across live refreshes. Thanks @rodboev. (#5674, #5598)
+
+- **Root fix for mobile scroll jump-back: off-screen tall messages keep their real height.** The underlying cause of the mobile jump-back family (#5637/#5638) was that a virtualization rebuild recreated user rows as fresh elements, discarding `content-visibility:auto`'s remembered size — so a tall message (e.g. a long paste) rebuilt off-screen collapsed to the flat 96px estimate, shrinking `scrollHeight` and forcing the browser to clamp or re-anchor the viewport. Each user row's real height is now remembered (keyed by a stable per-session index) and reserved on rebuild, with a content-length estimate covering rows not yet measured (floored at 96px so short rows never reserve less). Refreshed each measure pass so edits self-heal; inert on desktop. Thanks @allenliang2022. (#5672, #5638)
+
+- **Mobile: reading back in history during a stream no longer drifts backward each tick.** On Android, while a reply was streaming and you'd scrolled up into history, the viewport was nudged backward a few hundred pixels on each streaming tick (a residual of the earlier mobile jump-back). The stale viewport-anchor restore is now refused during streaming when the browser's native scroll-anchoring will hold the position instead — scoped to Android (where overflow-anchor actually compensates), leaving iOS and desktop on their existing, correct paths. Thanks @allenliang2022. (#5666, #5637)
+
+- **Scrolling up into history during a live stream no longer throws you to the top.** While a reply was streaming, a mid-stream re-render (tool completion, activity-scene refresh, clarify echo) wipes and rebuilds the transcript, momentarily collapsing its height so the browser clamps the scroll position to the top. For a reader who had scrolled up into history, that stranded them at the top (a jump of thousands of pixels). The pre-wipe viewport is now restored for an unpinned reader instead of being left clamped; readers following the tail are unaffected. Thanks @allenliang2022. (#5681)
+
+- **No more mid-stream scroll jitter while following a streaming reply.** When you were pinned to the bottom of a live-streaming answer, every mid-stream re-render (tool completion, activity-scene refresh, clarify echo) caused a brief ~1-row up-and-back bounce, so a long streaming reply visibly shuddered. The tail is now re-anchored to the settled bottom in a microtask after the re-render (after layout flushes, before paint), so the short intermediate never reaches the screen. Only a reader already following the tail is affected — an unpinned reader parked up in history is never moved. Thanks @allenliang2022. (#5685)
+
+- **Mobile drawer shows the dashboard link and extension nav-actions again.** On narrow screens (≤800px) the sidebar drawer was hiding the dashboard link and any extension-added nav-action icons, so you couldn't reach them on mobile. They now mirror into the drawer and stay in sync as they appear/disappear. Thanks @rodboev. (#5605, #5583)
+
+- **Inline Mermaid diagrams no longer collapse to zero height on mobile.** An inline diagram could render invisibly on narrow screens before its SVG laid out; it now has a min-height on mobile (scoped so the lightbox and desktop are unaffected). Thanks @nankingjing. (#5560, #5525)
+
+- **Steer file uploads no longer leak their progress across sessions.** When you steer a live turn with an attachment and switch to another conversation mid-upload, the progress bar and composer status now stay scoped to the session that owns the upload (and the steer indicator shows your original text). Thanks @ruizanthony. (#5630)
+
+- **The chat message header scales with your text-size preference.** The model name, icon, TPS badge, and timestamp in the assistant header were ignoring the font-size setting, so they looked disproportionately small at large/xlarge text; they now scale with the body. Thanks @nankingjing. (#5633)
+
+- **The three-panel desktop layout keeps the conversation readable when you resize.** With the sidebar and workspace panel both open, shrinking the window used to crush the center conversation; it now has a readable floor and the side rails yield first (desktop ≥901px; the workspace panel still fully collapses when closed). Thanks @rodboev. (#5594, #5545)
+
+- **Markdown tables render even when a row has trailing whitespace or a small leading indent.** A table whose header or separator row ended in a stray space (something LLMs emit routinely) previously fell through to raw literal pipes instead of a real ``; the renderer's table matcher now tolerates optional trailing whitespace and up to a 3-space leading indent, matching CommonMark. Thanks @rodboev. (#5644, #5641)
+
+- **No more empty italic hint under settlement error cards.** When a live turn ended in an error with no additional detail to show, the error card could render a dangling empty italic line; the hint block is now only emitted when there's actual hint text. Thanks @franksong2702. (#5653)
+
+- **Switching profiles now serves that profile's models and providers — not the previous one's.** A profile switch could keep serving the default (or previously-active) profile's model catalog and provider config: for example, a llama.cpp `custom_providers` profile showed the wrong models/keys. The config cache treated a profile-path change the same as a file-modification-time change, so an active in-memory override wrongly suppressed the reload on switch. A profile switch now forces a config reload unconditionally (while a mere file-mtime change still respects in-memory overrides). Thanks @rodboev. (#5645, #5619)
+
+- **The sidebar now shows a newly created session immediately.** Creating a new conversation from inside the WebUI left the sidebar list stale on some code paths until a manual refresh; `newSession()` now refreshes the list (coalesced, so no double-repaint). Thanks @nankingjing. (#5632, #3874)
+
+- **No more stray composer divider when the left button group is fully hidden.** If you hid all four left composer controls (attach, saved prompts, mic, voice mode), the vertical divider to their right was left orphaned; it's now hidden with them. The divider visibility is also recomputed after the voice-mode preference is applied on both the settings-load boot path and the voice-mode toggle, so it no longer boots against a stale button state. Thanks @silent-reader-cn. (#5499, #5451)
+
+- **Mobile: scrolling up into history during a streaming reply no longer jumps the viewport to the top.** On touch devices, a recent change applied `content-visibility: auto` with a flat 1px intrinsic-size estimate to every off-screen message row; tall assistant rows (long answers / large tool results) collapsed to 1px, `scrollHeight` lurched down by tens of thousands of pixels, and the browser clamped `scrollTop` back to the top. `content-visibility` is now kept only for the short, size-predictable user rows (assistant rows keep layout/style containment without the height-collapse), and when you've scrolled up and the semantic anchor can't be restored, the view now holds position via native scroll-anchoring instead of snapping to a stale offset. Thanks @allenliang2022. (#5638, #5637)
+
+- **Long transcripts no longer jump to the top when the scroll anchor row is recycled out of the virtual window.** The virtual-scroll compensation used to give up (leaving the viewport uncompensated) when its anchor row had been recycled during a re-render; it now recovers the anchor by session message index and falls back to a top-padding delta, so scroll position is preserved across re-renders. Thanks @allenliang2022. (#5635, #4346)
+
+- **Compacted (archived) messages no longer creep back into the model's context.** In-place context compaction marks old messages inactive in `state.db`, but WebUI's reconciliation reader still pulled those archived rows back into the next turn's model-facing context — undoing the compaction and making long conversations re-trigger compression every turn. The reader now excludes inactive rows by default (with an explicit opt-in for recovery/audit views), so compaction actually sticks. Schemas without the active-row marker are unaffected. Thanks @ai-ag2026. (#5626)
+
+- **Internal (test reliability): fixed a test-isolation flake in the auth/profile-cookie suite.** A memoized password-hash cache in `api.auth` wasn't reset between tests, so a test that set an auth password could make later profile-cookie tests fail spuriously depending on run order (a latent CI flake, no user impact). Added an autouse fixture that resets the cache around every test. (#5588)
+
+- **Clearing a forked conversation no longer resurrects the original conversation's messages if the original is later compressed.** When viewing a fork's full transcript, the display could stitch in the original (pre-fork) conversation's history after that original got compressed — so a cleared fork child appeared to bring back messages it shouldn't. Fork transcripts now include only their own fork-sourced history and stop at the first non-fork ancestor. Ordinary (non-fork) conversation history display is unchanged. Thanks @rodboev. (#5582, #5571)
+
+- **Steering a response now keeps your attachments.** If you had files staged in the composer and submitted them mid-response (steer), the attachments were silently dropped. They're now uploaded and included with the steer so the agent can read them, the file chips stay put if the steer is rejected (nothing lost), and retrying a rejected steer reuses the already-uploaded files instead of uploading duplicates. Files you stage while the steer is uploading are preserved. Thanks @ruizanthony. (#5459)
+
+- **Mobile sidebar header: the close (✕) and new-conversation (+) buttons are now a consistent, aligned pair.** The close button rendered a larger icon sitting slightly lower than the adjacent + button; both now use the same icon size on the same baseline, with the tap target kept large enough for comfortable mobile use. Desktop is unaffected. (mobile UI polish)
+
+- **iOS PWA: tapping a conversation now closes the sidebar immediately and reliably opens it.** On the mobile PWA, tapping an older (large) conversation left the sidebar drawer open for several seconds with only a tiny spinner — and a background refresh could cancel the switch mid-flight so the conversation never opened and the highlight jumped back. The sidebar now closes the instant you tap (matching how ChatGPT/Claude mobile dismiss the drawer on select, with a "Loading conversation…" placeholder in the chat area), and an in-flight session switch is protected from being cancelled by a concurrent background refresh. Desktop is unaffected. Thanks @luperrypf. (#5602)
+
+- **OpenCode Go: the model picker no longer lists models that 404 when you send.** OpenCode Go's live model probe returned entries from the full public catalog that aren't enabled on the Go tier, so picking one failed with "model not found" on the first message. The picker now uses the curated Go-tier model list for this provider instead of the live probe. Thanks @webtecnica. (#5611, #5311)
+
+- **The sidebar's "Retry" button now gives feedback when a conversation-list load fails.** When the session list failed to load, its Retry button behaved like an inert browser default — clicking it gave no sign anything happened while the (often slow) refetch ran, so it looked broken. Retry now shows an immediate "Retrying…" pending state, restores to "Retry" if it fails again, and is properly styled to match the sidebar. It's also accessible (the error is a polite live region; the pending button keeps keyboard focus) and can't get stuck pending if you switch profiles mid-retry. Thanks @rodboev. (#5505, #5501)
+
+- **Mobile: the command/tool approval popup is taller so its options fit without scrolling inside it.** On phones the approval card capped its height low enough that a prompt with several options (or a long command) pushed the bottom actions off the visible area, forcing you to scroll within the cramped popup to reach them. The mobile height cap is raised (from `min(52dvh, 360px)` to `min(60dvh, 420px)`), so a typical approval prompt now shows all its options at once; very tall content still scrolls inside the card, and desktop is unaffected. Thanks @nankingjing. (#5539, #5385)
+
+- **Security: CORS preflight no longer advertises wildcard cross-origin access.** The server answered every CORS preflight (`OPTIONS`) with `Access-Control-Allow-Origin: *`, which on a password-less deployment could let any website read authenticated responses. The preflight now echoes the request's origin only when it's same-origin or explicitly allowlisted via `HERMES_WEBUI_ALLOWED_ORIGINS` — the same policy the CSRF gate already enforces for real requests — and never emits `*`. Default deployments (no allowlist) are unaffected. Thanks @mo7al876any. (#5534)
+
+- **Clearing a conversation can no longer be undone by crash recovery — even after you send a new message.** `Clear conversation` writes a one-time `.json.bak` before wiping the transcript; if the app then crashed or restarted, startup recovery saw the backup as "larger" than the cleared session and could restore the cleared history — including on top of a message you'd sent after clearing. Clear now stamps a unique per-clear marker on the session, and recovery treats a backup that predates that marker as stale, so a cleared conversation stays cleared. Genuine crash-loss recovery (unmarked or same-generation backups, unreadable/partial files) still restores as before. Hardens the clear-conversation data-loss fix from the prior releases. Thanks @rodboev. (#5584, #5570)
+
+- **A message containing a broken emoji (lone Unicode surrogate) no longer breaks scroll position tracking.** If a message's text held a lone UTF-16 surrogate — e.g. a half-copied emoji from a truncated paste or interrupted stream — computing that message's scroll-anchor key threw an error, which broke scroll-position restore for the whole transcript. The anchor-key builder now handles lone surrogates safely (valid emoji are preserved; only the broken half is dropped), so scroll anchoring keeps working. Thanks @rumotoshino. (#5573, #5552)
+
+- **An in-progress CLI session now shows in the sidebar before its final output lands.** A CLI/agent session that had real activity (messages and user turns) but hadn't produced its final output yet was hidden from the sidebar until it completed, so an actively-running CLI session could be invisible while you waited on it. Such a session (real message count + user turns, not yet ended, no end reason) is now surfaced while it's still running. Empty/never-really-started rows stay hidden as before. Thanks @rodboev. (#5593, #5587)
+
+- **A completed answer is no longer followed by a misleading "No response from provider" card.** On the streaming settlement path, a stale terminal/partial-result flag could force the generic `no_response` error even when the current turn had already produced a full assistant answer — so you'd see the reply and then an error card under it. The generic silent-error path is now suppressed only when the merged current turn actually has a completed assistant answer; genuine failures (real silent failures, partial failures, auth/quota errors, cancellation, replayed rows, terminal tool/compression cases) all keep their existing error behavior. Thanks @rodboev. (#5592, #5575)
+
+- **The Mermaid diagram toolbar's "fit" and "fullscreen" buttons are now distinct icons.** The two buttons rendered a byte-identical glyph (the fullscreen icon was the fit icon's corner brackets drawn twice), so you couldn't tell them apart. Fullscreen now shows a distinct corner-frame-with-diagonal-arrows icon, visually separable from fit-to-screen's plain corner brackets. Thanks @rodboev. (#5565, #5525)
+
+- **A long-open tab no longer breaks with a runaway login-redirect URL.** If an auth session expired while a tab sat idle on the login page, the login redirect could feed itself its own address — each auth bounce wrapped and re-encoded the previous `/session/login?next=…` one level deeper, so the URL grew exponentially (a reporter measured ~12,000 characters) until it exceeded the browser's limit and the tab stopped working. All three redirect guards (client 401 handler, login page, and server login render/OIDC) now collapse a `next` whose destination resolves to the login route — detected through bounded percent-decoding so a deeply-encoded chain is still caught — while preserving legitimate redirects (including non-login paths that carry their own `next=` query key) and the existing open-redirect protections. (#5578)
+
+- **Concurrent chats on different profiles no longer intermittently fail turn-init citing the wrong profile's provider.** A background worker (e.g. automatic title generation) mirrored its profile's home into the process-global `HERMES_HOME` env var and ran the worker body outside the setup lock, so a concurrent worker on another profile could clobber that env var mid-run — making the agent's config reader resolve the wrong profile's `config.yaml` and fail the turn citing a provider the session never used. The worker now pins its profile via the agent's context-local home override (a task-local `ContextVar` the config reader consults before the env var), so a concurrent env-var clobber can't affect it — no worker serialization, no env mutation. Degrades cleanly on older agents (the override is resolved optionally; without it, behavior is exactly as before). Thanks @b3nw. (#5567)
+
+- **Forking or truncating a large/compacted conversation no longer misaligns the model context (occasional "dangling tool-call" API rejection).** WebUI keeps two arrays per session — the visible transcript and the trimmed model context — and forking a big session translated a display cut-point into the matching context cut-point using a per-message identity. But the model-context rows carried no stable id (a 989-session scan found zero), so on large sessions full of look-alike rows the aligner went ambiguous and could cut mid-turn — leaving the fork's context ending on a tool-call whose result was dropped, which the provider then rejected. Each message now gets a stable id shared between the transcript and model-context copies (the key the aligner already prefers, shipped in the previous release), so large-session forks/truncations align exactly. The id is additive, stripped before it ever reaches the model provider, and legacy id-less sessions keep working (they just fall back to the previous matching). Thanks @b3nw. (#5564)
+
+- **The sidebar now re-sorts a reactivated conversation to the top even if the tab was in the background.** When a conversation became active again from another device (or another tab) while your current tab was unfocused or backgrounded, the sidebar didn't bump it up to "Today" / the top until you manually refreshed. The session-list refresh coalescing only preserved the refresh *reason* and dropped its options, so a `force` refresh queued behind an in-flight one silently lost its force flag and got skipped on a hidden tab (`refreshSessionList` early-returns while `document.hidden`). Refresh options are now preserved through coalescing (force / refresh-active are OR-merged), and returning focus to the sidebar forces a catch-up — so cross-device recency re-sorts land on their own. The extra refresh coalesces into the existing render-skip no-op path, so there's no flicker and no background rebuild storm. Thanks @rodboev. (#5562, #5551)
+
+- **Transparent Stream: the final reply stays a visible answer after the turn settles.** In Transparent Stream mode, the moment a turn settled its final reply folded into the collapsed worklog/activity disclosure group — the answer vanished from view until you refreshed the page (the stored transcript was always correct, so a reload fixed it). The live-anchor scene renderer was hardcoding the compact-worklog layout mode when it projected the settling turn, regardless of your active display mode, so the final answer got compact-worklog display hints and was grouped away. It now reads your actual active mode and applies per-mode display hints, so in Transparent Stream the answer settles as a visible chronological reply. Compact Worklog is unchanged (bit-identical projection). Thanks @rodboev. (#5558, #5550)
+
+- **iOS/Android PWA: streaming stays smooth on long chats, and a dropped connection recovers instead of erroring.** Two mobile reliability fixes. (1) On touch devices, the chat now skips layout and paint for off-screen message rows while a response streams (`content-visibility` scoped to `pointer:coarse`), so a long conversation no longer degrades into a freeze on iOS WKWebView — the live streaming turn (targeted by its stable `#liveAssistantTurn` id so every render mode is covered) stays fully rendered so it never blanks mid-stream and the new-message cue still works, off-screen rows keep a tiny size estimate to preserve flick-scroll momentum, and desktop find-in-page is untouched. (2) The SSE reconnect ladder is extended (4→6 backoff steps) with a last-ditch full-session poll (an 8s-watchdog-guarded `_restoreSettledSession`, with a "Restoring session…" affordance) after the retries are exhausted, so a response that finished while the connection was flapping (e.g. an iOS Tailscale/VPN reconnect) is recovered without showing an error banner. Deliberately does not touch `overflow-anchor` — that would re-open the #4856/#5338 mobile jump-to-top regression. Thanks @luperrypf. (#5541)
+
+- **Starting a new chat is instant again — memory extraction no longer blocks it, and a managed restart no longer drops that memory.** Creating a new conversation could stall 1–5+ seconds because the previous session's memory-commit (the extraction call to the memory provider) ran synchronously on the request. That commit now runs in the background so "+ New Chat" responds immediately. To keep the deferred work safe across a restart, a `SIGTERM` handler drives an orderly stop so the server drains in-flight memory commits before exiting (previously a managed stop terminated instantly and lost them), the background-commit registry is bounded and self-cleaning, and the drain is wall-clock–bounded so a hung memory provider can't stall the stop (each commit runs under a shared time budget). Thanks @luperrypf. (#5543)
+
+- **Auto-scroll now resumes when you return to the bottom of a streaming reply.** If you scrolled up even once while a response was streaming, the chat permanently stopped following new output — scrolling back to the bottom didn't help; only starting a new turn re-engaged auto-follow. Returning to the very bottom of the transcript (within ~80px) now re-pins auto-scroll, so the view follows the stream again. It only re-pins at the true bottom and never while you're actively scrolling (wheel, keyboard, touch, or scrollbar drag), so reading back through history mid-stream still never yanks you down — preserving the #4295 behavior. Thanks @luperrypf. (#5544)
+
+- **Forking a long or compacted conversation now keeps a clean turn boundary.** "Fork from here" on a large session (one big enough to have been context-compacted) could cut the copied context in the middle of a turn — or right after a tool call, before its result — leaving the new conversation with a malformed context that the model choked on. The fork now aligns its cut to a real turn boundary in the same display coordinate space `/api/session` uses (matching both the compacted and non-compacted cases), erring toward keeping slightly less rather than slicing a turn, with send-time sanitization as a backstop. Small, non-compacted forks are unchanged. Thanks @b3nw. (#5563)
+
+- **Clearing a conversation now actually clears it — history no longer comes back after a refresh (P0 data-loss).** `Clear conversation` wiped the on-screen transcript but never recorded a truncation watermark, so the messages resurrected from the agent's state database on the next load: the cleared history reappeared after a refresh, and continuing the conversation still fed the full pre-clear context to the model. Clear now routes through the same truncate-to-empty path as the truncate action (setting the watermark that blocks state-db replay), verifies the empty state actually persisted, removes the stale backup, and — for a compressed-continuation session — detaches the compression-snapshot parent link so the archived transcript can't be stitched back in either (ordinary fork links are preserved). Thanks @rodboev and the maintainer fix. (#5556, #5553, #5532)
+
+- **Transparent Stream now shows a running tool as running, not already-done.** The live anchor-scene renderer hardcoded the tool-status as "settled" even while the tool was still executing, so a tool call flashed straight to its finished state in Transparent Stream instead of showing its in-progress status. It now uses the row's actual settled state, so a running tool reads as running until it genuinely completes. Thanks @rodboev. (#5547, #5523)
+
+- **The composer no longer repopulates the tail of a message you already sent.** On mobile/slow connections, a just-sent message could momentarily reappear in the composer because a stale server-side `composer_draft` restored before the empty-draft write caught up. A short per-session suppression window now blocks that stale restore right after send — but scoped to the specific sent payload, so a genuinely new draft you (or another tab) start for the same conversation still restores immediately instead of being swallowed for the window. Drafts are also cleared on the busy send paths (queue / interrupt / steer). Thanks @ruizanthony. (#5471)
+
+- **Windows: after an upgrade, browsers no longer keep serving stale JS/CSS when git isn't on the server's PATH.** When the WebUI server was launched on Windows from an environment without git on `PATH` (a venv, a service wrapper, a non-interactive launcher), version detection silently degraded to `unknown`, which froze the `?v=` static-asset cache key — so browsers kept serving cached `ui.js` / `messages.js` / CSS even after the server restarted with fixed code, and the only workaround was a manual hard-refresh on every device. Version detection now resolves `git.exe` from the Git-for-Windows registry key (and common install paths) when it isn't on `PATH`, so the cache stamp updates and clients pick up frontend fixes automatically. Non-Windows behavior is unchanged. Thanks @allenliang2022. (#5522)
+
+- **Models from a user-defined provider in `config.yaml` now route correctly.** If you defined a custom provider under the `providers:` block with an explicit `models:` allowlist, a bare model id from that list wasn't matched by request-time routing — it silently fell back to the default provider and often 404'd. Resolution now scans your `providers:` allowlists (exact match, same as `custom_providers:`), so those models reach the right endpoint. Copilot is deliberately excluded from this scan because `providers.copilot.models` is a per-model settings map (reasoning effort, limits), not a routing allowlist. Thanks @akay64. (#5511)
+
+- **A deleted conversation no longer comes back as a read-only "ghost" after a restart.** Deleting a WebUI session removed its file but left a recoverable row in the agent's state database, so after a server restart and page reload the deleted conversation could reappear as a read-only "Agent" session still showing its old transcript. Deleted WebUI sessions are now recorded in a durable tombstone that the sidebar projection, startup backup-recovery, audit, and session-claim paths all honor, so a deleted session stays gone (the backend keeps returning the 404 the client relies on to clear the stale route). Genuine crash recovery is unaffected — a session whose sidecar is lost without a delete is still restored — imported external-channel (messaging) sessions are exempt and keep their transcript, and a deleted id is automatically un-tombstoned if the same session is later re-created or re-imported. Thanks @rodboev. (#5504, #5498)
+
+- **A drag that ends over the sidebar no longer switches conversations by accident.** Releasing the mouse (a stray `pointerup`) over a session row after a drag that began elsewhere in the main content area could switch you into that conversation unintentionally. Session switching now ignores a `pointerup` that isn't a genuine click on the row. Thanks @webtecnica. (#5487, #5462)
+
+- **Extension gallery downloads no longer hang on networks with broken IPv6.** Gallery installs and registry fetches followed the OS address order (IPv6-first); on a network advertising dead IPv6 routes, every install stalled for the full TCP timeout before falling back to IPv4. Downloads now try IPv4 first (via a non-global, thread-safe per-connection override that preserves the existing redirect allowlist and timeouts), so installs are fast again on such networks. Thanks @rumotoshino. (#5458)
+
+- **Switching profiles no longer leaves you unable to upload in a fresh chat, and cross-profile sidebar rows open correctly.** When you switched profiles with an empty current chat, the browser could keep the previous profile's session id — so a later file upload posted a stale id and failed with "Session not found" until a hard reload. The empty chat is now replaced during the switch when it belonged to a different profile, so uploads work immediately. Additionally, clicking a session that belongs to another profile now switches to its owning profile before loading it (instead of 404ing). Thanks @ruizanthony. (#5460)
+
+- **Dictation now uses your current language after you change it in Settings.** After switching the interface language at runtime, plain dictation (the mic button) kept using the language it booted with, so speech was transcribed in the wrong language until a reload. Dictation now refreshes the speech-recognition language right before it starts listening — matching what voice mode already did. Thanks @webtecnica. (#5496, #5483)
+
+- **After an in-place upgrade, an old tab now tells you to refresh instead of silently breaking.** When the server was upgraded under a still-open tab, that tab kept running the pre-upgrade front-end against the newer server — leaving the new-session button dead and session switching broken, with no indication why (only opening a fresh tab helped). The tab now compares its own bundle version against the server's on boot (and while visible), and shows a "hard refresh" recovery banner when they differ, so you can restore full functionality with one click. The banner only appears on a genuine version mismatch (never when the server can't report its version), and coexists with the existing update banner. Thanks @rodboev. (#5480, #5428)
+
+- **A fullscreen Mermaid diagram now fits the screen instead of overflowing it.** Opening a Mermaid diagram in the fullscreen lightbox could render it larger than the viewport, clipping the edges so you had to pan around to read it. The lightbox now fits the diagram to a ~90%-of-viewport envelope on open, so the whole thing is visible at once (zoom and pan still work from there). Inline diagram sizing is unchanged — this only affects the fullscreen view, and it coexists with the inline readable-sizing fix from the previous release. Thanks @rodboev. (#5452, #5413)
+
+- **The sidebar skips its redundant rebuild on idle polls without ever going stale.** The session list re-applied `/api/sessions` and rebuilt the whole sidebar DOM on every 30-second poll even when nothing had changed; it now compares a signature of the full applied rows (plus the hidden nesting rows, projects, filters, density, selection, and counts) and skips the rebuild only when everything the sidebar renders is byte-identical. The signature covers every rendered field — streaming/pending state, attention dots, source/read-only/worktree/lineage/model/profile metadata — so a real change never gets skipped, and recovering from a loading skeleton or a "couldn't load conversations" banner always forces a real repaint. Part of the response to "WebUI feels laggy." Thanks @ai-ag2026. (#5467, #5455)
+
+- **A failed send no longer wipes the message you just typed.** When a message couldn't be started because of a provider or background error (auth failure, rate limit, connection refused, etc.), the composer had already been cleared, so the error message appeared with an empty box and you had to retype the whole thing from scratch. Now, when the send is rejected before the turn actually starts, your original text is put back in the composer (and any attached files are re-staged) so you can fix the issue and re-send with one key press. It restores exactly what you typed — not the expanded form of a slash command like `/moa` — never overwrites a new message you started typing while the send was in flight, and won't touch the composer if you've since switched to a different conversation (your draft is still saved to the original one). (#5472, #5488)
+
+- **Desktop: the transcript no longer jumps back up while the assistant is streaming a reply.** On desktop (which doesn't use the browser's overflow-anchoring), the scroll-position restore keyed off a content-derived anchor that a streaming chunk kept invalidating, so it silently fell back to an absolute scroll position that didn't account for the content growing above the viewport — yanking you upward mid-read. Restore now recovers via the stable per-message index when the content key goes stale, so your reading position holds steady as the answer grows. A genuinely removed message still concedes cleanly (no forced jump to the wrong row), and the separate mobile overflow-anchor fix is untouched. Thanks @allenliang2022. (#5469)
+
+- **The live streaming view stays smooth on long answers instead of getting progressively heavier.** With Transparent Stream / compact worklog, the live response area re-rendered the entire growing answer on every streamed frame (and re-serialized the whole turn for its restore snapshot, and re-parsed the full text on every token), so a long reply got quadratically more expensive as it grew — visible as rising CPU and jank late in a response. The live prose row now renders incrementally (the same streaming markdown parser the main chat body already uses, fed only the new text), the restore snapshot is throttled, and the redundant per-token parse is skipped once the answer row exists. Settled messages still render through the canonical full-markdown path (unchanged final output), and every fallback path (parser unavailable, mid-stream edit, session switch) reverts to it cleanly. Part of the response to "WebUI feels laggy." Thanks @ai-ag2026. (#5466, #5455)
+
+- **The sidebar no longer keeps polling and rebuilding itself in a hidden background tab.** The 30-second session-list poll and the 60-second relative-time refresh ran unconditionally, so a backgrounded window kept fetching `/api/sessions` and rebuilding a sidebar it couldn't show. Both now pause while the tab is hidden and refresh immediately when you return to it (each poll has its own `visibilitychange` handler), so nothing goes stale — you just stop paying for updates you can't see. (One small timing change: while a tab is hidden *and* a session is streaming, the in-app attention chime / unread badge now fires when you return to the tab rather than mid-hidden; OS-level notifications are unaffected, and browsers already throttled the old hidden-tab poll anyway.) Part of the response to "WebUI feels laggy." Thanks @ai-ag2026. (#5465, #5455)
+
+- **The sidebar session list stops holding a write-capable database handle just to read.** Building the sidebar opened a read-write SQLite connection on the live (potentially multi-GB, WAL-mode) state database and re-ran a defensive index check on every refresh — needless lock/checkpoint contention while the agent is streaming into the same database. The listing now opens the database read-only, and only touches it with a separate short-lived writable connection on the rare occasion the index is actually missing. Output is unchanged. Part of the response to "WebUI feels laggy." Thanks @ai-ag2026. (#5464, #5455)
+
+- **Your text-to-speech and voice-mode preferences now follow you across devices and browsers.** TTS settings (enabled, auto-read, engine, voice, rate, pitch) and voice-mode settings (voice button, raw-audio mode, continuous listening, silence timeout) were stored only in the browser's localStorage, so they were lost on a new device, a cleared browser, or a fresh profile — and never matched what the server knew. They're now persisted server-side in your settings file and mirrored back to the client. Only preferences you've actually set are written (a never-configured key stays absent and falls back to its default rather than being pinned to a value), and no save path clobbers your other settings. The voice-mode silence timeout is read at the moment auto-send is scheduled, so a persisted value takes effect on first load without a reload. Thanks @rodboev. (#5442, #5435)
+
+- **A profile whose default model lives in a large provider's picker "overflow" list no longer fails validation.** When a provider has more models than the visible picker cap (15 entries), the extras are moved into an overflow bucket that still renders in the dropdown. Profile model validation only searched the visible list, so selecting one of those overflow models as a profile default was rejected with a spurious "not available for provider" error even though it appeared (and worked) in the picker. Validation now searches both the visible and overflow buckets, matching what the dropdown shows. Thanks @WallaceWebster. (#5453)
+
+- **Read-only sessions (cron runs, subagent views) no longer show a broken "fork from here" affordance.** Forking is only meaningful for sessions you own, but the fork/branch controls were shown on read-only cron and subagent sessions too — clicking them just produced a confusing "Session not found" error. Those controls are now hidden for read-only sessions on the frontend, and the branch endpoint refuses a read-only source with a clear `403` instead of a misleading `404`. Thanks @rodboev. (#5449, #5439)
+
+- **Inline Mermaid diagrams stay readable instead of collapsing to a sliver.** The inline diagram viewer shared the lightbox's fit-to-screen scale, so on mobile (and other short viewports) a diagram could shrink to an unreadable strip. Inline rendering now uses its own width-based sizing with a minimum height floor (and its own minimum scale), decoupled from the full-screen lightbox fit, so inline diagrams keep a usable size while the lightbox zoom/pan behavior is unchanged. Thanks @rodboev. (#5434, #5413)
+
+- **The ElevenLabs text-to-speech proxy now gets the same transport hardening as the OpenAI one.** The ElevenLabs upstream fetch previously used a plain opener that would follow redirects and honor environment proxies — meaning a redirect could carry the `xi-api-key` to another host, or an ambient proxy could intercept the request. It now routes through the same no-redirect, no-proxy opener introduced for the OpenAI TTS proxy (#5407), so the key can't be redirected elsewhere and the dial goes directly to the intended endpoint. Thanks @rodboev. (#5437, #5430)
+
+- **The post-update "What's New" summary cache can no longer grow without bound.** Generated update summaries were cached in `sessionStorage` with no size limit, so repeated updates could accumulate until the browser's storage quota was hit. The cache is now capped at 256KB (measured in true UTF-8 bytes), keeping the newest entries that fit and dropping the oldest under pressure. Thanks @rodboev. (#5441, #5435)
+
+- **Screen readers no longer announce the approval and clarify dialogs while they're inactive.** The approval and clarify flyout cards stayed in the accessibility tree and tab order even when hidden, so a screen-reader user could land on a dialog that isn't actually showing. Inactive cards are now removed from assistive tech (`aria-hidden` + `inert`) and visually hidden (`hidden` + `display:none`), and restored when shown — with the show sequence ordered so the entrance animation still plays correctly. Thanks @sheldon-im. (#5404)
+
+- **Mobile: the transcript no longer jumps back up while the assistant is still rendering.** On mobile browsers, the layout engine's built-in scroll-anchoring would re-compensate `scrollTop` during the height churn of a streaming response, yanking the view back toward the top (residual of #4856). The fix suppresses browser overflow-anchoring across the *full* height-churn window rather than a single frame: consecutive renders extend one shared suppression window, and an independent hard-cap timer guarantees the suppression is always lifted (so a missed transition event can't pin it). Desktop is unaffected (no-op where the browser isn't anchoring). Thanks @allenliang2022. (#5392, #4856)
+
+- **The sidebar's session action menu no longer closes the moment you scroll the chat.** Opening a session's "…" action menu and then scrolling the conversation used to dismiss the menu immediately, because any scroll event closed it. Scrolls inside the chat transcript are now ignored (the menu stays put), scrolls of the sidebar list itself reposition the menu to keep it anchored to its row, and if a session-list refresh detaches that row the menu closes cleanly instead of floating orphaned. Thanks @nankingjing. (#5402, #5347)
+
+- **The profile dropdown opens instantly instead of stalling on a cold fetch.** On installs with many profiles (or slow profile metadata), clicking the profile chip used to block on a fresh `/api/profiles` request before the menu appeared, so it felt unresponsive for up to a few seconds. The dropdown now opens immediately from a short-lived cache (rendering a lightweight loading shell when there's no cache yet) and refreshes fresh data in the background; a malformed cache row is validated and purged rather than shown, and a single-profile install keeps its shared cache intact. Thanks @ruizanthony. (#5412)
+
+- **Hardened the built-in text-to-speech proxy against a DNS-rebinding attack (SSRF).** When you route text-to-speech through an OpenAI-compatible endpoint, the proxy used to validate the target host's address and then resolve it again to connect — a gap an attacker-controlled DNS server could exploit to answer "public" during the check and "internal" at connect time, redirecting the request (with your API key attached) at a machine on your private network. The proxy now resolves the host once, validates every returned address up front (rejecting any private/loopback/link-local/reserved target), and connects only to those already-vetted addresses — while keeping the original hostname for TLS/SNI so certificate validation is unaffected. Multi-address hosts still fail over correctly across their vetted addresses, environment proxies can't bypass the direct dial, and redirects can't carry your token elsewhere. Thanks @rodboev. (#5407, #5291)
+
+- **Creating a new conversation right after switching profiles no longer fails with a 404.** After switching profiles, the browser still held the previous profile's session id, and the new-session request ran a cross-profile access guard against that stale id — which 404'd and aborted the whole create, so you couldn't start a fresh chat until you reloaded. The create now proceeds regardless: on a cross-profile previous session it simply skips that session's memory-commit (it belongs to the other profile) and starts the new one. Cross-profile isolation is unchanged — the guard still refuses to read or commit another profile's session. Thanks @nankingjing. (#5423, #5420)
+
+- **Remote gateway health checks stop reporting a false failure on authenticated deployments.** In a multi-service deployment where the agent's API server requires a key, the WebUI's `/health/detailed` probe was sent without credentials and came back 401, so the health panel showed the gateway as down when it was fine. The probe now presents the same Bearer token the agent expects (only on `/health/detailed`, never logged, omitted entirely when no key is configured). Thanks @nankingjing. (#5424, #5418)
+
+- **The sidebar recovers on its own after a brief server blip during a profile switch.** If the backend returned a transient 502/503/504 (e.g. an nginx→backend restart window) on a warm session-list refresh — a profile switch, tab focus, or reconnect — the sidebar gave up on the first try and sat on the previous profile's list until you hard-reloaded. The idempotent session-list fetch now retries those transient upstream statuses on every refresh (not just the first cold load), so it heals itself without a Ctrl+F5. Thanks @weidzhou for the report and root-cause. (#5394)
+
+- **The sidebar no longer conjures a phantom "Cron Jobs" project you never asked for.** When you had no projects of your own, opening the sidebar source filter (or importing CLI sessions) would silently mint a "Cron Jobs" project just to group cron rows — cluttering a filter you'd never opted into. Cron sessions now stay as ordinary ungrouped rows (still visible in the sidebar and origin filter) until you actually create a project; an existing "Cron Jobs" project keeps resolving exactly as before, so nothing changes for anyone already using one. Thanks @rodboev. (#5398, #5379)
+
+- **The composer footer shows your model and workspace names again on desktop.** The Export-to-HTML button added to the composer footer in the previous release pushed the control row past its width budget, which collapsed the footer into icon-only mode and hid the model / workspace / profile text labels. The export button has been removed from the composer footer (export remains available from Settings), so the labels are visible again.
+
+- **Fixed a blank assistant turn (avatar with no text) that could appear after a dropped stream.** When a turn's live SSE dropped but its in-flight bookkeeping wasn't cleaned up, the empty live-turn shell could be re-attached over the settled transcript on the next self-heal re-render — pinning an avatar-only blank turn on top of the already-saved answer (the message was intact on disk; a reload restored it). The live turn is now preserved across the transcript re-render only when it's genuinely live (an active stream is running, or it already holds real rendered content), so a dead empty shell is dropped and the real answer renders. The mid-stream flicker fix (#3877) is unaffected. Thanks @allenliang2022. (#5390)
+
+- **MoA (Mixture-of-Agents) overrides fail closed on gateway-backed sessions instead of silently running the wrong model.** When WebUI chat is routed through a Hermes Gateway, a per-turn MoA override can't be honored gateway-side, so the request now returns an honest `409` ("MoA override is unavailable on gateway-backed sessions") rather than quietly dropping the override and running the base model. The non-gateway MoA path is unchanged. Thanks @ruizanthony. (#5153)
+
+- **`ctl.sh` loads `~/.hermes/.env` so `${VAR}` references in `config.yaml` resolve.** The control script now reads the environment file before launching, using a literal parser (no `source`/`eval`, so it's injection-safe) that correctly handles quoted values, inline comments, and `export`-prefixed lines. Thanks @hogehou-cmi. (#5309)
+
+- **Manual title regeneration honors your configured auxiliary title-generation timeout.** The manual "regenerate title" action used the frontend's default 30s request timeout, so a slow-but-valid generation timed out on the client when `auxiliary.title_generation.timeout` was set higher. It now fetches the current timeout fresh per regen (no stale cache across profile switches) and applies it only when valid, falling back to the 30s default on any fetch failure. Thanks @Stacey2911. (#5374)
+
+- **No more 57–70 s cold-startup stalls from the profile skills-stats thundering herd.** At container boot the frontend fires several profile-data requests at once; with `ThreadingHTTPServer` (one thread per request) they all missed the empty skills-stats cache simultaneously and each walked + parsed every profile's skill tree, stacking thousands of concurrent `stat()` calls under Docker's overlay2 filesystem. `_get_profile_skills_stats()` now serializes per-profile with double-checked locking (concurrent misses on one profile collapse to a single compute; independent profiles still compute in parallel), and `list_profiles_api()` single-flights the row build under `_LIST_PROFILES_CACHE_LOCK` so one thread builds while the rest wait for the cached result. The every-call cheap mtime probe (the #4783 out-of-band change-detection contract) is unchanged. (#5364)
+
+### Added
+
+- **Extension API: session-open interception + transcript rendering.** Two new extension-facing hooks let extensions react to session navigation and render message transcripts without DOM hacking. `window.registerHermesSessionOpenHandler(fn)` fires when a session opens — with `{preload:true}` a handler may return `{cancel:true}` to abort the navigation cleanly (no side-effect: no sidebar close, profile switch, stream open, transcript clear, or URL change on any entry path), and with `{loaded:true}` it runs after the session is fully populated. `window.renderTranscript(container, messages, opts)` renders an array of messages into any container, routing content through the same sanitized `renderMd`/`SAFE_TAGS` pipeline the chat uses (with a `textContent` fallback), so it carries no new XSS surface. Follows the established `registerHermesTtsEngine` registration pattern. Thanks @ChonSong. (#5508)
+
+- **Pinch-to-zoom for Mermaid diagrams on touch devices.** The zoomable Mermaid viewer (which already had mouse-wheel zoom + single-finger pan) now supports two-finger pinch-to-zoom on phones/tablets, anchored on the pinch midpoint. Pointer/drag handlers are guarded so an active pinch never fights the pan gesture; desktop mouse-wheel zoom and single-finger pan are unchanged. Uses Touch Events for iOS Safari robustness. Thanks @silent-reader-cn. (#5913)
+
+- **Collapsed `read_file` tool rows now show the requested line range.** When the agent reads a slice of a file (`read_file` with `offset`/`limit`), the collapsed tool label now appends the range — e.g. `Read hermes_constants.py · L321-480`, or `L42` for an offset-only read — so you can see which part of a large file was read without expanding the row. Full-file reads and any tool call without a trustworthy positive-integer range keep the plain filename label (invalid, non-integer, non-positive, and overflowing ranges all fall back safely). Only `read_file` is affected. Thanks @franksong2702. (#6062)
+
+### Performance
+
+- **Fork a scheduled-job (cron) conversation into your own editable chat.** Cron-run sessions are read-only (the scheduler owns them), so you couldn't continue one. You can now branch/fork a cron session into a new WebUI-owned conversation — the original read-only session is never modified, and the fork picks up its history so you can carry on. Only genuine cron sessions qualify (verified by the server-side source, not a guessable id), and every other read-only session stays unbranchable. Thanks @rodboev. (#5555, #5477)
+
+- **Pick the exact time and day for scheduled jobs — no cron syntax needed.** The Tasks "New job" form now builds the schedule as a plain sentence: choose a frequency (Hourly / Daily / Weekdays / Weekly / Monthly / Custom) and it shows just the controls that matter — a time picker, a day-of-week dropdown, or a day-of-month dropdown — e.g. "Weekly on Monday at 09:00." The generated cron expression is previewed inline, and the raw cron field now appears only under "Custom." Thanks @rodboev. (#5554, #5552)
+
+- **The Preferences default-model setting now uses the rich model picker.** The "Default Model" control in Settings → Preferences was a plain dropdown; it now uses the same searchable, provider-grouped picker as the composer (with a "Custom Model ID" field), so choosing a default model is consistent everywhere. On touch devices the picker no longer pops the keyboard the instant you open it (it still focuses search on desktop and while you're typing), and its label now activates the picker directly. Thanks @rodboev. (#5502, #5497)
+
+- **Scheduled jobs get a preset schedule builder.** The cron job form now has a "Preset" dropdown (Hourly / Daily / Weekdays / Weekly / Monthly, plus Custom) above the Schedule field — pick one and it fills the schedule for you (e.g. Daily → `0 9 * * *`, Hourly → `every 1h`) instead of hand-writing a cron expression. "Custom" keeps the free-text field, and editing an existing job maps its schedule back to the matching preset. Thanks @rodboev. (#5438, #5427)
+
+- **Add a self-hosted provider (Ollama or LM Studio) from Settings.** After onboarding, a new Settings control lets you point WebUI at a local OpenAI-compatible model server (Ollama / LM Studio) by choosing the provider, model, and base URL — no hand-editing `config.yaml`. The base URL is validated (scheme + provider allowlist) and persisted for the agent's provider client. An optional "Test connection" button reuses the existing authenticated onboarding probe to fetch the model list from the URL you enter. Thanks @rodboev. (#5408, #3260)
+
+- **Copy a workspace file's relative path.** The workspace file-tree right-click menu and the file-preview header now offer "Copy relative path" (e.g. `src/app/main.py`) alongside the existing absolute-path copy — handy for pasting a path into chat. On a narrow preview pane the header button folds to icon-only (with a localized tooltip + accessible name) so it never crowds out the path text or the Download/Edit actions. Thanks @rodboev. (#5548, #5533)
+
+- **Completed the remaining UI translations across all 8 non-English locales.** 664 strings that still fell back to English (marked `TODO` in the locale table) are now translated, so the German / Spanish / French / Japanese / Korean / Portuguese / Chinese / Russian interfaces are fully localized. No new selectable language was added — this fills in the languages already offered. Interpolation tokens are preserved per key. Thanks @mo7al876any. (#5535, #5530)
+
+- **The post-update "What's New" summary now has an Expand control.** The update banner's generated summary defaults to a compact scrollable box; a new "Expand summary" toggle grows it to a much taller view (up to ~75% of the window height, ~82% on mobile) so you can read a long changelog without scrolling inside a cramped panel, then collapse it back. Thanks @nankingjing. (#5209, #4705)
+
+- **Export a conversation to a self-contained, theme-matched HTML file.** From a session you can now download a single standalone `.html` of the transcript with the current theme's palette baked in — no external resource loads (fully offline/portable), messages rendered as escaped text (no script/style/raw-HTML injection from conversation content), and remote images neutralized. Thanks @brick-hard. (#4968)
+
+- **You can hide Claude Code sessions from the sidebar independently of other external sessions.** A new "Show Claude Code sessions" preference (Settings → Preferences, under "Show non-WebUI sessions", default on) filters out imported Claude Code rows while leaving your CLI / cron / webhook / other external sessions visible. Toggling it persists independently and doesn't disturb the other source-visibility preferences, and the default preserves the prior behavior for anyone who never sets it. Thanks @rodboev. (#5213, #4714)
+
+- **The Tasks panel can show your scheduled cron jobs from other profiles (read-only).** Mirroring the session sidebar's cross-profile visibility, the Tasks panel now offers an opt-in "Show N from other profiles" affordance (default-hidden): it enumerates cron jobs across your profiles server-side and renders foreign-profile rows read-only. Cron mutations (pause/run/delete/edit) and `/api/crons/recent` stay strictly active-profile-only — a foreign `job_id` doesn't resolve in the active-profile mutation context — and detail/history/watch state is pinned to a composite `{owner_profile, job_id}` identity so duplicate IDs across profiles can't cross-leak. Hidden root/default rows are skipped for named profiles, and stale cron detail state is cleared on profile switch. Thanks @rodboev. (#4682, #3947)
+
+- **Extensions can opt into a consent-gated loopback sidecar proxy.** An extension whose manifest declares a `127.0.0.1`/`localhost` sidecar origin can now, after an explicit per-origin user consent, have WebUI proxy same-origin browser requests to that local backend — the first supported mechanism for an extension to reach a co-located sidecar without shipping its own server route. The surface is deliberately narrow and fail-closed: loopback-only origin (no SSRF; hex/decimal/userinfo/port bypasses rejected), path traversal and URL/scheme smuggling rejected on the fully-decoded form, consent bound to the exact declared origin (an origin change forces reconsent, bounded at 512 entries), auth/CSRF-gated consent, credential isolation (Cookie/Authorization/CSRF/Host/Origin/Referer stripped outbound, Set-Cookie stripped inbound, hop-by-hop headers stripped both ways), ambient proxies disabled, redirects confined to the declared origin, a 512KB response cap on both the success and error paths, and same-origin **browser provenance required on every proxied method** (not just GET). Documented in `docs/EXTENSIONS.md`. Thanks @rodboev. (#5228, #4747)
+
+- **Push-to-talk hold gesture for dictation.** Hold the mic button (or its keyboard activation) to dictate and release to stop, in addition to the existing click-to-toggle mode — a more reliable way to capture a quick voice note. The browser-reserved `Ctrl+Shift+D` chord that an earlier draft proposed was dropped (it collides with browser bookmark shortcuts); only the page-safe hold gesture ships, and the restart/teardown paths are race-hardened so there's no stuck or zombie microphone. Thanks @rodboev. (#5310, #3700)
+
+### Documentation
+
+- **Documented the reverse-proxy basic-auth caveat for installed PWAs.** README, onboarding, and troubleshooting now explain that proxy basic auth can block the same-origin service-worker/shell update fetches an installed PWA needs (leaving it on a blank screen after an update), and give recovery steps — prefer WebUI's built-in password, or scope proxy auth so `sw.js`/manifest/shell requests complete. Thanks @rodboev. (#5673, #2781)
+
+- **Documented the `HERMES_WEBUI_*` environment variables and refreshed stale version/test/locale references.** README, ARCHITECTURE, TESTING, ROADMAP, SPRINTS, and the `.env` example files now describe the supported runtime env vars (host/port/state-dir/agent-dir/home) and no longer carry stale hardcoded version/test-count/locale figures. Thanks @mo7al876any. (#5536)
+
+- **Documented the gateway approval-runs API opt-in.** `docs/advanced-chat-setup.md` and `docs/docker.md` now explain how to enable and use the gateway approval-runs API. Thanks @rodboev. (#5549, #5269)
+
+### Internal
+
+- **Hardened `test_tls_support::test_tls_startup_failure_fallback_to_http` against stdout-preamble noise.** The test asserted the server prints "TLS setup failed" by reading only the first 2000 bytes of its stdout. When the test interpreter lacks optional deps (`requests`/`websockets`), startup emits a burst of plugin/tool import warnings plus the startup-config banner *before* the TLS line, pushing the marker past that fixed window → spurious failure (distinct from the agent-package-isolation cause fixed separately below). It now drains all currently-available stdout (bounded by a 5s deadline, short-circuiting once the marker is seen) instead of a single fixed-size read. Verified failing→passing in a fresh venv without the optional deps. Test-only.
+
+- **Fixed the last 2 chronic local full-suite test-isolation flakes.** `test_tls_support::test_tls_startup_failure_fallback_to_http` and `test_v050259_sessiondb_fd_leak::test_session_db_close_is_idempotent` failed only in a full local suite run (they skip on CI, where the agent package isn't co-located). Root cause was the same "simulate agent package unavailable" antipattern shipped-fixed for the profile cluster: `test_custom_provider_prefix_collisions` clobbered `sys.modules['agent']` at collection time (never restored), and several helpers emptied real package `__path__` lists in place. Fixed at the source — the collection-time shim is now guarded on `importlib.util.find_spec` (only installed when the real package is genuinely absent), the in-place `__path__` mutations use `monkeypatch.setattr(..., raising=False)`, and `test_issue1574`'s fake-agent helper restores the env/`sys.path` it mutates. Full local suite now 0 failed (was 2); `test_title_aux_routing` unaffected. Test-only.
+
+- **Fixed the chronic full-suite test-isolation flakes (8 tests).** A cluster of profile-resolution tests (`test_profile_skills_stats`, `test_scheduled_jobs_profile_isolation`, `test_sprint10` cron-output) passed in isolation but failed in the full suite: a test that simulates "the agent package isn't installed" emptied the real `hermes_cli` package's `__path__` in place, which `monkeypatch` can't undo, so every later `import hermes_cli.profiles` failed for the rest of the run. Added an autouse conftest guard that snapshots the real `hermes_cli` / `hermes_state` packages, their `__path__`, and the agent-path env vars + `sys.path` at session start and restores them after every test, so a "package unavailable" simulation can't poison later tests. Test-only; no product code changes.
+
+- **Shared layout-assertion helpers for browser tests.** Added `tests/_layout_helpers.py` (a reusable Playwright layout-lint: overlap / clipping / container-escape / degenerate-box / raw-i18n-key / a11y checks) plus its own coverage, so future per-issue browser tests can assert real rendered geometry without reimplementing the checks each time. Test-only; no production code changes. Thanks @rodboev. (#5668, #5665)
+
+- **Browser (Playwright) tests actually run in CI.** The pytest CI job now installs Playwright + Chromium (with a version-pinned cache), so the browser-backed test files that were silently skipping now execute — closing a real coverage gap. Thanks @rodboev. (#5661, #5658)
+
+- **Test suite reads static assets as UTF-8 so it runs on Windows.** 34 test files that opened `static/` assets without an explicit encoding now pass `encoding="utf-8"`, so the suite no longer fails under a non-UTF-8 default locale (Windows `cp1252`). Two method signatures over-matched by the encoding sweep were reverted. Assertions are unchanged. Thanks @mo7al876any. (#5537)
+
+- **Added regression coverage for messaging clear-watermark semantics.** New test locks the watermark behavior so a future change can't silently regress it. Thanks @rodboev. (#5589, #5572)
+
+## [v0.52.76] — 2026-07-18
+
+### Fixed
+
+- **Manual "Check for updates" now works even when automatic update checks are turned off.** Previously, disabling automatic update checks in Preferences → System also disabled the manual "check now" button, leaving no way to update from the WebUI without re-enabling the (noisy) automatic banner. The explicit manual force-check now bypasses the disabled auto-check toggle; the automatic path still honors the setting. Thanks @webtecnica. (#6088, #6082)
+
+- **No more spurious "No response from provider" after a tool-limit turn that did answer.** When a turn hit the per-turn tool-iteration limit (`max_iterations_reached`) but the model had already produced a valid final answer, the settlement classifier still appended a "No response from provider" error card after the good reply — a regression of the earlier false-`no_response` fix, because the suppression path admitted only `_soft_partial_terminal_failure` and deliberately excluded `_tool_limit_reached`. The suppression now also covers `_tool_limit_reached`, but *only* when the transcript already holds a final answer; a tool-limit turn with no final answer still fails honestly, and explicit errors / cancellations / interruptions stay outside the suppression. Thanks @coyaSONG. (#6081, #6079)
+
+- **New conversations can no longer become unstartable seconds after you create them.** On installs with a saved browser/password-manager credential and more than a few hundred sessions, the manager would autofill the account name into the sidebar conversation filter, silently firing a content search that walked every session through the cache and evicted the brand-new, not-yet-persisted session (its only copy lived in the in-memory cache) — so `/api/session/draft` and `/api/chat/start` then 404'd and the chat could never be started. Two fixes: the search/filter inputs are now `type="search"` with per-manager opt-out attributes and the Settings + onboarding password fields get a real `
` owner (`display:contents`, layout unchanged) so managers stop treating the sidebar filter as a username field; and `_session_is_evictable()` now proves persistence before evicting even a zero-message session, so an unsaved session you're still composing stays resident. Thanks @ai-ag2026. (#6083, #4765) + +- **Live reasoning no longer drops or double-renders when the turn anchor defers a paint.** On the streaming reasoning render, when the assistant-turn anchor accepted a reasoning event but deferred actually painting it, the reasoning text could go missing — and earlier attempts to fix that could briefly show the same reasoning under two owners or, with multiple reasoning segments, update the wrong row. The fallback now matches the exact anchor row identity (`run_id:seq`), so the right segment updates in place, other rows are untouched, and no duplicate owner appears — across Transparent, Compact, and hide-all modes. Thanks @franksong2702. (#6048, #5720) + +- **Deleting a session now cleans up its delegated subagent sessions too (no orphaned children).** When you delete a parent session, the delegated/subagent sessions it spawned (and their on-disk transcript artifacts) are now cascaded and removed, including Agent-migrated legacy delegates — while unrelated sessions, sibling branches, and any still-live child are never touched. The cascade fails closed on any uncertainty (can't confirm liveness, missing/unqueryable state.db, or no cross-process lock primitive → it skips destructive cleanup rather than risk over-deleting), cross-process cleanup is serialized with a file lock so two WebUI workers can't race, and a partial cleanup is reported honestly instead of shown as full success. Thanks @pxxD1998. (#5926, #5307) + +- **Agent updates no longer report a false failure when the gateway restart is transient — and never mask a real failure.** During an agent update, the launchd/gateway restart subprocess can briefly exit non-zero while the old process is being replaced; that was surfaced as a whole-update failure. The updater now confirms the outcome by the actual gateway PID for the exact profile being updated (retrying the restart once), so a genuine process handoff is reported as success — while a real restart failure, an unknown/ambient PID, a wrong-profile confirmation, or a compatibility-wrapped status helper that can't confirm the specific PID all fail closed (never masked as success). Restart targeting and the PID check are pinned to the update's profile (root aliases normalized), and malformed profile names are rejected before launch. Thanks @franksong2702. (#6054, #6045) + +- **Gateway provider errors are reported accurately, and your prompt survives a failed turn.** On gateway-backed sessions, a terminal provider error (bad model id, exhausted credentials, etc.) mid-turn is now classified and shown as the real error instead of a generic empty turn, the error card is bound to the correct session, and the in-flight user prompt plus any partial output is persisted so it survives a reload — even when you'd just re-sent an identical prompt (e.g. "continue"). Thanks @rodboev. (#5969, #5940) + +- **"Fork from here" works during an active response.** The fork button was silently dead while the agent was generating — clicking it did nothing. You can now fork from any past (already-committed) message while a response streams; only the currently-streaming message and the just-sent (not-yet-committed) prompt are blocked, with a clear toast. Thanks @kertisalex. (#5994, #5993) + +- **No more false "No response from provider" after a completed answer that used tools.** When an assistant turn ended with a real final answer delivered as structured content following tool calls (common with reasoning models and multi-tool turns), the completion detector could miss the final text and render a spurious "No response from provider" card under the finished reply. Final-answer detection now understands structured content parts (text after tool-use boundaries), while genuine empty completions still correctly show the no-response notice, and a real terminal provider error still surfaces as an error (never masked as a final answer). Thanks @jtstothard. (#5990, #5686) + +- **Custom/proxy providers keep the full vendor namespace when you deliberately pick a non-default model (follow-up to the earlier #5979 fix).** The initial fix preserved `x-ai/grok-4.5` when it was your configured default or already in a warm model catalog, but a model you *selected in the picker* that wasn't your default (e.g. `x-ai/grok-composer-2.5-fast`) was still truncated to the bare `grok-composer-2.5-fast` when the models catalog was cold at send time — the normal state right after a restart, a settings save, or a cache expiry. The proxy then rejected the bare id with `HTTP 400`. For a `custom` / `custom:` proxy the resolver now remembers that you deliberately selected the model this session and preserves the full `vendor/model` id on a cold catalog, while a stale cross-provider leftover you didn't pick still has its redundant prefix stripped so it keeps routing. The send path also warms model-catalog provenance from the durable on-disk cache first (network-free, non-blocking, never triggers a live probe). Thanks @b3nw for the detailed follow-up and the CLI-vs-WebUI comparison that localized it. (#5979) + +- **Cron unread ("Tasks") dots are now scoped to the active profile — no more ghost dots after switching.** In multi-profile setups, the session-sidebar unread dot and the Tasks badge could keep showing a cron completion from a *different* profile after you switched, and the count could resurrect from a stale poll. Cron completion markers now carry their source + owning profile, are cleared for the profile you switch away from (including legacy/untagged markers), and a previous-profile poll response can no longer restore a stale unread. Non-cron completion unread and the current profile's own cron unread are preserved; default ↔ renamed-root aliases are treated as the same profile so a root cron marker is neither wrongly cleared nor wrongly kept. Thanks @jbbottoms. (#5975, #5960) + +- **The Docker "manual update" banner instruction is now localized.** On Docker/no-git deployments the update banner shows the manual update command; that guidance string was hardcoded in English. It now goes through the i18n system, so it renders in the active language (English fallback where a translation isn't present yet). Thanks @jbbottoms. (#5973, #5959) + +- **Cross-platform test suite stabilized.** The profile skill-statistics coverage test now selects a genuinely-incompatible platform per-OS (so the incompatibility assertion holds on macOS as well as Linux), and eight pytest class-scoped instance-fixture deprecation warnings were removed by switching to state-free module fixtures. Test-only; no product code changes. Thanks @tahabakhit. (#5970) + +- **The transcript no longer jumps up while you type in a multi-row composer.** When the composer had grown to multiple rows and you were scrolled to the bottom, every keystroke nudged the transcript upward (by roughly the composer's height) and quietly broke stream auto-follow — you'd have to scroll back down. The auto-resize now preserves the scroll position of a bottom-pinned reader, so typing keeps you pinned and streaming keeps following. Readers who deliberately scrolled up are unaffected. Thanks @JeffArcLogic for the report and repro. (#5962, #5514) + +- **The reasoning-effort chip is correct right after an empty startup.** On a normal boot with no active session restored, the top-bar reasoning chip wasn't refreshed for the hydrated default model, so it could show a stale value until you changed the model. It now refreshes on empty boot too. Thanks @rodboev. (#5967, #5954) + +- **Custom/proxy providers keep the full vendor namespace on the model id (P0 routing fix).** When you run multiple upstream vendors behind one OpenAI-compatible proxy (`provider: custom` / `custom:`) and pick a vendor-namespaced model such as `x-ai/grok-4.5`, the WebUI was stripping the `x-ai/` namespace before sending, so the proxy received a bare `grok-4.5` it couldn't route and rejected the turn with `HTTP 400: Invalid model format`. The strip was a catalog-*family* guess that couldn't tell a namespace the proxy actually routes on (`x-ai/grok-4.5`, keep) apart from a stale cross-provider leftover on a relay that only serves bare ids (`openai/gpt-5.4` → `gpt-5.4`, strip). Resolution is now based on **what the endpoint actually advertised**: if the proxy's own catalog lists the full `vendor/model`, it is sent whole; if it lists only the bare id, the redundant prefix is stripped; and on a cold/unbuilt catalog the id is preserved verbatim (fail-safe toward the id you selected). First-party providers pointed at an OpenAI-compatible proxy still send the bare id as before. Regression was latent — it surfaced only once a model graduated into the first-party catalog. Thanks @b3nw for the report and root-cause analysis. (#5979) + +- **Transparent Stream no longer bloats memory (or freezes the tab) on long tool-heavy chats.** With Transparent Stream enabled, opening a conversation with many prompts built a DOM node for every activity event of every past turn at load and eagerly rendered each tool row's full detail body — a long reasoning-heavy history could balloon to multiple GB and lock up the tab. Settled tool rows now render header-only and build their detail on first expand (identical to the old view once opened), and a turn with a very large number of steps shows its most recent 30 behind a "Show N earlier steps" control. Live turns are unchanged. Thanks @curtisszmania-cytocync for the report. (#5974, #5966) + +- **Dictation no longer wipes text you already typed.** When you typed part of a prompt and then used the microphone (server-side transcription / MediaRecorder path), the recognized text replaced your typed prefix instead of being appended to it. Dictation now appends to whatever is already in the composer, so you can type a partial sentence and dictate the rest. A Settings toggle lets you opt back into the old replace behavior. Thanks @Kopamed. (#5895) + +- **The context-usage indicator is more accurate right after a context compaction.** After the conversation context was compressed, the indicator kept showing the previous provider-reported prompt size (the pre-compaction request) instead of the smaller prepared one, so it briefly overstated how full the window was. It now shows a short-lived, clearly-labeled post-compaction estimate that expires as soon as the next real provider count arrives — provider accounting, token metering, and recovery behavior are unchanged. Thanks @rodboev. (#5872, #4685) + +- **A failed turn now shows the real provider error instead of "no response, try again".** When a turn hit a non-retryable provider error (e.g. HTTP 400 "invalid model" or a credentials problem), the WebUI showed a misleading "No response from provider — this often means a usage/rate limit was hit silently, try again in a moment" message and hid the actual, non-retryable cause. The terminal error the agent reports is now surfaced through to the turn-completion classifier, so you see an accurate, actionable message (e.g. "Model not found — check the model ID in Settings" / "Authentication failed") instead of being told to retry a request that can never succeed. The generic no-response message stays reserved for genuinely empty completions. Thanks @b3nw for the report. (#5940) + +- **Public-share button labels now show in the right language.** A value-level locale swap shipped the public-share strings (Share / Stop sharing / confirmation dialogs) as Russian text in the English locale and English text in the Russian locale — key-parity checks didn't catch it because the keys themselves were balanced. Both locales are restored to their correct language, with a regression test that fails if the values are ever swapped again. Thanks @DrMaks22. (#5955) + +- **An errored turn no longer hides the response the assistant already produced.** When a turn ended in an error, the tool calls and reasoning the assistant had already generated were auto-collapsed behind a single header above the error card, so it looked like nothing came back. That produced content now stays visible by default on an errored turn (the error card still shows); successful turns collapse their worklog exactly as before. Thanks @b3nw for the report. (#5950, #5941) + +- **After a provider failure, retrying or edit-resubmitting now honors your newly-picked model.** If a turn failed and you switched the model/provider in the selector, `/retry` and edit-resubmit re-sent the *failed* model, forcing you to fork the conversation. A genuine non-default model pick is now carried into the recovery send, while an unchanged model still lets the server pick a compatible one — and the recovery can't leak one session's model into another when you switch sessions mid-retry. Thanks @b3nw for the report. (#5949, #5924) + +- **Russian localization refreshed and onboarding notes localized.** The `ru` bundle was refreshed and brought to full key parity with English (identical key sets across all locales), new update/workspace UI strings were propagated to every locale, and onboarding provider notes now resolve through a localized key (with the English text preserved as fallback). Thanks @DrMaks22. (#5778) + +- **The compression-recovery action now appears on reloaded/rebuilt conversations too.** The "Start focused continuation" card (shipped in the previous release) is normally attached to the terminal assistant message as it streams; on a session that was rebuilt or restored from disk, that marker could be missing even though the conversation is still in the compression-exhausted state. The card now also renders from the active session-level recovery state on the final assistant message, so the recovery path is reachable after a reload. Thanks @franksong2702. (#5655, #4685) + +- **The transcript no longer scrolls up when the composer grows.** When you were pinned to the bottom of a conversation and the composer got taller — multi-line typing, `Shift+Enter`, or a multi-line / dictation paste — the transcript viewport shrank by the same amount and left you stranded a row or two above the bottom (it read as the chat "randomly scrolling up" during normal use). The transcript now re-pins to the bottom whenever the composer grows, but only when you were genuinely still pinned there — if you'd scrolled up to read history, nothing yanks you back down. (#5516, #5514, #5515) + +- **Model-picker fixes: MoA presets and the Copilot catalog no longer clobber other providers' model allowlists.** Selecting a MoA preset now re-resolves cleanly per turn (and a malformed preset can't crash resolution), and the Copilot models-as-settings-map handling is scoped to Copilot only — so a `providers..models` allowlist on any other built-in provider (e.g. `providers.anthropic.models`, #644) is honored again instead of being silently disabled. Provider dedup/canonicalization (#1568/#2245/#2399) and the #1855 bare-model fast path are preserved. Thanks @promptclickrun. (#5301) + +### Added + +- **Images and media now render inline *as the response streams*, not just after it finishes.** When the assistant emits a `MEDIA:` token (generated image, video, audio, PDF, diff, CSV, Excalidraw, etc.) mid-turn, it now appears inline in real time during streaming instead of only materializing once the turn settles — including markers that arrive split across streaming chunks. Media routes through the same escaped `/api/media` path (no XSS surface: `javascript:`/`data:`/`file:` neutralized, SVG stays an ``), remote `.webm` correctly renders as video, and normal prose streaming (including the fade effect) is unchanged when no media is present. Thanks @silent-reader-cn. (#5802) + +- **Docker / no-git installs now get an update notice.** Container and other non-git WebUI installs previously had no way to see that a newer release existed (the in-app updater only works on git checkouts). The Settings update panel now shows an informational notice when the running image trails the latest published release — e.g. "WebUI (v0.52.30 → v0.52.43): 13 releases available · Manual update required: run `docker pull …`, then recreate the container." It is informational only: the Force-update and Clear-lock actions are disabled/hidden for these installs and the notice can never trigger a git reset. Prerelease tags are ignored so stable installs only see stable releases. Thanks @rodboev. (#5733, #2729) + +- **Reverse-proxy SSO via trusted identity headers (opt-in, off by default).** You can now put the WebUI behind a reverse proxy you run (Authelia, oauth2-proxy, corporate SSO, …) that authenticates the user and forwards their identity in a header. Set `HERMES_WEBUI_TRUSTED_AUTH_HEADER` (e.g. `Remote-User`) plus `HERMES_WEBUI_TRUSTED_PROXY_CIDRS` with the **exact** IP(s) of your proxy — the header is honored *only* when the request's un-spoofable socket peer is an allowlisted proxy, so a direct client can't forge it. Optional group→profile mapping (`HERMES_WEBUI_GROUP_PROFILE_MAP`, JSON) and SSO single-logout URL are supported. Fails closed on empty/malformed config; existing password/passkey auth is unchanged when this is not configured. See `.env.example` for the full setup and the security warning. Thanks @rodboev. (#5568, #3351) + +- **Deep-link straight to a profile with `?profile=`.** Opening the WebUI with a `?profile=NAME` query parameter now switches to that profile on boot. The name is validated against the existing profile allowlist (a nonexistent profile boots normally, path-traversal / cross-profile attempts are rejected), the switch goes through the same access-enforced `switchToProfile`, and boot fails safe on anything invalid. Thanks @rodboev. (#5730, #5682) + +- **Nix flake + NixOS module (optional install path).** Nix/NixOS users can now install and run the WebUI declaratively: `nix shell` for the package, or `services.hermes-webui.enable = true;` to run it as a managed systemd service. Secure by default — binds loopback-only (`127.0.0.1`), state directory is `0700`/`UMask 0077`, runs as a dedicated non-root system user, and secrets come from `environmentFiles`; network exposure requires explicitly setting both a non-loopback host and `openFirewall`. Purely additive — non-Nix installs are completely unaffected (community-supported; not covered by the project's CI). Thanks @rodboev. (#5431, #2422) + +- **Keyboard shortcut to focus the composer: `Cmd`/`Ctrl` + `/`.** Jump the cursor back into the message input from anywhere (sidebar, transcript, side panels) without creating a new chat. It's focus-only and a no-op while you're already typing in a field. Matches on the `/` character so it works correctly across keyboard layouts (and never intercepts browser zoom). Thanks @rodboev. (#5795, #5759) + +- **Profile-concept help is now localizable, with a concrete example.** The "Profiles vs workspaces" help card (Profiles / Spaces / Together) now routes all its copy through the i18n layer so it can be translated (missing locales fall back to English), and it gains a plain example showing a profile and workspace used together. Thanks @rodboev. (#5806, #2147) + +- **New chats honor your agent's `worktree:` config default.** If your agent config sets a `worktree:` default, a deliberate New Chat now inherits it (matching TUI behavior). System-created sessions — boot auto-bind, profile switches, workspace binding, file/folder creation, terminal sessions — never inherit it, so simply opening the UI can't spawn a stray worktree + branch. An explicit request flag always wins over the config default, and the config value is read strictly (only a real `true` opts in; anything else is safely ignored). Thanks @ayxuerui. (#6029, #6022) + +### Performance + +- **Profile switches no longer re-run the expensive provider probe every time.** `GET /api/providers` ran a full per-provider probe on every call, so switching profiles (or any UI that re-reads providers) repeatedly paid that cost. The response is now cached for a short TTL (30s), keyed by the active Hermes home plus `.env` mtime, `config.yaml` mtime, and a config fingerprint — so it's strictly per-profile (one profile can never see another's cached provider view) and busts immediately on a profile switch or a credential/config edit. Explicit key and OAuth credential changes invalidate the cache right away; cached values are deep-copied so callers can't mutate the shared entry. Thanks @SeanWit. (#6013, #6010) + +- **Faster session reconciliation for large histories.** When the WebUI reconciles a session against the agent's `state.db`, it now uses a cheap prefix-count check to skip the expensive full identity-key materialization whenever the two prefixes provably can't match — falling back to the full comparison whenever a match can't be ruled out (null timestamps, equal counts, ties, or a locked/unreadable db all take the safe full path). No change to which sessions or messages are shown. Thanks @Isla-Liu. (#6014) + +- **Send-to-first-token latency (TTFT) is now measured.** Each streamed turn records how long it took from send to the first visible token (`ttft_ms`), surfaced in metering stats and as a `_firstTokenMs` diagnostic on the message. Measurement-only — computed once on the first token with no added latency on the streaming hot path, and existing token/usage/cost metering is unchanged. Thanks @rodboev. (#6042, #6006) + +- **"Approve once" no longer silently sticks for the whole session.** On local (non-gateway) deployments, choosing **Approve once** on a tool approval prompt was wrongly persisting as a session-wide approval, so the next matching guarded tool call skipped its approval card. "Once" now applies to only the single call that prompted it, and the next matching call re-prompts as intended; "Approve for session" and "Always allow" are unchanged. Thanks @rudidev08. (#6035, #6017) + +- **Conversation actions menu is now keyboard- and screen-reader-accessible.** The per-conversation actions popup (the ⋯ menu in the sidebar) now exposes proper ARIA (`aria-expanded`/`aria-controls` on the trigger, `role=menu`/`menuitem` on the items), moves focus to the first action when opened, supports Arrow/Home/End navigation and Escape-to-close, and restores focus to the originating trigger on close. The existing click/touch behavior is unchanged. Thanks @sheldon-im. (#6019) + +- **Model picker no longer duplicates or drops proxy/catalog models.** When the same model was reachable via both a custom proxy and a first-party catalog (e.g. `grok-4.5`), the picker could show it twice; the dedup fix collapses true duplicates to one selectable row while keeping genuinely-distinct models that merely share a name suffix — including colon-suffixed proxy models like `…:free` / `…:thinking` / `…:latest` — independently selectable. The currently-selected model always survives dedup, and model restore no longer substitutes a suffix-sharing sibling for a missing model. Thanks @rodboev. (#5997, #5989) + +- **Simplified Chinese (zh-CN) localization fill.** Untranslated English strings in the zh-CN locale are now translated, so Simplified Chinese users no longer see English fall-through text across recently-added UI. Thanks @Loukky. (#6024) + +- **WebUI-created git worktrees can now be cleaned up (no more orphan accumulation).** The agent locks every worktree it creates, and git refuses to remove a locked worktree with a single `--force`, so WebUI-created worktrees were piling up unremovable. Session-worktree removal now runs a fail-soft `git worktree unlock` right before the remove (mirroring the agent's own cleanup ordering); the existing dirty/uncommitted-changes guard still runs first, so user data stays protected. Thanks @ayxuerui. (#6028, #6023) + +- **Recover from "context compression exhausted" with one click.** When a long conversation grows too large for automatic compression to make room, the terminal error now shows a **Start focused continuation** action. It opens a fresh linked session that keeps your workspace, model, profile, and toolset but starts with an empty model-facing transcript — so you can describe the next narrow task without replaying the oversized context. Bare "continue"/"go on" on an exhausted conversation is intercepted and pointed at the action (substantive prompts still go through), and repeated clicks or extra tabs converge on the same continuation instead of spawning duplicates. Thanks @franksong2702. (#5538, #4685) + +## [v0.52.41] — 2026-07-14 + +### Performance + +- **"Max" reasoning-effort level (opt-in, availability-gated).** The reasoning selector now offers `Max` for models that actually support it (Claude adaptive-thinking 4.6+, DeepSeek), matching the level Hermes core already accepts. `Max` is shown *only* when the active model genuinely supports it — it's hidden for GPT-5 / o-series / Gemini / legacy Claude and stripped anywhere it would be rejected, and a stored/CLI `max` on a model that can't take it degrades to the next supported level (`xhigh`) instead of erroring. (One known edge: an aggregator provider like OpenRouter/Copilot that returns no per-model capability data can't confirm support, so `max` stays selectable there and would fall back at the provider if the specific model doesn't support it — same behavior as the CLI.) Thanks @perejaslav. (#5910, #4627) + +- **Opt-in: start a new chat when switching workspace.** A new default-off setting (Settings → Preferences → "Start a new chat when switching workspace"). When enabled, selecting a *different* workspace starts a fresh conversation bound to that workspace and leaves your current chat on its original workspace — avoiding stale cross-workspace context carrying into the conversation. Selecting the same workspace stays an in-place refresh, and when the setting is off (the default) switching workspace changes the current conversation's workspace in place exactly as before, so nothing changes for anyone who doesn't opt in. Thanks @ruizanthony. (#5473) + +- **Public read-only conversation sharing.** You can now opt in to share a single conversation via a public link (session action menu → Share). The link serves a redacted, read-only snapshot at `/share/` that anyone can view without logging in — nothing is public unless you explicitly share it, and you can revoke the link at any time (Stop sharing). The public page renders the transcript through the same sanitized markdown path as the app, is theme- and skin-aware (light/dark + all skins), and looks clean on desktop and mobile. The share payload is hardened at a dedicated always-on boundary: credentials, local workspace/worktree/home paths, system/tool messages, and any non-text structured content are stripped regardless of your API-redaction setting; only the conversation title and user/assistant text reach the public snapshot. Share tokens are 144-bit CSPRNG; create/revoke stay authenticated + CSRF-protected. Thanks @MicoRobot. (#5337) + +- **Per-event timestamps in the Transparent Stream worklog.** In Transparent Stream activity mode, each worklog event row (thinking / tool call) now shows a small right-aligned time label, so you can see when each step of a long agent run happened — useful for telling whether a run is progressing or stuck. The timestamp resolves from whatever timing the event carries (falling back to the observed live time only for a genuinely live event, never fabricating a time for a recovered/replayed old session), persists correctly when the turn settles, and carries a full date+time tooltip on hover so a session reviewed days later isn't date-ambiguous. The label is hidden on narrow (<420px) viewports so it never squeezes the command preview, and Compact Worklog / Final answer only modes are unaffected. Thanks @rodboev. (#5739, #5700) + +- **New "Final answer only" chat activity display mode.** Settings → Appearance → Activity display now offers a third option beside Compact Worklog and Transparent Stream: "Final answer only" hides all activity rows (thinking, tool calls, worklog) and shows just the assistant's final answer — the clean, minimal output style of ChatGPT/Claude. It's a pure display lens (opt-in; the default stays Compact Worklog), so no activity data is lost — switching modes re-renders the same conversation with more or less detail. Thanks @rodboev. (#5746, #5706) + +- **Czech (Čeština) is now a fully-supported UI language.** Added a complete Czech (`cs`) locale to Settings → Language with full key parity to English (all ~1,640 UI strings), real Czech translations for every string and function-valued key (including Slavic plural forms for message/queue counts and tool-activity summaries), a Czech login screen, and a dedicated locale-parity + placeholder + diacritics test guard mirroring the other per-language suites. Thanks @ostravajih. (#5546) + +- **Transparent Stream fades newly streamed words in.** In Transparent Stream mode, newly arriving assistant words now fade in at the prose level as they stream — without animating (or re-flickering) the thinking rows, tool rows, or the transparent event rows. It respects your OS "reduce motion" setting (no fade when reduced motion is on) and is part of the Transparent Stream experience regardless of the "Fade text effect" toggle. Thanks @rodboev. (#5506, #5367) + +### Fixed + +- **A pending prompt no longer renders twice across a context-compaction boundary.** On reload / reattach to an active turn, a synthetic `[CONTEXT COMPACTION]` marker (a user-role row that isn't a real submitted turn) placed after your prompt was treated as the latest user message, so the tail scan missed the actual prompt right before it and rendered the same pending prompt twice. The session-load and refresh/reconnect tail scans now skip compaction markers when locating the current user message, while completed assistant rows stay hard boundaries so genuinely-repeated prompts still render. Thanks @starship-s. (#5920) + +- **Sessions from Zed and other ACP (Agent Client Protocol) clients now show up in the sidebar.** An ACP-adapter session persists to the agent database with source `acp`, which was classified as `other` and fell through both sidebar buckets — so those conversations were completely invisible and unclickable. ACP sessions are now grouped with the CLI/TUI family (labelled "ACP"), with the same zero-message / ended-connection hiding rules as other interactive agent sessions. Thanks @ai-ag2026. (#5939) + +- **Date-stamped legacy Claude 3.0 model ids no longer show reasoning-effort controls they don't support.** The capability check read an 8-digit date suffix (e.g. `claude-3-opus-20240229`) as the model's minor version, so a bare date-stamped Claude 3.0 id wrongly qualified as reasoning-capable. The minor-version match is now bounded (mirroring the existing date-stamp defense), so date-stamped Claude 3.0 ids correctly hide reasoning-effort options while Claude 3.7 / 4.x / adaptive keep them. Thanks @nankingjing. (#5934) + +- **Transparent Stream no longer flickers or flips its tool-activity view during a turn.** On an experimental build, a live transparent-stream turn with tool calls would repeatedly rebuild its rows each stream tick (flicker) and flip the tool-activity area between the grouped "Processed Ns" summary and the individual per-event row list (alternating). Root cause: a recent change let a hardcoded caller hint force the compact grouped-worklog frame onto a transparent turn instead of honoring your selected activity-display mode. The active display mode is now authoritative — the caller hint is only a fallback — so a transparent turn renders stable individual rows, a compact turn keeps its grouped worklog, and hide-all stays hidden. Verified by a deterministic stream-regression gate across all three activity modes and the full submit / reload / switch-away lifecycle. (#5946, #5942, #5943, #5367) + +- **Context chips restart at "Context 1" after you clear them.** The selection counter used to only ever increment, so clearing all context chips (or switching sessions) and selecting new text started at "Context N+1" instead of "Context 1". The counter now resets whenever the last chip is removed and on clear-all / session switch, so chip numbering starts fresh each time. Thanks @akay64. (#5929) + +- **The agent no longer tells you a stored credential is "wrong" when it's just masked.** Secrets are redacted (`***`, `sk-abc…xyz1`) before the model sees them, but nothing told the model that masking was intentional — so it sometimes read a masked API key or password and reported it as a typo or a placeholder. The WebUI progress-guidance prompt now explains that redacted credential fields are deliberate masking, so the agent stops flagging correctly-stored secrets as errors. Thanks @mvanhorn. (#5927, #5871) + +- **Stale "unread" dot no longer lingers on a session after you open it.** Visiting a session now clears its sidebar unread indicator across the paths that previously left it stuck — the same-session reselect, the metadata-arrival ack, and the post-message-load re-sync — while a completion that lands in a genuinely hidden/background tab correctly stays unread. It also stops a phantom unread/streaming indicator from appearing when you switch from a busy session to an idle one (the idle session's streaming flags are now reset from its own metadata before the sidebar repaint, instead of inheriting the previous session's busy state). Thanks @neaucode-bot. (#5917, #4946) + +- **Plugin-provided model providers route correctly when set as the default.** A model from a plugin-only provider (e.g. a `@plugin:model` route) was surfaced in the catalog but, when that plugin provider was also the configured default, the request dropped the `@plugin:` routing hint and went to the wrong backend. Provider-hint resolution now applies plugin routing before the configured-provider bare-passthrough. Thanks @alexfoxtm. (#5909, #5461) + +- **Composer no longer double-sends or re-uploads on a fast second send.** The message text + its attachments are now captured and the textarea cleared immediately on send, before the async upload round-trip, so a re-entrant / interrupt-mode send can't re-read stale composer text and submit the same message twice or inherit the previous send's attachment. A clipboard-copy failure after a successful send no longer shows a false "failed", and a draft typed during the upload window is no longer clobbered. Thanks @harryazj. (#5912, #4750) + +- **Mobile dictation stays alive through natural pauses.** On touch devices the composer mic now keeps a dictation session going across the brief silences that used to end it (desktop stays one-shot), with a serialized screen wake-lock so rapid start/stop/visibility changes can't leak or drop it, and cleaner fallback to recording when the browser's speech recognition is unavailable. Thanks @brianmmaina. (#5915, #4732) + +- **Transparent Stream is smoother during streaming — less row churn and no thinking-block scrollbar flicker.** Building on the identity-preserving live-row reconcile, the renderer now skips redundant work while a response streams: a preserved row's markup isn't rewritten when the incoming HTML is unchanged, a row isn't reinserted when it's already in the correct position, and long thinking blocks no longer flicker an internal scrollbar as their scroll container appears. Purely a reduction of no-op DOM work — what renders is unchanged, and matching rows still preserve their DOM node, copy button, tool-call data, and expanded/collapsed state. Thanks @Stacey2911. (#5456, #5367) + +- **Transparent Stream no longer flickers or drops rows while the assistant is responding.** With Activity Display set to Transparent Stream, the live response area (text, thinking, and tool rows) used to visibly blink on every streamed update because the renderer tore down and rebuilt every live row each tick, replaying the entrance animation. Live rows are now reconciled by identity across rerenders — matching rows are refreshed in place (preserving the DOM node, its copy button, tool-call data, and expanded/collapsed state), stale rows are removed, and only genuinely new rows animate in. Thanks @rodboev. (#5400, #5367) + +### Documentation + +- **Full accuracy refresh of `ROADMAP.md`.** The roadmap had drifted well behind what the project actually ships and was missing entire surfaces. This pass rewrites every section to reflect current reality — the extension system (loader, gallery, manifest contract, theme/TTS/nav/sidecar capabilities, per-extension settings + owned storage, trust model, vetted library repo), concurrent per-profile isolation, native Android app, release channels, and the StewardOS generalization — and switches version/test-count/release-history to be derived live rather than stamped, so the doc no longer goes stale every release. (#5709) + +## [v0.52.28] — 2026-07-13 + +### Added + +- **Resumable session event streams (server contract).** A new per-session SSE endpoint (`GET /api/sessions/{id}/events`) lets a client reconnect and resume from where its stream dropped — replaying journalled events after a cursor (standard EventSource `Last-Event-ID`, with an `after_event_id` query fallback for clients that can't set the header), deduped against live events at an atomic subscribe/snapshot boundary. Replay is bounded (per-line byte cap enforced pre-decode + an event-count limit) and *honest*: any limit, gap, or non-contiguous cursor returns a fresh `session_snapshot` recovery boundary so the client re-syncs rather than silently losing events. An idle no-cursor subscriber also re-syncs if a run starts and finishes entirely within one keepalive tick. Cross-profile access is refused (404) before any replay. This is the Phase-1 server contract; no client consumes it yet. Thanks @rodboev. (#5677, #4812) + +- **The workspace file viewer now previews modern Office documents (`.docx` / `.xlsx` / `.pptx`) and lets you edit plain, structurally-simple `.docx` files in place.** A shared backend Office authority owns previewability, editability, and archive-safety: parsers are optional at runtime (lazy-imported; a lean install degrades to a clean 503 install hint, not a crash), every OOXML archive is preflighted against decompression-bomb limits on *actual inflated bytes* before any parser runs, and preview size is bounded during accumulation. `.docx` editing is fail-closed — only documents whose body is plain paragraphs (no custom sections/tables/headers/rich runs) are editable, save rebuilds from the original package so document metadata/styles/theme are preserved, and the saved bytes are re-verified before write. Previews render as escaped text (no HTML injection). Thanks @rodboev. (#5142, part of #540) + +### Fixed + +- **CSV files shared in chat keep a download link again.** A `MEDIA:` CSV rendered an inline table preview but — unlike every other media type — dropped the download affordance, so the file couldn't be retrieved once previewed. The preview header now shows a 📎 download link (via the correct `api/media?…&download=1` path), and the link stays visible even when the inline table itself fails to render, so CSV files exchanged through chat output remain retrievable. Thanks @ruizanthony. (#5792) + +- **Repeated automatic retries after your provider credentials run out no longer spam the chat with identical error bubbles.** When a session's provider credential pool is exhausted, each completed background task used to start a fresh automatic "wakeup" turn that failed the same way, stacking duplicate terminal error messages. The first failure still shows clearly, but subsequent automatic wakeups on the same model/provider are now paused (recorded as auditable suppression metadata, not shown), and the pause clears automatically on recovery — a successful run, a model/provider switch, or when the underlying credential state actually changes. Explicit (non-automatic) recovery turns are never blocked. Thanks @franksong2702. (#5732, #3929) + +- **Opening Hermes from a browser-search or launcher deep-link now focuses the composer so you can press Enter immediately.** A `?q=`/`?prompt=` URL prefill populated the composer but left keyboard focus elsewhere, so the prefilled text sat there until you clicked into the box. Boot now focuses the composer after applying a non-empty URL prefill — the focus fires only on that prefill seam (ordinary boots are unchanged) and never auto-sends. Thanks @rodboev. (#5887, #5884) + +- **A fast session switch can no longer paint the wrong conversation's messages into the pane you're looking at.** The chat pane is shared state, and `loadSession()` previously tracked ownership only by destination session id. If you switched sessions (or reloaded the same one) quickly, a slow transcript response from the earlier load could finish late and populate `S.messages` after a newer navigation had already taken over — leaving a split pane: the current session's header above another session's rows. Each `loadSession()` invocation now carries a monotonically increasing generation token, and every point that mutates the pane after an `await` re-checks it still owns the load (matching both session id *and* generation) before writing — so a stale continuation bails instead of hijacking the view. A redundant same-session click no longer cancels the authoritative in-flight refresh, and a stale response can't collapse a long transcript to its tail window. Thanks @starship-s. (#5889) + +- **The model picker no longer mislabels a model as free when OpenRouter hasn't published complete pricing.** The OpenRouter free-tier detection treated a missing or `null` price field as `0`, so a model whose pricing metadata was absent or partial could be surfaced as "(free)" and added to the free-tier list. The check now fails closed — a candidate is only treated as free when both prompt and completion prices are present and genuinely zero — while models with an explicit `:free` id stay selectable. Thanks @igorroncevic. (#5882, #5880) + +- **A session no longer gets stuck sending to the wrong provider after its stored provider becomes stale.** When a session's persisted model/provider no longer matches where that model actually lives (e.g. the model moved groups, or an older session carried a now-foreign provider), chat start now repairs the binding to the correct provider — but only on positive proof: it reroutes solely when the live model catalog shows the stored group genuinely lacks the model AND exactly one other healthy group owns it. It checks both the primary and overflow (`extra_models`) model buckets before deciding a group doesn't own a model, and it fails safe — preserving the stored provider without rerouting — whenever any relevant catalog probe failed (a transient discovery error is never treated as proof the model is absent). Ordinary matching-provider sends make no catalog call, and explicit/ambiguous/unknown/stored-owner picks are all left untouched. Thanks @rodboev. (#5873, #5731) + +- **The auxiliary-model settings now come from a single catalog, so the task list can't drift.** The auxiliary/helper-model task definitions were maintained separately in Python and JavaScript, which let the two lists fall out of sync (and left the retired `session_search` task lingering in one). The settings panel now renders and saves exclusively from a single backend catalog; the retired task is purged on reset while every other custom binding is preserved, and existing configs referencing the old task still load cleanly. Thanks @starship-s. (#5865) + +- **The Experimental update channel now shows the real installed version instead of "unknown" / a bare SHA on a stable-tagged install.** When an install pinned to a stable `v*` tag opts into the Experimental channel, no `exp-v*` tag sits behind HEAD, so the channel-scoped version lookup returned nothing and the UI rendered "unknown" (and a bogus ahead-count of 1). The check now recovers the real installed version and the true number of Experimental releases ahead. The installed-version fallback is scoped to the WebUI repo (never injected into the Agent repo's payload), and the compare-link ref is always a git-verified tag (or omitted) rather than a possibly-dirty version string. Thanks @nesquena-hermes. (#5864, #5862) + +- **The activity worklog no longer freezes the browser on reasoning-heavy history (#4633 frontend half).** A settled turn's activity worklog (the reasoning + tool steps behind the "Processed Xm Ys" chip) can carry 80+ rows; in compact-worklog mode (the default) every collapsed group still eagerly built all its row DOM, so a long history ballooned the DOM and a later synchronous layout could tip the tab into a multi-GB freeze. Collapsed settled worklogs now defer their row DOM until first expand. Expanded rows still get full post-processing (syntax highlighting, copy buttons, Mermaid, KaTeX, structured trees) and their open/scroll state is preserved across transcript rebuilds — matching (and, for nested-card scroll, improving on) the eager path. Thanks @nesquena-hermes. (#5860, #5839, #4633) + +- **Server memory growth on reasoning-heavy sessions is fixed (#4633 backend root cause).** Reasoning-heavy sessions accumulate 250–480 KB of `anchor_activity_scenes` that were serialized *before* the `messages` array in the session sidecar, so the cheap 64 KB metadata-prefix read always overflowed and fell back to a full parse on every read — driving memory growth. The scene data is now serialized *after* `messages`, so the metadata prefix stays small (≈2 KB) and modern reads need no full parse. Existing session files in the old layout still load correctly (one authoritative parse, then cached), and round-trips preserve scene content exactly. Thanks @nesquena-hermes. (#5858, #5854, #4633) + +- **Uploading an Office document now writes a Markdown sidecar so the agent can read its text.** When you upload a `.docx`, `.xlsx`, or `.pptx` to a workspace, a best-effort `.md` sidecar with the extracted text is written alongside it (reusing the existing Office-preview extractor — no new dependency). The write is path-contained (`O_NOFOLLOW` openat-walk within the workspace), never overwrites an existing `.md` (`O_CREAT|O_EXCL`), and is best-effort — a sidecar failure leaves the uploaded Office file fully intact. Thanks @rodboev. (#5734, #540) + +- **Security: the embedded-terminal local-origin gate can no longer be bypassed via a spoofed `X-Forwarded-For` header, and no longer locks out direct LAN clients.** Previously, with `HERMES_WEBUI_TRUST_FORWARDED_FOR=1`, the gate honored the forwarded client IP without first verifying the request actually came through a trusted proxy — so a public client connecting directly to a passwordless WebUI could send `X-Forwarded-For: 127.0.0.1` and reach the embedded terminal (which runs a PTY shell). The forwarded chain is now consulted **only when the un-spoofable raw socket peer is a trusted proxy** (loopback, or an address in the new `HERMES_WEBUI_TRUSTED_PROXY_CIDRS` allowlist), it consumes the full `X-Forwarded-For` chain right-to-left (skipping trusted-proxy hops to find the real client), and it fails closed on malformed/empty chains. Direct loopback/LAN clients with no proxy header remain local, so onboarding, first-password/passkey setup, and passwordless terminal access keep working on the common direct-LAN deployment. Thanks @Hinotoi-agent for the original report. (#5764) + +- **Workspace image previews and file downloads now work when the WebUI is served under a subpath.** Behind a reverse proxy that mounts the app at a subpath (e.g. `/hermes/`), workspace image previews and downloads 404'd: those consumers built root-relative `/api/...` URLs that resolved to the server root instead of the mount, while text/code previews (which route through `api()`) kept working — a confusing partial failure. The workspace route helper now resolves against `document.baseURI`, so every consumer (image preview, media/pdf/html frames, download link, open-in-browser) gets a correctly-mounted URL. The normal root-mount deployment is unaffected (identical URLs). Thanks @tomtong2015. (#5830) + +- **Imported external-agent sessions (Claude Code, Codex) no longer show a non-zero "WebUI sessions" count with an empty list.** The server session-count classifier treated a read-only external-agent import (which carries a real title) as non-CLI — counting it under `webui_session_count` — while the client renderer filed the same row in the CLI bucket. The result was a confusing "WebUI sessions (N)" chip over an empty list, with those sessions only reachable under the CLI tab. The server now classifies `external_agent`/`external-agent` sources as CLI, matching the client, so the tab counts agree with where rows render. WebUI, messaging, and delegated-subagent sessions are unaffected. Thanks @nesquena-hermes. (#5846, #5831) + +- **Long tool outputs can no longer blow up the model context after a compression event.** After the conversation is compressed, the protected-tail tool-result payloads sent to the model are now hard-capped to a token budget (applied once, only after a confirmed compression). The cap operates on the model context only (a deep copy of `context_messages`) — it never removes or reorders messages (so no tool_call/tool_result pairing is broken) and never touches the visible/persisted transcript, which keeps the full tool output. Thanks @franksong2702. (#5667, #4685) + +- **"Copy conversation link" now copies an openable URL instead of a `session://` reference.** The copy action produced the internal `session://` reference (not openable in a browser); it now copies an absolute `origin + /session/` URL — the same shape used elsewhere in the app, with tracking query params stripped. Thanks @djsavvy. (#5478) + +- **The model picker now marks the active model with a "Selected" badge.** The currently-selected conversation model shows a distinct **Selected** pill (separate from the "Configured" state), and the picker opens with that model's provider group expanded and scrolled into view — so it's obvious at a glance which model is in use. Thanks @rodboev. (#5757, #5748) + +- **Transparent Stream no longer briefly renders a duplicate of the answer.** A live-token prose snapshot that was a strict prefix of the final answer escaped the existing dedupe guards (which only fired on near-complete matches) and rendered as a visible duplicate. A new provenance-gated guard suppresses only the ephemeral live-token accumulator row (role `prose` / kind `process_prose` / `token` source / `live-prose:*` id) when it's a strict shorter prefix of the settled answer and sits after the last tool row — so genuine intermediate narration is never suppressed. Thanks @rodboev. (#5758, #5749) + +- **A dropped SSE connection can no longer grow the offline replay buffer without bound.** When a stream disconnected without being cancelled, the per-`StreamChannel` offline replay buffer grew for the whole turn (a per-abandoned-turn memory risk). It's now a bounded `deque` (drop-oldest); the reconnect tail is intact and any dropped frames are recovered through the run-journal `last_event_id` replay path (the client requests journal replay on reconnect; the server enforces journal gap coverage before draining the tail). Thanks @ai-ag2026. (#5788, #4633) + +- **The embedded terminal no longer leaks a file descriptor, reader thread, and shell process on exit.** When the terminal client disconnected or the shell exited, the PTY fd, its reader thread, and the shell process were left dangling — accumulating over a long uptime (part of the #4633 long-uptime-crash family). Teardown now closes the fd once, stops the reader thread, and reaps the shell on both the client-exit and shell-exit paths. Thanks @ai-ag2026. (#5835, #4633) + +- **Atomic JSON writers now `fsync` before rename, and the discoverability temp write is thread-safe.** The OAuth, passkeys, and session-discoverability stores wrote via temp-file → `os.replace` but without an `fsync`, so a crash right after the rename could still surface a partially-flushed file; they now flush → `fsync` → replace (matching the settings/config atomic-write hardening). The discoverability temp file also now uses a thread-specific name so concurrent writers can't collide. Thanks @ai-ag2026. (#5843) + +- **The dashboard status poll now pauses while the browser tab is hidden.** The Insights/dashboard status poll kept firing on a backgrounded tab; it now skips unforced polls while `document.hidden` and does one catch-up fetch on re-show (sibling of the hidden-tab session-poll pause). Forced init/save/resume fetches are unaffected. Thanks @ai-ag2026. (#5840) + +- **A slow or stuck client can no longer pin the session-events invalidation stream.** The session-events SSE stream now arms a write deadline after headers and before subscription, so a client that stops reading is torn down (and unsubscribed in `finally`) instead of holding the stream open indefinitely. A healthy slow-but-alive client is unaffected. Thanks @ai-ag2026. (#5841) + +- **The per-client rate-limit maps are now bounded.** The client rate-limit tracking maps grew unbounded over long uptimes; a sweep now prunes keys whose newest timestamp is older than the live window (under the existing locks), so a key still inside its window is never evicted (no rate-limit bypass). Part of the long-uptime stability work. Thanks @ai-ag2026. (#5842) + +- **Steering a live *gateway-owned* run no longer errors and drops your message.** When you steered a stream backed by a gateway run (which has no process-local agent in `SESSION_AGENT_CACHE`), the old path showed a misleading "No agent cached for this session" toast and lost the text you'd typed. WebUI now detects the live gateway run and queues your steer as the next turn on that session (snapshotting model/provider/profile first), updates the queue badge, clears the composer draft, and shows a "Steer queued for next turn" notice — without cancelling the active stream. This is the gateway sibling of the existing local-agent queued-steer fallback. Thanks @rodboev. (#5823, #5585) + +- **CORS preflight (`OPTIONS`) responses are now framed with `Content-Length: 0`.** Under HTTP/1.1 keep-alive the preflight `200` was sent with no `Content-Length`, so a browser/proxy/curl client read the body until connection close (RFC 7230 §3.3.3) — a keep-alive preflight would hang to the 30s timeout and couldn't reuse the connection. The handler now emits `Content-Length: 0` before `end_headers()`; status stays `200` and CORS headers are unchanged. Thanks @ai-ag2026. (#5828) + +- **The session index is now parsed from bytes instead of a decoded string.** The `_index.json` read sites decoded to `str` before `json.loads`; they now pass `read_bytes()` directly (`json.loads` accepts bytes with RFC-4627 auto-detect), which is behavior-equivalent for the BOM-less UTF-8 the app writes and slightly more tolerant of a BOM. Shaves a redundant decode off the session-list hot path. Thanks @ai-ag2026. (#5834) + +- **Creating a worktree-backed session no longer fails when another `cli` package shadows the agent's `cli.py`.** The worktree helper imported the agent's `cli` module with a bare `from cli import ...`, which an unrelated `cli/` namespace package earlier on `sys.path` (e.g. a dependency like `stringzilla`) could preempt — bricking worktree creation. It now loads the agent's `cli.py` by absolute path via `importlib`, bypassing `sys.path` resolution, and preserves the existing failure contract. Thanks @silent-reader-cn. (#5832) + +- **`settings.json` is now written atomically so a crash mid-write can't wipe your settings.** `save_settings()` and the startup default-workspace rewrite used a truncating in-place write, so a crash, full disk, or power loss between truncate and full write could leave `settings.json` empty — losing theme, workspace, tab order, and the login `password_hash` (silently dropping to no-auth). Both writes now go through a same-directory temp file → `fsync` → `os.replace`, preserving the file's existing permission mode (a hardened `0600` stays `0600`) and writing through symlinks. Thanks @ai-ag2026. (#5829) + +- **The remaining `state.db` read projections now open the database read-only.** Eight pure-read session/model projections still opened `state.db` with a writable connection; they now use the read-only (`mode=ro`) URI helper, matching the rest of the read-path hardening. Behavior is unchanged (all sites are pure `SELECT`/`PRAGMA` guarded by existence checks); the two genuinely-writable paths are correctly left alone. Thanks @ai-ag2026. (#5833) + +- **A stuck or half-open gateway connection no longer pins a turn for 10 minutes ignoring Stop.** Gateway SSE reads now carry a bounded byte-silence budget (default 600s, env-tunable via `HERMES_WEBUI_GATEWAY_READ_TIMEOUT`) and treat a read timeout as terminal — so pressing Stop during a silent stall ends the turn cleanly instead of waiting out the full timeout. The 600s default preserves the prior budget so legitimately long, silent tool calls are not cut off. Thanks @ai-ag2026. (#5789, #2476) + +- **Session loading is faster — addresses the "WebUI feels laggy" reports.** The session-load path (`/api/sessions` + session-by-id) had redundant full-metadata reads on every request; it now uses a profile-scoped, content-verified, lock-guarded cache (capped at 32 entries) with a fast path that only serves cached data when disk state is provably fresh, falling back to full comparison otherwise. Response shape and profile-scoping are unchanged. Thanks @Kopamed. (#5803, #5455) + +- **The background-wakeup status-row label is now localized.** The "Background wakeup" label introduced with the wakeup status row (#5766) was hardcoded English; it now resolves through `t('process_wakeup_label')`, with translations in all 15 locales. Thanks @Isla-Liu. (#5768) + +- **The background-task completion drain thread can no longer spin at 100% CPU when the process registry is (re)initializing.** If the registry didn't yet expose its `completion_queue`, the drain loop's broad `except Exception: continue` swallowed the `AttributeError` and immediately retried with no delay — a hot loop. The loop now reads the queue defensively (`getattr(..., None)`) and backs off on the stop event when it's missing, catches `queue.Empty` separately as the normal idle path, and logs (no longer silently swallows) any unexpected queue error before backing off. No delivery latency is added — real completions are still drained on the 1 Hz blocking `get`. Thanks @ai-ag2026. (#5782, #2476, #4633) + +- **Zero-token turns (a pre-flight cancel or setup error) no longer leak a streaming-meter session.** The streaming meter's `begin_session()` and its 1 Hz ticker were started before the outer `try`, so a raise or cancel before the first token left the `_sessions[stream_id]` entry unreclaimed (`get_stats()` only prunes sessions that emitted a token), inflating the SSE `active` count over a long-lived server. `begin_session()` + the ticker start now live inside the outer `try` so the paired `end_session()` / ticker-stop teardown in the `finally` always runs. Thanks @ai-ag2026. (#5787, #4633, #2476) + +- **A long-running server no longer slowly leaks memory from the background-task completion-dedup map.** Each session that ever had a background task complete (delegation-completion delivery, kanban events) left a permanent entry in an in-memory dedup map — including headless completions where no browser tab was ever open — so the map grew unbounded over a long-lived server's lifetime. The session-channel reaper now sweeps that map by delivery lifecycle (dropping a session's entry once its completion has been delivered and is no longer pending), and session deletion prunes any still-pending entry. Exactly-once delivery is unaffected — it's independently guaranteed by the process registry's per-task consumed marker. Thanks @ai-ag2026. (#5781, #4633) + +- **Running out of provider credentials now shows an actionable message instead of a bare "Error."** When a provider's credential pool was exhausted ("All 0 credential(s) exhausted for "), the turn failed with a generic error and no hint, because that message didn't match the quota-error pattern and fell through to the catch-all. It's now classified distinctly with a hint that tells you what happened and what to do, without swallowing genuine quota errors or blocking partial-result harvesting. (This is the classification half of the credential-exhaustion work; recovering partial output on that path is a separate follow-up.) (#5559, #3929) + +### Internal + +- **Centralized WebUI static-asset resolution.** The app-shell (`index.html`), `/static/*`, manifest, service worker, and favicon now resolve through a single shared resolver instead of scattered `__file__`-relative logic — behavior-preserving (same bytes, headers, cache signature, CSRF-per-request injection, and path-traversal rejection), with new resolver regression tests. Thanks @rodboev. (#5557, #2695) + +- **Documented the lock-free GIL-atomic contract for the streaming `STREAM_*` buffers.** Added an in-code explanation of why the per-token hot path mirrors partial-text/reasoning/tool-call buffers without holding `STREAMS_LOCK` (single writer + GIL-atomic `STORE_SUBSCR` on immutable strings → readers see complete-but-possibly-stale values, never torn), and why snapshot readers do take the lock. No behavior change. Thanks @ai-ag2026. (#5800) + +## [v0.51.792] — 2026-07-01 + +### Fixed + +- **WebUI no longer grows memory without bound / crashes after hours of uptime.** The in-memory session cache is now safely bounded (`webui.sessions_cache_max` in config.yaml, default 300) with data-safe eviction (never evicts a streaming/pending/not-yet-persisted session) + lazy reload from disk. Fixes the memory-growth crash cluster on long-running self-hosted installs. (#4765, #2233, #4633) +- **Per-provider reasoning-effort levels can be configured in `config.yaml`** via a `reasoning_efforts` map under a provider entry; routes that must never expose a reasoning toggle (nested Gemini image/embedding) stay denied. Thanks @CharlesMcq. (#5313) +- **More complete Chinese (zh-CN) localization** — busy-placeholder hints, Kanban/skills/runtime labels, provider cost-budget controls and more are now translated; interpolation tokens preserved, no keys dropped. Thanks @Loukky. (#5335) + +- **After a container restart, a stale user message is no longer permanently prepended to every later turn.** WebUI's state.db reconciliation dedup key now strips the workspace prefix for user messages (matching the streaming-side identity), so a state.db row (`[Workspace::v1: /workspace]\n`) and its bare-text sidecar row are recognized as the SAME message instead of appended as a duplicate that the agent then merges into a permanent composite. Fixes the post-restart contaminated/out-of-order messages. (#5339) + +- **Silent server crashes now leave a diagnostic instead of vanishing.** Enabled `faulthandler` and installed thread/main-thread exception hooks plus an exit audit, so an uncaught handler-thread exception or a native fault is logged with a traceback rather than the process disappearing with no trace (previously a ~9-16h silent exit). Diagnostic hardening; stdlib-only, startup-only, no request-path change. (#4633) + +- **Internal verification-stop nudge no longer leaks into the transcript.** The Hermes Agent's internal verify-before-finish loop appends synthetic scaffolding turns (a "premature done" answer + a `[System: ...verification evidence...]` nudge) flagged with structured markers. WebUI now honors those markers (`_verification_stop_synthetic` / `_pre_verify_synthetic`) and drops the scaffolding turns from the visible transcript instead of rendering them as real user/assistant messages. (#5334) + +- **Delegated subagent sessions no longer vanish from the sidebar.** A subagent (delegate_task) child whose sidebar row was built from a stale sidecar reporting 0 messages now receives its real message count from the agent state.db, so it stays visible (nested under its parent) instead of being silently dropped by the sidebar visibility filter. (#5308) + +- **No more scroll jump-back on mobile while messages load.** When the app realigns the viewport after loading older messages, mobile browsers no longer double-compensate the scroll position (their native overflow-anchor fighting the app's own scroll write), which caused a visible jump. Desktop behavior is unchanged. (#5338) + +- **No more white flicker/flash while the assistant streams on light themes.** Live token-by-token markdown updates no longer inherit the global dark/light `color`/`background` transition, so the streaming turn paints instantly instead of briefly fading on each token. Theme-switch transitions elsewhere are unchanged. (#5328) + +- **No more false "Clarify endpoint unavailable. Please restart server." toast.** The clarify-pending poll used to fire the restart-server warning on any error whose message merely contained "404"/"not found" — so a harmless stale `Session not found` from a just-switched profile mis-triggered it even though the clarify endpoint was fine. The poll now branches on the structured HTTP status: a session-scoped 404 is treated as a stale poll (silently stops), and the restart warning fires only on a genuine missing-route 404. Interrupt provenance is also now labeled (explicit Stop vs stream-lifecycle) for clearer settlement. (#5345) + +- **Ghost sessions can be cleared from the sidebar.** Bulk index-only "Untitled" sessions — rows left in the session index with no backing conversation file — are now pruned by the session cleanup sweep instead of lingering forever and un-removable. A legitimate session (with a saved file or an active in-memory session) is never touched. (#5331) + +- **Delegated subagent sessions open correctly and are treated as view-only.** Opening a `delegate_task` subagent child from the sidebar now loads its transcript from Hermes `state.db` instead of showing an empty pane (it has no WebUI sidecar of its own). Subagent children are view-only — owned by the delegate runner — so they render read-only and every session-mutation route (delete / rename / truncate / clear / pin / duplicate / branch / retry / undo / archive / compress / draft / personality / goal / btw) refuses them, preventing accidental edits or deletion of the child's state.db transcript. (#5307) + +- **Delegated subagent sessions no longer flicker or vanish from the sidebar during an active parent turn.** A linked delegate child of the currently-active session is now kept visible even when it transiently reports zero messages between session-list polls (previously it was dropped by the visibility filter, then reappeared on the next refresh). The exception is scoped strictly to children of the active parent, so unrelated empty sessions stay hidden. (#5320, fixes #5306 and #5305) + +## [v0.51.791] — 2026-06-30 + +### Fixed + +- **The Busy input mode preference (queue / interrupt / steer) is honored on the first send after load.** Previously a send that happened before the async boot settings resolved fell back to `queue`, ignoring a saved `steer`/`interrupt` preference until it was re-saved. The preference is now mirrored to localStorage and applied eagerly on boot, on settings-load failure, and whenever it's changed via Settings. Thanks @b3nw for the report. (#5170, fixes #5167) + +## [v0.51.790] — 2026-06-30 + +### Fixed + +- **Historical `reasoning_content` is no longer replayed to local/generic model backends that can't use it.** Provider-facing conversation history now gates `reasoning_content` replay: preserved by default (no change for cloud providers), stripped only on explicit `webui.reasoning_content_replay='strip'` or a confidently-local `auto` backend (LM Studio / Ollama / llama.cpp / generic OpenAI-compatible). Persisted transcripts are untouched. Thanks @Stacey2911. (#5024) + +## [v0.51.789] — 2026-06-30 + +### Fixed + +- **Native OIDC login for WebUI sessions.** WebUI can now authenticate via an OpenID Connect provider (authorization-code + PKCE), with hardened token validation: HTTPS-only discovery/JWKS with private/loopback/link-local/reserved targets rejected, alg/curve pinning (RS/ES/HS/none confusion rejected), issuer + audience + nonce + state checks, JWKS kid rotation, and strict expiry validation. Thanks @rodboev. (#5012, #3825) + +## [v0.51.788] — 2026-06-30 + +### Fixed + +- **Hardened the embedded terminal against unauthenticated access when auth is disabled.** The embedded-terminal endpoints (start / input / resize / close / output) are now gated to local-origin requests when WebUI auth is not enabled, closing an access gap on network-exposed instances. Authenticated and local use is unaffected. (#5268) + +## [v0.51.787] — 2026-06-30 + +### Fixed + +- **Docker build no longer fails when `/opt/hermes` contains a `.playwright/` directory.** The agent-source staging step now excludes `.playwright` in both the `rsync` and `cp -a` fallback paths, fixing `rsync error code 23` during the build. Thanks @enihcam. (#5316, fixes #5315) + +## [v0.51.786] — 2026-06-30 + +### Fixed + +- **Reorder the chat-footer controls by dragging.** The composer footer controls (Attach, Saved prompts, Mic, Profile, Workspace, Model, Reasoning, Context, and the situational controls) can now be reordered by dragging their chips in Settings, in addition to toggling visibility. The order persists per profile and is validated/deduplicated server-side. Thanks @Paladin173. (#5075) + +## [v0.51.785] — 2026-06-30 + +### Fixed + +- **Orphaned zero-message native/CLI sidebar sessions are pruned from the sidebar.** When a CLI or API-server session that was clicked in WebUI (creating a WebUI-owned sidecar) is later deleted outside WebUI, the stale zero-message row no longer lingers in the sidebar forever — it's pruned once the backing agent session is confirmed gone. A session with any real transcript (`message_count > 0`), attention signal, or active/pending stream is always preserved (never pruned), so no transcript is lost. Thanks @enihcam. (#4988, fixes #4985) + +## [v0.51.784] — 2026-06-30 + +### Fixed + +- **Extension skins can declare a base scheme (`light`/`dark`).** A registered extension skin may now set `scheme: "light" | "dark"` via `registerHermesSkin()`, so a light-only or dark-only skin forces the effective base (`.dark` class) it needs while selected — without rewriting the user's saved Theme preference. Core owns the light/dark base decision for registered skins, so extensions no longer need broad workaround CSS. The scheme value is sanitized to only `light`/`dark` and never enters the CSS token path (no injection vector); the base initializes from the pre-painted theme so a no-scheme skin can't flash a saved dark theme to light during boot. Thanks @franksong2702. (#5271) + +## [v0.51.783] — 2026-06-30 + +### Fixed + +- **More complete Simplified Chinese (zhCN) localization.** Previously-unlocalized UI strings now have zhCN translations. Thanks @Loukky. (#5279) + +## [v0.51.782] — 2026-06-30 + +### Fixed + +- **A reader who scrolled up no longer gets yanked to the bottom when the SSE stream drops and recovers.** The four SSE-recovery follow-guards now capture explicit follow-intent (sticky-aware: a manually scrolled-up reader counts as unpinned even within 1200px of the bottom) before the terminal-error marker mutates the transcript, so a reconnect re-follows a genuine follower but spares a reader who scrolled up to read history. Thanks @allenliang2022. (#5217) + +## [v0.51.781] — 2026-06-30 + +### Fixed + +- **Deep-link composer prefill via `?q=` no longer loses the draft or starts a session early.** Opening the WebUI with a `?q=...` (and related prefill params) pre-fills the composer with that text; the draft now survives a login redirect (params are consumed after auth/profile bootstrap, and the logged-out `?q=` is carried through `next`), and a prefill boot leaves the session uncreated until you actually send — it no longer silently binds a fresh default-workspace session. Thanks @rodboev. (#4969, #4961) + +## [v0.51.780] — 2026-06-30 + +### Fixed + +- **The Run dispatcher now works on the default Kanban board.** The default board is stored as `null`, and the dispatcher's board check treated that as "no board," so the Run button silently no-opped on the default board. It now resolves the default board correctly and dispatches. Thanks @rodboev. (#5289, fixes #5231) + +- **PWA / deep-link new-chat launches show the empty conversation immediately.** The boot model-dropdown hydration is deferred to the background instead of blocking the first paint, so a new-chat launch from the installed PWA or a deep link renders the composer right away rather than waiting on the model list. Thanks @santastabber. (#5287) + +## [v0.51.779] — 2026-06-30 + +### Fixed + +- **Two new opt-in appearance skins: Neon Soft and Neon Paint.** Both are CSS-only, namespaced under `[data-skin]`, registered in server-side skin persistence, and selectable from Settings → Appearance — they change nothing unless you pick them. Neon Soft is a muted purple/violet neon; Neon Paint a bolder magenta + cyan neon. Thanks @savagebread. (#4738) + +## [v0.51.778] — 2026-06-30 + +### Fixed + +- **Opt-in Shift+Enter send-key mode.** A new send-key option mirrors the existing Ctrl+Enter mode: when set to "shift+enter", Shift+Enter sends the message and a plain Enter inserts a newline; the default "enter" behavior (Enter sends, Shift+Enter newline) is unchanged when the option is off. IME composition, numpad Enter, and the command-dropdown are all handled, with no double-send. Thanks @futureworld678. (#5005) + +## [v0.51.777] — 2026-06-30 + +### Fixed + +- **Configurable provider spend budget with a percent-used indicator.** The provider usage settings now have a "Monthly budget" field (with Set / Clear) and a progress bar showing how much of the budget the current monthly pace has used (e.g. "65% of $50.00 budget"). Budget input is validated on both the save and read paths via a shared coercion helper, so out-of-range or malformed values (e.g. `0.004`, `5e12`) are rejected rather than silently stored. Thanks @rodboev. (#5120, #692) + +## [v0.51.776] — 2026-06-30 + +### Fixed + +- **Opt-in per-project "New conversation" shortcuts in the sidebar.** A new default-off setting ("Show per-project new-conversation buttons") adds a small `+` button to each sidebar project chip that starts a conversation already assigned to that project. Off by default — the sidebar is unchanged unless you enable it. Thanks @rodboev. (#5002, #4676) + +## [v0.51.775] — 2026-06-30 + +### Fixed + +- **WebUI now supports an OpenAI-compatible TTS backend.** A new "OpenAI TTS (server)" option in Settings → TTS Engine (alongside Browser / Edge / ElevenLabs) routes text-to-speech through an OpenAI-compatible `/v1/audio/speech` endpoint for both the voice-mode auto-read and the per-message Listen button. The endpoint is SSRF-hardened: the configured base URL is rejected if it carries credentials or resolves to a private / loopback / link-local / reserved / multicast address (only public hosts and an explicit localhost-over-http dev case are allowed), and the request uses a no-redirect opener so an upstream redirect can't bounce to an internal target or leak the `Authorization` bearer; content-type and response-size caps apply and the key is resolved server-side. Default engine is unchanged. Thanks @rodboev. (#5079, #4982) + +## [v0.51.774] — 2026-06-30 + +### Fixed + +- **The Kanban "New task" modal now exposes skills, max runtime, and parent dependencies.** The WebUI task creator reaches parity with the CLI Kanban fields: a Skills input (comma-separated, e.g. `python,git`), a Max runtime (seconds) field (empty = unlimited), and a Parent dependencies (task ID) field. Max-runtime input is validated (`/^[1-9]\d*$/`) with an inline localized error so a junk value can't silently become 1 or unlimited; all fields are XSS-safe. Thanks @rodboev. (#4881, #4470) + +## [v0.51.773] — 2026-06-30 + +### Fixed + +- **Mermaid diagrams in chat now have on-diagram zoom / pan / fit / fullscreen controls.** A GitHub-style toolbar (zoom in, zoom out, reset, fit, fullscreen) mounts on each rendered Mermaid diagram so large flowcharts are navigable in place instead of static; zoom is clamped 0.25×–8×, a drag is suppressed from opening the lightbox accidentally while a clean click still opens it, and the Mermaid source is HTML-escaped (no XSS). Thanks @rodboev. (#4871, #4814) + +## [v0.51.772] — 2026-06-30 + +### Fixed + +- **Mobile titlebar now prioritizes the session title, with tap-to-reveal and long-press actions.** On mobile the titlebar gives the session title priority (ellipsized when long) over surrounding chrome; tapping the title reveals the full title in a popover, and a long-press opens the session actions. Listener-leak-safe (tap wired once per element, outside-click handler cleaned up on dismiss/switch; long-press guarded against double-fire, cancelled on touchmove). Read-only sessions are skipped, and desktop is untouched (touch-gated). Thanks @rodboev. (#4574, #4520) + +## [v0.51.771] — 2026-06-30 + +### Fixed + +- **Mobile approval dialog and touch targets are easier to tap.** At mobile width the approval card's action buttons now lay out as a clean 2-column grid (Allow once / Allow session / Always allow / Deny) with the "Skip all this session" YOLO button full-width below, every button at a 44px minimum touch target; the titlebar new-chat/reload and the mobile sidebar-close controls are also bumped to 44px. Mobile-scoped (inside the mobile media query) — desktop layout is unchanged. Thanks @disco32r. (#5019) + +## [v0.51.770] — 2026-06-30 + +### Fixed + +- **`/pet` now works in the WebUI, handing off to the Desktop Companion extension.** Typing `/pet` (and its subcommands) is intercepted client-side and routed to the petdex companion extension with graceful per-stage guidance when the extension isn't installed; the command output is XSS-safe. Thanks @rodboev. (#5168, #4843) + +- **Settings search results are now ranked by match source.** Results are ordered title/label > value/option > description, with a stable prefix + earlier-index tie-break, and the result cap is applied after ranking so the best matches are never dropped; supplemental and provider/plugin-card terms remain searchable (reorder-only). XSS-safe. Thanks @rodboev. (#5211, #5149) + +## [v0.51.769] — 2026-06-30 + +### Fixed + +- **Opt-in busy composer placeholder hint.** A new default-off setting swaps the composer placeholder to a busy-mode-specific hint (queue / steer / interrupt) when the agent is running, deferring to the compression placeholder and any user draft, and restoring on idle. Text-only (no layout change), XSS-safe, localized across all 14 locales. Thanks @rodboev. (#5161, #5144) + +## [v0.51.768] — 2026-06-30 + +### Fixed + +- **A server-initiated turn that finished during an SSE gap now renders correctly on a visible-tab wake.** When a tab regained focus after the SSE stream had a gap, a self-heal reload could clobber a concurrent same-session `server_turn_started`, losing live-render ownership. The active stream id is now re-read after the awaited message load and scoped to the same session, so a concurrent same-session stream is honored by the attach/idle decision. The original SSE-gap self-heal (metadata-only server-ahead, in-place replace, no jump) is intact. Thanks @allenliang2022. (#5248) + +- **The latest assistant response now carries a single accessible landmark, only once it has settled.** The "Latest Hermes response" landmark is no longer applied to an actively-streaming turn (only the latest settled turn gets it), giving screen-reader users one stable, correctly-placed landmark. Thanks @sheldon-im. (#5207) + +## [v0.51.767] — 2026-06-30 + +### Fixed + +- **A just-settled Compact Worklog now stays open for an unpinned reader instead of jumping.** When a streaming turn settled, the worklog could collapse-jump for a reader who wasn't pinned to the bottom; the disarm path now collapse-renders consistently for both pinned and unpinned readers (one frame, height-stable swap), so there's no post-settle scroll jump on mobile. No cache leak, and no #4970 regression. Thanks @allenliang2022. (#5260) + +- **The sidebar source filter now offers Webhooks parity with Cron Jobs.** Adds a `show_webhook_sessions` toggle threaded through the session-list cache key + builder and a localized checkbox, so webhook-origin sessions can be filtered like cron sessions. Preserves the archived-row paging behavior. Thanks @MaudeBot. (#4957, closes #4956) + +## [v0.51.766] — 2026-06-30 + +### Fixed + +- **A hidden background tab no longer keeps retrying a wedged multi-pane attach.** When a server-initiated turn arrived while the tab was hidden, a failed deferred multi-pane attach could leave the composer stuck busy and poll `/api/session/status` indefinitely. The attach failure now clears the partial `S.activeStreamId`/`S.busy`/`session.active_stream_id` state before returning, and the retry/deadline is bounded per `(sid, streamId)` (reset on stream-id change), so there's no wedged retry or unbounded polling. Thanks @allenliang2022. (#5266, follow-up to #5172) + +- **Large composer pastes becoming a `.md` attachment is now a toggleable Setting.** A new opt-in `large_text_paste_as_attachment` preference (default-on, preserving current behavior) gates the existing large-paste→attachment conversion, for users who'd rather keep big pastes inline. Thanks @Stacey2911. (#5264) + +- **A manual scroll-up in the live Compact Worklog is no longer yanked back to the bottom.** A manual-reader signal (`_recentMessageScrollIntent`) is recorded only when you scroll away from the bottom (>120px via wheel/touch/key/scrollbar-drag), and the DOM-replace snapshot then preserves that manual position instead of forcing it to the bottom. The signal excludes raw touch/key recency, so it doesn't reintroduce the iOS (#3479) or Android (#4856) scroll regressions, and a true bottom-follower still auto-follows. Thanks @franksong2702. (#5253, fixes #5252) + +- **Delegated subagent sessions now stack under their parent in the sidebar instead of being orphaned.** Cross-surface child orphaning is narrowed to only external/messaging parents (Telegram/CLI) with no WebUI-owned parent row; a delegated subagent session that has a visible WebUI parent now nests under it. Archived/hidden parents still suppress the orphan, with no cycle risk. Thanks @santastabber. (#5244) + +- **A server-initiated turn (cron / self-wake / restart hook) now appears in a hidden tab without a manual refresh.** While a tab is hidden the WebUI drops its persistent per-session live-view SSE to respect the per-origin connection budget, so a turn started server-side while you were on another tab was silently dropped until you refreshed or sent a message. A lightweight `/api/session/status` poll now detects a genuinely in-flight server-initiated turn while hidden and attaches the existing live renderer to it (reusing the normal reconnect path, idempotency-guarded, torn down on re-show / session switch / stop). The connection budget is respected: a single stream is opened only for an actually-running server turn, and idle hidden tabs keep just the short poll. Thanks @allenliang2022. (#5172) + +## [v0.51.765] — 2026-06-30 + +### Fixed + +- **The actively-viewed session no longer shows a stale unread badge after compaction.** The unread marker and the viewed marker were computed from two different message-count sources (`completedSession.message_count` vs `message_count ?? S.messages.length`), which diverged after a compaction, leaving the session you were actively looking at with a lingering unread dot. Both now read one unified `completedMessageCount` on done-settle, so the badge clears correctly. Thanks @rodboev. (#5276, fixes #5273) + +- **A CLI-origin session continued in the WebUI no longer loses its immediate prior context.** When a session started in the Hermes CLI was continued in the WebUI, the stitched CLI transcript could be shadowed by a stale sidecar context prefix on chat-start. `_get_or_materialize_session` now refreshes the CLI messages from `get_cli_session_messages` on chat-start and keeps the stitched CLI transcript authoritative (the compaction/compression-anchor path is preserved). Thanks @rodboev. (#5274, fixes #5270) + +- **Internal: split the terminal-failure transcript evaluator into a focused helper.** Behavior-preserving extraction of `_turn_transcript_lacks_final_assistant_answer` (operating on the already-merged transcript) with the original name kept as a thin delegating wrapper, so the terminal-failure settlement path is easier to reason about; the `error:''` silent-failure sentinel and `_terminal_failure` handling are unchanged. Thanks @nankingjing. (#5272, #5141) + +- **Context-menu Delete now removes the sidebar session row immediately, matching swipe-delete.** Menu-delete now passes an immediate `beforeDelete` hook through the existing `deleteSession(sid, beforeDelete)` path so the row disappears optimistically instead of lingering until the server round-trip completes; failure-rollback is handled by the existing flow. Thanks @franksong2702. (#5256, fixes #5255) + +- **Settings search results no longer get clipped by the panel boundary.** The settings side-menu buttons are wrapped in a scrolling `.settings-menu-items` container and `#settingsMenu` is set to `overflow: visible`, so the absolutely-positioned search-results dropdown escapes the scroll-clip instead of being cut off. Thanks @rodboev. (#5254, fixes #5250) + +- **Transparent Streaming live-activity rows now replay in the correct anchor scene order.** Live-activity rows render into the live assistant turn before the compact gate and idempotently replace legacy live-activity surfaces as the source of truth (via `data-anchor-scene` attributes + a scroll-rebuild guard), fixing live→final ordering on transparent-streaming turns without the prior scroll jump. Thanks @franksong2702. (#5257) + +## [v0.51.764] — 2026-06-29 + +### Fixed + +- **The `/goal` loop now continues after a turn on the gateway chat backend, not just the local one.** When WebUI chat is routed through the Hermes Gateway backend, the gateway worker persisted the final assistant turn and ended the stream *before* the goal judge ran, so a standing `/goal` silently stopped after one turn. The gateway worker now evaluates the goal locally after the turn settles (the same `evaluate_goal_after_turn` path the local backend uses) and emits the existing `goal` / `goal_continue` events, so the browser continues the goal exactly as it does on the local backend. Evaluation only runs when the turn is goal-related and a goal is active, so ordinary gateway turns are unaffected. Thanks @rodboev. (#5251, fixes #5092) + +## [v0.51.763] — 2026-06-29 + +### Fixed + +- **Session-scoped API endpoints now enforce the active profile on every request-supplied session ID.** On a multi-profile box, endpoints that accept a `session_id` (in the query string or the JSON/multipart body) — session read/duplicate/rename/delete, file operations, uploads, and chat start — looked up the session without confirming it belongs to the request's active profile, so a caller could reference another profile's session by id. A central preflight now rejects a request-supplied session id that isn't visible to the active profile (404), wired across all the session verbs plus the upload and chat-start paths; stream IDs are authorized through a synchronously-registered owner map so the check doesn't depend on worker-startup timing. Single-profile / no-auth deployments and same-profile access are unchanged. Thanks @starship-s. (#5198) + +## [v0.51.762] — 2026-06-29 + +### Fixed + +- **The file manager now enforces the active profile when resolving a session's workspace.** When multiple Hermes profiles share a box, a session-scoped file operation looked up the session by id without checking which profile owns it, so a session belonging to a *different* profile could expose its workspace path to the currently active profile. The session-for-file-ops resolver now rejects a session whose owning profile doesn't match the request's active profile (raising the same not-found that the file routes already translate to a 404), failing closed. Same-profile access, single-profile/no-auth deployments, and the external-session (state.db) fallback are all unchanged — only genuine cross-profile lookups are now denied. Thanks @Hinotoi-agent. (#5179) + +## [v0.51.761] — 2026-06-29 + +### Fixed + +- **A terminal streaming failure no longer wipes the transcript you can already see.** When the live SSE connection dropped for good mid-response and the server's settled snapshot came back *shorter* than what the browser had already rendered, the chat pane replaced the visible transcript with the shorter snapshot — momentarily losing rows the reader was looking at. The recovery path now preserves the visible transcript when the server snapshot is a verified strict prefix of it and the visible tail is the "Connection interrupted" marker, and otherwise still takes the authoritative server messages (normal settlement is unchanged and stays server-authoritative). The interrupted-connection marker is also de-duplicated so a recovery can't stack multiple copies. Thanks @rodboev. (#5240, fixes #5224) + +## [v0.51.760] — 2026-06-29 + +### Fixed + +- **Forking/truncating a conversation mid-history no longer drops a kept turn or its tool rows.** When the truncate logic matched rows that lacked a stable id/timestamp (common for provider rows, especially after a recovery that strips timestamps), a duplicate row adjacent to the cut could bind the wrong copy — dropping the kept user turn, or, in the ambiguous case, the kept assistant turn's trailing tool-result rows. The matcher now compares a richer row signature (role + content + tool_call_id / tool_use_id / tool_name / tool_calls) and **fails closed on ambiguity** (cutting at the earliest ambiguous candidate so the kept turn and its tool tail survive) instead of greedily binding the first match. Thanks @rodboev. (#5212, #5134) + +## [v0.51.759] — 2026-06-29 + +### Fixed + +- **Async continuations (delegate_task / process wakeup) no longer fall back to a bare, mis-routed model.** When a server-side continuation re-entered model resolution without a requested provider, the resolver's slow path skipped the custom-provider repair, so a bare session model wasn't re-qualified to the profile's `custom:…/model` default and could be sent to the wrong endpoint. The slow path now repairs a bare model back to the profile's slash-qualified custom-provider default when the suffix matches (mirroring the fast path). Thanks @claw-io. (#5246, fixes #5225) + +## [v0.51.758] — 2026-06-29 + +### Fixed + +- **Settled transcripts with duplicate assistant turns no longer mis-attribute tool-output rendering.** When a reloaded conversation contained byte-identical assistant messages, the legacy tool-metadata fallback used `indexOf` to test anchor ownership, which always resolved to the *first* matching message — so an anchor-owned turn could be wrongly treated as a legacy fallback (or vice versa). Ownership is now gated by each message's actual raw index, and the shared check is consolidated into one helper used at both call sites. Thanks @franksong2702. (#5242) + +## [v0.51.757] — 2026-06-29 + +### Fixed + +- **The session sidebar stays fast with a very large archive, and archived search still finds everything.** Archived sidebar rows are now paged (capped per request) instead of all loaded at once, so an instance with thousands of archived sessions no longer pays to render them all. When you turn on "Show archived" and start a search, the sidebar transparently refetches without the archive cap so a title/id match beyond the first archived page is still reachable, then restores normal paging when the search clears. Default (non-search) paging keeps the existing 2000-row safety cap, and a render-generation guard prevents a slower capped/uncapped response from overwriting newer sidebar state. Thanks @santastabber. (#5200) + +## [v0.51.756] — 2026-06-29 + +### Fixed + +- **Post-restart config reloads no longer stampede under concurrent traffic.** Right after a restart (or profile switch), several in-flight requests could all see the config cache as stale and each trigger a full `config.yaml` re-parse at once, briefly stalling the single-process server. Stale reads now route through a shared gate that re-checks freshness inside the config lock (double-checked locking), so concurrent stale readers collapse to a single refresh instead of a thundering herd. Forced reloads (writes, profile switches) and the in-memory-override guard keep their existing semantics. Thanks @rodboev. (#5235, fixes #5220) + +## [v0.51.755] — 2026-06-29 + ### Added - **The Neuralwatt provider now picks up its API key from `NEURALWATT_API_KEY`.** Added the env-var mapping so a Neuralwatt provider configured against the generic OpenAI-compatible path resolves its key the same way the other custom providers do — no other code path changed. Thanks @b-yelverton. (#4810) -- **Voice mode's silence timeout and continuous-recognition behavior are now tunable** via two `localStorage` keys: `hermes-voice-silence-ms` (pause before auto-send, default 1800 ms) and `hermes-voice-continuous` (`"true"`/`"false"`, default false — keep the mic open across natural pauses). This addresses users who pause mid-sentence getting cut off, or who want a slower/faster auto-send. No new UI (set from the dev console for now); defaults are byte-identical to before, and a missing/invalid/non-positive `silence-ms` falls back to 1800 with a 200 ms floor so a mistyped value can't trigger instant auto-send. Thanks @ChonSong. (#5176, fixes #4761) -- **The composer footer now fits its controls to the available width instead of relying on fixed breakpoints.** A small fit engine measures the footer's left control group and steps it down through full → icon-only → overflow-menu as space tightens (ResizeObserver + MutationObserver, rAF-debounced), so on mid-width layouts (e.g. tablet / split panes) more controls — the workspace, model, and reasoning chips — stay directly visible as clean chips before anything moves into the overflow menu, while very narrow widths still collapse to a tidy icon row. Verified equal-or-better than the previous fixed-breakpoint layout across wide / desktop / tablet / mobile. Thanks @Paladin173. (#4657) -- **Extensions can now declare browser-local settings that users review, edit, and reset from Settings → Extensions → Installed.** An extension that requests `permissions.storage.owned` can ship a `settings_schema` (typed fields: boolean / string / number / integer / enum, each with a label, default, and optional help text); core sanitizes the schema server-side (drops `sensitive` fields, unsupported types, malformed enum options, duplicate keys, and type-mismatched defaults), injects the accepted schema before extension scripts, and renders generic controls (with Save / Reset / Clear-storage) in the Installed panel. Persistence is **browser-local only** through core-mediated accessors (`window.hermesExt.settings.forExtension(id)` / `.storage.forExtension(id)`) keyed by an enforced `ext::` namespace, so one extension can't read or stomp another's (or core's) keys; the backend never stores extension settings, never exposes a generic write route, and never treats these values as secrets. Every rendered label/value is HTML-escaped. This is the agreed browser-local first pass of the extension-owned settings contract (RFC #5094); state-backed sync and secret-safe storage are intentionally deferred. Documented in `docs/EXTENSIONS.md`. Thanks @rodboev. (#5155, closes #5094) +## [v0.51.754] — 2026-06-29 -- **Extensions can now register a custom text-to-speech engine** via a new `window.registerHermesTtsEngine({ id, label, synthesize })` API. A registered engine appears in the Settings → TTS Engine dropdown alongside the built-ins (Browser / Edge / ElevenLabs) and is used by **both** playback paths — the hands-free voice-mode auto-read and the per-message "Listen" button. The extension only provides an async `synthesize(text, opts)` that returns audio bytes (ArrayBuffer / Blob); core owns selection, the dropdown option (label inserted via `textContent`, never HTML), and playback through the same `