* fix(#4164): bound project-context file walk in non-git workspaces
_project_context_candidates mirrored the agent's _find_hermes_md exactly,
including the bug that 'stop_at = git_root' is None in a non-git tree —
so the walk 'for directory in [cwd, *cwd.parents]: ... if stop_at and
directory == stop_at: break' never broke and ran to filesystem root.
A workspace at /tmp/x/project/subdir could surface /tmp/x/HERMES.md
(a file outside the workspace) in the Memory > Project Context tab.
Fix: when no git root is found, treat the workspace itself as the stop
boundary. The cwd is still scanned (preserving in-workspace AGENTS.md /
HERMES.md behavior), but parents are skipped — we never walk into the
user's home directory or /tmp.
Git workspaces are unchanged (still walk up to the git root). The
existing test_3866 walk-to-git-root test still passes.
The issue notes the agent-side walk in agent/prompt_builder._find_hermes_md
should be bounded the same way for full parity; that lives in a separate
repo (hermes-agent), so this PR fixes only the WebUI side. Until the
agent ships the same bound, the WebUI tab will under-report context
files that live above a non-git workspace — strictly less surprising
than the previous over-report.
tests/test_issue4164_bound_non_git_project_context_walk.py (4 tests) pins:
- non-git workspace does not leak a parent HERMES.md
- non-git workspace still reads in-workspace context files
- git workspace walk-to-git-root is unchanged
- workspace-level HERMES.md wins, parent copy doesn't appear even shadowed
All 10 existing test_3866_project_context_memory_section tests still pass.
Closes#4164.
* chore(release): stamp v0.51.428 (Release OO) for #4164
---------
Co-authored-by: Sanjay Santhanam <51058514+Sanjays2402@users.noreply.github.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
* fix(#3988): default show_cli_sessions to true for new installs, grandfather existing OFF
Combines the best of #3997 (rodboev) and #4222 (Sanjays2402):
- config.py default flipped True so NEW installs surface CLI/TUI/Telegram/
Discord/Hermes One sessions in the sidebar without hunting for the toggle.
- load_settings() backfill grandfathers ESTABLISHED installs OFF: when the saved
settings predate the flip (onboarding_completed True + key absent), pin False so
an existing user's sidebar doesn't silently change. Fresh installs get True.
- Default-true hydration at every read site (config.py / boot.js / panels.js
checkbox) via the !==false idiom — no config-ON-but-UI-OFF mismatch (#4006 class).
- 10 tests: config default, bool-key round-trip, grandfather matrix
(fresh/mid-onboarding/established-absent/explicit-true/explicit-false/
save-preserves-grandfather), and both frontend hydration sites.
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: Sanjays2402 <Sanjays2402@users.noreply.github.com>
* fix(#4217): guard /api/file/delete and /api/file/rename against symlink targets
* chore(release): stamp v0.51.427 (Release ON) for #3988 + #4217
* chore: drop unused imports (ruff F401) in absorbed test files
* fix(#4217,#3988): address review — dangling-symlink ordering + boot.js catch fallback
Greptile review on #4230 + the related-PR reviews surfaced two fixes on the
shipped code:
1. (#4217) _handle_file_delete/_handle_file_rename checked target.exists()
BEFORE is_symlink(), so a DANGLING workspace symlink resolved to a missing
target -> misclassified as 404 'File not found' and left permanently
undeletable/unrenameable. Moved the no-follow is_symlink() guard before the
follow-based exists() check in both handlers. +2 dangling-symlink tests.
2. (#3988) boot.js settings-load-FAILURE catch block hardcoded
_showCliSessions=false, inconsistent with the new True default (the #4006
catch-block-fallback class). Now defaults true. +1 test pinning the fallback.
Both confirmed by the new tests (20/20 in the two files).
* fix(#3988,#4217): address Opus review — broaden grandfather signal + move-handler parity
Opus #4230 ship-review surfaced 2 LOW findings, both now fixed in-line:
1. (#3988) The grandfather keyed only on onboarding_completed, so a CLI-configured
user who tweaked a WebUI setting but never ran the wizard would get flipped ON
(and onboarding.py later auto-persisting onboarding_completed=True would make it
permanent). Now: established = show_cli_sessions absent AND (onboarding_completed
OR any persisted setting other than a falsy onboarding flag). A genuinely new /
still-mid-onboarding file still falls through to True. +1 test.
2. (#4217) _handle_file_move still checked exists() before is_symlink() — the same
dangling-symlink-404 bug just fixed in delete/rename. Reordered for parity so a
dangling symlinked source is rejected 400, not 404.
22 tests across the two files pass; ruff clean.
---------
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: Sanjays2402 <Sanjays2402@users.noreply.github.com>
* fix: keep custom provider when model id has known-provider prefix (#4210)
Siblings of #3872 (v0.51.349, custom provider with a configured base_url
preserves intrinsic routing prefixes). The base_url unset variant of
the same bug was missed: with provider=custom or custom:<slug> and no
base_url set, a model id like 'x-ai/grok-2' or 'google/gemma-2' was
hijacked to provider='openrouter', base_url=None. The backend then
failed to initialize with 'RuntimeError: No LLM provider configured'
because the user had no OpenRouter credentials configured.
resolve_model_provider() now applies the same _is_custom exemption the
config_base_url branch already carries to the cross-provider redirect
at the end of the slash branch. The custom provider keeps the request
and the full model id; real first-party providers pointed at an
OpenAI-compatible proxy (e.g. provider=openai + base_url=litellm) are
unaffected because their strip path is in the base_url branch and the
'prefix == config_provider' branch above the change.
Verified:
- test_model_resolver.py 32 passed (30 existing + 2 new RED tests)
- test_custom_provider_bare_model_reasoning.py 34 passed
- Pre-existing F401 (api/config.py:23,24,28) are not introduced by
this change; same 3 errors exist on upstream/master.
Refs #4210
* chore(release): reposition #4210 CHANGELOG entry under v0.51.426 (OM)
---------
Co-authored-by: b3nw <b3nw@users.noreply.github.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
* fix: bound paginated session tails by visible messages
* fix(#4070): always merge state.db tail rows (drop the stale-tail timestamp shortcut)
Deep-review finding: the sidecar-only timestamp shortcut returned the sidecar
verbatim whenever state.db had no strictly-newer rows, but a state.db row
at-or-before the sidecar's newest timestamp (recovery / edited-in-place /
missing-timestamp) is still absent from the sidecar -> the paginated tail
diverged from the full merge path (empirically 3/6 adversarial cases). Always
run the cheap in-memory append-only merge; the real latency win (skipping the
lineage-parent DISK load) is preserved.
Co-authored-by: t3chn0pr13st <t3chn0pr13st@users.noreply.github.com>
* fix(#4070): keep matching trailing tool-result row in paginated window
Codex ship-gate finding: _message_window_for_display pulled end_idx back to the
last renderable (user/assistant) row, dropping a trailing role:tool result row
even when it's the result of the newest assistant tool-call. The renderer
rebuilds tool cards from these rows (CLI-origin / empty S.toolCalls path,
static/ui.js resultsByTid), so the card lost its result snippet. Now extend
end_idx forward through trailing tool rows whose tool_call_id matches a tool-call
present in the window; orphan trailing tool rows (no match) are still skipped to
preserve the visible-row budget. +2 regression tests.
Co-authored-by: t3chn0pr13st <t3chn0pr13st@users.noreply.github.com>
* chore(#4070): remove now-dead _max_message_timestamp + its import
Opus gate nit: _max_message_timestamp was only called by the removed
sidecar-timestamp shortcut, and the _message_timestamp_as_float import existed
solely to feed it. Both removed; _message_timestamp_as_float still lives in
api/models.py for its real callers.
* chore: add #4069 CHANGELOG entry (visible-message tail pagination)
* chore(release): stamp v0.51.424 (Release OK) for #4069
---------
Co-authored-by: t3chn0pr13st <technopriest@live.ru>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: t3chn0pr13st <t3chn0pr13st@users.noreply.github.com>
* perf(chat): virtualize long message transcripts (#500)
* fix(chat): preserve virtual height cache on append (#500)
* fix(chat): bound virtual tail follow-up (#500)
* fix(chat): surface older history and bound virtual rerenders (#500)
* fix(chat): stabilize virtual history fallback (#500)
* fix(chat): preserve virtual compression anchors (#500)
* Keep virtualization regressions aligned with rebased sources (#500)
* fix(chat): preserve virtual heights across prepends (#500)
* test(update): isolate restart timing checks (#814)
* fix(chat): restore render contracts around virtual cache
* fix(chat): preserve legacy assistant anchor predicates
* fix(#3714): restore worklog-disclosure capture after HTML-cache early-return
Deep-review finding: the capture of _captureWorklogDetailDisclosureState was
moved before the HTML-cache fast-path return, failing the invariant test
test_render_rebuild_preserves_worklog_detail_disclosure_click_state (it must
not traverse the previous-session DOM when the cache path returns early).
Moved it back to just before inner.innerHTML='' (after the cache return).
Co-authored-by: Rod Boev <rod.boev@gmail.com>
* fix(#3714): restore Transparent Stream rehydration in HTML-cache fast path
Full-suite gate caught test_issue3820_chat_activity_display_mode failure: the
virtualization rewrite of the renderMessages HTML-cache fast-path block dropped
the _rehydrateTransparentStreamDom(inner) call (master has it; PR had 2 of 3
occurrences). On a cache-restore session switch, Transparent Stream controls
lost their property-assigned handlers. Restored the call in the same position
master has it (right after _sessionHtmlCacheSid=sid).
Co-authored-by: Rod Boev <rod.boev@gmail.com>
* fix(#3714): preserve live turn on scroll-rebuild even with zero streamed text
Opus ship-review residual (now fixed, not deferred): the live-turn re-attach
after a scroll-triggered virtualized rebuild was gated on _preservedLen>0, so a
turn showing only Activity/tool/worklog blocks with no streamed text yet (mid
tool-call) would not be preserved — scrolling up into history on a >80-row
transcript mid-stream could blink those live-only blocks for a frame. Broaden
the gate to also restore when the preserved turn has MORE structural blocks than
the rebuilt (lagging-S.messages) turn. Hoisted the _structuralCount helper above
the gate; whole-turn-vs-segment restore logic unchanged.
Co-authored-by: Rod Boev <rod.boev@gmail.com>
* chore(release): stamp v0.51.423 (Release OJ) for #500 transcript virtualization
---------
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
* feat(workspace): add Todos tab to workspace panel behind a setting (#2942)
* fix(workspace): use DOM attribute to check active tab across script scope
* Prefer the live todos snapshot in the workspace tab
* Fill the missing workspace Todos locale keys
* feat(workspace): add optional Todos tab to workspace panel (#3564)
Rebased rodboev's #3564 onto current master + CHANGELOG entry.
Co-authored-by: Rod Boev <rod.boev@gmail.com>
* fix(#3564): apply workspace Todos tab visibility on cold boot
Codex gate: boot.js set window._workspaceTodosTab from saved settings but
never called _applyWorkspaceTodosTabVisibility(), so a previously-enabled
Todos tab stayed hidden on a normal page reload (only re-appeared after
opening Settings). Call the visibility helper after assigning the flag in
both the settings-apply and the fallback/default paths.
Co-authored-by: Rod Boev <rod.boev@gmail.com>
* chore(release): stamp v0.51.422 (Release OI) for #3564
---------
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
* fix(#3750): preserve LM Studio auth on reasoning probe
Rebased rodboev's #3837 onto current master (master reworked
resolve_model_reasoning_efforts via #3431 with a combined hermes_cli
import + _nested_route_reasoning_denied guard). Threaded the api_key
resolution + the _lmstudio_model_reasoning_options wrapper + the
HTTP-fallback probe into master's current structure; intent preserved
byte-identically (+164/-24, same as the original PR diff).
Co-authored-by: Rod Boev <rod.boev@gmail.com>
* fix(#3837): gate LM Studio probe credential to configured endpoint + no-redirect opener
Codex security review (CORE) on the rebased #3837: GET /api/reasoning accepts
a caller-supplied base_url, and the new probe attached the configured LM Studio
API key as a Bearer token; urllib also re-sends Authorization across redirects.
Together that could exfiltrate the stored credential to an attacker-controlled
host. (master only escalates here because master probed WITHOUT a key.)
Fixes:
1. Only forward the LM Studio credential when the probe target normalizes to
the configured LM Studio base URL (reuses _normalize_base_url_for_match);
a caller-supplied non-matching base_url is probed keyless.
2. _lmstudio_reasoning_probe_options_fallback uses a no-redirect opener
(_NoRedirectHandler) so a 3xx can't forward the Authorization header onward.
3. Two regression tests: caller-supplied base_url drops the key; redirect is
not followed (credential never reaches the redirect target).
Co-authored-by: Rod Boev <rod.boev@gmail.com>
* fix(#3837): route ALL credentialed LM Studio probes through the no-redirect probe
Codex re-gate caught that the first fix only hardened the built-in fallback;
in production hermes_cli IS importable, so the preferred path called
hermes_cli.models.lmstudio_model_reasoning_options(..., api_key=...) which uses
plain urlopen and still follows redirects with the credential.
Fix: _lmstudio_model_reasoning_options now short-circuits to the built-in
no-redirect probe whenever api_key is set; the hermes_cli path is used ONLY for
keyless probes (no credential to leak). Reworked tests accordingly:
- test_credentialed_probe_never_calls_hermes_cli: credentialed probe bypasses CLI
- test_keyless_probe_logs_signature_mismatch_before_fallback: keyless CLI degrade
- test_credentialed_probe_does_not_follow_redirects_end_to_end: E2E through
resolve_model_reasoning_efforts with real hermes_cli; redirect target never hit
Verified both security tests fail without the guard and pass with it.
Co-authored-by: Rod Boev <rod.boev@gmail.com>
* chore(release): stamp v0.51.420 (Release OG) in CHANGELOG
---------
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Combined light-UX release NW. Both rebased onto current master (were 96 and
247 behind; net 3-dot diffs applied, byte-identical to PR heads). Both reuse
existing renderers (chat image lightbox / chat CSV table renderer).
Review fix (#4025, Codex): the CSV table preview previously stripped the
workspace Edit affordance .csv had when it fell through to the code preview.
Restored: csv mode is editable, renderCsvPreviewContent caches raw content for
the edit textarea, and saving an edited CSV re-renders the table (not markdown).
Verified the full edit round-trip live + regression test.
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Consolidates #4162 + #3944. Aux path: skip the reasoning-disable inject for
routes that 400 on it via _route_rejects_reasoning_extra — hostname-matched
OpenAI / Azure OpenAI / Azure AI Foundry (+ provider aliases openai*/azure*/
azure-foundry/azure-ai*) and OpenRouter Anthropic mandatory-reasoning; keep it
for local/OpenRouter-non-anthropic/other reasoning routes (#4161). Agent path:
rely on the existing agent.reasoning_config={enabled:False} + _build_api_kwargs()
(route-correct per provider profile) rather than re-injecting a generic disable
on top of it; only MiniMax reasoning_split is added (unchanged from master).
Co-authored-by: Rod Boev <rod.boev@gmail.com>
In Transparent Stream mode a live multi-round turn (narrate -> tools ->
narrate -> tools) rendered all assistant prose bunched at the top with
every tool/thinking row clustered below; it self-healed only once the
turn settled.
_syncLiveWorklogReasonsForAnchor() runs on every live segment render
(_flushPendingSegmentRender + the RAF _doRender in messages.js) and
unconditionally built the top-anchored live-worklog rail, mirrored each
round's prose into a wl-reason row there, and hid the real inline
assistant-segment via assistant-segment-worklog-source (display:none).
That prose-folding is the Compact Worklog presentation (#3401) and must
not run in Transparent Stream mode.
Gate the helper on isCompactWorklogMode() so transparent mode keeps prose
as visible, chronologically-placed inline segments interleaved with tool
rows (matching the already-correct settled render). Compact Worklog
folding is unchanged. Verified in an isolated mock-LLM env via an
on-screen visual-position probe (before: textAfterFirstTool=0,
allTextAboveAllTools=true; after: textAfterFirstTool=3,
allTextAboveAllTools=false) and confirmed compact_worklog still folds.
Adds a behavioral regression test asserting the gate is the first
statement in the helper (precedes the rail build), red/green validated.
Two side-by-side PWA standalone windows saturate the browser's per-origin
HTTP/1.1 connection limit, causing 'Request timed out' toasts to cycle every
~30s. The idle-SSE close added in #3992/#3996 keys off the Page Visibility API
(visibilitychange / document.hidden), but a PWA standalone window does not
reliably fire visibilitychange when it loses focus to another window of the
same app -- document.hidden only flips on minimize. So both windows stay
'visible', each holds its two global sidebar SSE streams (session-events +
gateway) open, 2x3 = 6 hits the connection cap, and subsequent fetch() calls
(the 30s background polls) queue behind the saturated pool and time out.
The two global sidebar streams now also close on a sustained window blur (gated
on document.hasFocus(), the signal visibilitychange misses) and reopen --
catching up the session list -- on focus, via a shared _sidebarSseBackgrounded()
predicate and a debounced _installSidebarSseFocusHook().
The per-session live stream (messages.js startSessionStream) is intentionally
left visibility-only: it carries live bg_task_complete toasts and
server_turn_started live-view that an unfocused-but-visible window must still
receive (the OS-notification path is gated on document.hidden, so the in-app
toast is the only completion signal a visible-unfocused window gets). Closing
it on blur would regress the multi-window live-view UX.
Reported by @ddiall with a thorough root-cause analysis.
Co-authored-by: ddiall <ddiall@users.noreply.github.com>