feat(model-picker): group multi-endpoint providers under one row#35227
Merged
Conversation
…ze up to here' Adds a user-chosen compression boundary to the existing /compress command. /compress here [N] summarizes everything except the most recent N exchanges (default 2), which are preserved verbatim — letting the user pick the compression boundary instead of relying on the automatic token-budget heuristic. Inspired by Claude Code's Rewind 'Summarize up to here' action (v2.1.139, Week 20, May 2026): https://code.claude.com/docs/en/whats-new/2026-w20 - hermes_cli/partial_compress.py: pure split/parse helpers + seam-alternation guard (shared by CLI and gateway). - cli.py / gateway/run.py: route 'here [N]' / '--keep N' to partial compression; compress only the head, re-append the verbatim tail through the seam guard. - Preserves message-flow role alternation (seam guard merges any illegal user->user / assistant->assistant adjacency). - Reuses the existing _compress_context session-rotation/lock machinery — no changes to the compression core. - Bare /compress (full) and /compress <focus> behavior unchanged. Tests: 12 helper unit tests + 5 CLI integration tests + E2E (interleaved tool-call transcript, degenerate/multimodal seams, real handler path).
The interactive provider pickers (hermes model, setup wizard, Telegram /model) listed every provider slug flat, so vendors with several endpoints (Kimi/Moonshot, MiniMax, xAI Grok, Google Gemini, OpenAI, OpenCode, GitHub Copilot) each occupied multiple top-level rows. Now related slugs fold into one top-level row that drills down to the specific endpoint. - models.py: add PROVIDER_GROUPS table + group_providers() fold (display only — CANONICAL_PROVIDERS, slugs, --provider, /model <provider:model> all unchanged and individually addressable). - hermes model (main.py): group rows drill into a member sub-picker, then dispatch to the existing _model_flow_* unchanged. setup wizard inherits it. - Telegram /model: new mpg:<group> callback expands to member mp:<slug> buttons; single authenticated member degrades to a direct button. - Grouping is the single shared fold across all three surfaces. Validation: 163 targeted tests pass; E2E confirms group->member->model resolves to the correct concrete slug for all families.
KKT-OPT
pushed a commit
to KKT-OPT/hermes-agent
that referenced
this pull request
May 31, 2026
…sResearch#35227) * Inspired by Claude Code: /compress here [N] — boundary-aware 'summarize up to here' Adds a user-chosen compression boundary to the existing /compress command. /compress here [N] summarizes everything except the most recent N exchanges (default 2), which are preserved verbatim — letting the user pick the compression boundary instead of relying on the automatic token-budget heuristic. Inspired by Claude Code's Rewind 'Summarize up to here' action (v2.1.139, Week 20, May 2026): https://code.claude.com/docs/en/whats-new/2026-w20 - hermes_cli/partial_compress.py: pure split/parse helpers + seam-alternation guard (shared by CLI and gateway). - cli.py / gateway/run.py: route 'here [N]' / '--keep N' to partial compression; compress only the head, re-append the verbatim tail through the seam guard. - Preserves message-flow role alternation (seam guard merges any illegal user->user / assistant->assistant adjacency). - Reuses the existing _compress_context session-rotation/lock machinery — no changes to the compression core. - Bare /compress (full) and /compress <focus> behavior unchanged. Tests: 12 helper unit tests + 5 CLI integration tests + E2E (interleaved tool-call transcript, degenerate/multimodal seams, real handler path). * feat(model-picker): group multi-endpoint providers under one row The interactive provider pickers (hermes model, setup wizard, Telegram /model) listed every provider slug flat, so vendors with several endpoints (Kimi/Moonshot, MiniMax, xAI Grok, Google Gemini, OpenAI, OpenCode, GitHub Copilot) each occupied multiple top-level rows. Now related slugs fold into one top-level row that drills down to the specific endpoint. - models.py: add PROVIDER_GROUPS table + group_providers() fold (display only — CANONICAL_PROVIDERS, slugs, --provider, /model <provider:model> all unchanged and individually addressable). - hermes model (main.py): group rows drill into a member sub-picker, then dispatch to the existing _model_flow_* unchanged. setup wizard inherits it. - Telegram /model: new mpg:<group> callback expands to member mp:<slug> buttons; single authenticated member degrades to a direct button. - Grouping is the single shared fold across all three surfaces. Validation: 163 targeted tests pass; E2E confirms group->member->model resolves to the correct concrete slug for all families.
1 task
nextsaver-source
added a commit
to nextsaver-source/hermes-agent
that referenced
this pull request
Jun 8, 2026
) * fix: detect pyproject.toml / __init__.py version drift in hermes doctor (#35142) A git conflict resolution (reset --hard or merge) can revert hermes_cli/__init__.py to a stale __version__ while pyproject.toml stays current, so 'hermes --version' silently reports the wrong version. Nothing cross-checked the two files. Add a version-consistency check to the doctor 'Python Environment' section: reads the [project] version from pyproject.toml and compares it to hermes_cli.__version__. Reports OK when they match, fails with a re-sync hint when they drift, and is a silent no-op for installed wheels where pyproject.toml isn't present. Closes #35070 * fix: surface /agents nudge while delegate_task is in-flight (TUI + CLI) The subagent spawn-observability overlay added a `(/agents)` hint, but only on the standalone "Spawn tree" panel, gated behind `!inlineDelegateKey` — it never showed for a single delegate_task call, and only appeared once subagents had already registered. A nudge that arrives at the end (or only after spawn) is useless for the actual goal: letting users open the live monitor *while* delegation is running. Surface it the moment delegation starts, on both surfaces: TUI (ui-tui/src/components/thinking.tsx) - Show `(/agents)` on any "Delegate Task" tool group as soon as it appears (in-flight, before any subagent registers), not gated on subagents already existing. Same `startsWith('Delegate Task')` predicate already used for delegateGroups. CLI (agent/tool_executor.py) - Append `· /agents to monitor` to the delegate spinner label, which is displayed for the full duration of the delegate_task call. The previous attempt put the hint on the completion line (get_cute_tool_message), which only renders after the call finishes — reverted. TUI tsc clean (pre-existing execFileNoThrow type errors unrelated); subagentTree 35/35; display.py reverted to upstream. * fix(vision): fail fast on non-retryable image download errors (#35221) _download_image() wrapped every download attempt in a blanket `except Exception` and retried 3x with 2s/4s/8s backoff regardless of cause. A 404/403 image URL would never resolve on retry, so it just burned up to 6s of wall-clock + extra GETs before failing — inflating latency for a deterministic failure (issue #32296, umbrella #35114). Add _is_retryable_download_error(): 4xx client errors (except 429), website-policy PermissionError, and too-large/SSRF ValueError now raise on the first attempt. 429, 5xx, and unclassified network errors stay retryable. Removed the now-unreachable fall-through branch since the loop always returns on success or re-raises on the final/terminal attempt. * fix(tui): use base64 encoding for PowerShell clipboard writes to preserve UTF-8 When writing text to the clipboard via PowerShell (WSL2 and native Windows), the previous implementation piped text through stdin using `Set-Clipboard -Value $input`. PowerShell reads stdin using the Windows system's default ANSI code page (e.g. CP936 for Chinese Windows), causing all non-ASCII characters (CJK, emoji, accented) to become garbled. Fix: encode the text as base64 in Node.js and pass it as a command argument. PowerShell decodes it from base64 using explicit UTF-8, bypassing the code page issue entirely. Fixes #35107 * refactor(tui): simplify base64 clipboard write to a stdin flag The per-entry psScript callback was identical for every PowerShell entry, so the function-valued union member added structure without behavior. Collapse WriteCmd to a plain stdin boolean and apply the one shared base64 script in the write loop. Document the CP936 root cause inline. Co-authored-by: BROCCOLO1D <279959838+BROCCOLO1D@users.noreply.github.com> * fix(kanban): rebuild legacy TEXT-PK tables to INTEGER AUTOINCREMENT on open Legacy kanban boards (pre-AUTOINCREMENT schema) crashed the gateway notifier on every tick — int(None) on a NULL id in unseen_events_for_sub — silently losing all kanban notifications. CREATE TABLE IF NOT EXISTS skips existing tables regardless of schema and _add_column_if_missing only adds columns, so neither could fix a drifted primary-key type. _rebuild_drifted_tables() detects the legacy shape via PRAGMA table_info and rebuilds task_events/task_comments/task_runs (TEXT PK -> INTEGER AUTOINCREMENT) and kanban_notify_subs.last_event_id (TEXT/NULL -> INTEGER NOT NULL DEFAULT 0), preserving data. The whole pass is one transaction so an interruption can't leave a table half-renamed, and recreates every index DROP TABLE would otherwise take down (including idx_events_run). Co-authored-by: liuhao1024 <liuhao1024@users.noreply.github.com> * fix(kanban): prevent infinite retry loop when worker exhausts iteration budget recompute_ready() previously reset consecutive_failures to 0 when auto-recovering a blocked task. This defeated the circuit-breaker: a task that repeatedly exhausted its iteration budget would cycle forever (block → auto-recover with counter=0 → respawn → budget exhausted → block → …) with no signal to the operator. Fix: don't auto-recover tasks whose consecutive_failures has reached the effective failure limit (per-task max_retries or DEFAULT_FAILURE_LIMIT). The counter is also preserved across recovery so the breaker can accumulate across cycles. Fixes #35072 * fix(kanban): align recompute_ready guard with breaker's configured failure_limit Follow-up to the budget-exhaustion recovery fix. recompute_ready's new circuit-breaker guard resolved its effective limit from per-task max_retries -> DEFAULT_FAILURE_LIMIT, skipping the dispatcher's configured kanban.failure_limit. _record_task_failure resolves max_retries -> failure_limit(config) -> DEFAULT, so the two disagreed whenever an operator set kanban.failure_limit != 2: - config > 2: a task could get stuck at DEFAULT(2) before reaching its allowed retry count. - config < 2: a task the breaker already blocked could be auto-recovered back to ready, defeating the stricter limit. Thread the dispatcher's failure_limit through dispatch_once into recompute_ready so the guard and the breaker share one resolution order. Updated test_circuit_breaker_block_still_auto_promotes (it asserted a failures=5 block auto-recovers and resets the counter — that's the pre-#35072 behavior the loop fix removes); it now exercises a below-limit transient block, with the at-limit case covered in test_kanban_db.py. Added two tests for the config-tier and per-task override resolution. * fix(update): export launcher virtualenv to uv * feat(model-picker): group multi-endpoint providers under one row (#35227) * Inspired by Claude Code: /compress here [N] — boundary-aware 'summarize up to here' Adds a user-chosen compression boundary to the existing /compress command. /compress here [N] summarizes everything except the most recent N exchanges (default 2), which are preserved verbatim — letting the user pick the compression boundary instead of relying on the automatic token-budget heuristic. Inspired by Claude Code's Rewind 'Summarize up to here' action (v2.1.139, Week 20, May 2026): https://code.claude.com/docs/en/whats-new/2026-w20 - hermes_cli/partial_compress.py: pure split/parse helpers + seam-alternation guard (shared by CLI and gateway). - cli.py / gateway/run.py: route 'here [N]' / '--keep N' to partial compression; compress only the head, re-append the verbatim tail through the seam guard. - Preserves message-flow role alternation (seam guard merges any illegal user->user / assistant->assistant adjacency). - Reuses the existing _compress_context session-rotation/lock machinery — no changes to the compression core. - Bare /compress (full) and /compress <focus> behavior unchanged. Tests: 12 helper unit tests + 5 CLI integration tests + E2E (interleaved tool-call transcript, degenerate/multimodal seams, real handler path). * feat(model-picker): group multi-endpoint providers under one row The interactive provider pickers (hermes model, setup wizard, Telegram /model) listed every provider slug flat, so vendors with several endpoints (Kimi/Moonshot, MiniMax, xAI Grok, Google Gemini, OpenAI, OpenCode, GitHub Copilot) each occupied multiple top-level rows. Now related slugs fold into one top-level row that drills down to the specific endpoint. - models.py: add PROVIDER_GROUPS table + group_providers() fold (display only — CANONICAL_PROVIDERS, slugs, --provider, /model <provider:model> all unchanged and individually addressable). - hermes model (main.py): group rows drill into a member sub-picker, then dispatch to the existing _model_flow_* unchanged. setup wizard inherits it. - Telegram /model: new mpg:<group> callback expands to member mp:<slug> buttons; single authenticated member degrades to a direct button. - Grouping is the single shared fold across all three surfaces. Validation: 163 targeted tests pass; E2E confirms group->member->model resolves to the correct concrete slug for all families. * fix(packaging): include mcp_serve in py-modules so hermes mcp serve works on pip installs mcp_serve.py was missing from the setuptools py-modules list, causing hermes mcp serve to crash with ModuleNotFoundError on standard pip installs. Fixes #34871 * fix(skills): fix transaction ordering in reset_bundled_skill and handle read-only files in rmtree Two related bugs in tools/skills_sync.py affecting Nix-store and immutable-package installs: **#34972 — reset_bundled_skill corrupts manifest on rmtree failure:** The function deleted the manifest entry BEFORE attempting rmtree. If rmtree failed (read-only files from Nix store), the function returned early — leaving the skill in a manifest-less limbo state where future syncs silently skip it forever. Fix: reorder steps — attempt rmtree FIRST, only delete manifest entry after rmtree succeeds. If rmtree fails, nothing is changed. **#34860 — stale .bak directories after sync:** sync_skills() called shutil.rmtree(backup, ignore_errors=True) which silently failed on read-only files, leaving persistent .bak dirs. Fix: add _rmtree_writable() helper that makes files writable via an onerror callback before retrying removal. Used in both sync_skills() backup cleanup and reset_bundled_skill(). Fixes #34972 Fixes #34860 * fix(skills): make _rmtree_writable handle read-only directories, not just files The cherry-picked fix's onerror handler chmod'd only the failing path, but unlinking a child requires write permission on its PARENT directory. On a true Nix-store copy (r-xr-xr-x dirs + files) rmtree still failed. Now chmod the parent dir as well before retrying. Also rewrites the regression test: the original asserted the helper FAILS on a read-only dir (documenting the limitation), which is the wrong success criterion. Split into two tests — restore succeeds on a full read-only tree (real Nix case), and manifest is preserved when removal genuinely cannot proceed (monkeypatched). * test(skills): assert restore via synced[copied], not manifest re-read The hermetic CI env (slice 4/6) redirects HERMES_HOME, so a post-restore _read_manifest() can resolve to an empty/redirected manifest path and return {}. Assert on sync_skills's in-memory return value (synced["copied"]) instead, which is the resilient signal that the skill was re-copied and is no longer in limbo. * fix(file-tools): make write_file/patch atomic (temp-file + rename) (#35252) * Inspired by Claude Code: /compress here [N] — boundary-aware 'summarize up to here' Adds a user-chosen compression boundary to the existing /compress command. /compress here [N] summarizes everything except the most recent N exchanges (default 2), which are preserved verbatim — letting the user pick the compression boundary instead of relying on the automatic token-budget heuristic. Inspired by Claude Code's Rewind 'Summarize up to here' action (v2.1.139, Week 20, May 2026): https://code.claude.com/docs/en/whats-new/2026-w20 - hermes_cli/partial_compress.py: pure split/parse helpers + seam-alternation guard (shared by CLI and gateway). - cli.py / gateway/run.py: route 'here [N]' / '--keep N' to partial compression; compress only the head, re-append the verbatim tail through the seam guard. - Preserves message-flow role alternation (seam guard merges any illegal user->user / assistant->assistant adjacency). - Reuses the existing _compress_context session-rotation/lock machinery — no changes to the compression core. - Bare /compress (full) and /compress <focus> behavior unchanged. Tests: 12 helper unit tests + 5 CLI integration tests + E2E (interleaved tool-call transcript, degenerate/multimodal seams, real handler path). * fix(file-tools): make write_file/patch atomic (temp-file + rename) write_file streamed content straight into the target via `cat > path`, so a crash, SIGKILL, or truncated pipe mid-write left the file half-written and corrupt. patch_replace routes through write_file, so it shared the flaw. Now writes stream into a temp file in the SAME directory and `mv` it over the target — a real same-filesystem rename, which is atomic on POSIX and on every terminal backend (local/docker/ssh/modal). A failed write leaves the original byte-intact and leaks no temp file. The existing file's mode is preserved across the swap (stat + chmod, GNU/BSD), and content still rides stdin so there's no ARG_MAX limit. A trap cleans the temp on any error path. Tests: added TestAtomicWrite (real LocalEnvironment, no mocks) covering inode-change-on-overwrite, mode preservation, failed-write-leaves-original, no-temp-leak, special chars, and patch routing. Updated two mocks in test_file_operations.py that keyed on the literal `cat >` write command to key on the stdin_data behavioral signal instead. 200 file-tool tests green. * fix(cli): use `uv tool upgrade` when Hermes is a uv tool install (#29700) Hermes installed via `uv tool install hermes-agent` lives outside any venv. `_cmd_update_pip` previously ran `uv pip install --upgrade`, which errors with `No virtual environment found; run uv venv ...`. The user hits this on the very first `hermes update` after a standard non-`--system` install with `uv` on PATH. Add `is_uv_tool_install()` in `hermes_cli/config.py`: fast path inspects `sys.prefix` for the standard `uv/tools/hermes-agent/` layout, falls back to `uv tool list` for non-standard prefixes. Both the user-facing `recommended_update_command_for_method("pip")` string and the actual subprocess invocation in `_cmd_update_pip` now switch to `uv tool upgrade hermes-agent` when detected. Non-tool installs and the no-`uv` fallback keep their existing commands unchanged. * fix(cli): restrict uv-tool-install detection to running interpreter Copilot review on PR #29703 flagged two issues with the `uv tool list` fallback in `is_uv_tool_install`: 1. False positive: `uv tool list` returns the *machine*'s installed tools, not the active install. A regular pip/venv Hermes on a host that also has `uv tool install hermes-agent` available would be misclassified as a uv-tool install, and `hermes update` would upgrade the wrong copy. 2. Overhead: the subprocess call (up to a 15s timeout) was triggered even from `recommended_update_command_for_method`, which just computes a display string. Restrict detection to properties of the running interpreter (`sys.prefix` and `sys.executable` — both can carry the uv-tool layout marker depending on entry point). Drop the `uv tool list` fallback and the `uv_path` parameter entirely. `_cmd_update_pip` now also surfaces a clear hint when the runtime looks like a uv-tool install but `uv` is missing from PATH, instead of silently falling back to `python -m pip`. * fix(update): handle pipx installs + --system fallback in _cmd_update_pip Extends the uv-tool detection (briandevans, #29703) to cover the remaining no-venv install layouts that hit the same uv 'No virtual environment found' error: - pipx-managed installs (sys.prefix under .../pipx/...) -> 'pipx upgrade', matching scripts/auto-update.sh (pipx-detection idea from inchargeautomation-lab, #29852) - bare pip outside any venv -> 'uv pip install --system --upgrade' - venv (launcher shim) keeps the VIRTUAL_ENV overlay from #35224 and never gets --system, so the install always targets the venv, not system Python The four branches are mutually exclusive; VIRTUAL_ENV is exported only for the uv-pip-in-venv path (uv tool / pipx upgrade ignore it). Co-authored-by: Joshua Kimbrell <incharge.automation@gmail.com> * chore(release): map inchargeautomation-lab author email * fix(mcp): reap stdio MCP grandchildren via process-group signal The orphan reaper for stdio MCP subprocesses only tracked the direct child PID spawned by ``stdio_client`` (e.g. ``openclaw mcp serve``). When that wrapper itself spawned a helper (``claude mcp serve``) and then exited, the helper reparented to ``systemd --user`` and survived shutdown. The MCP SDK already spawns stdio children with ``start_new_session=True``, so the wrapper is its own pgroup leader and same-pgroup descendants are reachable via ``killpg``. Capture the pgid at spawn time and reap via ``killpg(pgid, sig)`` so reparented grandchildren are reaped alongside the direct child, even after the wrapper itself exits. Falls back to per-pid ``os.kill`` on Windows or when no pgid was recorded. Fixes part 2 (orphan ``claude mcp serve``) of #23799. Part 1 (per-invocation respawn) was confirmed by the reporter to be an environmental artifact, not a code bug. * test(mcp): import os and pytest in test_mcp_stability The salvaged grandchild-reaping tests reference os.getpgid/os.killpg and pytest.mark/skip/importorskip directly, but the file only imported asyncio, signal, and unittest.mock. Add the missing imports so collection succeeds on current main. * fix(lsp): detect Windows wrapper binaries in installer probes * fix(lsp): handle Windows .cmd shims in LSP process spawn asyncio.create_subprocess_exec cannot run .cmd/.bat files on Windows because CreateProcess expects a valid PE executable. npm-installed LSP servers (intelephense, typescript-language-server, etc.) ship as .cmd shims on Windows, causing WinError 193 on spawn. Detect .cmd/.bat extensions and wrap with cmd.exe /c before spawning. Gated behind sys.platform == 'win32' — no code path changes elsewhere. Fixes #34864 * chore(release): map tuancookiez-hub for #34865 salvage * fix(state): persist mid-session model switch to database When a user switches models mid-session via /model, the gateway updates the in-memory agent and session overrides, but the database was never updated. The COALESCE(model, ?) in update_token_counts() only fills NULL values, so the dashboard always showed the original model. Fix: Add SessionDB.update_session_model() that unconditionally sets the model column, and call it from both the interactive picker and direct /model command paths in the gateway. Fixes #34850 * test(state): cover update_session_model overwrite + getattr-guard text path Follow-up to LengR's #35181 salvage: - gateway text-path uses getattr(self, '_session_db', None) to match the picker callback path (defensive for object.__new__() gateway test pattern). - add SessionDB.update_session_model test asserting it overwrites the COALESCE-pinned model and survives subsequent token updates (#34850). * fix(run_agent): gate concurrent checkpoint preflight on block_result (fixes #34827) In the concurrent tool-execution path, checkpoint preflight (write_file, patch, destructive terminal) fired BEFORE plugin guardrail block_result was computed. A blocked write_file could still dirty checkpoint state (doc_modified_this_turn, _last_write_file_call_id, turn_counter). Move checkpoint preflight to AFTER block_result computation, gated on `if block_result is None:` — matching the invariant the sequential path already enforces. * fix(google-workspace): handle Gmail header casing case-insensitively Normalize Gmail API message header names to lowercase before lookup so gmail get/search/reply populate to/subject/from regardless of the casing the message was stored with. Emit conventional MIME header casing (To/Subject/Cc/From) on send and reply. Fixes #34806 Co-authored-by: Donovan Yohan <donovan-yohan@users.noreply.github.com> * fix(update/windows): robustly exclude launcher-shim ancestors from concurrent check (#35257) hermes update on Windows still aborted with 'Another hermes.exe is running', listing its own launcher shim(s) as concurrent instances (issues #29341, #34795). The distlib Scripts\hermes.exe launcher spawns python.exe and waits; detection runs in the python child, so the launcher shim shows up in process_iter. The prior fix walked the ancestor chain with per-hop current.parent() inside 'except: break' — the first psutil AccessDenied/NoSuchProcess (common on Windows across session/elevation boundaries) bailed the walk early, leaving the launcher in the candidate set and re-triggering the false positive. - Switch to proc.parents() (whole ancestor list in one call), evaluate each ancestor independently so one unreadable hop never strands the launcher. - Only exclude ancestors whose exe is itself a shim, so a genuine second hermes.exe under a non-Hermes parent (Desktop backend child) is still flagged. - Message now prints a copy-pasteable 'taskkill /PID … /F' for the exact stale PIDs so a user who already closed everything can self-remediate. Conservative shim-only ancestor approach credited to the parallel attempts in PRs #29358 (xxxigm) and #31808 (jquesnelle). * fix(auxiliary): pass base_url/api_key/api_mode through set_runtime_main for custom providers When a user configures a custom: provider (e.g. custom:openclaw-router), set_runtime_main() only stored provider and model in process-local globals. _resolve_auto() then had no base_url or api_key for the custom endpoint, causing Step 1 to fail and auxiliary tasks (approval, compression, title generation) to fall through to the aggregator chain and route to wrong providers. Fix: extend set_runtime_main() to accept base_url, api_key, and api_mode keyword arguments; store them in new globals alongside the existing provider and model; fall back to these globals in _resolve_auto() when the main_runtime dict is empty. The call site in conversation_loop.py now passes all five fields from the agent object. Fixes #34777 * test(auxiliary): e2e routing assertions for custom-provider aux resolution Adds two real-client tests on top of the salvaged #34783 fix: - config-less custom:<name> endpoint routes via the carried live base_url (guards the #34777 symptom directly, not just the wiring) - named custom:<name> WITH a config entry still resolves via the named-custom branch (regression guard against collapsing to bare custom) * fix(tools): recognize email addresses as explicit targets in send_message When using send_message with the email platform, valid email addresses like user@example.com were not recognized as explicit targets by _parse_target_ref(). This caused the function to return (None, None, False), forcing the system into channel-name resolution which has no way to resolve a raw email address, resulting in 'No home channel set for email' errors. Add _EMAIL_TARGET_RE pattern and email platform handler in _parse_target_ref() so email addresses are treated as explicit targets and routed directly without requiring a home target configuration. * fix(tools): point email home-channel error at EMAIL_HOME_ADDRESS The no-home-channel error for send_message derived the env var name generically as <PLATFORM>_HOME_CHANNEL, producing EMAIL_HOME_CHANNEL for the email platform. But gateway/config.py reads EMAIL_HOME_ADDRESS, so a user following the error's guidance would set a variable that is never consulted. Add a per-platform override map so the email hint names the variable actually read; all other platforms keep the generic hint. * perf(tui): stop slow/dead MCP servers from freezing TUI startup The 'summoning hermes…' phase blocked on gateway.ready, which ran MCP tool discovery inline. Any configured-but-unreachable MCP server burned its full connect-retry backoff (1+2+4s ≈ 7s) before the composer appeared — startup went from instant to ~7.5s of dead air for anyone with a down stdio/http server in mcp_servers. Move discovery into a background daemon thread so gateway.ready fires immediately; tools register into the shared registry as servers connect, and the agent isn't built until the first prompt. Measured spawn→ready: ~7500ms → ~115ms (dead twozero_td server in config). Also drop rich.console + prompt_toolkit off banner.py's import path (lazy-imported inside cprint/build_welcome_banner). tui_gateway.server imports banner only to reach the lightweight prefetch_update_check helper; the eager rich/pt imports added ~45ms before gateway.ready for no benefit. tui_gateway.server import: ~115ms → ~69ms. * feat(cli): add hermes prompt-size diagnostic (#35276) Adds a 'hermes prompt-size' command that reports the fixed prompt budget for a fresh session: system prompt total, skills index, memory, user profile, prompt tiers, and tool-schema JSON bytes. Runs offline (dummy credentials force the direct-construction path, no network call). Lets users see which block dominates their per-call payload — the skills index is often the largest single block when many skills are installed (issue #34667). Zero model-tool footprint: it's a top-level CLI subcommand, not an agent tool. --platform <name> simulates a channel's platform hint; --json emits a machine-readable breakdown. Closes #34667 * fix(gateway): merge nested gateway.platforms configuration block * fix(discord): bridge explicit allow_from configuration to env var mapping * fix(gateway): run adapter config hooks for nested-only platform blocks The plugin apply_yaml_config_fn dispatch loop only ran when a top-level platform block (e.g. `discord:`) existed. Configs that defined a platform only under `platforms.<name>` or `gateway.platforms.<name>` skipped the hook, so `platforms.discord.extra.allow_from` never reached DISCORD_ALLOWED_USERS. Fall back to those nested blocks when the top-level one is absent. Also map byquenox@gmail.com -> Que0x for the salvaged commits. * fix(nous_account): add threading lock to prevent TOCTOU race on cache Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(file-tools): handle UTF-8 BOM in read_file / write_file / patch (#35278) Some Windows editors prepend an invisible UTF-8 BOM (U+FEFF) to text files. We had no awareness of it, so: read_file surfaced a phantom U+FEFF as the first character; patch matches against the true first line could miss; and a write/patch round-trip silently stripped the marker, changing the file's byte signature. Now: - read_file / read_file_raw strip a single leading BOM so the model never sees it (only on the first chunk — the marker lives at byte 0). - patch_replace strips the BOM before fuzzy-matching (so an exact first-line match works) and its post-write verification compares BOM-stripped content. - write_file restores the BOM when the original file had one and the new content doesn't, mirroring the existing line-ending preservation (detect on disk via a cheap `head -c 3` probe or reuse pre_content, re-prepend across the edit). Guards against double-BOM. Mid-content U+FEFF is left alone (it's data there, not a file marker). Tests: TestBomHandling (real LocalEnvironment) — read-strips, raw-read strips, write preserves, no-BOM-when-original-had-none, no-double-BOM, patch round-trip preserves, patch matches first line through a BOM, plus helper unit tests. 208 file-tool tests green. * perf(read_file): compact line-number gutter — ~14% fewer tokens per read (#35368) read_file's gutter used a fixed-width zero/space-padded prefix (" 1|content"). The padding is pure token overhead: measured with cl100k on real Hermes source, the padded gutter costs ~48% more tokens than bare content and ~16% more than a compact "<n>|content" gutter, because the leading spaces tokenize into extra tokens on every line. Switched the default to the compact "<n>|content" form. An A/B (Sonnet 4.6 via OpenRouter, 2 passes, 4-task battery, every claim verified against ground truth) showed: - padded : 4/4 PASS both passes - compact : 4/4 PASS both passes ← keeps line-referencing + patch - none : 3/4 PASS both passes ← dropping numbers entirely made the model hand-count lines and answer off-by-one (33 vs 34) So we keep the line numbers (the model genuinely uses them to reference lines) but drop the wasteful padding — capturing ~14% of the read-token cost with zero measured accuracy change. Dropping numbers entirely (the larger 33% saving) is rejected: it regresses line-referencing. patch/fuzzy_match never consumed the gutter (they match old_string text and compute char offsets internally), so editing is unaffected. No downstream parser keys on the fixed-width columns. HERMES_READ_GUTTER= padded restores the legacy format for anyone relying on alignment. Tests: updated the 3 format assertions to the compact gutter; added an env-override test for the legacy padded format. 209 file-tool tests green. * fix(kanban): respect mobile safe areas in task detail drawer (#35378) * fix(file-tools): handle UTF-8 BOM in read_file / write_file / patch Some Windows editors prepend an invisible UTF-8 BOM (U+FEFF) to text files. We had no awareness of it, so: read_file surfaced a phantom U+FEFF as the first character; patch matches against the true first line could miss; and a write/patch round-trip silently stripped the marker, changing the file's byte signature. Now: - read_file / read_file_raw strip a single leading BOM so the model never sees it (only on the first chunk — the marker lives at byte 0). - patch_replace strips the BOM before fuzzy-matching (so an exact first-line match works) and its post-write verification compares BOM-stripped content. - write_file restores the BOM when the original file had one and the new content doesn't, mirroring the existing line-ending preservation (detect on disk via a cheap `head -c 3` probe or reuse pre_content, re-prepend across the edit). Guards against double-BOM. Mid-content U+FEFF is left alone (it's data there, not a file marker). Tests: TestBomHandling (real LocalEnvironment) — read-strips, raw-read strips, write preserves, no-BOM-when-original-had-none, no-double-BOM, patch round-trip preserves, patch matches first line through a BOM, plus helper unit tests. 208 file-tool tests green. * fix(kanban): respect mobile safe areas in task detail drawer The task detail drawer is a body-level z-60 fixed overlay using height:100vh starting at the viewport top. On mobile this puts the drawer header behind the dashboard's fixed top bar (min-h-14, z-40) and lets the bottom comment input sit under the browser's collapsing nav bar. - drawer: 100vh -> 100dvh (+ max-height:100dvh), 100vh kept as fallback - head: padding-top honors env(safe-area-inset-top); mobile (<1024px, matching the lg breakpoint where the fixed bar shows) clears the 3.5rem header - comment-row + body: bottom padding extended with env(safe-area-inset-bottom) so the bottom-most element clears the mobile browser chrome Mirrors the host shell idiom (100dvh + env(safe-area-inset-bottom) in web/), and web/index.html already sets viewport-fit=cover so the insets resolve. max()/calc() fallbacks leave desktop unchanged. Closes #35324 * fix(gateway): recover model on post-interrupt turn; gate fallback status (#35381) Empty model could reach the API on a recovery turn after stream_interrupt_abort, failing HTTP 400 "No models provided" with no recovery — the session went silent until the user manually re-sent (#35314). - gateway/run.py: cache last-successfully-resolved model per session (+ a process-wide slot); when a fresh config read returns an empty model on a recovery turn, reuse the last-known-good instead of building model="". - run_agent.py + agent/conversation_loop.py: only emit "trying fallback..." status when a fallback chain actually exists, so the UI stops announcing a fallback that will never run (also #17446). - tests: empty-model recovery + _has_pending_fallback gate. * fix(tools): wrap _run_tool cleanup in finally to prevent interrupt state leak When _invoke_tool raises a BaseException (CancelledError, KeyboardInterrupt), the cleanup code at the end of _run_tool was bypassed because it sat outside the except block (which only catches Exception). ThreadPoolExecutor recycles thread IDs, so the leaked tid in _interrupted_threads poisons the next tool scheduled on that thread — it instantly aborts with 'Interrupted'. Move the discard + _set_interrupt(False) into a finally block so cleanup runs regardless of how the worker exits. Fixes #35309 * test(interrupt): assert no leaked tid instead of no-op block Follow-up on the #35309 regression test: the trailing `with _lock: pass` asserted nothing. Replace it with a concrete assertion that _interrupted_threads is empty after the worker exits, directly verifying the leak the fix prevents. * fix(compression): drop conflicting 'resume Active Task' directive in summary prefix SUMMARY_PREFIX previously contained two contradictory directives: 1. "treat it as background reference, NOT as active instructions" "Do NOT answer questions or fulfill requests mentioned in this summary" "Respond ONLY to the latest user message that appears AFTER this summary" 2. "Your current task is identified in the '## Active Task' section of the summary — resume exactly from there." When the latest user message contradicted Active Task (e.g. 'stop the i18n refactor', 'never mind, look at grafana instead'), models tended to follow (2) anyway because 'resume exactly' is a strong, unambiguous directive — leading to repeated re-surfacing of already-cancelled work across turns, even after explicit 'stop'/'don't keep bringing that up' messages from the user. This change: - Removes the conflicting 'resume exactly from Active Task' clause. - Makes the precedence explicit: latest user message is the single source of truth; it WINS on conflict; cancelled Active Task / In Progress / Pending User Asks / Remaining Work must be discarded entirely (no 'wrap up the old task first'). - Names canonical reverse signals (stop, undo, roll back, never mind, just verify, topic change) so the model recognizes them as cancellation triggers, not background context. - Updates the summarizer template instruction so the LLM doesn't mechanically copy a cancelled task into Active Task on the next compaction (it's instructed to copy the reverse signal verbatim). - Preserves: REFERENCE ONLY framing, MEMORY.md/USER.md authority, and the 'don't repeat work already reflected in session state' clause. Adds tests/agent/test_summary_prefix_semantics.py to pin invariants so the conflict can't regress. Tested: - All compaction tests pass: tests/agent/test_context_compressor.py, tests/agent/test_context_compressor_summary_continuity.py, tests/run_agent/test_413_compression.py, tests/run_agent/test_compression_persistence.py, tests/run_agent/test_compression_boundary_hook.py, tests/cli/test_manual_compress.py — 117/117 passing. - Tested on macOS. * fix(compressor): treat unanswered user questions as Active Task, not 'None' The Active Task field in compression summaries is the single most important field for task continuity across context boundaries. The previous template described it narrowly as a 'task assignment' or 'request', which caused the summary LLM to write 'None' whenever the user's most recent input was a question, a decision request, or a discussion turn rather than an imperative command. The assistant on the other side of the compaction then treated the conversation as resolved and gave a generic recap instead of answering the still-open question. Expand the template guidance to cover: * explicit task assignments * questions awaiting an answer * decisions awaiting input (A vs B) * ongoing discussions where the assistant owes the next substantive reply Reserve 'None' for the rare case where the last exchange was fully resolved (e.g. user said 'thanks, that's all'). Also tighten the trailing CRITICAL instruction in the summary prompt so the LLM cannot fall back to the old 'no imperative command → None' heuristic. No behavioural code changes — template strings only. All 83 existing compressor tests pass. * fix(compressor): strip stale handoff prefix on resume; reconcile #26290+#32787 (#35344) A handoff persisted under an older SUMMARY_PREFIX can be inherited into a resumed lineage. _strip_summary_prefix only matched the current/legacy literal, so on re-compaction the old 'resume exactly from Active Task' directive stayed embedded in the body and kept hijacking replies to new, unrelated user messages. - Add _HISTORICAL_SUMMARY_PREFIXES (pre-#35344 prefix) and strip/recognize them in _strip_summary_prefix + _is_context_summary_content so resumed stale handoffs are re-normalized to the current latest-message-wins prefix. - Reconcile the overlapping Active Task template edits from the salvaged #26290 (reverse-signal cancellation) and #32787 (capture open questions / decisions, don't write None too eagerly) — both intents kept. - Regression coverage in tests/agent/test_resume_stale_active_task.py. - AUTHOR_MAP entries for both salvaged contributors. * fix(browser): recover from CDP DOM-node serialization crash in browser_console (#35385) browser_console(expression="document.body") returned the cryptic CDP error "Object reference chain is too long" instead of a usable result. With returnByValue=true, Chrome deep-serializes the eval result; for a live DOM Node/NodeList/Window that serialization overruns CDP's recursion guard and fails the whole call with a protocol-level error (not a JS exception), which _browser_eval surfaced raw. - browser_supervisor.evaluate_runtime: on that specific error, retry once with returnByValue=false so Chrome returns the node's description string — the same graceful path already used for document.querySelector() results. - browser_tool._browser_eval (CLI subprocess fallback): the subprocess can't retry, so convert the reference-chain error into actionable guidance (extract a primitive / use JSON.stringify) instead of leaking it raw. No expression rewriting — normal evals (1+41 -> 42) are untouched. * fix(cli): fail closed on empty oneshot responses * fix(cli): surface oneshot agent exceptions to stderr with rc=1 Layer an exception guard on top of the empty-response fix so a crash inside the agent (e.g. OSError from prompt_toolkit/Vt100 when stdout is a non-TTY pipe, per #30623) is surfaced on the real stderr with rc=1 instead of crashing past the redirect_stderr block. KeyboardInterrupt/SystemExit are re-raised so Ctrl-C and explicit exits still propagate. Also map briancl2 in scripts/release.py AUTHOR_MAP for the cherry-picked empty-response commit. Adapts the exception-guard approach from sweetcornna's PR #33818. Co-authored-by: sweetcornna <96944678+ymylive@users.noreply.github.com> * fix(dashboard): allow insecure WS peers on explicit non-loopback binds (#35386) The merged 0.0.0.0/:: insecure-bind fix (#35141) did not cover binding directly to a specific non-loopback address (e.g. a Tailscale/LAN IP via --host 100.64.0.10 --insecure). In that mode the dashboard HTML loaded but every WebSocket upgrade was rejected by the loopback-only peer guard, so /chat connected then silently received no data. Generalize _ws_client_is_allowed to lift the loopback-only peer gate for any explicit non-loopback bound host, not just the 0.0.0.0/:: wildcard. DNS-rebinding stays blocked: _ws_host_origin_is_allowed already requires the Host header to exactly match the bound interface for explicit binds, mirroring _is_accepted_host on the HTTP layer. Co-authored-by: pxdsgnco <14163800+pxdsgnco@users.noreply.github.com> * feat: add text debounce batching for WhatsApp and WeChat platforms WhatsApp and WeChat (Weixin/iLink) both deliver messages individually without any client-side batching, so rapid multi-message bursts (forwarded batches, paste-splits, etc.) each trigger a separate agent invocation. This wastes tokens (redundant system prompts / context for each fragment) and degrades UX (the user receives reply fragments instead of a single coherent response). Both adapters now mirror the Telegram adapter's proven text-debounce pattern: - _text_batch_delay_seconds / _text_batch_split_delay_seconds (configurable via env vars) - _pending_text_batches dict for per-session aggregation - _enqueue_text_event() concatenates successive TEXT messages and resets the flush timer - _flush_text_batch() dispatches after the quiet period expires Configurable via env vars: HERMES_WHATSAPP_TEXT_BATCH_DELAY_SECONDS (default 5.0) HERMES_WHATSAPP_TEXT_BATCH_SPLIT_DELAY_SECONDS (default 10.0) HERMES_WEIXIN_TEXT_BATCH_DELAY_SECONDS (default 3.0) HERMES_WEIXIN_TEXT_BATCH_SPLIT_DELAY_SECONDS (default 5.0) * fix(gateway): config.yaml path for WhatsApp/Weixin text-batch delays Convert the salvaged text-debounce delays from HERMES_* env vars to config.yaml (gateway.platforms.<name>.extra.text_batch_delay_seconds / text_batch_split_delay_seconds), per the '.env is for secrets only' policy. Adds a finite/non-negative guard so bad YAML values fall back to the defaults instead of crashing asyncio.sleep(). - whatsapp.py / weixin.py: read delays via _coerce_float_extra(config.extra) - update Weixin content-dedup regression test for the deferred dispatch path - add text-debounce coverage (whatsapp + weixin): defaults, config override, bad-value fallback, env-var-ignored, burst-collapse, lone-message - docs: WhatsApp + Weixin config keys * fix(gateway): never auto-pause platforms on transient network/DNS failures (#35387) The per-platform reconnect watcher auto-paused a platform after 10 consecutive reconnect failures, setting next_retry=inf and requiring a manual /platform resume to recover. But both pause sites only ever fire on *retryable* failures — non-retryable errors (bad auth) already drop out of the retry queue earlier. So a transient DNS outage that spanned the watcher's backoff window would silently park the bot forever, even after connectivity returned. The watcher's own docstring already promised 'retryable failures keep retrying at the backoff cap indefinitely' — the code contradicted it. Remove the auto-pause from both reconnect-failure branches. Retryable failures now retry at the 5-min backoff cap forever and self-heal once the network recovers. The circuit breaker (_pause_failed_platform / _resume_paused_platform) stays for manual /platform pause|resume. Fixes #35284. * fix(gateway): support Windows absolute paths in MEDIA tag regex and extract_local_files (#34632) The MEDIA_TAG_CLEANUP_RE and extract_local_files path regex both used (?:~/|/) to anchor paths, which only matches Unix-style absolute and home-relative paths. Two additional _TOOL_MEDIA_RE patterns in run.py had the same limitation. Windows absolute paths (C:\Users\..., D:/...) were silently ignored, causing MEDIA directive delivery to fail. Add [A-Za-z]:[/\\] as a third anchor alternative in all four regex locations (base.py x2, run.py x2). Also update path separators in extract_local_files from / to [/\\] so it can traverse Windows directory trees. Revert accidental + quantifier in MEDIA_TAG_CLEANUP_RE lookahead that changed match-one to match-one-or-more (unrelated to fix). Fixes: #34632 * test: use raw docstring in test_run_tool_media_re to silence escape warning * test: update extract_local_files Windows-path test for new matching behavior test_windows_path_not_matched asserted the pre-fix POSIX-only behavior. The Windows drive-letter support now intentionally matches these paths, so replace it with parametrized positive cases plus a relative-path negative guard, mirroring tests/gateway/test_platform_base.py. * feat(kanban): file attachments on tasks (#35395) Tasks can now carry file attachments (PDFs, images, source docs) that workers read directly — closes the gap where source material had to be pasted as a path into the task body. - kanban_db: task_attachments table (additive), Attachment dataclass, add/list/get/delete accessors, attachments_root/task_attachments_dir path helpers (per-board, HERMES_KANBAN_ATTACHMENTS_ROOT override) - build_worker_context: surfaces each attachment's absolute path so the worker (full file/terminal tool access) reads it via read_file/pdftotext - dashboard API: POST/GET/DELETE attachment routes (multipart upload, 25MB cap, traversal-safe filenames, root-containment check on download) - dashboard UI: Attachments section in the task drawer — upload button, list with download, per-row remove - docs + tests (13 cases: DB accessors, REST round-trip, traversal rejection, collision suffixing, worker-context surfacing) Closes #35338 * perf(cli): stop eager MCP discovery from blocking agent-capable startup * fix(file-tools): anchor relative-path resolution to absolute base; report resolved path (#35399) Relative paths in write_file/patch could resolve against the agent PROCESS cwd instead of the terminal's working directory. In a git-worktree session with a stale TERMINAL_CWD='.' (a relative base), early edits silently landed in the MAIN checkout, verified there, and reported success — while the agent inspected the worktree and saw nothing, misreading it as the patch tool no-op'ing. - _resolve_base_dir(): resolution base is now ALWAYS absolute. A relative TERMINAL_CWD is anchored to the process cwd once, deterministically, instead of being left to resolve()-time cwd. Live terminal cwd stays authoritative. - write_file/patch pass the resolved absolute path to the shell FileOps layer so the tool layer and shell layer can't disagree about which file is edited. - Responses now report the absolute resolved_path and files_modified, so a wrong-cwd mismatch is visible on the first call. - _path_resolution_warning(): emits a _warning when a relative path resolves OUTSIDE the live terminal cwd (e.g. a worktree session writing into main). Validation: 11 new unit tests + 43 live E2E assertions (worktree routing, mid-session cd, V4A patches, divergence warning, absolute paths, consecutive patches); 466 existing file/path/terminal tests green. * fix(managed-gateway): keep tool availability scans off the Nous token-refresh path * fix(cli): stop OSC 11 bg probe from trapping users in a stray editor (#35441) Over SSH the OSC 11 background-color query round-trip routinely exceeds the 100ms read budget, so _query_osc11_background() gives up and the late reply lands after prompt_toolkit has grabbed the tty. prompt_toolkit then injects the OSC payload as typed text and reads its BEL terminator (\x07 = Ctrl+G) as a keystroke — Ctrl+G is the open-external-editor binding, dropping the user into vi with garbage and no obvious way out. - Skip the OSC 11 probe on remote sessions (SSH_CONNECTION/CLIENT/TTY); fall back to COLORFGBG / env hints / the dark default. - Restore the tty with TCSAFLUSH instead of TCSANOW so any partial/late reply is scrubbed from the input buffer before pt reads it. * perf(read_file): make compact gutter the only format; drop HERMES_READ_GUTTER (#35532) The compact "<n>|content" gutter from #35368 is now the sole behavior. Removes the HERMES_READ_GUTTER=padded escape hatch and its env lookup — no legacy fixed-width path to maintain. Padding was pure token overhead (~48% more tokens than bare content, ~16% more than compact) with no measured accuracy gain in the original A/B. - file_operations.py: drop env lookup + os import; gutter always f"{i}|{line}" - tests: drop the padded env-override test; compact assertions retained * fix(gateway): stop system tips from auto-uploading local files * fix(gateway): denylist config.yaml for media delivery (belt-and-suspenders) Defense-in-depth on top of the EphemeralReply gate: even if a config.yaml path reaches response text via some other path, it can never be delivered as a native attachment. Matches existing protection for .env, auth.json, and credentials/. Co-authored-by: JezzaHehn <jezzahehn@gmail.com> * fix(install): refresh stale uv so installs actually get FTS5 Python (#35541) The installer's ensure_fts5() handled a no-FTS5 Python by running 'uv python install --reinstall', but WHICH Python builds a uv can install is baked into the uv binary's download manifest. A stale uv (e.g. 'pip install uv==0.7.20', which predates python-build-standalone #694) only knows about pre-FTS5 builds, so --reinstall just pulls the same FTS5-less interpreter — a no-op for FTS5. Result: 'Could not obtain an FTS5-capable Python' and a broken session search even on the supported installer path. ensure_fts5() now escalates uv itself: reinstall with current uv -> 'uv self update' + reinstall (stale standalone uv) -> install a fresh standalone uv into a temp dir and reinstall with that (externally-managed uv that can't self-update, the reported case). Pythons live in uv's shared store, so the fresh uv's --reinstall overwrites the stale interpreter in place and the installer's later 'uv python find' resolves to the FTS5-capable build. Verified against the reporter's exact repro (ubuntu:24.04 + pip install uv==0.7.20): Python 3.11.13 (no FTS5) -> 3.11.15 (FTS5). * fix(session): survive missing FTS5 runtimes * fix(tui): swallow degraded mouse-burst noise so a stalled loop can't lock the composer (#35512) * fix(tui): swallow degraded mouse-burst noise so a stalled loop can't lock the composer When the Node event loop blocks during a heavy render/tool-call burst, stdin stops being drained. Mode-1003 any-motion mouse reports pile up in the kernel buffer, get partially read, and arrive as text with the `\x1b[<` prefix AND coordinate digits chewed off across many partial reads. The existing fragment recovery (SGR_MOUSE_FRAGMENT_RE) only handles clean `button;col;row[Mm]` triples, so the degraded shards leak into the composer as typed text — the user can no longer type or exit until the stall clears. Captured leak (Windows Terminal, during tool calls): M6M35;220;56M6M35;218;56M169;48M;157;47M;44M20;43M79;40M78;40M0M7M35;49;41M 48;41M;47;40M9;15;32M[I;31M5;211;26M35;211;25M7M;220;1MM0M09;25M24M23M3;22M M18M99;26M32MM38M63;44M47MM1;51M M4M54M Add two recovery layers in parseTextWithSgrMouseFragments / the text-token path: - MOUSE_BURST_NOISE_RE: whole-text fast path. If a text token is drawn only from the mouse-leak alphabet (`[ ] < ; I M m`, digits, spaces) AND carries the structural signature of mouse coordinates (>=3 M/m terminators, a digit, and a `;`), swallow it wholesale. - MOUSE_BURST_RESIDUE_RE: swallows pure-noise residue in the gaps between and after recovered fragments, so a partially-recovered burst doesn't trail a chewed-up tail into the prompt. All three constraints together preserve real prose: `Mmm MMM mmm yummy` has no digit/`;`, `see 1;2;3M for details` has disqualifying letters, and `1234;56;78M9;10;11M` has only two terminators — none are swallowed. This is defense-in-depth: it stops the leak/lockout regardless of what blocks the loop. The underlying event-loop stall during streaming is a separate, still-open issue that needs live-turn instrumentation to root-cause. * fix(tui): check mouse-burst noise before fragment recovery; drop test cast Copilot review on #35512: - MOUSE_BURST_NOISE_RE was only evaluated when parseTextWithSgrMouseFragments returned null. A noise blob that contains any intact `<b;c;r M` fragment makes fragment recovery return non-null, so the whole-text swallow never fired and the code emitted a pile of recovered mouse events instead of dropping the blob wholesale (contradicting the comment, and doing extra work mid-stall). Move the noise check ahead of fragment recovery so pure-noise tokens are dropped early. Add a regression test for a noise blob carrying intact fragments. - Drop the unnecessary `(e as { isPasted?: boolean })` cast in the test; discriminated-union narrowing on `e.kind === 'key'` exposes isPasted directly. * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix(tui): clamp bogus terminal dimensions (WSL 131072x1) (#35657) Some hosts (notably WSL) report a junk window size such as 131072 columns by 1 row. Both the Ink fork and our components only guard against 0/null/undefined/NaN (stdout.columns || 80), so a positive-but-absurd width sails through into createScreen(width*height), allocating tens to hundreds of MB per frame and tripping the TUI memory monitor's hard exit. Add clampStdoutDimensions(), installed in entry.tsx before ink.render: it patches process.stdout.columns/rows with clamping getters (cols 1-2000, rows 1-1000; out-of-range -> 80x24). One install point fixes the renderer, its resize handler, and every component read. Live resizes still propagate through the original descriptor, just clamped. * fix(update): name new config options in migration prompt; skip prompt for pure version bumps (#35658) The 'hermes update' config-migration prompt printed only counts ('1 new config option available') then asked 'configure them now?' without ever saying what the options were. Users said no because they couldn't tell what they were agreeing to. For pure config-format version bumps (no new env/config keys) it still asked the question, where saying yes just bumped the version and looked like a no-op. - List each new env var / config key by name + description before prompting (cap at 8, then '… and N more'). The data was already available; we just threw it away and printed a count. - Pure version bump (no new options): apply the format migration non-interactively and print what happened, instead of asking a misleading yes/no. Reported by ScottFive and Tt2021. * fix: remove Discord mention redaction from secret scrubber * test(redact): assert Discord mentions pass through unchanged Rewrite TestDiscordMentions as negative assertions (mentions survive the redactor) and clean up the orphaned comment + dangling whitespace left by removing _DISCORD_MENTION_RE. Follow-up to the salvaged #32259 fix for #35611. * feat(models): add deepseek-v4-flash, trim variants, group curated lists by maker (#35659) * feat(models): add deepseek-v4-flash to OpenRouter + Nous curated lists deepseek/deepseek-v4-flash was already in the native deepseek provider catalog but missing from the curated OpenRouter and Nous Portal picker lists. Added it to both and regenerated the model-catalog.json manifest (drift guard requires same-PR regeneration). * refactor(models): trim redundant variants, group curated lists by maker Remove claude-opus-4.7/4.6, gpt-5.4-nano, gpt-5.3-codex, gemini-3-pro-image-preview, gemini-3.1-flash-lite-preview, grok-4.20, and the older gemini-3-pro-preview (Nous). Reorder both OPENROUTER_MODELS and _PROVIDER_MODELS[nous] into contiguous per-maker blocks with comment headers. Regenerated model-catalog.json (openrouter 27, nous 20). * feat(models): add gemini-3-pro-preview to OpenRouter + Nous curated lists Adds google/gemini-3-pro-preview to both curated pickers (new on OpenRouter, restored on Nous). Regenerated model-catalog.json (openrouter 28, nous 21). * test(models): use claude-opus-4.8 in OpenRouter fetch fixtures The two TestFetchOpenRouterModels tests mocked a live OpenRouter response with claude-opus-4.6 and relied on it surviving the curated-list filter. Since 4.6 was removed from OPENROUTER_MODELS, those models got filtered out and the recommended tag shifted. Swap the fixture to claude-opus-4.8 (still curated, still first in the Anthropic block). * Block Hermes root config in media delivery * fix(telegram): retry on httpx pool timeout instead of dropping the send (#35664) When PTB's general httpx pool is exhausted, it converts httpx.PoolTimeout into telegram.error.TimedOut whose message states the request was *not* sent to Telegram. The send retry loop treated all non-connect TimedOut as non-retryable, so a pool timeout raised immediately, skipped all 3 retry attempts, and was returned as retryable=False -- silently dropping the message (agent responses, cron reports, etc.). A pool timeout means the request never left the process, making it the safest case to retry. Add _looks_like_pool_timeout() and treat it like a connect timeout in both the in-loop retry decision and the outer retryable determination, so pool timeouts flow through the existing backoff loop and stay retryable on exhaustion. Reported-by: q3874758 (#35610) * fix(security): neutralize file paths in mutation-verifier footer (#35584) (#35684) The per-turn file-mutation verifier footer rendered failed-write paths as bare absolute paths in the user-facing response. The gateway's extract_local_files() scans response text for bare paths ending in a deliverable extension (.yaml/.json/etc.), validates os.path.isfile(), and auto-attaches matches as native uploads — so a denied write to ~/.hermes/config.yaml surfaced the path in the footer and got the credential file silently uploaded to the messaging channel. The gateway denylist (validate_media_delivery_path) already blocks the config.yaml case after #35634. This is defense-in-depth at the source: backtick-wrap every path the footer emits — both the bullet path and any path echoed inside the tool's error preview (the protected-file denial message embeds the path in single quotes, which do NOT block the extractor regex). extract_local_files skips paths inside inline-code spans, so wrapping defeats auto-attachment for ANY protected file while keeping the path human-readable. - run_agent.py: _format_file_mutation_failure_footer wraps bullet paths; new _neutralize_footer_paths backticks any remaining bare path (covers the preview echo). staticmethod -> classmethod (caller unaffected). - tests: backtick-wrap assertion + end-to-end extract_local_files leak test. * fix(gateway,cron): prevent agent restart loops via self-targeting gateway commands (#30719) Three defenses against SIGTERM-respawn loops when agent schedules its own gateway restart under launchd/systemd KeepAlive: 1. HERMES_IN_GATEWAY env var: gateway sets it at startup; stop/restart subcommands refuse to run when set (exit 1 with clear message). 2. Cron create payload filter: regex pre-flight rejects prompts/scripts containing hermes gateway restart/stop, launchctl kickstart/unload, systemctl restart/stop, and pkill patterns. 3. 30 new tests: pattern matching (14), cron block (5), gateway guard (4), safe command negatives (7). * fix(gateway,cron): reuse existing _HERMES_GATEWAY marker; tighten cron regex Follow-up to the salvaged #30728: - Gateway already exports _HERMES_GATEWAY=1 at startup (gateway/run.py) and cli.py already keys off it. Drop the redundant new HERMES_IN_GATEWAY var; guard stop/restart on _HERMES_GATEWAY instead. One marker for one fact. - Drop the greedy \bgateway.*restart alternation from the cron lifecycle filter — it false-positived on legit prompts that merely mention an unrelated gateway + a restart (API/payment gateway monitoring). The specific 'hermes gateway (restart|stop|start)' pattern already covers the real command. - Rework the two negative guard tests to sentinel the first downstream call so they don't drive real signal delivery (tripped the live-system guard). - Add false-positive regression cases to test_safe_commands. * docs(toolsets): clarify all/* wildcard does not enable kanban (#35729) The all/* wildcard expands to every registered toolset, but a handful of tools have an additional check_fn gate on top of toolset membership and are intentionally NOT turned on by all/* alone: - Capability-gated tools (browser, computer_use, code_execution, Feishu, Home Assistant, cronjob) require their backend/credential prerequisite. - The kanban toolset is workflow-gated and deliberately opt-in. Kanban tools mutate shared board state, so they stay off by default even under all/* — you must list 'kanban' by name (or be a dispatcher-spawned worker with HERMES_KANBAN_TASK set). This was the expectations gap behind #35581 — the docs previously said all/* expands to 'every registered toolset' without noting the carve-out. Closes #35581. * fix(voice): allow /voice over SSH when a sound server is reachable (#35719) SSH sessions hard-failed voice mode on the presence of SSH_* env vars alone, even when a PulseAudio/PipeWire server is running on the host and audio works (ffplay/aplay/pw-play -> pulseaudio). Probe the default sound-server sockets (PULSE_SERVER unix path, PULSE_RUNTIME_PATH/native, $XDG_RUNTIME_DIR/{pulse/native,pipewire-0}) and actually connect() so a stale socket doesn't count; downgrade the SSH branch to a notice when audio is reachable. Mirrors the existing Docker/WSL forwarding handling. Fixes #35622 * fix(vision): cap embedded image size before it wedges a session (#35732) Resize vision tool-result images down to a 4 MB embed cap at load time, not just at the 20 MB hard ceiling. A 5-20 MB image previously sailed through the native fast path and got baked into conversation history, where Anthropic's 5 MB per-image base64 limit rejected every subsequent turn with a 400 — and because history is immutable, retries could never clear it, permanently wedging the session. Also harden the reactive shrink-recovery: it now returns False (don't retry) when any oversized image part can't be brought under target, so the single retry isn't burned re-sending a payload that will fail identically. Previously it returned True after shrinking *any* part, even when the actual oversized culprit survived. * fix(streaming): stop duplicating tool-call args from cumulative-resend providers (#35718) DeepSeek / Baidu Qianfan stream tool-call arguments in cumulative mode: each chunk resends the full arguments-so-far instead of the new fragment. The stream accumulator blindly concatenated arg deltas with +=, turning that into '{...}{...}{...}', which failed json.loads and got nuked to '{}' — a silently corrupted tool call (#35592). Worse on multi-param tools (search_files, session_search, memory replace) because longer args take more chunks, giving more resend opportunities. - Per-slot cumulative latch in the stream accumulator: a delta that is a strict superset of the accumulated buffer marks the slot cumulative and replaces (not appends); exact duplicates are dropped only after latching. Incremental fragments are untouched (default += path). - Backstop _collapse_repeated_json_arguments() in the repair pipeline collapses pure identical-resend buffers (K exact repeats of a valid-JSON unit) for providers that resend the complete object from chunk 1. Only reached after json.loads already failed, so compliant single objects are never touched. Not a gateway or DeepSeek-model bug — any OpenAI-wire provider in cumulative streaming mode is affected. * feat(models): refresh model catalog hourly instead of daily (#35756) Lower the model_catalog disk-cache TTL from 24h to 1h so freshly published model-catalog.json deploys reach the picker within an hour instead of up to a day. The picker now refetches on the next `hermes model` / `/model` once the cache is older than 1h; younger than 1h still serves the cache (no network hit), and network failures still fall back to the stale copy. - DEFAULT_TTL_HOURS 24 -> 1 (model_catalog.py) - DEFAULT_CONFIG model_catalog.ttl_hours 24 -> 1, _config_version 24 -> 25 - migration v24->25 rewrites a stale ttl_hours:24 to 1, preserving any custom value the user set E2E: verified >1h refetches / <1h skips, and migration rewrites 24->1 while preserving a custom 6. * fix(feishu): cap _message_text_cache with LRU eviction to prevent unbounded growth _message_text_cache was a plain dict with no size limit. Every unique message_id whose text was fetched (for reply-context lookups) stayed in memory permanently, causing unbounded growth in long-running deployments with active group chats. Replace with an OrderedDict and evict the least-recently-used entry whenever the cache exceeds _FEISHU_MESSAGE_TEXT_CACHE_SIZE (512). Cache hits call move_to_end() to refresh LRU order. Mirrors the identical pattern already used by _pending_processing_reactions in the same class. * fix(bluebubbles): cap _guid_cache with LRU eviction to prevent unbounded growth The _guid_cache dict grows without bound as…
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The interactive provider pickers now group a vendor's multiple endpoints under one top-level row instead of listing every slug flat.
Vendors with several Hermes provider slugs (one per endpoint / auth method) each used to occupy multiple top-level rows in the
hermes modelpicker, the setup wizard, and the Telegram/modelkeyboard. They now fold into a single row that drills down to the specific endpoint:kimi-coding,kimi-coding-cnminimax,minimax-oauth,minimax-cnxai,xai-oauthgemini,google-gemini-cliopenai-codex,openai-apiopencode-zen,opencode-gocopilot,copilot-acpGrouping is display only —
CANONICAL_PROVIDERS, slug identity, the--providerflag, and/model <provider:model>are all unchanged, and every member slug remains individually addressable.Changes
hermes_cli/models.py: addPROVIDER_GROUPStable,group_providers()fold, andprovider_group_for_slug()— the single shared fold used by all picker surfaces.hermes_cli/main.py(select_provider_and_model): multi-member groups render one row; selecting one opens a member sub-picker that resolves to a concrete slug, then dispatches to the existing_model_flow_*unchanged. The setup wizard inherits this for free (it delegates to the same function).gateway/platforms/telegram.py: new_build_provider_keyboard()helper +mpg:<group>callback that expands a group into membermp:<slug>buttons (with a back button). A group reduced to a single authenticated member degrades to a direct button — no one-item submenu.Validation
hermes modeltop-level rows--provider,/model x:y)test_provider_groups, telegram/discord pickers, provider resolution, aux config, setup, models, custom-provider switch)Infographic