fix: bound Slack approval block text - #1
Conversation
…OOLONG dropping long slash commands
When the user pastes a long slash command like \`/goal <long prose>\` into
\`hermes chat\`, the input flows into \`_detect_file_drop()\`, whose
\`starts_like_path\` prefilter accepts anything starting with \`/\` and
forwards it to \`_resolve_attachment_path()\`. That helper calls
\`Path.exists()\` which invokes \`os.stat()\`, which raises
\`OSError(errno=ENAMETOOLONG)\` — 63 on macOS, 36 on Linux — when the
candidate exceeds NAME_MAX (typically 255 bytes).
The OSError propagates up to the broad \`except Exception\` in
\`process_loop\` (cli.py:11798), gets logged at WARNING level, and the
user's input is silently dropped. From the user's POV the chat prompt
hangs — the only signal is in agent.log:
WARNING cli: process_loop unhandled error (msg may be lost):
[Errno 63] File name too long: "/goal Drive the space board..."
This affects any slash command with prose-length arguments — \`/goal\`
in particular but also \`/skill\`, \`/cron\`, custom user commands.
Fix: wrap the \`exists()\`/\`is_file()\` calls in try/except OSError so
structurally-invalid path candidates cleanly return None. The slash-
command dispatch path downstream (cli.py:11718) then handles the
input correctly.
Tests: two new regression cases in test_cli_file_drop.py cover the
original \`/goal\` reproducer and a synthetic long path. All 35 file-
drop tests pass.
Reproducer (without the fix):
python -c "from cli import _detect_file_drop;
_detect_file_drop('/goal ' + 'a'*300)"
→ OSError: [Errno 63] File name too long
Follow-up to the salvaged fix for /goal ENAMETOOLONG drop — adds AUTHOR_MAP entry so the release script resolves the commit author to the correct GitHub user.
…vices, pitfalls (NousResearch#20748) Replaces the 22-line stub with a ~320-line guide covering the parts of the Windows/WSL2 split that specifically affect Hermes users: - Why WSL2 (and not native Windows) - Install: distro choice, WSL1→2, systemd via /etc/wsl.conf - Filesystem boundary: /mnt/c vs \\wsl$, perf/perms/watchers/case, wslpath/wslview, CRLF + git core.autocrlf, clone-where guidance - Networking in both directions: - WSL → Windows services: links to the canonical WSL2 Networking section in integrations/providers.md (mirrored mode, NAT + host IP, bind addr, firewall) instead of duplicating - Windows/LAN → Hermes in WSL: mirrored vs NAT, netsh portproxy one-liner, firewall rule, webhook tunneling pointer - Long-running services: systemd gateway + Task Scheduler wsl.exe --exec 'sleep infinity' to keep the VM alive at login - GPU passthrough: NVIDIA works, AMD/Intel out of matrix - Common pitfalls: connection refused, /mnt/c slowness, CRLF ^M, UNC warnings, post-sleep clock drift, mirrored-mode DNS with VPN, PATH, Defender scanning, VHDX disk reclaim All internal links use site-absolute /docs/... form (matches the rest of user-guide/); all seven link targets verified to exist.
… map, opt-in fix (NousResearch#20749) * docs(providers): add model-provider-plugin authoring guide + fix stale refs New docs: - website/docs/developer-guide/model-provider-plugin.md — full authoring guide (directory layout, minimal example, ProviderProfile fields, overridable hooks, user overrides, api_mode selection, auth types, testing, pip distribution) - Wired into website/sidebars.ts under 'Extending' - Cross-references added in: - guides/build-a-hermes-plugin.md (tip block) - developer-guide/adding-providers.md - developer-guide/provider-runtime.md User guide: - user-guide/features/plugins.md: Plugin types table grows from 3 to 4 with 'Model providers' row Stale comment cleanup (providers/*.py → plugins/model-providers/<name>/): - hermes_cli/main.py:_is_profile_api_key_provider docstring - hermes_cli/doctor.py:_build_apikey_providers_list docstring - hermes_cli/auth.py: PROVIDER_REGISTRY + alias auto-extension comments - hermes_cli/models.py: CANONICAL_PROVIDERS auto-extension comment AGENTS.md: - Project-structure tree: added plugins/model-providers/ row - New section: 'Model-provider plugins' explaining discovery, override semantics, PluginManager integration, kind auto-coerce heuristic Verified: docusaurus build succeeds, new page renders, all 3 cross-links resolve. 347/347 targeted tests pass (tests/providers/, tests/hermes_cli/test_plugins.py, tests/hermes_cli/test_runtime_provider_resolution.py, tests/run_agent/test_provider_parity.py). * docs(plugins): add 'pluggable interfaces at a glance' maps to plugins.md + build-a-hermes-plugin Devs landing on either the user-guide plugin page or the build-a-plugin guide now get an upfront table of every distinct pluggable surface with a link to the right authoring doc. Previously they'd have to read the full general-plugin guide to discover that model providers / platforms / memory / context engines are separate systems. user-guide/features/plugins.md: - New 'Pluggable interfaces — where to go for each' section below the existing 4-kinds table - 10 rows covering every register_* surface (tool, hook, slash command, CLI subcommand, skill, model provider, platform, memory, context engine, image-gen) - Explicit note: TTS/STT are NOT plugin-extensible yet — documented with a pointer to the current config.yaml 'command providers' pattern and a note that register_tts_provider()/register_stt_provider() may come later guides/build-a-hermes-plugin.md: - New :::info 'Not sure which guide you need?' map at the top so devs see all pluggable interfaces before investing in this 737-line general-plugin walkthrough - Existing bottom :::tip expanded to include platform adapters alongside model/memory/context plugins Verified: - All 8 cross-doc links in the new plugins.md table resolve in a docusaurus build (SUCCESS, no new broken links) - TTS link corrected (features/voice → features/tts; latter exists) - Pre-existing broken links/anchors (cron-script-only, llms.txt, adding-platform-adapters#step-by-step-checklist) are unchanged * docs(plugins): correct TTS/STT pluggability \u2014 they ARE plugins (command-providers) Previous commit incorrectly said TTS/STT 'aren't plugin-extensible'. They are, via the config-driven command-provider pattern \u2014 any CLI that reads text and writes audio (or vice versa for STT) is automatically a plugin with zero Python. The tts.md docs cover this extensively and I missed it. plugins.md: - TTS row: 'Config-driven (not a Python plugin)', points at tts.md#custom-command-providers - STT row: points at tts.md#voice-message-transcription-stt (STT docs live in tts.md despite the filename) - Expanded note: TTS/STT use config-driven shell-command templates as their plugin surface (full tts.providers.<name> registry for TTS; HERMES_LOCAL_STT_COMMAND escape hatch for STT) - Any CLI that reads/writes files is automatically a plugin \u2014 no Python register_* API needed - Future register_tts_provider()/register_stt_provider() hooks mentioned as nice-to-have for SDK/streaming cases, not as the primary story build-a-hermes-plugin.md: - Same map update: TTS/STT rows explicit, footer note corrected Verified: - tts.md anchors (custom-command-providers, voice-message-transcription-stt) exist and resolve in docusaurus build (SUCCESS, no new broken links) * docs(plugins): expand pluggable interfaces table with MCP / event hooks / shell hooks / skill taps Broadened the scope beyond Python register_* hooks. Hermes has MULTIPLE plugin-style extension surfaces; they're now all in one table instead of being scattered across feature docs. Added rows for: - **MCP servers** — config.yaml mcp_servers.<name> auto-registers external tools from any MCP server. Huge extensibility surface, previously not linked from the plugin map. - **Gateway event hooks** — drop HOOK.yaml + handler.py into ~/.hermes/hooks/<name>/ to fire on gateway:startup, session:*, agent:*, command:* events. Separate from Python plugin hooks. - **Shell hooks** — hooks: block in config.yaml runs shell commands on events (notifications, auditing, etc.). - **Skill sources (taps)** — hermes skills tap add <repo> to pull in new skill registries beyond the built-in sources. Both docs updated: - user-guide/features/plugins.md: table column renamed to 'How' (mixes Python API + config-driven + drop-in-dir surfaces accurately) - guides/build-a-hermes-plugin.md: :::info map at top mirrors the new surfaces with a forward-link to the consolidated table Note block rewritten: instead of singling out TTS/STT as the 'different style' exception, now honestly describes that Hermes deliberately supports three plugin styles — Python APIs, config-driven commands, and drop-in manifest directories — and devs should pick the one that fits their integration. Not included (considered and rejected): - Transport layer (register_transport) — internal, not user-facing - Tool-call parsers — internal, VLLM phase-2 thing - Cloud browser providers — hardcoded registry, not drop-in yet - Terminal backends — hardcoded if/elif, not drop-in yet - Skill sources (the ABC) — hardcoded list, only taps are user-extensible Verified: - All 5 new anchors resolve (gateway-event-hooks, shell-hooks, skills-hub, custom-command-providers, voice-message-transcription-stt) - Docusaurus build SUCCESS, zero new broken links - Same 3 pre-existing broken links on main (cron-script-only, llms.txt, adding-platform-adapters#step-by-step-checklist) * docs(plugins): cover every pluggable surface in both the overview and how-to Both plugins.md and build-a-hermes-plugin.md now cover every extension surface end-to-end \u2014 general plugin APIs, specialized plugin types, config-driven surfaces \u2014 with concrete authoring patterns for each. plugins.md: - 'What plugins can do' table grows from 9 rows (general ctx.register_* only) to 14 rows covering register_platform, register_image_gen_provider, register_context_engine, MemoryProvider subclass, register_provider (model). Each row links to its full authoring guide. - New 'Plugin sub-categories' section under Plugin Discovery explains how plugins/platforms/, plugins/image_gen/, plugins/memory/, plugins/context_engine/, plugins/model-providers/ are routed to different loaders \u2014 PluginManager vs the per-category own-loader systems. - Explicit mention of user-override semantics at ~/.hermes/plugins/model-providers/ and ~/.hermes/plugins/memory/. build-a-hermes-plugin.md: - New '## Specialized plugin types' section (5 sub-sections): - Model provider plugins \u2014 ProviderProfile + plugin.yaml example, auto-wiring summary, link to full guide - Platform plugins \u2014 BasePlatformAdapter + register_platform() skeleton - Memory provider plugins \u2014 MemoryProvider subclass example - Context engine plugins \u2014 ContextEngine subclass example - Image-generation backends \u2014 ImageGenProvider + kind: backend example - New '## Non-Python extension surfaces' section (5 sub-sections): - MCP servers \u2014 config.yaml mcp_servers.<name> example - Gateway event hooks \u2014 HOOK.yaml + handler.py example - Shell hooks \u2014 hooks: block in config.yaml example - Skill sources (taps) \u2014 hermes skills tap add example - TTS / STT command templates \u2014 tts.providers.<name> with type: command - Distribute via pip / NixOS promoted from ### to ## (they were orphaned after the reorganization) Each specialized / non-Python section has a concrete, copy-pasteable example plus a 'Full guide:' link to the authoritative doc. Devs arriving at the build-a-hermes-plugin guide now see every extension surface at their disposal, not just the general tool/hook/slash-command surface. Verified: - Docusaurus build SUCCESS, zero new broken links - All new cross-links (developer-guide/model-provider-plugin, adding-platform-adapters, memory-provider-plugin, context-engine-plugin, user-guide/features/mcp, skills#skills-hub, hooks#gateway-event-hooks, hooks#shell-hooks, tts#custom-command-providers, tts#voice-message-transcription-stt) resolve - Same 3 pre-existing broken links on main (cron-script-only, llms.txt, adding-platform-adapters#step-by-step-checklist) * docs(plugins): fix opt-in inconsistency — not every plugin is gated The 'Every plugin is disabled by default' statement was wrong. Several plugin categories intentionally bypass plugins.enabled: - Bundled platform plugins (IRC, Teams) auto-load so shipped gateway channels are available out of the box. Activation per channel is via gateway.platforms.<name>.enabled. - Bundled backends (plugins/image_gen/*) auto-load so the default backend 'just works'. Selection via <category>.provider config. - Memory providers are all discovered; one is active via memory.provider. - Context engines are all discovered; one is active via context.engine. - Model providers: all 33 discovered at first get_provider_profile(); user picks via --provider / config. The plugins.enabled allow-list specifically gates: - Standalone plugins (general tools/hooks/slash commands) - User-installed backends - User-installed platforms (third-party gateway adapters) - Pip entry-point backends Which matches the actual code in hermes_cli/plugins.py:737 where the bundled+backend/platform check bypasses the allow-list. Rewrote '## Plugins are opt-in' to: - Retitle to 'Plugins are opt-in (with a few exceptions)' - Narrow opening claim to 'General plugins and user-installed backends are disabled by default' - Added 'What the allow-list does NOT gate' subsection with a full table of which bypass the gate and how they're activated instead - Fixed migration section wording (bundled platform/backend plugins never needed grandfathering) Verified: docusaurus build SUCCESS, zero new broken links.
…usResearch#20752) * feat(skills/linear): add Documents support + Python helper script The bundled Linear skill (PR NousResearch#1230) covered issues, projects, teams, and workflow states via curl. It had no coverage for Linear's Documents API, so fetching an RFC/doc from a linear.app URL required hand-writing GraphQL against an underdocumented schema. Adds: - Documents section in SKILL.md explaining slugId extraction from URLs, the contentState (markdown) vs contentState (ProseMirror) split, and four canonical curl examples (fetch by slugId, fetch by UUID, list recent, title-search). - scripts/linear_api.py — stdlib-only Python CLI wrapping the most common operations (whoami, list-teams, list/get/search/create/update issues, add-comment, update-status, list/get/search documents, raw GraphQL passthrough). Zero deps, reads LINEAR_API_KEY from env. Auth header quirk (personal key takes bare $LINEAR_API_KEY, no Bearer prefix) is already documented in the skill. Found during RFC review: the existing skill's lack of document support forced falling back to the browser (which hit Linear's login wall). Also fixes a schema gotcha — the Document field is `contentState`, not `contentData` (which returns 400). Tested end-to-end against the production API: python3 linear_api.py whoami python3 linear_api.py get-document 38359beef67c Both return expected payloads. * fix(skills/linear): point LINEAR_API_KEY setup to the correct page The org-level Settings > API page (/settings/api) only shows OAuth apps and workspace-member keys. Personal API keys live under Account, Security, access (/settings/account/security). Update both the setup link in config.py (shown during hermes setup) and the setup step in SKILL.md so users land on the page that can create a personal key.
…+ publishing a skill tap (NousResearch#20800) Two pluggable surfaces were mentioned in the interfaces map without a real authoring guide behind them: 1. **Image-gen backends** — only had 'See bundled examples' pointers. Now a full developer-guide/image-gen-provider-plugin.md (270 lines) mirroring the memory/context/model provider docs: - How discovery works, directory structure, plugin.yaml - ImageGenProvider ABC with every overridable method (name, display_name, is_available, list_models, default_model, get_setup_schema, generate) - Full authoring walkthrough with a working MyBackendImageGenProvider - Response-format reference (success_response / error_response) - Handling b64 vs URL output (save_b64_image helper) - User overrides at ~/.hermes/plugins/image_gen/<name>/ - Testing recipe + pip distribution - Reference examples (openai, openai-codex, xai) 2. **Skill taps** — features/skills.md mentioned the CLI commands but never explained the repo contract for publishing a tap. Added 'Publishing a custom skill tap' section under Skills Hub covering: - Repo layout (skills/<name>/SKILL.md by default) - Minimal working example - Non-default path configuration (taps.json) - Installing individual skills without subscribing - Trust-level handling - Full tap management CLI + in-session /skills tap commands Wired into: - website/sidebars.ts: image-gen-provider-plugin added to Extending group - website/docs/user-guide/features/plugins.md: pluggable interfaces table + 'What plugins can do' table now link to the real guides instead of 'See bundled examples' - website/docs/guides/build-a-hermes-plugin.md: top info map and inline sub-sections updated, 'Full guide:' line added to image-gen block, tap section mentions publishing Verified: docusaurus build SUCCESS, new page renders at /docs/developer-guide/image-gen-provider-plugin, anchor #publishing-a-custom-skill-tap resolves from plugins.md + build-a-hermes-plugin.md. Pre-existing zh-Hans broken links unchanged.
…ative providers (NousResearch#20802) OpenCode Go and OpenCode Zen are flat-namespace model resellers — their /v1/models returns bare IDs (deepseek-v4-flash, minimax-m2.7), and the inference API rejects vendor-prefixed names with HTTP 401 'Model not supported'. Two bugs fixed: 1. `switch_model` in hermes_cli/model_switch.py was silently switching the user off opencode-go to native deepseek when they typed `/model deepseek-v4-flash`. Step d found the model in opencode-go's live catalog, but step e (detect_provider_for_model) still ran and matched the bare name against deepseek's static catalog. Fix: track whether the live catalog resolved it; skip step e when it did. 2. `normalize_model_for_provider` in hermes_cli/model_normalize.py only stripped the exact `opencode-zen/` prefix, leaving arbitrary vendor prefixes like `minimax/minimax-m2.7` (commonly copied from aggregator slugs into fallback_model configs) intact — causing HTTP 401s when the fallback chain activated. Fix: opencode-go/opencode-zen strip ANY leading vendor prefix because their APIs are flat-namespace. Tests: 11 new cases in tests/hermes_cli/test_opencode_go_flat_namespace.py covering both normalization (prefix stripping, regression guards for opencode-zen Claude hyphenation and openrouter vendor-prepending) and switch_model (bare-name resolution on opencode-go's live catalog must not trigger cross-provider hijack). Reported by @UFOnik via Discord; Kimi K2.6 always worked because moonshotai has no overlapping entry in a native provider's static catalog. Deepseek and minimax failed because their v4/v2.7 names existed in the native deepseek/minimax catalogs.
…ze (NousResearch#20820) Same Hermes Teal palette as the default theme, but with baseSize 18px, lineHeight 1.65, and spacious density so the whole dashboard scales up. Gives users a one-click bigger-text preset and a copyable reference for authoring custom YAML themes with their own typography settings.
Introduce the foundation for independently selecting web search and extract backends — enabling future combinations like SearXNG for search + Firecrawl for extract. Architecture: - tools/web_providers/base.py: WebSearchProvider and WebExtractProvider ABCs with normalized result contracts (mirrors CloudBrowserProvider) - tools/web_tools.py: _get_search_backend() and _get_extract_backend() read per-capability config keys, fall through to shared web.backend - hermes_cli/config.py: web.search_backend and web.extract_backend in DEFAULT_CONFIG (empty = inherit from web.backend) Behavioral change: - web_search_tool() now dispatches via _get_search_backend() - web_extract_tool() now dispatches via _get_extract_backend() - When per-capability keys are empty (default), behavior is identical to before — _get_search_backend() falls through to _get_backend() This is purely structural — no new backends are added. SearXNG and other search-only/extract-only providers can now be added as simple drop-in modules in follow-up PRs. 12 new tests, 49 existing tests pass with zero regressions. Ref: NousResearch#19198
Adds SearXNG as a free, self-hosted web search provider. SearXNG is a
privacy-respecting metasearch engine that requires no API key — just a
running instance and SEARXNG_URL pointing at it.
## What this adds
- `tools/web_providers/searxng.py` — `SearXNGSearchProvider` implementing
`WebSearchProvider` (search only; no extract capability)
- `_is_backend_available("searxng")` — gates on SEARXNG_URL
- `_get_backend()` — accepts "searxng" as a configured value; adds it to
auto-detect candidates (lower priority than paid services)
- `web_search_tool` — dispatches to SearXNG when it is the active backend
- `check_web_api_key()` — includes SearXNG in availability check
- `OPTIONAL_ENV_VARS["SEARXNG_URL"]` — registered with tools=["web_search"]
- `tools_config.py` — SearXNG appears in the `hermes tools` provider picker
- `nous_subscription.py` — `direct_searxng` detection, web_active / web_available
- `setup.py` — SEARXNG_URL listed in the missing-credential hint
- 23 tests covering: is_configured, happy-path search, score sorting, limit,
HTTP/request errors, _is_backend_available, _get_backend, check_web_api_key
## Config
```yaml
# Use SearXNG for search, any paid provider for extract
web:
search_backend: "searxng"
extract_backend: "firecrawl"
# Or: SearXNG as the sole backend (web_extract will use the next available)
web:
backend: "searxng"
```
SearXNG is search-only — it does not implement WebExtractProvider. Users
who only configure SEARXNG_URL get web_search available; web_extract falls
back to the next available extract provider (or is unavailable if none).
Closes NousResearch#19198 (Phase 2 Task 4 — SearXNG provider)
Ref: NousResearch#11562 (original SearXNG PR)
Closes the remaining gaps from PR NousResearch#11562 that weren't covered by the core SearXNG integration landed in NousResearch#20823. - optional-skills/research/searxng-search/ — installable skill with SKILL.md (curl-based usage, category support, Python example) and searxng.sh helper script for health checks and instance queries - website/docs/user-guide/configuration.md — SearXNG added to the Web Search Backends section (5 backends, backend table, per-capability split config example, correct search-only note) - website/docs/reference/environment-variables.md — SEARXNG_URL row - website/docs/reference/optional-skills-catalog.md — searxng-search entry The core SearXNG code, OPTIONAL_ENV_VARS, hermes tools picker, and tests were already on main via NousResearch#20823. This commit is purely additive docs + the optional skill scaffold. Credits from NousResearch#11562 salvage: @w4rum — original _searxng_search structure @nathansdev — tools_config.py integration @moyomartin — category support and result formatting @0xMihai — config/env var approach @nicobailon — skill and documentation structure @searxng-fan — error handling patterns @Local-First — self-hosted-first philosophy and docs
Route Feishu topic progress, status, approval, stream, and fallback messages through threaded replies by preserving the originating message id as the reply target. Add regressions for tool progress topic metadata and Feishu metadata-driven reply routing.
- Remove dead metadata.get('reply_to') fallback in _send_raw_message;
nothing in the codebase ever sets 'reply_to' inside a metadata dict —
the key only appears as a top-level send_voice() keyword argument
- Simplify _status_thread_metadata construction in run.py to use a
single dict literal instead of create-then-mutate pattern; the
or-{} guard was dead since source.thread_id implies _progress_thread_id
is also set for Feishu
- Add yuqian@zmetasoft.com to AUTHOR_MAP for contributor attribution
- Expand migration comment to name the primary failure mode (missing column OperationalError from NousResearch#20842) ahead of the secondary SQLite schema-reparse concern; also document the stale-cols-snapshot invariant - Add clarifying comments on from_row() legacy fallback branches noting they are belt-and-suspenders dead code post-migration - Add task_events comment in existing test explaining why the table is required by the migrator - Add test_legacy_migration_no_legacy_columns_at_all: Scenario A — explicitly asserts the exact NousResearch#20842 crash no longer occurs and that consecutive_failures defaults to 0 on a DB that never had spawn_failures - Add test_legacy_migration_both_columns_already_present: Scenario D — asserts the migration is a no-op when both columns already exist, preserving the existing counter value
change: enable ruff/ty
Switch top-level concurrency to cancel-in-progress=false so every push to main gets its own SHA-tagged image published — no more discarded builds when commits land back-to-back. Guard the :latest tag with a second job that has its own concurrency group with cancel-in-progress=true plus a git-ancestor check against the revision label on the current :latest. Together these guarantee :latest only ever moves forward in history: a slower run whose commit isn't a descendant of the current :latest refuses to clobber it, and a newer push mid-way through the move-latest job preempts the older one before it can retag. - Every main push publishes nousresearch/hermes-agent:sha-<commit> with an org.opencontainers.image.revision label embedded. - move-latest job reads that label off :latest, runs merge-base --is-ancestor, and only retags (via buildx imagetools create, registry-side, no rebuild) if our commit strictly descends. - fetch-depth bumped to 1000 so merge-base has the history it needs. - Release tag flow unchanged (unique tag, no race).
…ch#20827) Previous version read like internal API docs \u2014 leading with env var tables, config YAML, and 'precedence' rules before ever explaining the product. Complete rewrite inverts the structure so readers see value first, mechanics last. Structure now: - Lede: 'One subscription. Every tool built in.' + pitch paragraph - CTA: subscribe/manage button styled as a real call-to-action - What's included: emoji-led table with expanded descriptions per tool. Image gen lists all 9 models by name (FLUX 2 Klein/Pro, Z-Image Turbo, Nano Banana Pro, GPT Image 1.5/2, Ideogram V3, Recraft V4 Pro, Qwen) - Why it's here: value bullets \u2014 one bill, one signup, one key, same quality, bring-your-own anytime - Get started: two-command flow (hermes model \u2192 hermes status) - Eligibility: paid-tier note with upgrade link - Mix and match: three realistic usage patterns - Using individual image models: ID reference table for power users - --- separator --- - Configuration reference (demoted): use_gateway flag, disabling, self-hosted gateway env vars moved below the fold where they belong - FAQ: streamlined, removed redundant content Fact-checked against code: - 9 FAL models confirmed from tools/image_generation_tool.py FAL_MODELS - Status section output verified against hermes_cli/status.py - Portal subscription URL preserved - Self-hosted env vars (TOOL_GATEWAY_DOMAIN etc.) kept accurate Verified: docusaurus build SUCCESS, page renders, no new broken links.
…n profile Profile processes (kanban workers, cron subprocesses, delegated subagents) read the profile's auth.json only. If a provider was authenticated at the global root but not inside the profile, the profile's credential_pool comes back empty and the process fails with 'No LLM provider configured' — even though the credentials are sitting in ~/.hermes/auth.json. NousResearch#18594 propagated HERMES_HOME correctly, which is what surfaced this: workers now land in the right profile, and the profile turns out to shadow global with no fallback. Semantics (read-only, per-provider shadowing): * Profile has any entries for provider X → use profile only (global ignored). * Profile has zero entries for provider X → fall back to global. * Writes (write_credential_pool, _save_auth_store) still target the profile. * Classic mode (HERMES_HOME == global root) skips the fallback entirely — _global_auth_file_path() returns None. Also mirrors the fallback in get_provider_auth_state so OAuth singletons (nous, minimax-oauth, openai-codex, spotify) inherit cleanly — the Nous shared-token store (PR NousResearch#19712) remains the authoritative path for Nous OAuth rotation, this just makes the read side consistent with it. Seat belt: _load_global_auth_store() refuses to read the real user's ~/.hermes/auth.json under PYTEST_CURRENT_TEST even when HERMES_HOME points to a profile-shaped path. Guard uses $HOME (stable across fixtures) rather than Path.home() (which fixtures often monkeypatch to a tmp root). Reported by @SeedsForbidden on Twitter as the credential_pool shadowing follow-up to the NousResearch#18594 fix.
Adds an opt-out toggle on PlatformConfig that gates both restart lifecycle pings: the "♻ Gateway restarted" message sent to the chat that issued /restart, and the "♻️ Gateway online" home-channel startup notification. Defaults to True so existing deployments are unaffected. The motivating split is operator vs. end-user surfaces: a back-channel like Telegram should keep these pings, while a Slack workspace shared with end users should not surface gateway lifecycle noise. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extend the gateway_restart_notification flag to cover
_notify_active_sessions_of_shutdown — the message that fires just
before drain ("⚠️ Gateway restarting — Your current task will be
interrupted. Send any message after restart and I'll try to resume
where you left off.") sent to active sessions and home channels.
Same operator/end-user reasoning: on a Slack workspace shared with
end users, "Gateway restarting" reads as "the bot is broken" — the
operator should be able to suppress it consistently with the other
two lifecycle pings rather than having a partial opt-out.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
For cherry-picked commits in PR NousResearch#20801.
…rsonality fix(tui): preserve session when switching personality
…ch#21541) Makes first-time use of the kanban view self-explanatory. Every control that wasn't already labelled now has a `title` tooltip describing what it does, and a `?` icon next to the board switcher opens the kanban docs page in a new tab. Coverage: - BoardSwitcher: board select, + New board button, docs-link icon (both compact and full variants) - BoardToolbar: Search, Tenant, Assignee, Show archived, Nudge dispatcher, Refresh - BulkActionBar: → ready, Complete, Archive, reassign group, Apply, Clear - Column header: hovering the header now surfaces COLUMN_HELP as a tooltip in addition to the visible sub-text; column count also labelled - Card: task id, priority badge, tenant badge, assignee/unassigned, comment count, link count, age timestamp - InlineCreate: assignee, priority, parent-task selectors Closes the community feedback from @CharlieDePew asking for tooltips and a docs link in the kanban view. Relevant docs page: https://hermes-agent.nousresearch.com/docs/user-guide/features/kanban
# Conflicts: # gateway/config.py # gateway/platforms/slack.py # hermes_cli/web_server.py # tests/gateway/test_config.py # tests/gateway/test_slack.py # tests/hermes_cli/test_web_server.py # web/package-lock.json # web/package.json # web/src/App.tsx # web/src/components/ui/select.tsx # web/src/i18n/en.ts # web/src/i18n/types.ts # web/src/i18n/zh.ts # web/src/lib/api.ts # web/src/pages/AnalyticsPage.tsx
|
Superseded by upstream PR NousResearch#27441. |
🚨 CRITICAL Supply Chain Risk DetectedThis PR contains a pattern that has been used in real supply chain attacks. A maintainer must review the flagged code carefully before merging. 🚨 CRITICAL: Install-hook file added or modifiedThese files can execute code during package installation or interpreter startup. Files: Scanner only fires on high-signal indicators: .pth files, base64+exec/eval combos, subprocess with encoded commands, or install-hook files. Low-signal warnings were removed intentionally — if you're seeing this comment, the finding is worth inspecting. |
🔎 Lint report:
|
| Rule | Count |
|---|---|
F401 |
736 |
F841 |
108 |
E402 |
95 |
F541 |
34 |
E401 |
15 |
E741 |
14 |
F821 |
14 |
F811 |
10 |
E731 |
8 |
E701 |
7 |
E702 |
5 |
F601 |
2 |
First entries
../../../../../tmp/lint-base/tests/gateway/test_discord_document_handling.py:326: [F841] Local variable `responses` is assigned to but never used
../../../../../tmp/lint-base/tests/run_agent/test_1630_context_overflow_loop.py:109: [F841] Local variable `status_code` is assigned to but never used
../../../../../tmp/lint-base/tests/gateway/test_update_command.py:85: [F841] Local variable `original_path` is assigned to but never used
../../../../../tmp/lint-base/tests/test_trajectory_compressor_async.py:79: [F841] Local variable `client2` is assigned to but never used
../../../../../tmp/lint-base/tests/tools/test_file_tools_live.py:21: [F811] Redefinition of unused `pytest` from line 11: `pytest` redefined here
../../../../../tmp/lint-base/tests/hermes_cli/test_plugins_cmd.py:220: [F401] `argparse` imported but unused
../../../../../tmp/lint-base/tests/tools/test_voice_mode.py:1152: [F841] Local variable `pid` is assigned to but never used
../../../../../tmp/lint-base/rl_cli.py:63: [F401] `hermes_constants.get_hermes_home` imported but unused
../../../../../tmp/lint-base/tests/tools/test_tirith_security.py:868: [F401] `tools.tirith_security._INSTALL_FAILED` imported but unused
../../../../../tmp/lint-base/agent/memory_manager.py:31: [F401] `json` imported but unused
../../../../../tmp/lint-base/tests/gateway/test_discord_thread_persistence.py:11: [F401] `pytest` imported but unused
../../../../../tmp/lint-base/environments/terminal_test_env/terminal_test_env.py:45: [F401] `atroposlib.type_definitions.Item` imported but unused
../../../../../tmp/lint-base/tests/honcho_plugin/test_async_memory.py:25: [F401] `plugins.memory.honcho.session._ASYNC_SHUTDOWN` imported but unused
../../../../../tmp/lint-base/tests/agent/test_external_skills.py:5: [F401] `pathlib.Path` imported but unused
../../../../../tmp/lint-base/hermes_cli/doctor.py:60: [E402] Module level import not at top of file
../../../../../tmp/lint-base/tests/gateway/test_send_image_file.py:17: [F401] `gateway.platforms.base.SendResult` imported but unused
../../../../../tmp/lint-base/tests/agent/test_rate_limit_tracker.py:199: [F401] `unittest.mock.MagicMock` imported but unused
../../../../../tmp/lint-base/tests/hermes_cli/test_auth_codex_provider.py:14: [F401] `hermes_cli.auth.PROVIDER_REGISTRY` imported but unused
../../../../../tmp/lint-base/tests/hermes_cli/test_skin_engine.py:4: [F401] `os` imported but unused
../../../../../tmp/lint-base/tests/skills/test_google_workspace_api.py:5: [F401] `os` imported but unused
../../../../../tmp/lint-base/tests/agent/test_model_metadata_local_ctx.py:497: [F401] `agent.model_metadata.CONTEXT_PROBE_TIERS` imported but unused
../../../../../tmp/lint-base/tests/hermes_cli/test_update_gateway_restart.py:11: [F401] `unittest.mock.MagicMock` imported but unused
../../../../../tmp/lint-base/tests/tools/test_tool_call_parsers.py:19: [F401] `environments.tool_call_parsers.ParseResult` imported but unused; consider using `importlib.util.find_spec` to test for availability
../../../../../tmp/lint-base/environments/web_research_env.py:73: [E402] Module level import not at top of file
../../../../../tmp/lint-base/environments/hermes_swe_env/hermes_swe_env.py:45: [F401] `atroposlib.envs.base.ScoredDataGroup` imported but unused
... and 1023 more
Unchanged: 0 pre-existing issues carried over.
ty (type checker)
Total: 7686 on HEAD, 4561 on base (🆕 +3125)
🆕 New issues (1663):
| Rule | Count |
|---|---|
unresolved-import |
500 |
invalid-argument-type |
408 |
unresolved-attribute |
388 |
invalid-assignment |
185 |
unsupported-operator |
40 |
not-subscriptable |
37 |
invalid-method-override |
25 |
invalid-return-type |
15 |
call-non-callable |
12 |
invalid-parameter-default |
12 |
no-matching-overload |
10 |
unresolved-reference |
8 |
invalid-type-form |
8 |
unused-type-ignore-comment |
6 |
possibly-missing-submodule |
3 |
| +5 more rules |
First entries
tests/hermes_cli/test_kanban_specify_db.py:173: [unresolved-attribute] unresolved-attribute: Attribute `body` is not defined on `None` in union `Task | None`
tests/hermes_cli/test_web_ui_build.py:14: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/cli/test_cprint_bg_thread.py:174: [unresolved-attribute] unresolved-attribute: Unresolved attribute `run_in_terminal` on type `ModuleType`
tests/gateway/test_telegram_topic_mode.py:866: [unresolved-attribute] unresolved-attribute: Object of type `bound method _FakeAdapter.rename_dm_topic(**kwargs) -> CoroutineType[Any, Any, Unknown]` has no attribute `assert_not_called`
run_agent.py:2413: [invalid-argument-type] invalid-argument-type: Argument to function `build_anthropic_client` is incorrect: Expected `str`, found `Any | (str & ~AlwaysFalsy) | None`
tests/agent/test_curator.py:14: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
plugins/google_meet/meet_bot.py:508: [unresolved-attribute] unresolved-attribute: Object of type `bool | None` has no attribute `get`
tests/tools/test_kanban_tools.py:697: [unresolved-attribute] unresolved-attribute: Attribute `current_run_id` is not defined on `None` in union `Task | None`
tests/gateway/test_discord_race_polish.py:7: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
plugins/platforms/teams/adapter.py:242: [unresolved-attribute] unresolved-attribute: Attribute `Response` is not defined on `None` in union `Unknown | None`
tests/hermes_cli/test_aux_config.py:46: [invalid-argument-type] invalid-argument-type: Method `__getitem__` of type `Overload[(key: SupportsIndex | slice[SupportsIndex | None, SupportsIndex | None, SupportsIndex | None], /) -> LiteralString, (key: SupportsIndex | slice[SupportsIndex | None, SupportsIndex | None, SupportsIndex | None], /) -> str]` cannot be called with key of type `Literal["session_search"]` on object of type `str`
tests/gateway/test_signal_format.py:8: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/hermes_cli/test_kanban_cli.py:45: [unresolved-attribute] unresolved-attribute: Attribute `endswith` is not defined on `None` in union `str | None`
gateway/platforms/dingtalk.py:991: [unresolved-attribute] unresolved-attribute: Attribute `DeliverCardRequest` is not defined on `None` in union `Unknown | None`
tests/gateway/test_restart_resume_pending.py:33: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/tools/test_browser_lightpanda.py:598: [invalid-assignment] invalid-assignment: Object of type `Literal[True]` is not assignable to attribute `_browser_engine_resolved` of type `Literal[False]`
tests/cron/test_compute_next_run_last_run_at.py:85: [invalid-argument-type] invalid-argument-type: Argument to bound method `date.fromisoformat` is incorrect: Expected `str`, found `str | None`
run_agent.py:12542: [invalid-argument-type] invalid-argument-type: Method `__getitem__` of type `bound method dict[str, str].__getitem__(key: str, /) -> str` cannot be called with key of type `slice[None, Literal[12], None]` on object of type `dict[str, str]`
tests/agent/test_openrouter_response_cache.py:231: [invalid-argument-type] invalid-argument-type: Method `__getitem__` of type `Overload[(i: SupportsIndex, /) -> Unknown, (s: slice[SupportsIndex | None, SupportsIndex | None, SupportsIndex | None], /) -> list[Unknown]]` cannot be called with key of type `Literal["response_cache"]` on object of type `list[Unknown]`
tests/hermes_cli/test_fallback_cmd.py:9: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/hermes_cli/test_goals.py:8: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
cron/scheduler.py:1482: [invalid-argument-type] invalid-argument-type: Argument to bound method `dict.get` is incorrect: Expected `Never`, found `Literal["error"]`
hermes_cli/doctor.py:274: [invalid-argument-type] invalid-argument-type: Argument to bound method `list.append` is incorrect: Expected `tuple[str, tuple[str, str, str], str, str, bool] | tuple[str, tuple[str], str, str, bool] | tuple[str, tuple[str], str, None, bool] | tuple[str, tuple[str], None, str, bool]`, found `tuple[str, tuple[Unknown, ...] & ~AlwaysFalsy, str | None, Unknown | None, Literal[True]]`
tests/gateway/test_discord_attachment_download.py:21: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/gateway/test_discord_send.py:166: [unresolved-import] unresolved-import: Cannot resolve imported module `discord`
... and 1638 more
✅ Fixed issues (194):
| Rule | Count |
|---|---|
invalid-argument-type |
79 |
unresolved-attribute |
33 |
invalid-assignment |
27 |
unresolved-import |
19 |
unsupported-operator |
7 |
not-iterable |
6 |
too-many-positional-arguments |
5 |
invalid-return-type |
4 |
invalid-method-override |
3 |
invalid-raise |
3 |
no-matching-overload |
3 |
unresolved-reference |
3 |
not-subscriptable |
1 |
parameter-already-assigned |
1 |
First entries
gateway/platforms/qqbot.py:229: [unresolved-attribute] unresolved-attribute: Attribute `AsyncClient` is not defined on `None` in union `Unknown | None`
gateway/platforms/email.py:541: [invalid-method-override] invalid-method-override: Invalid override of method `send_document`: Definition is incompatible with `BasePlatformAdapter.send_document`
hermes_cli/dump.py:235: [invalid-assignment] invalid-assignment: Object of type `Literal[""]` is not assignable to `Literal["2026.4.13"]`
gateway/platforms/api_server.py:1864: [too-many-positional-arguments] too-many-positional-arguments: Too many positional arguments to bound method `remove_job`: expected 1, got 2
tests/run_agent/test_compression_feasibility.py:169: [unresolved-attribute] unresolved-attribute: Unresolved attribute `config` on type `AIAgent`
run_agent.py:6039: [invalid-argument-type] invalid-argument-type: Argument to bound method `ContextCompressor.update_model` is incorrect: Expected `str`, found `str | Any | dict[Unknown | str, Unknown | str] | int | dict[Unknown, Unknown]`
gateway/platforms/discord.py:2109: [invalid-return-type] invalid-return-type: Return type does not match returned value: expected `list[str] | None`, found `list[object]`
agent/credential_pool.py:743: [invalid-argument-type] invalid-argument-type: Argument to function `_write_codex_cli_tokens` is incorrect: Expected `str`, found `str | None`
gateway/platforms/slack.py:1650: [invalid-raise] invalid-raise: Cannot raise object of type `None | Unknown`: Not an instance or subclass of `BaseException`
tests/agent/test_auxiliary_config_bridge.py:271: [unsupported-operator] unsupported-operator: Operator `in` is not supported between objects of type `Literal["provider"]` and `Unknown | int | str | ... omitted 8 union elements`
tests/run_agent/test_context_pressure.py:14: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
gateway/platforms/api_server.py:1795: [invalid-argument-type] invalid-argument-type: Argument to bound method `create_job` is incorrect: Expected `str`, found `Self@_handle_create_job`
cli.py:7366: [invalid-argument-type] invalid-argument-type: Argument to function `len` is incorrect: Expected `Sized`, found `(str & ~AlwaysFalsy) | (list[str] & ~AlwaysFalsy) | (int & ~AlwaysFalsy) | (Queue[Unknown] & ~AlwaysFalsy) | list[Unknown]`
tests/gateway/test_sms.py:217: [invalid-argument-type] invalid-argument-type: Method `__getitem__` of type `Overload[(key: SupportsIndex | slice[SupportsIndex | None, SupportsIndex | None, SupportsIndex | None], /) -> LiteralString, (key: SupportsIndex | slice[SupportsIndex | None, SupportsIndex | None, SupportsIndex | None], /) -> str]` cannot be called with key of type `Literal["deliver"]` on object of type `str`
tests/cli/test_reasoning_command.py:543: [invalid-argument-type] invalid-argument-type: Argument to bound method `TestCase.assertIn` is incorrect: Expected `Iterable[Any] | Container[Any]`, found `str | dict[Unknown, Unknown] | list[Unknown] | ... omitted 20 union elements`
cli.py:4775: [invalid-argument-type] invalid-argument-type: Argument to function `get_model_context_length` is incorrect: Expected `str`, found `str | Any | None`
tests/agent/test_credential_pool_routing.py:207: [invalid-assignment] invalid-assignment: Object of type `MagicMock` is not assignable to attribute `_try_activate_fallback` of type `def _try_activate_fallback(self) -> bool`
run_agent.py:11211: [invalid-argument-type] invalid-argument-type: Argument to bound method `MemoryManager.sync_all` is incorrect: Expected `str`, found `None | Unknown | str`
gateway/platforms/base.py:551: [invalid-raise] invalid-raise: Cannot raise object of type `None | Unknown`: Not an instance or subclass of `BaseException`
gateway/platforms/api_server.py:1704: [invalid-assignment] invalid-assignment: Object of type `staticmethod[(prompt: str, schedule: str, name: str | None = None, repeat: int | None = None, deliver: str | None = None, origin: dict[str, Any] | None = None, skill: str | None = None, skills: list[str] | None = None, model: str | None = None, provider: str | None = None, base_url: str | None = None, script: str | None = None), dict[str, Any]]` is not assignable to `def create_job(prompt: str, schedule: str, name: str | None = None, repeat: int | None = None, deliver: str | None = None, origin: dict[str, Any] | None = None, skill: str | None = None, skills: list[str] | None = None, model: str | None = None, provider: str | None = None, base_url: str | None = None, script: str | None = None) -> dict[str, Any]`
gateway/run.py:9420: [unresolved-attribute] unresolved-attribute: Attribute `channel_prompt` is not defined on `None` in union `None | MessageEvent`
tests/cli/test_fast_command.py:429: [invalid-argument-type] invalid-argument-type: Argument to bound method `TestCase.assertIn` is incorrect: Expected `Iterable[Any] | Container[Any]`, found `str | dict[Unknown, Unknown] | list[Unknown] | ... omitted 20 union elements`
tests/agent/test_credential_pool_routing.py:17: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
run_agent.py:1049: [invalid-assignment] invalid-assignment: Invalid subscript assignment with key of type `Literal["default_headers"]` and value of type `(str & ~AlwaysFalsy) | (Any & ~AlwaysFalsy) | dict[Unknown, Unknown]` on object of type `dict[str, str]`
gateway/run.py:6446: [invalid-argument-type] invalid-argument-type: Argument to bound method `SessionDB.create_session` is incorrect: Expected `str`, found `Unknown | None`
... and 169 more
Unchanged: 2368 pre-existing issues carried over.
Diagnostics are surfaced as warnings — this check never fails the build.
|
Fifth and final slice polish on top of @dlkakbs's docs + skill. Three things ship here: 1. Subscription renewal cron recipe (the #1 operational footgun). Microsoft Graph webhook subscriptions expire at 72 hours max and don't auto-renew. The shipped operator runbook mentioned `maintain-subscriptions --dry-run` as a "daily or periodic check" but never told operators how to actually automate it. Without a scheduled job, any production deployment silently stops ingesting meetings three days after go-live. Adds an "Automating subscription renewal (REQUIRED for production)" section to website/docs/guides/operate-teams-meeting-pipeline.md with three concrete options and copy-pasteable configs: - Option 1: Hermes cron (`hermes cron add --schedule "0 */12 * * *" --script-only --command "hermes teams-pipeline maintain-subscriptions"`) - Option 2: systemd service + timer (12h cadence, Persistent=true so missed runs catch up after reboots) - Option 3: plain crontab with a wrapper that sources .env for credentials Go-Live Checklist gains a bolded mandatory item for the schedule being in place, with a cross-link to the section. website/docs/user-guide/messaging/teams-meetings.md adds a `:::warning:::` admonition right after the manual `subscribe` examples so anyone who creates a subscription manually is told the same day that it will silently expire in 72 hours. 2. Sidebar wiring. Shela's new docs pages (teams-meetings.md and operate-teams-meeting-pipeline.md) weren't in website/sidebars.ts, so they were orphaned URLs — reachable only if someone knew the path. Wired teams-meetings into Messaging Platforms next to the existing teams entry, and operate-teams-meeting-pipeline into Guides & Tutorials next to microsoft-graph-app-registration from PR NousResearch#21922. Adjacent placement keeps the related pages discoverable from each other. 3. SKILL.md rewrite (v1.0.0 → v1.1.0). The original skill had five Turkish-only trigger phrases, which works in a Turkish-speaking session but doesn't match English triggers. Rewrote the skill to: - Describe triggers by intent instead of exact phrases, with explicit "works in any language" framing and example phrases in both English and Turkish. - Add a Decision Tree section covering the three most common user asks (missing summary, setup verification, re-run request) and the specific CLI command sequence for each. - Add a dedicated "Critical pitfall: Graph subscriptions expire in 72 hours" section that tells the agent exactly what to do when a user reports "worked yesterday, nothing today" — the most common operational failure mode. - Expand the command reference into three labeled groups (Status and inspection / Re-running and debugging / Subscription management) so the agent can reach for the right command without scanning. - Add cross-links to all four related docs pages (Azure app registration, webhook listener setup, full pipeline setup, operator runbook). Validation: - npm run build: all new pages route, anchor to #automating-subscription-renewal-required-for-production resolves from both the runbook TOC and the teams-meetings.md admonition. - scripts/run_tests.sh on the relevant test suites (607 tests): all pass.
…olve on first launch Three interrelated bugs from teknium1's first interactive chat on Windows: 1. **Snapshot/cwd file paths unquoted in bash command strings.** The session bootstrap and per-command wrapper interpolated ``self._snapshot_path`` / ``self._cwd_file`` unquoted into bash commands like ``export -p > C:/Users/ryanc/.../hermes-snap-xxx.sh``. Git Bash's MSYS2 layer handles ``C:/...`` paths correctly ONLY when quoted; unquoted, the colon and forward-slash get glob-parsed and the redirect targets a bogus path. Symptom: every terminal command emitted two ``C:/Users/.../hermes-snap-*.sh (No such file or directory)`` lines that bled into stdout (``stderr=STDOUT`` on the local backend) and corrupted file contents when the agent wrote to scratch paths via the terminal tool. Fix: ``shlex.quote()`` every interpolation of ``_snapshot_path`` and ``_cwd_file`` in base.py — no-op on POSIX (the paths contain no shell-metachars), critical on Windows. 2. **Stale PATH on first hermes launch after install.** ``install.ps1`` adds the PortableGit ``cmd`` / ``bin`` / ``usr\bin`` directories to the Windows **User** PATH via ``SetEnvironmentVariable(..., "User")``. That write propagates to newly *spawned* processes only — already-running shells (including the one the user types ``hermes`` into immediately after install) retain their old PATH. So hermes starts with a PATH that doesn't include bash, rg, grep, ssh — and ``search_files`` reports "rg/find not available" when the user clearly just installed them. Fix: new ``_augment_path_with_known_tools()`` helper called from ``configure_windows_stdio()`` on startup. Prepends the Hermes-managed Git directories + the WinGet Links directory (where ripgrep lands) to ``os.environ['PATH']`` if they exist on disk but aren't already in PATH. Subsequent subprocess calls (including bash spawns via ``_find_bash()``) inherit the augmented PATH and find everything. No-op on POSIX and when the directories don't exist. 3. **Root cause of "file content corruption".** #1 was the proximate cause. Errors like ``C:/Users/.../hermes-snap-xxx.sh: No such file or directory`` were emitted on stderr by the failed redirect, captured into stdout via ``stderr=subprocess.STDOUT``, and if the agent used terminal commands like ``cat > file`` the leaked error bytes became part of the file. Fixing #1 eliminates this entirely. ## Tests All 77 Windows-compat tests still pass on Linux (POSIX path is shlex.quote('/tmp/foo.sh') → '/tmp/foo.sh' — unchanged). ## Not addressed here (would need a bigger design) - Python file tools (``write_file``, ``read_file``) and the bash-backed terminal tool see DIFFERENT views of ``/tmp`` on Windows. Python treats ``/tmp`` as ``C:\tmp`` (drive-relative), Git Bash's MSYS2 treats it as a virtual mount to the PortableGit install's ``tmp\``. Would need a translation shim in the Python tools to resolve bash-virtual paths to their native-Windows equivalents. Workaround for users today: use absolute native paths (``C:\Users\you\...``) instead of ``/tmp/...`` when crossing between terminal and Python file tools.
…search#22920) Found 18 real Hermes-Agent stories from HN, X, and Reddit not yet captured on the page. All URLs HTTP-verified to return 200 with matching titles. Reddit (15): r/hermesagent (Obsidian-as-memory writeup at 794 upvotes, LLM cheatsheet at 635 upvotes, Kanban game-changer post, OpenRouter #1 ranking, AMA from the Nous team, etc.); r/LocalLLaMA, r/Rag, r/openclaw, r/SideProject, r/LocalLLM threads where users describe their actual setups (Qwen3.5-9b on 16gb VRAM, 5060Ti + Telegram, smart routing tiers). X (3): @vmiss33's 'what I use Hermes for' guide, @HeyYanvi's X-to-NotebookLM podcast workflow, @ExileAI_0's spare-laptop Iris running RenPy + ComfyUI, @brucexu_eth's Hermes Inc. Telegram startup sim from the hackathon, Hype's deep-dive blog. HN (1): 'I'm using Hermes — sandbox it like any agent.' No component changes — all new entries fit the existing schema (real URL, real author, real date).
…3456) * feat(goals): /goal checklist + /subgoal user controls Two-phase judge for /goal — Phase A decomposes the goal into a detailed checklist on first turn; Phase B evaluates each pending item harshly against the agent's most recent response. The goal completes only when every item is in a terminal status (completed or impossible). Adds /subgoal so the user can append, complete, mark impossible, undo, remove, or clear items the judge missed or got wrong. Mechanics: - GoalState gains `checklist` and `decomposed` fields, both backwards compatible (old state_meta rows load unchanged). - Phase A: aux call writes a harsh, exhaustive checklist; biased toward more items not fewer. Falls through to legacy freeform judge when decompose fails. - Phase B: judge gets the checklist + last-response snippet + path to a per-session conversation dump at <HERMES_HOME>/goals/<sid>.json. A bounded read_file tool (max 5 calls per turn, restricted to that one file) lets the judge inspect history when the snippet is ambiguous. Stickiness in code: terminal items are frozen, only the user can revert via /subgoal undo. - Continuation prompt shows checklist progress when non-empty; reverts to old prompt when empty. - Status line shows M/N done counts. CLI + gateway + TUI gateway all pass the agent reference into evaluate_after_turn so the dump can be written. Gateway-side /subgoal is allowed mid-run since it only modifies the checklist the judge consults at turn boundaries. Tests: 24 new cases — backcompat round-trip, Phase A decompose, Phase B updates + new_items + stickiness, user override flows, conversation dump (incl. unsafe-sid sanitization), judge read_file restriction. Existing freeform-mode tests updated to patch the renamed `judge_goal_freeform` and skip Phase A explicitly. * fix(goals): off-by-one in judge index, message-list plumbing, prompt tuning Three live-test findings from running /goal end-to-end against gemini-3-flash-preview as the judge: 1. Off-by-one bug — the judge sees the checklist rendered with 1-based indices ('1. [ ] foo, 2. [ ] bar') but the apply layer indexed state.checklist as 0-based. Result: every judge update landed on the wrong item, evidence got attached to neighbouring rows, and the genuine 'first pending' item (usually #1) never got marked. Fix: convert 1 → 0 in _parse_evaluate_response. Also tightened the user prompt to call out the 1-based scheme explicitly. New tests cover the parser conversion + an end-to-end fake-judge round-trip. 2. Conversation dump never happened — _extract_agent_messages tried common AIAgent attribute names (.messages, .conversation_history, etc.) but AIAgent doesn't expose the message list as an instance attribute; it lives inside run_conversation()'s scope. Result: the judge's read_file tool always saw history_path=unavailable. Fix: added an explicit messages= kwarg to evaluate_after_turn that all three call sites (CLI, gateway, TUI gateway) now pass directly. Agent-attribute extraction kept as back-compat fallback. 3. Prompt was too harsh on simple goals. The original 'be HARSH, default to leaving items pending' wording made the judge refuse to mark 'file exists' completed even after the agent ran ls, test -f, os.path.isfile, and find — burning the entire 8-turn budget on a fizzbuzz task. Softened to 'strict but not absurd' with explicit guidance on what counts as evidence and a directive not to require re-proving items already established earlier. Re-tested live with the same fizzbuzz goal: now terminates in 2 turns with all 8 checklist items correctly attributed to their own evidence. /subgoal user-action flow (add / complete / undo / impossible) verified live as well.
…registries
Both web_search_registry._resolve() and image_gen_registry.get_active_provider()
walked their registered providers and returned the first one matching the
capability flag — without checking whether that provider was actually
usable. On a fresh install with no credentials at all, this meant
get_active_search_provider() returned `brave-free` (legacy preference
order) even though BRAVE_SEARCH_API_KEY was unset, leading the
dispatcher to surface a "BRAVE_SEARCH_API_KEY is not set" error for a
provider the user never chose. Same bug shape in image_gen for FAL.
Resolution semantics now match tools.web_tools._get_backend():
1. Explicit config name wins, ignoring is_available() — the dispatcher
surfaces a precise "X_API_KEY is not set" error rather than silently
switching backends. Matches user expectation: "I configured X, tell
me what's wrong with X."
2. Fallback (no explicit config) walks the legacy preference order
filtered by is_available() — pick the highest-priority backend the
user actually has credentials for.
is_available() is wrapped in a try/except so a buggy provider doesn't
brick resolution.
E2E verified:
- No creds + no config: get_active_search_provider() -> None
- Explicit brave-free + no key: get_active_search_provider() -> brave-free
(and .is_available() correctly reports False)
This fix was identified during the spike (NousResearch#25182 finding #1) and is
fold-in to the same PR rather than a follow-up.
…sResearch#26672) The #1 confusing cause of the xAI 403 (per Teknium): X Premium+ subscribers see Grok inside the X app and assume API access is included. It is NOT — only standalone SuperGrok subscribers can use xai-oauth with Hermes today. Without calling this out, every Premium+ user hits the 403 with no idea why. PR NousResearch#26666's neutral 4-cause list was correct but buried the most common cause. Lead with the Premium+ gotcha, then list the other possibilities (no subscription, wrong tier, exhausted quota) as fallbacks. Same neutral framing — does not accuse anyone of being unsubscribed.
Three issues flagged by the Copilot review on this PR: 1. Double JSON emit on stage failure (Copilot #1, NousResearch#2). When -Stage <name> ran a worker that threw, Invoke-Stage's finally emitted a JSON result frame AND the entry-point catch emitted a second error frame -- producing two concatenated JSON objects on stdout and breaking the one-line-per-invocation contract that drivers parse against. Same issue applied to -Json mode on a full install (every stage's finally plus a final error frame missing duration_ms/skipped). Fix: Invoke-Stage's finally now sets $script:_StageEmittedErrorFrame when it emits a failure frame; the entry-point catch checks the flag and skips its own emit, still exit 1. 2. $prevEAP uninitialized on early try-block throw (Copilot NousResearch#3). In Install-Uv, Test-Python, Test-Node's winget fallback, _Run-NpmInstall, and the playwright block, '$prevEAP = $ErrorActionPreference' lived as the first statement INSIDE the try. If anything between 'try {' and that line threw (Write-Info on an unusual host, the npx-finding loop, etc.), the catch's 'if ($prevEAP) { ... }' restore was a no-op and EAP could remain relaxed. Fix: hoist '$prevEAP = $ErrorActionPreference' to the line immediately before 'try {' in all five sites. Catch's restore is now always meaningful regardless of where in the try the throw originated. No change to Invoke-Stage's success path or to the four lint-clean EAP sites (Test-Node was the only winget-related catch). All 19 metadata smoke tests still pass.
Four findings from Copilot's review on PR NousResearch#22891, all in the AX elements-array cap added by 22fa1ed: 1. The truncation note ("response truncated to N of M elements") was appended unconditionally — including in the som/vision multimodal path, whose response carries a screenshot rather than an `elements` array. The note described a payload field that wasn't present. Moved the note into the AX-text branch where the array actually appears. 2. `_format_elements(cap.elements)` ran on the full untrimmed list with its own `max_lines=40` cap, so a caller passing `max_elements=10` would see summary lines referencing `NousResearch#11..NousResearch#40` even though the JSON `elements` array only held #1..NousResearch#10. Format on `visible_elements` instead so the summary indices always exist in the response. 3. `_coerce_max_elements` enforced a lower bound but no upper bound, so `max_elements=10_000_000` silently disabled the safeguard and reintroduced the original context-blow-up. Added a hard cap (`_MAX_ALLOWED_MAX_ELEMENTS = 1000`) that clamps oversized values. 4. The schema string said "Default 100" but the property carried no `default` field, and claimed `max_elements` had no effect on som/ vision while the image-missing fallback path can still return an elements array. Added `"default": 100`, `"maximum": 1000`, and clarified the fallback-path wording. Each finding gets a regression test: - test_capture_ax_clamps_oversized_max_elements_to_hard_cap - test_capture_ax_summary_indices_match_returned_elements - test_capture_multimodal_summary_omits_truncation_note - test_schema_max_elements_documents_default_and_upper_bound Verified with `pytest tests/tools/test_computer_use.py` (53 passed, including the 5 new cases). Confirmed each new test fails on the pre-fix code path before applying the production change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…NousResearch#31416) PR NousResearch#31416 (avoid persisting borrowed credential secrets) added sanitize_borrowed_credential_payload, which strips access_token from any auth.json pool entry whose (provider, source) isn't in the _PERSISTABLE_PROVIDER_SOURCES allowlist. (copilot, gh_cli) is borrowed (not in the allowlist), so the test fixture's pre-seeded access_token now gets stripped at load_pool() time, leaving the pool empty. resolve_target('1') then fails with 'No credential #1. Provider: copilot.' Fix: align the test with the new contract. At runtime, copilot tokens are hydrated by resolve_copilot_token() — mock that path so the pool gets an entry the test can remove. The behavior under test (suppression of gh_cli + env variants on remove) is unchanged. CI repro on origin/main HEAD; reproduced locally with stock checkout.
…s reached After key #1 is marked exhausted the retry still called the API with key #1 due to env-var bias in _get_cached_client / resolve_api_key_provider_credentials. Fix: peek the pool and pass the active entry's key as explicit_api_key. Secondary: api_key_hint in mark_exhausted_and_rotate pins the correct entry under concurrent CLI+gateway calls; _is_payment_error matches GoUsageLimitError; extract_api_error_context parses "Resets in Xhr Ymin".
…ookies
Mission-control style deploys reverse-proxy the dashboard at a path
prefix (e.g. mission-control.tilos.com/hermes/* -> :9119) and inject
X-Forwarded-Prefix: /hermes on every request. The SPA mount already
honoured this for asset URLs and the bootstrap __HERMES_BASE_PATH__,
but the OAuth gate didn't:
1. The gate's Location: header to /login and the 401 envelope's
login_url were built bare ("/login?next=..."). Under a /hermes
prefix the browser follows that to mission-control.tilos.com/login
which the proxy doesn't route to the dashboard.
2. _redirect_uri (the OAuth callback URL handed to the IDP) used
request.url_for() which doesn't honour X-Forwarded-Prefix
(Starlette/uvicorn only proxy_headers Host + Proto + For). The
IDP redirects back to /auth/callback instead of /hermes/auth/
callback → 404 in the user's browser.
3. Cookies were set with Path=/ which leaks them to other apps on
the same origin and won't be sent back on requests under the
prefix in the first place.
Fix threads the normalised prefix through every boundary:
* New hermes_cli/dashboard_auth/prefix.py — single source of truth
for X-Forwarded-Prefix parsing. web_server._normalise_prefix
becomes a re-export so the SPA mount, the gate, and the cookies
helper all agree.
* middleware._unauth_response builds login_url = f"{prefix}/login".
* routes._redirect_uri splices the prefix into the path component
of the IDP-bound URL (with full validation of the header).
* cookies.{set,clear}_{session,pkce}_cookie now take prefix="".
Path attribute switches to /hermes when set; cookie name switches
name variant (see below). Every caller passes the request's
normalised prefix.
Cookie hardening (Teknium's lesser-note #1 in the PR review): adopt
the __Host- / __Secure- cookie name prefixes per draft-west-cookie-
prefixes. The variant is selected from (use_https, prefix):
* Loopback HTTP → bare "hermes_session_at" (both prefixes require
Secure, incompatible with HTTP).
* HTTPS, direct deploy (Path=/) → "__Host-hermes_session_at".
Strongest spec: bound to exact origin, no Domain attribute, Secure
required.
* HTTPS, behind a proxy prefix (Path=/hermes) →
"__Secure-hermes_session_at". __Host- forbids Path != "/"; the
explicit Path=/hermes covers same-origin app isolation.
Setter and reader BOTH consult the prefix because the cookie *name*
changes — a reader that looked up the bare name when the setter wrote
__Secure- would never find the value. The reader falls back across
all three variants so a request whose shape changed mid-session (e.g.
post-deploy from no-prefix to /hermes) still picks up the existing
cookie until it expires.
Test coverage:
- tests/hermes_cli/test_dashboard_auth_prefix.py — new file. 11 tests
pinning:
• Location: /hermes/login on the gate's HTML redirect
• 401 envelope login_url carries the prefix
• Malformed X-Forwarded-Prefix is ignored (header-injection
defence; the script-tag value is normalised to empty string)
• _redirect_uri splices /hermes into the path (the property
that prevents the IDP-returns-to-404 failure)
• PKCE cookie uses Path=/hermes + __Secure- when proxied
• Session cookies use __Host- when direct, __Secure- when
proxied, bare on loopback HTTP
• End-to-end round trip with hand-managed PKCE cookie carriage
(TestClient can't simulate a Path=/hermes cookie automatically)
- tests/hermes_cli/test_dashboard_auth_cookies.py — rewritten to pin
each (use_https, prefix) shape produces its expected cookie name,
plus reader-side coverage that __Host- and __Secure- variants are
both recognised.
- Existing tests across middleware / 401-reauth / etc. updated to
match the new cookie names (substring contains instead of
startswith).
Mutation-tested: reverting _unauth_response to build the bare
"/login" URL trips exactly the two tests that pin the prefix
carriage, confirming the suite discriminates the regression.
Two CI flakes surfaced on PR NousResearch#34572 (both in files this PR doesn't touch; pre-existing host-dependent flakes): 1. test_process_registry::TestPopenLeakOnSetupFailure — the failure-cleanup tests use a fake proc.pid (8888/9999) and assert proc.kill() runs. But spawn_local's primary cleanup is os.killpg(os.getpgid(pid), SIGKILL), falling back to proc.kill() only on ProcessLookupError/PermissionError/ OSError. When the fake PID happens to exist on a busy host, os.getpgid succeeds, os.killpg fires against an UNRELATED real process group, and proc.kill() is never reached -> flaky AssertionError (and a real risk of SIGKILLing an innocent process group from a unit test). Patch os.getpgid to raise ProcessLookupError so the fallback path runs deterministically and no real killpg is ever issued. 2. test_web_server::test_resize_escape_is_forwarded — the receive loop calls the blocking conn.receive_bytes() with no exception guard. Once the child prints its winsize and exits, the PTY closes; on a missed-marker run the next recv blocks until the 30s pytest-timeout instead of failing fast. Add a try/except break (matching the working sibling tests) and bump the child's pre-read sleep 0.15s -> 0.5s so the resize reliably lands first. Verified: 4/4 pass across 3 consecutive runs; root cause for #1 reproduced (os.getpgid(1) succeeds -> old code skips proc.kill).
Phase 1 of the pluggable cron-scheduler refactor (Axis B — the trigger).
No call-site changes; this phase only makes the abstraction exist + tested
in isolation.
Task 1.1: cron/scheduler_provider.py — the EXPERIMENTAL CronScheduler ABC.
Required surface is name + start; is_available()/stop() carry safe defaults.
is_available has a no-network invariant. Docstring marks it experimental
until the Chronos provider (Phase 4) validates the shape.
Task 1.2: InProcessCronScheduler wraps the historical 60s ticker loop, calling
cron.scheduler.tick(sync=False) exactly as the raw ticker does. Uses
stop_event.wait(interval) for responsive stop (both raw tickers already do).
Tests: ABC-is-abstract, default-is_available, the InProcess loop drives tick
and stops, stop() no-op, and test_abc_growth_stays_additive (the forward-compat
guard: required abstractmethods must stay exactly {name, start}, so the three
Phase-4 hooks land as NON-abstract additions).
tick() internals in cron/scheduler.py are byte-unchanged (only new file added).
Phase 0 characterization tests still green. Full tests/cron/: 445 passed.
…onnect ladder can't freeze silently (NousResearch#66377) The Telegram gateway could go silently deaf for hours: the reconnect ladder stalled mid-way (e.g. "attempt 4/10, reconnecting in 40s" then nothing) while the process stayed active(running), so Restart=always never fired. Root class: every recovery path — the ladder's re-entry (_schedule_polling_recovery), the pending-update probe (_probe_pending_updates), and PTB's error callback — gates new recovery on _polling_error_task.done(). If that single task wedges on any hung await, all recovery returns early forever and nothing retries. The heartbeat loop is a separate task, so make it an independent, cause-agnostic watchdog: if the same recovery task stays in-flight past _POLLING_ERROR_TASK_STUCK_TIMEOUT (300s — well beyond a healthy ladder attempt's bounded stop+drain+start+backoff), force a retryable-fatal so the background reconnector rebuilds the adapter instead of relying on the frozen ladder. This guarantees progress regardless of *where* the stall is (issue direction #1), tracked locally so no task-assignment site needs to change. Also salvages @koduri-mahesh-bhushan-chowdary's NousResearch#66492 (drain-await timeout), which closes the one concrete wedge vector documented in the incident (_drain_polling_connections' unbounded shutdown()/initialize() on a wedged CLOSE-WAIT pool). The watchdog covers the rest of the class. Co-authored-by: Koduri Mahesh Bhushan Chowdary <mkoduri73@gmail.com>
…reaming 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.
Blocking #1 — gateway-connecting-overlay.tsx reduced-motion regression: the top `if (reduce) setPhase('gone')` fired unconditionally on mount whenever reduce-motion was on, so every OS reduced-motion user lost the CONNECTING overlay during cold boot entirely (jumped to 'gone' before the gateway was even open). The intent was to skip the exit *choreography*, not to skip showing the overlay. Removed the unconditional top block and the redundant nested preview block; kept only the third branch (`gatewayState === 'open' && shownRef.current` → `reduce ? 'gone' : 'text-out'`) which correctly gates the short-circuit on connect. Also fixed `if(reduce)` missing-space, 6-space misindent, and the same 3-line comment pasted three times. Nit #1 — tsconfig excludes e2e, so specs were never typechecked in CI. Added tsconfig.e2e.json (extends base, includes e2e/ + playwright.config.ts, adds @playwright/test types) and wired it into the typecheck script. This surfaced three latent type errors that are fixed in the same commit: - fix-electron-tracing.ts: `app._context` and `electron._playwright` are private APIs — added `as any` on the access before the existing cast. - playwright.config.ts: `reducedMotion: 'reduce'` directly under `use:` is not a valid UseOptions property in playwright 1.58; it's a BrowserContextOption accessed via `contextOptions: { reducedMotion: 'reduce' }`. The old form was silently ignored at runtime, so reduced-motion emulation wasn't actually active — screenshots could catch overlays mid-fade (exactly what the comment warned about). Nit NousResearch#2 — fix-electron-tracing.ts reaches into Playwright internals (_playwright, _allContexts, _context) with no public contract. Added a header comment calling out the `@playwright/test` exact pin (=1.58.2) so a future bump knows to re-verify the private symbols still exist. Nit NousResearch#3 — main.ts TEST_WORKER_INDEX block had stray 6-space indentation. Verified: tsc -p . && tsconfig.electron && tsconfig.e2e → 0 errors; vitest boot-failure-overlay (3/3) + boot-failure-reauth (21/21) pass; npm run build clean; playwright e2e/boot-failure.spec.ts 2/2 pass.
…own (NousResearch#74136) Fix-up for the cherry-picked cooldown persistence: the PR's tests mocked the DB (SimpleNamespace(_db=MagicMock())), which cannot prove the cooldown survives a restart. Replace with the production shape — a real SessionDB on disk behind the real AsyncSessionDB facade — and add a restart regression: fail a hygiene compression on runner #1, tear it down, build a fresh GatewayRunner on the SAME database, and assert the cooldown is still honored (no compression agent instantiated). Also updates the timeout test to assert the DB-backed record_compression_failure_cooldown write instead of the removed in-memory dict. Sabotage-verified: reverting gateway/run.py to the in-memory dict makes the restart test fail.
Users following abbreviated links guess /docs/quickstart and /docs/installation and hit raw GitHub-Pages 404s — the real pages live under /docs/getting-started/. Add client redirects for both. Consumer-onboarding audit finding #1, Aug 2026.
The #1 patch failure class in production (state.db mining, 250k-window) is a re-send of an edit that already landed: 'old_string and new_string are identical' (299 occurrences) plus a share of hunk-not-found errors where the new text is already in the file. These errored, sending models into re-read/re-patch loops. New tools/fuzzy_match.is_already_applied(content, old, new) — a conservative check requiring (1) non-trivial new_string (>=8 chars), (2) EXACT presence of new_string, (3) old_string gone (unless identical). Wired into three sites: - patch_replace (replace mode): returns success + no_change: true + an explicit note instead of the identical-strings / no-match error. - V4A validation phase: an already-applied hunk validates as a no-op so multi-hunk patches no longer fail wholesale when one hunk landed in a prior call. - V4A apply phase: mirrors the same skip so the two phases agree. Genuine no-matches (new text absent) and half-applied renames (old text still present) keep their error behavior — covered by tests.
process(action='wait') hitting its window returned status='timeout' with a terse note — models read it as an error and re-issued identical waits (process is the #1 exact-duplicate tool call in production: 511 dupes in a 400k-msg window; wait is 57% of all process actions). The timeout result now carries: - process_running: true — machine-readable 'this is a status, not a failure' - an explicit note: 'Wait window of Ns elapsed — the process is still running. This is not an error. Uptime: Ms.' plus the right next step: when notify_on_complete is set, 'you will be notified on exit — do more work instead of waiting again'; otherwise a pointer to notify_on_complete for next time. - the clamp note (requested > max) now composes with the status note instead of replacing it. Exited/interrupted results are unchanged.
…e-review #1) revoke_commit_admission() used to invoke the holder-qualified lease release unconditionally — including while an admitted commit was still mutating SessionDB — letting a second compressor acquire the durable lock mid-commit and interleave with the first commit's writes. The admission_revoked flag store stays lock-free, but the lease-release decision now coordinates with the fence lock: - revoke acquires the fence lock non-blocking; on success no commit can be in flight (an admitted commit retains the lock until finish_commit) and the release runs immediately, still under the lock so a racing begin_commit cannot slip between the check and the release. - on failure the release is deferred: finish_commit() re-checks _admission_revoked and performs it AFTER the commit completes (prompt even if the worker thread is later parked), and the begin_commit refusal path does the same for a revoke that lost the race to a transient lock-setup/cancel boundary. All paths are idempotent with the worker's own outer cleanup (DB release is holder-qualified). Invariant encoded + tested: no second compressor can acquire the durable lock while an admitted commit is still mutating; after a post-revoke commit finishes the lease is released promptly. Both regressions (revoke-during-commit deferral, revoke-before-commit immediate release + refused begin_commit) are sabotage-verified.
Summary
Test Plan