Refined the Codex-CORE fix: the happy-path (already-stored session) guard rejects
only an explicit read_only flag — a stored messaging session already owns its
sidecar, so the messaging-fork risk is specific to the materialize FALLBACK
(which creates a sidecar) where the _is_messaging_session_record check stays.
Also completed test_issue1436's _stub_session MagicMock (read_only=False,
_loaded_metadata_only=False) — bare MagicMock auto-attrs were truthy, tripping
the new read-only guard + _ensure_full_session_before_mutation reload.
Codex caught two data-integrity gaps in the contributor's guard: (1) the
get_session() happy path returned a stored session without checking read_only/
messaging, so an already-imported read-only session could be mutated via
rename/update/move; (2) the fallback only checked cli_meta.read_only, but agent
rows normalize messaging sources WITHOUT setting read_only — materializing a
writable sidecar for a state.db-owned messaging session forks its title/state.
Now reject getattr(s,'read_only') OR _is_messaging_session_record on the happy
path, and cli_meta.read_only OR _is_messaging_session_record(cli_meta) in the
fallback. Replaced the messaging-stub test with 3 regression tests (stored
read-only, stored messaging, messaging cli_meta without read_only flag).
When a session exists in Hermes Agent state.db but has no WebUI sidecar
(SESSION_DIR/{sid}.json), mutation routes (rename, move, update) would
return 404 "Session not found" despite the session appearing in the sidebar.
This mirrors the existing fallback in /api/session/archive:
- Try get_session() first (WebUI store)
- On KeyError, look up CLI metadata via _lookup_cli_session_metadata()
- For messaging/Claude Code (read_only): return 403 instead of silent 404
- For regular CLI sessions: import_cli_session() to materialize sidecar
- Preserve source_tag/raw_source/session_source/etc. for lineage
Routes updated:
- /api/session/rename
- /api/session/update (workspace switch)
- /api/session/move
Refs: #3746 (same class: session discovery vs mutation mismatch),
#3915 (session store empty but data exists in Agent store)
Add HERMES_WEBUI_COOKIE_NAME so multiple WebUI instances sharing a
hostname (different ports) can use distinct auth cookie names. Browsers
scope cookies by host, not host+port (RFC 6265), so same-host instances
otherwise trample each other's `hermes_session` cookie and log users out.
- Resolve the cookie name via _resolve_cookie_name(): env > default,
mirroring the existing _resolve_session_ttl() pattern.
- Keep `hermes_session` as the default for backwards compatibility.
- Validate against the RFC 6265 token grammar; fall back to the default
with a logged warning on empty or malformed values.
- Cover default, override, empty, invalid, and Set-Cookie paths in
tests/test_auth_sessions.py::TestCookieNameResolution.
#3964 [security] gate first-password bootstrap (_set_password on POST /api/settings
while auth disabled) to local clients — blocks remote unauth first-run ownership.
Uses request-start auth snapshot (auth_enabled_before), so no mid-request TOCTOU.
Self-rebased onto v0.51.357 (8-behind, 3-dot fidelity verified byte-identical).
#3970 (oauth single-flight) DROPPED from this stage: Codex+Opus both caught a
check-then-insert race — _pending_oauth_flow_for releases the lock before the
device-code request + flow insertion, so concurrent unauth starts still spawn
multiple workers (Codex empirically reproduced w/ 2 threads). Returned to author
w/ the atomic per-(provider,home) start-lock fix. Re-gating the (N-1) stage.
Co-authored-by: Hinotoi-agent <Hinotoi-agent@users.noreply.github.com>
- _load_models_cache_from_disk: resolve _get_models_cache_path() once
(was called twice — exists() then open()) (greptile P2).
- conftest: use a _MISSING sentinel for the _active_profile restore guard so
it restores whenever api.profiles was importable pre-test, independent of the
value (greptile P2; _active_profile defaults to 'default' so the prior
is-not-None guard was functionally fine but technically value-dependent).
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Codex + Opus both independently caught a CORE gap in the first cut: the
bounded /api/models rebuild runs on a detached 'models-catalog-rebuild' daemon
thread that inherits neither the request-profile thread-local (#798) nor
os.environ. So on a non-default profile the worker probed the DEFAULT profile's
credentials and, when the 4s budget was exceeded, published the rebuilt catalog
to the DEFAULT profile's disk cache (cross-contamination) — exactly the slow
path a non-default cold rebuild takes.
Fix:
- profile_scope_for_detached_worker(profile_name): sets the request-profile TLS
AND applies the profile .env on the worker thread, restoring both on exit
(no-op for default). Distinct from profile_env_for_active_request (which reads
the current thread's TLS and must not clear it).
- get_available_models() captures the active profile on the request thread and
wraps the rebuild worker body (probe + over-budget publish + disk save +
fingerprint) in that scope; the legacy synchronous rebuild applies the profile
env on the foreground. /api/models route no longer wraps (the work moved into
get_available_models so ALL callers — chat/start, resolution — are fixed).
- 2 new regression tests incl. the worker-thread before/inside/after assertion.
Empirically verified: a fresh worker thread resolved models_cache.json/default
WITHOUT the scope (the bug) and models_cache.work.json + the work .env + the
work auth.json WITH it.
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
On a non-default profile, Settings → Providers timed out and the model
picker showed only the default profile's models. WebUI profile switching is
per-client/cookie-scoped (#798), but two read-only paths resolved from the
process-global default profile:
- Facet A: /api/providers + /api/models did not apply the active profile's
.env around the read, so get_auth_status() / provider_model_ids() / custom
key lookups resolved the default profile's credentials. On a non-default
profile the auth probes could stall past the 30s frontend abort.
- Facet B: the /api/models disk cache was a single import-time
STATE_DIR/models_cache.json shared across every profile, while the cache
fingerprint is profile-specific -> a non-default profile rejected the shared
snapshot every read and cold-rebuilt (the slow serial-probe path).
Fix:
- api.profiles.profile_env_for_active_request(): applies the active
per-request profile's .env for the duration of the read (delegates to the
existing profile_env_for_background_worker used by streaming). No-op for the
default/root profile, so single-profile deployments are byte-identical.
- api.config._get_models_cache_path(): profile-keys the disk cache filename
(models_cache.<profile>.json) derived from the default path; default profile
keeps models_cache.json unchanged (no file migration).
- routes.py: wrap both GET handlers in profile_env_for_active_request.
- conftest: restore api.profiles._active_profile + clear request-profile TLS
after each test (a pre-existing isolation hole that profile-keyed cache
paths newly surface under sharding).
Tested: 10 new regression tests; live before/after on isolated servers shows
a non-default profile now surfaces its configured provider (deepseek) with
its own credentials + its own cache file, where master showed only the
default profile's providers and one shared cache.
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Adds an opt-in, default-off, desktop-only floating panel that lists the user's
questions in a conversation as a numbered jump-list (click to scroll + flash the
message). Gated behind a Settings → Preferences toggle (show_conversation_outline)
wired through the existing show_* preference boot/load/autosave/save paths.
Review follow-ups applied on absorb:
- Outline is now strictly chat-only: leaving the chat view (Settings, Tasks,
Insights, …) hides the toggle button AND closes the panel; returning to chat
restores the toggle (panel stays closed until re-opened). Implemented by gating
_outlineAllowed() on the active panel and re-evaluating via a MutationObserver on
the <main> showing-<panel> class (switchPanel is a global fn declaration that
can't be reliably wrapped from this script).
- Fixed a latent visual bug: #outlinePanelWrapper{display:flex} (id selector)
outranked the UA [hidden]{display:none}, so wrapper.hidden=true never actually
hid the panel — the × close button and auto-close had no visual effect. Added
#outlinePanelWrapper[hidden]{display:none;}.
- Regression tests for both.
Closes#2124.
Co-authored-by: Rod Boev <rod.boev@gmail.com>
_resolve_compatible_session_model_state() no longer reverts an explicit
@provider:model selection to the default when the provider's group is missing from
the cached catalog snapshot. explicit picks always honored; non-explicit (2nd+ turn
/ chat switch) preservation requires the provider to be KNOWN/CONFIGURED via the new
_provider_is_known_or_configured() (static registry + custom-provider config, NOT the
cold catalog) — so a cold live-discovery provider (ollama-cloud/deepseek/xai) is
preserved while a genuinely-unknown provider (@removed:...) falls through to
default-repair. A known-but-unconfigured builtin is deliberately preserved (surfaces
a clear runtime auth error rather than a silent swap; a cheap env/config credential
check would mis-classify OAuth/auth-store providers). Keeps the #3867 cached-catalog
hot path intact.
Co-authored-by: starship-s <starship-s@users.noreply.github.com>
Adds a server-side run-journal live snapshot (_run_journal_live_snapshot) returned
in GET /api/session as runtime_journal_snapshot, so a FRESH client (another device,
or a tab with no in-memory snapshot) opening an in-progress session immediately sees
the already-streamed assistant text + tool cards rebuilt from the server. Composes
with the existing _replay_run_journal cursor path (seeds lastRunJournalSeq so replay
resumes from the snapshot cutoff, not duplicating it) and keys tool cards by the same
5 id aliases (tid/id/tool_call_id/tool_use_id/call_id) as #3763 so SSE replay replaces
rather than duplicates snapshot cards. Payload values truncated; redaction test added.
Co-authored-by: t3chn0pr13st <technopriest@live.ru>
When SESSION_DIR has no session .json files and the index is empty/absent,
print_startup_config() now scans sibling state directories and prints a diagnostic
pointing the user at the populated sibling + the HERMES_WEBUI_STATE_DIR to set —
fully fail-safe (try/except, warning-only, no behavior change).
Closes#3915.
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Extract _build_partial_message() shared by cancel_stream() and the new
_snapshot_and_append_partial_on_error(); the two provider/exception error paths
in _run_agent_streaming now snapshot accumulated text/reasoning/tool_calls under
STREAMS_LOCK and append a _partial assistant message instead of discarding it.
Closes#3929.
Co-authored-by: b3nw <150195942+b3nw@users.noreply.github.com>
Two distinct timeout causes, both surfacing as the client's 30s 'Request timed
out' toast with no server-side signal:
A) /api/session/move acquired the per-session agent lock with a bare unbounded
'with _get_session_agent_lock(sid):'. The streaming thread holds that same
lock during checkpoint saves; on slow file I/O (WSL/DrvFs) the move could
block past the client abort. Now acquires with timeout=5 and returns HTTP 503
on contention (lock kept, not dropped, since s.save() still races the writer).
B) /api/projects/delete unlinked every assigned session via get_session()+save()
— O(N) full-messages reserialize. For an actively-streaming session we now
clear project_id on the LIVE CACHED Session object under LOCK (the streaming
thread persists it on its next save — the worker always does a final save at
turn completion) instead of issuing a competing s.save(); falls back to a
direct save when not cached. Non-streaming sessions unchanged.
Also guards the '+ New project and move' shortcut (sessions.js) against the new
503 so it shows a toast instead of an unhandled rejection, keeping the #2551
authoritative refetch in both the success and catch paths.
Adds tests/test_issue3746_session_move_delete_timeout.py (behavioral lock-timeout
test + structural guards for both handlers + the frontend 503 guard). Widened the
#2551 new-project-refetch test's fixed byte-window to a block-scoped search so the
try/catch wrap (which preserves the refetch) doesn't trip a brittle offset assertion.
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
A bare 'custom' provider with a remote base_url is a vendor-routing proxy
(LiteLLM, Bedrock gateway, etc.). A slashed model id like bedrock/opus-4-6 is
intrinsic — the proxy routes on the full string. The prior heuristic stripped
ANY known-provider prefix (bedrock is also a provider name), truncating it to
opus-4-6 and causing 403 'model not allowed'.
Fix: in the custom-base_url branch, strip the prefix only when (a) the configured
provider is a real first-party provider pointed at an OpenAI-compatible proxy
(provider=openai + proxy base_url — the #433 path), OR (b) for a bare 'custom'
provider, the bare id is genuinely a first-party model of that prefix's catalog
(openai/gpt-5.4 -> gpt-5.4, since gpt-5.4 IS an OpenAI model). An intrinsic
routing prefix whose bare id is NOT first-party of that namespace is preserved
(bedrock/opus-4-6 stays whole). Unknown prefixes (zai-org/...) preserved as before.
Both prior behaviors work in tandem with the fix:
- #433 sprint40: bare custom + openai/gpt-5.4 -> gpt-5.4 (redundant, strip)
- #433 model_resolver: provider=openai + google/gemma -> gemma (proxy strip)
- #548: custom + zai-org/GLM-5.1 -> preserved (unknown prefix)
- #1625: custom + loopback base_url -> preserved (local server)
- #3872: custom + bedrock/opus-4-6 -> preserved (intrinsic vendor prefix)
Adds regression tests covering all of the above.
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: haolf000 <haolf000@users.noreply.github.com>
Absorbs contributor PR #3809 (@b3nw), rebased onto fresh master (was ~20 behind,
panels.js conflict resolved by merging the new !isNoAgent skill-tags guard with
the model-select call).
Adds a Model Override dropdown to the Tasks scheduled-jobs create/edit form,
populated from /api/models grouped by provider, persisting model+provider,
clearable to default, disabled in no-agent mode. Surfaces hermes-agent's existing
per-job model override (CLI parity).
greptile P1s (override cleared on fast-save / on API failure) verified
ALREADY-FIXED in PR head; also applied an Opus UX hardening (keep the model
select disabled on a failed /api/models load so the user can't think they
cleared the override). UX approved by Nathan via screenshots.
Pre-merge fixes:
- i18n: the PR added the 3 cron_model_* keys to all locales but left 10 of them
as 'TODO: translate' English stubs (only es was done), tripping
test_zh_hant_locale. Provided real translations for de/zh/zh-Hant/ru/ja/fr/pl/
it/pt/tr.
- test isolation: #3809's new test file shifts pytest-shard composition so
test_issue2863's background-rebuild test ran after a test that leaves the
#3884 _SESSION_INDEX_REBUILD_THREAD globals populated, suppressing the fresh
thread it asserts on. Made that test hermetic (joins+clears the rebuild-thread
globals up front) so it passes regardless of shard run order.
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: b3nw <b3nw@users.noreply.github.com>
* Release v0.51.344 — Release LH (sidebar fork-lineage grouping #3799/#3884)
Absorbs #3884 (@rodboev): manual forks are kept as sidebar lineage boundaries
so a forked session isn't collapsed under a compression-continuation root,
while enriched child-session rows stay independently visible until the later
attachment pass. Also addresses the greptile TOCTOU flag: the background
index-rebuild thread now pins + re-checks its (SESSION_DIR, SESSION_INDEX_FILE)
target under _SESSION_INDEX_REBUILD_LOCK before writing.
Rebased onto fresh master, content byte-identical to PR head, full-suite +
Codex + Opus gated.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* fix(models): propagate target kwargs in index-rebuild fallback (Opus SHOULD-FIX)
Opus advisor stage-344: the _write_session_index fast-path fallback recursed
with _write_session_index(updates=None) and no kwargs, falling back to the
global SESSION_DIR. Safe today (the only kwargs-caller passes updates=None and
never reaches the fast path) but the invariant was implicit. Propagate the
resolved session_dir/session_index_file so a target-scoped rebuild falls back
to that same target.
---------
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Phase-1 low-risk batch, each rebased onto fresh master + gated fresh:
- #3883 (@rodboev, #3740): sidebar refreshes a stale message_count:0 index row
from its sidecar when user_message_count>0 + sidecar mtime newer than index,
self-healing the interrupted-stream stale-count case beyond compression lineage.
- #3878 (@rodboev, #3833): manual workspace refresh clears the dir cache and
re-fetches expanded descendants so background-written files become visible.
- #3880 (@koshikai): translate the 11 remaining English strings in the ja locale.
greptile flags evaluated: #3878 P1 relative-path + P2 stale-comment already fixed
in PR head; #3883 P2 missing-snapshot-test already covered by the PR's own
test_all_sessions_refreshes_stale_zero_count_snapshot_row_from_sidecar, P2
double-stat is a bounded cheap micro-opt (FOLD); #3880 'needs screenshots'
rejected (in-place translation of existing keys, no UI shape change).
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Co-authored-by: koshikai <koshikai@users.noreply.github.com>
* fix(session): retire stale truncation watermark on new committed turn (#3831)
retry_last / undo_last / the Edit-truncate handler set truncation_watermark
to suppress the *replaced* tail from the append-only state.db merge.
Session.save() deliberately never auto-clears it (#2914), but nothing retired
it when the user then sent a genuinely NEW turn either — so it froze at the old
edit boundary. A frozen watermark then dropped post-watermark state.db rows
whenever the sidecar was later reconstructed empty (recovery/reconcile),
permanently losing the turns sent after the edit (state.db still had them).
Retire a POSITIVE watermark to None once the new user turn is COMMITTED to
session.messages — at the success-merge (3 sites), eager-checkpoint, error/
recovery materialization, and cold-load repair commit points. Not at chat-start:
in deferred mode the new row isn't in messages yet, so a merge in that window
would resurrect the replaced tail (the max-sidecar guard hasn't risen past the
old boundary). Once committed, max_sidecar_timestamp rises past the replaced
tail and the merge suppresses it without the watermark, so retiring is safe.
Cleared to None, never 0.0 — 0.0 is the truncate-to-empty sentinel (#2914) that
must keep blocking all state replay, so the clear is falsy-gated.
Closes#3831
* chore(changelog): clarify watermark-retirement timing to commit-time
Greptile review noted the original phrase "retires the watermark at the
start of a new user turn" was timing-imprecise. The retirement actually
fires when the new turn is durably committed to session.messages —
at the agent-result merge, the eager user-message checkpoint, or the
cold-load recovery commit. Reword for accuracy; semantics unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(#3831): add regression tests for the two inline watermark-clear paths (greptile P2)
Cover the error/cancel materialization path (_materialize_pending_user_turn_before_error)
and the eager first-turn checkpoint path (_checkpoint_user_message_for_eager_session_save),
which inline the falsy-gated watermark clear instead of calling the tested helper.
The error path is precisely the #3831 failure mode (recovery/reconcile after a
crash), so a dedicated regression test closes that gap. Both assert a positive
watermark clears to None while the 0.0 truncate-to-empty sentinel (#2914) is
preserved.
---------
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* fix(sidebar): prime idx_messages_session before CLI-session scan (#3887)
The sidebar's CLI-session scan (read_importable_agent_session_rows) orders
candidate sessions by a correlated MAX(timestamp) subquery over messages,
which collapses to a full per-session table scan when the agent's standard
idx_messages_session ON messages(session_id, timestamp) index is absent.
A normally-migrated hermes-agent state.db has it; a db that lost its
migrations (older hermes-agent, or a hand-rebuilt/reimported db) does not,
and /api/sessions then stalls for seconds on every refresh (the 5s cache
TTL never settles -> repeating 'Slow WebUI request still running' warnings).
Prime the index with CREATE INDEX IF NOT EXISTS before the scan: a no-op
when it already exists, a ~20ms self-heal otherwise (measured 13.3s ->
0.009s on a no-index 8k-session db). Best-effort: degrades silently on a
read-only db, a locked db, or a minimal schema without a timestamp column.
Closes#3887
* test(#3887): skip read-only-db test under root (greptile P2)
Root bypasses POSIX permission bits, so chmod 0444 doesn't make the file
read-only for root — the prime would succeed and the test would validate the
wrong path, giving false confidence on root-run CI. Skip under root; the
production handler's except sqlite3.Error: pass covers the read-only/locked/
corrupted/older-schema cases regardless.
---------
Co-authored-by: nesquena-hermes <[email protected]>
* stage bg_task trio combined (#2979 superset) on master for deep review
* fix(bg_task): unsubscribe SessionChannel on header-write failure (Codex deep-review catch) + regression test
* test: realign on-subscribe-recovery anchor to subscribe_to_session_channel after leak fix
* CHANGELOG: bg_task trio as v0.51.340 LD (HELD pending independent review)
* bg_task trio: apply 3 independent-review (greptile) fixes
1. start_session_turn now threads the session PROFILE model defaults
(_read_profile_model_config) into the wakeup model-resolve, so a brand-new
session with an empty model falls back to the profile default not global
DEFAULT_MODEL. Updated the white-box spy test signature accordingly.
2. /api/session/stream omits the Connection header (HTTP/1.1 keep-alive
default) to match the #3103 long-lived-SSE pattern.
3. Reaper now prunes _LAST_EMIT_TS for collected sessions so the coalesce
timestamp map can't grow one permanent entry per session forever.
nesquena APPROVED the PR; these are the 3 non-blocking greptile suggestions.
* test: realign _start_session_turn adapter stub lambda to new profile-defaults signature
* feat(composer): add saved prompts library with per-profile storage (#2732)
* fix(composer): move saved-prompts popup out of .composer-left to preserve DOM test (#2732)
* fix(composer): correct ARIA roles, add server-side prompt limits (#2732)
* fix(composer): surface save-prompt errors instead of silent success toast (#2732)
* Release v0.51.338 — Release LB (saved prompts library, #3571)
Composer saved-prompts library (@rodboev): bookmark button → popup of saved
prompts; click to insert, save current input, delete. Persists to
$HERMES_HOME/webui/saved_prompts.json with server-side caps (8000 chars / 200).
Maintainer work (per Nathan): conditions were (a) verify it actually works and
(b) hide on mobile. Both met:
- Live-verified load/save/delete all persist through the UI.
- Added mobile-hide (#btnSavedPrompts,.saved-prompts-popup display:none in the
@media max-width:640px + 900px composer blocks). DOM-verified visible at 1280px,
vision-confirmed absent from the composer at 390px.
- Added missing Polish (pl) i18n for the 5 saved_prompts_* keys (PR had en+others
but not pl — failed locale-parity).
- Added tests/test_issue3571_saved_prompts.py (mobile-hide + caps + wiring guards).
Full suite green, ESLint/scope-undef CLEAN, Opus SHIP-safe (auth-gated, CSRF,
XSS-safe, sane caps), Codex SAFE-TO-SHIP.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
---------
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* fix(streaming): make per-token inline-thinking extraction linear (#3633 follow-up)
Codex post-merge perf catch on #3633: _parseStreamState() and
syncInflightAssistantMessage() call _extractInlineThinkingFromContent on the
FULL accumulated assistantText on EVERY streamed token. The #3633 rewrite made
that a full char-by-char walk, so cost was O(n^2) over a stream — a Node harness
measured ~88s (no-tag) / ~103s (leading <think> block) for 2000x100-char tokens,
which would freeze the main thread on long reasoning-model responses.
Two fixes (Python api/streaming.py + JS static/messages.js twin, line-for-line parity):
1. Fast path: if the text contains no complete thinking opener AND (when
streaming) its tail is not a prefix of an opener, return unchanged without the
char walk — two cheap substring scans. Handles the common no-tag case.
2. Bulk-skip plain trailing content: track the next complete opener via
str.find/indexOf (_next_inline_thinking_opener / _nextThinkingOpener); once no
opener remains ahead, append the remainder and stop instead of walking it
(streaming still suppresses a trailing partial-opener prefix). Handles the
leading-block-then-long-answer case.
Result: ~88s/103s → ~0.5s/0.9s (Python), ~0.18s/0.21s (JS). All behavioral cases
(persist + streaming, code-awareness, position-aware unclosed, leading whitespace)
verified unchanged in both twins. Added a per-token streaming perf regression test
and wired _nextThinkingOpener into the node driver harness.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* fix(streaming): perf bulk-skip must respect code context for partial-opener tails
Codex catch on the perf fix: the no-complete-opener bulk-skip suppressed a
trailing partial opener (e.g. '<thi') unconditionally during streaming, but a
partial opener INSIDE inline-backtick / fenced / indented code must stay visible
(master parity). Now, when streaming and the tail is a partial opener, fall
through to the code-aware char walk (bounded — a partial tail is a transient
single token) instead of bulk-skipping; only a PLAIN-text partial opener is
suppressed as a forming block. Added _text_tail_is_partial_opener /
_textTailIsPartialOpener (Python + JS parity) + regression tests for the
inside-code vs plain partial-tail cases.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* docs(changelog): v0.51.336 KZ — inline-thinking streaming perf fix
---------
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Co-authored-by: Hermes Agent <hermes-agent@nesquena-hermes.local>
* fix(streaming): normalize inline thinking extraction across live and persisted turns (#3599)
# Conflicts:
# api/streaming.py
# static/messages.js
# static/ui.js
* fix(streaming): code-aware inline-thinking extraction + position-aware unclosed handling
Codex deep-review caught two regressions in the leading-only -> full-scan
rewrite (both silent data-mangling on the persist/reload path):
1. Code-span unawareness: the scanner only protected triple fences, so a
literal <think> in an inline single-backtick code span or an indented
(>=4-space/tab) code block got silently extracted into reasoning. Added
_inline_thinking_indented_code_at + inline-backtick tracking (Python +
the JS twin _thinkingIndentedCodeAt), so all three code contexts now keep
thinking tags visible.
2. Unclosed-tag truncation: any unmatched open tag moved the trailing prose
into reasoning. Now position-aware — a LEADING unclosed block (cut off
mid-thought) is still reasoning (#3455 intent), but an unclosed tag AFTER
visible content stays visible so literal typed tags don't truncate prose.
Gated partial handling on the previously-unused options.streaming param
(live streaming keeps 'still thinking' behavior; persist/reload does not).
Updated 2 tests that pinned the buggy behavior + added 4 regression tests
(inline-backtick, indented-code, mid-body-unclosed-visible, leading-unclosed-
extracted). Updated the node driver harness to include the new helper.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* fix(streaming): recognize fenced code blocks indented 1-3 spaces
Codex round-3: a fence indented 1-3 spaces is valid Markdown but the fence
detector only matched at column 0, so a literal think tag inside such a fence
(not 4+-space indented code either) was still extracted. Both detectors
(_inline_thinking_fence_marker_at / _thinkingFenceMarkerAt) now walk back over
up to 3 leading spaces to a line start. Added backtick + tilde indented-fence
regression tests.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* fix(streaming): O(n) inline-thinking scan + merge separate reasoning on reload
Round-4 Codex deep-review caught two real issues in my own fixes:
1. PERF (O(n^2)): the indented-code check (_inline_thinking_indented_code_at /
_thinkingIndentedCodeAt) scanned to line boundaries at EVERY character index,
plus the leading check sliced+stripped the whole prefix per unclosed tag. On
long no-newline content this was quadratic (~8.4s @ 200k, called repeatedly
on the streaming path). Replaced with incremental O(1)-per-iteration line
state (_line_is_indented_code / _lineIsIndentedCode evaluated only at line
starts) + a seen_nonspace flag. 200k now extracts in ~55-140ms.
2. RELOAD reasoning-drop: renderMessages() seeded the shared extractor with ''
so a message with BOTH an inline <think> block AND a separate m.reasoning
payload showed only the inline part — the separate payload was dropped
because the !thinkingText worklog resolution was then skipped. Now seeds with
the message's direct reasoning (m.reasoning_content||m.reasoning||...) so the
two MERGE (deduped); separate-only reasoning is preserved without promoting
it into visible prose.
Python + JS twins kept line-for-line parity. Added merge + perf + reload
regression tests; updated the reload structure test and the node driver harness
for the renamed helper.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* fix(streaming): revert reload reasoning-seed; keep O(n) perf fix
Codex round-4 finding #2 (seed renderMessages' inline extractor with
m.reasoning so a separate payload merges) turned out to VIOLATE a deliberate
architectural invariant pinned by test_issue2565 +
test_sprint42: the reload content-extraction path must NOT touch
m.reasoning/m.reasoning_content — reasoning metadata is owned exclusively by
the Worklog Thinking Card path (_worklogReasoningTextFromMessage /
_assistantReasoningPayloadText), never conflated with inline-content
extraction (which would risk promoting provider reasoning into final-answer
prose). Reverted the ui.js seed to the PR's original `thinkingText` arg.
The inline+separate merge is still a genuine extractor capability (exercised
by the live streaming path via liveReasoningText) and is covered by a unit
test, just not invoked from the reload render path by design.
The O(n) perf fix (finding #1) and the code-awareness + position-aware
unclosed handling (rounds 1-3) are all retained.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* fix(streaming): only lstrip extracted content when a leading block was removed
Codex round-5 catch: the extractor unconditionally lstripped the final content
(.lstrip() / .replace(/^\s+/,'')) even when NO thinking block was extracted, so
an assistant reply that legitimately starts with an indented code block or blank
lines lost its leading whitespace on live display, reload, and persistence. This
was a real regression vs master (master returned non-thinking content unchanged).
Now track leading_removed (set only when a LEADING thinking block/prefix is
actually extracted) and lstrip only in that case. Mid-body / no-thinking content
keeps its exact leading whitespace. Python + JS twins kept in parity; added
backend regression tests (indented-first preserved, leading-blank preserved,
leading-think still strips).
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* fix(streaming): reconnect restore prefers raw inflight accumulator
Codex round-6 CORE catch: on reconnect, the single-live-message restore used
(_liveInflightAssistant.content || ''). Because the PR now splits a leading
unclosed <think> into empty content, restoring from the split content dropped
the open tag — so a later </think> token leaked into the visible reply and
corrupted the live accumulator. Restore from
(_fullInflightAssistant || _liveInflightAssistant.content || '') so the raw
open tag survives reconnect and the accumulator stays correct. Added a
reconnect-restore regression test.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* Release v0.51.335 — Release KY (normalize inline thinking extraction, #3633)
Unify inline-thinking (<think>/<|channel>/<|turn|>) extraction across live,
reload, and persisted turns (#3599/#3633, @rodboev). Deep-reviewed: Opus +
6 Codex rounds; maintainer fixes resolved every Codex finding — code-awareness
(inline-backtick/indented/1-3-space fences keep literal tags visible),
position-aware unclosed handling, O(n) line scanning (was O(n^2) on long
content), conditional lstrip (preserve leading whitespace when no leading block
removed), and a reconnect-restore CORE fix (raw accumulator preferred so an open
<think> tag survives reconnect). Python + JS twins in parity. Full suite 8330,
Opus SHIP-SAFE, Codex SAFE-TO-SHIP, ESLint/scope-undef/ruff clean.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
---------
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: Hermes Agent <hermes-agent@nesquena-hermes.local>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Backfill docstrings for api/oauth.py + api/kanban_bridge.py (51 functions, verified accurate to current behavior). Conflicted files dropped for follow-up. Docstring-only, no behavior change. Full suite 8275, CI 11/11. Co-authored-by: camr <camr@users.noreply.github.com>
#3814: O(n) index-count map for /api/sessions. #3815: skip backup-less sidecars on startup recovery. Full suite 8275, Codex SAFE, Opus SHIP, CI 11/11. Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
Phase-3-light. #3790 (@ai-ag2026): expand cold-load transcript window to ~msg_limit renderable rows so tool-heavy sessions don't open showing 1-2 messages. Codex CORE fix: explicit expand_renderable flag (cold-load only; Load-earlier keeps raw cap). Also fixed a recurring CI timing flake (git-parallel test → deterministic Barrier). Full suite 8228, Codex SAFE, Opus SHIP, CI 11/11. Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
Phase-3-LOW backend security fix. #3727 (@rodboev, #1909): enforced CSP now honors the same connect-src as report-only (built from one shared template). Full suite 8205 passed, Codex SAFE (live-verified headers), Opus SHIP. Co-authored-by: rodboev <rodboev@users.noreply.github.com>
list_profiles_api() builds rows from cheap upstream helpers and skips find_alias_for_profile (which reads ~2.4GB of binaries from ~/.local/bin per dropdown open), with a 4s TTL cache + graceful fallback. 4700ms->250ms cold, 0ms cached. Full suite 8183 passed, Codex SAFE, Opus SHIP-IT. Supersedes #3772.
Fix-ourselves pickup of #3774 (@bambalados). _purge_agent_pycache() before os.execv() in _schedule_restart() so the re-exec'd process recompiles freshly-pulled source — fixes AttributeError on first chat after self-update. Full suite 8180 passed, Codex SAFE, Opus SHIP-IT. Co-authored-by: bambalados <bambalados@users.noreply.github.com>
* fix(security): gate /api/onboarding/complete on the local-network check (#3765)
Sibling-path gap surfaced by the #3758 release gate. /api/onboarding/oauth/start,
/setup, and /probe are gated by _onboarding_gate_allows(), but
/api/onboarding/complete was not — it called complete_onboarding() unconditionally
(persists onboarding_completed=True, which hides the first-run wizard). On a
passwordless public bind, an unauthenticated no-Origin POST passes generic CSRF
and could flip the wizard off.
Pre-existing (the endpoint was ungated before #3758 too; #3758 only refactored the
three already-gated siblings). Low severity — it toggles a UI flag, not credentials
or access — but the inconsistency is a real hole, so close it the same way as its
siblings.
- Gate /api/onboarding/complete with _onboarding_gate_allows() → 403 when denied.
- Regression tests: public client (no forwarded headers) → 403 + complete_onboarding
NOT called; loopback client → 200; auth-enabled → 200.
- Mark the legacy _is_local_from_handler mirror in test_onboarding_network.py as a
STALE pre-#3758 contract (it trusts unauthenticated XFF); the authoritative
trust-matrix tests live in test_security_review_fixes.py. Migrating the mirror to
delegate to the real helper is tracked as follow-up test debt, out of scope here.
* docs(changelog): stamp v0.51.308 — Release JX (#3765 onboarding-complete sibling-consistency gate)
---------
Co-authored-by: nesquena-hermes <[email protected]>
* fix(security): ignore spoofable forwarded IPs in onboarding gate + make update-check CSRF-safe (#3758, partial)
Ships the two unambiguous slices of #3758's security review. The two slices with
breakage risk for existing installs — the Docker-default public-bind-requires-auth
gate and removing /tmp from the /api/media allowed roots — are held for separate
review/decision.
Onboarding forwarded-IP spoof hardening (+ release-gate CORE fix):
- The unauthenticated first-run onboarding local-network gate now IGNORES
X-Forwarded-For / X-Real-IP by default (a direct client can spoof them to a
private/loopback address to bypass the gate), trusting them only when
HERMES_WEBUI_TRUST_FORWARDED_FOR=1 is set behind a trusted proxy (rightmost
proxy-appended hop).
- Release-gate (Codex) CORE catch + refinement: when forwarded headers are
present but untrusted, the header is ignored and locality is judged by the raw
socket — but a PRIVATE/LAN raw socket (a separate proxy box that could forward
an arbitrary public client) is no longer treated as local; only a LOOPBACK raw
socket is (genuine same-host; a remote attacker can't forge a 127.0.0.1 TCP
source). This closes the new fail-open the initial refactor introduced (public
client behind a LAN proxy read as local) while preserving genuine same-host
onboarding. LAN-proxy operators must set HERMES_WEBUI_TRUST_FORWARDED_FOR=1.
Regression tests lock the full matrix (spoof-block, LAN-proxy-deny,
loopback-allow, trusted-proxy-rightmost-hop, direct-public-deny).
- Three duplicated inline gate blocks unified into _onboarding_gate_allows /
_onboarding_request_is_local; ONBOARDING_OPEN normalized to canonical truthy
values via _truthy_env.
Update-check CSRF hardening:
- GET /api/updates/check is cache-only (cached_update_status(): no network/git
mutation); forced refresh moves to POST /api/updates/check {force:true}; both
frontend call sites updated and the test_api_timeout contract assertion updated.
- cached_update_status() preserves cached agent info when include_agent re-enabled.
Docker log masking: ENV_OBFUSCATE_PART also masks PASSWORD/SECRET/CREDENTIAL/COOKIE/SESSION.
Held for separate review (NOT in this PR): public-bind-requires-auth startup gate
(server.py + Dockerfile default) and the /api/media /tmp-root removal.
Co-authored-by: fantasticsquirrel <[email protected]>
* docs(changelog): stamp v0.51.307 — Release JW (stage-a3 #3758 partial)
---------
Co-authored-by: nesquena-hermes <[email protected]>
* fix(sessions): resolve branchy compression lineage to the freshest tip (#3751)
Compression parents can have multiple continuation-looking children when a stale
segment is resumed after a newer compressed branch already exists. The previous
projection followed the newest DIRECT child only, so it could hide the deeper
branch with the latest real activity and make a conversation look missing/stale
after compaction or session rotation.
- compression_tip() now walks ALL reachable continuation descendants (DFS) and
selects the freshest importable (messageful) tip by (last_activity, depth).
- read_session_lineage_metadata() expands descendants from the materialized
ancestors via the parent index (scoped, 20-hop cap), pulls per-session message
stats from the messages table, and exposes a canonical _lineage_tip_id so the
WebUI sidebar collapse picks the same tip as the projection.
Backward-compat hardening (two release-gate Codex findings, both fixed + tested):
The new message-stats / tip-scoring code must not raise on older/minimal or
non-standard state.db schemas, which previously collapsed the whole projection:
- messages table with NO `timestamp` column → MAX(timestamp) raised in SQL →
read_session_lineage_metadata returned {} (lost all lineage metadata).
- ISO-8601 TEXT messages.timestamp → float()/raw comparison raised TypeError;
in read_importable_agent_session_rows that propagated through compression_tip
and get_cli_sessions() swallowed it, hiding ALL imported agent rows.
Fixes:
- PRAGMA table_info(messages) detection: require session_id, only SELECT
MAX(timestamp) when present (else NULL + COUNT only); fall back to message_count.
- new _as_score() helper (first numerically-coercible value, else next
candidate e.g. started_at) used at EVERY tip-scoring / sort site in both
compression_tip() and freshest_continuation_tip(), plus the projection sort.
- regression tests: lineage metadata survives REAL/absent/TEXT messages.timestamp,
and read_importable_agent_session_rows survives a TEXT timestamp (no empty hide).
Co-authored-by: ai-ag2026 <[email protected]>
* docs(changelog): stamp v0.51.306 — Release JV (stage-a2 #3751)
---------
Co-authored-by: nesquena-hermes <[email protected]>
* refactor(sessions): add dormant JSON-backed SessionDB adapter (#3720, #3383)
First, lowest-risk slice of the unified-session-db migration: a SessionDB-shaped
adapter over the existing WebUI JSON store, behind a dormant experimental flag
(experimental.unified_session_db, default false). No runtime call site is rewired
— is_unified_session_db_enabled() has no live callers, so persistence behavior is
unchanged until a later migration PR opts in. Includes the adapter, the dormant
config flag + _apply_config_defaults wiring, an architecture doc, and adapter tests.
Also adds docs/architecture/ to the .gitignore docs allowlist (the docs/* rule
excludes subdirectories; the new architecture doc and its presence test would
otherwise be silently dropped on a clean checkout).
Co-authored-by: rodboev <[email protected]>
* docs(changelog): stamp v0.51.305 — Release JU (stage-p2b #3720)
---------
Co-authored-by: nesquena-hermes <[email protected]>
* fix(terminal): reap reparented terminal descendants by process group (#3725, #2577)
Embedded-terminal descendants reparented to the WebUI process could linger as
zombies. The reaper now calls os.waitpid(-terminal_pgid, WNOHANG) scoped to the
terminal's own process group (terminals spawn with start_new_session=True, so
proc.pid == pgid) rather than process-wide waitpid(-1), which would otherwise
reap unrelated WebUI subprocess children and silently coerce their exit codes to
0. Bounded by a 64-iteration limit and lock-guarded. Runs on reader cleanup and
terminal close.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* docs(docker): add opt-in GPU runtime image path (#3721, #3243)
The default image stays CPU-only. A new INSTALL_GPU_LIBS=1 build arg installs
VA-API user-space libraries for users passing through host GPU devices, and
docker_init.bash preserves Docker --group-add supplemental groups (e.g. render/
video for /dev/dri) when dropping privileges to the runtime user. Default
(INSTALL_GPU_LIBS=0) is a no-op. Docs + regression test included.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* docs(changelog): stamp v0.51.304 — Release JT (stage-p2a #3725#3721)
---------
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* fix(cron): toggle run output rows instead of re-fetching when already open (#3732)
_loadRunContent() only ever expanded, so clicking an already-open cron run row
re-fetched its content pointlessly. It now toggles: an open row collapses (clears
the expansion state + resets the toggle button) and returns early, avoiding the
redundant API call.
Co-authored-by: mysoul12138 <mysoul12138@users.noreply.github.com>
* feat(config): expand ${VAR} references in config.yaml at load time (#3736)
hermes-agent already expands ${ENV_VAR} in config.yaml, but the WebUI's own
loader stored the raw dict, leaving literal ${...} strings. Recursively expand
${VAR} against os.environ on both config load paths (reload_config and
_load_yaml_config_file); unset vars are left untouched (${VAR} preserved).
Co-authored-by: Carry00 <Carry00@users.noreply.github.com>
* fix(security): anchor untracked-file deletes in git_discard (#3702)
git_discard(delete_untracked=True) used raw shutil.rmtree / Path.unlink after a
separate safe_resolve_ws validation, leaving a validation-to-use symlink-swap
window. Route untracked deletes through the anchored helpers (rmtree_anchored /
unlink_anchored) so a swapped path component is rejected at delete time; preserve
the prior missing_ok tolerance for benign concurrent-removal races. Adds
regression coverage for both the symlink-swap block and the concurrent-missing case.
Co-authored-by: Hinotoi-agent <Hinotoi-agent@users.noreply.github.com>
* docs(changelog): stamp v0.51.303 — Release JS (stage-p1a #3732#3736#3702)
---------
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: mysoul12138 <mysoul12138@users.noreply.github.com>
Co-authored-by: Carry00 <Carry00@users.noreply.github.com>
Co-authored-by: Hinotoi-agent <Hinotoi-agent@users.noreply.github.com>
* fix(ui): stop hidden toast from intercepting clicks on mobile (#3735)
The .toast container kept pointer-events:auto while hidden (opacity:0), so its
fixed padding sat over mobile profile action buttons and ate their clicks. Set
pointer-events:none when hidden; restore auto on .toast.show.
Co-authored-by: timlawrenz <timlawrenz@users.noreply.github.com>
* fix(sessions): rename saves on blur so iOS Safari rename works (#3729)
iOS Safari has no Enter key; the keyboard 'Done' button fires blur, and the old
onblur=cancel discarded the rename. Flip blur to save (Escape still cancels) for
session rename and project create/rename, with a _finishDone guard to prevent a
double-fire between blur and the API callback.
Co-authored-by: reinocheong <reinocheong@users.noreply.github.com>
* perf(session): skip fuzzy dedup matching for giant merge payloads (#3730)
Large tool/log payloads made _matching_visible_duplicate() casefold+regex-tokenize
multi-megabyte contents on every visible key, so /api/session took 10s+ and blocked
/api/sessions for ~19s. Keep loose normalization lazy+cached and skip substring/fuzzy
matching for non-exact payloads >200KB; exact visible-key matches still short-circuit.
Co-authored-by: alvistar <alvistar@users.noreply.github.com>
* docs(changelog): stamp v0.51.302 — Release JR (stage-brick brick/perf hotfixes #3735#3729#3730)
---------
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: timlawrenz <timlawrenz@users.noreply.github.com>
Co-authored-by: reinocheong <reinocheong@users.noreply.github.com>
Co-authored-by: alvistar <alvistar@users.noreply.github.com>
@rodboev. providers.<name>.models.<model>.context_length overrides (standard provider,
no base_url) were invisible to the session context resolver → wrong window shown/persisted,
could trip auto-compression at the wrong threshold. New _context_length_lookup_inputs_for_model
helper resolves provider config / base_url / custom_providers across route-load, session-save,
and SSE-usage paths; provider-scoped overrides match by provider name and forward as
config_context_length (returned before any base-url-gated probe).
Maintainer pre-merge items both already satisfied in PR head: no-base_url regression test
(test_route_resolver_uses_provider_model_context_length_without_base_url) present; session-save
_cfg_base_url assigned before the helper call (safe-bound, no NameError on TypeError fallback).
Verified api code byte-identical to PR head; 14 context-length tests pass. + CHANGELOG v0.51.300.
Co-authored-by: nesquena-hermes <[email protected]>
* fix(#3718): /api/models/live probes upstream for custom providers with model config (#3719)
@DanielMaly. Config model IDs were added to the ids list before the 'if not ids:' guard,
so a custom provider with a model: field skipped the live /v1/models probe and Settings'
refresh returned only the config entry. Now collects config IDs separately, always probes
for custom providers, merges live (priority) + config (fallback). Includes the maintainer
review follow-ups (CUSTOM_MODELS_ENDPOINT_TIMEOUT_SECONDS constant + behavioral tests).
Captured all 3 logical PR commits' net effect; routes.py + test verified byte-identical
to the PR head. + CHANGELOG v0.51.298.
* test(#3718): remove unused BytesIO import (ruff F401)
* test(#3719): update timeout assertion to CUSTOM_MODELS_ENDPOINT_TIMEOUT_SECONDS
The #3719 maintainer-review commit replaced the hardcoded urlopen timeout=8 with the
CUSTOM_MODELS_ENDPOINT_TIMEOUT_SECONDS constant (5.0). test_named_custom_live_fetch_uses_matching_entry_endpoint
asserted the old literal 8. Reference the constant directly now so the assertion can't
drift again. Not a behavior change — only the live-probe timeout value (8s -> 5s) moved,
URL + auth unchanged.
---------
Co-authored-by: nesquena-hermes <[email protected]>