The new top-level mcp: config section surfaces exactly one field
(auto_reload_on_config_change) in the dashboard settings schema, which
tripped the no-single-field-categories invariant. Merge it into the
agent tab like onboarding/computer_use.
Follow-up on the salvaged #67449: auxiliary.mcp is the side-LLM task
provider block (provider/model/timeout for MCP aux calls) — a watcher
behavior toggle doesn't belong there. Move it to a new top-level mcp:
runtime section and read it from the same freshly-parsed config.yaml the
watcher already diffs (no second load_config() per tick, and flipping the
toggle + editing mcp_servers in one edit behaves correctly).
Also adds a regression test for the salvaged #55701 false-positive fix:
${VAR} templates in mcp_servers made the raw-yaml-vs-expanded-snapshot
comparison permanently unequal, so ANY save_config_value() rewrite (e.g.
/reasoning changing agent.reasoning_effort) fired a full MCP reconnect.
Credits: @OYLFLMH (#55701 env-expand fix), @TurgutKural (#67449 opt-out).
The opt-out default was declared in DEFAULT_CONFIG["auxiliary"]["mcp"][...]
but the watcher in _check_config_mcp_changes() read top-level
load_config().get("mcp") — a key that does not exist in the loaded
config shape. Consequently the declared default was never observed and
the fallback stayed True at runtime: setting auto_reload_on_config_change
to false in config.yaml silently did nothing.
Resolve through the same path the default is declared on:
cfg["auxiliary"]["mcp"]["auto_reload_on_config_change"]
Tests:
- test_optout_disables_auto_reload: mocked config now mirrors the real
DEFAULT_CONFIG shape (auxiliary.mcp), so the test exercises the actual
lookup path instead of a separately mocked shape.
- test_optout_path_is_auxiliary_mcp_not_top_level: regression guard — a
config that sets ONLY top-level mcp.auto_reload_on_config_change=false
must NOT disable the reload. This pins the config-path contract so a
future regression to _cfg.get("mcp") is caught.
Addresses sweeper review: the declared default was never observed at
runtime because the watcher read a different config path than the one
where the default was defined.
Co-authored-by: Turgut Kural <turgut.kural@gmail.com>
The automatic MCP reload added in #1474 watches config.yaml's mcp_servers
section every 5s and reloads on any change. Every reload rebuilds the agent
tool surface and INVALIDATES the provider prompt cache — the next message
re-sends the full input prefix, which is expensive on long-context /
high-reasoning models. When config.yaml is rewritten frequently (external
tooling, multiple Hermes instances, or a flapping MCP server that rewrites
config), this causes silent, repeated cache-breaking reloads.
Add `mcp.auto_reload_on_config_change` (default: true, backward compatible).
When set to false:
- The config change is still DETECTED (watcher keeps running).
- No automatic reload happens.
- The user is told the config changed, that new settings are NOT yet
applied, and how to apply them on their own terms with /reload-mcp —
including the explicit warning that /reload-mcp invalidates the prompt
cache.
Manual /reload-mcp is unaffected and still works for users who want to
apply changes deliberately.
Tests: extend TestMCPConfigWatch with test_optout_disables_auto_reload.
Co-authored-by: Turgut Kural <turgut.kural@gmail.com>
_check_config_mcp_changes compared mcp_servers from two inconsistent
sources:
- init: self.config.get('mcp_servers') -> from load_config() + _expand_env_vars -> expanded values
- watcher: yaml.safe_load(cfg_path) -> raw templates
When mcp_servers uses env-var templates like ${POWERMEM_API_KEY},
every save_config_value() that rewrites config.yaml (even for unrelated
keys) triggers a false-positive MCP reload, reconnecting all servers.
Apply _expand_env_vars() to the raw watcher value before comparison
so both sides use the same expanded representation.
Test plan: tests/cli/test_cli_mcp_config_watch.py (6/6 pass)
Follow-up to the salvaged #66083/#42792 commits:
- alibaba (Qwen Cloud coding-intl) gets qwen3.7-plus too — same platform
allowlist as alibaba-coding-plan (issue #44662 comment by @coder-movers)
- qwen3-max substring context entry (262144) so the newly-listed
qwen3-max-2026-01-23 snapshot doesn't fall to the generic 131072 qwen
fallback
Add qwen3.7-plus to DEFAULT_CONTEXT_LENGTHS with 1M context window.
Without this entry, the model falls back to the generic 'qwen' entry
(128K), causing premature context compression at 50% (64K tokens)
instead of the correct 500K threshold.
Official docs: https://help.aliyun.com/zh/model-studio/developer-reference/
The model list for alibaba coding plan is currently out of sync with
the actually supported models. See the official documentation[1].
Per the docs, alibaba coding plan does not support qwen3.7-max;
it supports qwen3.7-plus instead. Additionally, qwen3-max-2026-01-23
was missing from the model list.
Changes to the alibaba-coding-plan model list:
- Replace qwen3.7-max with qwen3.7-plus
- Add qwen3-max-2026-01-23
[1] https://www.alibabacloud.com/help/en/model-studio/coding-plan
The supermemory SDK already honors SUPERMEMORY_BASE_URL, but the raw
urllib call used for session-end conversation ingest hardcoded
https://api.supermemory.ai/v4/conversations, so ingest always hit the
cloud even when pointing at a self-hosted server (e.g.
http://localhost:6767).
Resolve the base URL as config (supermemory.json base_url) >
SUPERMEMORY_BASE_URL env var > https://api.supermemory.ai, strip any
trailing slash, and use it for both the SDK client and the
/v4/conversations ingest endpoint.
Phase 2c found 3 tests that called _run_and_exit_oneshot without
mocking _cleanup_oneshot_runtime, causing real cleanup (terminal,
browser, MCP, auxiliary) to run in the pytest worker. Add the mock
to all three for test isolation.
Also remove redundant 'import logging' inside _exit_after_oneshot
(already imported at module level, line 729).
The initial salvage from #43698 only shut down MCP servers and cached
auxiliary clients. The interactive CLI's _run_cleanup() also closes
terminal environments, browser sessions, and interrupts async
delegations — all of which can hold native-extension-backed resources
(aiohttp connectors, websocket clients) that SIGABRT during
Py_FinalizeEx.
Add the missing three sites to _cleanup_oneshot_runtime(), matching the
order in cli.py:_run_cleanup(). Update tests to cover the expanded
cleanup chain.
Credit: @konsisumer (#67768) identified the full cleanup surface.
Follow-up fixes for salvaged PR #67686 (issue #67639):
1. Windows regression: bare 'import fcntl' at module level in entry.py and
slash_worker.py would crash on Windows (fcntl is POSIX-only). entry.py
explicitly aims to 'import cleanly on Windows'. Extracted all fcntl/socket
logic to tui_gateway/_stdin_recovery.py with try/except import guards.
2. fd leak: socket.fromfd() dups fd 0; s.detach() returned the fd number
without closing it, leaking one fd per recovery call. Changed to s.close()
(safe — fromfd duped the fd, closing won't close stdin).
3. Code duplication: ~80 lines of recovery loop + diagnostic were copy-pasted
between entry.py and slash_worker.py. Extracted to shared
tui_gateway/_stdin_recovery.py (handle_spurious_eof + diagnose_stdin_state).
4. Profile-unsafe path: scanner used Path.home() / '.hermes' / 'plugins' but
canonical plugin discovery uses get_hermes_home() / 'plugins'. With
HERMES_HOME pointing elsewhere, scanner missed the actual plugin dir.
Also gated project plugins behind HERMES_ENABLE_PROJECT_PLUGINS to match
hermes_cli/plugins.py.
5. SO_RCVTIMEO not cleared: recovery only called os.set_blocking(0, True)
but a child that set SO_RCVTIMEO would cause the next readline to time
out and loop. Now clears SO_RCVTIMEO alongside O_NONBLOCK.
The copy-paste config sample had reasoning_effort: low active, which
would silently downshift effort for anyone pasting the block. Keep it
commented like other optional keys. Also add the contributor email
mapping for the salvage.
The dict-form guard from PR #67878 only covered the mapping shape
({model: {context_length: ...}}). The list-of-dicts shape
([{id: model, context_length: ...}]) is also a supported config form
(per _declared_model_ids) and was still being replaced with a flat
list of strings, destroying per-model metadata.
Sibling site for #67841.
When custom_providers[].models uses the mapping form to store
per-model metadata (e.g. context_length), _save_discovered_models_to_config
must not replace it with a flat list of strings. Add a guard that skips
entries whose models value is a dict, preserving the user's curated
metadata.
The regression was introduced by PR #65652, which added the auto-save
helper without considering the dict form.
Consolidates the three Qwen provider slugs (alibaba / Qwen Cloud,
alibaba-coding-plan / Alibaba Cloud Coding Plan, qwen-oauth / Qwen CLI
OAuth) under a single 'Qwen' group row in the interactive provider
pickers, matching the existing OpenAI / Kimi / MiniMax / xAI groups.
Display-only via PROVIDER_GROUPS — slug identity, --provider, and
/model <provider:model> paths are unchanged. Because group_providers()
is the shared fold, the CLI 'hermes model' picker, the setup wizard,
and the Telegram /model keyboard all pick up the grouping with no
per-surface changes.
#60194 flipped SessionResetPolicy's default to mode: none, but
cli-config.yaml.example still shipped session_reset.mode: both. Every
install path (install.sh, install.ps1, docker stage2-hook, hermes
doctor) copies the template verbatim to ~/.hermes/config.yaml, so fresh
installs got an EXPLICIT mode: both that overrides the code default —
users hit 24h-idle resets with 'nothing' in their config enabling it.
- cli-config.yaml.example: session_reset.mode both -> none, comments
rewritten to describe auto-reset as opt-in
- docs/session-lifecycle.md: appendix example updated to match
- tests/gateway/test_config.py: invariant tests — template seed, absent
config, and mode-less session_reset block all resolve to mode none;
explicit opt-in still honored
_find_tail_cut_by_tokens aligns cut_idx away from tool-call/result
boundaries (_align_boundary_backward), and both tail anchors re-align after
moving it. The final statement then raised the result to head_end + 1 so
compression always claims at least one message — without that floor the
caller's compress_start >= compress_end guard turns the pass into a no-op
that re-runs forever.
That raise discarded the alignment. When the floor landed inside a tool
group, the parent assistant(tool_calls) fell in the summarised region while
its tool results started the tail, and _sanitize_tool_pairs dropped those
orphans outright — so the tool output was neither summarised nor kept. It
vanished. That is exactly the silent loss _align_boundary_backward's own
docstring says the alignment exists to prevent.
Two back-to-back tool calls are enough to trigger it on default settings
(protect_first_n=3):
system, assistant(call_1), tool, tool, assistant(call_2), tool
aligned cut = 4 (keeps call_2's group together)
returned cut = 5 (floor overrode it)
summarised region = [assistant(call_2)]
tail = [tool(call_2)] -> orphan -> dropped
Sweeping every well-formed block layout up to length 6 (21840 transcripts),
5623 of them — 26% — split a call/result pair this way.
Re-align FORWARD after applying the floor. Forward, never backward: pulling
back would hand return the message the floor just claimed and reopen the
no-op loop. Sliding forward instead moves the cut past the end of the group,
so the whole call/result pair is summarised together and nothing is
orphaned. The same sweep reports 0 violations after the change, and the
progress guarantee is pinned by its own test.
Salvage of PR #67447 — the original PR fixed 3 of 7 missing keys.
gateway/config.py reads 4 more top-level keys (stt_echo_transcripts,
reset_triggers, always_log_local, filter_silence_narration) that
produced the same false 'Unknown top-level config key' warning.
Add all 4 and extend the regression test to cover them.
Regression for known_plugin_toolsets / group_sessions_per_user /
thread_sessions_per_user so validate_config_structure no longer
false-positives on keys Hermes owns.
Hermes writes known_plugin_toolsets via tools_config and bridges
group_sessions_per_user / thread_sessions_per_user in gateway/config,
but doctor treated them as unknown top-level keys. Add them to
_EXTRA_KNOWN_ROOT_KEYS so validation matches keys Hermes itself uses.
The delivery ledger durably records a final response before the send so a
crash between finalize and platform ACK can redeliver it on the next boot.
attempts is that redelivery budget, capped at MAX_ATTEMPTS=3.
sweep_recoverable() claims every dead-owner row and increments attempts
before the caller knows whether it can send. self.adapters only holds a
platform after its connect() succeeded, so when the platform failed to
connect this boot _redeliver_pending_obligations() hits its "adapter is
None" branch and continues WITHOUT sending — but the attempt is already
spent. Three such boots and the row abandons, having never been sent once.
That is the loss the ledger exists to prevent, and the trigger correlates
with the crash that created the obligation: the network trouble that killed
the send tends to still be there on the next boot. Worse, the message stays
lost — once abandoned it is never retried even after the platform recovers.
Reproduced against the real runner with an unconnected adapter:
boot 1: claimed=1 state='attempting' attempts=1 (0 sends attempted)
boot 2: claimed=1 state='attempting' attempts=2 (0 sends attempted)
boot 3: claimed=1 state='attempting' attempts=3 (0 sends attempted)
boot 4: claimed=0 state='abandoned' attempts=3 (0 sends attempted)
Let the caller declare which platforms it can send on, and skip claiming
rows for the others. attempts then only ever buys a real send. Rows for a
platform that never returns are still bounded by the stale cutoff, so
nothing accumulates. The parameter is keyword-only and optional — omitting
it keeps the previous claim-everything behaviour for other callers.
A `persist:` partition's cookie store hydrates lazily, so the first
cookies.get() on a fresh launch can return empty for a signed-in user.
That false-negative made hasLiveOauthSession() throw "not signed in",
which on the no-retry initial boot path surfaced as the transient
"Hermes couldn't start" OAuth overlay that always cleared on Retry.
hasLiveOauthSession now reads once (no added latency on the happy path);
only on an empty read does it warm the store (flushStorageData + a
throwaway get, memoized) and re-read with a bounded ~180ms backoff
before trusting the negative. Genuinely signed-out users still resolve
false quickly and get the overlay. Fixes the whole class: the same
function backs the reconnect path and the Settings connected indicator.
* fix(desktop): scope multi-pane model UI and stabilize tile chrome
Composer model controls were still keyed off the primary session globals, so every tile showed the same model and a busy primary blocked switches in idle panes. Bind the pill/menu/select path to SessionView, force lone session-tile headers (incl. after tab cycle), and persist strip order so add/remove/switch stops scrambling adjacent panes.
* fix(desktop): scope preset effort/fast writes per surface, simplify tile order sync
A tile's model pick still pushed effort/fast onto the primary composer globals via applyModelPreset — scope it to the surface (primary → globals, tile → its session slice). Tile order persistence drops the before-stamping walk for a plain sort by tree encounter order; restore replays the array sequentially so array order is strip order.
* test(desktop): cover tile strip-order + selection-home; fix stale docs
Extract syncTileStripOrder's sort into a pure `orderTilesByTree` and the
selection listener's guard into `selectionHomesToWorkspace` (same shape as
the PR's lone-header extraction), then unit-test both — the two store
behaviors that shipped without coverage. Correct the `anchor`/`before` docs
(now persisted, not in-memory) and note that a tile's effort/fast edit still
writes the shared per-model preset even though the session write is scoped.
* fix(desktop): drop forbidden import() type annotations in model tests
`importOriginal<typeof import('…')>()` trips consistent-type-imports (error)
and reddens the desktop lint job. Switch to the repo's accepted top-level
`import type * as X` + `typeof X` form, matching skills/index.test.tsx.
* perf(desktop): idle-mount boot-hidden panes off the cold-start critical path
The layout tree keeps a chrome-hidden pane's content MOUNTED behind
display:none (so toggling back is instant) — but that means files, preview,
review (Shiki diff) and logs all mount their real content during first paint
even though none are visible at launch (fresh profile: no cwd, review off,
no preview target, logs not in the default tree). First paint only needs
sessions + workspace + statusbar; the rest is pure app-mount tax, the one
cold-start lever that's actually in our code (Electron startup and the
un-splittable bundle eval are not).
Wrap those four pane renders in <IdleMount>: mount on requestIdleCallback
(2s timeout fallback), then stay mounted. Idle fires within a frame of first
paint, so a hidden pane is warm before it can be revealed — zero UX change,
the instant-toggle contract intact. Degrades to eager mount where rIC is
absent (jsdom/tests), so no behavioral fork.
* refactor(desktop): collapse the four idle-mount wrappers into one idle() helper
findBy*/waitFor default to a 1000ms deadline, which is too tight for
async-heavy settings panels (radix menus + refetch chains) when the full
suite runs under xdist CPU contention in CI. toolset-config-panel.test.tsx
has reddened unrelated PRs multiple times with `Unable to find ...` timeouts
that pass on re-run — the textbook contention flake.
Bump asyncUtilTimeout to 5000ms in the shared ui setup. Success still
resolves the instant the node appears; the wider deadline only absorbs a
starved runner, so happy-path speed is unchanged and only genuine failures
wait longer.
Two tool-render wins during streaming / on session switch:
1. Every ToolEntry did useStore($activeSessionId)+useStore($currentCwd), so any
session or cwd change re-rendered *every* mounted tool row — but they're only
read inside the preview-artifact effect. Read .get() at fire time instead
(the effect only runs when a previewable target appears); no subscription.
2. memo() AnsiText + CompactMarkdown. Their text props are string values
(value-equal across renders), so memo skips the re-render — and the per-tick
ANSI parse / Streamdown re-run — when a parent ToolEntry re-renders on an
unrelated stream delta.
No behavior change. typecheck + eslint clean; tool fallback tests green (30).
buildToolView ran prettyJson (JSON.stringify + clamp) on part.args AND part.result
for EVERY tool row, on every rebuild:
- rawArgs was dead — assigned + typed, never read anywhere. Removed.
- rawResult is only rendered by the web_search raw-JSON drilldown, yet was
serialized for read_file/terminal/every tool. Moved to a memoized, web_search-
only computation in the consumer (fallback.tsx), so a 100KB read_file result
is no longer stringified just to be discarded.
No behavior change (web_search drilldown identical; clamp still applies via
prettyJson). The oversized-result guard test retargets from view.rawResult to
prettyJson (its real layer now).
typecheck + eslint clean; fallback-model tests green (26).
Both drag handlers wrote to nanostores on every pointermove — the pane sash via
setPaneWidth/HeightOverride / setTreeSplitWeights (relayouts the whole pane
tree), the preview console sash via consoleState.setHeight (reflows webview +
split). pointermove outpaces 60fps, so that's several store-driven relayouts per
frame during a drag.
Stash the latest clamped value and apply it once per frame in a requestAnimation-
Frame (the same pattern drag-session.ts / use-popout-drag.ts already use);
cleanup cancels the pending frame and commits the final position. Behavior
identical, just one relayout per frame instead of per event.
typecheck + eslint clean; preview-pane tests green.
Rewrite of the paradigm, not just a cheaper version of it. Before, any file
mutation bumped a contentless $workspaceChangeTick and the tree re-read EVERY
loaded directory to diff — the parent state was never told what actually changed.
Now the mutation carries its path:
- workspace-events accumulates the changed dir(s) (dirname of an absolute tool
path) and exposes consumeWorkspaceChange(); an opaque mutation (terminal, or a
relative/unresolvable path) sets `full` instead.
- gateway-event passes toolChangedPath(payload) through on tool.complete.
- revalidateTree(cwd, change) re-reads ONLY the changed dirs that are loaded and
patches just those subtrees — root + untouched folders never hit the FS or
re-render. Full recursive reconcile is kept as the fallback for `full`.
So a write in one folder no longer crawls the whole tree; the opaque terminal
case still self-heals via the full path. Safe fallback everywhere a path can't be
resolved, so no change is ever missed.
typecheck + eslint clean; use-project-tree / right-sidebar / gateway-events tests green.