* Release v0.51.230 (stage-p14): extract <think> to m.reasoning #3455 + LLM Wiki last-writer (#1257)
Salvage of #3455 (@gsurenull): dropped the stale api/config.py bits (MiniMax-M3 +
SCHEMA_VERSION 3->4 — both already on master via #3374). Kept the two genuine fixes:
(1) _splitThinkFromContent persist-path extraction of inline <think> blocks into
m.reasoning (fixes 30-50% session bloat for reasoning-only providers like MiniMax-M3);
(2) LLM Wiki status Last-writer 3-tier fallback (was always 'Not available' since #1257).
Added 9 Node-driven think-split regression tests (data-loss guards: content-before/after
preserved, unclosed blocks intact, lookalike tags not extracted).
* fix(#3455): renderer-matching think extraction + wiki symlink/bounded-read guards (Codex review)
Codex review of stage-p14 found 3 SILENT bugs, all fixed:
(1) DATA-LOSS: _splitThinkFromContent's Pass-2 whole-body scan extracted a CLOSED literal
<think>...</think> from visible prose/code (e.g. inside a fenced code block) into m.reasoning,
emptying it — more aggressive than the renderer (which only strips LEADING blocks). Removed
Pass 2; extraction now matches _streamDisplay semantics (leading-only, loop captures
consecutive leading blocks). +fenced-code regression test.
(2) PRIVACY: _llm_wiki_last_writer followed symlinked .md pages resolving OUTSIDE the wiki
(is_file follows symlinks), leaking external frontmatter. Now requires resolved path under
wiki_root. +symlink-containment regression test.
(3) CONTRACT/PERF: replaced full read_text() with bounded line-by-line reads (frontmatter
block only / capped log-heading scan), never page bodies.
* fix(#3455): think-split is leading-single (renderer-matching) + fix 2 stale source-match tests
Codex re-review finding #2: looping consecutive leading blocks diverged from the renderer
(_streamDisplay/_parseStreamState strip ONE leading block). Now extracts exactly one leading
block. Also updated 2 tests that asserted pre-split implementation strings:
test_live_stream_tokens_persist (content:assistantText -> content:split.content, invariant
preserved) and the consecutive-blocks test. NOTE: Codex finding #1 (client-only split doesn't
persist server-side) is a separate architectural decision pending Nathan.
* feat(#3455): split inline <think> server-side before s.save() so persisted file is compacted (Codex #1)
Codex finding #1: the think-split was client-only, so the SAVED session file still
carried inline <think> blocks (bloat) — the fix only compacted the browser copy.
Added _split_thinking_from_content (api/streaming.py), a server-side twin of the JS
helper with identical leading-only/single-block semantics, applied to the final
assistant message before s.save() (extended the existing reasoning-persist block).
Merges with on_reasoning-stream reasoning. +8 backend-parity regression tests covering
the mid-body-code-block data-loss guard, unclosed-intact, single-leading, none-content.
* test: update 3 save-path source-assertion tests for #3455 server-side think-split
The backend think-split (api/streaming.py reasoning-persist block) changed the literal
code shape + grew the pre-save block, breaking 8 source-assertion tests that anchor on it:
- test_sprint42: assert _rm['reasoning']=_reasoning_text -> now _merged_reasoning/_existing_reasoning
+ _split_thinking_from_content present (intent preserved: reasoning persisted before save).
- test_pr1318 (6) + test_pr1341: re-anchored the locator from the changed 'if _reasoning_text
and s.messages:' line to the stable 'Persist reasoning trace in the session' comment marker;
bumped the 1341 byte-distance limit 15000->16000 (the test self-documents bumping on legit
pre-save growth). All behavioral invariants (reasoning persisted + context fields before save)
unchanged.
---------
Co-authored-by: nesquena-hermes <[email protected]>
* Release v0.51.229 (stage-p13): /model never silently snaps versioned name to -tier variant (#3368, @nesquena-hermes)
Agent-authored, nesquena-APPROVED. Rebased onto current master. Both _findModelInDropdown
(ui.js) and _bestModelMatch (commands.js) reject a prefix-snap when the typed target ends
in a version digit and the longer option's extra text is a variant/tier suffix (.pro) rather
than a version continuation (.digit). Adds _nearestModelSuggestion + 'did you mean?' toast.
34 tests pass (14 new #3368 + 20 regression: #1188 fuzzy + #3360 collision).
* fix(commands): /model did-you-mean toast renders suggestion + single quotes (#3368 review)
Live-render review of the approved #3437 caught two toast-assembly bugs in cmdModel:
(1) t('model_did_you_mean') was called WITHOUT the suggestion arg — model_did_you_mean
is a (m)=>... template that t() invokes, so it rendered 'did you mean "undefined"?';
fixed to t('model_did_you_mean', suggestion). (2) no_model_match already ends with an
opening quote, so '"${args}"' doubled it ('No model matching ""deepseek-v4""'); fixed
to '${args}"'. +4 source-assertion regression tests. Verified live: toast now reads
'No model matching "deepseek-v4" — did you mean "deepseek/deepseek-v4-pro"?'.
* fix(commands): slash-qualified versioned no-snap falls through to suggestion (#3368 Codex CORE)
Codex review found a 2nd no-snap layer the version guard missed: for a slash-qualified
versioned query (e.g. 'xiaomi/mimo-v2.5') whose only near catalog entry is a rejected
tier variant ('xiaomi/mimo-v2.5-pro'), cmdModel's cross-provider /api/session/update
fallback would silently persist the invalid model + 'Switched to...'. Now gated on
!versionedNoSnap (_looksLikeVersionedModel(bare) && a near suggestion exists) so it falls
through to the 'did you mean?' toast; genuinely off-catalog providers (no near variant)
still direct-update. Verified live: '/model deepseek/deepseek-v4' no longer switches, shows
suggestion toast. +1 regression test.
---------
Co-authored-by: nesquena-hermes <[email protected]>
* Release v0.51.228 (stage-p12): workspace tree-drop #3411 + large-markdown preview #3378#3411 (@pamnard): stopPropagation on workspace file-tree OS-file dragenter/dragover/drop
so a tree drop uploads to the workspace WITHOUT also attaching to the composer.
#3378 (@starGazerK): raise md rich-render ceiling 64KB/1500L->256KB/5000L + backend
file read 200KB->400KB, add 'Render as markdown anyway' force button (reuses cached
raw content, no extra fetch).
* fix(workspace): force-render uses fresh path-scoped cache, blocked while dirty (#3378 Codex follow-up)
Codex review of #3378 found the markdown force-render path had two SILENT bugs:
(1) saving a md file from the plain-text fallback didn't update _previewRawContent,
so a later force-render showed stale pre-save content; (2) the cache-reuse check
'path===_previewCurrentPath' was tautological (var just assigned), so a force-render
after a file switch could render the previous file's cached content. Fixes: track
_previewRawContentPath (set on fetch AND save), reuse cache only when it matches the
requested path, and block force-render while the editor is dirty/open. +3 regression
tests. (#3411 was cleared clean by Codex.)
---------
Co-authored-by: nesquena-hermes <[email protected]>
* fix(sidebar): keep active New Chat visible before first message (#3408, @AJV20)
Squashed net diff of #3408. Injects ONLY the active ephemeral session into the
sidebar render rows (when the server list omits it) so a freshly-created New Chat
stays visible/selected before its first turn; inactive empty sessions stay
filtered as before. New Chat also resets a CLI source-filter back to webui so the
active chat isn't immediately hidden.
* fix(sidebar): gate active-row reinjection to 0-message ephemeral only (#3408 Codex follow-up)
Codex review found _ensureActiveSessionRowPresent re-injected ANY active session
after search-merge — so an active conversation WITH messages that was correctly
filtered out by the search query would pollute unrelated search results. Gate the
reinjection to Number(activeRow.message_count||0)<=0 so only the freshly-created
0-message ephemeral chat is re-added; an active chat with messages stays filtered
by search as before. Added a regression test asserting the gate.
---------
Co-authored-by: nesquena-hermes <[email protected]>
* feat(composer): replace mobile config-button sliders icon with context-usage ring (#3062, @NottheGuy007)
Squashed net diff of #3062 (the PR branch's tip commits were deletes of files not
present in our tree). Replaces the composerMobileCtxBadge text badge with an SVG
progress ring (ctx-arc + ctx-num) showing real-time context-window usage: ring
fill via stroke-dashoffset, centered percentage, color-coded green<=50%
orange<=85% red>85%. Ring resets to 0%/green on new session.
* feat(activity): add 'expand activity feed by default' appearance setting (#3080, @AJV20)
Squashed net diff of #3080. Adds a Settings -> Appearance checkbox
(activity_feed_expanded_default, default off) to expand new Activity disclosures
by default; preserves manual per-turn collapse/expand (explicit toggle still
wins); live 'Waiting on model' rows explain what the agent is doing before/after
tool calls. i18n keys for all locales.
* docs(changelog): v0.51.226 — context-usage ring (#3062) + activity-feed default-expand setting (#3080)
* test(mobile): update touch-target test for #3062 ring (badge removed)
#3062 replaced the composerMobileCtxBadge text badge with the SVG context-usage
ring (composerMobileCtxRing) but left 3 stale references in tests/test_mobile_layout.py.
The full suite caught test_mobile_composer_primary_controls_keep_touch_friendly_sizing
asserting the removed .composer-mobile-ctx-badge CSS rule + #composerMobileCtxBadge
element. Updated the assertion to the new ring: confirm composerMobileCtxRing exists,
the old badge is fully gone (not dangling), and the ring SVG is aria-hidden so it
stays a decorative overlay that doesn't steal the config button's 44px touch target
(which is still asserted via .composer-mobile-config-btn above). 56/56 mobile-layout
tests pass. Codex: no production JS dereferences the removed badge.
---------
Co-authored-by: nesquena-hermes <[email protected]>
* fix(health): probe /health/detailed first and unify gateway env vars (#3355)
* docs(changelog): v0.51.225 — remote gateway health probe gateway_state fix (#3355)
* fix(health): normalize gateway URL health-suffix + cap remote body read (#3355 Codex follow-up)
Codex review of #3355 found two issues:
(1) SILENT — a gateway env var already pointing at a health path (e.g.
GATEWAY_HEALTH_URL=http://host/health) produced doubled paths like
/health/health/detailed once probe paths were appended. Now strip a trailing
/health/detailed, /health, /v1/health, /status suffix before appending
(mirrors api/updates.py).
(2) CORE — the new resp.read() on a 2xx body was unbounded; a large/trickled
remote response could hang /api/health/agent or balloon memory. Cap the read
at _REMOTE_PROBE_BODY_LIMIT_BYTES (64KB)+1 and skip JSON parse when over cap
(still report the gateway alive, just without parsed gateway_state).
Adds regression tests for both (no doubled /health/health path; oversized body
is capped + does not hang). Also updated _FakeResp.read to accept the size arg.
---------
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: nesquena-hermes <[email protected]>
* fix: respect profile toolset/skill config on WebUI streaming worker (#3294)
The streaming agent runs on a detached worker thread that does not inherit
the per-request thread-local profile context (set from the hermes_profile
cookie on the HTTP handler thread). On that worker, the ambient get_config()
resolves via get_active_profile_name() which falls back to the process-global
_active_profile (usually 'default'). A session under a non-default profile
with an empty platform_toolsets.cli therefore loaded the DEFAULT profile's
full toolset list, inflating a tools-disabled profile's prompt from ~400 to
~15K input tokens.
Add api.config.get_config_for_profile_home() — a race-free direct disk read
of an explicit profile home's config.yaml (no shared-cache mutation), which
defers to get_config() when the requested home matches the ambient path so
in-memory test overrides are preserved. The streaming worker now resolves
_cfg from the session's own profile home, fixing toolsets, prefill context,
and fallback chains in one place.
Closes#3294
Co-authored-by: gottipx <gottipx@users.noreply.github.com>
* docs(changelog): v0.51.224 — #3294 profile toolset config (dropped #3405 to hold per Codex stale-model-repair finding)
---------
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: gottipx <gottipx@users.noreply.github.com>
* feat: upgrade MiniMax default model to M3
Add MiniMax-M3 as the new default and prune deprecated older
versions (M2.5/M2.5-highspeed/M2.1/M2) from the model catalog.
M2.7 (and M2.7-highspeed) is retained as the legacy compatible
option for users who pin to it.
Updated:
- api/config.py: _FALLBACK_MODELS adds minimax/MiniMax-M3 (placed
before M2.7 so the dropdown surfaces it first)
- api/config.py: _PROVIDER_MODELS['minimax'] adds M3 first, removes
M2.5/M2.5-highspeed/M2.1
- api/config.py: _PROVIDER_MODELS['minimax-cn'] adds M3 first,
removes M2.5/M2.1/M2
- tests/test_minimax_provider.py: updated CN catalog assertions
to match the new {M3, M2.7} list
API URL and TTS configuration are unchanged.
Co-Authored-By: Octopus <liyuan851277048@icloud.com>
* fix(models): register openai-api as first-class picker provider
* fix(models): detect OPENAI_API_KEY as openai-api, not bare openai (#3443 Codex follow-up)
Codex review found #3444 added the openai-api picker entry but the env-detection
side still did detected_providers.add('openai') for OPENAI_API_KEY. The agent
registry has only openai-api and openai-codex (no bare openai), so a env-only
OPENAI_API_KEY setup emitted @openai: picker entries the agent can't resolve on
the send path. Detect openai-api to match the registry. Adds a regression test.
* docs(changelog): v0.51.223 — re-stamp keep-set (#3443 openai-api + #3374 MiniMax-M3); dropped #3289 + #3264 to hold per Codex
---------
Co-authored-by: octo-patch <octo-patch@github.com>
Co-authored-by: Octopus <liyuan851277048@icloud.com>
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: nesquena-hermes <[email protected]>
* fix: reject cross-script drifted auto-generated session titles (#3293)
The title-language mismatch guard only knew two states: German (de) or empty,
and _title_language_mismatch early-returned False whenever the user start
wasn't German. So an English conversation whose LLM-generated title came back
in Chinese / Spanish / Russian sailed through and persisted with
llm_title_generated=true. The German case was the only one covered because
that's the one prior report it was built for.
Generalize from a German-specific binary to a language-agnostic cross-script
check. Add _script_counts() + _dominant_script() (cheap, dependency-free
Unicode-block classification: latin / cjk / cyrillic / arabic / hebrew / greek
/ devanagari). _title_language_mismatch now rejects a title that introduces a
substantial amount (>=35% of alphabetic chars, min 2) of a script different
from the conversation start's dominant script — so short titles that embed a
borrowed Latin technical term still trip, while an English title with a single
foreign place-name does not. The legacy German->English same-script heuristic
is preserved verbatim.
Kept api/streaming.py ASCII-only (the test_title_generation_source_has_no_cjk_
literals guard) — all CJK examples live in the test file, not the source.
Closes#3293
Co-authored-by: andrewkangkr <andrewkangkr@users.noreply.github.com>
* fix: prune orphaned imported-CLI sidecars from the WebUI sidebar (#3238)
When a CLI/agent session is opened in WebUI it gets a WebUI-owned sidecar
(webui/sessions/<id>.json + _index.json row) so it can render and reopen;
all_sessions() then returns it independently of the agent state.db. If the
user later deletes that session from the CLI / local Hermes storage, nothing
pruned the sidecar — the merge loop only overlays CLI metadata when a matching
state.db row exists and otherwise continues, so the stale row lingered in the
sidebar indefinitely (there is no WebUI delete affordance for CLI rows).
Add api.models.agent_session_row_exists(): an exact, uncapped existence probe
against the state.db sessions table. The sidebar merge loop now drops a row
that is_cli_session_row + not WebUI-native + absent from cli_by_id + whose
state.db row is genuinely gone, and calls prune_session_from_index() so
_index.json self-heals.
The state.db probe is deliberate: get_cli_sessions() caps at
CLI_VISIBLE_SESSION_LIMIT (20), so a still-existing session can fall out of
that window and look deleted — pruning on cli_by_id absence alone would delete
live sessions. WebUI-native rows with a CLI ancestor are never pruned, and any
probe error degrades to keep-the-row so a transient failure can't lose data.
Closes#3238
Co-authored-by: Luxciax <Luxciax@users.noreply.github.com>
* fix: count pin quota by visible session lineage
* docs(changelog): v0.51.222 — backend bugfix batch (#3293 title drift, #3238 sidecar prune, #3288 pin lineage)
* fix(pins): forks count as own pin lineage, not collapsed to parent (#3288 Codex follow-up)
Codex review of the batch found a pin-limit UNDERCOUNT: _session_row_lineage_root_id
followed any parent_session_id to the root, but /api/session/branch creates
independent visible fork sessions that also carry parent_session_id (session_source=
'fork'). Two pinned forks of the same parent collapsed to one quota lineage, letting
a user exceed pinned_sessions_limit with no 400. Fix: a fork returns its own id as
its lineage root (it's a separately-visible session); only compression/continuation
rows still collapse to a shared root. Adds a regression test with two pinned forks +
the parent counting as three distinct lineages, and confirms the existing
pre-compression-snapshot collapse case still passes.
* test(pins): update #2508/#2821 source-match tests for #3288 lineage rename
#3288 replaced the raw-session-id pin counter (pinned_ids set) with a
visible-lineage counter (pinned_lineage_ids via _visible_pinned_lineage_ids over
persisted_rows/candidate_rows). Two pre-existing source-string-matching tests
asserted the OLD implementation literals (pinned_ids = {, _session_field(existing,
session_id...), len(pinned_ids) >=). Updated both to assert the new mechanism while
preserving the invariants they actually guard: snapshot computed BEFORE LOCK (no
all_sessions()-inside-LOCK deadlock), quota filtering via the shared
_session_counts_toward_pin_quota helper, and the limit/400 guard. Behaviour
unchanged; these were implementation-detail assertions, not behaviour tests.
---------
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: andrewkangkr <andrewkangkr@users.noreply.github.com>
Co-authored-by: Luxciax <Luxciax@users.noreply.github.com>
Co-authored-by: Andy Kang <andrewkang.kr@gmail.com>
* [security] fix(workspace): block all symlink escapes from the selected workspace (#3398, @Hinotoi-agent)
Previously safe_resolve_ws allowed a symlink placed inside a workspace to resolve
to an external host path as long as it wasn't a system dir (/etc, /proc, etc).
But the workspace file API is reachable by LLM agent tool calls (read_file_content),
so an in-workspace symlink to ~/.ssh, ~/.hermes/auth.json (credentials), etc. was a
real read path. Now ANY symlink escape is blocked: safe_resolve_ws resolves and
requires the result stay under the workspace root; list_dir hides escaping symlinks
(they could never be opened anyway); internal symlinks resolving back under the
workspace still work. Updated the upload symlink-target test to accept the new
400 'Path traversal blocked' rejection (was 403) — the invariant (nothing lands
outside the workspace) is unchanged.
Co-authored-by: Hinotoi-agent <Hinotoi-agent@users.noreply.github.com>
* docs(changelog): v0.51.221 release header for #3398 symlink-escape security fix
* [security] harden workspace file API against symlink-swap TOCTOU via portable anchored openat-walk (#3398 follow-up)
Codex review of #3398 flagged that safe_resolve_ws() validates a path but
list_dir/read_file_content/upload/extraction then re-open by pathname, leaving a
TOCTOU window: a symlink swapped in AFTER the check could still escape. (This
race pre-existed #3398; closing it here so the containment is complete.)
A first attempt used /proc/self/fd for the post-open containment check, but that
BRICKS workspace browsing on macOS/Windows (no /proc → every read/list rejected).
This version is portable:
- open_anchored_fd(): opens the (already symlink-resolved) target
component-by-component from the workspace root via openat (dir_fd) + O_NOFOLLOW.
Every component must be a real non-symlink entry, so a component swapped to a
symlink mid-flight is refused. No /proc dependency. Used by read_file_content
(read from the fd) and list_dir (enumerate via os.scandir(fd), per-entry
fstatat/readlinkat).
- open_anchored_create_fd(): same anchored walk for writes, creating missing
intermediate dirs with mkdir(dir_fd=) and the leaf with O_CREAT|O_EXCL|
O_NOFOLLOW. Used by the workspace upload write AND archive (zip+tar) member
writes, anchored against the TRUE workspace root (not the mutable extraction
dest_dir, closing Codex's root-swap finding). fd-leak-safe on rejection.
- Portability: gated on os.supports_dir_fd; platforms without it (Windows, where
symlink creation needs admin) fall back to a plain O_NOFOLLOW open/exclusive
create — no new race protection but no regression vs the prior path-based code.
Legit in-workspace symlinks still resolve and read/list fine (safe_resolve_ws
collapses them to a real in-workspace path, which the anchored walk then opens).
Verified: the swap-race leaks external content against the old path-based read
and is blocked here; macOS-class symlinked-root workspaces work; no fd leak over
300 rejected creates. Adds TOCTOU + anchored-create regression tests.
* [security] close 3 more #3398 TOCTOU gaps from Codex r3: root-swap, pre-create mkdir, Windows list_dir fallback
Codex round-3 review found three residual issues in the anchored openat-walk:
1. (CORE) The workspace ROOT itself could be swapped to a symlink after
resolve() but before the root os.open() — add _O_NOFOLLOW to the root open in
open_anchored_fd() and open_anchored_create_fd() (and make_anchored_dir()), so
a raced root symlink is refused. Verified: root-swap race now blocked.
2. (SILENT) Upload/extraction still did pathname Path.mkdir() AFTER the
containment check, so a raced symlink component could make the server create
dirs outside the workspace before the anchored file create rejected. Removed
the redundant member_path.parent.mkdir() calls (open_anchored_create_fd
already creates intermediates via anchored mkdirat) and replaced the two
base-dir mkdirs (upload target dir + archive extraction root) with a new
make_anchored_dir() that walks from the true workspace root via
openat+O_NOFOLLOW + mkdir(dir_fd=).
3. (CORE) list_dir() unconditionally used os.scandir(fd)/os.stat(dir_fd=)/
os.readlink(dir_fd=), which would brick workspace browsing on platforms
without os.supports_dir_fd (Windows). Split list_dir() into a _DIR_FD_OK
anchored branch and a path-based fallback branch (prior behaviour) sharing one
_process() entry builder. open_anchored_create_fd()'s Windows fallback now also
creates parent dirs.
Adds regression tests: no-dir_fd fallback (list+read+create+symlink filtering)
and the root-swap race. All prior TOCTOU + anchored-create tests still green.
* fix(workspace): portable symlink-loop filtering in list_dir via follow-stat ELOOP
CI on Python 3.13 caught test_mutual_symlink_loop_filtered failing: a mutual
symlink loop (a->b->a) was NOT filtered from the listing. Root cause: the new
readlink-based cycle detection relied on (target_resolved / raw_link).resolve()
RAISING on a loop, but Path.resolve() loop handling differs by Python version
(3.11 raises RuntimeError, 3.13 can return a path), so the loop slipped through
on 3.13.
Fix: compute a version-independent 'reachable' flag per symlink via
os.stat(..., follow_symlinks=True) — the syscall reliably returns ELOOP for
mutual/self loops and ENOENT for broken targets on every platform/version. A
symlink whose follow-stat raises can never be opened, so list_dir filters it.
Applied in both the dir_fd-anchored branch (fd-relative stat) and the Windows
path-based fallback branch. Mutual loop now filtered on all versions.
---------
Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Hinotoi-agent <Hinotoi-agent@users.noreply.github.com>
Boots the real server.py agent-free and loads the key pages in headless
Chromium, failing on any console error or uncaught JS exception. Catches the
runtime-JS brick class (const-reassign #3162, function/window collision
#2715/#2771) that node --check, ESLint, and the mocked pytest suite cannot see
because they only manifest when a real browser executes the page.
Credential-free: strips *_API_KEY from the env, drives no real model, needs no
secrets. Runs on every PR + push via .github/workflows/browser-smoke.yml.
Co-authored-by: nesquena-hermes <[email protected]>