* 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>
* 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>
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.
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.
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.
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>
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>
* 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>
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>
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>
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>
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>
* 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(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>
* 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>
* 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>
* 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>
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>
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>