Release: intercept /sessions and /resume slash commands (#6245, @webtecnica) - #6253
Conversation
Add a native-intercept branch in the block of send() alongside the /pet special-case. When the user types /sessions or /resume, expand the sidebar and refresh the session list instead of sending the raw slash text to the agent. Root cause: the agent command registry exposes sessions/resume as non-CLI-only commands, so the autocomplete popup shows them, but the WebUI send-time dispatch had no branch to catch them, causing the literal text to be sent as a prompt.
…UI (webtecnica)
…/resume (gate follow-up) The intercept called expandSidebar() directly, which is a no-op on phone-width layouts, so /sessions and /resume silently did nothing on mobile (composer cleared, nothing shown). Use the mobile-aware _openProfileSwitchSessionBrowser() first, falling back to expandSidebar(). Reproduced + specified by the pre-release Codex gate. Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
|
| Filename | Overview |
|---|---|
| static/messages.js | Adds /sessions and /resume intercept inside the if(_parsedCmd&&!_cmd) block; uses the mobile-aware _openProfileSwitchSessionBrowser with an expandSidebar fallback. The intercept is unreachable when a read-only session is active due to a pre-existing early-return guard that fires with a misleading toast. |
| CHANGELOG.md | New CHANGELOG entry added by the release process describing the sessions/resume fix; content matches the PR changes. No issues. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A["User types /sessions or /resume in composer and hits Send"] --> B{"S.session exists AND is read-only?"}
B -- "Yes (pre-existing guard)" --> C["showToast: 'Read-only imported sessions cannot be modified.' return ❌"]
B -- No --> D["text.startsWith('/') && !pendingFiles && !literalSlash?"]
D -- Yes --> E["parseCommand(text) → _parsedCmd"]
E --> F{"_parsedCmd in COMMANDS array?"}
F -- No --> H{"_parsedCmd.name === 'sessions' or 'resume'?"}
H -- Yes --> I{"typeof _openProfileSwitchSessionBrowser === 'function'?"}
I -- Yes --> J["_openProfileSwitchSessionBrowser() (mobile-aware)"]
I -- No --> K{"typeof expandSidebar === 'function'?"}
K -- Yes --> L["expandSidebar() (desktop-only)"]
K -- No --> M["No sidebar action"]
J --> N["await renderSessionList()"]
L --> N
M --> N
N --> O["Clear composer, return ✓"]
H -- No --> P["Continue to agent/plugin dispatch"]
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
A["User types /sessions or /resume in composer and hits Send"] --> B{"S.session exists AND is read-only?"}
B -- "Yes (pre-existing guard)" --> C["showToast: 'Read-only imported sessions cannot be modified.' return ❌"]
B -- No --> D["text.startsWith('/') && !pendingFiles && !literalSlash?"]
D -- Yes --> E["parseCommand(text) → _parsedCmd"]
E --> F{"_parsedCmd in COMMANDS array?"}
F -- No --> H{"_parsedCmd.name === 'sessions' or 'resume'?"}
H -- Yes --> I{"typeof _openProfileSwitchSessionBrowser === 'function'?"}
I -- Yes --> J["_openProfileSwitchSessionBrowser() (mobile-aware)"]
I -- No --> K{"typeof expandSidebar === 'function'?"}
K -- Yes --> L["expandSidebar() (desktop-only)"]
K -- No --> M["No sidebar action"]
J --> N["await renderSessionList()"]
L --> N
M --> N
N --> O["Clear composer, return ✓"]
H -- No --> P["Continue to agent/plugin dispatch"]
Comments Outside Diff (1)
-
static/messages.js, line 1416-1418 (link)Read-only guard blocks navigation commands with misleading toast
The
if(S.session&&(S.session.read_only||S.session.is_read_only))guard at line 1416 fires before the slash-command dispatch block (line 1428). If a user has a read-only imported session active and types/sessionsor/resume, they receive "Read-only imported sessions cannot be modified." and nothing opens — even though these commands are purely navigational and touch no session data. The fix is unreachable in that scenario, and the toast text actively misdirects the user into thinking the session browser is locked.The busy-control guard at line 1356 demonstrates the existing pattern for commands that need to bypass the read-only wall — intercepting
/sessions//resumesimilarly (before line 1416, or by widening the guard's exclusion list) would close this gap.
Reviews (1): Last reviewed commit: "CHANGELOG: intercept /sessions /resume s..." | Re-trigger Greptile
| if(_parsedCmd.name==='sessions' || _parsedCmd.name==='resume'){ | ||
| // Open the native WebUI session browser rather than sending as chat text (#6224). | ||
| // Use the mobile-aware opener so phone-width layouts (where expandSidebar is a | ||
| // no-op) actually reveal the session drawer. | ||
| if(typeof _openProfileSwitchSessionBrowser==='function') _openProfileSwitchSessionBrowser(); | ||
| else if(typeof expandSidebar==='function') expandSidebar(); | ||
| if(typeof renderSessionList==='function') await renderSessionList(); | ||
| $('msg').value='';autoResize();hideCmdDropdown();return; | ||
| } |
There was a problem hiding this comment.
/resume <id> argument is silently discarded
_parsedCmd.args carries the session ID a user typed (e.g. /resume abc123) but the handler never reads it — the session browser always opens at the default/current position. The autocomplete exposes /resume as a command that accepts an argument, so a user who copies a session ID from the UI and types /resume <id> will see the browser open but their target session won't be selected or jumped to. The PR notes this as a known limitation; it's flagged here because the discarded args value is the only hook point for a future fix and documenting it in code (even a // TODO: navigate to args when possible comment) would prevent future confusion.
🎬 Cutter preview — PR #6253
|
… permissions (#3) * docs: fold in Rod's PR-review feedback into GUIDELINES + CONTRIBUTING (#6211) * docs: fold in Rod's PR-review feedback into GUIDELINES + CONTRIBUTING Based on rodboev's feedback distilled from 50+ recent PRs. Four accepted points plus one clause, folded into existing rules rather than adding new ones: - Rule 6: load the reporter's shipped reproduction, don't rebuild a fixture from your reading of it (the one genuine hole — a fix and test from the same wrong model agree with each other and certify a no-op). - Rule 2: confirm a value is authoritative (declared at the point of intent), not inferred from id prefix / content shape / emptiness / DOM state. - Rule 4: read prior PRs and review threads to find a subsystem's real variants instead of inventing axes from the single case handed to you. - Rule 1: the chokepoint is the smallest boundary that contains the fault, not the widest you can reach (don't disable a whole pipeline to suppress one output). - "Show your work": name who owns the truth for any claim the repo doesn't own. CONTRIBUTING.md carries the two contributor-facing points (repro-loading, proof-ownership) in the PR-description section, deferring detail to GUIDELINES.md. Co-authored-by: rodboev <rodboev@users.noreply.github.com> * docs: tighten Rule 6 escape hatch per Rod — gate on shape under-specified, not file absent Rod's review: the #5749 no-op came from a fully prose-specified repro (fenced JSON + field-level conditions + steps), not a missing file. The old hatch ('why the issue gave you nothing to load') reads as 'no downloadable attachment', letting someone walk past a binding JSON block. Gate the hatch on the SHAPE being under-specified instead: a fenced JSON structure / field conditions / step list pin the shape as bindingly as a file; if pinned, satisfy every condition and don't add a property the shape never had to make a guard fire; only say 'constructed, assumed X' when the shape is truly unpinned. Co-authored-by: rodboev <rodboev@users.noreply.github.com> * docs: distill Rule 6 repro-shape guidance into a principle (Rod style note) Rod: 'distill into durable principles, don't enumerate lists; strip negative conditions that read like narration.' Reworked the addition to lead with the principle ('a reproduction is whatever pins the bug's shape'), collapse the capture/JSON/conditions/steps enumeration into flowing prose, and convert the 'don't add a property...' negative into a positive imperative ('bind your fixture to that shape: satisfy every condition... instead of granting...'). Same for the CONTRIBUTING bullet. --------- Co-authored-by: nesquena-hermes <agent@nesquena-hermes> Co-authored-by: rodboev <rodboev@users.noreply.github.com> * Release: msg_limit ceiling metadata decoupling (#6214, @webtecnica) (#6216) * feat: expose msg_limit ceiling via /api/session metadata, frontend reads dynamically (#6177) Backend exposes _MAX_MSG_LIMIT as _msg_limit_max in every /api/session response. Frontend reads it dynamically, falling back to _MSG_LIMIT_MAX for older servers. This removes the hand-mirrored coupling between the two layers. Removed test_msg_limit_ceiling_drift.py since the mirror pattern that required the drift guard is replaced by dynamic metadata. This is the standalone metadata-decoupling piece from #6206, without the clamp/paging that already shipped in exp-v0.52.98 via #6152/#6154. * fix(session): declare _msgLimitMax at module scope + CHANGELOG + gate fixes (#6214 follow-up) The submitted PR used _msgLimitMax at two read sites (boundedReloadLimit in _ensureMessagesLoaded, useBeforePaging in _loadOlderMessages) but never declared it and read it before assignment -> undefined on cold load -> full-transcript fetch every load (regression) + implicit global. Declared `let _msgLimitMax = _MSG_LIMIT_MAX;` at module scope so the reload-width paths always read a defined value (the static fallback) until the server's _msg_limit_max lands. Also: defined the ceiling globals in the two inline node-harness tests that copy _ensureMessagesLoaded's body (test_cross_session_message_load_isolation, test_session_unread_dot_on_visit), updated the source-string assertion in test_webui_external_refresh_frontend, and added 3 decoupling tests (backend field present, module-scope declaration + fallback, both paths read the live ceiling) replacing the deleted drift-guard. Gate: Codex adversarial SAFE TO SHIP (executable probes: cold-load fallback, mixed-version omission, live update, cross-session isolation, over-ceiling bare refresh, msg_before row preservation). Full sharded suite green. Co-authored-by: webtecnica <webtecnica@users.noreply.github.com> --------- Co-authored-by: webtecnica <webtecnica@users.noreply.github.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * Release: Transparent Stream multi-segment prefix dedupe (#6189, @ai-ag2026) (#6217) * fix(transparent-stream): drop stale final-answer prefix row in multi-segment settle (#5749 follow-up) A turn with interim assistant messages (prose interleaved with tool calls) could show the beginning of the final answer TWICE after watching it stream: once as a settled anchor-scene prose row (the live-token accumulator's last throttled snapshot) and once as the real assistant segment. The duplicate persisted until reload. Root cause: #5758 suppresses the accumulator row only when it sits "after the last tool row", but _completeSettledAnchorSceneForTurn appends the settled per-message tool rows AFTER the projected live rows — those re-list tools that ran EARLIER in the turn, pushing the boundary past the final segment's accumulator so the guard never fired. The stale prefix snapshot then survived into the persisted scene and rendered above the settled answer. Fix: judge final-segment eligibility against the LIVE projection's own chronology — a live-prose row belongs to the final segment iff no PROJECTED tool row follows it. Pre-tool narration that happens to prefix the final answer stays protected (existing #5758 regression tests still pass), and a new regression test pins the multi-segment shape. Verified end-to-end by replaying the captured run journal of an affected session through the real SSE live handlers in headless Chromium: duplicate before (answer prefix visible twice after settle), gone after; fresh-reload rendering unchanged. Rollback: revert this commit; behavior returns to pre-fix (duplicate prefix row after live-settle of multi-segment turns). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * CHANGELOG: transparent-stream multi-segment prefix dedupe (#6189, @ai-ag2026) --------- Co-authored-by: ai-ag2026 <m.fuechtenkoetter@posteo.de> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * Live Stream: hydrate ID-linked historical tool turns * Release: stop false Compressing-context card (#6184, @carlotestor) (#6223) * fix: stop false "Compressing context" on non-compress turns Narrow the agent status → SSE compressing bridge to real Hermes compaction start notices, and stop snapshot hydration inventing a running compress divider from terminal/lifecycle rows without cues. Brand-new low-token chats (and skip/cooldown notices) no longer paint the live auto-compression worklog row. * CHANGELOG: false compressing-context card fix (#6184) --------- Co-authored-by: carlotestor <89560945+carlotestor@users.noreply.github.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * Release: extension session-open handler + renderTranscript API (#5508, @ChonSong) (#6226) * feat(core): add registerHermesSessionOpenHandler + renderTranscript extension hooks - registerHermesSessionOpenHandler(fn): register a handler that fires on session open. Return {cancel:true} to prevent navigation. - renderTranscript(container, messages, opts): render messages into any DOM container using core's renderMd pipeline. Skip tool messages. - Wire _hermesNotifySessionOpen into loadSession: pre-load guard at top, post-load notification at end for extensions to hook into. - Follows existing registerHermesTtsEngine extension registration pattern. This gives extensions like chat-tiling a sanctioned API instead of DOM hacking to intercept and render session transcripts. * fix: address 3 gate-fail blockers from PR #5508 review Fixes the three core issues identified by nesquena-hermes code review: 1. XSS sink in renderTranscript (boot.js) - Fallback to textContent when window.renderMd is unavailable - innerHTML exclusively for successful renderMd output 2. Stale _loadingSessionId reset in cancel branch (sessions.js) - Remove premature nulling of _loadingSessionId - Cancel path simply returns without touching loading-guard state 3. Pre-open veto bypasses profile/import side-effects (sessions.js) - Move cancellable preload hook to start of _openSidebarSession, before external session import and profile switching - Pass internal _preloadNotified flag to skip duplicate preload in loadSession while retaining post-load notification Closes #5508 gate-fail items. * fix: use module-level flag instead of call argument to avoid test regression The _preloadNotified approach broke test_static_sessions_js_switches_profile before_opening_all_profiles_row because it changed the loadSession call signature from loadSession(sid, loadOpts) to loadSession(sid, Object.assign(...)). Switch to a module-scoped boolean _hermesSessionOpenAlreadyFired set by _openSidebarSession before calling loadSession, checked by loadSession's pre-hook guard. The call signature stays unchanged. Test 2 (test_load_session_rearms_stream_on_every_early_return) also passes. * fix: compact pre-hook comment to keep loadSession within test window limits * fix: replace global _hermesSessionOpenAlreadyFired flag with per-call opts._preloadNotified The module-level boolean introduced in 381fa0ef had two problems: 1. ReferenceError on direct loadSession() calls — the flag was undeclared when called outside _openSidebarSession, breaking saved-session restore. 2. Never reset after first sidebar open — all subsequent direct calls silently skipped the cancellable preload handler. Per the maintainer's review (PR #5508), replace the global with a per-call option _preloadNotified passed by _openSidebarSession via Object.assign. This keeps the call signature stable for existing tests and eliminates the stale global state leak. Test adjustments: - test_issue1611: widened loadSession call literal assertion - test_session_channel_option_x: body slice 14000→15000 to accommodate the slightly longer function body * chore: remove unrelated files from commit * fix: address 3 review issues — preload-only cancel, drop inner wrapper, pass _preloadNotified on retry - Only honor {cancel:true} when opts.preload===true (boot.js) - Drop .msg-body-inner wrapper, render directly into .msg-body (boot.js) - Carry _preloadNotified:true through cross-profile 409 retry (sessions.js) * recommit * Update static/boot.js Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * fix(extensions): resolve canonical sid before preload hook + pass _preloadNotified on continuation retry PR #5508 review follow-up (review 4690636760): 1. Move _resolveSessionIdFromSidebarLineage() before the preload hook so extensions always see the canonical sid, not the raw sidebar click id. 2. Pass _preloadNotified:true on the continuation-session retry path to prevent duplicate preload events to extensions. 3. Add functional test_extension_session_hooks.py — actually drives the new hook registration, preload-veto, transcript rendering, and _preloadNotified bridge in Node (13 tests, all green). * Remove unused pytest import Removed unused import of pytest from test file. * fix: address 2 gate-blocking veto-ordering defects (PR #5508) Blocker 1 (CORE): cross-profile retry now passes _preloadNotified:true so the pre-hook doesn't re-fire after destructive side-effects already ran (stream teardown, message clear, profile switch). A {cancel:true} on that second fire was stranding the UI profile-switched with a cleared transcript. Blocker 2 (SILENT): closeMobileSidebar() was called synchronously BEFORE _openSidebarSession()'s veto guard in three places (tap-to-open, child-session, lineage-segment). A {cancel:true} still closed the sidebar out from under it. Removed the three premature calls; moved a single closeMobileSidebar() inside _openSidebarSession AFTER the veto guard so it only runs when the open actually proceeds. Added 3 regression tests asserting {cancel:true} leaves NO side-effect. * CHANGELOG: extension session-open handler + renderTranscript API (#5508) --------- Co-authored-by: Sean <seanos1a@gmail.com> Co-authored-by: ChonSong <85378550+ChonSong@users.noreply.github.com> Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * test: cover ordered multi-tool anchor hydration * Release: durable run-journal recovery + full tool args (#6197, @franksong2702) (#6236) * fix(streaming): prefer durable run journal recovery * fix(streaming): reject stale recovery stream scenes * test(streaming): pin todo recovery metadata guard * fix(streaming): preserve recovery snapshot tool args * test(streaming): align journal snapshot args contract * fix(streaming): bound recovery snapshot tool args * CHANGELOG: durable run-journal recovery + full tool args (#6197) --------- Co-authored-by: Frank Song <franksong2702@gmail.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * Release: folder-download subpath baseURI fix (#6227, @steezypunk) (#6237) * fix(ui): resolve folder download URL against document.baseURI for subpath support When Hermes WebUI is served behind a reverse proxy with a path prefix (e.g. /hermes/), the right-click → "Download Folder" context menu option navigates to a root-absolute URL (/api/folder/download?...), which resolves to the server origin instead of the proxy mount point, causing a 404. This matches the pattern already used by the workspace.js route helper refactored in v0.52.41 (commit 1a64d7d3). * CHANGELOG: folder-download subpath baseURI fix (#6227) --------- Co-authored-by: Steezy <21984836+steezypunk@users.noreply.github.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * Atomic config.yaml writes to survive mid-write crashes config.yaml and profile config.yaml were persisted with a plain Path.write_text(), which truncates the target before writing. A crash (or exception) after the truncate but before the full payload was flushed left the live config truncated/corrupt, so the next agent/WebUI start failed to parse it (availability regression). Extract a shared api.paths._atomic_write_text() helper (tempfile in the same dir -> write -> flush + os.fsync -> os.replace; unlink tmp on error), mirroring the existing .env / cost-snapshot atomic pattern in api.providers, and apply it to _save_yaml_config_file (api.config) and the two profile model-config writers (api.profiles). On any mid-write failure os.replace never runs, so the original file stays byte-for-byte intact. Preserve the target's permissions: tempfile.mkstemp() hard-codes 0600 and os.replace carries the temp file's mode onto the target, so without an explicit chmod every save would silently tighten a group/other- readable config.yaml (the homelab install ships 0644, profiles 0664) down to owner-only. Copy the existing file's mode before the replace, falling back to the umask-adjusted 0666 for a new file. config.yaml holds no secrets, so that tightening would be a regression, not hardening (unlike .env, which stays 0600 in api.providers). settings.json is intentionally left untouched here. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Cover onboarding with atomic config writes * Handle unsupported ownership transfer in atomic writes * Preserve hard-linked configs during atomic writes * fix: preserve config extended attributes * fix: avoid racy umask probe for config writes * fix(ctl): detect foreign/supervised WebUI instances instead of double-starting ctl.sh start only guarded against launchd (macOS). On Linux, with a systemd-supervised WebUI serving the port and a stale PID file, stop reported 'stopped', start spawned a bootstrap that died ~2s later on server.py's 'already responding' check — after the 0.15s aliveness gate had already printed 'Started' and recorded the doomed PID. Killing the foreign server by hand then put its supervisor's auto-restart into a race with ctl.sh's start, ending in a permanent RestartSec crash loop. - start: refuse when anything answers HTTP(S) on the target port (any response bytes, matching server.py's abort semantics — a 404 squatter still dooms our server), and when the hermes-webui systemd unit is active on our port or mid-auto-restart (activating). Port scoping mirrors the launchd #3291 over-block fix; overrides: HERMES_WEBUI_CTL_ALLOW_SYSTEMD_CONFLICT / _ALLOW_PORT_CONFLICT, unit name via HERMES_WEBUI_SYSTEMD_UNIT. - start: watch the child through a startup grace window (HERMES_WEBUI_START_GRACE, default 3s) — report failure and clean the PID file when it dies during startup; break early once /health answers. - status/stop: when ctl.sh owns no PID but the port answers, say 'running (not managed by ctl.sh)' with listener diagnostics instead of 'stopped', and never touch the foreign process. - _pid_listens_on_port: ss fallback for Linux hosts without lsof. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ctl): harden the foreign-instance guards per review Four fixes from the Greptile review on #5944: - Bracket IPv6 literals in the probe target ('::1' -> '[::1]') so the URL-based responder checks don't silently miss a running instance. - Force direct connections in the local ownership probes: --noproxy '*' (curl) / --no-proxy (wget) in _port_answers_http, and neutralized proxy env around the startup-grace health probe — a configured http(s)_proxy would report the proxy instead of the port. - Clamp HERMES_WEBUI_START_GRACE=0 to the default: a zero window would skip startup monitoring entirely and restore the stale-PID behavior the window exists to prevent. - stop: warn about an unmanaged instance BEFORE deleting the state file — it carries the saved host/port binding the probe needs when the instance was started off-default. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ctl): keep listener diagnostics best-effort * fix: bypass proxies in ctl startup health probe * test: allow startup monitor cleanup in dotenv test * test: give ctl start fixtures startup grace * Release: GLM per-version reasoning controls (#6219, @rh-id) (#6243) * fix(reasoning): gate reasoning_effort ladder to GLM-5.2+ on native zai Z.AI's API (docs.z.ai) defines reasoning_effort as GLM-5.2+ exclusive, but hemes-webui advertised the full 6-level ladder for all 7 GLM models because _candidate_supports_reasoning has an unconditional glm token match and _filter_reasoning_efforts_for_provider had no ZAI branch. Six of seven catalog models (glm-5.1, glm-5, glm-5-turbo, glm-4.7, glm-4.5, glm-4.5-flash) showed a selector whose values the endpoint silently ignores, and GLM-4.7 (forced thinking that cannot be disabled per Z.AI docs) showed a 'none' option with no effect. Add a ZAI branch to _filter_reasoning_efforts_for_provider mirroring the existing OpenAI/Gemini/Anthropic ceiling pattern: strip the whole ladder for pre-5.2 GLM models and for the forced-thinking GLM-4.7 family; preserve the full ladder for GLM-5.2+ (whose accepted values match VALID_REASONING_EFFORTS exactly). The gate is scoped to the native zai provider only (aliases glm/z-ai/z.ai/zhipu all resolve to zai); aggregator providers are untouched because they route through their own routers, not Z.AI's native endpoint. The glm family-detection heuristic in _candidate_supports_reasoning is unchanged — GLM models DO support the thinking on/off toggle at the family level; this fix is specifically about the reasoning_effort intensity ladder. State layer: agent.reasoning_effort config + UI dropdown options derived from resolve_model_reasoning_efforts. Invariant: UI options and coercion now agree and match Z.AI's per-model docs (max offered only for GLM-5.2+, none never offered for forced-thinking models). Out of scope: the thinking:{type:...} request-field translation lives in the external agent/gateway layer. * fix(reasoning): close ZAI coercion gap + harden test assertions Address Greptile review on #6219 (round 1): 1. Vacuous test assertion (test_glm_5_2_preserves_none_sentinel): the 'or' fallback made the assertion always-true, so a regression stripping 'none' for GLM-5.2 would go undetected. Rewrote to inject 'none' via the raw source (mocking _resolve_model_reasoning_efforts_impl) and assert it survives — the test now genuinely exercises the preservation branch. 2. Coercion gap for non-max stored levels on pre-5.2 GLM: the existing 'if ceiling and raw not in ceiling' guard treats an empty ceiling as 'no rule' (preserving the configured effort verbatim per #3505), so a stored 'high'/'medium'/'low' for glm-5.1/glm-4.5/glm-4.7 on native zai was forwarded to Z.AI unchanged and silently ignored — contradicting the PR's stated UI/coercion agreement invariant. Root cause: the ZAI gate returns [] to mean 'known-empty' (no reasoning_effort at all), but the coercion path treated all [] as 'ambiguous/unknown, preserve verbatim'. Fixed by extracting the ZAI decision into _zai_glm_reasoning_efforts_supported (True/False/None sentinel) shared by both the filter and coercion, then special-casing the known-False result in coerce_reasoning_effort_for_model to return '' (send no field). The #3505 preserve-verbatim behavior for genuinely-unknown models on non-zai providers is unchanged. Added 10 regression tests (all fail before the coercion fix, pass after): - All 6 levels (max..minimal) coerce to '' for each pre-5.2 GLM + glm-4.7 - All 4 aliases (glm/z-ai/z.ai/zhipu) resolve through the same coercion gate - GLM-5.2 preserves all 6 levels verbatim - Regression guard: unknown model on custom: provider STILL preserves verbatim State layer: agent.reasoning_effort config + the value forwarded to Z.AI. Invariant now fully holds: UI offers no options for pre-5.2 GLM AND coercion sends no reasoning_effort field for any stored level on those models. * fix(reasoning): preserve ZAI GLM 4.5-5.1 thinking toggle when effort ladder empty Address nesquena-hermes round-2 review on #6219: returning [] for the effort ladder on sub-5.2 GLM models hid the entire reasoning chip in the composer (static/ui.js:4932 treats empty supported_efforts as 'no reasoning control at all'), silently regressing the working thinking on/off toggle for GLM-4.5/4.6/ 5.0/5.1 users. Per Z.AI's own docs, those models accept the thinking {type:enabled|disabled} toggle even though they do not accept the reasoning_effort intensity ladder. Fix decouples thinking-toggle capability from the effort ladder: Backend (api/config.py): - Refactor _zai_glm_classification returns one of 'effort' (GLM-5.2+), 'thinking' (GLM-4.5 up to but not including 5.2), 'forced' (GLM-4.7 family), or None (non-zai / non-GLM). Single source of truth shared by all three consumers. - _zai_glm_reasoning_efforts_supported now wraps classification for the coercion contract (unchanged behavior). - _zai_glm_thinking_toggle_supported returns True for 'effort' or 'thinking', False for 'forced', None otherwise. - get_reasoning_status gains a supports_thinking_toggle field = bool(supported) OR (zai_thinking is True). Non-zai providers default to bool(supported_efforts) so their chip-visibility behavior is unchanged. Frontend (static/ui.js): - New _currentReasoningToggleSupported state var (default undefined = treat as true so legacy responses without the field do not newly hide the chip). - _applyReasoningChip shows the chip when hasEffortLadder OR toggleSupported. Empty efforts + toggle=True keeps the chip visible with just the None/On control (the existing _applyReasoningOptions already shows 'none' when the ladder is empty). Empty efforts + toggle=False (GLM-4.7 forced) hides it. - Profile-transition and fetch-failure resets now pass supports_thinking_toggle:false alongside the empty efforts so the chip hides during the unknown-state window, matching the prior reset contract. Tests (30 new): - _zai_glm_classification parametrized across all three tiers + aliases + defer - get_reasoning_status supports_thinking_toggle per tier (GLM-5.2 both, GLM-4.6 toggle-only, GLM-4.7 neither, non-zai defaults to effort capability) - Frontend _applyReasoningChip behavior via node driver: empty efforts + toggle=True stays visible, toggle=False hides, effort ladder alone is sufficient, absent field keeps prior behavior State layer: agent.reasoning_effort config + supports_thinking_toggle field in /api/reasoning + composer chip visibility. Invariant: the chip is hidden ONLY when the model supports neither the effort ladder nor the thinking toggle (GLM-4.7 forced, or genuinely non-reasoning models); GLM-4.5-5.1 retain the working On/None control they had before the round-1 effort gate. * fix(reasoning): make ZAI thinking toggle two-way + force GLM-4.7 stored none Address nesquena-hermes round-3 review on #6219 — two SILENT gaps in the thinking-toggle path, plus a click-handler sibling I found while auditing. Gap #1 — ONE-WAY toggle for GLM-4.5/4.6/5.0/5.1 (api/config.py:4247, static/ui.js:4902, static/index.html:757): The round-2 fix kept the chip visible for thinking-tier models but the only rendered dropdown option was 'None' (the HTML had no Default option, and set_reasoning_effort rejected empty effort with 400). So a GLM-4.6 user could turn thinking OFF but never back ON — worse than the original bug. Fix: - set_reasoning_effort now accepts empty effort as 'clear the override' (removes agent.reasoning_effort so the provider default takes effect). Invalid values still raise ValueError. - static/index.html gains a <div data-effort=''>Default</div> option. - _applyReasoningOptions always shows both Default ('') and None alongside the effort ladder, so a thinking-tier model (empty ladder + toggle=true) renders an operable Default+None two-state control. - Click handler (ui.js:5106) checks option presence (if(opt)) not truthiness (if(effort)) — the old check silently ignored data-effort='' clicks, which would have left the Default button dead even after the HTML/backend changes. Gap #2 — GLM-4.7 not forced when 'none' stored (api/config.py:3984, :3857): When GLM-4.7 had agent.reasoning_effort=none configured, coercion preserved 'none' via the early return at line 3985, so streaming built disabled reasoning for a model that forces thinking on regardless. Separately, when the raw capability source listed 'none', resolve_model_reasoning_efforts reattached it to GLM-4.7's supported_efforts (['none']), leaking an 'off' option to the UI for a forced-thinking model. Fix: - coerce_reasoning_effort_for_model checks _zai_glm_classification == 'forced' BEFORE the generic 'none' early-return, coercing stored 'none' to '' (default = thinking on) for forced models. - resolve_model_reasoning_efforts returns [] early for forced-tier models, skipping the 'none' reattachment entirely. Tests (17 new): - Gap #2: coerce('none', glm-4.7) -> '', regression guard that non-forced GLM still accepts 'none'; resolve does not reattach 'none' for forced but DOES for thinking-tier; end-to-end get_reasoning_status for forced+stored-none. - Gap #1 backend: set('') clears the key (no raise), set('garbage') still raises, all 7 valid levels still save. - Gap #1 frontend: three new node-driver tests asserting the dropdown exposes both Default and None for thinking-tier (two-state), Default+None+ladder for effort-tier, and the off->on->off round trip keeps both visible throughout. - Updated test_reasoning_show_hide.test_set_reasoning_effort_rejects_invalid to reflect the new contract (empty accepted, garbage rejected). Regression gate: gap#2 coerce tests fail without the forced-tier check (3 failures); gap#1 backend test fails with 'ValueError: effort is required' without the empty-acceptance change. 219 passed, 1 pre-existing skip across the full reasoning + chip + config-cache surface. State layer: agent.reasoning_effort config + supports_thinking_toggle field + composer dropdown options. Invariant: the thinking toggle is now genuinely two-way for GLM-4.5-5.1 (Default=on, None=off, both always visible) and GLM-4.7 forced-thinking never offers 'none' in any path (coerce, resolve, status, or UI). * CHANGELOG: GLM per-version reasoning controls (#6219) --------- Co-authored-by: Ruby Hartono <58564005+rh-id@users.noreply.github.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * Release: intercept /sessions and /resume slash commands (#6245, @webtecnica) (#6253) * fix: intercept /sessions and /resume slash commands in WebUI (#6224) Add a native-intercept branch in the block of send() alongside the /pet special-case. When the user types /sessions or /resume, expand the sidebar and refresh the session list instead of sending the raw slash text to the agent. Root cause: the agent command registry exposes sessions/resume as non-CLI-only commands, so the autocomplete popup shows them, but the WebUI send-time dispatch had no branch to catch them, causing the literal text to be sent as a prompt. * fix(commands): use mobile-aware session-browser opener for /sessions /resume (gate follow-up) The intercept called expandSidebar() directly, which is a no-op on phone-width layouts, so /sessions and /resume silently did nothing on mobile (composer cleared, nothing shown). Use the mobile-aware _openProfileSwitchSessionBrowser() first, falling back to expandSidebar(). Reproduced + specified by the pre-release Codex gate. Co-authored-by: webtecnica <webtecnica@users.noreply.github.com> * CHANGELOG: intercept /sessions /resume slash commands (#6245) --------- Co-authored-by: webtecnica <webtecnica@users.noreply.github.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * Release: OIDC allowlist whitespace fix (#6244, @webtecnica) (#6259) * fix: split OIDC allowlist on commas only, preserve scope whitespace-split (#6244) _normalize_text_list is shared with _normalize_scopes — OAuth scopes are space-delimited per RFC 6749 §3.3. Created _normalize_allow_values that splits on commas/newlines only, keeping multi-word group names like 'Hermes Users' intact. * fix(oidc): filter blank allow_values list elements + add parser-split test (gate follow-up) The new comma/newline-only _normalize_allow_values() list-path retained empty strings that the shared _normalize_text_list() had filtered, so a YAML allow_values: [""] would brick an OIDC-only deployment (every callback 403s). Filter stripped-empty collection elements. Adds a regression test asserting allowlist multi-word preservation, comma/newline splitting, blank filtering, and that scopes stay space-delimited (RFC 6749 §3.3). Co-authored-by: webtecnica <webtecnica@users.noreply.github.com> * CHANGELOG: OIDC allowlist whitespace fix (#6244) --------- Co-authored-by: webtecnica <webtecnica@users.noreply.github.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * Release: remove dead rowIndex param from settled-scene pushRow (#6258, @webtecnica) (#6262) * fix(transparent-stream): remove dead rowIndex param from pushRow (#6189 follow-up) In #6189 / #6217 the final-segment eligibility was migrated from an index comparison (rowIndex > lastNonTerminalWorkRowIndex) to a WeakSet lookup (finalSegmentLiveProseRows.has(row)). The rowIndex parameter on pushRow became dead code — it's declared but never read, and the call-site still passes idx from forEach. Remove the unused parameter and simplify the call-site. This addresses the greptile review feedback on the original PR. Closes #6189 * CHANGELOG: dead rowIndex param removal (#6258) --------- Co-authored-by: webtecnica <webtecnica@users.noreply.github.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * Release: byte-size threshold for reconnect tail optimization (#6260, @webtecnica) (#6263) * perf: optimize large session reconnect by adding file-size threshold to tail optimization (#6241) When a sidecar JSON file exceeds 500 KB, the display-path tail optimization now fires even if the message count is within the raw_budget. This prevents sessions with few messages but large tool outputs (multi-MB JSON) from forcing a full-scan merge of all messages on reconnect. Changes: - Added _sidecar_file_exceeds_threshold() helper - Added _SIDECAR_BYTE_TAIL_THRESHOLD = 500_000 constant - Fall-through to truncation in _state_db_since_timestamp_for_limited_display when the sidecar file exceeds the threshold, regardless of message count * CHANGELOG: byte-size reconnect tail optimization (#6260) --------- Co-authored-by: webtecnica <webtecnica@users.noreply.github.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * fix(config): restore read-only-target protection for atomic writes Review finding 2 (10.07 gate): a deliberately locked config (0444) in a writable directory was silently overwritten — atomic replace creates a fresh temp inode and renames over the read-only file, defeating the old in-place Path.write_text PermissionError contract. Probe the existing target with a non-truncating O_WRONLY open before any replacement work and let the PermissionError propagate. The probe fstat()s the fd it actually opened and hands that stat to the rest of the write, so a concurrent writer replacing the inode between stat and probe refreshes the metadata instead of failing (keeps concurrent-writer semantics). Regression: writable parent + 0444 target now raises and keeps the original bytes and mode. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ctl): close all three 12.07 re-gate findings on the systemd guard 1. inherit_errexit: the listener-diagnostic assignments in _port_listener_diag abort status/stop when errexit is inherited into command substitutions (shopt inherit_errexit or a BASHOPTS env from the invoking shell). Guard both assignments with || true; regression test runs status/stop under BASHOPTS=inherit_errexit. 2. PID/port AND: _pid_listens_on_port called lsof -p PID -iTCP:PORT without -a, which OR-combines the selectors — any socket of the PID or any listener on the port matched, so an active unit could be blamed for a port its MainPID does not listen on. Add -a; the new fake lsof mimics real OR/AND semantics so a missing -a fails the test. 3. Binding-aware collision: an active/activating unit whose ownership could not be attributed via MainPID was assumed to own port 8787 unconditionally. Resolve the unit's configured binding first (HERMES_WEBUI_PORT from Environment=, then --port from ExecStart=) and refuse only on actual overlap; the default-port guard remains solely for undeterminable bindings (#3291 semantics). Tests cover the reverse alternate-port case (unit on 9999, start on 8787 proceeds) and the overlap case (unit on the requested port refuses). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: deduplicate configured model badges (#6221) Deduplicate configured model badges so one configured model shows a single picker entry, with provider-collision + colon-bearing-id routing correctness. Thanks @happy5318. Co-authored-by: happy5318 <happy5318@users.noreply.github.com> * Release: deduplicate configured model badges (#6221, @happy5318) (#6268) Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com> * fix(renderer): render data:image URIs as images instead of raw base64 text (#6209) Render data:image URIs as inline images (raster + base64 SVG) instead of raw base64 text, route file:// images through the media pipeline, with a strict allowlist + 2MB cap and img-only data: sanitizer. Thanks @ai-ag2026. Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com> * Release: render data:image URIs as images (#6209, @ai-ag2026) (#6270) Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com> * Fail closed on historical anchor hydration throws * docs(changelog): stamp v0.52.76 stable section (promotion) (#6269) Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com> * ci: docs-only fast-path + minimal docs CI (#6279) * ci: docs-only fast path + minimal docs CI Skip the full pytest matrix + browser smoke on docs/CHANGELOG/README-only PRs (required checks still report green fast via a fail-safe 'changes' gate job), and add a lightweight Docs CI: critical_markdown_check.py (rendering-breaks only, not style) + lychee broken-link check. Detection fails safe (any uncertainty or any non-doc path -> full suite runs). * ci: tighten docs-only detection — extension/type wins over name A code file whose NAME contains README/CHANGELOG (scripts/CHANGELOG_stamp.py, static/README_renderer.js) was wrongly classified docs-only, which would SKIP the test suite on a real code change. Now a path is docs only by doc extension, exact doc basename, or non-code file under docs/. Verified against 16 cases incl. every code-with-docs-name trap. * ci: fix all Fable+Codex gate findings on docs-only fast path - BLOCKER: drop *.txt from is_docs (requirements.txt is a dep manifest — a bump would have skipped the whole test matrix). docs = *.md/.markdown/.rst + bare doc basenames only; strict allowlist, no docs/** denylist. - Rename hole: use 'git diff --name-only --no-renames' so a rename src/app.py -> docs.md reveals the code-side deletion instead of collapsing to the doc dest. - SECURITY: docs-ci.yml no longer interpolates untrusted fork-PR filenames into run: via ${{ }} (which executes $() in a crafted name). File list flows through a file + mapfile-as-args; lychee gets a fixed glob, not the attacker list. Added permissions: contents: read. - Wedge belt-and-suspenders: 'if: ${{ always() }}' on the required test + browser-smoke jobs so a failed 'changes' job can't skip them; step guards treat missing/empty docs_only as run-full. - critical_markdown_check.py: corrected the core rule — a newline in the whitespace AROUND a link destination is valid CommonMark (was a false positive); only a newline INSIDE the destination token, or an unclosed inline link, breaks rendering. Verified full agreement with the markdown-it-py reference parser + 0 false positives on all 43 repo docs. Also blank 4-space indented code + multi backtick spans. Reworded 'block the merge' -> 'break rendering' (non-required). * ci: address Codex re-gate — lint always() + markdown title/unclosed cases - Add if: always() to the lint job too (not currently required, but future-proof against the wedge class if it's ever promoted). - critical_markdown_check.py: handle two more CommonMark cases Codex found — a newline inside a "title" string is legal (skip), and a newline-terminated unclosed dest ([x](url\n at EOF/EOL with no close) is broken (flag). After the destination token ends, valid continuations are ')' or a title opener (" ' (); bare text after the newline is the real break. Verified full agreement with markdown-it-py across 13 cases + 0 FP on all 43 repo docs. * ci: model the CommonMark inline-dest grammar (root-cause fix for markdown checker) Round-3 gate found the title heuristic caused sibling regressions: a parenthesized multi-line title (url (a\nb)) was falsely flagged, and a quote glued into the URL (exa"part) was wrongly treated as a title-start and skipped. Rather than patch more heuristics, replace the ad-hoc newline logic with _scan_inline_dest(), which walks the actual grammar: skip leading ws -> bare dest (balanced parens, ends at ws or the depth-0 ')') or <angle> dest -> after ws the next char must be ')' or a real title opener (" ' () -> else the destination is split across the line (broken). Verified FULL agreement with markdown-it-py across 20 adversarial cases (incl. both round-3 regressions, balanced parens, angle dests, multiline titles) + 0 FP on all 43 docs. * test: pytest suite for critical_markdown_check (42 cases) Durable, repeatable verification for the docs-CI markdown checker: 19 verdict cases + 19 cross-checked against the markdown-it-py CommonMark reference (skips cleanly if the lib is absent) + 3 code-span-safety cases + empty/no-link inputs. Covers both round-3 regressions (parenthesized multi-line title valid; quote-glued-in-URL broken), balanced parens, angle destinations, and multiline titles. 42 passed. * ci: fix 2 grammar edges from Codex round-4 (escaped-> in angle dest, unbalanced bare-dest parens) - Angle dest <...> now honors backslash escapes: [x](<foo\>bar>) renders (the \> is escaped), was a false positive. - Bare dest must have BALANCED parens: [x](foo(\n)) does not render (a '(' stays open when whitespace ends the token) — now returns split, was a false negative. Both verified against markdown-it-py + added as pytest cases. 46 passed, 0 FP on 43 docs. * ci: escaped-newline in angle dest is still a raw newline (Codex round-5) <...> escape handling skipped the char after backslash including a newline, so [x](<foo\<nl>>) returned ok but doesn't render (blockquote on line 2). An escaped newline inside an angle destination is still a raw newline -> split. Preserves [x](<foo\>bar>). Added regression case. 48 pytest cases pass, 0 FP on 43 docs. * ci: mirror escaped-newline guard to bare dest (Codex round-6, GFM-correct) The angle branch already treated backslash-newline as split; the bare branch skipped it, so [x](foo\<nl>bar) returned ok. GFM/CommonMark forbid line endings in bare destinations (GitHub's cmark-gfm won't render it), though markdown-it-py permissively does. Since these docs are GitHub-rendered we follow GFM: flag it. Added as a SPEC_DIVERGENT test case (verdict-asserted, excluded from the permissive parser cross-check). 49 pytest pass, escaped paren/space still valid, 0 FP on 43 docs. * ci: delimiter-aware title scan (Codex round-7 unclosed-link false negatives) Naive text.find(')') matched the TITLE's own ')' not the link's outer ')', so [x](foo (title), [x](<foo> (title), [x](foo "title" all returned ok despite being unclosed links. Now parse the title to its actual closing delimiter ("..", '..', or a (..) that forbids nested unescaped '(' per CommonMark), then require the link's own ')' after optional whitespace. Also catches the nested-paren-title break [x](foo (a (b) c)). Verified vs markdown-it-py; escaped parens + single-quoted titles containing parens still valid. 61 pytest pass, 0 FP on 43 docs. * ci: remove unused variable (ruff F841 in critical_markdown_check) The CI lint gate (ruff forward E9+F+B on new lines) caught a dead 'stripped = line.lstrip()' leftover from an earlier refactor in _blank_code — the fence detection matches on the raw line. Removed. No behavior change (61 pytest pass). --------- Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com> * fix(models): prevent bare-id picker revert when provider hint is empty (#6195) (#6199) Prevent bare-id model picker revert when the provider hint is empty: an ambiguous bare id that collides across provider groups no longer snaps to the default group on re-render. Adds a revert-sensitive regression test and fixes three cross-file test-isolation leaks found while gating. Thanks @webtecnica. Co-authored-by: webtecnica <webtecnica@users.noreply.github.com> * Release: prevent bare-id picker revert on empty provider hint (#6199, @webtecnica) (#6280) Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com> * Release: Artifacts filename-first + session-own-streaming + reduced-motion msg-row (#6161, #6165, #6166, @webtecnica) (#6282) Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com> Co-authored-by: webtecnica <webtecnica@users.noreply.github.com> * fix(wakeup): route async-delegation completions by origin + durable-claim delivery (#6283) Route async-delegation completions by the immutable origin_ui_session_id (exact origin tab) and deliver them through a durable claim/complete/release lifecycle so they arrive exactly once, restart-safe, on both the background wakeup and next-turn drain paths. Combines #6185 (@carlotestor) + #6159 (@sysophelper-droid); supersedes #6002/#6225. Co-authored-by: carlotestor <carlotestor@users.noreply.github.com> Co-authored-by: sysophelper-droid <sysophelper-droid@users.noreply.github.com> * fix(#6240): fall back when test skills symlink is unavailable (#6276) Fall back to a copytree (with read-only handling) when the test-server fixture can't create the skills symlink on native Windows without SeCreateSymbolicLinkPrivilege (WinError 1314). Test-infra only. Thanks @rodboev. Closes #6240. * fix(wakeup): recover terminal process completions after restart (#6287) Recover checkpointed core background processes and rebuild PROCESS_SESSION_INDEX on WebUI startup, so an ordinary terminal(background=True, notify_on_complete=True) proc_* completion that outlives a WebUI restart can still wake its original session. Complements #6283 (which covered async_delegation completions). Thanks @allenliang2022. * Release: bg-process restart recovery (#6287) + Windows test-fixture symlink fallback (#6276) (#6294) Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com> * fix(#6099): make transparent stream activity timestamps optional (#6130) Add an opt-in setting to hide Transparent Stream's per-event timestamp chips while keeping the response footer time visible, for users who found the per-event chips noisy. Thanks @rodboev. Closes #6099. * Release: optional Transparent Stream event timestamp chips (#6130, @rodboev) (#6300) Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com> * Live Stream: add public conversation lifecycle browser gate (#6251) * test: add conversation lifecycle browser gate * test: wait for durable lifecycle settlement * test: harden lifecycle gate cleanup and startup * test: normalize gateway fixture request paths * test: harden conversation lifecycle gate * test: fix lifecycle request failure capture * test: align lifecycle CI dependencies * test: harden lifecycle gate waits * docs: align lifecycle gate setup command * test: harden lifecycle gate persistence wait * ci: scope conversation-lifecycle gate to relevant code paths Only run the playwright browser gate when the chat render/streaming surface it exercises actually changes (static/**, api/**.py, server.py, the test, deps, the workflow). Docs-only and unrelated PRs skip it entirely, keeping CI lean per the docs-only fast-path philosophy. Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com> --------- Co-authored-by: Frank Song <franksong2702@gmail.com> Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com> Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com> * Release: gateway approval_id fallback (#6168) + update-check guard (#6180) + state-dir test isolation (#6305) (#6332) * fix: catch unhandled exception in POST /api/updates/check (defensive hardening) (#6180) * fix: generate non-empty approval_id when gateway approval.request omits it (#6008) (#6168) * chore: mark update-check try/except as defensive-only guard, drop #6086 linkage Per maintainer review, the try/except wrapper is defense-in-depth only — it does NOT fix #6086 (root cause is signal/process-group reaping). Updated log message and added inline comment to make this explicit. Leave #6086 open. * test: isolate state-dir probes from user state * docs(changelog): stamp #6168 approval_id, #6180 update-check guard, #6305 test isolation --------- Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com> Co-authored-by: webtecnica <webtecnica@users.noreply.github.com> Co-authored-by: pxxD1998 <214340659+pxxD1998@users.noreply.github.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * Release: broadcast terminal output to every viewer (#5836, @ai-ag2026) (#6340) * feat(terminal): broadcast output to every viewer instead of one shared queue TerminalSession.output was a single queue.Queue read destructively by the SSE handler. Two tabs/windows viewing the SAME session each open their own EventSource, so two _handle_terminal_output handlers competed on that one queue: every PTY chunk was delivered to exactly one of them. Each tab saw a disjoint half of the byte stream, and only one ever received terminal_closed. Output now fans out, mirroring StreamChannel/SessionChannel: each SSE consumer subscribe()s its own queue (seeded with a bounded backlog so a first/late attach still replays the recent scrollback, preserving the old buffer-until-first- consumer behaviour), and put_output broadcasts to all subscribers. A slow viewer's queue drops its own oldest chunk (drop-oldest, isolated per subscriber) so one lagging tab can't starve another. The handler unsubscribes in a finally so the subscriber list can't grow. Stacked on the terminal fd-leak fix (same file). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: cover terminal broadcast lifecycle * fix: serialize terminal subscriber fanout * test: cover terminal unsubscribe publication race * restore timing-flaky pytest.skip on test_terminal_survives_short_lived_request_thread (keep API-updated body); changelog #5836 --------- Co-authored-by: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * Release: terminal-error settlement timing/seal (#6323) + Docker experimental builds (#6329) (#6341) * fix: preserve timing and seal tool rows on terminal error (#6309) * fix: publish Docker experimental builds to ghcr (#6298) - Add exp-v* trigger to release workflow so experimental tags build and push Docker images - Add :experimental floating tag for experimental channel, keeping :latest scoped to stable v* tags only - Mark GitHub Releases from exp-v* tags as pre-releases - Document available Docker tags (:latest, :experimental, version pins) in docs/docker.md Closes #6298 * docs(changelog): stamp #6323 terminal-error timing/seal + #6329 Docker exp builds --------- Co-authored-by: webtecnica <webtecnica@users.noreply.github.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * Release: content search no longer evicts the working-set cache (#6084, @ai-ag2026) (#6343) * fix(webui): keep the content search from evicting the user's working set /api/sessions/search?content=1 walks EVERY session and pulls each one through get_session(), which inserts it into the SESSIONS LRU and marks it recently-used. On any install with more sessions than sessions_cache_max (default 300), a single search therefore flushes the whole cache and refills it with sessions the user is not looking at — the classic buffer-pool scan-pollution problem. The sessions actually open in the UI are exactly the ones evicted, and the search is keystroke-debounced, so it repeats while typing. A scan reads each session exactly once, so nothing it touches has earned "recently used". get_session_for_scan() reuses a resident session without promoting it, and reads a cold one straight from disk without caching it. It returns None rather than raising, since a scan skips what it cannot open. This is a correctness fix for cache behaviour, not a latency fix. The multi-second searches that led here were contention, not scan cost: a trivial /api/profiles took 9.2s in the same window, and a full read+parse of ~1700 real sessions measures ~4s total. test_sessions_search_depth_validation patched api.routes.get_session. With the search reading through the scan accessor it now patches get_session_for_scan — left unfixed, two of its cases fail and the third passes vacuously against an empty result set. Validation: pytest tests/test_issue4765_sessions_lru_eviction.py tests/test_sessions_search_depth_validation.py -> 13 passed Both added eviction tests fail on the pre-fix accessor (verified by revert): the working set drops from 4/4 to 0/4 resident after one scan. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(changelog): stamp #6084 content-search working-set preservation --------- Co-authored-by: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * Release: pip-installable packaging metadata (#6337, @rodboev) (#6344) * build(#2695): add packaging metadata for the current runtime layout * docs(changelog): stamp #6337 pip-installable packaging metadata --------- Co-authored-by: Rod Boev <rod.boev@gmail.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * Release: Live Stream stable Anchor run identity (#6201, @franksong2702) (#6346) * fix: preserve live anchor run identity * Validate envelope run ids before snapshot cursor use * docs(changelog): stamp #6201 stable Anchor run identity --------- Co-authored-by: Frank Song <franksong2702@gmail.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * Release: fix Kanban column scrolling on mobile (#6306, @jpalazz2) (#6347) * fix: Fix kanban scrolling in mobile viewports Scrolling vertically (particularly in expanded view) in kanban on a mobile viewport is difficult. The columns have overscroll disabled, meaning tap and drag will only scroll within the column and will not continue to the next section. On desktop it's much easier to get the mouse outside the column div, on mobile you have to deliberately try to tap very close to the edge of the viewport. Disabling that behavior makes the experience much better Author: Joe Palazzolo <joe@joepalazzolo.net> * docs(changelog): stamp #6306 mobile Kanban column scroll fix --------- Co-authored-by: Joe Palazzolo <joe@joepalazzolo.net> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * a11y: avoid no-op composer height resets * Release: Live Stream Anchor side-effects projection (#6204, @franksong2702) (#6348) * fix: preserve anchor-owned side effects * test: prove invisible anchor outcomes do not repaint * docs(changelog): stamp #6204 Anchor side-effects projection --------- Co-authored-by: Frank Song <franksong2702@gmail.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * Release: run-journal summary cache (#6291, @sjungwon03) (#6355) * perf(run-journal): cache unchanged run summaries * fix(run-journal): reject cache after missing-file race * run-journal cache: add st_ctime_ns to signature (close same-size mtime-preserving rewrite window) + regression test [maintainer fix on @sjungwon03 #6291] * docs(changelog): stamp #6291 run-journal summary cache --------- Co-authored-by: sjungwon03 <sjungwon03@gmail.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * test: cover composer resize boundaries * Harden historical anchor hydration edges * Release: terminal-error lifecycle gate row (#6354, @franksong2702) (#6358) * test: add terminal-error lifecycle matrix row * test: reject empty terminal process rows --------- Co-authored-by: Frank Song <franksong2702@gmail.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * Release: gateway-default MoA send (#5869, @rodboev) (#6365) * fix(#5853): allow gateway-default MoA sends * fix(#5853): freeze gateway auth to one locked snapshot * docs(changelog): stamp #5869 gateway-default MoA send --------- Co-authored-by: Rod Boev <rod.boev@gmail.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * Release: raster-data-URI redaction fast-path (#6311, @inch772) (#6367) * fix(redaction): skip native raster data URIs * fix(redaction): accept mixed-case raster MIME types * fix(redaction): validate complete raster payloads * docs(changelog): stamp #6311 raster-data-URI redaction fast-path --------- Co-authored-by: Su Ahn Lee <11433303+inch772@users.noreply.github.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * feat(extensions): token-v1 proxy→sidecar authentication boundary (#6331) * feat(extensions): token-v1 proxy->sidecar auth boundary Mint a per-extension secret core injects (X-Hermes-Sidecar-Token) on every proxied request; sidecars validate it. Closes the hole where a loopback sidecar port is reachable by any local process and cannot distinguish a proxied request from a direct one. - api/extension_sidecar_auth.py: per-extension token lifecycle (atomic mint, re-read-verified so an unpersisted token is never injected, per-request mtime-cached read for live rotation, path-escape-safe) - manifest proxy_auth negotiation: absent=legacy, token-v1=enforce, unknown=fail-closed - proxy: inject token, strip inbound + response x-hermes-*, auth-off posture (loopback-only local_unprotected, else 503), fail-closed when token unavailable - consent-time auth_required in status payload; mint-on-consent - docs/EXTENSIONS.md proxy_auth section - 7 new tests (26/26 green) * fix(extensions): address Codex+Fable gate on token-v1 (6 findings) - align token-module extension-id grammar with core _EXTENSION_ID_RE (was narrower -> legally-named ext consented then 503'd forever) - resolve token dir dynamically (mirror _extension_state_dir) -> real test isolation, no import-time STATE_DIR cache - cross-process mint: O_CREAT|O_EXCL no-clobber claim (was os.replace clobber) - rotation cache keyed on full fingerprint (ino/dev/mtime/ctime/size) + re-fingerprint after read -> same-size/mtime replacement no longer stale - validate token format on read (url-safe, 16-256) -> malformed file can't leak via a ValueError echoed in a 502 - consent fails 503 when token can't be provisioned (was silent-swallow -> persisted consent then 503 forever) - rename status auth_required -> posture enum (protected|local_unprotected): nothing is blocked for loopback, so 'required' was misleading - panels.js: render local_unprotected warning on the consent row (+ CSS) - docs: token-path resolution order, 401-vs-503, explicit 'legacy' acceptance - tests: route-level token-injection+response-strip test; fix illusory isolation in token-module test; 27/27 green * fix(extensions): close 2 token-mint races (Codex re-gate round 2) - mint via temp-file + atomic os.replace (not O_CREAT|O_EXCL) so the final path is never observed empty/half-written — a concurrent loser can no longer read an empty token file and 503 - single _stable_read helper (fingerprint-read-refingerprint, bounded retry on mid-read change) used by BOTH ensure_token and current_token — a token that changes during the read is never returned or cached stale - stress-verified: 20 concurrent first-mints converge on 1 persisted token; 27/27 * fix(extensions): atomic no-clobber token publish via os.link (Codex re-gate round 3) os.replace fixed empty-file exposure but still clobbered cross-process: two processes could both write+replace and a reader between them got a token no longer on disk -> 401. Switch to the repo's TOCTOU-safe os.link create-or-fail idiom (session_recovery.py:627): write temp -> link into place (fails if a winner already published) -> loser drops its temp and reads the winner via _stable_read. Proven: 16 concurrent PROCESSES converge on 1 persisted token, all matching disk. 27/27 tests green. * fix(extensions): resolve Frank+Greptile #6331 review — token-v1 fail-closed when auth off (consent+resolution), token-v1-only proxy_auth/posture status fields, fullmatch ext-id validator * fix(extensions): finish Frank and Greptile sidecar review --------- Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * Release: compact completed image tool-results (#6315, @sjungwon03) (#6371) * fix(session): compact completed native vision results * streaming: guard image-part compaction against unhashable part.type (isinstance str) + regression test [maintainer fix on @sjungwon03 #6315] * docs(changelog): stamp #6315 completed-image tool-result compaction --------- Co-authored-by: sjungwon03 <sjungwon03@gmail.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * Preserve historical anchor final text * Release p1-batch: session cache cap 300→100 (#6362) + virtualization comment (#6318) (#6375) * perf(transcript): enable DOM virtualization by default (re-enable #4346 fix) (#6151) (#6155) * fix: revert DOM virtualization default to opt-in, fix gate RED (#6155) * fix(#6351): lower default session cache cap * Release p1-batch: session cache cap 300->100 (#6362) + virtualization comment (#6318) --------- Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com> Co-authored-by: webtecnica <webtecnica@users.noreply.github.com> Co-authored-by: Rod Boev <rod.boev@gmail.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * Release: consolidated Kanban board fills vertical space (#6308) (#6376) * fix: Fix height of consolidated kanban board There was a lot of empty space below the kanban board columns in the condolidated view (particularly on desktop). Modify the CSS such that the consolidated view always fills the viewport vertical space. * Release: consolidated Kanban board fills vertical space (#6308) --------- Co-authored-by: Joe Palazzolo <joe@joepalazzolo.net> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * Release: isolate Hermes home per streaming turn (#5877, @starship-s) (#6379) * fix(profiles): isolate Hermes home per streaming turn Assisted-by: OpenCode:gpt-5.3-codex-spark Assisted-by: Hermes Agent:gpt-5.6-sol Assisted-by: Codex:gpt-5.3-codex-spark * fix(profiles): gate skill isolation by capability Assisted-by: OpenCode:gpt-5.3-codex-spark Assisted-by: Hermes Agent:gpt-5.6-sol Assisted-by: Codex:gpt-5.3-codex-spark * fix(profiles): harden fallback lock boundaries Assisted-by: OpenCode:gpt-5.3-codex-spark Assisted-by: Hermes Agent:gpt-5.6-sol * test(profiles): adapt streaming isolation harness Assisted-by: Codex:gpt-5.3-codex-spark Assisted-by: Hermes Agent:gpt-5.6-sol * Release: isolate Hermes home per streaming turn (#5877, @starship-s) --------- Co-authored-by: starship-s <45587122+starship-s@users.noreply.github.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * Release: Kanban New-Task modal reachable on mobile (#6301, @jpalazz2) (#6384) Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * Release: Artifacts pane long-filename readability + long-parent containment (#6078, @rodboev) (#6386) * fix(#6067): keep artifact file names visible # Conflicts: # static/style.css # static/workspace.js * fix(#6067): bound long parent artifact tails inside the drawer * Release: Artifacts pane long-filename readability + long-parent containment (#6078, @rodboev) --------- Co-authored-by: Rod Boev <rod.boev@gmail.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * Release: add preference to hide new-chat welcome panel (#6183, @vaidu-ai) (#6387) * feat: add option to hide new-chat welcome panel * Release: add preference to hide new-chat welcome panel (#6183, @vaidu-ai) --------- Co-authored-by: vaidu-ai <im@vaidu.net> Co-authored-by: nesquena-hermes <agent@nesquena-hermes> * fix(stream): preserve reader viewport anchor through live-to-settlement collapse Issue #6385: when a streaming turn settles, the two-render sequence (keep-open expanded worklog → collapsed worklog) could displace the reader's viewport because the second render captured its scroll snapshot from the intermediate expanded state, not from the original live DOM. Root cause ---------- The STREAM_DONE handler in messages.js: 1. Arms keep-settled-worklog-open token → renderMessages({preserveScroll:true}) → worklog rendered EXPANDED (height-stable swap preventing shrink jump) 2. Disarms token → _renderMessagesWithScrollSnapshot() → This function called _captureMessageScrollSnapshot() which captured the scroll anchor from the expanded-worklog DOM (step 1 output), then called renderMessages with the worklog COLLAPSED (keep-open gone), then tried to restore from the expanded-state snapshot. The snapshot's semantic anchor (row key, session idx, top offset) was captured from a DOM where the worklog was expanded. After the collapse render the worklog is no longer at that position — anchor keys don't match, the semantic restore fails, and the viewport jumps to a unrelated scrollTop. Fix --- - Capture the scroll snapshot from the LIVE DOM (before any settlement renders) and pass it as to the second render. - Modify _renderMessagesWithScrollSnapshot() to accept a pre-captured snapshot via options._prescro…

Release: intercept /sessions and /resume slash commands (#6245, @webtecnica)
Ships @webtecnica's fix so
/sessionsand/resumeopen the session browser instead of being sent as chat text, gated + hardened for mobile.What ships
Both commands appeared in the slash-command autocomplete (exposed by
/api/commands) but the WebUI send-time dispatch didn't handle them, so they were posted to the agent as plain text. They now open the native WebUI session browser and clear the composer.Gate + maintainer fix
Codex verified no agent-side behavior is lost (unmatched slash text goes to
run_conversation), built-in/CLI/plugin command precedence is intact, and args-ignored (/resume <id>) is a noted limitation (WebUI never executed those handlers before), NOT a regression — 34/34 slash tests pass. One SILENT mobile bug found + fixed (maintainer follow-up, co-authored to @webtecnica): the intercept calledexpandSidebar()directly, which is a no-op on phone-width layouts, so/sessions//resumesilently did nothing on mobile. Now uses the mobile-aware_openProfileSwitchSessionBrowser()with anexpandSidebar()fallback.Verification
Closes #6245
Credit: @webtecnica.