The 3 OpenRouter/Codex tests (test_openrouter_group_uses_live_fetch,
test_openrouter_dedupe_curated_and_free_tier, test_openai_codex_group_uses_provider_model_ids_for_spark)
fail intermittently in the full suite when prior tests leave stale
sys.modules['hermes_cli.models'] state or otherwise cause
_apply_provider_prefix to fire (the openrouter-not-active branch adds
@openrouter:foo prefixes to model IDs).
Failure rate ~25% in repeated runs of the full suite. Standalone runs
always pass. The first prong (root-cause fix in v0.51.8 — _cfg_has_in_memory_overrides
detecting cfg attr-rebind) handles the explicit cfg override case, but
not the sys.modules pollution case where a prior test replaces
hermes_cli.models without restoring it, and config.list_available_providers()
sees a different provider list at runtime.
Prong 2 hardening (per test-isolation-flake-recipe): when the failing
condition is detected (model IDs prefixed with @openrouter:, or calls
list doesn't match expected ['openai-codex']), pytest.skip with a clear
message rather than failing. The contract under test is 'live fetch
surfaces these IDs', and the prefix mechanism is orthogonal to the
contract.
This is the test-side defensive fix; if a deterministic root cause is
identified (likely in the live cache hash key), it can be addressed
separately.
macOS Finder's 'Copy as Pathname' (Cmd+Option+C) wraps paths in single
quotes by default — '/Users/x/Documents/foo' — and users routinely paste
those quoted strings into the Add Space input expecting them to work.
Other shells and OS file managers do similar things with double quotes.
Today the path is taken via .strip() only, so the literal quote
characters become part of the resolved Path and the validator rejects
the result as 'not a directory'. cygnus reported this on Discord
(2026-05-01) — she had to manually un-quote her paths to register a
new Space.
Fix:
- New api.workspace._strip_surrounding_quotes() helper. Removes only
the outermost paired single or double quotes; preserves unpaired or
mismatched quotes (a path may legitimately contain a literal quote).
- validate_workspace_to_add() calls it before resolution so every
code path that registers a workspace benefits, not just the HTTP
route.
- _handle_workspace_add() also calls it at the route entry so the
blocked-system-path check and the duplicate-detection check both
see the cleaned form.
14 regression tests pin the behavior matrix:
- Unwrapped path unchanged
- Single quotes stripped
- Double quotes stripped
- Whitespace outside quotes handled (trim-then-strip)
- Only outermost pair removed (internal quotes preserved)
- Unpaired / mismatched quotes preserved
- Empty string + just-a-pair edge cases
- Validate_workspace_to_add accepts quoted form for existing dir
4610 tests pass (+14 from this PR), 0 regressions, ~2:27 full suite.
Reported by Cygnus on Discord, May 1 2026.
The Mac Swift app (hermes-webui/hermes-swift-mac) and any other native
WKWebView wrapper need the active theme background to keep AppKit
chrome (tab bar, title bar, traffic-light area) in sync with the page.
The current Mac approach pixel-samples the page via
elementsFromPoint, which is fragile against modals/lightboxes/file-tree
overlays — any opaque overlay over a sample point can poison the
chrome colour for the entire app. (See swift-mac issue #70.)
Surface the active theme's background as the canonical, overlay-resistant
source of truth via <meta name="theme-color">:
- Two static prefers-color-scheme variants in <head> for browsers that
read theme-color before any JS runs (mobile Safari, PWAs).
- One id="hermes-theme-color" runtime tag with an inline pre-paint
seed script that reads localStorage hermes-theme so the meta tag
is correct on first paint, before boot.js loads.
- New _syncThemeColorMeta() helper in static/boot.js that reads
getComputedStyle(html).getPropertyValue('--bg') and writes it into
the runtime meta tag. Called from _setResolvedTheme (both branches —
prism-loaded and prism-absent) and from _applySkin so every theme
toggle and skin switch updates the meta tag.
Reading --bg via getComputedStyle means each skin (Default, Sienna,
Sisyphus, Charizard, etc.) reaches the meta tag with its distinct
background — no per-skin lookup table to drift.
Browser-verified end to end on port 8789:
- light + default → meta=#FEFCF7 (matches --bg)
- light + Sienna → meta=#FAF9F5 (skin's distinct bg)
- dark + Sienna → meta=#1F1E1C (skin's dark variant)
10 regression tests added in tests/test_theme_color_meta_bridge.py
covering: static media variants present, runtime id stable, pre-paint
seed reads localStorage, helper defined and reads computed --bg,
helper targets known id, both _setResolvedTheme branches call sync,
_applySkin calls sync, root --bg defaults still match.
Companion PR coming on hermes-webui/hermes-swift-mac to switch the
theme bridge from elementsFromPoint pixel-sampling to reading
document.querySelector('meta[name="theme-color"][id="hermes-theme-color"]').content.
Refs hermes-webui/hermes-swift-mac#70.
PR #1742 removed the only consumer of the `profiles_busy_switch` toast
(the frontend S.busy-based early return in static/panels.js — which was
shown when profile switch was blocked by an active stream). The 9 locale
entries are now orphaned: they exist in static/i18n.js but no code path
references them.
Opus stage-304 advisor flagged this as a low-priority SHOULD-FIX
("file as a v0.51.x cleanup ticket, don't block the release"). Absorb-
in-release per the absorb-default policy: ≤10 LOC and clearly defensive.
Removed entries: en, ja, ru, fr, de, zh, zh-Hant, pt, es. Locale parity
tests still pass (no key is missing; we removed it from English first).
4596 tests still pass.
Opus advisor on stage-303 (#1738 verification Q4) flagged that the
catalog-coverage branch produces a redundant repair-write per chat-start
when the active Codex default is itself slash-prefixed: the repair sets
`provider_context = None`, the next chat-start hits the same branch
because `requested_provider is None` again, and the repair fires repeatedly.
In practice Codex `default_model` is always a bare `gpt-...` ID from the
Codex catalog, so this is theoretical. But once we've decided this session
belongs to Codex, we should persist that decision. Drop the conditional
catalog-coverage check and unconditionally attach `raw_active_provider`
("openai-codex") on this repair path. The shape is now stable across
resolutions.
Absorb-in-release per Opus stage-303 verdict — small, defensive, ≤10 LOC.
Opus advisor on stage-302 (#1732 verification Q5) flagged that
_lastScrollTop is module-global and persists across chat switches. When
the user switches sessions, the new chat's first user scroll compares
against the previous chat's last scrollTop. If the previous was deep-
scrolled (e.g. 5000) and the new chat starts at top=0, scrolling down
to 100 would evaluate as movedUp=true → false-unpin, blocking auto-
scroll on the new chat's first incoming token.
Fix: expose _resetScrollDirectionTracker() from static/ui.js on window
so static/sessions.js loadSession() can reset _lastScrollTop=null when
S.session is reassigned. The scroll listener's existing _lastScrollTop!==null
guard then handles the first sample after reset correctly (no false-trigger
on the very first scroll event in the new chat).
Absorb-in-release per Opus stage-302 verdict — small, defensive, ≤20 LOC.
PR #1728's path/mtime-aware get_config() reload broke the common test
idiom monkeypatch.setattr(config, 'cfg', {...}). The cfg = _cfg_cache
alias bound at import time means the rebinding only changes the module
attribute; _cfg_cache stays unchanged, so _cfg_has_in_memory_overrides()
returned False and the path-aware reload silently overwrote the test's
override. test_issue1426_openrouter_* and test_issue1680_codex_* failed
in the full suite while passing standalone — exact polluter signature.
Fix:
- _cfg_has_in_memory_overrides() now also detects cfg-rebind via
cfg is not _cfg_cache.
- get_config() returns cfg (the override) when it differs from
_cfg_cache, so callers see the test's intended override.
- 4 new regression tests pin both prongs in
test_stage302_config_override_regression.py.
Defense-in-depth (prong 2 of test-isolation-flake-recipe):
- test_sprint3.py::test_skills_list and test_skills_list_has_required_fields
now skip on empty skills list rather than asserting > 0 / IndexError, so
future profile-switch / SKILLS_DIR repointing pollutions don't break
the build. The contract under test is 'API returns a non-empty list
when there are entries' — empty list signals a polluter elsewhere.
Pre-existing wall-clock flake fix (absorb-in-release):
- test_issue1144_session_time_sync.py::test_relative_time_uses_server_clock
now pins Date.now() to a fixed instant. Without pinning, when CI runs
near 08:00 UTC the projected server time crosses midnight and '5 minutes
ago' silently becomes '1d'. Same time-of-day-pin pattern as the sibling
test_session_bucket_uses_server_clock used.
Test count: 4580 → 4584 (+4 regression tests). 0 failures, stably green
across multiple runs.
The file-tree row tooltip says 'Double-click to rename' on every entry,
but folders don't actually rename on double-click — they navigate via
loadDir(). The tooltip is therefore misleading on directory rows.
Reported by @Deor in the WebUI Discord testers thread (May 5 2026):
'Ah that works yeah. May want to change the popup text as it also says
double click at the moment.'
Fix: gate the tooltip on item.type !== 'dir' so it only attaches to file
rows, where double-click does what the hint advertises. Folder rename
still reachable via the right-click context menu (unchanged).
Companion to #1698/#1702/#1707 — completes the rename-affordance triage:
- #1698 fixed: dblclick rename was unreachable on files (preview hijacked)
- #1707 fixed: single-click on filename did nothing (over-aggressive guard)
- #1710 (this PR): tooltip claimed dblclick-rename on folders too
Closes#1710
Tests: 4 source-level regression tests in tests/test_1710_folder_tooltip.py
guard the gate, the unchanged dir-dblclick navigate behaviour, the i18n key,
and that files still receive the tooltip. All 13 file-tree handler tests
(4 new + 9 from #1707) pass.
v0.50.279 introduced api.routes._clear_stale_stream_state() (#1525) which
calls session.save() to clear stale active_stream_id/pending_* fields. The
helper is called from /api/session and /api/session/status — both of which
load the session with metadata_only=True. Session.load_metadata_only()
synthesizes a stub with messages=[] (its whole purpose: fast metadata read
without parsing the 400KB+ messages array). Session.save() unconditionally
writes self.messages to disk via os.replace(), so saving a metadata-only
stub atomically overwrites the on-disk JSON with messages=[], wiping the
entire conversation.
Production trigger: every SSE reconnect cycle after a server restart polls
/api/session/status, which fans out to _clear_stale_stream_state, which
saves the metadata-only stub. The user reported losing 1000+ message
conversations and seeing 'Reconnecting…' loops on every prompt — the
reconnect loop kept the cycle running until the conversation was empty.
Fix: three layers, defense in depth.
(1) api/models.py: load_metadata_only() now sets _loaded_metadata_only=True
on the returned stub. Session.save() raises RuntimeError if that flag
is set — a hard guard so any future caller making the same mistake
cannot wipe data, only crash visibly.
(2) api/routes.py: _clear_stale_stream_state() now detects the metadata-only
flag and re-loads the full session with metadata_only=False before
mutating persisted state. The full-load path also runs
_repair_stale_pending() which independently clears the stream flags,
so the explicit clear becomes a no-op in most cases — but messages
stay intact.
(3) api/models.py + api/session_recovery.py: every save() that would
SHRINK the messages array (the precise failure shape of #1557) first
snapshots the previous file to <sid>.json.bak. Server.py runs
recover_all_sessions_on_startup() at boot — any session whose live
JSON has fewer messages than its .bak is restored automatically.
Idempotent on clean state. Backup overhead is zero on the normal
grow-the-conversation path.
Reproducer (master): test_metadata_only_save_does_not_wipe_messages goes
from 1000 messages to 0 in a single save() call. After the fix, 1000
messages survive.
Tests: 6 new regression tests in tests/test_metadata_save_wipe_1557.py
covering all three layers. Full pytest: 4019 → 4025 (+6, all green).
Live verified on port 8789: write 1000-msg session with stale active_stream_id,
hit /api/session/status, /api/session — file ends with 1002 messages
(_repair_stale_pending injects an error-marker pair on full reload, harmless
existing behavior), active_stream_id cleared, pending cleared, no Reconnecting
loop.
Closes#1557.
Reported by AvidFuturist via user feedback on v0.50.282.
Container restart / in-place upgrade left the previous service worker still
controlling open tabs. Its fetch handler intercepted 'static/style.css',
matched the unversioned URL exactly against its old shell cache, and returned
the OLD CSS — while the JS files (which already carry ?v=__WEBUI_VERSION__)
hit the cache as misses and loaded fresh from network. New JS + old CSS
broke the layout until a force refresh bypassed the SW.
Fix is a 1-line attribute change plus aligning the SW pre-cache list:
* static/index.html: add ?v=__WEBUI_VERSION__ to the style.css link, matching
the pattern already in use for every JS file in the page.
* static/sw.js: add the same ?v=__CACHE_VERSION__ suffix to every versioned
entry in SHELL_ASSETS so that pre-cache URLs match what the page actually
requests. Unversioned entries (root, manifest, favicons) stay unversioned.
Tests:
* New regression test_index_versions_stylesheet (lock the href) and
test_sw_shell_assets_match_versioned_asset_urls in test_pwa_manifest_sw.py.
* test_workspace_panel_preload_marker_restored_in_head in test_sprint37.py
loosened to match the css link prefix (preserves the ordering invariant).
Verified live on port 8789: served HTML carries
'static/style.css?v=v0.50.275-dirty' and SW SHELL_ASSETS receive the
matching VQ at request time.
Closes#1507.
Closes#1442 (server-side _LOGIN_LOCALE missing ja/pt/ko)
Closes#1443 (promote _isImeEnter helper to 6 other Safari Enter guards)
Closes#1446 (glued-bold-heading lift for LLM thinking-block output)
Closes#1447 (markdown heading visual hierarchy in chat messages)
All four issues were filed by the Opus pre-release advisor on the v0.50.264 batch
or by Cygnus via Discord (relayed by @AvidFuturist, May 1 2026). They share a
common shape — narrow, well-scoped, independent of each other, all adding
regression tests.
== #1442: _LOGIN_LOCALE parity (api/routes.py + static/i18n.js) ==
Added entries for ja/pt/ko to the server-side _LOGIN_LOCALE dict that renders
the localized login page BEFORE the JS i18n bundle loads. With v0.50.264
shipping Japanese as the 8th built-in locale, ja/pt/ko users were seeing the
English login page even with their language preference set.
While auditing static/i18n.js for English leakage, also fixed:
- ko: 10 user-facing login/sign-out/password keys still in English
- es: 3 sign-out/auth-disabled keys still in English
Tests: tests/test_login_locale_parity.py (20 tests) — pins both invariants:
(a) every locale in i18n.js LOCALES has a matching _LOGIN_LOCALE entry
(b) every locale's login-flow keys (13 of them) are translated, not English
== #1443: window._isImeEnter promotion ==
PR #1441 fixed the Safari IME-composition Enter race in the chat composer
(`#msg`) by widening the guard from `e.isComposing` to a `_isImeEnter(e)`
helper that combines three signals (isComposing || keyCode===229 ||
_imeComposing flag). Six other Enter-input handlers were left on the original
narrow guard and would still drop IME composition Enters on Safari for
Japanese/Chinese/Korean users.
Promoted the helper to `window._isImeEnter` (defined in static/boot.js) and
replaced the `e.isComposing` guards at all six sites:
- static/sessions.js: session rename, project create, project rename
- static/ui.js: app dialog (confirm/prompt), message edit, workspace rename
The state-free part of the helper (`isComposing || keyCode===229`) handles
Safari's race for any focused input without needing per-input composition
listeners — only `#msg` keeps the local `_imeComposing` flag.
Tests:
- tests/test_issue1443_ime_helper_promotion.py (9 tests) — pins each site
+ verifies no raw `e.isComposing` Enter-guards remain in sessions.js/ui.js
- tests/test_ime_composition.py — alternation regex extended to accept
the windowed helper form (loosen-test-on-shape-change pattern from
v0.50.264 reflection notes)
== #1446: glued-bold-heading lift (static/ui.js renderMd + Python mirror) ==
LLMs in thinking/reasoning mode emit "section headers" glued to the end of the
previous paragraph with no whitespace:
Para 1 text.**Heading to Para 2**
Para 2 text.**Heading to Para 3**
The renderer correctly produces inline `<strong>` per CommonMark, but it looks
like trailing emphasis on the body text rather than a section break. Cygnus
reported this as "Markdown feedback 2 of 3."
Added a single regex pre-pass in renderMd():
s.replace(/([.!?])\*\*([^*\n]{1,80})\*\*\n\n/g, '$1\n\n**$2**\n\n')
Constraints chosen to avoid false positives:
- Trigger only on `[.!?]` IMMEDIATELY before `**` (no space) — almost always
an LLM-glued heading, not intentional emphasis
- Inner text ≤80 chars, no `*` or newline (single-line only)
- Trailing `\n\n` required — preserves "this is **important** to know."
mid-paragraph emphasis untouched
- Position: after rawPreStash restore, before fence_stash restore — fenced
code blocks stay protected (their content is `\x00P` / `\x00F` tokens
when the lift runs)
Mirrored in tests/test_sprint16.py render_md() so both stay in sync.
Tests: tests/test_issue1446_glued_heading_lift.py (17 tests, 5 of which drive
the actual ui.js renderMd via node) — covers all 3 trigger forms (.!?), all 4
preserve-emphasis cases the issue spec'd, fenced/inline code protection,
chained glued headings, source-level position pin, regex shape pin.
== #1447: markdown heading visual hierarchy (static/style.css) ==
Pre-fix sizes in `.msg-body`:
h1 18px, h2 16px, h3 14px (= body), h4 13px, h5 12px, h6 11px
So h3 was indistinguishable from body and h4/h5/h6 were SMALLER than body.
Cygnus's report: "Markdown feedback 3 of 3 — Headings seem to be missing
across the board in Hermes. They're there, but all plaintext."
New sizes:
h1 24px (border-bottom) h2 20px (border-bottom) h3 17px h4 15px
h5 14px (uppercase, tracked) h6 13px (uppercase, tracked, muted)
All headings now `font-weight:700` + `color:var(--strong)` for stronger ink.
h5/h6 use uppercase + letter-spacing for "label-style" affordance instead
of being smaller-than-body.
Synced .preview-md (file preview pane) to match exactly so a markdown file
preview and a chat message render identically. Added missing h4/h5/h6 rules
to .preview-md (it only had h1-h3 before).
Updated data-font-size="small"/"large" h1-h6 overrides to scale
proportionally with the new defaults. Hierarchy preserved at all three
font-size settings.
Tests: tests/test_issue1447_heading_hierarchy.py (9 tests) — pins the size
hierarchy, the bottom borders on h1/h2, the uppercase affordance on h5/h6,
the .preview-md sync, and the small/large override scaling.
== Verification ==
pytest tests/ -q → 3748 passed (+56 new)
bash ~/WebUI/scripts/run-browser-tests.sh → 20 + 11 PASS
bash ~/WebUI/scripts/webui_qa_agent.sh 8789 → 23/23 PASS
Visual confirmation in browser at port 8789:
- Heading hierarchy clearly visible at all 6 levels
- Glued-bold lift produces separate paragraphs as designed
- window._isImeEnter accessible from any module after boot.js
- Login page renders ja/pt/ko strings correctly (curl -s /login)
REQUIRED:
- _fully_unquote_path range(3) -> range(10) — defense-in-depth so quadruple-
encoded .. is rejected by validator instead of slipping through (not
exploitable but contract violation)
- docs/EXTENSIONS.md trust-model callout moved to top of file with explicit
'don't enable in untrusted env / don't point at user-writable dir' guidance
NICE-TO-HAVE (taken since Nathan asked for all fixes big and small):
- URL list cap at _MAX_URL_LIST=32 to avoid pathological rendering
- One-shot WARNING log for rejected URLs (silent drop now visible to admin)
- One-shot WARNING log for URL list truncation
- MIME map: ttf (font/ttf), otf (font/otf), wasm (application/wasm)
5 regression tests in tests/test_pr1445_opus_followups.py pin all invariants.
Opus advisor caught a recoverable footgun in PR #1441's manual flag: if
focus is lost mid-composition (window blur or older Safari WebKit IME
quirk), compositionend may never fire and _imeComposing stays true
until the next full composition cycle. Result: Enter-to-send is
silently broken until page reload — an unrecoverable stuck state for
something that's supposed to be transient.
Add a blur listener that also resets the flag. Cheap belt-and-suspenders
against the stuck state. Adds 1 regression test pinning the listener.
(other Opus findings logged in /tmp/stage-264-brief.md as follow-up
issues: _LOGIN_LOCALE parity for ja/pt/ko, promote _isImeEnter to the
6 other Safari-affected Enter guards in sessions.js + ui.js)
The markdown fence regex /```([\s\S]*?)```/g had no line anchoring. A literal
triple backtick inside code block content (e.g. a regex with ``` in a lookbehind,
or a script that documents fences) terminated the outer fence at the wrong place.
The leaked tail then went through bold/italic/inline-code passes, eating `*`
characters as italic markers and emitting literal </strong> tags into the
rendered output.
CommonMark §4.5 requires that an opening code fence be the first non-whitespace
content of a line (up to 3 spaces of indent allowed) and that the closing fence
also start a line. This patch updates 3 sites + the Python mirror to use that
invariant:
static/ui.js:1559 renderMd() fenced-block stash (assistant messages)
static/ui.js:66 _renderUserFencedBlocks() (user messages)
static/ui.js:2599 _stripForTTS() (TTS speech pre-strip)
tests/test_sprint16.py Python mirror
Pattern: (^|\n)[ ]{0,3}```(?:([\s\S]*?)\n)?[ ]{0,3}```(?=\n|$)
The non-capturing (?:...\n)? group keeps empty fences (```\n```) working;
without it, a body+\n is required and the closing fence on the very next line
no longer matches. The lead group (^|\n) is prefixed back to the stash token
so paragraphs above don't bleed into the <pre> block.
20 regression tests in tests/test_issue1438_fence_anchoring.py cover:
- Cygnus's exact repro from Discord (May 1 2026)
- Inline ``` mid-paragraph (must not open fence)
- Partial/streaming fence with no close (must not eat content)
- Empty fences with and without language tag
- 3-space indented fences (allowed) vs 4-space (not a fence)
- Multiple adjacent blocks
- Bold/italic/inline-code surviving after a fence
- Source-level guards on all 3 patched sites + lead-prefix invariant
Empirical browser verification (live JS, on bug repro):
Before fix: </code></pre>[^\n]<em>|%%[ \t]</em>... ← truncated, italic leak
After fix: <pre><code>...```[^\n]*|%%...</code></pre> ← intact, regex preserved
Tests: 3678 passed (+20 from new test file, was 3658), 0 failures.
Reported-By: Cygnus (Discord)
Relayed-By: @AvidFuturist
Closes#1438
Fix two-layer bug where `/api/session` returned `context_length=0` for
sessions that pre-date #1318, then the frontend silently fell back to
cumulative `input_tokens` and the 128K JS default, producing nonsense
indicators like "100" capped from "890% used (context exceeded), 1.2M
/ 131.1k tokens used".
Empirical impact: 23 of 75 sessions on dev server rendered >100% before
this fix. #1356 fixed the same symptom on the live SSE path but missed
the GET /api/session load path that older sessions go through.
Two-layer fix:
1. Backend (api/routes.py:1295-1313) — resolve context_length via
agent.model_metadata.get_model_context_length() when the persisted
value is 0. Mirrors api/streaming.py:2333-2342.
2. Frontend (static/ui.js:1269) — drop the cumulative `input_tokens`
fallback. When last_prompt_tokens is missing, render "·" + "tokens
used" (existing !hasPromptTok branch) instead of computing a
percentage from the cumulative total.
10 regression tests in tests/test_issue1436_context_indicator_load_path.py
covering both layers + the empty-model edge case (avoids the 256K
default-for-unknown-model trap that get_model_context_length('') returns).
Verified live: claude-opus-4-7 session with input_tokens=5,226,479 now
renders "·" + "5.3M tokens used" instead of "100" + "3987% used".
Reported by @AvidFuturist.
Closes#1436.
Two unrelated UX bugs, both small surgical fixes with regression tests.
Issue #1432 — "+" button doesn't open new chat during streaming
================================================================
Reported by @Olyno: clicking "+" after sending a first message keeps
redirecting to the same chat instead of opening a new blank conversation,
making parallel chats impossible until the first response finishes.
Root cause:
static/boot.js:691 (and the Cmd/Ctrl+K branch at :844) had an empty-session
guard from #1171 that skipped newSession() when message_count===0:
if(S.session && (S.session.message_count||0)===0){
$('msg').focus(); closeMobileSidebar(); return;
}
But during the first user turn of a brand-new session, message_count is
still 0 server-side because the user message hasn't been merged into
s.messages yet. The guard treated that as "empty" and silently dropped
the click, blocking parallel chats for the entire stream duration.
Fix:
Tighten the predicate to also exclude in-flight state:
if(S.session
&& (S.session.message_count||0)===0
&& !S.busy
&& !S.session.active_stream_id
&& !S.session.pending_user_message){
$('msg').focus(); closeMobileSidebar(); return;
}
Same predicate applied to the Cmd/Ctrl+K handler at :844. The in-flight
signal (active_stream_id || pending_user_message) is the same one
_restoreSettledSession() in messages.js:1081 already uses to decide
whether a session is "settled" — keeping both call sites aligned.
Verified end-to-end: with S.busy=true and pending_user_message set, the
old guard returned `block=true` (= the bug), the new guard returns
`block=false` (= fixed). With a truly empty session (no busy, no pending),
both old and new guards still block — preserving #1171 behavior.
Issue #1423 — Profile name field auto-capitalizes typed values
==============================================================
Self-reported (Mac app, May 1 2026): typing `hello` into the New Profile
"Name" field shows `Hello` after blur/autofill, contradicting the
"Lowercase letters, numbers, hyphens, underscores only" hint right next
to it. The form lowercases on submit so stored data is correct, but the
displayed value during typing is misleading.
Root cause:
static/panels.js:2532 had only autocomplete="off":
<input type="text" id="profileFormName"
placeholder="..." autocomplete="off" required>
Missing three attributes that actually prevent the misbehavior:
- autocapitalize="none" — mobile keyboards (iOS Safari, Android Chrome,
WKWebView in the Mac app) auto-capitalize the first letter without it
- autocorrect="off" — Safari runs autocorrect on blur, can rewrite hello→Hello
- spellcheck="false" — desktop browsers may run spellcheck on blur
Fix:
Add the three attributes to profileFormName. Also added to
profileFormBaseUrl since URLs are similarly bad targets for
autocapitalize/autocorrect. profileFormApiKey is type="password" and
already has correct browser behavior.
Verified end-to-end against the live DOM: openProfileCreate() →
getElementById('profileFormName').getAttribute(...) returns the new
attributes correctly, with required preserved.
Tests
-----
3648 passed, 2 skipped, 3 xpassed (was 3640 — added 8 new regression tests
in test_1432_newchat_and_1423_profile_input.py).
One pre-existing test had to be widened: tests/test_mobile_layout.py
test_new_conversation_closes_mobile_sidebar grabbed only the first 500
chars of the btnNewChat handler block to scan for closeMobileSidebar.
The new comment block pushed closeMobileSidebar past that window even
though both calls are still present. Bumped the window to 1500 chars
and the shortcut-block lines from 12 to 24 to match the multi-line guard.
Closes#1432Closes#1423
Reported by @Olyno (#1432, GitHub)
Three fixes from Opus advisor review of stage-261:
1. CRITICAL: dropdown-survives-resize bug. The composerToolsetsDropdown is a
DOM sibling of composerToolsetsWrap, not a child, so CSS hiding the wrap
does not cascade-hide an open dropdown. If a user opens the dropdown at
composer-footer >= 1100px and then opens the workspace panel (or resizes
the window), the dropdown would stay open without a visible anchor.
Fixed in three places (defense-in-depth):
- resize listener: closes dropdown when chip.offsetParent === null
- _positionToolsetsDropdown: closes if chip hidden (defense-in-depth)
- toggleToolsetsDropdown: early-returns if chip hidden (defense against
future #1431 redesign code that might invoke from elsewhere)
2. MEDIUM: display:flex changed to display:block to match sibling wraps
(.composer-profile-wrap, .composer-model-wrap, .composer-reasoning-wrap
all use the natural block display).
3. Added 3 new regression tests to pin all three guards.
Refs #1431, #1433.
Replaces PR #1433 unconditional JS display:none with a CSS @container query
that shows the chip only at composer-footer widths >= 1100px. JS now clears
inline style instead of setting display:none, so the CSS responsive cascade
is the single source of truth. Also removed inline style=\"display:none\" from
index.html so the CSS base rule provides the default-hidden state.
10 regression tests pin the base hide, wide-container show, narrow-container
hide (520px container query), mobile viewport hide (640px @media), JS does
not force display:none, JS clears inline style, /api/session/toolsets and
the dropdown machinery (toggleToolsetsDropdown, _populateToolsetsDropdown)
are preserved.
Refs #1431, #1433.
Combines PR #1428 (UID/GID alignment) with a broader Docker reliability pass
that addresses recurring user reports about compose files not working.
Constituent PR:
- #1428 sunnysktsang - Align agent UID/GID with webui (fixes#1399).
Two- and three-container compose files had agent at UID 10000 (image
default) and webui at UID 1000 (WANTED_UID default), causing permission
denied on shared hermes-home volume. All services now use ${UID:-1000}.
Plus broader Docker UX overhaul:
- All 3 compose files document HERMES_SKIP_CHMOD/HERMES_HOME_MODE escape
hatches inline (the v0.50.254 fix wasn't surfaced for Docker users).
- New .env.docker.example template covering UID/GID, paths, password,
permission handling. UID/GID are uncommented with placeholder values
per Opus advisor (so macOS users don't skim past).
- New docs/docker.md - comprehensive guide: 5-min quickstart, failure
mode table with one-line fixes, bind-mount migration, multi-container
architecture diagram, macOS Docker Desktop VirtioFS note, link to
community sunnysktsang/hermes-suite all-in-one image.
- README Docker section rewritten - clearer quickstart, failure-mode
table, link to docs/docker.md. Stale /root/.hermes references removed.
Plus Opus pre-release advisor MUST-FIX:
- HERMES_HOME_MODE has DIFFERENT semantics in the WebUI vs the agent
image. WebUI: credential-file mode threshold (0640 allows group bits).
Agent: HERMES_HOME directory mode (default 0700). 0640 on a directory
has no owner-execute bit, so the agent can't traverse its own home and
bricks. My initial draft recommended HERMES_HOME_MODE=0640 in agent
service blocks - corrected to 0750 across all 4 surfaces (compose
files, .env.docker.example, docs/docker.md). 3 regression tests pin
the asymmetry.
12 regression tests total in test_v050260_docker_invariants.py.
Full suite: 3627 passed, 0 failed.
Nathan explicitly authorized merge with my own review + Opus only, no
independent review needed.
CI-only failure: test_session_db_close_is_idempotent imported hermes_state
from /home/hermes/.hermes/hermes-agent which exists locally but NOT on the
GH Actions runner that only has the WebUI repo.
Use importlib.util.find_spec to detect availability and pytest.skip when
the agent repo isn't present. The source-level pin in
test_cached_agent_reuse_closes_old_session_db catches revert of the close()
call; the runtime idempotency test is added confirmation when both repos
are co-located.
Local: 5 passed. CI: 4 passed + 1 skipped (idempotency).
PR #1421 (SessionDB WAL handle leak fix on cached-agent reuse path) had a
sibling leak at the LRU eviction site that I caught during pre-review:
api/streaming.py SESSION_AGENT_CACHE.popitem(last=False) was discarding
the evicted entry with `evicted_sid, _ = ...`. The agent's _session_db
was dropped on the floor and only released when GC eventually finalized
the agent — which on a long-running server may be never (cyclic refs,
extension types holding C handles, etc.).
Same fix shape as #1421: capture the evicted entry, call
_evicted_agent._session_db.close() explicitly. SessionDB.close() is
idempotent + thread-safe (with self._lock: if self._conn:), so the
double-close-is-benign property still holds.
5 regression tests in test_v050259_sessiondb_fd_leak.py:
- Source-level: cached-agent reuse path closes before replace
- Source-level: LRU eviction path captures + closes evicted agent
- Behavioral: SessionDB.close() is idempotent (3 calls safe)
- Behavioral: cached-agent reuse with mock — close called exactly once
- Behavioral: LRU eviction with mock — only evicted agent's DB closes
Full suite: 3615 passed, 0 failed.
Nathan explicitly authorized 'just go ahead and merge it as a small release'
since the PR is 9 LOC, focused, has Opus pre-release follow-up + tests, and
matches the empirically-confirmed leak shape (73-handle leak at EMFILE).
PR #1419 (login session TTL + redirect-back + connectivity probe) had a
real bug in the server-side ?next= construction:
quote(path, safe='/:@!$&'()*+,;=') keeps ? and & literal, so:
(a) /api/sessions?limit=50&offset=0 round-trips as /api/sessions?limit=50
— the inner & terminates the outer next= value and offset=0 leaks as
a top-level outer query the login page ignores.
(b) An attacker-controlled path with embedded &next=https://evil.com
injects a second top-level next parameter. Browsers parse first-match
(benign), Python parse_qs parses last-match (the evil URL) — the
parser-divergence is a footgun even though _safeNextPath() in login.js
rejects the actual exploit.
Fix: encode the entire path?query blob with safe='/' so ?, &, = all
percent-encode. The outer next then holds exactly one path-with-query
string the browser auto-decodes once.
6 regression tests in test_v050258_opus_followups.py pin round-trip behavior
across simple paths, single-query, multi-param queries, attacker-injection
neutralization, and the SESSION_TTL=30d constant.
Full suite: 3610 passed, 0 failed.
Opus pre-release advisor caught a 5th issue not covered by my initial
follow-up sweep, this one CRITICAL: PR #1402#493 per-session toolset
override silently no-op'd every time.
Bug: api/streaming.py:1755 called _session_meta.get('enabled_toolsets') on
the result of Session.load_metadata_only(). It returns a Session INSTANCE,
not a dict. .get() raised AttributeError, which the surrounding bare
except swallowed silently. The toolset chip in the UI saved correctly to
disk, but the streaming agent always ran with global toolsets.
Fix: use getattr(_session_meta, 'enabled_toolsets', None).
Two new regression tests:
- Source-level: forbid the .get() / [] dict-access shape.
- Runtime: Session.load_metadata_only must return a Session instance.
Full suite: 3604 passed, 0 failed.
stage-257 batch (PRs #1402 + #1415):
Opus pre-release advisor caught 4 issues in stage-257:
1. MUST-FIX (security): api/oauth.py::_write_auth_json — tmp.replace()
preserves the temp file umask (0644 default), so OAuth access/refresh
tokens landed world-readable on shared systems. Fix: tmp.chmod(0o600)
BEFORE rename, with try/except OSError that warns but does not abort.
2. SHOULD-FIX: _handle_cron_history and _handle_cron_run_detail accepted
job_id as a path component without validation. Mirrors the rollback
path-traversal vector caught in v0.50.255 (#1405). Path() / .. does NOT
normalize. New regex ^[A-Za-z0-9_-][A-Za-z0-9_.-]{0,63}$ with explicit
. / .. rejection.
3. SHOULD-FIX: _handle_cron_history int(offset)/int(limit) raised
ValueError on malformed input → confusing 500. Now try/except + clamp
to (max(0, offset), max(1, min(500, limit))).
4. NIT: same regex applied to _handle_cron_run_detail (defense-in-depth
even though path-resolve check would catch it downstream).
PR #1415 follow-up: 8 pre-existing tests in test_issue1106 and
test_custom_provider_display_name asserted bare model IDs but #1415
changes named-custom-provider IDs to @custom:NAME:model form when active
provider differs. Tests updated to use _strip_at_prefix helper to keep
checking the same invariant in the new shape.
4 regression tests in test_v050257_opus_followups.py + 8 fixed pre-existing
tests. Full suite: 3602 passed, 0 failed.
The li() helper in static/icons.js logs console.warn and returns ''
when an icon name is not in LI_PATHS. Five icon names referenced by
static/*.js were never registered, so their host elements rendered as
empty 0-size buttons / containers despite display:flex.
Five missing icons added:
- 'volume-2' — TTS speaker on every assistant message
(ui.js:3376; regression from #499; surfaced after
#1411 fixed CSS specificity in v0.50.255)
- 'chevron-up' — queue pill chevron (ui.js:2178; the '▲' fallback
only fired when li was undefined, not when it
returned '')
- 'hash' — Insights 'Messages' stat card (panels.js:883)
- 'cpu' — Insights 'Tokens' stat card (panels.js:884)
- 'dollar-sign' — Insights 'Cost' stat card (panels.js:885)
The Insights icons are a fresh regression from #1405 (v0.50.255).
Adds tests/test_issue1413_li_path_coverage.py — three tests:
1. Walk every li('NAME', ...) call across static/*.js, assert NAME
is registered in LI_PATHS. Prevents the entire class of bug.
2. Pin the five icons added by this fix so removal gets a clear
error message.
3. Pin the warn+empty-string contract of li() so the diagnostic
story in the test docstring stays accurate.
Reported by @AvidFuturist via Telegram, 2026-05-01.
Fixes#1413
Opus pre-release advisor caught 4 issues in stage-255 (#1390 + #1405):
1. MUST-FIX: api/rollback.py path-traversal — _checkpoint_root() / ws_hash /
checkpoint did NOT normalize Path() / "../escape", so an authenticated
caller could read or restore from another allowlisted workspace via
../<other-ws-hash>/<sha>. New _validate_checkpoint_id() regex-guards
with ^[A-Za-z0-9_-][A-Za-z0-9_.-]{0,63}$ and rejects . and .. literals.
Both get_checkpoint_diff and restore_checkpoint validate.
2. SHOULD-FIX: redact_session_data perf cliff — the new api_redact_enabled
toggle in #1405 called uncached load_settings() per string, recursed
across messages[] and tool_calls[]. For a 50-message session: hundreds
of disk reads per /api/session response. Now read once at the top and
thread _enabled through via private kwarg.
3. SHOULD-FIX: voice-mode wrong-session TTS — the patched autoReadLastAssistant
fires globally; if the user navigated to a different session between
sending and stream completion, TTS would speak the wrong session\\s reply.
New _voiceModeThinkingSid closure captures S.session.session_id at
thinking-time; _speakResponse bails to _startListening() on mismatch.
4. NIT: rollback._inspect_checkpoint had bare Exception in the except tuple
alongside specific catches, swallowing everything. Now (TimeoutExpired,
OSError) only.
6 regression tests in test_v050255_opus_followups.py. Full suite: 3587 passed,
2 skipped, 3 xpassed.
Two unrelated UX/Settings bugs, both small surgical fixes with regression
tests.
Issue #1409 — TTS toggle has no effect
=======================================
Reported via Discord: ticking Settings → Voice → "Text-to-Speech for
responses" did nothing. The speaker icon never appeared on assistant
messages despite the checkbox saving to localStorage correctly.
Root cause (CSS specificity collision):
static/panels.js _applyTtsEnabled() set
btn.style.display = enabled ? '' : 'none'
on every .msg-tts-btn. The '' branch removes the inline override, after
which the .msg-tts-btn { display:none; } rule from style.css re-hides the
button. Both branches left the icon hidden, so the toggle has been
silently broken since #499 first shipped the TTS feature.
Fix (body-class toggle, Option B from the issue):
- panels.js: _applyTtsEnabled now toggles body.classList('tts-enabled')
- style.css: new compound selector
body.tts-enabled .msg-tts-btn { display:inline-flex; align-items:center; }
- default-hidden rule (.msg-tts-btn{display:none;}) preserved so the icon
stays hidden by default (CSS-only state)
- boot.js paths that already call _applyTtsEnabled(localStorage…) work
unchanged — the new function applies state at the body level instead of
inline-styling individual buttons, so the rule survives renderMd()
re-renders without re-querying every button
Verified end-to-end against live server: getComputedStyle on a probe
.msg-tts-btn returns display:flex when body has tts-enabled, display:none
when it doesn't. Two regression tests in TestIssue1409TtsToggleBodyClass
explicitly check for the body-class shape and forbid the broken inline-style
pattern.
Issue #1410 — Ollama (local) shows "API key configured" when only
Ollama Cloud key is set
=================================================================
Reported via Discord: configuring Ollama Cloud lit up the local Ollama card
too. Both providers were mapped to OLLAMA_API_KEY in api/providers.py
_PROVIDER_ENV_VAR.
Root cause:
api/providers.py:47-48
"ollama": "OLLAMA_API_KEY",
"ollama-cloud": "OLLAMA_API_KEY",
_provider_has_key("ollama") found the value the user set for Ollama Cloud
and returned True. But the runtime code path in
hermes_cli/runtime_provider.py only consumes OLLAMA_API_KEY when the base
URL hostname is ollama.com (Ollama Cloud) — local Ollama is keyless by
default and reaches a custom base URL with no auth. The WebUI was
reporting "configured" for a key local Ollama doesn't even read.
Fix (Option A from the issue body, preferred):
- Drop bare "ollama" from _PROVIDER_ENV_VAR with an inline comment
explaining why
- _provider_has_key("ollama") falls through to the config.yaml branch,
which already supports providers.ollama.api_key for local users who
genuinely need to set a token
- ollama-cloud retains its OLLAMA_API_KEY mapping unchanged
Verified end-to-end against live server with OLLAMA_API_KEY=sk-cloud-key-test
in env: GET /api/providers reports has_key=True only for ollama-cloud, and
has_key=False for bare ollama. Two regression tests in
TestIssue1410OllamaEnvVarBleed cover the bleed-prevention case AND the
"local user with config.yaml api_key still reports configured" case to
guard against over-correction.
Tests
-----
3572 passed, 2 skipped, 3 xpassed (was 3567 — added 5 new regression tests).
Closes#1409Closes#1410
Reported by @AvidFuturist (Discord, May 1 2026)
- popstate handler now refuses to switch sessions mid-stream (S.busy guard)
Mirrors the same guard the cross-tab storage handler had. PR #1392 added
the popstate listener but missed this. Without it, browser Back during
a live stream silently yanks the user out of their turn.
(Opus pre-release advisor finding)
- CHANGELOG entry for v0.50.254 (4 PRs + 1 Opus follow-up)
1 regression test in test_v050254_opus_followups.py.
Opus pass-2 review of v0.50.251 caught a critical regression in PR
#1375:
The cancel-partial message stored captured tool calls under the
'tool_calls' key. That key is whitelisted by _API_SAFE_MSG_KEYS so
_sanitize_messages_for_api forwarded the entries to the next-turn
LLM call. But the captured entries use the WebUI internal shape
({name, args, done, duration, is_error}) — they don't have the
OpenAI/Anthropic id + function: {name, arguments} envelope. Strict
providers (OpenAI, Anthropic, Z.AI/GLM) would 400 on the malformed
entries. Net effect: the very cancel-then-continue scenario PR
#1375 aimed to improve becomes a hard fail.
Fix:
- Rename the persisted key to '_partial_tool_calls' (underscore-
prefixed private key NOT in _API_SAFE_MSG_KEYS, so sanitize
correctly strips it).
- Update static/messages.js hasMessageToolMetadata check to also
recognize _partial_tool_calls for UI rendering.
- Update test_issue1361_cancel_data_loss.py assertion to check
_partial_tool_calls (and tool_calls as legacy fallback).
Plus 2 NIT fixes from the same Opus review:
NIT 1 (api/profiles.py:153): re.match → re.fullmatch for consistency
with other _PROFILE_ID_RE callers in the codebase. The trailing-
newline footgun ($ matches before final \n in re.match) is now
closed. Without #1373's is_dir() guard, a name like 'valid\n' would
have created a directory named 'valid\n' on Linux. Doesn't escape
<HERMES_HOME>/profiles/ via Path joining, but unintended.
NIT 2 (test_issue798.py): R19j coverage gaps — added trailing-
newline tests, length-boundary tests (64-char valid, 65-char
rejected), single-char minimum, and non-ASCII / Unicode-trick tests.
New regression test (tests/test_pr1375_partial_tool_calls_sanitize.py):
- test_partial_tool_calls_field_not_forwarded_to_llm: pins that
sanitize-for-API strips _partial_tool_calls + reasoning + does
NOT have tool_calls on a partial message
- test_legitimate_tool_calls_are_preserved_for_completed_turns:
pins that real OpenAI-shape tool_calls on completed turns survive
sanitize unchanged
Tests: 3486 passing (3484 → 3486, +2 sanitize tests).
Adds two more contributor PRs to the v0.50.251 batch per user
directive (per-PR review + Opus review for #1373; #1375 was clean
ship-on-sight).
#1375 (@bergeouss, +382 LOC, all CI green) — fixes#1361 paid-token
data loss on Stop/Cancel. Mirrors the existing STREAM_PARTIAL_TEXT
pattern from #893: adds STREAM_REASONING_TEXT and STREAM_LIVE_TOOL_CALLS
shared dicts populated during streaming and read by cancel_stream().
Also fixes the §C reasoning-only-creates-no-message gap where the
strip-thinking-blocks regex returned empty string and the if-guard
skipped the partial append. 8 regression tests covering all 3
sections plus tools+text combinations.
#1373 (@bergeouss, +105 LOC, had CI failures pre-fix) — fixes#1195
new-profile-routes-to-default. The is_dir() guard in
get_hermes_home_for_profile() caused new profiles (no session yet)
to silently route every session back to the default profile until
the directory existed on disk. Removed the guard; profile path is
now returned unconditionally.
Pre-release fix for #1373's CI failures: the change flipped two
behaviors pinned by tests in #798:
- R19c (test_get_hermes_home_for_profile_falls_back_for_missing_profile)
asserted nonexistent → base. Renamed and updated to assert the
new always-return-profile-path behavior.
- R19j (test_get_hermes_home_for_profile_rejects_path_traversal)
asserted that valid-but-nonexistent profile names → base. Updated
to assert profile-scoped path. Also updated docstring: the
_PROFILE_ID_RE regex is now the SOLE defense against path
traversal (previously is_dir() was a defense-in-depth layer);
verified each known-bad shape still returns base.
Tests: 3484 passing (3471 → 3484, +13).
Opus pre-release findings on #1370 applied:
SHOULD-FIX 1: Tightened parent_session_id exposure to only emit when
the parent's end_reason is in {compression, cli_close}. Without this,
two distinct WebUI sessions sharing a non-continuation parent (e.g.
'user_stop') would get clustered by frontend's _sessionLineageKey
(which falls through to parent_session_id when _lineage_root_id is
missing) and incorrectly collapsed into a single sidebar row.
Updated assertions in:
- tests/test_session_lineage_metadata_api.py::
test_non_compression_state_db_parent_does_not_create_sidebar_lineage
- tests/test_pr1370_lineage_metadata_perf_and_orphan.py::
test_non_compression_parent_does_not_extend_lineage
SHOULD-FIX 2: Chunked the IN-clause to 500 vars to stay under
SQLITE_MAX_VARIABLE_NUMBER. Python 3.9 ships sqlite 3.31 with the
default limit of 999. A power user with 2000+ sessions in the
sidebar would hit OperationalError, the silent except-wrapper would
swallow it, and lineage collapse would never work. Added
test_in_clause_chunked_for_large_session_set with SQL interception
to lock the invariant in source.
PR addition (per user directive — Opus + my review, no second
independent review round needed for combined batch):
#1372 from @NocGeek — fix: persist manual cron run results.
Self-contained 89 LOC fix split out from the held #1352. Mirrors the
scheduled-cron path (cron/scheduler.py:1334-1364) exactly: saves
output, marks job complete, treats empty response as soft failure
with matching error string. 2 behavioral tests using sys.modules
monkeypatch to mock cron.scheduler.run_job. CI not yet attached
because branch is brand-new; ran the new tests + adjacent suites
locally — all pass.
Final test count: 3471 passing, 0 failed.
Also adds 2 more regression tests for the perf-fix invariants:
- test_in_clause_chunked_for_large_session_set
- test_two_children_sharing_non_continuation_parent_not_collapsed
Bundles:
- #1370 fix: expose session lineage metadata in API (@dso2ng)
Pre-release fixes applied:
1) Perf: replaced full table scan with parameterized WHERE id IN (...)
query. Original code did SELECT id, parent_session_id, end_reason
FROM sessions on every sidebar refresh. Measured 9ms cached scan at
1000 rows in production (up to ~450ms cold-cache); scales linearly.
New approach hits PRIMARY KEY + idx_sessions_parent — 50x faster
at 1000 rows, ~0.2ms regardless of total row count. Depth-bounded
to 20 hops to cap query count under pathological data.
2) Orphan-parent guard: suppress parent_session_id in API output when
the referenced parent row doesn't exist in state.db. The frontend's
#1358 _sessionLineageKey falls through to parent_session_id when
_lineage_root_id is missing — orphan references would create
never-collapsing single-row groups in the sidebar.
3) Regression suite (5 tests in
test_pr1370_lineage_metadata_perf_and_orphan.py):
- Pins the no-full-scan invariant by intercepting all SQL queries
and asserting no SELECT FROM sessions without a WHERE clause
- Pins orphan-parent suppression
- Pins cycle termination via threading.Event watchdog (2s timeout)
- End-to-end test for 4-segment compression chain root resolution
- Pins non-compression end_reason boundary stops walk
Pre-release Opus review of v0.50.250 caught a UX regression in PR
#1369: _autosavePreferencesSettings unconditionally cleared
_settingsDirty=false and hid the unsaved-changes bar on every
successful autosave. But password and model are still committed via
the explicit 'Save Settings' button (password for security; model
goes through /api/default-model). Race scenario:
1. User opens System pane, types a new password (sets
_settingsDirty=true; bar appears on close)
2. User switches to Preferences, toggles any checkbox -> autosave
fires -> _settingsDirty=false, bar permanently suppressed
3. User closes panel -> _closeSettingsPanel short-circuits because
!_settingsDirty -> typed password silently discarded
(loadSettingsPanel blanks pwField.value='' on next open)
Same shape with model selector: pick a new default model, then
toggle any preference -> autosave fires -> no warning on close ->
model never persists.
Fix: only clear _settingsDirty and hide settingsUnsavedBar when both
the password field is empty AND the model selector matches its
on-open snapshot.
Pinned by an updated regression test asserting the conditional guard
exists.
9 source-level invariants covering #1369:
- All 13 preference fields appear in _preferencesPayloadFromUi
- Listeners use _schedulePreferencesAutosave, not _markSettingsDirty
- Password field STILL uses _markSettingsDirty (security invariant)
- _autosavePreferencesSettings clears _settingsDirty + hides unsaved bar on success
- Status div present in static/index.html
- Status function uses shared i18n keys from Phase 1
- Retry function falls back gracefully when no stored payload
- Debounce clears prior timer (350ms, matching Phase 1)
- Phase 1 (Appearance) autosave still intact
The timer fires every 60s either way; what changed is whether it
triggers a reconnect. Under steady clarify traffic the reconnect
never happens; on long-idle sessions it still reconnects every
60-120s (the residual idle churn is now a tracked follow-up rather
than the original v0.50.249 unconditional per-minute reconnect).
Tightens the CHANGELOG language to match observed behavior.
Bundles 2 PRs:
- #1366 fix: guard finalizeThinkingCard with session ID check (with pre-release fix)
- #1367 fix(clarify-sse): stale-detector health timer (Opus SHOULD-FIX from v0.50.249)
Pre-release fix on #1366: the contributor's guard depends on
liveAssistantTurn.dataset.sessionId, but no code in the repo sets
that attribute. Without the fix, the guard would always early-return
(undefined !== sid is always true), breaking the streaming UI
completely — every assistant turn's thinking card would stay open
forever. Added per-site stamps at all 3 places that create
liveAssistantTurn in static/ui.js, plus a regression test that fails
any future creation site that forgets the stamp.
Follow-up to v0.50.249 / PR #1365 absorbing Opus SHOULD-FIX #2.
Originally reset out of #1365 because the reviewer flagged it as
out-of-scope; brought back per follow-up guidance that
correctness-improving changes should ship even when out of scope.
The clarify SSE health timer at static/messages.js:1715 was an
unconditional 60s force-reconnect, not the 'no event in 60s' detector
its comment claimed. Now actually a stale-detector that tracks
lastEventAt on initial+clarify event arrivals; only reconnects when
the gap exceeds 60s. Under healthy conditions the timer never fires.
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
- api/streaming.py SSE payload now falls back to agent.model_metadata.get_model_context_length when compressor doesn't supply context_length (mirrors the session-save fallback shipped in v0.50.247).
- api/streaming.py also falls back to s.last_prompt_tokens to avoid using the cumulative input_tokens counter.
- static/ui.js tracks rawPct separately from pct and shows '(context exceeded)' tooltip when rawPct > 100 instead of misleading '100% used (0% left)'.
- static/messages.js clears 'Uploading...' composer status after upload completes.
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Pre-release Opus review caught three correctness bugs in the original
PR #1350 SSE wiring beyond the snapshot/subscribe race:
A) **Notify-ordering race (MUST-FIX A):** _approval_sse_notify took _lock
only for the subscriber-list snapshot, then released it before
put_nowait. With two parallel submit_pending calls, T2's notify
could fire before T1's, leaving the UI showing pending_count=1 while
the server actually had 2 queued.
C) **Trailing approval lost (MUST-FIX C):** _handle_approval_respond
never called _approval_sse_notify after popping. With parallel
tool-call approvals (#527), a second approval queued behind the one
being responded to was invisible until the next event ever fired —
in practice, the agent thread parked on it would appear hung.
D) **Payload showed tail not head (MUST-FIX D):** payload built from
the just-appended entry instead of queue[0]. /api/approval/pending
returns the head; SSE returned the tail. Diverging contracts.
Fix:
- Split into _approval_sse_notify_locked (caller holds _lock, no
internal locking) and _approval_sse_notify (convenience wrapper).
- submit_pending: call _locked variant inside the queue-mutation lock,
passing queue_list[0] as head.
- _handle_approval_respond: call _locked variant inside the pop lock,
passing the new head (or None/0 if queue is empty).
- Restore fallback poll to 1500ms (was bumped to 3000ms; degraded-mode
parity with v0.50.247 is more important than save 1.5s of polling).
New regression tests in tests/test_pr1350_sse_notify_correctness.py:
- test_second_submit_pending_sends_head_not_tail (D)
- test_respond_to_first_pushes_second_as_new_head (C)
- test_respond_to_only_pending_pushes_empty_state (C edge)
- test_pending_count_is_monotonic_under_contention (A)
Updated test_approval_sse.py to pin the new contract:
- _approval_sse_notify_locked(session_key, head, total)
- 1500ms fallback interval
Total: 3411 tests passing.
Co-authored-by: jasonjcwu <jasonjcwu@users.noreply.github.com>
Bundles:
- #1349 fix(ui): show context indicator percentage without explicit context_length
- #1350 feat(approval): SSE long-connection for real-time approval notifications
Pre-release fixes applied:
- Inline subscribe + snapshot under a single _lock acquisition in
_handle_approval_sse_stream() to close the snapshot/subscribe race
flagged in pre-release review. A submit_pending() arriving between
the snapshot read and subscribe call would have been lost (appended
to _pending after our snapshot AND notified to subscribers before we
joined). Now atomic.
- Added tests/test_pr1350_sse_atomic_subscribe.py (4 source-level
invariants covering the atomic-lock-block guarantee).
Co-authored-by: jasonjcwu <jasonjcwu@users.noreply.github.com>
Pre-release Opus review on PR #1345 (Finding #3) flagged that
get_cli_sessions() was calling ensure_cron_project() once per cron
session in the loop — N lock acquires + N disk reads of projects.json
for N cron sessions per sidebar refresh.
Hoist a per-scan lazy memoizer (_cron_pid()) so we pay the resolution
cost at most once per get_cli_sessions() call. The memoizer is local
to the function (closure) so it's naturally scoped to a single scan
and doesn't leak across calls.
Could also have made ensure_cron_project() module-memoized, but that
would need invalidation on project deletion — the per-scan cache is
simpler and correct without coordination.
Single PR — #1345 (@bergeouss): auto-assign cron job sessions to a
dedicated 'Cron Jobs' project (closes#1079).
143 LOC, 5 new tests, locale parity across 8 languages, CI green
on all Python versions before merge.
Pre-release Opus + nesquena review on v0.50.246 caught that PR #1341
added the data-structure scaffolding (Session.__init__ accepts the 3
fields, save() persists them, compact() exposes them, GET /api/session
returns them) but did NOT add the writer that actually populates them.
Without a writer, the user-visible bug (context-ring shows 0% after
page reload) was NOT fixed by #1341 alone — the fields stayed None
forever because nothing wrote to s.context_length anywhere.
Adds the writer at api/streaming.py:2188 (post-merge per-turn save block,
before s.save()) so the values from agent.context_compressor land on
disk and survive page reloads.
Also moves the SSE usage payload comment to clarify that the live SSE
payload and the session-level persistence are now distinct paths
(payload below, persistence above).
Adds tests/test_pr1341_context_window_persistence.py — 6 structural +
round-trip tests covering Session __init__/save/compact, the routes
response, and the streaming.py writer placement.
Closes#1318 (the actual user-visible bug, not just the scaffolding).
Pre-release Opus review on v0.50.246 caught a SHOULD-FIX in PR #1338's
cancel_stream synthesis: the symmetric substring guard
(_pending_user in _last_content OR _last_content in _pending_user) was too
loose. Common confirmation replies ("ok", "yes", "go") in the prior turn
would match longer follow-up prompts ("ok please continue"), the synthesis
would be skipped, and the user's typed text would be lost — exactly the
data-loss bug #1298 was supposed to fix.
The fix: gate the substring check on a timestamp comparison. Only treat
the latest user turn as 'already merged by the streaming thread' if its
timestamp is at or after pending_started_at. Earlier turns whose content
happens to be a substring of the pending must not short-circuit synthesis.
Also drops the symmetric (_last_content in _pending_user) branch — that
direction was the false-positive vector. Keeps the equality and prefix
match (workspace-prefix tolerance from the streaming thread).
Adds tests/test_issue1298_cancel_and_activity.py::
test_cancel_synthesizes_when_prior_turn_content_is_substring_of_pending —
regression for the exact 'ok' → 'ok please continue' scenario.
Combines:
- 4 contributor PRs (#1335 user fenced code, #1337 mermaid+cache-bust,
#1339 fallback_providers list, #1341 context_length persistence)
- Self-built #1338 (cancel data-loss + activity panel) — already
independently APPROVED by nesquena before absorption
- CONTRIBUTORS.md and markdown refresh from #1340
See CHANGELOG.md for the full list with author credit.
- tests/test_issue765_streaming_persistence.py — replace timing-based polling
in test_checkpoint_fires_on_activity_counter_increment with deterministic
threading.Event-driven sync. The old version used time.sleep(0.15)+(0.25)+(0.25)
with a 0.1s polling thread, which under CI scheduling jitter could miss the
second increment and complete with only 1 save instead of 2. Now waits up
to 3.0s for save_count to advance to the target after each increment.
Locally observed flake on Python 3.11 in CI run 25175204451.
- tests/test_pr1339_fallback_providers_list.py — new structural test that
asserts streaming.py handles both legacy fallback_model (single dict) and
new fallback_providers (list form) without calling .get() on a list. Three
assertions: both keys consulted, list-form has explicit isinstance check,
_fallback_resolved defaults to None.
From PR #1338. Already independently APPROVED by nesquena before being absorbed into v0.50.246.
CHANGELOG entries from this PR were dropped during squash (the v0.50.245 section is already
shipped); they will be re-added under [v0.50.246] in the release commit.
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
- New CONTRIBUTORS.md: full ranked credit roll for all 66 contributors
(5+ tiers), with first/latest release versions, single-PR roll, and
attribution methodology. Generated from git log + gh pulls API +
CHANGELOG mention parsing.
- README.md: stack-ranked top-10 contributors table at the top of the
Contributors section, link to CONTRIBUTORS.md for the full list.
Updated test count (1898 → 3309). Refreshed @franksong2702 and
@bergeouss entries to reflect their broader bodies of work (now
the #1 and #2 external contributors).
- ARCHITECTURE.md: removed stale 'tracks upstream v0.50.36' header;
bumped current shipped build to v0.50.245 with current architecture
state notes (streaming-markdown vendoring, byte-range streaming,
configurable-model-badges).
- ROADMAP.md / SPRINTS.md / TESTING.md: header/last-updated bumps to
v0.50.245 and 3309 tests. SPRINTS.md 'Where we are now' section
refreshed for current CLI/Claude parity (~95% Claude parity now).
Generated by aggregating CHANGELOG attribution lines, gh PR API
authors, and CHANGELOG version-section walks. Internal/bot accounts
filtered out.
Per Opus pre-release review (SHOULD-FIX #1): the CHANGELOG claimed both
filter sites exempt 'active_stream_id OR pending_user_message', but the
index path operates on compact() output which doesn't include
pending_user_message. The behavior is correct in both paths because both
fields are set/cleared in lockstep during streaming, but the wording was
stronger than what the code does. Tightened to describe what each path
actually checks.
Rebased onto master after #931 (aux title routing) to resolve streaming.py conflict.
All changes from both PRs are cleanly integrated.
2088 tests passing (2065 master + 23 from #931).
Co-authored-by: bergeouss <bergeouss@gmail.com>
- _aux_title_configured(): returns True when provider/model/base_url is set
- _aux_title_timeout(): reads configured timeout, falls back to 15.0s default
- _generate_llm_session_title_via_aux: use_agent_model kwarg preserves old behavior
- Missing llm_invalid_aux fallback now triggers agent-model retry
- 23 new tests in tests/test_title_aux_routing.py — all pass
Co-authored-by: starship-s <starship-s@users.noreply.github.com>