Fix gateway /resume leaking cached agent state across session switches - #10702
Fix gateway /resume leaking cached agent state across session switches#10702Junass1 wants to merge 1 commit into
Conversation
QA SummaryResult: QA FAILED for PR #10702. Why this PR was selected
Scope checked
Evidence reviewed
Validation run
Environment limits / honesty note
Verdict
Follow-up / fixes
|
drousselbot
left a comment
There was a problem hiding this comment.
QA Summary
Result: QA FAILED for PR #10702.
Why this PR was selected
- Deterministically selected by the local workflow dispatcher for the
qalane. - The PR currently had no workflow labels, so this run treated it as ownerless intake triage rather than a normal labeled handoff.
Scope checked
- PR claim:
/resumeshould clear chat-keyed cached runtime state so resuming one session cannot inherit cached agent/model state from the previously active session.
Evidence reviewed
- PR description and diff
- GitHub Actions status for PR #10702
- Failed
testjob logs from Actions run24490433199 - Independent reviewer pass/fail check on the live diff
Validation run
python -m pytest tests/gateway/test_resume_command.py -qpython -m pytest tests/gateway/test_agent_cache.py -qpython -m pytest tests/gateway/test_session_model_reset.py -q
Environment limits / honesty note
- The targeted gateway tests above passed locally, so the narrow code path looks plausible.
- I did not mark QA passed because the PR still has a failing GitHub Actions
testcheck, and the PR is not linked to a GitHub issue, so the requested scope is not traceable to an issue-side acceptance target. - The failing
testjob contains many failures outside the touched files, but the required check is still red on this PR, so QA must fail closed.
Verdict
- Handing back to dev.
- Blocking reasons:
- required CI check
testis failing on the PR - no linked issue / issue-side traceability for the claimed fix
- required CI check
Follow-up / fixes
- Applied dispatcher transition:
qa-fail - Expected workflow result: remove
needs-qa/qa-passedif present and addqa-failed - Recommended next step: relabel/requeue only after CI is green and the PR is linked to its originating issue
CorrectionThe earlier QA summary incorrectly stated that the dispatcher What actually happened:
This is a workflow-gap / intake-triage defect in the current repo state, not a QA pass. |
|
Closing as a duplicate — we're handling the cached-agent-state-leak class through #10710 (evict cached agent on session boundary). Your fix here targets the Thanks for the contribution, @Junass1 — the analysis was correct and your authorship is noted. Follow #10710 for the merged work. |
|
Closing as a duplicate — we're addressing the Thanks for the report and fix, @Junass1 — your analysis of the cross-session leak (session-scoped model overrides, env-passthrough, and credential state surviving a |
/resume is a conversation boundary, but unlike /new it did not clear the chat-keyed _session_model_overrides / _pending_model_notes. A /model switch made in the previous session under the same chat session_key leaked into the resumed conversation, running it on the wrong model. Clear both maps for the session_key after the switch (mirroring /new), scoped to that key so other chats' overrides are untouched. The cached-agent eviction this leak also implied already landed via #6672. Closes #10702.
|
Your fix was salvaged onto current One note on scope: the cached- Thanks for the catch and the clean writeup of the session-key vs session-id distinction. |
…lations (NousResearch#1096) (#4) * fix(desktop): make project "Add folder" picker remote-gateway aware The new-project / add-folder dialog (PR #49037) picked folders via the native Electron dialog (pickDefaultProjectDir), which only browses the LOCAL machine. On a remote gateway that picks a path that doesn't exist on the backend where sessions actually run. Route pickProjectFolder() through selectDesktopPaths({directories, multiple:false}) — the same remote-aware path the retired right-sidebar picker used: local mode opens the native directory dialog, remote mode browses the backend filesystem via the in-app RemoteFolderPicker. Seed it with the backend's default cwd on remote so it opens somewhere useful. * style(desktop): tighten pickProjectFolder comment * feat(desktop): make the git cockpit work over a remote gateway After the folder picker fix, an added remote folder was still half-usable: the desktop's git GUI (coding-rail status, worktree lanes, review pane, branch switch, file diff) all ran Electron-local git on the USER's machine, so against a remote-gateway repo they silently degraded to empty. Mirror the whole surface over the dashboard REST API so it acts on the BACKEND repo where sessions actually run: - hermes_cli/web_git.py: git/gh logic (status, worktrees, branches, review list/diff/stage/unstage/revert/commit/commit-context/push/ship-info/ create-pr, file-diff, worktree add/remove, branch switch) shelling to the system git, mirroring the Electron ops' shapes. - web_server.py: /api/git/* routes (same auth gate + _fs_path hardening as /api/fs, executor-offloaded, mutations -> 400). - apps/desktop desktop-git.ts: remote-aware facade exposing the same shape as window.hermesDesktop.git; coding-status / review / projects / model / desktop-fs route through desktopGit() so local stays Electron, remote hits /api/git/*. Tests: tests/hermes_cli/test_web_server_git.py (real repo: status counts, review classification, diff incl. untracked all-add, stage+commit roundtrip, worktree/branch lifecycle, commit-context, gh-absent ship-info, auth) and desktop-git.test.ts (local vs remote routing, envelope unwrap, POST bodies). * refactor(web_git): unify porcelain-v2 parsing into one walker Collapse the two near-duplicate status parsers (_parse_status_v2 + _iter_status_entries) into a single _walk_entries generator feeding the rail, review list, and commit flow; share the staged predicate; hoist `import re`. Behavior unchanged. * fix(desktop): write project IDEA.md through the remote-aware fs path writeProjectIdea used the local-only Electron writeTextFile, so on a remote gateway IDEA.md never landed on the backend (where the project folder lives). Route it through writeDesktopFileText (local Electron / POST /api/fs/write-text). * fix(desktop): route composer context picking through remote-aware fs Second pass on the remote-project flow: the project dialog and git cockpit were remote-aware, but the composer's Add file/folder context picker still called the native Electron picker directly. Route it through selectDesktopPaths so remote sessions use the backend-aware picker instead of local disk paths; preserve local multi-select behavior and keep remote folder selection single because the in-app remote picker only supports one directory. Also use readDesktopFileDataUrl for image previews so an already-known backend image path can be read through /api/fs/read-data-url, and add focused coverage for backend file-diff routing plus the plain-folder git init/worktree path. * refactor(desktop): centralize remote git REST routing Keep the remote git mirror as a thin facade: route all GETs through gitGet, all mutations through gitPost, and keep consumers on desktopGit(). On the backend, route git paths through a single _git_path helper instead of repeating str(_fs_path(...)) in every endpoint. Behavior unchanged. * refactor(desktop): keep remote fs routing inside the fs facade Let UI callers ask for folders/files without knowing remote-picker limits: selectDesktopPaths now normalizes remote directory selection to a single folder inside the facade. Project creation and composer context picking no longer branch on remote mode; they route through desktop-fs helpers just like git callers route through desktopGit(). Behavior unchanged except remote folder context now works through the same backend picker path. * test(desktop): assert new backend sessions carry workspace cwd Pin the desktop-to-gateway cwd handoff: createBackendSessionForSend must pass the current workspace cwd into session.create so the backend registers the session cwd before the agent/tools run. * docs: reconcile docs with code across last 3 releases (#54254) Audited the last 3 releases (v2026.5.28..main) against the docs site and fixed code-vs-docs drift: - slash-commands: add /moa, /prompt, /pet, /hatch, /timestamps - cli-commands: add hermes pets / project / desktop / whatsapp-cloud + dashboard register; correct --insecure (now a deprecated no-op); add gateway migrate-legacy + enroll --wake-url + dashboard --skip-build - environment-variables: document the remaining ~48 env vars (SimpleX, Photon, Teams adapter, per-platform *_ALLOW_ALL_USERS, home-channel vars, IRC, Brave/Krea/Notion/Linear/Airtable/Tenor keys, QQ_SANDBOX) — full OPTIONAL_ENV_VARS (265) now covered - configuration: document tool_loop_guardrails, goals, prompt_caching, network, onboarding, dashboard config blocks - toolsets/tools-reference + tools.md: add coding/project toolsets and read_terminal/project_* tools; remove the stale messaging toolset and send_message agent tool (removed in #47856); drop stale RL-training prose - messaging: new IRC channel page (adapter shipped without docs) + index row + sidebar + env vars - pets: document the /hatch AI generation pipeline + Nous/OpenRouter image backend - web-dashboard: document the bearer-token / TokenPrincipal service auth path - purge agent-callable send_message references across guides/features and the research-paper-writing skill (tool removed in #47856) Verified: docusaurus build succeeds; all authored internal links resolve. * fix(windows): cover remaining console-flash spawn legs (#54417) * fix(desktop): remote project picker UX and profile-scoped fs/git routing Route FS/git REST through the active profile, mount the remote folder picker at app root, keep the project dialog open while picking, show a first-run blank state, flip into grouped view on create, and constrain the picker scroll area so Select stays reachable. * fix(browser): extend private-network guard to browser_get_images The SSRF cluster (7a6fe9bb, 48f5c425, 7ef04ae7) sealed browser_snapshot, browser_vision, and _browser_eval against eval-navigated private pages, but browser_get_images bypasses _browser_eval and calls _run_browser_command("eval", ...) directly. An eval-driven navigation to a private address followed by browser_get_images would leak image src URLs and alt text from the private page. Add the same _eval_ssrf_guard_active + _current_page_private_url recheck before returning image data, matching the pattern established by the sibling guards. 5 new tests cover: block on private page, allow on public page, skip for local backend, skip when private URLs allowed, no guard needed on failed eval. * fix(telegram): reject unauthorized users before event construction (#40863) Removed/unauthorized Telegram users could inject prompt content before the per-user auth gate fired. The adapter ran `_should_process_message`, `_build_message_event`, and text/photo batching — and dispatched to the runner — before `_is_user_authorized()` (gateway/authz_mixin.py) rejected the sender. Unmentioned group chatter from a removed user was also persisted into the session transcript via `_observe_unmentioned_group_message`, leaking into the agent's observed context independent of dispatch. Add `_is_user_authorized_from_message()` as an intake prefilter that runs in `_handle_text_message`, `_handle_command`, `_handle_location_message`, and `_handle_media_message` BEFORE batching, event construction, and the unmentioned-group observe branch. It reuses the runner's `_is_user_authorized()` with a correctly-shaped SessionSource (group vs forum vs dm, real chat_id for TELEGRAM_GROUP_ALLOWED_* allowlists), falls back to env allowlists, and only rejects when an allowlist actually exists — unknown DMs with no allowlist still reach the pairing flow. Channel posts authorize via `sender_chat` identity when `from_user` is absent. Co-authored-by: liuhao1024 <sunsky.lau@gmail.com> Co-authored-by: Carlos Manuel Cejas <carlosmcejas@gmail.com> * test(web_git): assert default branch invariant, not hardcoded main CI git init defaults to master on some runners; compare branch to defaultBranch instead of pinning a branch name. * fix(daytona): quote single-upload mkdir parent path (#54440) * fix(daytona): quote single-upload mkdir parent path The single-file _daytona_upload() path shelled out 'mkdir -p {parent}' with the remote parent interpolated unquoted, so shell metacharacters in the path could break the command or inject arbitrary commands into the sandbox. The bulk-upload, bulk-download, and delete paths were already hardened with shlex-quoting helpers; this single-upload path was missed. Route it through the existing quoted_mkdir_command() helper and add a regression test covering a path with shell metacharacters. Reported by @Gutslabs (#3960); the original branch predated the file_sync refactor, so the fix is re-applied to the current code path. * docs(infographic): daytona quote-sync fix * fix(windows): repair missing hermes.exe after pip install (#52931) On Windows, uv pip install -e . can register hermes.exe in package metadata while the launcher never lands on disk. Detect missing [project.scripts] shims and reinstall entry points under the existing quarantine path in hermes update and install.ps1. * test(cli): cover Windows console script repair (#52931) Add unit tests for missing-shim detection and repair trigger in _verify_console_scripts_installed. * fix(windows): verify launchers after primary install * fix(curator): never archive cron-referenced skills + floor use=0 pruning (#54443) The curator's inactivity prune archived any non-pinned agent-created skill whose activity was older than archive_after_days (90d). A skill loaded only by a cron job had its usage bumped solely when the job fired, so paused jobs, infrequent (quarterly/annual) schedules, and far-future one-shots aged their skills out from under them — the next run then failed to load the now-archived skill. - cron/jobs.py: add referenced_skill_names() returning skills used by ANY job (incl. paused/disabled). - curator.apply_automatic_transitions(): skip cron-referenced skills like pinned; add a use=0 grace floor so a never-used skill is not marked stale/archived until it is at least stale_after_days old. - LLM review pass: candidate list marks cron=yes; prompt forbids pruning cron-referenced skills and never-used skills under 30 days. Tested E2E against a real cron job + real usage records and with 4 new unit tests. * fix(gateway): preserve sessions across restarts (#54442) * fix(provider): auto+base_url bypasses cloud API when custom endpoint configured (#3846) When config.yaml has `provider: auto` and a non-cloud `base_url` (e.g. Ollama at localhost:11434), requests were silently sent to https://api.anthropic.com whenever ANTHROPIC_API_KEY was present in the environment, ignoring the configured local endpoint and returning HTTP 401 / "credit balance too low". Root cause: resolve_provider("auto") scans env vars and returns "anthropic" when ANTHROPIC_API_KEY is set, before config.model.base_url is ever consulted. In resolve_runtime_provider(), before calling resolve_provider(), short-circuit to the OpenAI-compatible resolver when no explicit creds were passed, provider is "auto"/unset, and a non-cloud base_url is configured. Well-known cloud roots (openrouter.ai, anthropic.com, openai.com) are matched on HOST (not substring) so look-alike hosts can't evade the bypass and leak a cloud credential. Co-authored-by: Hermes Agent <hermes@nousresearch.com> * perf(startup): lazy-load gateway platform adapters (#54448) Bundled platform plugins (telegram, discord, feishu, teams, ...) were eagerly imported at plugin-discovery time on every `hermes` invocation, including plain `hermes chat` which never touches a gateway platform. Their modules import heavy platform SDKs at module level (lark_oapi, microsoft_teams, discord.py, slack_bolt, ...) — feishu alone pulled in lark_oapi (~2.6s), teams pulled microsoft_teams (~1.9s). Discovery now registers a cheap deferred loader per platform in the platform_registry; the adapter module is imported only when the gateway / cron / setup / send_message path actually asks for that platform. is_registered() and the iterate-all accessors stay correct (deferred counts as registered; plugin_entries()/all_entries() materialize all deferred loaders, since those paths genuinely need every adapter). Cold start: ~4.4s -> ~2.45s to banner. discover_and_load: 2.0s -> 0.3s (warm), and the heavy SDKs are no longer imported at all in CLI mode. Every shipped platform remains available out of the box — it just loads on first use. * fix(anthropic): ignore stale non-Anthropic base_url across all resolution paths A config left with `provider: anthropic` but a leftover `base_url: https://openrouter.ai/api/v1` (e.g. after a provider switch) would route Anthropic OAuth/setup-token traffic to OpenRouter and 404. Add `_anthropic_base_url_override_ok()` and gate the three native-Anthropic resolution branches (pool, explicit, native) on it. The guard honors a configured `model.base_url` only when it plausibly speaks the Anthropic Messages protocol — official `*.anthropic.com` / `*.claude.com` hosts, Azure Foundry endpoints, and `/anthropic`-suffixed or Kimi `/coding` proxies — and falls back to `https://api.anthropic.com` otherwise. Aggregator URLs like openrouter.ai / api.openai.com are treated as stale. Reconstructed from @clovericbot's PR #3661 onto current main: the original patched one branch with an anthropic-only allow-list, which would have broken Azure-via-anthropic; widened to all three sites and made Azure/proxy-safe. * docs: add PR infographic for anthropic stale base_url guard * fix(security): SSRF guard yuanbao media download_url (#54470) yuanbao_media.download_url() fetched model-supplied (outbound) and inbound image/file URLs server-side via httpx with follow_redirects=True and no SSRF check. A model response containing <img src="http://169.254.169.254/..."> routed through ImageUrlHandler -> download_url and would fetch cloud-metadata endpoints; same for inbound media. Add an is_safe_url() pre-flight plus an async redirect event-hook that re-validates every 30x target, matching the cache_image_from_url() guard in gateway/platforms/base.py. The other gateway adapters already guard their URL-fetch paths; this was the remaining unguarded one. * perf(startup): parse config + plugin manifests with libyaml CSafeLoader (#54486) The startup config/manifest reads used PyYAML's pure-Python SafeLoader, which is ~8x slower than the libyaml-backed CSafeLoader C extension. config.yaml is parsed several times during launch (cli config, raw config, early interface/redaction bridge, logging config) and every plugin manifest is parsed once — all on the slow path. Add utils.fast_safe_load (CSafeLoader-preferring, pure-Python fallback, true drop-in for safe_load) and route the hot startup parse sites through it: hermes_cli/config.py (config + manifest reads), hermes_cli/plugins.py (manifest parse), env_loader, cli.load_cli_config, hermes_logging, and the two pre-config early YAML bridges in main.py. Behavior is identical (same restricted safe tag set); only speed changes. safe_load calls on the startup path drop from ~79 to ~0, cutting the YAML parse cost from ~0.9s to ~0.15s under profiling. Adds tests/test_fast_safe_load.py asserting equivalence with safe_load across input shapes, empty-doc falsiness, C-loader preference, and that python/object tags are still rejected (safe, not full loader). * fix(windows): hide console flash on checkpoint git + skills_hub gh probes The #54236/#54417 backend git/gh sweep routed git_probe, the repo-file picker, coding_context, context_references, copilot_auth, and the gateway process scans through CREATE_NO_WINDOW, but two sibling spawn legs that also run inside the console-less desktop/gateway backend were missed: - tools/checkpoint_manager.py `_run_git` (and the one-shot `git init --bare` in `_init_store`) — when checkpoints are enabled, every file-mutating turn fires multiple bare `git` calls (status, add, write-tree/commit-tree, update-ref). Spawned from a parent with no console (Electron spawns the backend with windowsHide → CREATE_NO_WINDOW), each one allocates its own conhost window → a flurry of terminal popups. - tools/skills_hub.py `GitHubAuth._try_gh_cli` — `gh auth token`, the same bug class as the already-fixed copilot_auth gh probe. Route both through `windows_hide_flags()` (no-op on POSIX), matching the established per-site pattern. Tests added to tests/test_windows_subprocess_no_window_flags.py. * fix(windows): hide pdftoppm console flash on PDF attach server.py's PDF-attach handler shells out to `pdftoppm` from the console-less desktop/gateway backend; on Windows that pops a conhost window each attach. Route it through windows_hide_flags() like the sibling _list_repo_files git calls (no-op on POSIX). * refactor(windows): unify windowless spawn form across the touched sites windows_hide_flags() already returns 0 on POSIX (and creationflags=0 is the no-op default there, exactly how server.py::_list_repo_files does it), so drop the IS_WINDOWS import + ternary/one-use-dict gating and just pass creationflags=windows_hide_flags() directly. Tests lose the now-pointless IS_WINDOWS monkeypatch. * fix(dashboard): stop ElevenLabs voice-list 401 log spam The /api/audio/elevenlabs/voices endpoint logged a WARNING on every failure, and the desktop re-polls it on each settings open/focus — a bad/expired/scoped ELEVENLABS_API_KEY floods agent/gui logs with identical "voice list failed: HTTP Error 401" lines indefinitely. Treat 401/403 as a persistent "integration unavailable" state: return {available: false, error: "unauthorized"} with a 200 (the dropdown already handles available:false) instead of a 502, and collapse repeated identical failures to a single log line via a small re-arming latch (logs again on recovery or when the error changes). Non-auth errors keep the 502 but are throttled the same way. * test(gmi): stub profile fetch_models in static-fallback test The fallback test only mocked fetch_api_models; CI still hit the real GMI /v1/models endpoint via ProviderProfile.fetch_models and merged live models into the result. * fix(desktop): restore cross-wired runtime-id guard on session resume resumeSession's warm-cache fast-path once again trusted the storedSessionId -> runtimeId -> ClientSessionState mapping without checking the cached state still BELONGS to the session being resumed. A pooled profile backend that gets idle-reaped and respawned re-mints runtime ids, so a recycled id resolves to a live-but-DIFFERENT session's cache entry and paints the wrong transcript under the current route: click thread A, a totally different thread (often from another worktree) loads. The session.usage 404 guard only catches a fully-dead id; a recycled-live id 200s, so the fast-path happily served the stale cache. Straight regression, not a new bug. f7bf74064 ("reject cross-wired runtime-id cache on session resume") landed takeWarmCache() + its regression test; 62af32efe ("keep active sessions aligned with cwd"), rebased off a stale branch, restructured resumeSession and silently reverted both 29 minutes later -- the exact stale-branch squash clobber AGENTS.md warns about ("Squash merges from stale branches silently revert recent fixes"). Re-apply the whole-class fix on top of the current cwd-aligned code: takeWarmCache() validates state.storedSessionId === storedSessionId at BOTH cache reads (the early transcript-keep decision and the fast-path), purging a cross-wired mapping on a miss so it falls through to a full resume that rebinds a correct runtime id. Restore the two regression tests guarding it. Tests: resumeSession warm-cache mapping integrity -- a cross-wired mapping is rejected + purged (the bug), a correctly-wired cache is still served with no needless refetch (no perf regression). Co-authored-by: professorpalmer <professorpalmer@users.noreply.github.com> * feat(desktop): multi-terminal panel with side tab rail Multiple persistent in-app terminals managed by a thin VS Code-style icon rail docked on the terminal pane's outer edge. Each tab is its own live xterm+PTY that survives tab switches, session switches, and hiding the pane (VS Code parity: only an explicit close or `exit` kills a shell). Terminals own their state independent of the session — the sole thing they inherit is an initial cwd snapshotted at creation. - Rail: icon-only tabs (name + live hotkey on hover), +/hide controls, context menu. Sits at z-40 above the collapsed sidebars' hover-reveal triggers and marks itself data-suppress-pane-reveal, so reaching for a tab can't summon the file-browser/review panel. - Lifecycle: PersistentTerminal latches mounted on first open so shells stay alive while hidden; ensureTerminal re-creates one on reopen. - Agent reader: id-keyed registry drives read_terminal off the active tab. - Keybinds (Ctrl-family, OS-aware): toggle Ctrl+`, new Ctrl+Shift+`, next/prev Ctrl+Shift+Down/Up, close Ctrl+Shift+W. * fix(desktop): keep inactive terminals sized so switching doesn't garble Hide inactive terminal tabs with `visibility` (absolute-stacked at full size) instead of `display:none`. A display:none host is 0×0, so its ResizeObserver fit bails and the terminal stops tracking pane resizes — re-showing it at a changed size reflowed the buffer into a garbled prompt. Visibility-hidden hosts keep their layout size, stay in sync, and switch instantly. * feat(desktop): ⌘W closes the focused terminal Fold terminal close into the existing ⌘/Ctrl+W handler so focus decides the target: a focused terminal takes ⌘W (closes the active tab) and otherwise the keystroke closes the active preview tab as before. Only the ⌘ gesture is intercepted — Ctrl+W stays the shell's werase — and a focused terminal never lets ⌘/Ctrl+W close a preview out from under it. * refactor(desktop): generalize focus check to isFocusWithin primitive Replace the one-off isTerminalFocused with isFocusWithin(selector) in the keybinds lib (beside isEditableTarget) — the reusable primitive for any focus-scoped shortcut. The terminal marks itself data-terminal and the ⌘W handler routes via isFocusWithin('[data-terminal]'); future surfaces just add their own marker. * fix(desktop): force a repaint when a terminal is re-activated A WebGL terminal doesn't paint while visibility:hidden, so switching to it (e.g. after closing the active tab) revealed a stale/garbled frame. On activation, clear the glyph atlas and force a full term.refresh against the live buffer (after the refit), then focus. * feat(desktop): mirror agent background terminals as read-only tabs When the agent runs terminal(background=true) — Hermes's equivalent of Cursor's is_background — surface it as a read-only "agent" tab in the rail (distinct sparkle icon), alongside the glanceable status-stack row, which now links to the tab. The tab is a write-only xterm (no PTY, no input) fed by the process output tail, appended live (faster poll while a tab is open) and env-agnostic (works for local/docker/ssh shells alike). - terminals.ts: TerminalEntry gains kind ('user'|'agent') + procId; agent tabs auto-surface once (closing one doesn't resurrect it) and the status row can reopen/focus them. ensureTerminal now guarantees a user shell specifically. - use-agent-terminal.ts: slim read-only xterm hook, delta-appended. - workspace: render user vs agent instances; auto-surface from the background store; tail faster while an agent tab exists. - composer-status: $backgroundOutputByProc selector; status row links to the tab instead of an inline disclosure. * feat(desktop): stream agent terminal output live instead of polling Replace the 5s output_tail poll (which often showed nothing) with a real push stream. The process registry gains an on_output sink called from its reader threads with each chunk; the tui_gateway wires it to emit agent.terminal.output {process_id, chunk} (write_json is _stdout_lock-guarded, so emitting from the reader thread is safe). The desktop routes chunks by process id straight into the read-only agent xterm via a small writer registry, with a capped backlog so a tab opened mid-stream (or reopened) replays what it missed. Drops the fragile poll/tail path: no session-key matching, no truncation, no lag — full-fidelity ANSI, env-agnostic (local/docker/ssh). * fix(desktop): seed agent terminal tabs from process snapshots Read-only agent terminal tabs now consume both live agent.terminal.output chunks and the process-list/status snapshot. The snapshot seeds tabs opened after output already exists and acts as a fallback if the live stream races startup, so agent background tabs don't sit blank while the status stack already knows the tail. * fix(desktop): show the agent command before terminal output arrives Seed read-only agent terminal tabs with the background command immediately, so they never open as a blank pane while stdout is pending or a live stream races startup. Snapshot fallback now preserves that command header and appends only missing output without duplicating live chunks. * fix(docker): gate resource limit flags on cgroup controller availability (#54516) On hosts where the cgroup v2 cpu/memory/pids controllers are not delegated to the docker/podman process (unprivileged Proxmox LXCs, some rootless and nested setups), --pids-limit/--cpus/--memory cause every container start to fail with OCI runtime error / exit 126, breaking terminal + execute_code. - Add _cgroup_limits_available(image): one-shot, host-wide cached probe that spawns a throwaway container from the sandbox image itself (sleep 0) with all three flags together, mirroring the existing _storage_opt_supported probe-and-degrade pattern. - Remove --pids-limit from static _BASE_SECURITY_ARGS; apply it (default 256 via _DEFAULT_PIDS_LIMIT) in resource_args gated on the probe. - Gate --cpus and --memory on the same probe. Behavior unchanged on cgroup-capable hosts; graceful degradation with a one-time warning where controllers aren't delegated. Fixes #6568. (cherry picked from commit c933880b7ee2ce4d1167e0f89caa2d233db5639f) Co-authored-by: angelos <angelos@oikos.lan.home.malaiwah.com> * fix(terminal): require approval for host-bound Docker commands (#54483) * fix(terminal): require approval for host-bound Docker commands The Docker terminal backend blanket-skips dangerous-command approval on the assumption that the container is isolated from the host. That holds only when nothing is bind-mounted in. Once a host path is exposed (via TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE or a host-path entry in TERMINAL_DOCKER_VOLUMES), a command like `rm -rf /workspace` reaches real host files but is still auto-approved. Detect host bind mounts and route those sessions through the normal approval flow. Isolated Docker keeps the fast path. The same gating is applied to the execute_code guard, which had the identical blanket skip. Co-authored-by: Hermes Agent <agent@nousresearch.com> * chore: add AUTHOR_MAP entry for PR #6436 salvage (Kolektori) * test: accept has_host_access kwarg in _check_all_guards mocks The host-bound Docker approval fix adds a has_host_access kwarg to the _check_all_guards wrapper. Six pre-existing tests monkeypatch it with a fixed (command, env_type) / (cmd, env) lambda signature, which now raises TypeError when terminal_tool passes the new kwarg. Widen those mock signatures to accept **kwargs. --------- Co-authored-by: Kolektori <256073454+Kolektori@users.noreply.github.com> Co-authored-by: Hermes Agent <agent@nousresearch.com> * fix(security): redact bare-token credentials in URL userinfo (#6396) (#54475) git remote set-url with an embedded password (https://PASSWORD@github.com) leaked the credential into agent output — the redaction engine only masked user:pass@ DB connection strings, never the colon-less bare-token userinfo form a git remote uses. Add _URL_BARE_TOKEN_RE: scheme://TOKEN@host for web/transport schemes (http/https/wss/git/ssh/ftp), 8+ char floor to skip short usernames, token class forbidding /:@ so an @ in a path/query is never treated as userinfo. Deliberately scoped to the bare-token form only. The user:pass@ colon form and query-string tokens stay passing through (#34029, 'pass web URLs through unchanged') so magic-link / OAuth round-trip skills keep working — a bare credential in userinfo is never a workflow token (those live in the query string), so masking it can't break a skill. * fix(gateway): log error-notification failures instead of silently swallowing (#54472) * fix(gateway): log error-notification failures instead of silently swallowing The last-resort exception handler in _process_message_background() that sends an error notice to the user caught all exceptions with a bare pass, leaving zero trace when the notification itself failed. Upgrade to logger.error(..., exc_info=True) so a failed error-notification send is debuggable post-mortem. Salvaged from #6499 by @BongSuCHOI (the logging-upgrade portion only). * docs: add PR infographic for gateway error-notify logging * fix(config): strip `export ` prefix in .env parsers across three modules All three .env parsers use `line.partition("=")` without stripping the bash-compatible `export ` prefix first. A line like `export API_KEY=sk-...` produces key `"export API_KEY"` instead of `"API_KEY"`, silently ignoring the variable and causing auth failures for users who copy-paste from bash profiles or follow tutorials that include `export`. - tools/skills_tool.py: `load_env()` for skill environment - hermes_cli/config.py: `load_env()` for core config - hermes_cli/main.py: `_has_any_provider_configured()` inline parser Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> * test: cover export-prefix stripping in .env parsers (PR #6659) * fix(agent): guard Anthropic interrupt, cap vision data-URL size Two independent agent-loop hardening fixes: - anthropic: when the streaming loop breaks on _interrupt_requested, return None instead of calling stream.get_final_message() on the partially-drained stream — the SDK may hang draining remaining events or return a Message with incomplete tool_use blocks. The outer poll loop raises InterruptedError, so the return value is discarded anyway. - vision: add a 20 MB cap on base64 data-URL payloads before base64.b64decode() in _materialize_data_url_for_vision. A 100MB+ payload creates ~275MB of memory pressure; gateway users sharing the process can trivially OOM it. Oversized payloads return ("", None). The third change from the original PR (streaming tool-name += to assignment dedup) was already landed independently on main. Co-authored-by: aaronlab <1115117931@qq.com> * fix(gateway): sanitize agent error messages, validate webhook gh args Two of the three fixes from PR #6660 (the cli.py reopen_session change is moot — that raw _conn.execute reopen block no longer exists on main). - gateway/run.py: stop sending raw type(e).__name__ and str(e)[:300] to end users on chat platforms. Exception text from LLM providers can leak API URLs, file paths, and partial credentials. Return a generic message; keep curated status hints for known HTTP codes; full detail stays in logs. - gateway/platforms/webhook.py: validate pr_number (positive int) and repo (owner/name regex) before passing to the 'gh pr comment' subprocess. Payload-controlled values could otherwise inject gh flags (--help, a different --repo). List-form subprocess means this is arg injection, not shell injection, but validation is still correct. Co-authored-by: aaronagent <1115117931@qq.com> * fix(profiles): validate custom alias names to prevent path traversal `hermes profile alias <profile> --name <custom>` accepted arbitrary strings and used them verbatim as a filename under ~/.local/bin. Because normalize_profile_name only lowercases/strips (no regex gate), a value like `../../.bashrc` escaped the wrapper directory and clobbered arbitrary user-writable files. remove_wrapper_script had the same sink. Add validate_alias_name (reusing the profile-id regex, which forbids `/`, `.`, and `..`) and wire it into check_alias_collision, create_wrapper_script, remove_wrapper_script, and the CLI alias action so the rejection surfaces a clear "Invalid alias name" error instead of silently writing or unlinking outside the wrapper dir. Co-authored-by: Gutslabs <gutslabsxyz@gmail.com> Co-authored-by: Xowiek <xowiekk@gmail.com> * feat(desktop): unify non-settings overlays under a shared Panel primitive Extract the agents/trace overlay chrome into overlays/panel.tsx and adopt it across the Cron, Profiles, and Agents overlays so they share one layout (centered card, header, master/detail list with built-in search, kebab row actions, big "+" footer, empty state) instead of three ad-hoc split layouts. Also in this pass: - OverlayView insets equidistantly on every side (was top/left-only, which left a large left gutter on narrow windows). - Form-control chrome: input border/background/recessed-inset are now per-mode theme-var knobs (--dt-input-border/-bg/-inset) — resting borders blend in, strengthen on hover, and go solid on focus / while a Select is open. - Thread-timeline popover reuses the shared dropdown surface (1:1 with the kebab menus) and scrolls the hovered prompt into view. * chore(desktop): drop dead overlay primitives Remove zero-consumer overlay code surfaced while auditing the primitive set: OverlayNewButton (orphaned once "New" moved into PanelAddButton), OverlayCard / overlayCardClass, and the unused overlay-search-input module. Leaves three intentional layers: OverlayView (base), Panel (master/detail), and OverlaySplitLayout (settings/command-center nav→content). * fix(approvals): warn and default to manual on unknown approvals.mode _normalize_approval_mode() previously accepted any string, so an unknown value like 'auto' fell through every downstream mode check (off/smart) and silently behaved like manual with no signal. Validate against the known modes (manual/smart/off), emit a warning for anything else, and default to manual to match the config default and the rest of the function. Bug 1 from the original PR (/approve & /deny bypassing the running-agent guard) already landed on main independently, so only the mode-validation fix is salvaged here. Fixes #4261 Co-authored-by: Hermes Agent <agent@nousresearch.com> * docs: add PR infographic for approval mode validation * style(desktop): prettier + eslint pass Repo-wide `npm run fmt` + `eslint --fix`; also drop two unused destructured params in titlebar-overlay-width.cjs so the lint run is clean. * feat(desktop): live agent terminals + agent-driven tab close Make the read-only agent terminal mirrors stream in real time and give the agent a desktop-only way to dismiss its own tabs. - Stream background output live: the local reader used a blocking read(4096) that buffered small periodic output until EOF, so agent tabs only "filled in" at process exit. Switch to buffer.read1(4096) (decoded) for incremental chunks. - Route agent.terminal.output / terminal.close to the window that owns the process (its gateway session) instead of an empty session id, so events actually reach the desktop renderer. - Add close_terminal: a HERMES_DESKTOP-gated tool (sibling of read_terminal) that drops a process's read-only tab WITHOUT killing it via process_registry.on_close; output keeps buffering and the user can reopen from the status stack. - ⌘W now closes a focused agent tab: mark the agent instance data-terminal and focus it on activation so isFocusWithin routes there. - ensureTerminal() no longer spawns an extra user shell when a tab already exists (e.g. opening a background task from the status stack). * feat(dashboard): catalogue all memory-provider API keys in OPTIONAL_ENV_VARS The dashboard Keys page and `hermes setup` render API-key rows from OPTIONAL_ENV_VARS, but only Honcho had an entry — so Hindsight, Supermemory, Mem0, RetainDB, ByteRover, and OpenViking read their keys straight from os.environ yet had no place to set them in the GUI. Add catalog entries (category=tool, password-masked, with get-key URLs and the tool each powers) for all six, plus the relevant base-URL/endpoint companions. Pure declaration: the generic GET /api/env endpoint, the save/reveal write path, and the sandbox env blocklist (which auto-derives from tool-category OPTIONAL_ENV_VARS) all pick these up with no further wiring. Adds a behavior-contract test asserting every memory provider's primary credential key is catalogued, tool-categorised, and password-masked. * fix(desktop): make agent terminal tabs fully readable Register read-only agent terminals with the same renderer-side terminal reader as user terminals so read_terminal works on whichever tab is active. Also bring agent xterm rendering closer to user-terminal parity (unicode 11, web links, font weights/spacing) and make the gateway sink wiring resilient if only one terminal event sink was already installed. * refactor(desktop+dashboard): extract shared WebSocket/JSON-RPC layer The Electron desktop app and the web dashboard each carried their own copy of the tui_gateway JSON-RPC WebSocket client plus near-identical auth'd WS-URL construction. The dashboard's copy was the historical source of the "is the dashboard required to run the desktop app?" confusion, since the two surfaces looked coupled. Consolidate the genuinely shared transport into the existing framework-agnostic `@hermes/shared` package so both surfaces consume it independently — neither app depends on the other: - Move `resolveGatewayWsUrl` + `GatewayReauthRequiredError` (single-use OAuth ticket re-mint vs long-lived token fallback) into `@hermes/shared`; desktop now imports them directly. - Add `buildHermesWebSocketUrl`, one base-path/scheme/auth-aware URL builder, and route every dashboard WS endpoint through it (`/api/ws`, `/api/events`, `/api/pty`, plugin WS URLs). - Reduce the dashboard `GatewayClient` to a thin subclass of the shared `JsonRpcGatewayClient`, deleting ~210 lines of duplicated pending-call /event-dispatch/connect plumbing while keeping its dashboard-specific ticket-vs-token auth selection. - Drop the stale "start it with --tui" chat banner, which implied the dashboard flag was required. Behavior is preserved on both surfaces; the dashboard additionally inherits the shared client's 15s connect timeout (previously desktop-only), so a hung connect now fails fast instead of pinning the composer in "connecting". * fix(desktop): match agent terminal scrollback to user tabs Keep read-only agent terminal tabs visually and behaviorally aligned with normal terminal tabs by using the same 1,000-line scrollback cap. * fix(shared): close websocket clients deterministically Ensure intentional client closes mark the transport closed and reject pending RPCs immediately instead of relying on a browser close event that can be ignored after the socket reference is cleared. * feat(desktop): live gateway popout + statusbar/command-center polish - Gateway status popout: flatten the header to stacked connection + inference statuses with system-panel and restart actions (reusing the shared runGatewayRestart helper). The recent-activity tail is now live while the popout is open via the shared LogView (WS connection churn filtered), and the icon / "View all logs" link dismiss the popover. - Statusbar "menu" items accept a menuContent(close) render fn over a now controlled DropdownMenu, so popover content can close itself. - Drop the always-on gateway-log poll from useStatusSnapshot (logs are fetched by the popout only while open). - SearchField → text-xs to match Input/Select (controlVariants). - Command center: remove the usage/system section dividers, swap the sessions nav icon (Pin → MessageCircle), small padding tweaks. * refactor(web): centralize dashboard websocket URL calls Keep dashboard pages and components on the dashboard API helper instead of calling the raw shared URL primitive directly. The shared helper remains the single low-level implementation; web/src/lib/api.ts is the dashboard-specific facade for auth, base path, and ticket minting. * chore(desktop): keep the diff surgical Revert the repo-wide prettier churn the earlier fmt pass pulled into files unrelated to this work; run prettier/eslint scoped to the touched files only. * style(shared): apply workspace formatter to websocket helpers Run the package-appropriate Prettier config on the shared WebSocket files so the extracted helpers match the surrounding desktop/shared TypeScript style. * fix(desktop): stop injecting ctrl-l into terminal startup Remove the prompt-gap cleanup that sent Ctrl-L into the user's shell; it could render as literal ^L and create the exact top-line gap it was meant to hide. Keep first-prompt cleanup renderer-side only, and parse short ESC charset sequences so the initial newline stripper does not disarm early. Also add a Close all action to the terminal tab context menu. * fix(docker): include apps/shared in dashboard image build The shared websocket package is a web file: dependency but was excluded by .dockerignore and never copied into the Docker build context. Also fix tsc -b errors: expose buildWsUrl on api and drop the GatewayClient state getter that conflicted with the shared base class. * docs: clarify desktop is self-contained, not dependent on the dashboard The desktop app spawns a headless `hermes dashboard --no-open` backend and talks to it through the shared @hermes/shared WebSocket client — it never runs or requires the browser dashboard UI. Spell this out in the desktop README, the desktop docs page, and AGENTS.md so "dashboard" stops reading as a desktop prerequisite. * feat(cli): add headless `hermes serve` backend; desktop no longer launches `dashboard` The desktop app spawned `hermes dashboard --no-open` as its backend, which made the dashboard look like a desktop prerequisite. Add a dedicated headless `hermes serve` command that boots the same gateway (shared cmd_dashboard / start_server) but never opens a browser, and point the desktop backend spawn exclusively at it. dashboard and serve are now independent surfaces — neither launches the other. - subcommands/dashboard.py: factor shared server args; add `serve` parser (always headless; accepts legacy --no-open as a no-op) - main.py: register serve in _BUILTIN_SUBCOMMANDS + coalesce set + gui-log detection; extend stale-backend reaper patterns to match `serve` - desktop electron: spawn `serve`, rename dashboardArgs -> backendArgs, update comments + windows-child-process test assertions - docs: desktop README, desktop.md (incl. remote-backend), AGENTS.md, and cli-commands.md now describe `hermes serve` as the desktop/headless backend * fix(desktop): route old runtimes through `dashboard` when `serve` is absent `hermes serve` is newer than the desktop binary's release cadence, so a new app launched against an un-upgraded managed install / PATH `hermes` would crash on an unknown subcommand and brick the user mid-upgrade. Detect whether the resolved runtime registers `serve` (fast source read of its dashboard.py, with a one-time CLI probe fallback) and rewrite the backend argv to the legacy `dashboard --no-open` only when it does not. Happy path (current runtimes) pays nothing and still spawns `serve`. - electron/backend-command.cjs: pure serve/dashboard argv helpers + serve- source detection (unit-tested in backend-command.test.cjs) - main.cjs: backendSupportsServe() cache + getBackendArgsForRuntime() guard at both backend spawn sites; expose `root` from the Windows venv unwrap so the fast source check covers Windows too - docs: note the backward-compat fallback in README, desktop.md, AGENTS.md * test(cli): pin the `hermes serve` decoupling contract Add a focused contract test for the headless `serve` command (routes to the shared dashboard handler, headless by default while `dashboard` is not, accepts the legacy --no-open, shares the same runtime/lifecycle flag surface). Also refresh the dashboard.py module docstring to cover both commands. * feat(desktop): persist & restore terminal tabs + scrollback across relaunch User terminal tabs and their recent scrollback now survive an app restart (VS Code parity). Tabs, active selection, cwd, and a serialized scrollback snapshot are written to localStorage on every change; on launch the tabs reopen with their history replayed above a fresh shell. Processes are NOT revived — a new shell starts one line below the restored block. - Capture: SerializeAddon snapshots the buffer on a 750ms leading-edge throttle, so a `cmd; quit` lands on disk before teardown; the snapshot is trimmed of its trailing idle prompt (no "double prompt" on restore) and capped (200 scrollback lines / 48k chars) to stay under the storage budget. - Teardown guard: app quit/reload kills the PTYs from the main process, firing onExit in the renderer, but React skips effect cleanups on teardown so the per-instance `disposed` flag never flips. A pagehide/beforeunload flag stops onExit from calling closeTerminal() and wiping the persisted tabs right before relaunch restores them. A real `exit`/Ctrl-D still closes. - Agent mirror tabs stay runtime-only — only user tabs persist. * fix(agent): limit .hermes.md parent walk to git repos only _find_hermes_md walks parent directories looking for .hermes.md/HERMES.md, stopping at the git root. But when there is no git repo (_find_git_root returns None), the stop guard never fires and the loop walks all the way to /. On shared systems (CI runners, multi-tenant servers), a .hermes.md planted at /tmp, /home, or / would be loaded into the system prompt of any agent session not inside a git repo — a cross-user prompt-injection vector. Fix: when there is no git root, only check cwd; do not walk parents. Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com> * test(agent): cover .hermes.md no-git-root cwd-only behavior Regression tests for the injection fix: outside a git repo only cwd is checked (planted ancestor .hermes.md is ignored), a cwd-local .hermes.md is still found, and inside a git repo the parent walk to the git root still works. * chore: remove committed PR infographics and gitignore the path (#54564) PR infographics are rendered locally and embedded in PR descriptions via the image-provider (fal.media) URL — they were never meant to live in the repo. The intended .gitignore enforcement (documented as added back in May 2026) was never actually committed, so 35 PNGs (~54MB) accumulated under infographic/ via 'docs: add PR infographic for X' commits. - Remove all 35 tracked infographic/*.png files. - Add infographic/ to .gitignore so git add on the path is now a no-op. The PR body remains the archive for these images. * fix: normalize lmstudio base urls * chore(release): add AUTHOR_MAP entry for PR #53295 salvage * fix(matrix,mattermost): invite auth check + API path traversal guard Two platform-security hardenings: - Matrix: _on_invite now checks the inviter against the existing allow-list (_allowed_user_ids / GATEWAY_ALLOW_ALL_USERS) before auto-joining. Without this any federated Matrix user could invite the bot into arbitrary rooms, exposing its presence and metadata. The message and reaction paths already enforce this allow-list; the invite path bypassed it. - Mattermost: _api_get / _api_post / _api_put reject any path containing '..'. WebSocket-event values (channel_id, post_id, file_id) are interpolated directly into API paths, so a malicious or compromised server could craft traversal payloads to make the bot issue authenticated requests to arbitrary endpoints with its bearer token. The configurable-E2EE-passphrase change from the original PR is dropped: the matrix adapter was rewritten onto mautrix and the passphrase-protected key-export file no longer exists. * test(matrix): authorize inviter in DM-invite fixture for new invite-auth gate _on_invite now rejects auto-joins from users not on the allow-list. The DM-recording tests invite @alice and expect a join, so the shared _make_adapter fixture now puts @alice on _allowed_user_ids. * fix(cron): don't report a false 'gateway not running' on external-provider instances (#54600) `hermes cron status` (and the create/list 'gateway not running' nag) judge whether cron will fire purely from the in-process ticker's heartbeat file + a live gateway PID. That heuristic is correct for the built-in ticker but WRONG for an external provider like Chronos: Chronos arms exactly one external one-shot per job and is fired by a NAS-mediated webhook (POST /api/cron/fire). Its `start()` returns immediately and it deliberately runs no 60s loop and writes no ticker heartbeat — that's the whole point of scale-to-zero (the machine is at zero between fires). So on a perfectly healthy Chronos instance, `cron status` always printed '✗ Gateway is not running — cron jobs will NOT fire' (or a STALLED-ticker warning), and `cron create` always appended the 'jobs won't fire automatically' nag — both false. Verified live on a staging Chronos instance: jobs fired and completed on schedule via the relay while `cron status` insisted the gateway wasn't running and the heartbeat was 370s+ stale. Fix: resolve the active provider (offline — `resolve_cron_scheduler`, whose `is_available()` contract forbids network) and, for any non-builtin provider, report the managed-scheduler state instead of the ticker heuristics, and suppress the ticker-only 'gateway not running' warning. The built-in path is byte-unchanged. Active-job summary is factored into a shared helper so both paths print it identically. New tests prove both directions (chronos: no false negative even with no gateway PID / no heartbeat; builtin: historical warning preserved) and fail without the fix. * fix(skills): replace string prefix check with strict path containment * test(cli): drop pytest dep + use real sentinel handlers in serve test Clears the ty diff bot's warnings on the new test: pass real callables to build_dashboard_parser (not object()) and replace the pytest.mark.parametrize with a plain loop so the file is stdlib-only. * docs(cron): document explicit per-channel delivery targets for all platforms (#54630) The cron delivery table only showed Discord/Telegram with explicit target syntax and described Slack and every other platform as home-channel-only. In fact the generic platform:<target> routing in _resolve_single_delivery_target resolves explicit targets for every platform: Slack (#channel / channel ID / channel:thread_ts), Matrix (room/user IDs), Feishu (chat:thread), WhatsApp (JID / E.164), Signal (group / E.164), SMS, Email, and Weixin all have dedicated explicit- target branches in _parse_target_ref; the remaining platforms accept a generic platform:<chat_id> passthrough. Update the Delivery Model table (en + zh-Hans) to show the real per-platform syntax, document #channel name resolution via the channel directory, and note the Slack thread_ts nuance. Docs-only. * fix(file-tools): sanitize host/relative cwd override before it reaches container sandbox (#54447) (#54616) (cherry picked from commit 82132f7911ecf71f27ee5657870bf4105cecf8e2) Co-authored-by: Tranquil-Flow <66773372+Tranquil-Flow@users.noreply.github.com> * Add dashboard backup upload and download * fix(gateway): clear session-scoped model overrides on /resume /resume is a conversation boundary, but unlike /new it did not clear the chat-keyed _session_model_overrides / _pending_model_notes. A /model switch made in the previous session under the same chat session_key leaked into the resumed conversation, running it on the wrong model. Clear both maps for the session_key after the switch (mirroring /new), scoped to that key so other chats' overrides are untouched. The cached-agent eviction this leak also implied already landed via #6672. Closes #10702. * fix(gateway): evict cached agent on auto-reset to prevent stale context summary leak When a session is auto-reset by daily schedule, idle timeout, or suspended state, the agent cache was not being cleared. This caused the old agent's context_compressor._previous_summary to leak into the new session, mixing old conversation history into new compaction summaries. This was the root cause of the "skin making history" appearing after compaction in fresh sessions reported by the user. Follow-up to #9893 which only handled compression_exhausted case. Changes: - Add _evict_cached_agent(session_key) call after was_auto_reset check - Covers daily, idle, and suspended auto-reset scenarios - Matches the behavior of manual /reset command Related tests: test_session_boundary_hooks, test_async_memory_flush, test_session_reset_notify, test_session_reset_fix - all passing. * test(gateway): pin auto-reset cached-agent eviction (#10710) Relocate marco0158's eviction into the dedicated auto-reset cleanup block (single source of truth for dropping session-scoped transient state) and add an AST invariant pinning _evict_cached_agent into that block. Add AUTHOR_MAP entry for marco0158. * fix(security): cap WeCom callback body size before pre-auth XML parse (#54615) The WeCom callback endpoint (internet-facing, 0.0.0.0) parsed untrusted request bodies before signature verification. defusedxml already guards the entity-expansion class on main, but there was no cap on raw body size, so an unauthenticated POST could still force unbounded read work pre-auth. Set client_max_size=64KB on the aiohttp app (413 at the framework layer) plus an explicit length guard in _handle_callback as defense in depth. WeCom callbacks are small encrypted XML envelopes — media is delivered out-of-band via MediaId, never inline — so 64KB is ample for legitimate traffic. Adds tests for oversized (413) and normal-sized (not 413) bodies. Salvaged from #10192 by @memosr (body-size limit half; defusedxml half already superseded on main). * fix(logging): suppress Windows lock timeout tracebacks * infographic: Windows CLH lock-timeout traceback suppression (#54436 salvage) * fix(agent): omit stream_options for native Gemini streaming Google's native Gemini REST endpoint (generativelanguage.googleapis.com, non-/openai) rejects OpenAI-only stream_options={"include_usage": true}, crashing every streaming chat-completions call with TypeError. Omit it for that endpoint while keeping it for the Gemini OpenAI-compat shim and all OpenAI-compatible aggregators (OpenRouter, etc.) so usage accounting is preserved. Reuses is_native_gemini_base_url() so the compat shim (.../openai), which accepts stream_options, is correctly excluded from the omission. Fixes #14387 Co-authored-by: Hermes Agent <127238744+teknium1@users.noreply.github.com> * fix(agent): return OpenAI-shaped copilot ACP tool calls * fix(agent): stream copilot ACP chat completions * fix(vision): detect Ollama vision models via /api/show (#54511) When local Ollama models are absent from models.dev, probe the Ollama server's /api/show capabilities so attached images are routed natively instead of being stripped as non-vision input. * test(vision): cover Ollama /api/show vision capability routing (#54511) * feat(dashboard): list & add arbitrary custom .env keys on the Keys page The Keys page only rendered env vars present in a catalog (OPTIONAL_ENV_VARS or the provider catalog); any other key a user set in .env was invisible, and there was no way to add an arbitrary env var from the GUI (e.g. to inject a var a skill or MCP server needs). Backend: GET /api/env now also emits a row for every on-disk .env key that isn't in any catalog, flagged category="custom" + custom=true and password-masked (an unrecognised key could hold anything, so it's redacted and reveal-gated like any secret). Channel-managed credentials stay excluded. The write (PUT /api/env) and reveal (POST /api/env/reveal) paths already handle arbitrary keys, with the existing env-name guard + denylist (PATH, LD_PRELOAD, PYTHONPATH, …) enforced server-side — no new write surface. Frontend: a new "Custom Keys" section lists those custom rows and carries an add-a-key form (client-side name validation mirroring the backend regex; the new row reuses the normal edit/save flow, so on save it round-trips back from the backend as a durable custom row). i18n added for en + zh + types. Tests: behavior-contract coverage that an unknown .env key surfaces as a masked custom row and a catalogued key does not — verified to fail on the pre-fix backend. * i18n: add Custom Keys strings to all locale files The env translation block is type-checked across every locale (tsc -b), so the 8 new customKeys strings must exist in all of them, not just en/zh. Add translated entries to the remaining 14 locales (de, es, fr, it, ja, ko, pt, ru, tr, uk, hu, ga, af, zh-hant). * fix(desktop): launch Windows backend as console python so child consoles are inherited, not flashed The recurring Windows desktop console-flash bug (#54220) is governed by the *parent's* console, not by each child spawn. The desktop backend was launched as GUI-subsystem pythonw.exe, which has no console at all — so every console-subsystem child it spawns (git, gh, cmd, wmic, powershell, ...) had to allocate its own console, flashing a window. That is why the fix had become an endless per-call-site sweep of CREATE_NO_WINDOW flags: each leaf spawn was papering over a missing console on the root. Launch the backend as the venv's console python.exe instead. Under the existing hiddenWindowsChildOptions() wrapper (windowsHide: true -> CREATE_NO_WINDOW) the backend owns a single *windowless* console, and every descendant spawn inherits it instead of allocating a visible one. This makes "no flashing windows" a property of the one backend launch rather than a flag that must be remembered at every spawn site — including spawns inside third-party libraries that no call-site sweep can reach. Verified on Windows 11 25H2 (Windows Terminal default): with the per-site hide flag forcibly neutered, the canonical culprits (git/gh/cmd/wmic/powershell) spawned naively and none flashed, while the same naive spawn from the old console-less pythonw parent did flash — isolating the parent console as the cause. Two premises behind the old pythonw approach did not hold up on current Windows and are dropped here: - The venv Scripts\python.exe uv shim, under CREATE_NO_WINDOW, re-execs base python *windowless* — it does not flash a conhost (the #52239 concern), so the base-pythonw detour is unnecessary. - Console python restores stdout, so the backend announces its port on the normal HERMES_DASHBOARD_READY stdout line; the pythonw-only ready-file side channel is no longer needed and the readyFile opt-in is removed. Removes the now-dead pythonw machinery (getNoConsoleVenvPython, toNoConsolePython, applyWindowsNoConsoleSpawnHints, readVenvHome) and updates the test to assert the new invariant: backend command is never pythonw, both backend spawns still go through hiddenWindowsChildOptions, and no backend opts into the ready-file path. Scope: this fixes the high-frequency backend-descendant flash classes. The updater/UAC handoff (#54543) and embedded-terminal PTY accumulation (#53555) classes have separate root causes and are unaffected. * test(desktop): match multiline spawn(ps, fullArgs) via regex like sibling sites The bootstrap-runner PowerShell spawn is formatted multiline (spawn(\n ps,\n fullArgs,...), so the literal substring 'spawn(ps, fullArgs' never matched and the assertion was failing on main independent of #54635. Convert it to a whitespace-tolerant regex like every other call-site assertion in this file. * fix(memory): lazy-install supermemory + mem0 SDKs like honcho/hindsight The supermemory and mem0 memory providers shipped third-party SDKs (supermemory / mem0ai) that are not core dependencies, but — unlike the honcho and hindsight providers — they imported those SDKs directly with no tools.lazy_deps.ensure() preflight and had no LAZY_DEPS allowlist entry. On the published Docker image the agent venv is sealed (HERMES_DISABLE_LAZY_INSTALLS=1) and lazy installs are redirected to a writable durable target (HERMES_LAZY_INSTALL_TARGET). honcho/hindsight route through ensure() and install fine there; supermemory/mem0 never called it, so their SDK was never installed on a hosted instance and the provider silently reported itself unavailable even with the API key set. Fixes: - Add memory.supermemory + memory.mem0 to the LAZY_DEPS allowlist (tools/lazy_deps.py), pinned to current PyPI releases. - Call ensure('memory.<x>', prompt=False) at each SDK-import chokepoint (_SupermemoryClient.__init__; Mem0MemoryProvider._create_backend), mirroring honcho's wrapped try/except shape. - Drop the SDK-import gate from supermemory's is_available() — it was a chicken-and-egg trap (provider never loaded on a sealed venv, so ensure() never ran). Now key-presence only, like honcho/mem0. - Add matching pyproject extras [supermemory]/[mem0]; update the lazy-covered-extras contract test (excluded from [all] by policy). Tests prove each path fails without the fix and the real sealed-venv durable-target gate accepts both features. * chore: regenerate uv.lock for supermemory + mem0 extras * test(windows): harden pid-scan no-window assertion against captured-call leakage (#54707) test_gateway_pid_scan_hides_wmic_and_powershell_windows flaked once in CI (slice 7/8) with 'KeyError: creationflags' while passing 15/15 under exact CI-parity locally. The positional 'kwargs["creationflags"]' indexing raises a bare KeyError the moment any stray subprocess.run call is captured, masking the real contract. Filter captured calls to the two intended Windows console spawns (wmic + PowerShell fallback) and assert each is windowless via .get('creationflags'); a leaked/extra call now surfaces as a readable len-mismatch with the full captured list, not a cryptic KeyError. * fix(slack): subscribe to message.mpim + mpim scopes so group DMs work Group DMs (multi-person DMs, channel_type=mpim) were never delivered to the Slack bot. The adapter already classifies mpim as a DM and replies ambiently (adapter.py:2526, is_dm = channel_type in {im, mpim}), but the generated app manifest only subscribed to message.im / im:history — the 1:1 DM pair. Without the message.mpim event subscription Slack drops group-DM messages before the adapter ever sees them, so 1:1 DMs worked while group-DM ambient mode was dead. Add message.mpim to bot_events and mpim:history (the scope that event requires per Slack docs) + mpim:read (mirrors im:read for the conversations.info classification call) to bot_scopes. Update the SLACK_BOT_TOKEN / SLACK_APP_TOKEN setup-help strings and the Slack docs (EN + zh-Hans: scope table, event table, troubleshooting) so existing installs are told to add the new scopes and reinstall. Reported by an enterprise customer. Note: this is a manifest/scope change, so it only takes effect after the app is reinstalled and the new scopes are accepted. Tests: assert message.mpim + mpim:history + mpim:read are in the manifest (with and without assistant mode); both fail on current main and pass with this change. * feat(slack): nudge stale installs to add mpim scopes; mark message.mpim required Follow-up to the group-DM manifest fix. The manifest change only helps NEW installs; existing apps keep their old (mpim-less) scopes until the admin reinstalls. Since a missing message.mpim event delivers nothing (no runtime API error to catch), detect stale installs at connect time from the auth.test x-oauth-scopes header and log an actionable reinstall nudge when im:history is granted but mpim:history is not. Also promote message.mpim from Recommended to Required in the docs event tables so the default setup path can't drop it. * fix(tools): send listItemId instead of sessionKey in Camofox tab creation The Camoufox REST API server expects `listItemId` in the `POST /tabs` body, but `_ensure_tab` was sending `sessionKey`. This caused a 400 Bad Request on every `browser_navigate` call. The parameter name mismatch is visible in the same file: line 283 already reads `tab.get("listItemId")…
/resume is a conversation boundary, but unlike /new it did not clear the chat-keyed _session_model_overrides / _pending_model_notes. A /model switch made in the previous session under the same chat session_key leaked into the resumed conversation, running it on the wrong model. Clear both maps for the session_key after the switch (mirroring /new), scoped to that key so other chats' overrides are untouched. The cached-agent eviction this leak also implied already landed via NousResearch#6672. Closes NousResearch#10702.
/resume is a conversation boundary, but unlike /new it did not clear the chat-keyed _session_model_overrides / _pending_model_notes. A /model switch made in the previous session under the same chat session_key leaked into the resumed conversation, running it on the wrong model. Clear both maps for the session_key after the switch (mirroring /new), scoped to that key so other chats' overrides are untouched. The cached-agent eviction this leak also implied already landed via NousResearch#6672. Closes NousResearch#10702.
/resume is a conversation boundary, but unlike /new it did not clear the chat-keyed _session_model_overrides / _pending_model_notes. A /model switch made in the previous session under the same chat session_key leaked into the resumed conversation, running it on the wrong model. Clear both maps for the session_key after the switch (mirroring /new), scoped to that key so other chats' overrides are untouched. The cached-agent eviction this leak also implied already landed via NousResearch#6672. Closes NousResearch#10702.
/resume is a conversation boundary, but unlike /new it did not clear the chat-keyed _session_model_overrides / _pending_model_notes. A /model switch made in the previous session under the same chat session_key leaked into the resumed conversation, running it on the wrong model. Clear both maps for the session_key after the switch (mirroring /new), scoped to that key so other chats' overrides are untouched. The cached-agent eviction this leak also implied already landed via NousResearch#6672. Closes NousResearch#10702.
Completes the #64934 system beyond the point fix. Two structural changes, both eliminating whole bug classes rather than instances: 1. _clear_conversation_scope — THE single conversation-boundary funnel. /new, /resume, auto-reset, expiry finalization, and the compression-exhausted reset each carried a hand-copied pop-list of the per-session dicts, and the lists drifted every time a new dict was added (#48031, #58403, #10702, #35809 were all 'boundary X forgot dict Y' bugs). All five sites now make one funnel call driven by the _CONVERSATION_SCOPED_STATE registry; adding a new conversation-scoped dict means adding one name to the registry, and every boundary picks it up automatically. Scope rules documented at the registry: turn-scoped state, the monotonic generation counter, and the agent cache are deliberately excluded (different lifecycles). 2. SessionTurnLeaseRegistry.rebind — the held turn lease now FOLLOWS mid-turn compression rotation. Both rotation sites (session-hygiene pre-compression, agent-result session_id swap) alias the same _SessionLease object under the new id, so an alias routing key resolving the fresh child (topic tip-walk) still serializes against the in-flight turn. Closes the rotation-alias window flagged as a known limit on #64934. Ownership-checked like release; when the target id already has a live lease the rebind fails open with a loud WARNING (never a mid-turn deadlock). Tests: 3 new rebind behavior tests + 5 funnel behavior tests (including a real-setter drift guard); the two AST change-detector pins in test_10710/test_48031 were re-pointed at the funnel and the #58403 pin converted to a behavioral test. E2E: rotation-alias scenario against a real SessionStore + SessionDB — turn B on the fresh child waits behind the rotated holder, sees its rows, alternation intact.
) (#67401) * fix(gateway): serialize concurrent turns per resolved session_id with a turn lease Closes the serialization half of #64934. The busy guards are keyed by routing key, but the durable transcript is owned by session_id — and switch_session() makes the key→id mapping many-to-one (/resume from a second chat/topic, CLI-continuity rebinding, async-delegation pinning, topic-binding tip-walks). Two routing keys mapped to one session_id ran concurrent turns on two different agent objects, invisible to every per-key guard: flushes persisted in completion order, the identity-marker dedup swallowed rows, and the second turn ran on a stale history base — leaving a permanent user;user alternation wedge. The fix: an asyncio lease keyed by RESOLVED session_id (gateway/turn_lease.py), acquired in _handle_message_with_agent after session resolution is final (post switch_session/tip-walk), immediately before the transcript load, and released in _handle_message's finally on every exit path. Tokens are granted per (routing key, run generation) so a stale unwind can never release a newer turn's lease (#28686 ownership lesson). Same-key messages never reach the acquisition point mid-turn (both routing-key guards hold them), so the lock is uncontended outside the alias-key route — where the second turn now waits for the first turn's flush and logs one WARNING naming the session and both routing keys (pairs with the #67371 tripwire). Fail-open: a stuck holder degrades to today's unserialized behavior with a loud ERROR after agent.gateway_timeout — never a wedged session; a degraded token holds nothing and can't steal the lease. Registry is size-capped and never evicts a live lease. Persist-disabled review forks never dispatch through _handle_message, so they cannot contend. Known limits (tracked on #64934): CLI-continuity cross-process pairs need a DB-level lease; mid-turn compression rotation leaves a small alias window for a follow-up at the binding-sync sites. Validation: 8 behavior tests (alias-key wait + flush order, no cross-session contention, generation-scoped idempotent release, timeout fail-open without lease theft, bounded registry, bare-runner-safe release wiring) + E2E against a real SessionStore reproducing the issue's switch_session alias route — strict alternation and arrival order preserved. * refactor(gateway): conversation-scope funnel + mid-turn lease rebind Completes the #64934 system beyond the point fix. Two structural changes, both eliminating whole bug classes rather than instances: 1. _clear_conversation_scope — THE single conversation-boundary funnel. /new, /resume, auto-reset, expiry finalization, and the compression-exhausted reset each carried a hand-copied pop-list of the per-session dicts, and the lists drifted every time a new dict was added (#48031, #58403, #10702, #35809 were all 'boundary X forgot dict Y' bugs). All five sites now make one funnel call driven by the _CONVERSATION_SCOPED_STATE registry; adding a new conversation-scoped dict means adding one name to the registry, and every boundary picks it up automatically. Scope rules documented at the registry: turn-scoped state, the monotonic generation counter, and the agent cache are deliberately excluded (different lifecycles). 2. SessionTurnLeaseRegistry.rebind — the held turn lease now FOLLOWS mid-turn compression rotation. Both rotation sites (session-hygiene pre-compression, agent-result session_id swap) alias the same _SessionLease object under the new id, so an alias routing key resolving the fresh child (topic tip-walk) still serializes against the in-flight turn. Closes the rotation-alias window flagged as a known limit on #64934. Ownership-checked like release; when the target id already has a live lease the rebind fails open with a loud WARNING (never a mid-turn deadlock). Tests: 3 new rebind behavior tests + 5 funnel behavior tests (including a real-setter drift guard); the two AST change-detector pins in test_10710/test_48031 were re-pointed at the funnel and the #58403 pin converted to a behavioral test. E2E: rotation-alias scenario against a real SessionStore + SessionDB — turn B on the fresh child waits behind the rotated holder, sees its rows, alternation intact.
/resume is a conversation boundary, but unlike /new it did not clear the chat-keyed _session_model_overrides / _pending_model_notes. A /model switch made in the previous session under the same chat session_key leaked into the resumed conversation, running it on the wrong model. Clear both maps for the session_key after the switch (mirroring /new), scoped to that key so other chats' overrides are untouched. The cached-agent eviction this leak also implied already landed via NousResearch#6672. Closes NousResearch#10702.
…1 python/runtime; desktop frozen (#152) * chore(contributors): map s0xn1ck@proton.me -> s0xn1ck * feat(desktop): list config-defined command TTS/STT providers in settings The Settings > Voice provider dropdowns (tts.provider / stt.provider) only offer the built-in providers plus whatever value is currently set. Custom `type: command` providers declared in config.yaml aren't selectable — and once you switch away from one it drops off the list, so you can only return to it by hand-editing config. enumOptionsFor now merges in the names of any `type: command` entries under the tts/stt config sections, so local command-backed engines appear alongside the built-ins and can be switched freely from the UI. Enumeration mirrors the runtime's own resolution so the dropdown can only offer a name the runtime would actually honour: the canonical `<section>.providers.<name>` location plus the back-compat top-level `<section>.<name>` block, the optional `type:` discriminator, and the built-in-name guard. The guard compares against the runtime's built-in sets rather than the ENUM_OPTIONS display list, which is not a substitute — it already omits `deepinfra` (TTS) and `deepinfra`/`local_command` (STT), so a `providers.deepinfra` command block would otherwise be offered as selectable while the runtime dispatches to the native backend instead. - helpers.ts: add commandProviderNames() + the built-in guard; merge for tts.provider + stt.provider - helpers.test.ts: cover both sections, incl. that non-command config blocks aren't offered and that built-ins absent from the display list are never offered as command providers * feat: surface all xAI TTS params in desktop GUI config - Add speed, auto_speech_tags, text_normalization, optimize_streaming_latency, sample_rate, bit_rate to DEFAULT_CONFIG tts.xai block (backend schema source) - Add field labels, descriptions, and section keys in frontend constants.ts for all 7 xAI TTS fields - Update i18n translations (ja, zh, zh-hant) - Fix stale tts.provider options in web_server.py schema overrides (was missing xai, minimax, mistral, gemini, kittentts, piper) * fix(gui): add xAI prefix to all xAI-specific TTS field labels Consistent naming across the xAI TTS settings section. Speed and sampleRate are shown only when xAI is the selected provider, so they get the prefix too. * fix(desktop): drop tts.xai.text_normalization — not honored by the xAI TTS backend Follow-up to the salvaged #56724: the runtime's _generate_xai_tts reads voice_id, language, speed, auto_speech_tags, optimize_streaming_latency, sample_rate, and bit_rate — but never text_normalization, and the xAI /v1/tts payload builder has no such field. Surfacing it in the desktop GUI would be a dead knob, so remove it from DEFAULT_CONFIG, constants.ts (labels/descriptions/SECTIONS), and the ja/zh/zh-hant locale catalogs. The other six xAI keys are all verified against tools/tts_tool.py. * fmt(js): `npm run fix` on merge (#67419) Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * fix(credentials): suppress re-seeding when a pool entry is deleted via API (#55217) (#67429) * fix(gateway): per-session turn lease + conversation-scope funnel (#64934) (#67401) * fix(gateway): serialize concurrent turns per resolved session_id with a turn lease Closes the serialization half of #64934. The busy guards are keyed by routing key, but the durable transcript is owned by session_id — and switch_session() makes the key→id mapping many-to-one (/resume from a second chat/topic, CLI-continuity rebinding, async-delegation pinning, topic-binding tip-walks). Two routing keys mapped to one session_id ran concurrent turns on two different agent objects, invisible to every per-key guard: flushes persisted in completion order, the identity-marker dedup swallowed rows, and the second turn ran on a stale history base — leaving a permanent user;user alternation wedge. The fix: an asyncio lease keyed by RESOLVED session_id (gateway/turn_lease.py), acquired in _handle_message_with_agent after session resolution is final (post switch_session/tip-walk), immediately before the transcript load, and released in _handle_message's finally on every exit path. Tokens are granted per (routing key, run generation) so a stale unwind can never release a newer turn's lease (#28686 ownership lesson). Same-key messages never reach the acquisition point mid-turn (both routing-key guards hold them), so the lock is uncontended outside the alias-key route — where the second turn now waits for the first turn's flush and logs one WARNING naming the session and both routing keys (pairs with the #67371 tripwire). Fail-open: a stuck holder degrades to today's unserialized behavior with a loud ERROR after agent.gateway_timeout — never a wedged session; a degraded token holds nothing and can't steal the lease. Registry is size-capped and never evicts a live lease. Persist-disabled review forks never dispatch through _handle_message, so they cannot contend. Known limits (tracked on #64934): CLI-continuity cross-process pairs need a DB-level lease; mid-turn compression rotation leaves a small alias window for a follow-up at the binding-sync sites. Validation: 8 behavior tests (alias-key wait + flush order, no cross-session contention, generation-scoped idempotent release, timeout fail-open without lease theft, bounded registry, bare-runner-safe release wiring) + E2E against a real SessionStore reproducing the issue's switch_session alias route — strict alternation and arrival order preserved. * refactor(gateway): conversation-scope funnel + mid-turn lease rebind Completes the #64934 system beyond the point fix. Two structural changes, both eliminating whole bug classes rather than instances: 1. _clear_conversation_scope — THE single conversation-boundary funnel. /new, /resume, auto-reset, expiry finalization, and the compression-exhausted reset each carried a hand-copied pop-list of the per-session dicts, and the lists drifted every time a new dict was added (#48031, #58403, #10702, #35809 were all 'boundary X forgot dict Y' bugs). All five sites now make one funnel call driven by the _CONVERSATION_SCOPED_STATE registry; adding a new conversation-scoped dict means adding one name to the registry, and every boundary picks it up automatically. Scope rules documented at the registry: turn-scoped state, the monotonic generation counter, and the agent cache are deliberately excluded (different lifecycles). 2. SessionTurnLeaseRegistry.rebind — the held turn lease now FOLLOWS mid-turn compression rotation. Both rotation sites (session-hygiene pre-compression, agent-result session_id swap) alias the same _SessionLease object under the new id, so an alias routing key resolving the fresh child (topic tip-walk) still serializes against the in-flight turn. Closes the rotation-alias window flagged as a known limit on #64934. Ownership-checked like release; when the target id already has a live lease the rebind fails open with a loud WARNING (never a mid-turn deadlock). Tests: 3 new rebind behavior tests + 5 funnel behavior tests (including a real-setter drift guard); the two AST change-detector pins in test_10710/test_48031 were re-pointed at the funnel and the #58403 pin converted to a behavioral test. E2E: rotation-alias scenario against a real SessionStore + SessionDB — turn B on the fresh child waits behind the rotated holder, sees its rows, alternation intact. * fix(desktop): resolve session color for repo-root-only sessions liveSessionProjectId bailed the instant a session had no cwd, so an older/imported session carrying only a git_repo_root — which the backend still groups under its project — got no project and rendered a grey idle dot instead of the project color ("grouped but grey"). Anchor on the repo root when cwd is absent, matching how the sidebar grouped the row, and keep the sibling-worktree guard for the cwd-present case. * feat(desktop): let inherited projects set color and icon Auto-detected git repos ("inherited" projects) have no projects.db row, so their menu hid appearance/rename/etc. entirely and they could never be themed. Add appearance to the auto-project menu: the first color/icon choice adopts the repo as a real project (folder = repo root, name = its label) carrying that look, after which it themes in place like any explicit project. Routes both explicit and auto edits through one setProjectAppearance helper; the picker closes on adopt so a stale second write can't double-create. * bench(desktop): systematized perf harness; sunset 12 one-off scripts (#67466) Replaces the dozen ad-hoc measure-*/profile-* scripts (each reinventing the CDP client — 4 different copies — plus its own arg parsing, stats, output path, and none with a baseline) with one framework under scripts/perf/: - lib/cdp.mjs one CDP client + target discovery + typing + CPU-profile wrapper + DOM selectors - lib/stats.mjs percentiles, histograms, CPU-profile self-time ranking - lib/baseline.mjs load/compare/update baseline + regression gate (new capability) - lib/launch.mjs attach, OR spawn a fully ISOLATED instance - scenarios/* one module per measurement, registered in scenarios/index.mjs - run.mjs / serve.mjs, baseline.json, README.md Isolation solves the long-standing measurement blocker: a running `hgui` held the Electron single-instance lock, so a second instance quit. `--spawn` / `perf:serve` launch with their own --user-data-dir (separate lock scope), their own HERMES_HOME (separate backend/sessions, config seeded from ~/.hermes so it reaches a chat view without onboarding), and their own --remote-debugging-port. Synthetic scenarios drive $messages via window.__PERF_DRIVE__, so no LLM credits. Scenario -> sunset script mapping: stream <- measure-synthetic-stream, profile-synth-stream, profile-long-stream stream --real <- measure-real-stream, profile-real-stream keystroke <- measure-latency, profile-typing, leak-typing transcript <- (new: long-transcript mount cost) submit <- measure-submit, measure-jump session-switch <- profile-session-switch profile-switch <- measure-profile-switch CPU profiling is now a cross-cutting --cpuprofile flag, not 5 separate scripts. CI-tier scenarios (stream, keystroke, transcript) need no backend/credits and are gated against baseline.json (seed values; re-capture with --update-baseline on a reference device). Backend-tier scenarios are report-only. perf-probe.tsx gains loadTranscript() for the transcript scenario. No core files touched; isolation is via CLI args, not env-gated app changes. Verified: node --check all modules, tsc, eslint, and a unit smoke of the stats + regression-gate logic. The end-to-end GUI run (which opens a window) is left to run interactively via `npm run perf -- --spawn`. * fmt(js): `npm run fix` on merge (#67474) Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * fix(desktop): Windows browser-setup journey — console flash, idempotent setup, Nous Portal activation (#67473) * fix(windows): suppress console-window flash in tools post-setup subprocess spawns The desktop GUI runs post-setup hooks via a detached, console-less 'hermes tools post-setup <key>' child (spawned with windows_detach_flags). But the hook implementations in tools_config.py ran their inner installers (npm install, agent-browser install, uv/pip installs, ensurepip, cua-driver version probes and installer) without Windows creationflags — and on Windows a console-less parent spawning a console/.cmd child materializes a brand-new console window, the 'terminal flash' reported on the Capabilities > Browser Automation setup journey. Add _post_setup_no_window_flags(), a local wrapper around windows_hide_flags() (CREATE_NO_WINDOW only — DETACHED_PROCESS would sever stdio and break capture_output), and pass it at every post-setup subprocess call site. Spawns that stream live output to the user's console (verbose cua-driver install) only hide when stdout is not a tty, so interactive CLI installs keep their output. POSIX behavior is unchanged (the helper returns 0 off-Windows). * fix(desktop): make Capabilities post-setup idempotent — Installed state instead of unconditional Run setup The GUI panel rendered the primary 'Run setup' CTA whenever a provider declared post_setup, ignoring the server-computed readiness status the config endpoint already serves. Users on Windows clicked 'Run setup' on an already-installed Local Browser and watched it 'install' again. Frontend: PostSetupRunner now takes installed (provider.status === 'ready') and renders an 'Installed' pill + small 'Re-run setup' text button in that state; onComplete still refetches the toolset config, so a fresh install flips the row to Installed once the endpoint reports ready. Backend: - _POST_SETUP_READY extended: agent_browser now tracks the FULL local install (_local_browser_runnable: CLI + Chromium-or-Lightpanda) instead of the bare CLI check; new entries for the cloud 'browserbase' hook (CLI only — cloud rows host their own Chromium) and camofox (npm package present). - _run_post_setup prints distinct 'already installed, nothing to do' messages for the agent-browser/Chromium/Camofox early-exits so the GUI action log tells the truth on re-runs vs fresh installs. i18n: new postSetupInstalled/postSetupRerun/postSetupInstalledHint strings in en, ja, zh, zh-hant + types. * fix(desktop): let managed Nous Subscription rows activate from the GUI via the Portal sign-in flow PUT /api/tools/toolsets/{name}/provider intentionally skips the Nous Portal auth gate the CLI runs inline (ensure_nous_portal_access) — but no desktop surface handled it. Selecting 'Nous Subscription (Browser Use cloud)' from Capabilities wrote browser.cloud_provider=browser-use + use_gateway=true and then silently never activated: _is_provider_active requires feature.managed_by_nous, which stays false without the entitlement, and the credential was never used. Backend: after apply_provider_selection, the endpoint now checks the managed row's entitlement (get_nous_subscription_features force_fresh + the same per-category coverage gate the CLI applies) and reports the gap with additive response fields {needs_nous_auth: true, feature}. The selection is still persisted — activation is what's gated. Frontend: handleSelect surfaces a 'Sign in to Nous Portal' warning toast with a Sign-in action instead of the misleading success toast. The action drives the EXISTING Nous Portal OAuth device-code flow (provider id 'nous' in _OAUTH_PROVIDER_CATALOG): POST /api/providers/oauth/nous/start, open verification_url, poll /poll/{session}; on approval the panel refetches the toolset config so is_active/status flip. i18n: nousAuthNeeded*/nousAuthSignIn/nousAuthDone*/nousAuthFailed strings in en, ja, zh, zh-hant + types. * feat(desktop): per-job model picker in the cron create/edit dialog (#67472) The cron backend has always supported per-job model/provider pins (the dashboard web UI and the cronjob tool expose them), but the desktop app's cron editor had no way to set one — every job silently ran on the global default model. - Cron editor gains an optional Model select, grouped by provider, fed by the same model.options catalog as the chat model picker (configured providers with available models only, curated order preserved). - Resetting to 'Default (global model)' clears a previous pin (model and provider written as null); script-only (no_agent) jobs never touch the model fields since the scheduler ignores overrides for them. - A pinned model that has since left the catalog stays visible and re-selectable instead of rendering Radix's blank trigger. - Job detail pane shows the pinned model when one is set. - ui/select grows SelectGroup + SelectLabel primitives for the grouped list. - CronJob/CronJobCreatePayload/CronJobUpdates types carry model/provider; en/ja/zh/zh-hant locales add the two new labels. The cronjob model tool schema is intentionally unchanged — model selection stays a user-facing UX affordance, not an agent-facing tool parameter. * fmt(js): `npm run fix` on merge (#67486) Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * feat(config): surface custom and plugin voice providers in config schema * fix(web): compute voice provider schema options per-request, align guards with desktop (#40338 follow-up) Refactor the cherry-picked #40338 backend half: - Move option merging from import-time _SCHEMA_OVERRIDES mutation to a per-request overlay in GET /api/config/schema — options now reflect the current config.yaml (no restart needed) and the module-level CONFIG_SCHEMA is never mutated. The endpoint gains an optional ?profile= param scoped via _config_profile_scope. - Keep builtin display order first, customs appended (drop the sorted(set(...)) re-sort) — matches desktop enumOptionsFor. - Only command-type provider blocks count (type absent or 'command' plus non-empty command string), enumerated from the canonical <kind>.providers.* location AND the legacy top-level <kind>.<name> fallback — the same dual resolution as _get_named_provider_config / _get_named_stt_provider_config. Builtin-name collisions are excluded case-insensitively against the RUNTIME builtin sets (not the display shortlist), mirroring apps/desktop/src/app/settings/helpers.ts commandProviderNames (#67209). - Drop the plugin.yaml 'provides: [tts]' manifest scan — that convention does not exist (manifests carry provides_tools/provides_hooks only); plugin TTS/STT providers register at runtime via ctx.register_tts_provider(). Instead, opportunistically include names from agent.tts_registry / agent.transcription_registry when plugins happen to be loaded in this process. - Current tts.provider/stt.provider value preserved in options. - Tests: custom command provider merge (tts+stt), builtin-order preservation, EDGE collision exclusion, non-command block exclusion, current-value preservation, per-request freshness, legacy top-level block support. * feat(desktop): five Capabilities-tab UX fixes from live testing — hints, vision link, web split, key deep-links (#67482) * fix(desktop): stop contradicting the Ready pill with the one-time-install hint When a provider's server-computed status is 'ready' (post_setup install verifiably satisfied, e.g. cua-driver on PATH), the PostSetupRunner row still said 'This backend needs a one-time install (…)'. Swap the copy for a muted installed-confirmation one-liner and keep the Run setup button for repair re-runs. Gated purely on the provider status prop so it composes with the server-driven resting state work in the sibling lane. * feat(tools): surface the web search/extract capability split in the Capabilities UI The runtime has dispatched web_search and web_extract to independently configurable backends for a long time (web.search_backend / web.extract_backend overrides with web.backend as the shared fallback), but the Capabilities tab still presented one monolithic 'Web Search & Extract' choice that only wrote web.backend. Backend: - GET /api/tools/toolsets/web/config now returns active_search_backend / active_extract_backend resolved via the REAL runtime getters (tools.web_tools._get_search_backend/_get_extract_backend), plus each provider row's web_backend key and supported capabilities (from the registry's supports_search/supports_extract flags). - PUT /api/tools/toolsets/web/provider accepts an optional capability ('search'|'extract') that writes web.<capability>_backend without touching web.backend; validates the provider actually supports the requested capability (ddgs/brave-free are search-only). Omitted → unchanged legacy apply_provider_selection path. - New tools_config.web_provider_capabilities() helper reads the plugin registry's capability flags. Frontend: 'Search: <backend>' / 'Extract: <backend>' pills above the web provider matrix, per-row 'Search backend'/'Extract backend' assignment pills, and 'Use for Search'/'Use for Extract' actions gated on each backend's declared capabilities. Tests: endpoint tests assert the runtime getters resolve to the written backend (searxng for search, firecrawl for extract) after the endpoint write; vitest covers badges, capability-gated buttons, and non-web toolsets staying untouched. * feat(desktop): deep-link Capabilities key rows to Settings → API Keys Set env-var rows in the toolset config panel now offer 'Manage in API Keys' in the row actions menu — an internal route change to /settings?tab=keys&key=<ENV_KEY>. KeysSettings consumes the ?key= param via the shared useDeepLinkHighlight hook (same mechanism as the command palette's ?field= config deep links and ?session= archived-session links): scrolls the credential card into view, flashes it, and expands it. Applies generically to every env-var row, and only when the key is set (unset keys are managed inline via Set). i18n in en/zh/zh-hant/ja. * feat(desktop): point the vision Capabilities detail at Settings → Models The vision toolset has no TOOL_CATEGORIES provider matrix — its provider/model resolution runs through the auxiliary model config (agent/auxiliary_client.py), so the Capabilities detail pane looked empty with no hint of where the model choice lives. Add a short explainer + an internal deep link (/settings?tab=config:model&aux=vision) rendered only for toolset.name === 'vision'. ModelSettings consumes the ?aux= param via the shared useDeepLinkHighlight hook and scrolls/flashes the matching auxiliary task row (rows now carry aux-task-<key> anchor ids). No external URLs. i18n in en/zh/zh-hant/ja. * test(desktop): use type-alias imports for the react-router mock (lint) * chore: drop accidentally committed node_modules symlinks * chore: drop remaining committed node_modules symlinks (apps/desktop, apps/shared) * fmt(js): `npm run fix` on merge (#67491) Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * fix(desktop): profile-scope all cron REST calls Salvaged from #49948 by @helix4u: every desktop cron API call (list/get/runs/create/update/pause/resume/trigger/delete) now carries profileScoped(), so global-remote mode routes the request to the profile the UI is acting for instead of silently hitting the primary backend's default profile. * fix(cron): resolve provider with the job's effective model; default dashboard cron creates to the backend's own profile Two follow-ups to the per-job model pin surface (#67472 / #49948 review): - cron/scheduler.py: pass target_model=<effective job model> to resolve_runtime_provider() on the primary path, so providers with model-specific api_mode routing derive the mode from the model the job actually runs (per-job pin > env > config default) instead of the stale persisted default. The auth-fallback path already did this for its fb_model. - hermes_cli/web_server.py: POST /api/cron/jobs (and its sync worker) no longer hardcodes profile="default" when the request carries no profile param. A pool backend scoped to a named profile now resolves its own profile via get_active_profile_name(), so pre-profileScoped desktop clients can't write a named profile's job into ~/.hermes. Unscoped / custom HERMES_HOME keeps the legacy default fallback. Tests: target_model capture test on run_job; two profile-default tests on the create endpoint. * test(cron): accept target_model kwarg in codex-path resolver stub run_job now passes target_model to resolve_runtime_provider; the codex 401-refresh test stubbed it with a requested-only lambda. Widen to **kwargs like every other cron resolver stub. * test(desktop): contract test — every cron helper is profile-scoped Salvaged from #59888 by @isfttr: the profileScoped() fix itself landed via #67493 (salvaged from the earlier #49948), but this PR contributed a contract test locking all 9 cron helpers to the active gateway profile — omitted when none is set (single-profile users unaffected), attached when one is active. Keeps the multi-profile/remote cron routing from silently regressing. * feat(delegation): live-viewable subagent transcripts — tail your subagents while they work (#67479) * feat(delegation): live-viewable subagent transcripts for delegate_task Each child now streams an append-only, human-readable log to <hermes_home>/cache/delegation/live/<delegation_id>/task-<n>.log while it runs, and the dispatch return includes the paths so the caller can tail them immediately instead of waiting blind for the consolidated summary. - New tools/delegation_live_log.py: LiveTranscriptWriter (per-event append + flush, one-line rendering with truncation, never raises into the agent loop), wrap_progress_callback (tees the child's existing tool_progress_callback events into the log, preserves the _flush contract), dispatch-time creation with pre-headered files so tail -f attaches immediately, manifest.json (goals/task count/per-task status), and 7-day retention pruning on new dispatches. - delegate_task: wraps each child's progress callback with the writer; sync results and background dispatch responses gain live_transcripts (+ hint field on dispatch); per-task result entries carry live_transcript; transcripts finalized with exit-reason markers. - async_delegation: dispatch_async_delegation_batch accepts an optional delegation_id so the live/ dir name matches the returned handle; the completion event carries live_transcripts. - process_registry: consolidated batch-completion block references each task's live transcript path. - Tool schema description documents the live_transcripts return surface; docs gain a 'Live Transcripts' section with a tail -f example. Placement under cache/delegation means the logs are mounted read-only into remote terminal backends for free. Side-channel only: zero changes to message content, so prompt caching is unaffected. Transcript-OUT only — no overlap with the subagent control surfaces of PR #66046. * fix(delegation): label the kickoff transcript line as user — it is the child's one user message * fix(desktop): scope the cron jobs list to the active profile Salvaged from #42654 by @digitalbase (earliest report of the leak, June 9): the desktop sidebar and cron overlay showed EVERY profile's jobs because GET /api/cron/jobs defaults to profile=all and the desktop never sent the param — profileScoped() (landed in #67493) routes the backend process but adds no endpoint filter on local pools. - hermes.ts: getCronJobs(profile?) appends ?profile= when given; omitting the arg keeps the legacy unfiltered path. profileScoped() still rides along for process routing. - use-session-list-actions.ts: sidebar cron refresh passes the sidebar's profile scope (concrete profile → own jobs; ALL_PROFILES → 'all'). - app/cron/index.tsx: the cron overlay's refresh uses the same scope so the overlay and sidebar (shared $cronJobs atom) always agree. - Tests: list ?profile= contract in hermes-cron-scope.test.ts; sidebar scoping in use-session-list-actions.test.tsx. Reworked onto current main per the sweeper review: threaded through the existing profileScoped()/list-param seams instead of the original PR's pre-refactor call sites (DesktopController has since delegated to use-session-list-actions). * feat(agent): adaptive thinking for Kimi-family Anthropic endpoints Kimi's Anthropic-compatible endpoints (api.moonshot.cn/anthropic, api.kimi.com/coding) implement the adaptive thinking contract — they accept thinking.type=adaptive + output_config.effort (all of low, medium, high, xhigh, max verified live) and return thinking blocks, and the replay-validation 400s that originally motivated dropping the parameter (#13848) no longer occur. _supports_adaptive_thinking() now returns True for Kimi-family models, so they get thinking={type: adaptive, display: summarized} + output_config.effort via ADAPTIVE_EFFORT_MAP instead of nothing, and the blanket drop of the thinking parameter for Kimi-family endpoints is removed. MiniMax and other non-adaptive third parties keep the manual budget_tokens path; Claude behavior is unchanged. * fix(desktop): support spaced Windows Git paths in review simple-git's custom-binary validation rejects paths containing spaces, so the default Windows Git install (C:\Program Files\Git\cmd\git.exe) made every Review pane git call throw and the pane silently showed 'No diffs'. The binary is resolved inside the Electron main process from known install locations or PATH — never renderer/user input — so for spaced paths we opt into simple-git's supported unsafe.allowUnsafeCustomBinary escape hatch rather than falling back to PATH (often absent in GUI-launched apps). Simplified from PR #64713 by @unsupportedpastels; supersedes the 8.3 short-path approaches in #55337/#60156. Fixes #54888 * bench(desktop): make --spawn work + capture a real baseline (#67670) - Resolve the vite CLI via vite/package.json `bin` (Vite 8's exports block importing vite/bin/vite.js directly — --spawn failed with ERR_PACKAGE_PATH_NOT_EXPORTED). - Add a post-launch settle so cold-start contention (vite dep pre-bundling, first backend-connect attempts) doesn't contaminate the first scenario. - Drop the raw autolink from the default stream chunk (resolvable URLs trigger link-embed DNS lookups unrelated to render cost). - Replace seed baseline with real numbers from a darwin-arm64 --spawn run. keystroke + transcript are clean; stream is a clean single-run capture (the isolated backend may not connect, and its reconnect churn inflates frame pacing — re-capture on a connected instance for tighter tolerances). * refactor(desktop): tidy session-color pass (#67671) - sessionColorFor: drop the no-op `?? undefined` (the map read is already string | undefined). - sessionProjectColor: fix a now-stale doc line — a rootless (no cwd AND no git_repo_root) row returns null, not any cwd-less row (repo-root-only rows resolve since the grouped-but-grey fix). - ProjectMenu.applyAppearance: await instead of a .then block; flatten the auto-branch's nested ternary. * feat(desktop): per-session color override (#66565 layer 2) (#67681) Add a color picker to the session menu (an Appearance submenu of reusable ColorSwatches, in both the dropdown and right-click flavors). The pick is a per-session override that wins over the inherited project color; clearing falls back to it. Storage is desktop-local like pins ($sessionColorOverrides persistentAtom), keyed by the DURABLE lineage id so a color survives auto-compression's id rotation. Precedence folds into the existing $sessionColorById resolver, so sidebar rows AND pane tabs pick it up with no changes to either — the payoff of the shared store. To take this to the TUI later, promote this one atom to a backend SessionInfo.color field; the resolver and picker stay put. * bench(desktop): trustworthy --spawn stream numbers + real baseline (#67694) Chased the "stream frame p95 = 60ms with ZERO longtasks" mystery to its actual cause: the default stream chunk had no paragraph breaks, so it grew into one giant ~22KB block that re-rendered fully every flush — defeating the block memoization real streaming relies on. Plain text = 21ms; realistic chunk with `\n\n` breaks (blocks settle, only the tail re-renders) = 23ms. Fixed the default chunk to model real LLM output; a break-less `--chunk` remains available as a single-block worst-case stress. Also hardened the isolated instance so measurements reflect real cost: - Wait for the gateway socket to actually connect before measuring (a booting/ absent backend's reconnect backoff churns the main thread). Exposed via a new __PERF_DRIVE__.connected() probe reading $gateway.connectionState. - Focus emulation + anti-throttle/occlusion flags so a backgrounded perf window isn't frame-throttled (no OS focus stealing). - Generation-guarded the rAF frame recorder so repeated runs don't leave overlapping recorders polluting frame intervals. Baseline re-captured as the median of 5 --spawn runs (darwin-arm64); all three CI scenarios now green and stable. Absolute values are dev-build (noted in _meta) — regression guards, not shipped numbers. * bench(desktop): measure the full picture — prod build, cold-start, first-token (#67697) Stop drip-feeding scenarios: extend the harness to cover the latencies that actually dominate perceived speed, and measure them on a REAL production build. - --prod: build a production renderer with the probe included (VITE_PERF_PROBE=1, off in normal builds) and launch it from dist/. Measures minified React, so numbers are representative shipped figures instead of ~3x-inflated dev ones. - cold-start scenario (tier "cold"): launch → CDP → driver → first paint, via a fresh isolated spawn per run. Captures spawn_to_cdp_ms, spawn_to_driver_ms, fcp_ms. - first-token scenario (backend tier): Enter → first assistant token painted — the TTFT latency an agent app is uniquely judged on. - run.mjs gained --prod (build once), cold-start fresh-spawn loop, and gates ci+cold tiers against the baseline. Baseline re-captured on a PRODUCTION build (median of 5), darwin-arm64 — all green. Representative numbers: cold-start spawn→interactive ~1.6s, FCP ~0.5s stream frame p95 22ms, 1 longtask keystroke p50 2ms, p95 8.7ms transcript mount 145ms, 82ms longtask (400-msg open) The prod build also settled the open question from the dev numbers: the transcript-mount "lead" (221ms longtask in dev) is only ~72-82ms in prod — not actionable. Measurement did its job. * fix(dashboard): don't let a provider-name query hide the selected provider's models (#65374) (#65413) Co-authored-by: Simplicio, Wesley (ext) <wesley.simplicio.ext@siemens-energy.com> * fix(dashboard): opaque MoA presets modal (stop page bleed-through) (#67410) * fix(dashboard): make MoA presets modal opaque and readable Card defaults to bg-background-base/80 glass, so the Mixture of Agents dialog let the Models page bleed through — especially on Cyberpunk/mobile. Portal an opaque dialog shell above the z-2 dashboard column, and ignore Escape while the nested model picker is open. * test(web): lock dashboard modal shell to opaque panel classes Guard the MoA/dialog shell contract so glass Card defaults cannot quietly return to modal panels, and Escape stays picker-aware. * bench(desktop): trustworthy cold-start measurement (code-splitting is not the lever) (#67720) * bench(desktop): measure the full picture — prod build, cold-start, first-token Stop drip-feeding scenarios: extend the harness to cover the latencies that actually dominate perceived speed, and measure them on a REAL production build. - --prod: build a production renderer with the probe included (VITE_PERF_PROBE=1, off in normal builds) and launch it from dist/. Measures minified React, so numbers are representative shipped figures instead of ~3x-inflated dev ones. - cold-start scenario (tier "cold"): launch → CDP → driver → first paint, via a fresh isolated spawn per run. Captures spawn_to_cdp_ms, spawn_to_driver_ms, fcp_ms. - first-token scenario (backend tier): Enter → first assistant token painted — the TTFT latency an agent app is uniquely judged on. - run.mjs gained --prod (build once), cold-start fresh-spawn loop, and gates ci+cold tiers against the baseline. Baseline re-captured on a PRODUCTION build (median of 5), darwin-arm64 — all green. Representative numbers: cold-start spawn→interactive ~1.6s, FCP ~0.5s stream frame p95 22ms, 1 longtask keystroke p50 2ms, p95 8.7ms transcript mount 145ms, 82ms longtask (400-msg open) The prod build also settled the open question from the dev numbers: the transcript-mount "lead" (221ms longtask in dev) is only ~72-82ms in prod — not actionable. Measurement did its job. * bench(desktop): trustworthy cold-start measurement (code-splitting is NOT the lever) Investigated code-splitting the ~22MB renderer bundle to cut cold start. It is the wrong fix on both counts: 1. Intentional design: vite.config disables codeSplitting because Shiki emits thousands of dynamic chunks and electron-builder OOMs scanning them — a packaging/installer constraint, not an oversight. 2. The data says it wouldn't help. Fixing the cold-start measurement to be trustworthy and reading the boot composition (prod build): spawn → interactive ~1.5s renderer nav → DOMInteractive ~0.8s, → DOMContentLoaded ~1.06s so the whole 22MB bundle EVAL is only ~0.27s (DCL − DOMInteractive) of the ~1.5s. The dominant costs are Electron/window startup and React app mount — neither touched by splitting. The measurement fixes (the real content of this PR — no app change, since the optimization was rejected): - Drop HERMES_DESKTOP_BOOT_FAKE from spawned instances — it injected artificial per-phase boot-overlay sleeps that inflated cold-start (and slowed every run). - Unique debug/dev port per cold-start run — a just-killed instance can hold :9222 briefly, so reusing it made CDP attach to the DYING instance and report garbage (spawn_to_cdp of ~4ms). Stepping the port per run fixes the race. - Richer boot marks (dom_interactive, dom_content_loaded, main-script size) so cold-start composition is visible, not just a single number. - Forward all numeric boot marks from the cold-start loop. - Re-baseline cold-start with the clean numbers. A real cold-start win would target Electron startup / app-mount (e.g. V8 code cache, deferred non-critical mount) — a future pass, now that it's measurable. * bench(desktop): measure representative (warm-cache) cold start (#67733) Profiling the boot answered "is there a real cold-start win?": no wasteful hotspot — the renderer does only ~tens of ms of work at mount, no heavy library (shiki/mermaid/katex/d3/motion) initializes at startup; the rest is Electron runtime + waiting, near the Electron floor. It also exposed that the cold-start number was pessimistic: a fresh --user-data-dir per run means a COLD V8 code cache and worst-case bundle recompile every launch. Real users reuse their profile. Measured delta: fresh (cold cache): spawn→interactive ~1.48s reused (warm cache): ~1.0s So representative launch is ~1.0s; only first-launch-after-install pays ~+400ms. - coldStartSamples() reuses one profile (run 0 warms the cache, discarded; runs 1..N are warm samples), stepping ports + pausing so the single-instance lock releases. `--cold-fresh` measures the first-launch worst case. - Re-baselined cold-start with the representative warm numbers. Net: nothing high-ROI left to optimize. The only lever is shipping a pre-warmed V8 code cache to make first launch match warm (~400ms, once per update) — real packaging complexity for a marginal win, deliberately not pursued. * perf(desktop): stop per-token sidebar + tool-row re-renders during streaming Two real render-cost wins found by inspection (no behavior change): 1. Sidebar re-rendered on every stream token. $sessionStates is republished on every message delta (tens/sec during a turn), and the derived ID computeds ($workingSessionIds, $attentionSessionIds, $backgroundRunningSessionIds) allocated a fresh array each time. nanostores notifies on !==, so the whole ChatSidebar + every mounted row re-rendered per token even when the working/ attention/background set was unchanged. Return the previous array reference when the contents match → nanostores skips the notify unless the set actually changes. Turns streaming from O(visible rows)/token into O(0) for the sidebar. 2. Tool rows normalized the FULL uncapped detail every render. `looksRedundant` (lowercase + whitespace-collapse over the entire read_file/terminal payload) ran twice in the ToolEntry render body, so every completed tool re-normalized its whole output on every stream tick of the running message. Memoize on the view fields so it recomputes only when the tool's content changes. Both are correctness-preserving (stable refs + memoization). The CI stream scenario drives $messages directly, not the publishSessionState path, so it won't reflect #1 — verified by inspection. * fix(desktop): stop hard-failing pack on non-git checkouts + fix ZIP-path autocrlf (supersedes #67643) (#67730) * fix(desktop): allow write-build-stamp from non-git checkouts Stop hard-failing npm pack when neither GITHUB_SHA nor git HEAD is available (ZIP installs / broken .git). Emit an explicit fallback stamp instead so local Windows desktop builds can finish (#50823). * fix(desktop): treat fallback stamps as unpinned; harden Windows install Keep all-zero fallback commits out of -Commit/--commit pins and fetch install.ps1 by branch instead. After bootstrap, pin the marker to the checkout HEAD so isBootstrapComplete accepts it. On Windows, force ZIP checkout, seed GITHUB_SHA (ASCII-only install.ps1), and avoid the pack stamp failure. * fix(install): pin core.autocrlf=false before ZIP-path checkout (#50823 review) The ZIP-fallback path added in #67643 runs `git checkout -f FETCH_HEAD` before core.autocrlf gets pinned (which only happened later, on the shared clone-path config). On Git for Windows -- where core.autocrlf defaults to true -- that renormalizes the repo's LF text files to CRLF in the working tree during checkout, leaving the freshly-created managed checkout dirty versus HEAD and aborting the next `hermes update`. That is the exact "dirty tree the user never touched" failure the surrounding code already guards against (install.ps1:1461-1469, 1750-1753). Move the `config core.autocrlf false` pin to run immediately after `git init`, before the fetch/checkout. The later idempotent pin on the shared clone path is retained so git-clone installs are unaffected. Addresses teknium1's review on #67643 and supersedes it, preserving the original author's two commits. Co-authored-by: HexLab98 <8422520+HexLab98@users.noreply.github.com> * chore(contributors): map austinpickett commit email for attribution The check-attribution CI gate flagged austinpickett@users.noreply.github.com as an unmapped commit-author email (introduced by the autocrlf fix commit on this PR). Add the per-email mapping file as the gate instructs (the legacy AUTHOR_MAP in scripts/release.py is frozen). --------- Co-authored-by: HexLab98 <liruixinch@outlook.com> Co-authored-by: austinpickett <austinpickett@users.noreply.github.com> Co-authored-by: HexLab98 <8422520+HexLab98@users.noreply.github.com> * fix(desktop): preserve new-chat selector choices (#67729) Salvaged and rebased from #66354 by @UnathiCodex onto current main. Fixes a fresh-chat race in Hermes Desktop where a model, reasoning-effort, or Fast selection made before the first Send could be replaced by an in-flight profile refresh, or read only after the profile handshake yielded. Send is now the linearization point: the visible selector state is snapshotted before awaiting profile readiness, and intent-generation guards make older config/model responses stand down after a picker/toggle action. Adds the contract-v4 session-create wire contract for explicit Fast=false. Conflict resolution vs the original branch (use-model-controls.ts / .test.tsx): combined main's catalog-aware keepManualPick() sticky-pick logic with the PR's profileRefreshEpoch + composerSelectionGeneration staleness guards so both a removed-from-catalog reseed and the in-flight-picker race are handled. Verified on current main: apps/desktop tsc --noEmit clean; 80 affected UI/store tests pass (use-model-controls, use-hermes-config, use-session-actions, model-edit-submenu, model-presets, updates). Co-authored-by: UnathiCodex <theunathi@gmail.com> * feat(x_search): default model grok-4.20-reasoning -> grok-4.5 (#67719) grok-4.5 is xAI's newest release (their versioning is non-monotonic: 4.5 > 4.20) and is the model xAI's own docs use for the server-side x_search tool. Users who explicitly pinned x_search.model keep their choice; everyone else picks up the new default via the config deep-merge — no _config_version bump needed. - tools/x_search_tool.py: DEFAULT_X_SEARCH_MODEL - hermes_cli/config.py: DEFAULT_CONFIG x_search.model + comment - agent/reasoning_timeouts.py: 300s stale-timeout floor entry for grok-4.5 (grok-4.20-reasoning entry kept for pinned users) - docs: x-search.md en + zh-Hans (config sample + troubleshooting) - tests: default-model assertion + timeout-floor positive case * fix(docs): fix broken image and video in TUI docs (#43501) * fix(docs): fix video tag self-closing in tui.md * fix(docs): fix image and video paths, fix self-closing video tag * fix: speed up CLI /model picker by skipping non-current custom provider probing (#65652) * fix: speed up CLI /model picker by skipping non-current custom provider probing The CLI /model picker calls build_models_payload() with default probe_custom_providers=True, which live-fetches /v1/models from every saved custom endpoint on every open. The GUI/desktop picker already passes probe_custom_providers=False for snappiness. Match the GUI behavior: skip probing non-current custom providers, but still probe the current one so its model list stays accurate. Users can force a full re-fetch with /model --refresh. Fixes #65650 Related: #63583 * fix(cli): forward force_refresh to model picker probe flags When /model --refresh is used, the CLI model picker must probe all custom providers to refresh their model lists — not skip them. Normal bare /model still skips non-current probes for speed. Mirrors the existing desktop/TUI behavior. Add regression test for both normal and refresh flag forwarding. Fixes #65650 * fix: auto-save discovered models to config for discover-once caching After a successful /v1/models probe, persist the discovered model list back to config.yaml under the matching custom_providers entry. This makes discover_models: false meaningful out of the box — users get a populated cache after the first probe instead of a stale 1-model list. - Add _save_discovered_models_to_config() helper - Call after successful fetch_api_models in section 4 probe path - Skip config write when model list hasn't changed - Idempotent — no-op on empty api_url or model_ids Tests: 4 new tests covering auto-save, empty-probe skip, unchanged skip, and no-op-on-empty-args. All 4 pass. Refs: #65652, #65650 --------- Co-authored-by: ajzrva-sys <302567740+ajzrva-sys@users.noreply.github.com> * fix(tui): recognize standard DSR cursor position reports (supersedes #48762) (#67731) * fix(tui): recognize standard DSR cursor position reports in input parser The CURSOR_POSITION_RE regex only matched DECXCPR reports (CSI ? row;col R) but not standard DSR reports (CSI row;col R without the ? marker). Terminals that respond to CSI ? 6 n with the plain DSR form had their cursor position reports fall through to parseKeypress, where they were inserted as literal text — garbling the composer input with escape sequences like ESC[22;1R. Fix: make the regex match both forms. For the standard form (no ?), only treat it as a cursor position report when row > 1, since modified F3 keys (Shift+F3 = CSI 1;2 R, etc.) always use row 1 and are genuinely ambiguous with row-1 cursor reports. * fix(tui): reject invalid row-zero DSR cursor position reports Follow-up to the standard-DSR recognition fix. The row guard rejected only row === 1, which let CSI 0;col R (row 0, no ? marker) through and misclassified it as a cursorPosition report. Terminal coordinates are 1-indexed, so row 0 is an invalid DSR report and must remain unclassified. Change the guard to row <= 1 to match the stated 'row > 1' semantics, and add a boundary test asserting CSI 0;col R is not emitted as a response. Supersedes #48762; incorporates review feedback from that PR. --------- Co-authored-by: Alex Yates <43525405+yatesjalex@users.noreply.github.com> * fmt(js): `npm run fix` on merge (#67749) Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * feat(desktop): custom endpoint settings (supersedes #42745) (#67759) * feat(desktop): add custom endpoint settings (supersedes #42745) Salvages PR #42745 (elashera:custom-endpoints-desktop), which could no longer merge cleanly against main. Re-integrated the work onto current main and reconciled the conflicts: - Settings nav: wired the new 'Custom Endpoints' provider sub-view into main's data-driven navGroups/OverlayNav layout (PR predated that refactor) and added it to PROVIDER_VIEWS. - providers-settings: kept BOTH main's LocalEndpointRow affordance and the PR's fuller CRUD panel; unified ProvidersSettingsProps to carry onClose + onConfigSaved + onMainModelChanged. - web_server: kept main's _normalize_main_model_assignment + api_key propagation AND the PR's provider base_url lookup in _apply_model_assignment_sync. - model_switch: dropped the PR's bare direct-custom-config picker block; main already implements it (source='model-config', with live model discovery). Updated the salvaged test to assert main's behavior. - Merged additive import/type blocks in hermes.ts and types/hermes.ts. Backend endpoints, i18n labels (en/ja/zh/zh-hant), and the custom-endpoints-settings.tsx panel carried over. 28 custom-endpoint tests pass. Co-authored-by: elashera <emilio.jesus.lasheras.romero@nttdata.com> * chore(contributors): map elashera's commit email Salvage of #42745 (superseded by #67759) preserves @elashera's authorship, whose corporate commit email had no contributor mapping. Adds contributors/emails/ mapping so check-attribution passes. Verified: GitHub user 'elashera' id=135239963 matches their own noreply commit email (135239963+elashera@users.noreply.github.com). --------- Co-authored-by: elashera <emilio.jesus.lasheras.romero@nttdata.com> * fmt(js): `npm run fix` on merge (#67771) Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * refactor(desktop): DRY the computed-dedup into stableArray + freeze One shared `stableArray(prev, next)` helper replaces the duplicated element-equal/keep-prev logic in both stores, and freezes the shared ref so a future in-place mutation fails loud instead of silently corrupting the cache. Computed return type is now `readonly string[]` (it always was, immutably). * perf(agent): drop per-call base64 re-serialization from request-size estimate Every API iteration computed `total_chars = sum(len(str(msg)) ...)`, which str()-serializes the ENTIRE history — including base64 images and large tool results — just to take its length, then called estimate_request_tokens_rough, which walked the messages a SECOND time (it re-runs estimate_messages_tokens_rough internally, already computed one line above). Now derive both from one image-stripped message estimate: approx_tokens = estimate_messages_tokens_rough(api_messages) # once request_pressure_tokens = approx_tokens + tools_tokens # == old value total_chars = approx_tokens * 4 # log/metric only request_pressure_tokens is byte-identical to the old estimate_request_tokens_rough(api_messages, tools=agent.tools or None) (no system_prompt arg → messages + tools). total_chars only feeds a verbose log and the pre-api-request hook's request_char_count, so a rough proxy is fine and it no longer balloons on image turns. On the TTFT critical path for every call. tests/agent/test_model_metadata.py + test_compressor_image_tokens.py green. * style(agent): tighten request-estimate comment * fmt(js): `npm run fix` on merge (#67793) Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * perf(desktop): virtualize the review-pane diff (no more full-Shiki freeze) Selecting a large changed file in the review pane froze it: FileDiffPanel with no fullText + no showLineNumbers rendered SyntaxDiff over EVERY line — a full Shiki highlight + thousands of mounted DOM nodes — because windowing was tied to showLineNumbers/fullText and the review call had neither. Decouple windowing from the gutter: - `windowed = showLineNumbers || virtualized`; windowed paths always render the fixed-row chunked body (TokenizedDiffBody chunked / PreviewDiffRows), never SyntaxDiff, so only visible rows mount. - New `virtualized` prop → windowed scroller WITHOUT the line-number gutter. - Review passes `virtualized` + the preview's fill className. Preview (showLineNumbers + fullText) and tool-card (compact) render byte-for-byte as before — the gutter body just reads the same chunked window it already used, and the no-fullText+highlight case (previously SyntaxDiff) now windows too. tsc + eslint clean. Visual paths preserved by construction; needs an in-app eyeball on a large review diff. * refactor(desktop): merge the two windowed diff returns into one * perf(desktop): stop the file tree going sticky during agent edit bursts revalidateTree runs on every $workspaceChangeTick (mutating-tool completion, coalesced ~500ms). Two costs per tick, gone: 1. clearProjectDirCache() wiped the gitroot + gitignore caches. But listings are read fresh every time (readProjectDir never caches them), so the wipe bought nothing except forcing a full re-read of every ancestor .gitignore — each a full readdir — for every loaded dir, every tick. Dropped; a .gitignore edit is still picked up on the next full refresh (cwd/connection change / manual). 2. reconcile awaited each child dir serially, crawling a wide/deep tree one dir at a time. Now Promise.all over siblings (order preserved), recursing per loaded subfolder. use-project-tree.test.ts + right-sidebar/index.test.tsx green (15). tsc + eslint clean. * style(desktop): tighten revalidateTree comments * perf(desktop): targeted file-tree revalidation instead of whole-tree rescan 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. * perf(desktop): rAF-coalesce pane + console sash resizes 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. * refactor(desktop): extract shared rafCoalesce helper for sash drags * perf(desktop): stop eagerly JSON.stringify-ing every tool's args + result 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). * perf(desktop): stop tool rows re-rendering on session/cwd change + memo leaves 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). * test(desktop): widen Testing Library async deadline to de-flake UI panels (#67849) 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. * perf(desktop): idle-mount boot-hidden panes off the cold-start critical path (#67857) * 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 * fix(desktop): scope multi-pane model UI and stabilize tile chrome (#67855) * 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. * fix(desktop): retry OAuth cookie read on cold-start jar race (#67769) 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(gateway): don't spend a redelivery attempt when the platform is down 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. * fix(config): whitelist Hermes-owned roots doctor falsely flagged 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. * test(config): cover doctor allowlist for Hermes-written root keys 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. * fix(config): widen doctor allowlist to all gateway-bridged top-level keys 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. * fix(compression): stop the progress floor from splitting a tool group _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 land…
/resume is a conversation boundary, but unlike /new it did not clear the chat-keyed _session_model_overrides / _pending_model_notes. A /model switch made in the previous session under the same chat session_key leaked into the resumed conversation, running it on the wrong model. Clear both maps for the session_key after the switch (mirroring /new), scoped to that key so other chats' overrides are untouched. The cached-agent eviction this leak also implied already landed via NousResearch#6672. Closes NousResearch#10702.
…sResearch#64934) (NousResearch#67401) * fix(gateway): serialize concurrent turns per resolved session_id with a turn lease Closes the serialization half of NousResearch#64934. The busy guards are keyed by routing key, but the durable transcript is owned by session_id — and switch_session() makes the key→id mapping many-to-one (/resume from a second chat/topic, CLI-continuity rebinding, async-delegation pinning, topic-binding tip-walks). Two routing keys mapped to one session_id ran concurrent turns on two different agent objects, invisible to every per-key guard: flushes persisted in completion order, the identity-marker dedup swallowed rows, and the second turn ran on a stale history base — leaving a permanent user;user alternation wedge. The fix: an asyncio lease keyed by RESOLVED session_id (gateway/turn_lease.py), acquired in _handle_message_with_agent after session resolution is final (post switch_session/tip-walk), immediately before the transcript load, and released in _handle_message's finally on every exit path. Tokens are granted per (routing key, run generation) so a stale unwind can never release a newer turn's lease (NousResearch#28686 ownership lesson). Same-key messages never reach the acquisition point mid-turn (both routing-key guards hold them), so the lock is uncontended outside the alias-key route — where the second turn now waits for the first turn's flush and logs one WARNING naming the session and both routing keys (pairs with the NousResearch#67371 tripwire). Fail-open: a stuck holder degrades to today's unserialized behavior with a loud ERROR after agent.gateway_timeout — never a wedged session; a degraded token holds nothing and can't steal the lease. Registry is size-capped and never evicts a live lease. Persist-disabled review forks never dispatch through _handle_message, so they cannot contend. Known limits (tracked on NousResearch#64934): CLI-continuity cross-process pairs need a DB-level lease; mid-turn compression rotation leaves a small alias window for a follow-up at the binding-sync sites. Validation: 8 behavior tests (alias-key wait + flush order, no cross-session contention, generation-scoped idempotent release, timeout fail-open without lease theft, bounded registry, bare-runner-safe release wiring) + E2E against a real SessionStore reproducing the issue's switch_session alias route — strict alternation and arrival order preserved. * refactor(gateway): conversation-scope funnel + mid-turn lease rebind Completes the NousResearch#64934 system beyond the point fix. Two structural changes, both eliminating whole bug classes rather than instances: 1. _clear_conversation_scope — THE single conversation-boundary funnel. /new, /resume, auto-reset, expiry finalization, and the compression-exhausted reset each carried a hand-copied pop-list of the per-session dicts, and the lists drifted every time a new dict was added (NousResearch#48031, NousResearch#58403, NousResearch#10702, NousResearch#35809 were all 'boundary X forgot dict Y' bugs). All five sites now make one funnel call driven by the _CONVERSATION_SCOPED_STATE registry; adding a new conversation-scoped dict means adding one name to the registry, and every boundary picks it up automatically. Scope rules documented at the registry: turn-scoped state, the monotonic generation counter, and the agent cache are deliberately excluded (different lifecycles). 2. SessionTurnLeaseRegistry.rebind — the held turn lease now FOLLOWS mid-turn compression rotation. Both rotation sites (session-hygiene pre-compression, agent-result session_id swap) alias the same _SessionLease object under the new id, so an alias routing key resolving the fresh child (topic tip-walk) still serializes against the in-flight turn. Closes the rotation-alias window flagged as a known limit on NousResearch#64934. Ownership-checked like release; when the target id already has a live lease the rebind fails open with a loud WARNING (never a mid-turn deadlock). Tests: 3 new rebind behavior tests + 5 funnel behavior tests (including a real-setter drift guard); the two AST change-detector pins in test_10710/test_48031 were re-pointed at the funnel and the NousResearch#58403 pin converted to a behavioral test. E2E: rotation-alias scenario against a real SessionStore + SessionDB — turn B on the fresh child waits behind the rotated holder, sees its rows, alternation intact.
Replace the numbered text reply for /sessions on Telegram with an inline-keyboard picker. Each session is a button; tapping it resumes that session directly. The currently-active session is filtered out so the list only shows resumable targets. Pickers paginate when there are more than 8 sessions (Prev / N/M / Next), and a Cancel button dismisses the picker. Buttons render the title on its own line and the first-message preview on a second line (truncated to 40 chars, 64-char Telegram cap). Built on the same picker state pattern as the existing model picker / choice picker / approval picker — but with the security-aware fixes that the blocker sweep on NousResearch#43695 and NousResearch#49038 flagged: 1. IDOR guard at the runner boundary. The picker's on_session_selected callback re-runs _resume_target_allowed with the captured SessionSource before delegating to the shared _resume_session_by_id helper. A co-member in a shared group cannot tap a button to bind to another user's persisted session — same gate the text /resume <id> path uses. The adapter also re-checks via _is_callback_user_authorized for a cheap fail-closed layer. 2. Collision-safe state key. State is keyed by (chat_id, msg_id, thread_id) instead of chat_id alone. A second /sessions opened in the same chat (forum threads, /sessions called twice in a row) cannot overwrite the first picker's state, and a stale click on the old keyboard after a new /sessions has replaced it is rejected at the adapter before the runner is invoked. 3. Session-switch via the funnel. _resume_session_by_id uses async_session_store.switch_session and calls _release_running_agent_state + _clear_conversation_scope + _evict_cached_agent — the same funnel that fixed the bug-class regressions NousResearch#10702, NousResearch#58403, NousResearch#6672. The text /resume path still uses the inline switch logic (kept distinct to preserve the Matrix --cross-room branch which needs source-object-aware title substitution). 4. Origin-scoped listing. The picker receives only the rows the runner already filtered through _resume_row_visible + _resume_target_allowed — same scope the text list uses, so the picker cannot bypass the IDOR guard that the listing already enforces. The picker branch lifts the legacy 10-cap (text fallback still caps at 10) so the picker can paginate through the full origin-scoped list (up to 50). 5. Authorization gate at the adapter. Mirrors the approval / choice picker pattern: a co-member tap is rejected at the Telegram adapter before the runner callback runs. Tests: tests/gateway/test_telegram_sessions_picker.py — 16 tests tests/gateway/test_sessions_command_picker_integration.py — 6 tests Regression-safe: existing 27 tests in test_resume_command.py all still pass. 49 passed. Why not just merge NousResearch#43695 / NousResearch#49038: both were kept open by the hermes-sweeper with the same four blockers. Salvage credit: the pagination shape, cancel button, and adapter scaffold follow the pattern eltecnicowd opened in NousResearch#49038. Rebased on top of the current plugins/platforms/telegram/adapter.py (the path NousResearch#43695 conflicted on).
Summary
Fixes a gateway session-boundary bug where
/resumecould carry over runtime state from the previously active session in the same chat.Before this change,
/resumeupdated the session pointer inSessionStore, but it did not clear other state keyed by the stable chatsession_key. That meant a resumed session could inherit:AIAgentinstance still bound to the oldsession_id/modeloverride from the previous sessionThis is a cross-session contamination issue and breaks the expected semantics of
/resume.What changed
In the gateway
/resumeflow, we now treat the session switch as a real session boundary and clear chat-keyed runtime state before switching:session_keyThe actual session switch behavior is otherwise unchanged.
Why this matters
session_keyis chat-scoped, butsession_idis conversation-scoped./resumeswitches conversations, so leaving cached agent/runtime state attached to the samesession_keycan cause the next turn to run with stale state from the wrong session. This patch makes/resumeconsistent with/new/ session reset behavior and closes that leak.Tests
Added regression coverage for:
/resume/resumeValidated with:
python -m pytest tests/gateway/test_resume_command.py -qpython -m pytest tests/gateway/test_agent_cache.py -qpython -m pytest tests/gateway/test_session_model_reset.py -qAll passed.