Conversation
|
| Filename | Overview |
|---|---|
| api/config.py | Adds show_claude_code_sessions default (True) and registers it in _SETTINGS_BOOL_KEYS. Clean and consistent with existing settings patterns. |
| api/models.py | Adds _callable_accepts_include_claude_code compatibility helper, threads include_claude_code through _resolve_cli_sessions_context and get_cli_sessions, and folds the flag into the lower CLI-session cache key. All-profiles mode correctly limits the global Claude Code scan to the first profile. |
| api/routes.py | Adds _session_list_cache_key wrapper that appends the Claude Code flag, replaces TypeError-swallowing with inspection-based compatibility, and threads show_claude_code_sessions through the builder and GET handler. Cache key default aligns with the config default (True). |
| static/panels.js | Hydrates, gates, and autosaves the new checkbox correctly. One inconsistency: show_claude_code_sessions (true-default) is initialized with !! instead of the !== false pattern used for show_cli_sessions and check_for_updates next door. |
| static/index.html | Adds the Claude Code sessions checkbox as a subordinate indented field after cron sessions, with correct i18n attributes and styling. |
| static/i18n.js | Adds settings_label_claude_code_sessions and settings_desc_claude_code_sessions in all 14+ locale blocks; non-English locales carry English fallback strings (same pattern as other partially-translated keys in this repo). |
| tests/test_issue4714_claude_code_visibility_toggle.py | Comprehensive new test file covering route behavior, cache key differentiation, all-profiles deduplication, compatibility shim, autosave payload, live UI gating, and explicit save-path regression. |
| tests/test_1003_preferences_autosave.py | Extends autosave coverage list with the new checkbox/key pair. Minor doc-comment tidy-up removes the hardcoded field count. |
Sequence Diagram
%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant UI as Browser (panels.js)
participant Route as /api/sessions (routes.py)
participant Builder as _build_session_list_cache_payload
participant Models as get_cli_sessions (models.py)
participant DB as state.db
UI->>Route: GET /api/sessions
Route->>Route: load_settings() → show_claude_code_sessions
Route->>Route: _session_list_cache_key(..., show_claude_code_sessions) cache hit?
alt cache miss
Route->>Builder: _build_session_list_cache_payload(..., show_claude_code_sessions)
Builder->>Models: "get_cli_sessions(include_claude_code=show_claude_code_sessions)"
Models->>DB: "_load_cli_sessions_uncached(..., include_claude_code=...)"
DB-->>Models: rows (filtered or not)
Models-->>Builder: cli session list
Builder-->>Route: merged payload
end
Route-->>UI: sessions JSON
Note over UI: loadSettingsPanel() hydrates checkbox
UI->>UI: onChange → _schedulePreferencesAutosave()
UI->>Route: "POST /api/settings {show_claude_code_sessions: bool}"
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant UI as Browser (panels.js)
participant Route as /api/sessions (routes.py)
participant Builder as _build_session_list_cache_payload
participant Models as get_cli_sessions (models.py)
participant DB as state.db
UI->>Route: GET /api/sessions
Route->>Route: load_settings() → show_claude_code_sessions
Route->>Route: _session_list_cache_key(..., show_claude_code_sessions) cache hit?
alt cache miss
Route->>Builder: _build_session_list_cache_payload(..., show_claude_code_sessions)
Builder->>Models: "get_cli_sessions(include_claude_code=show_claude_code_sessions)"
Models->>DB: "_load_cli_sessions_uncached(..., include_claude_code=...)"
DB-->>Models: rows (filtered or not)
Models-->>Builder: cli session list
Builder-->>Route: merged payload
end
Route-->>UI: sessions JSON
Note over UI: loadSettingsPanel() hydrates checkbox
UI->>UI: onChange → _schedulePreferencesAutosave()
UI->>Route: "POST /api/settings {show_claude_code_sessions: bool}"
Reviews (4): Last reviewed commit: "fix(sidebar): preserve Claude Code opt-o..." | Re-trigger Greptile
🎬 Cutter preview — PR #5213
|
|
Autonomous triage: Visible sidebar feature (Claude Code session visibility toggle), 691L, CONFLICTING. Concept + screenshot call — parked for Nathan. Rebase deferred until concept greenlit (no point resolving conflicts on an unsanctioned visible feature). |
0cd5436 to
3c8d1d9
Compare
🔬 Gate certification — RED ⛔ (well-built; one real preference-stomp — a 2-line fix)Certified head: What I ran (rebased worktree
|
| Gate | Result |
|---|---|
| Rebase onto current master | ✅ merge-base == master tip (05aa4731); git apply clean |
| Codex (reproduce) | SHIP-WITH-FIXES — 2 related SILENT (preference stomp); confirmed by me |
| Full pytest suite | 11423 passed, 0 failed |
| Filter + cache-key + read-path (mine) | ✅ all correct (below) |
Findings
✅ Well-built:
- Filter is precisely scoped:
if source_filter in (None, CLAUDE_CODE_SOURCE) and include_claude_code: extend(get_claude_code_sessions())(models.py:5410) —include_claude_code=Falseskips ONLY claude_code rows; cron/webhook/other CLI sources still flow through. Subordinate toshow_cli_sessions. - Cache-key correct (the Add Webhooks sidebar filter parity #4957 lesson applied):
_session_list_cache_keytakesshow_claude_code_sessionsand appends(bool(...),)to the tuple (routes.py:1812/1828), threaded through all call sites — no stale list on toggle. - Read-path subordination correct: routes.py:2411
show_claude_code_sessions if show_cli_sessions else False. - Follows the established show_cron/show_webhook toggle pattern; i18n present; suite green.
⛔ SILENT (I REPRODUCED) — preference stomp on an opt-out default (static/panels.js:7693 autosave + :11031 explicit Save): both save paths persist show_claude_code_sessions = (showCli.checked && showClaudeCode.checked). This copies the cron/webhook && showCli idiom (#3514) — which is safe for those because they default to False (opt-in). But Claude Code defaults to True (opt-out), so for a user with show_cli_sessions=false, changing ANY autosaved preference writes show_claude_code_sessions=false even though they never opted out of Claude Code. Then re-enabling CLI sessions leaves Claude Code rows hidden — the default-visible state is silently, permanently lost. The runtime subordination already lives in the read path (routes.py:2411), so gating it again at save time is redundant for enforcement and harmful for an opt-out default.
- Fix (2 lines, Codex-exact): persist the child independently — autosave
payload.show_claude_code_sessions = showClaudeCodeCb.checked(dropshowCliCb.checked &&); explicit savebody.show_claude_code_sessions = showClaudeCodeSessions(dropshowCliSessions &&). Keepshow_cli_sessionsas the parent gate ONLY in the read/filter path (already there). Add a regression: parent-off + toggle-unrelated-pref must not persist the child false.
Recommendation to the next agent
RED — gate-fail/changes-requested, 2-line fix. Everything else is solid (filter scoping, cache-key, read-path subordination, suite green). The fix is to stop baking subordination into the persisted value for this opt-out child (both save paths), keeping it only in the read path. Converges fast. concept 4/5 (Claude Code visibility toggle is a reasonable sidebar-curation feature; maintainer-review label). Author @rodboev (T1). crit=2. Visible sidebar surface — once the fix lands, a quick screenshot of the toggle hiding/showing Claude Code rows would be the last step before merge.
Gate-certifier layer (warm-up → gate → release). I do not merge/tag/deploy. Rebased onto current master; the stomp reproduced by reading both save paths + the opt-out default asymmetry. Cert valid for sha:3c8d1d9c.
|
Fixed on
|
|
Checked that note against the live settings path. |
🔬 Gate certification — GREEN ✅ · CONVERGED (bounce closed)Certified head: What I ran (rebased worktree
|
| Gate | Result |
|---|---|
| Rebase onto current master | ✅ merge-base == master tip (05aa4731); git apply clean |
| Codex (reproduce) | SAFE TO SHIP — gated the rebased worktree, 0 findings |
| Full pytest suite | 11423 passed, 0 failed |
| Toggle + preferences tests | ✅ 22/22 (test_issue4714 + test_1003_preferences_autosave) |
| Live server (8783) | ✅ boots on the rebased head; toggle settingsShowClaudeCodeSessions + "Claude Code" label present in served DOM |
Findings
✅ CONVERGED — the preference-stomp is fixed (verified byte-exact):
My prior RED: both save paths persisted show_claude_code_sessions = (showCli && showClaudeCode), which stomped the opt-out default to false when the parent CLI toggle was off. The re-push (commit "fix(sidebar): preserve Claude Code opt-out state") applies my exact fix:
- autosave (
panels.js:7693):payload.show_claude_code_sessions = showClaudeCodeCb.checked(theshowCli &&gate removed) - explicit save (
panels.js:11031):body.show_claude_code_sessions = showClaudeCodeSessions(theshowCli &&gate removed) - read-path subordination STILL intact (
routes.py:2411show_claude_code_sessions if show_cli_sessions else False) — enforcement preserved, only the persisted value corrected. So the parent gate still hides Claude Code at runtime when CLI is off, but the child's default-visible state is no longer silently lost.
✅ The rest remains solid (re-confirmed on the new head):
- Filter scoping precise:
models.py:5410include_claude_code=Falseskips onlyclaude_code-source rows; other CLI/cron/webhook sources flow through. Subordinate toshow_cli_sessions. - Cache-key includes
show_claude_code_sessions(routes.py:1812/1828) — no stale list on toggle (the Add Webhooks sidebar filter parity #4957 lesson). - No new regression from the rework commits ("honest wrappers under legacy hooks", "cache keys aligned with webhook-aware filter") — Codex SAFE, suite green.
- i18n keys present; follows the established show_cron/show_webhook toggle pattern.
Recommendation to the next agent
Ready to merge — use branch gate-rebase/5213-claude-code-toggle (sha:c5430897), NOT the PR's stale head 2f839397. Bounce closed cleanly; Codex SAFE + suite 11423/0 + the opt-out fix verified byte-exact with the read-path gate preserved. concept 4/5 (Claude Code sidebar-curation toggle is a reasonable fit; maintainer-review label). Visible surface — a quick sidebar screenshot (with a seeded Claude Code row, toggle off→on) is a nice pre-merge glance for Nathan, but the toggle is DOM-present and behavior is test-verified. Credit @rodboev (T1, co-authored on the rebase). crit=2.
Gate-certifier layer (warm-up → gate → release). I do not merge/tag/deploy. Rebased onto current master; the opt-out fix verified byte-exact + read-path gate preserved. Cert valid for sha:c5430897.
|
Shipped in v0.51.816 — thanks @rodboev! 🎉 You can now hide Claude Code sessions from the sidebar independently of other external sessions, via a "Show Claude Code sessions" preference (Settings → Preferences, under "Show non-WebUI sessions", default on). Toggling it persists independently and doesn't disturb the other source-visibility preferences (closes #4714). Gate: Codex SAFE-TO-SHIP (verified the prior preference-stomp bounce is fixed — the save path persists the child visibility without clobbering siblings, and the read-path gate preserves prior behavior for users who never set it) + full suite 11727 passed + 22/22 targeted + screenshot of the Settings toggle. Rebased onto current master, credit preserved. |
…uena#5420) (#4) * test(#5231): harden JS source extraction coverage * fix(#5079): block private/link-local/reserved IP targets in OpenAI TTS base_url (SSRF hardening) The base_url validator accepted any https host; an https URL pointing at an internal/link-local/loopback/reserved IP (e.g. https://169.254.169.254 cloud metadata, https://10.x internal) passed the scheme-only check. Now resolves the host and rejects blocked-target addresses (private/loopback/link-local/reserved/ multicast/unspecified), while still allowing public OpenAI-compatible hosts and the explicit localhost-over-http dev case. DNS-resolution failure is allowed (unreachable host can't be an SSRF vector + avoids false-rejecting public hosts that don't resolve in sandboxed envs). +5 regression vectors. * fix(#5079): no-redirect opener for OpenAI TTS (block redirect-to-private SSRF + bearer leak) A public TTS host could 301/302/303-redirect POST /audio/speech to an internal target (e.g. http://169.254.169.254), and urllib's default redirect handler would follow it carrying the Authorization bearer — both an SSRF bounce past the base-url guard and a credential leak. Now uses a no-redirect opener (_NoRedirectTtsHandler raises on any redirect) via the _tts_open seam. +redirect rejection regression test. Residual DNS-rebinding TOCTOU (re-resolve at connect) is a narrower low-severity window noted for follow-up. * docs(changelog): OpenAI-compatible TTS backend, SSRF-hardened (#5079) * docs(changelog): opt-in per-project new-conversation shortcuts (#5002) * fix(#5002): hydrate _projectQuickCreate at boot + rebuild sidebar on toggle change Codex gate findings: (1) the opt-in flag was only set when Settings opened, so an enabled setting didn't take effect on a fresh load — now hydrated from /api/settings at boot (mirrors _largeTextPasteAsAttachment, default-false); (2) toggling the checkbox now rebuilds the sidebar so the + buttons appear/disappear immediately. * fix(#5002): repaint sidebar after quick-create newSession (Codex: newSession doesn't render; callers must) * docs(changelog): configurable provider budget + %-used (#5120) * docs(changelog): opt-in Shift+Enter send-key mode (#5005) * fix(#4738): register neon-soft/neon-paint in _SETTINGS_SKIN_VALUES (server-side skin persistence) * docs(changelog): two opt-in neon skins (#4738) * docs(changelog): default-Kanban dispatch fix (#5289) + PWA new-chat hydration defer (#5287) * docs(changelog): deep-link ?q= composer prefill, converged (#4969) * test(#5217): scope follow-intent ordering assertion to _handleStreamError + strip EOF blank line (Codex gate nits) * docs(changelog): SSE-recovery follow-intent sticky guard (#5217) * [locale]Add zhCN unlocalized text * Update i18n.js * fix the wrong word * Restore sort * Unified translation vocabulary * Fix translation for goal paused message * Update curator description and transcript settings text * Update notification permission status message * fix(i18n): restore large_text_paste keys dropped in zh during rebase resolution * docs(changelog): expand zhCN localization (#5279) * docs(changelog): extension skin base scheme (#5271) * docs(changelog): prune orphan zero-message sidebar sessions (#4988) * fix(security): gate embedded-terminal endpoints to local origins when auth disabled The embedded workspace terminal spawns a PTY shell that runs arbitrary commands as the server-process user. check_auth() returns True unconditionally when no password/passkey is configured (the default out-of-the-box state), so without a network-scope gate the terminal endpoints were reachable by any unauthenticated caller able to hit the port — which on a passwordless public bind is remote code execution. Apply the same local-origin gate the onboarding/bootstrap endpoints use (_onboarding_gate_allows) to /api/terminal/{start,input,resize,close} and /api/terminal/output: with auth disabled, accept only loopback/private origins, ignore spoofable X-Forwarded-For/X-Real-IP unless HERMES_WEBUI_TRUST_FORWARDED_FOR=1, and honor HERMES_WEBUI_ONBOARDING_OPEN=1 as the explicit opt-out for a deliberately-exposed server. Auth-enabled servers (cookie already verified upstream) and genuine same-host clients are unaffected. Also fixes a latent test-isolation leak in test_extension_route_remains_behind_webui_auth: it set HERMES_WEBUI_PASSWORD but never invalidated the process-wide password-hash cache, so its result depended on suite execution order (exposed when the new test file shifted ordering). Invalidate before+after so it reads the env var deterministically. 12 new gate tests in tests/test_cvd3_terminal_local_origin_gate.py. * fix(#3825): harden oidc endpoint and claim validation * rebase #5170 onto current master (union-resolved busy-mode boot conflicts: keep persisted pref on settings-load-fail + preserve placeholder-hint/showBusyPlaceholderHint) * fix(#5170): persist busy-input-mode mirror on Settings autosave + panel-load (Codex: mirror only written on boot-apply, so a Settings change didn't survive the boot race) * Fix composer control reorder rebase collision Reapply footer control ordering on current upstream/master while preserving the required situational chip renderer. Persist composer_control_order with backend validation, keep the settings descriptions reorder-aware, and make primary/situational chip renderers participate in same-group drag ordering. Verified with: node --check static/boot.js; node --check static/panels.js; git diff --check; ./scripts/test.sh tests/test_issue4598_composer_control_visibility.py * feat: support per-provider reasoning_efforts in config.yaml Add a config-driven path to resolve_model_reasoning_efforts() that reads providers.<name>.reasoning_efforts from config.yaml. This lets users explicitly list valid reasoning effort levels per provider, so the WebUI dropdown only shows options the model actually supports. Handles both custom:<name> and bare registered provider names. Falls through to existing heuristics (Copilot per-model lists, LM Studio live API, models.dev) when no config entry is present. * Update api/config.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * fix: guard cursor-acp/copilot-acp before config lookup, fall through on all-invalid list Addresses Greptile review feedback on PR #5313: 1. Move cursor-acp/copilot-acp guard before step 0 so a stray config entry can't surface unsupported effort options for those providers. 2. Only short-circuit when the filtered list is non-empty; an all-invalid list (e.g. typos) falls through to heuristics instead of hiding reasoning support from the UI. * fix(model): dynamically repair bare custom-provider models using active custom provider catalog On subsequent turns (such as Turn 2), the client-side select dropdown can automatically normalize and strip the provider namespace prefix from the selection. On the next user input, the client POSTs the bare model ID (e.g. `grok-composer-2.5-fast`), which the server failed to repair back to its full qualified name unless it matched the suffix of the profile's configured default model. This updates both fast-path and slow-path repair checks in `_resolve_compatible_session_model_state()` to dynamically query the active custom provider's configured models list in `config.yaml` (`custom_providers`). This guarantees that any bare model belonging to the configured custom provider is dynamically re-qualified back to its fully-namespaced form, regardless of whether it is the profile's configured default model. Refs #5314 Co-authored-by: b3nw <b3nw@users.noreply.github.com> * Add Agents * fix(model): extract custom bare-model repair helper; fix #1855 fast-path CI - Add _repair_bare_custom_provider_model() shared by fast/slow paths (#5314) - Use ordered model id list (config declaration order) for deterministic repair - Shrinks fast-path block so test_issue1855 fast path stays before catalog call Refs #5314 Co-authored-by: b3nw <b3nw@users.noreply.github.com> * fix(docker): exclude .playwright from rsync staging to avoid error 23 The agent source at /opt/hermes inside the container may contain a .playwright/ directory with browser dependency files that have restricted permissions. rsync fails with exit code 23 when attempting to read them, which kills the container build ("Failed to stage hermes-agent source"). - Add --exclude=.playwright to rsync in docker_init.bash - Add rm -rf .playwright to cp -a fallback path for symmetry - Update test_docker_init_excludes_egg_info_during_staging to assert both the rsync --exclude and a broad .playwright presence check - Add a brief note in AGENTS.md Contribution style about mirroring directory exclusions in both rsync and cp paths Fixes #5315 * docs(changelog): composer footer control reordering (#5075) * docs(changelog): docker rsync .playwright exclude (#5316) * fix(sidebar): keep active-parent delegate children stably visible (flicker) (#5306) #5306 (flicker): while a parent WebUI session is the active/streaming session, a linked delegate subagent child that transiently reports message_count===0 between /api/sessions polls was dropped by _sidebarRowHasVisibleMessages BEFORE _attachChildSessionsToSidebarRows could stack it under its parent. It never entered sessionsRaw, so the row vanished, then reappeared on the next refresh once its list metadata caught up — the flicker. Extend the visibility predicate with an active-parent exception (mirroring the existing active-session exception): a child_session whose parent_session_id is the active sidebar session stays visible even at message_count 0. Scoped to the active parent so truly-empty unrelated sessions are still hidden. #5305 (orphan): a delegated subagent child whose WebUI parent is filtered out of the current render (project/profile/source scope) was promoted to a contextless top-level "Subagent Session" orphan. Suppress cross-surface child_session rows whose parent row is absent from the render instead of orphaning them, mirroring the existing archived-hidden-parent suppression (#4293). The genuinely-external parent case (messaging/CLI) still orphans via the parentIsExternal branch. Tests: tests/test_5306_subagent_sidebar_flicker.py (7 tests) locks both invariants and the no-regression guards, executing the real sessions.js helper regions under node like the existing lineage tests. * docs(changelog): gate embedded-terminal endpoints to local origins (#5268) * docs(changelog): native OIDC login for WebUI (#5012) * docs(changelog): gate reasoning_content replay for provider-facing history (#5024) * docs(changelog): honor busy input mode on first send (#5170) * docs(changelog): keep active-parent delegate children stably visible (#5306/#5305) * docs(extensions): link EXTENSIONS.md to the vetted extension library repo EXTENSIONS.md documented the WebUI-side extension infrastructure (loader, manifest, capabilities incl. settings_schema / skin scheme / TTS engine, install client) but never linked to hermes-webui/hermes-webui-extensions — the public repo where the vetted, one-click-installable gallery entries actually live and where new extensions are contributed. Adds two cross-links, no behavior change: - intro callout: points to the library repo + its docs/extension-entry.md, framing this doc as the infrastructure side and the library repo as where entries live. - a 'Contributing to the extension library' pointer at the end of the authoring guidance, describing the entry-PR + CI-safety-gate + registry-publish flow. Docs-only. * fix(sessions): load delegated subagent child transcript from state.db (#5307) A delegated subagent child (source='subagent' in state.db) usually has no WebUI sidecar but is registered in the WebUI index sharing the parent's lineage. That made GET /api/session -> _claim_or_synthesize_cli_session return 'was_webui' -> 404, so the child pane opened empty despite state.db holding messages. - api/routes.py: add _state_db_session_source() + _is_subagent_child_session_id(); exclude subagent children from the was_webui 404 gate so they recover their state.db transcript (the #2782 self-heal 404 for deleted WebUI sessions is kept). - static/sessions.js: add _isSubagentChildSession() + _sessionNeedsServerImportForLoad() (kept separate from _isExternalSession to avoid widening refresh-gating); the main session-tap, lineage-segment, and child-open handlers now trigger the import/merge path for subagent children. - tests: test_5307_subagent_child_transcript.py (7 tests). Fixes #5307 * fix(#5307): recover subagent child transcript view-only (Codex hardening) Reworked per the Codex gate finding: the first approach widened the client import predicate, which (a) conflicted with #3603's intentional _isExternalSession gate and (b) let import_cli persist the subagent child as a WRITABLE, CLI-classified session that then passed the poll-skip/active-refresh gates. Corrected to a server-side, view-only recovery: - api/routes.py: mark source='subagent' as NON-claimable in _is_claimable_cli_source (both cli_meta and state.db source denylists). A subagent child now resolves via the not_claimable branch -> read-only Session with its state.db transcript, and build_session takes is_cli_flag (False for subagent children) so the recovered session is NOT CLI-classified and can't widen the frontend _isExternalSession gates. - static/sessions.js: REVERTED to master (no client change needed; #3603 contract intact). - tests: assert reason=not_claimable, read_only=True, is_cli_session!=True, transcript present; #2782 deleted-webui 404 preserved; #3603 _isExternalSession contract preserved. 58 tests green (5307 + 3603 + claim-cli + core-data-loss). Fixes #5307 * fix(#5307): close 2 more subagent-child writable-session holes (Codex round 2) - api/routes.py GET /api/session synthesized response: stop hardcoding is_cli_session=True; serialize bool(synth.is_cli_session) so a recovered subagent child stays not-CLI-classified (was overriding the helper's False). - api/routes.py POST /api/session/import_cli: gate source='subagent' into the read-only view payload (is_cli_session=False, imported=False) BEFORE import_cli_session(), so a subagent child can never be materialized as a writable WebUI sidecar via this endpoint. - tests: assert import_cli routes subagent children read-only (no materialize). Both were paths that bypassed the _is_claimable_cli_source denylist. Fixes #5307 * fix(webui): stop live streaming message flicker Opt live assistant streaming nodes out of the global theme color/background transitions so token-by-token markdown updates do not flash or fade on light themes. Adds a targeted regression test in test_smooth_text_fade.py while keeping the opt-in smooth text fade feature intact. * fix(#5307): gate subagent children in the shared materialize chokepoint (Codex round 3) Codex found a 3rd writable path: POST /api/chat/start -> _get_or_materialize_session() materialized source='subagent' as a writable sidecar before the not_claimable guard. Fix: refuse subagent children (PermissionError) at that shared chokepoint, checked via _is_subagent_child_session_id(sid) (state.db source, independent of cli_meta) BEFORE the materialize path — so all three entry points (GET synth, import_cli, chat-start) now consistently keep a delegated child view-only. CLI/TUI/Desktop materialization preserved. Tests: materialize helper refuses subagent child + still allows tui. Fixes #5307 * fix(#5307): gate the 3rd/final import_cli_session write path (archive fallback) Codex round 4 found POST /api/session/archive's missing-sidecar fallback also calls import_cli_session(). grep confirms exactly 3 import_cli_session() call sites in routes.py; all 3 now refuse source='subagent' children: - 4792 _get_or_materialize_session (chat-start) -> PermissionError - 22382 import_cli endpoint -> read-only view payload - 13885 archive fallback -> 400 'Subagent sessions cannot be archived' So no path can materialize a delegated child as a writable WebUI sidecar. Fixes #5307 * fix(#5307): close cross-profile + existing-session subagent edges (Codex round 5) - import_cli _read_only_view now also treats resolved cli_meta source_tag/raw_source =='subagent' as view-only (all_profiles=true resolves cli_meta from a non-active profile, which the active-profile state.db _sa_child check could miss). - import_cli existing-session refresh branch no longer hardcodes is_cli_session=True for a subagent child (both the persisted update and the response payload). Fixes #5307 * docs(changelog): redistribute [Unreleased] backlog into per-version blocks (v0.51.693–792) (#5329) The [Unreleased] section had accumulated 116 shipped feature/fix bullets spanning 100 releases (v0.51.693 → v0.51.792) — the release process bumped the version + tag but never MOVED each PR's bullet out of [Unreleased] into a dated version block (it stopped creating per-version blocks after v0.51.692). Marquee features (/moa, the appearance skins, custom TTS engine, theme/skin registration, OIDC login, …) all sat orphaned in [Unreleased], which is why the Discord announcement cron kept re-listing the same items every run. This moves every bullet into a dated version block reconstructed from its shipping git tag (PR-number → first-containing-tag → tag commit date), grouped under the original Added/Changed/Fixed subsections, and empties [Unreleased] to a self-documenting placeholder. No bullet lost (116/116 redistributed, verified), no new duplicate headers introduced. Docs-only, no code change. Pairs with a release-process fix so this can't recur. Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com> * fix(#5307): guard persisted subagent sidecars against writable use (Codex round 6) - _get_or_materialize_session happy path: reject an already-persisted subagent sidecar (source_tag/raw_source=='subagent' or state.db subagent) even when it was stored read_only=False (pre-fix materialization), so chat-start can't write it. - import_cli existing-session refresh: coerce read_only=True on the persisted sidecar (and response) when it's a subagent child. - test: persisted read_only=False subagent sidecar is still refused by the helper. Fixes #5307 * fix(#5307): root-fix subagent classification + GET serialization (Codex round 7) - api/agent_sessions.py is_cli_session_row(): add 'subagent' to non_cli_sources so EVERY consumer (sidebar rows, /api/sessions, etc.) classifies a delegated child as non-CLI (the root the per-site fixes were compensating for). - api/routes.py GET /api/session happy path: coerce is_cli_session=False + read_only=True in the serialized payload for a subagent child before redaction, so a stale writable sidecar can't be exposed as writable to the browser. Fixes #5307 * fix(#5307): guard direct mutation routes + coerce list rows (Codex round 8) The root non-CLI classification surfaced subagent rows in the sidebar without read_only, exposing delete/truncate/pin — and /api/session/delete calls delete_cli_session() which erases the child's state.db transcript (data loss). - /api/sessions list: coerce subagent rows to read_only=True + is_cli_session=False so the UI offers no mutation affordances. - New _session_is_subagent_view_only() shared guard; applied to the direct mutation routes that bypass _get_or_materialize_session(): delete / clear / truncate / pin now 400 for subagent children. (rename/move already route through the gated _get_or_materialize_session and 403.) - tests: guard helper + static contract that all 4 routes + list coercion present. Fixes #5307 * fix(#5307): gate ALL remaining session-mutation routes + stale-webui-row coercion (Codex round 9) Enumerated every /api/session/<mutation> route and applied _session_is_subagent_view_only: duplicate, branch, retry, undo, toolsets, compress, archive (existing-sidecar path) now 400 for subagent children; delete/clear/truncate/pin already gated; rename/move/update route through the gated _get_or_materialize_session (403). This closes the data-loss paths (delete_cli_session, retry/undo truncate, branch/duplicate fork). Also: /api/sessions list coercion now also honors state.db source='subagent' for rows whose stale index says webui/fork, so they can't surface as writable/CLI sidebar rows. Fixes #5307 * fix(#5307): gate final 4 subagent write paths (Codex round 10) _session_is_subagent_view_only guard added to the remaining paths that could mutate a stale persisted subagent sidecar: - _handle_handoff_summary (appends tool msg to state.db) - _handle_chat_sync (fallback POST /api/chat) - /api/session/draft POST (composer_draft save) - /api/personality/set (per-session personality save) Fixes #5307 * test: widen brittle static-grep windows for delete/duplicate route assertions (#5307 guard lines shifted markers past the fixed windows; functionality unchanged) * fix(#5307): gate /api/goal + /api/btw subagent write paths (Codex round 11) * docs(changelog): view-only delegated subagent session transcript recovery (#5307) * test: drop unused api.models import (ruff F401) * fix(sessions): prune index-only ghost sessions (#5331) Phase 2 of cleanup now sweeps _index.json for stale entries with no backing file and no in-memory session, removing them regardless of title (catches multi-language 'Untitled' variants). Phase 3 only deletes the index when Phase 1 removed files AND Phase 2 couldn't run, fixing the cache-busting on every cleanup call. Fix two phase-interaction bugs flagged by Greptile review: - Track phase1_removed_ids to prevent Phase 2 double-counting sessions already removed from disk by Phase 1. - Track phase2_rewrote_index so Phase 3 skips deletion when Phase 2 already cleaned the index in-place. Adds test_issue5331_index_only_ghost_cleanup.py with 12 test cases. * [locale]Translation of Chinese texts lacking localization * Remove extra spaces after the period. * Update i18n.js * fix(webui): split MoA picker and Copilot catalog fixes - Keep resolve_moa_preset optional so older hermes-agent installs still degrade gracefully. - Surface MoA presets in the WebUI model picker as a virtual provider. - Keep configured Copilot per-model settings from collapsing the built-in catalog. - Refresh the Copilot static fallback used when the live catalog probe misses. Verification: ./scripts/test.sh tests/test_issue5057_moa_webui_route.py tests/test_copilot_provider_model_settings_not_allowlist.py tests/test_moa_model_picker_provider.py -q 11 passed, 1 skipped * fix(webui): harden MoA picker preset fallbacks - Avoid an unguarded resolve_moa_preset fallback call when preset resolution fails. - Populate the MoA picker directly from configured presets so older Hermes Agent installs that lack provider_model_ids('moa') still render the virtual provider. - Add regression coverage for both review findings. Verification: ./scripts/test.sh tests/test_issue5057_moa_webui_route.py tests/test_copilot_provider_model_settings_not_allowlist.py tests/test_moa_model_picker_provider.py -q 12 passed, 1 skipped * fix(#5301): scope models-as-settings-map guard to Copilot only (was breaking providers.<id>.models allowlist for all built-ins, #644 regression) * fix(#5301): admit active models-only custom provider without reopening dedup regression Maintainer review found regression 2 on commit 575996a7: the new _has_provider_route gate (api/config.py) required api/base_url/api_key/key_env before admitting a configured provider into the picker, which dropped a models-only custom provider config (the lmstudio-style shape from #1970, tests/test_pr1970_lmstudio_base_url_fallback.py::test_provider_catalog_preserves_dict_shaped_raw_key_lookup). A naive fix (admit any config with models) would re-break test_unknown_duplicate_copilot_provider_config_is_not_rendered, since a spurious alias like copilot-2: {name: copilot, models: {...}} must stay rejected. Fix: admit a models-only provider config as evidence only when its canonical id matches the active/configured provider (threaded via active_provider, already in scope). Non-active models-only configs (including duplicate aliases of known providers) are still rejected. Added a regression test: tests/test_pr1970_lmstudio_base_url_fallback.py::test_provider_catalog_rejects_non_active_models_only_custom_provider covering both the active-admits and non-active-rejects cases side by side. Verification: ./scripts/test.sh tests/test_issue5057_moa_webui_route.py tests/test_copilot_provider_model_settings_not_allowlist.py tests/test_moa_model_picker_provider.py tests/test_pr1970_lmstudio_base_url_fallback.py -q 26 passed, 1 skipped in 5.58s Broader sanity pass: ./scripts/test.sh tests/ -k "config or provider or model or copilot or moa or lmstudio" -q 1728 passed, 5 skipped, 9654 deselected (4 pre-existing failures confirmed unrelated: 2 reproduce identically on the pre-fix commit via git stash, 2 are order-dependent and pass in isolation). * fix(webui): keep #1855 fast-path window and Copilot gpt-4o regression tests green - Extract the MoA @moa:/moa/ prefix-stripping branch of _resolve_compatible_session_model_state into a new _moa_fast_path_model_state() helper. Inlining it had pushed `catalog = get_available_models()` just past the 6000-char source window that tests/test_issue1855_resolve_model_provider_fast_path.py:: TestFastPathSourceShape scans to guard the #1855 fast-path/ catalog-call ordering, breaking that regression test after rebasing onto current master. - Re-add gpt-4o to the refreshed Copilot static fallback list. It's a real Copilot-served model and tests/test_issues_373_374_375.py::TestStaleModelListCleanup:: test_copilot_list_unchanged asserts the Copilot list keeps it even as #374 removes it from the generic OpenAI list. The live Copilot catalog probe remains authoritative; this only affects the cold-start/probe-miss fallback. Verification: ./scripts/test.sh tests/test_issue1855_resolve_model_provider_fast_path.py tests/test_issues_373_374_375.py tests/test_issue5057_moa_webui_route.py tests/test_copilot_provider_model_settings_not_allowlist.py tests/test_moa_model_picker_provider.py tests/test_pr1970_lmstudio_base_url_fallback.py -q -> 73 passed in 3.60s ./scripts/test.sh tests/ -k "config or provider or model or copilot or moa or lmstudio" -q -> 1745 passed, 2 skipped, 9775 deselected, 1 xpassed in 68.57s * fix: respect custom provider config and nested route denies Resolve review feedback on PR #5313: - Read reasoning_efforts from named custom_providers entries for custom:<name> providers instead of looking only in providers:<name>. - Preserve the nested-route deny ordering before the provider-config shortcut so Gemini image/embedding routes cannot be re-enabled by config. - Deduplicate configured effort values before returning them. Add focused regression tests for bare provider config, named custom provider config, all-invalid fallthrough, ACP hard guards, and nested route denies. * fix(webui): guard non-dict resolve_moa_preset result in resolve_moa_config A hermes-agent build that returns a non-dict (e.g. None) for an unknown preset without raising would make resolved.update(selected) raise TypeError, which bypasses the routes.py except RuntimeError guard and surfaces as an unhandled 500 on /api/chat/start. Coerce a non-dict result to {} so preset resolution degrades cleanly. Adds a regression test. * fix: strip named-provider slug before nested-route reasoning deny Resolve gate certifier feedback on PR #5313 (deeper bypass, round 3): A provider-qualified hint like `@custom:<slug>:vertex/gemini-image-1.0` still bypassed `_nested_route_reasoning_denied()`. The old `_strip_provider_hint_for_reasoning()` did a naive first-colon split, which only removed the leading `@custom:` wrapper and left the named provider's slug (e.g. `agg:vertex/gemini-image-1.0`) attached to the model id. That leftover slug fragment no longer starts with `vertex/gemini-`, so the nested-route deny missed it and a configured `providers.<name>.reasoning_efforts` / `custom_providers[].reasoning_efforts` allowlist re-enabled reasoning controls on Gemini image/embedding routes that must never expose them. Fix: `_strip_provider_hint_for_reasoning()` now accepts the resolved provider id and strips the exact `@{provider}:` prefix first (e.g. `@custom:agg:`), so both the wrapper and the slug are removed in one pass before the nested-route deny check runs. Falls back to the original first-colon split when no provider is supplied, preserving existing behavior for plain `@provider:model` hints. Verified in-process: both `@custom:agg:vertex/gemini-image-1.0` and `@custom:agg:vertex/gemini-embedding-001` now correctly resolve to [] instead of leaking the configured ["low", "high"]. Added regression test extending the existing nested-route-deny test to cover provider-qualified hinted models. Verification: `./scripts/test.sh tests/test_reasoning_effort_model_capabilities.py tests/test_custom_provider_bare_model_reasoning.py` -> 53 passed in 1.23s Full suite: 11258 passed (63 pre-existing failures unrelated to this change — network/credential/profile-isolation/fd-leak tests; confirmed identical failure set on unmodified branch). * fix(model): gate feedback — profile-scoped repair, malformed config (#5317) Address nesquena-hermes gate certification on PR #5317: - _repair_bare_custom_provider_model: coerce config values via str(); use api.config._custom_provider_entries; optional config_obj for profile config.yaml (not process-global cfg). - Thread profile_config from _load_profile_config_dict through session display, chat/start, wakeup, goal, and sync resolvers. - Tests: malformed name=None entry; profile vs global collision. Co-authored-by: b3nw <b3nw@users.noreply.github.com> * test: fix CI stubs for profile_config; widen #1855 source window CI Tests job (run 28525484615) failed one test per shard: - Stubs for _resolve_compatible_session_model_state lacked profile_config (wakeup wiring spy, start_session_turn runtime adapter fixture). - #1855 structural test used 6k char slice; resolver helper outgrew window. Co-authored-by: b3nw <b3nw@users.noreply.github.com> * refactor: make nested-route reasoning deny boundary-based, not prefix-based Structural hardening per user request, following the 3rd round of the same bypass class on PR #5313's reasoning_efforts feature: Round 1: plain-ordering regression (provider-config short-circuit ran before the nested-route deny). Round 2: a provider-qualified hint (@custom:<slug>:vertex/gemini-...) left a slug fragment that the deny's prefix-match missed. Both were legitimate, narrowly-targeted fixes, but the pattern — _nested_route_reasoning_denied() requiring the model string to START WITH the route prefix — meant every future wrapper/nesting scheme would need the strip logic to be updated in lockstep, and a missed case fails OPEN (reasoning re-enabled on a route that must never show it), which is the wrong failure mode for a security-adjacent guard. This commit removes that class of bug instead of patching its latest instance: _nested_route_reasoning_denied() now searches for the vertex/gemini- or gemini_cli/gemini- pattern ANYWHERE in the string at a non-alphanumeric boundary, rather than requiring it at position 0. Correctness no longer depends on _strip_provider_hint_for_reasoning() having stripped exactly the right prefix first — any number of opaque wrapper layers (@provider:, a named custom-provider slug, or any nesting scheme not yet invented) can precede the route and the deny still fires. Verified: all historical bypass strings (round 1 and round 2, plus a hypothetical deeper double-wrapped case) now correctly deny; embedded substrings that must NOT match (e.g. 'notvertex/gemini-x') correctly don't, thanks to the boundary lookbehind. Added test_nested_route_deny_is_boundary_based_not_prefix_based locking in the structural invariant directly, independent of any particular wrapper scheme. Verification: ./scripts/test.sh tests/test_reasoning_effort_model_capabilities.py tests/test_custom_provider_bare_model_reasoning.py # 54 passed in 1.31s Full suite: 11257 passed, 64 pre-existing failures (identical file/test set as the prior commit's baseline — network/credential/profile-isolation/fd-leak tests, unrelated to reasoning_efforts). * test(webui): make live-transition guard anchors explicit * docs(changelog): prune index-only ghost sessions (#5331) * fix(webui): stop false clarify-unavailable toast; add interrupt provenance (#5345) /api/clarify/pending always returns HTTP 200 when present (returns {"pending": null} for an unknown session — it never 404s). The front-end clarify poller warned "Clarify endpoint unavailable. Please restart server." on ANY caught error whose message merely contained "404" or "not found", so an unrelated stale-session 404 ("Session not found", e.g. an old-profile session polling briefly after a profile switch) or a transient error produced a misleading missing-endpoint toast that pointed operators at the wrong layer. Clarify polling now branches on the structured HTTP status that api() attaches to the thrown Error (err.status): - 404 "Session not found" -> handled as a stale-session poll (stop + hide card silently), no toast; - restart-server warning fires only on a genuine route-not-found 404 whose body is NOT session-scoped; - poll failures are logged with path, status, polling session id, and current session id for diagnosis. Interrupt provenance: cancelStream()/cancelSessionStream() now log a '[stream] cancel requested' line with the trigger reason (composer-stop / slash-stop / slash-interrupt / busy-interrupt / sidebar-stop). Passive UI lifecycle events (session switch, tab hide, page unload) already tear down only the local SSE transport via closeLiveStream() and never call /api/chat/cancel — only explicit Stop/interrupt paths interrupt the backend agent/tool run. This is confirmed by test_clarify_pending_never_404s locking the handler shape. Supersedes #5343 (which handled only the profile-switch sub-case and kept the broad message-scrape). Adds tests/test_issue5345_*.py (9 tests). Co-authored-by: claw-io <claw-io@users.noreply.github.com> Co-authored-by: ruizanthony <ruizanthony@users.noreply.github.com> * test: make cancelStream harnesses tolerate the new reason param + stdout provenance log Codex gate on #5346 flagged two brittle static extractors that broke on the cancelStream(reason) signature change + the new '[stream] cancel requested' stdout log: - test_cancel_stream_owner_guard.py: the Node harness parsed ALL of stdout as JSON; the provenance console.info line (fires during runAll) polluted it. Parse the LAST non-empty stdout line (the result JSON is always emitted last, after runAll resolves). - test_sprint36.py: two extractors did src.find('async function cancelStream()') (exact, no params). Switched to a signature-tolerant regex and widened the catch-block window (the provenance log/comments now precede the try/catch). Both pre-existing tests, updated to match the intentional #5345 change (not the code bent to fit the test). * fix(webui): keep handled clarify 404s out of warn logs * test: drop unused pytest import (ruff F401) — from #5346 be894353 * docs(changelog): fix false clarify-unavailable toast + interrupt provenance (#5345) * docs(changelog): stop live-streaming message flicker on light themes (#5328) * fix(chat): suppress browser overflow-anchor during JS scroll-anchor realign (mobile scroll jump-back) Root cause (mobile-only, never reproduces on desktop): .messages CSS resting overflow-anchor is 'auto' on touch devices but 'none' on hover+fine-pointer desktops (style.css media query). When _restoreMessageViewportAnchor writes scrollTop to realign the reader's anchor row AND content height above the viewport changed in the same frame, a mobile browser's native scroll-anchoring ALSO shifts scrollTop -- the two compensations stack and yank the reader to an unrelated earlier turn. Desktop never has the browser layer, which is why this reproduced only on phones. Fix: _suppressBrowserOverflowAnchor() sets overflow-anchor:none for the JS scrollTop write, releases (restores prior value) next frame. Engages ONLY when computed value is 'auto' (mobile) -- pure no-op on desktop (already none). Verified on isolated debug instance (mobile-viewport Playwright): - mobile auto: 800px above-viewport growth compensation 800px -> 0 (browser layer suppressed) - desktop none: helper returns null, inline value untouched (byte-identical behavior) - streaming: real turn, mid-read follow, 0 jumps, content held - scroll-regression suite green * fix(chat): keep overflow-anchor suppressed across the async post-render settle window (mobile jump-back) The sync-frame guards (_fixMobileScrollJank / _suppressBrowserOverflowAnchor) only cover the render frame itself. postProcessRenderedMessages() — syntax highlight, inline diff/csv/pdf/html/excalidraw, katex/mermaid — is scheduled a FRAME LATER via requestAnimationFrame(), after those guards have released. Each of those can change the height of rows ABOVE the viewport; on mobile (overflow-anchor:auto) the browser's native anchor engine then compensates scrollTop a SECOND time in that unguarded frame, yanking an unpinned reader to another turn (the residual mobile 往回大跳). Wrap all three deferred post-process dispatches (fast-path cache branch, main render tail, live-tool remount) in _postProcessWithAnchorSuppression(), which routes through the shared _suppressBrowserOverflowAnchor() and holds suppression one extra frame so late media/layout reflow is covered too. Desktop rests at overflow-anchor:none so the wrapper is a verified no-op there. Reproduced on an isolated debug instance with a cloned 1179-message session: above-viewport +350px during the async settle window jumped scrollTop +350 on mobile (auto) and 0 with the wrapper; desktop (none) 0 both ways. static/ui.js only. * test(chat): update 6 post-render tests orphaned by the _postProcessWithAnchorSuppression refactor (#5338) Commit 7536f6f1 routed the deferred post-render dispatches through _postProcessWithAnchorSuppression() (holds overflow-anchor suppression across the async media/layout settle frame, then calls postProcessRenderedMessages). Six pre-existing tests string-matched the old 'requestAnimationFrame(()=>postProcessRenderedMessages(inner))' literal and failed on the rename — behavior is preserved (the wrapper still invokes postProcessRenderedMessages), so this is a test-fix not a code-fix. Per the gate-cert recommendation, the tests now assert the BEHAVIOR chain (post-render is scheduled via _postProcessWithAnchorSuppression, and that wrapper calls postProcessRenderedMessages) rather than the exact rAF literal, so a future wrapper rename can't re-orphan them. Files: test_csv_table_rendering, test_excalidraw_inline_embed, test_issue483_inline_diff_viewer, test_issue484_json_tree_viewer, test_issue347, test_pdf_html_preview. Verified: the 6 updated assertions pass locally (the only local failures are the pre-existing Windows-only WinError 206 command-line-too-long in Node-harness tests, unrelated, green on Linux CI). * test(chat): stub _postProcessWithAnchorSuppression in the renderMessages node harness (#5338) The Node-executed gate in test_anchor_fallback_ownership.py (test_render_messages_keeps_anchor_owned_turn_out_of_legacy_activity_rebuilds) eval()s the real renderMessages(). Commit 7536f6f1 made renderMessages schedule its post-render pass via _postProcessWithAnchorSuppression(), but the harness only stubbed postProcessRenderedMessages() — so the eval threw 'ReferenceError: _postProcessWithAnchorSuppression is not defined' and the test failed on Linux CI (shard 2). It passed locally only because Windows hit the unrelated WinError 206 command-line-too-long first, masking the real error. Add a no-op stub for _postProcessWithAnchorSuppression alongside the existing postProcessRenderedMessages stub. Verified by dumping the generated node script to a temp .js file and running 'node file.js' (bypassing the Windows -e length limit): the eval no longer throws and the test's assertions pass. * docs(changelog): suppress mobile overflow-anchor double-compensation scroll jump (#5338) * fix(webui): drop verification-stop synthetic nudge from transcript (#5334) * fix(webui): crash visibility — faulthandler + thread excepthook + exit audit (#4633) server.py exited SILENTLY after 9-16h: no traceback, no shutdown-audit line, no core dump — the log just stopped mid-request. It runs a ThreadingHTTPServer with daemon_threads=True, so an unhandled exception in a request/SSE/long-poll handler thread could terminate work with nothing recorded, and faulthandler was not enabled so a native crash left nothing at all. The WebUI also configures no logging handlers, so INFO/ERROR records are dropped by logging's lastResort filter (WARNING+ only) — meaning even the existing shutdown audit never reached the log. Add api/crash_visibility.py (stdlib-only, hooks never raise) and wire install_crash_visibility() into server.main() before any heavy startup: * faulthandler.enable(all_threads=True) — native crash dumps a C-level traceback; SIGUSR1 registered for on-demand hang diagnosis. * threading.excepthook — logs uncaught daemon/handler-thread exceptions (thread name, ident, traceback) instead of losing them silently. * sys.excepthook — logs uncaught main-thread exceptions (KeyboardInterrupt preserved). * atexit exit-audit breadcrumb — a clean/unwound exit is recorded; its absence narrows a silent death to an un-unwound kill (OOM/SIGKILL/abort). All diagnostics are written directly to the fault stream (stderr, which the bootstrap redirects into the WebUI log) AND mirrored through logging, so the line is guaranteed to land regardless of logging config. No request-handling behavior change, no new deps. Paired memory root-cause: #4765. Fixes #4633. * fix(webui): overlay real state.db message count for subagent children so they don't vanish from the sidebar (#5308) Regression seam behind #5308: a delegated subagent child's sidebar row is built from a stale sidecar that reports message_count==0, and the state.db count overlay in _apply_sidebar_state_db_override_metadata was gated on `state_db_source == 'webui'`. A subagent child (state_db_source=='subagent') therefore never received its true message count, so the front-end visibility predicate (_sidebarRowHasVisibleMessages) dropped the row and the subagent session disappeared entirely (not nested, not orphaned) after #5244+#5306. Fix: widen the count/last-message overlay to `state_db_source in ('webui','subagent')`, keeping the same conservative anti-resurrection guard. The source-tag/title reassignment stays WebUI-only so a subagent child keeps its subagent classification. Same state.db-blind-metadata root as the #5307 transcript recovery, fixed server-side rather than by loosening the front-end predicate (which would fight the #5306 active-parent scoping). Tests: subagent child gets its count overlaid + classification preserved; a non-webui/non-subagent foreign source (cron) still gets NO overlay. Fixes #5308. * docs(changelog): subagent sessions no longer vanish from sidebar (#5308) * fix(webui): bound in-memory SESSIONS cache with lazy reload (#4765) Root cause of the silent-crash-after-hours cluster (#4765/#2233/#4633): the global in-memory SESSIONS LRU evicted with a blind popitem(last=False), which could drop an actively streaming or not-yet-persisted session (data loss) and was capped only via an env var. On long-running installs the effective result was unbounded RAM growth until segfault. - Add _session_is_evictable(): a session is evictable ONLY when it is not streaming (no active_stream_id), has no in-flight turn (no pending_user_message / pending_started_at), and its full state is proven on disk (sidecar message_count >= in-memory count; metadata-only stubs and zero-message shells are trivially safe). - Add _evict_sessions_over_cap(): replaces all 8 blind popitem loops across models.py, routes.py, streaming.py. Walks the LRU oldest-first and removes only provably-safe entries; never acquires LOCK/stream locks itself (caller holds LOCK) so no lock-ordering deadlock. May briefly exceed the cap rather than ever evict an active/unsaved session. - Make the cap configurable via config.yaml webui.sessions_cache_max (get_sessions_cache_max()); precedence config.yaml -> HERMES_WEBUI_SESSIONS_MAX (legacy) -> DEFAULT_SESSIONS_CACHE_MAX=300. No new HERMES_* env var. Invalid or <1 values fall back so a typo can never disable the bound. - Evicted sessions lazily reload from their JSON sidecar via the existing get_session() accessor; no call sites changed. _index.json sidebar behavior unchanged (the sidebar reads the index, not SESSIONS). - Add tests/test_issue4765_sessions_lru_eviction.py (8 tests): eviction past cap, active/streaming never evicted, unsaved/stale-tail never evicted, lazy reload with identical content, and no-data-loss under heavy churn. - README: document the config.yaml key + safety semantics. Fixes #4765. * docs(changelog): drop verification-stop synthetic nudge from transcript (#5334) * docs(changelog): crash visibility hardening (#4633) * fix(webui): align reconciliation dedup key with workspace-prefix stripping (#5339) _session_message_content_key (the state.db reconciliation key in api/models.py) normalized whitespace only, while the streaming-side identity _message_identity strips the workspace prefix for user turns. WebUI sends the model a workspace-prefixed user_message ([Workspace::v1: /path]\n<text>) while the visible/optimistic bubble and sidecar row carry the bare <text>. The mismatch made a prefixed state.db row and a bare sidecar row key differently, so state_db_delta_after_context failed to align them, treated the state.db copy as new, and appended a duplicate user turn. The agent-side merge then concatenated the two adjacent user rows into a permanent composite -- the post-restart stale-user-prepend bug. Fix: strip the workspace prefix for role=='user' in the reconciliation key, reusing the same _strip_workspace_prefix helper the streaming side uses (lazy import to avoid the api.streaming -> api.models cycle) so the two dedup layers can't drift again. Assistant/tool keys are unchanged and prefix-free user messages key identically (idempotent). Fixes #5339. * docs(changelog): align reconciliation dedup key with workspace-prefix stripping (#5339) * fix(#5340): use local time for pasted-text filenames * fix(#4251): stop in-flight turns from reverting picker model choice * Fix profile skills-stats thundering herd at cold startup (#5364) The two-tier mtime cache from #4783 fixed the per-request SKILL.md rescan but left two concurrency holes that only bite at container cold start, when the frontend fires several profile-data requests at once and the caches are empty: 1. `_get_profile_skills_stats()` had no lock, so concurrent misses on the same profile each ran `os.walk(followlinks=True)` + parsed every SKILL.md simultaneously. 2. `_build_profile_rows_fast()` ran outside `_LIST_PROFILES_CACHE_LOCK` in `list_profiles_api()`, so every concurrent request rebuilt all rows (each walking every profile's skill tree) at once. With ThreadingHTTPServer (one OS thread per request) and Docker overlay2, this stacked thousands of concurrent stat() calls and stalled workers 57-70s (per the report's thread dumps). Fix: - Add a per-profile compute lock (registry guarded by a meta-lock) and use double-checked locking in `_get_profile_skills_stats()`: concurrent misses on one profile collapse to a single compute, while independent profiles still compute in parallel. - Single-flight the row build in `list_profiles_api()` by holding `_LIST_PROFILES_CACHE_LOCK` across the build + cache write. Lock order is strictly list-lock -> per-profile skills-lock, so no deadlock. The report's third suggestion (debounce the mtime probe) is deliberately NOT taken: the every-call cheap probe is the #4783 out-of-band change-detection contract (test_issue4783 asserts it MUST run on every call). Serializing the misses removes the herd without weakening that contract, since only the expensive compute is guarded, not the probe. Adds tests/test_issue5364_skills_stats_thundering_herd.py proving the herd collapses (single compute / single build under a concurrent burst), independent profiles still parallelize, and the every-call probe contract is preserved. All existing #4783 contract tests still pass. Co-authored-by: claw-io <claw-io@users.noreply.github.com> * fix(#4251): preserve raced picker ownership through profile repair * docs(changelog): restore #4765/#5313/#5335 entries dropped in merge conflicts (shipped v0.51.801/803/804) * fix(#4737): retry model catalog fetch once after cold-cache fallback * fix(#4737): preserve boot redirect handling on catalog retry * fix(model): address gate feedback #4857080754 — slug names, list models, get_config, single YAML parse - _repair_bare_custom_provider_model matches display-named providers via _custom_provider_slug_from_name (custom:my-proxy matches 'My Proxy'). - _ordered_custom_provider_model_ids now handles dict keys, list strings, and list dicts with id/model/name, aligned with api/config.py catalog. - config_obj=None fallback uses get_config() instead of raw cfg. - _read_profile_model_config returns profile config dict too, avoiding a second YAML parse on hot display paths. - Tests added for slug matching, list-form models, and get_config fallback. Co-authored-by: b3nw <b3nw@users.noreply.github.com> * fix(#4251): guard provider ownership under the session lock * fix(#4737): restore source-shape contracts for model refresh retry * fix(#4251): drop dead post-repair ownership writes * fix(#4737): skip stale live-model fetch before catalog retry * fix(tests): update _read_profile_model_config callers/assertions for 3-tuple return * fix(#4737): retry even when the synth fallback is empty * fix(tests): more _read_profile_model_config stubs need 3-tuple * test(#5364): fully restore sys.modules in the profiles import harness The regression test re-imports api.profiles in isolation by stubbing flask/yaml/agent in sys.modules and deleting+reloading the real api / api.profiles modules. Teardown only popped api/api.* (never restoring the real modules) and left the flask/yaml stubs behind, so the manipulation LEAKED: subsequent tests re-imported api.config/api.routes against the stub yaml (safe_load->None) and a half-populated api package, silently breaking ~120 unrelated tests in the full serial suite (e.g. MCP/provider/config tests whose get_config patch no longer saw real config). Snapshot every sys.modules key we touch and restore it exactly (real modules back, injected stubs removed) in a finally block. Full suite now matches master baseline (11537 passed, only the 2 known cron-isolation artifacts). * docs(changelog): MoA picker + Copilot catalog fixes (#5301) * fix: respect auxiliary title timeout for manual regenerate * docs(changelog): manual title regen honors aux timeout (#5374) * #5153: MoA gateway fail-closed routing * #5309: ctl.sh load ~/.hermes/.env * #5310: push-to-talk hold gesture (#3700) * chore(changelog): Phase-1 batch — #5153 MoA fail-closed, #5309 ctl.sh .env, #5310 push-to-talk * #5228: opt-in extension loopback proxy (#4747), rebased on master; union urllib imports * #5228: require browser provenance on all proxy methods (close GET/POST asymmetry from gate); CHANGELOG * #4682: surface read-only other-profile cron jobs in Tasks panel (#3947), rebased on master * chore(changelog): #4682 cross-profile cron visibility * #5142: office-doc preview + safe docx editing (#540), rebased on master; optional deps * #5213: Claude Code sidebar visibility toggle (#4714), rebased on master * chore(changelog): #5213 Claude Code sidebar visibility toggle * #5390: don't preserve dead empty live-turn shell across DOM wipe (blank assistant turn), rebased on master * chore(changelog): #5390 blank assistant turn fix * #4968: export chat to self-contained themed HTML, rebased on master * chore(changelog): #4968 export chat to themed HTML * fix(composer): remove Export-to-HTML button from composer footer (keep settings-panel export) The #4968 export button was hard-inserted into the composer footer .composer-left row, bypassing the configurable composer-control framework. On desktop it pushed the footer over its overflow threshold, tripping _fitComposerFooter into cf-icons mode which HIDES the model/workspace/profile text labels. Removing it restores label visibility. Export stays available via the settings-panel HTML button (#btnExportHTML). * chore(changelog): composer-footer export-button removal hotfix * feat(sessions): move Export-to-HTML into the sidebar conversation menu Follow-up to v0.51.819 which removed the export button from the composer footer (it tripped the footer overflow-collapse, hiding model/workspace labels). Per design consult (Fable) + ChatGPT/Open-WebUI convention, export now lives in the per-conversation sidebar three-dot action menu, right after Duplicate: - exportSessionHTML(session) parameterized (was active-session-only); Settings button now wired ()=>exportSessionHTML() and still exports the active session - new _appendSessionExportHtmlAction() added after Duplicate + in the read-only early-return branch (export is non-mutating; imported sessions re-exportable) - exports THAT row's conversation, not just the active one - download icon added to ICONS; session_export_html[_desc] added to 14 locales - Settings HTML button retained as secondary data-management entry * test: update read-only action-menu shape assertion for the appended Export item * fix(cron): stop auto-creating the Cron Jobs project without project opt-in (#5379) * test(cron): align the legacy fixture with PROJECTS_FILE (#5379) * test(cron): isolate legacy mocks from PROJECTS_FILE reads (#5379) * chore(changelog): #5398 stop auto-creating Cron Jobs project without opt-in * fix(sessions): always retry sidebar session-list GET on 502/503/504 (#5394) The sidebar session-list GET had 502/503/504 retry logic, but it was gated to cold boot only. Once `_sessionListHasLoadedOnce` flipped true, every later refresh (profile switch, focus/visible/reconnect) shipped no retryStatuses, so a transient 502 during an nginx->backend restart window failed on the first attempt and left the sidebar stale until a hard reload (Ctrl+F5). The session-list GET is idempotent, so retrying it is safe unconditionally. This moves `retries:1` + `retryStatuses:[502,503,504]` into the base request options so they apply to every refresh, while keeping the larger boot timeout (`_SESSION_LIST_BOOT_TIMEOUT_MS`) and `retryTimeouts` boot-only. The api() wrapper in static/workspace.js already retries when the error status is in retryStatuses, so no other change is needed. Extends the existing source-string regression test to assert the retry options are now always present (declared before the boot-only gate) while the boot path still carries the timeout + timeout retry. Reported and root-caused by @weidzhou, who traced the boot-only retry gate. Co-authored-by: weidzhou <weidzhou@users.noreply.github.com> * fix(ux): add expand control for update summary panel (#4705) Move scrolling to an inner container and add an Expand/Collapse toggle so long generated summaries are readable on narrow viewports. Fixes #4705 * chore(changelog): #5399 session-list 502 retry + #5209 update-summary expand * fix(settings): reconcile #5145 rename+steer-flip onto master's #5170 mirror Rebase PR #5162 (rename busy_input_mode -> default_message_mode; flip the default from 'queue' to 'steer') onto current origin/master WITHOUT dropping the shipped #5170 localStorage persistence mirror. Rename the mirror machinery to the new setting name for consistency: _BUSY_INPUT_MODES -> _DEFAULT_MESSAGE_MODES (values unchanged) _normalizeBusyInputMode -> _normalizeDefaultMessageMode (fallback now 'steer') _persistBusyInputMode -> _persistDefaultMessageMode _readPersistedBusyInputMode -> _readPersistedDefaultMessageMode window._busyInputMode -> window._defaultMessageMode (+ renamed exports) localStorage: write the new 'hermes-default-message-mode' key; read it with a fallback to the legacy 'hermes-busy-input-mode' key so an existing user's persisted preference survives the rename. Preserve #5170 behavior at every mirror site under the new names: - boot success -> window._defaultMessageMode=_persistDefaultMessageMode(...) - boot FAILURE -> window._defaultMessageMode=_readPersistedDefaultMessageMode() (NOT a hardcoded 'steer' — a saved 'interrupt'/'queue' must still apply when the server is unreachable; do not regress #5167/#5132) - preferences autosave, settings-panel load, and _applySavedSettingsUi all persist through _persistDefaultMessageMode(...) Tests updated for the rename while keeping the persistence-behavior assertions (test_1062, test_5145, test_5167); test_5167 gains explicit guards that the load-failure path reads the persisted pref and never hardcodes a literal mode, plus autosave/panel-load mirror-write coverage. Co-authored-by: Rod Boev <rod.boev@gmail.com> * chore(changelog): #5162 default message mode rename + steer default * fix(sessions): profile switch no longer breaks /api/session/new (#5420) Remove redundant local imports of get_active_profile_name inside handle_post() that shadowed the module-level binding and could raise UnboundLocalError. When prev_session_id belongs to a different profile after a profile switch, skip the memory commit instead of returning 404 so new session creation proceeds. Co-authored-by: Raj_Pabnani <RajPabnani03@users.noreply.github.com> --------- Co-authored-by: nesquena-hermes <nesquena+hermes@gmail.com> Co-authored-by: Rod Boev <rod.boev@gmail.com> Co-authored-by: Frank Song <franksong2702@gmail.com> Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com> Co-authored-by: Loukky <12481807+Loukky@users.noreply.github.com> Co-authored-by: Paladin173 <35980893+Paladin173@users.noreply.github.com> Co-authored-by: Charles Inglis <charles@Charless-MacBook-Pro.local> Co-authored-by: Charles <dcm.inglis@gmail.com> Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Co-authored-by: b3nw <b3nw@users.noreply.github.com> Co-authored-by: nanw <nanw@example.com> Co-authored-by: ruizanthony <ruizanthony@users.noreply.github.com> Co-authored-by: Gordie <gordie@coltoncoan.com> Co-authored-by: promptclickrun <promptclickrun@users.noreply.github.com> Co-authored-by: b3nw <b3nw@duck.com> Co-authored-by: claw-io <claw-io@users.noreply.github.com> Co-authored-by: allenliang2022 <allenliang2022@users.noreply.github.com> Co-authored-by: hermes-agent <hermes-agent@users.noreply.github.com> Co-authored-by: Stacey2911 <STACEY2911@users.noreply.github.com> Co-authored-by: weidzhou <weidzhou@users.noreply.github.com> Co-authored-by: nankingjing <1079826437@qq.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Raj_Pabnani <RajPabnani03@users.noreply.github.com>

Thinking Path
Show non-WebUI sessionspreference, but the backend importer still exposes a narrowerinclude_claude_codehook that never gets a user-facing setting.#4714is explicitly the focused alternative to the broader multi-origin filter redesign, so the right change is to thread one small subordinate preference through the existing settings and session-import owners.What Changed
api/config.py: add theshow_claude_code_sessionsdefault and bool-setting registration.api/routes.py: include the new setting in/api/sessionscache-key and builder wiring, keep the helper backward-compatible for older direct callers, and tolerate olderget_cli_sessionstest doubles while the wider suite stays on the historical signature.api/models.py: route the existinginclude_claude_codehook from the new preference, fold that bit into the lower CLI-session cache key, and keep all-profiles mode scanning global Claude Code history only once.static/index.html,static/panels.js,static/i18n.js: add, hydrate, localize, and autosave the subordinate Claude Code visibility checkbox.tests/test_issue4714_claude_code_visibility_toggle.py: add route, cache, all-profiles, autosave, live UI-gating, and explicit save-path regression coverage.tests/test_1003_preferences_autosave.py: extend Preferences autosave coverage for the new checkbox.Why It Matters
Users who want to keep Telegram, Discord, cron, or generic CLI sessions visible should not have to hide all non-WebUI sessions just to remove Claude Code imports from the sidebar. This keeps the change narrow and avoids reopening the broader sidebar-filter design work.
Verification
pytest tests/test_issue4714_claude_code_visibility_toggle.py -v --timeout=60pytest tests/test_1003_preferences_autosave.py -v --timeout=60pytest tests/test_issue3238_orphaned_cli_sidecar_prune.py tests/test_issue4385_cron_archive_reappears.py tests/test_session_lineage_metadata_api.py tests/test_session_list_long_history_perf.py tests/test_issue4766_sidebar_source_pushdown.py tests/test_issue4775_sidebar_hidden_zero_message_pushdown.py tests/test_1079_cron_session_project.py -v --timeout=60eslint --no-config-lookup -c eslint.runtime-guard.config.mjs static/panels.js static/i18n.jspytest tests/ -v --timeout=60Upstream
Closes #4714.
Related context: #3418 already pursues a broader multi-origin filter redesign; this PR keeps the narrower settings-based toggle requested in #4714.
Model Used
OpenAI GPT-5 Codex via Codex CLI